feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
This commit is contained in:
224
memento-note/components/markdown-slash-commands.tsx
Normal file
224
memento-note/components/markdown-slash-commands.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user