'use client' /** * MarkdownSlashCommands * Detects "/" typed in a textarea and shows a floating command palette. * Supports keyboard navigation (↑↓ Enter Esc) and replaces the "/cmd" text * with the appropriate markdown syntax. */ import { useEffect, useRef, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, Code, Minus, Bold, Italic, Pilcrow, Link as LinkIcon, } from 'lucide-react' import { cn } from '@/lib/utils' import { useLanguage } from '@/lib/i18n' type SlashCmd = { title: string desc: string icon: typeof Heading1 keywords: string[] insert: string cursorOffset?: number // chars from end where cursor lands } const COMMANDS: SlashCmd[] = [ { title: 'Texte', desc: 'Paragraphe normal', icon: Pilcrow, keywords: ['text', 'texte', 'p'], insert: '' }, { title: 'Titre 1', desc: 'Grand titre de section', icon: Heading1, keywords: ['h1', '1', 'heading', 'titre'], insert: '# ' }, { title: 'Titre 2', desc: 'Titre de niveau 2', icon: Heading2, keywords: ['h2', '2', 'titre'], insert: '## ' }, { title: 'Titre 3', desc: 'Titre de niveau 3', icon: Heading3, keywords: ['h3', '3', 'titre'], insert: '### ' }, { title: 'Liste à puces', desc: 'Liste non ordonnée', icon: List, keywords: ['list', 'liste', 'ul', 'bullet', '-'], insert: '- ' }, { title: 'Liste numérotée', desc: 'Liste ordonnée', icon: ListOrdered, keywords: ['ol', 'numbered', 'numéro', '1.'], insert: '1. ' }, { title: 'Tâche (to-do)', desc: 'Case à cocher', icon: CheckSquare, keywords: ['todo', 'tache', 'task', 'checkbox', '[]'], insert: '- [ ] ' }, { title: 'Citation', desc: 'Bloc de citation', icon: Quote, keywords: ['quote', 'citation', 'blockquote', '>'], insert: '> ' }, { title: 'Bloc de code', desc: 'Snippet de code', icon: Code, keywords: ['code', 'block', '```'], insert: '```\n', cursorOffset: 4 }, { title: 'Séparateur', desc: 'Ligne horizontale', icon: Minus, keywords: ['hr', 'divider', 'separator', '---'], insert: '---\n' }, { title: 'Gras', desc: '**texte en gras**', icon: Bold, keywords: ['bold', 'gras', '**'], insert: '****', cursorOffset: 2 }, { title: 'Italique', desc: '*texte en italique*', icon: Italic, keywords: ['italic', 'italique', '*'], insert: '**', cursorOffset: 1 }, { title: 'Lien', desc: '[texte](url)', icon: LinkIcon, keywords: ['link', 'lien', 'url'], insert: '[]()', cursorOffset: 3 }, ] interface MarkdownSlashCommandsProps { textareaRef: React.RefObject value: string onChange: (value: string) => void } export function MarkdownSlashCommands({ textareaRef, value, onChange }: MarkdownSlashCommandsProps) { const { t } = useLanguage() const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [coords, setCoords] = useState({ top: 0, left: 0 }) const slashPosRef = useRef(-1) const menuRef = useRef(null) const filtered = COMMANDS.filter(cmd => { if (!query) return true const q = query.toLowerCase() return cmd.keywords.some(k => k.includes(q)) || cmd.title.toLowerCase().includes(q) }) const close = useCallback(() => { setOpen(false) setQuery('') setSelectedIndex(0) slashPosRef.current = -1 }, []) const getCaretCoords = useCallback(() => { const el = textareaRef.current if (!el) return { top: 0, left: 0 } const rect = el.getBoundingClientRect() // Approximate caret position using a mirror div const mirror = document.createElement('div') const style = window.getComputedStyle(el) ;['font', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight', 'paddingTop', 'paddingLeft', 'paddingRight', 'borderTop', 'borderLeft', 'whiteSpace', 'wordWrap', 'overflowWrap'].forEach(p => { (mirror.style as any)[p] = (style as any)[p] }) mirror.style.position = 'absolute' mirror.style.visibility = 'hidden' mirror.style.width = `${el.offsetWidth}px` mirror.style.top = '0' mirror.style.left = '0' document.body.appendChild(mirror) const textBefore = el.value.substring(0, el.selectionStart ?? 0) mirror.textContent = textBefore const span = document.createElement('span') span.textContent = '|' mirror.appendChild(span) const spanRect = span.getBoundingClientRect() document.body.removeChild(mirror) return { top: rect.top + el.scrollTop + spanRect.top - mirror.getBoundingClientRect().top + span.offsetHeight + 8, left: Math.min(rect.left + spanRect.left - mirror.getBoundingClientRect().left, rect.right - 240), } }, [textareaRef]) const applyCommand = useCallback((cmd: SlashCmd) => { const el = textareaRef.current if (!el || slashPosRef.current < 0) { close(); return } const cursorPos = el.selectionStart ?? 0 const before = value.substring(0, slashPosRef.current) const after = value.substring(cursorPos) if (cmd.insert === '') { // "Text" — just remove the slash const newVal = before + after onChange(newVal) setTimeout(() => { el.focus() const pos = slashPosRef.current el.setSelectionRange(pos, pos) }, 0) } else { const newVal = before + cmd.insert + after onChange(newVal) setTimeout(() => { el.focus() const offset = cmd.cursorOffset ?? 0 const pos = slashPosRef.current + cmd.insert.length - offset el.setSelectionRange(pos, pos) }, 0) } close() }, [value, onChange, close, textareaRef]) // Monitor textarea input useEffect(() => { const el = textareaRef.current if (!el) return const handleInput = () => { const pos = el.selectionStart ?? 0 const text = el.value // Look for "/" at start of word const lineStart = text.lastIndexOf('\n', pos - 1) + 1 const lineText = text.substring(lineStart, pos) const slashMatch = lineText.match(/\/(\S*)$/) if (slashMatch) { slashPosRef.current = pos - slashMatch[0].length setQuery(slashMatch[1]) setSelectedIndex(0) setOpen(true) const c = getCaretCoords() setCoords(c) } else { if (open) close() } } el.addEventListener('input', handleInput) return () => el.removeEventListener('input', handleInput) }, [textareaRef, open, close, getCaretCoords]) // Keyboard navigation useEffect(() => { if (!open) return const handleKey = (e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length) } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length) } else if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) applyCommand(filtered[selectedIndex]) } else if (e.key === 'Escape') { e.preventDefault(); close() } } document.addEventListener('keydown', handleKey, true) return () => document.removeEventListener('keydown', handleKey, true) }, [open, filtered, selectedIndex, applyCommand, close]) // Close on outside click useEffect(() => { if (!open) return const handleClick = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) close() } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [open, close]) if (!open || filtered.length === 0) return null if (typeof document === 'undefined') return null return createPortal(

{t('richTextEditor.slashHint') || 'Commandes Markdown'}

{filtered.map((cmd, idx) => ( ))}
, document.body ) }