225 lines
9.0 KiB
TypeScript
225 lines
9.0 KiB
TypeScript
'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<HTMLTextAreaElement>
|
|
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<number>(-1)
|
|
const menuRef = useRef<HTMLDivElement>(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(
|
|
<div
|
|
ref={menuRef}
|
|
style={{ position: 'fixed', top: coords.top, left: coords.left, zIndex: 9999 }}
|
|
className="w-60 bg-card border border-border rounded-xl shadow-xl overflow-hidden"
|
|
>
|
|
<div className="px-3 py-2 border-b border-border/40">
|
|
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
|
|
{t('richTextEditor.slashHint') || 'Commandes Markdown'}
|
|
</p>
|
|
</div>
|
|
<div className="max-h-64 overflow-y-auto py-1">
|
|
{filtered.map((cmd, idx) => (
|
|
<button
|
|
key={cmd.title}
|
|
className={cn(
|
|
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
|
|
idx === selectedIndex ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
|
)}
|
|
onMouseDown={e => { e.preventDefault(); applyCommand(cmd) }}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="w-7 h-7 rounded-lg border border-border bg-background flex items-center justify-center shrink-0">
|
|
<cmd.icon className="w-3.5 h-3.5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[12px] font-medium leading-none truncate">{cmd.title}</p>
|
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5 truncate">{cmd.desc}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|