fix: images, couverture, slash, agents, wizard, Stripe et admin
- Collage/accès images: ownership path userId + meta legacy, 403 non caché - Couverture note: explicite (choix/aucune), plus d’auto depuis le corps - Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée - Slash: listes par titre (plus d’index), keywords nettoyés - Agents: nextRun à la réactivation, cron sans stampede null - Wizard: titres courts + mode Expert plus robuste - Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings - Indexation notes courtes + test unit aligné
This commit is contained in:
@@ -70,10 +70,11 @@ import {
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
|
||||
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
getEditor: () => Editor | null
|
||||
@@ -88,6 +89,8 @@ export interface RichTextEditorHandle {
|
||||
export interface RichTextEditorProps {
|
||||
content?: string
|
||||
onChange?: (content: string) => void
|
||||
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
|
||||
onChangeImmediate?: (content: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
@@ -137,6 +140,11 @@ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch'
|
||||
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
// Treat image as a single selectable unit (Backspace/Delete must remove it)
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
@@ -149,22 +157,98 @@ const CustomImage = Image.extend({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const deleteSelectedImage = () => {
|
||||
const { selection } = this.editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
// Cursor just after an image block → Backspace removes it
|
||||
const { $from, empty } = selection
|
||||
if (!empty) return false
|
||||
const before = $from.nodeBefore
|
||||
if (before?.type.name === 'image') {
|
||||
const from = $from.pos - before.nodeSize
|
||||
return this.editor.chain().focus().deleteRange({ from, to: $from.pos }).run()
|
||||
}
|
||||
// Cursor at start of paragraph right after image (common after paste under title)
|
||||
if ($from.parentOffset === 0 && $from.depth >= 1) {
|
||||
const index = $from.index($from.depth - 1)
|
||||
if (index > 0) {
|
||||
const prev = $from.node($from.depth - 1).child(index - 1)
|
||||
if (prev.type.name === 'image') {
|
||||
const pos = $from.before($from.depth) - prev.nodeSize
|
||||
return this.editor.chain().focus().deleteRange({ from: pos, to: pos + prev.nodeSize }).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
Backspace: deleteSelectedImage,
|
||||
Delete: () => {
|
||||
const { selection } = this.editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
// Cursor just before an image → Delete removes it
|
||||
const { $from, empty } = selection
|
||||
if (!empty) return false
|
||||
const after = $from.nodeAfter
|
||||
if (after?.type.name === 'image') {
|
||||
return this.editor.chain().focus().deleteRange({ from: $from.pos, to: $from.pos + after.nodeSize }).run()
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/** Insert / toggle a bullet list without the toggle-off → re-insert glitch. */
|
||||
function insertBulletList(editor: Editor) {
|
||||
// Already a bullet list → toggle off (do not re-insert)
|
||||
if (editor.isActive('bulletList')) {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
return
|
||||
}
|
||||
// Convert from other list types if needed
|
||||
if (editor.isActive('orderedList')) editor.chain().focus().toggleOrderedList().run()
|
||||
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
|
||||
const ok = editor.chain().focus().toggleBulletList().run()
|
||||
if (!ok) {
|
||||
editor.chain().focus().insertContent('<ul><li><p></p></li></ul>').run()
|
||||
}
|
||||
}
|
||||
|
||||
function insertOrderedList(editor: Editor) {
|
||||
if (editor.isActive('orderedList')) {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
return
|
||||
}
|
||||
if (editor.isActive('bulletList')) editor.chain().focus().toggleBulletList().run()
|
||||
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
|
||||
const ok = editor.chain().focus().toggleOrderedList().run()
|
||||
if (!ok) {
|
||||
editor.chain().focus().insertContent('<ol><li><p></p></li></ol>').run()
|
||||
}
|
||||
}
|
||||
|
||||
const slashCommands: SlashItem[] = [
|
||||
// Basic blocks
|
||||
// Basic blocks — lists BEFORE table so defaults / shortcuts never collide with Tableau
|
||||
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
|
||||
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => insertBulletList(e) },
|
||||
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => insertOrderedList(e) },
|
||||
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
|
||||
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
// Media
|
||||
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
|
||||
// Formatting
|
||||
@@ -300,7 +384,7 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
@@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const editorInstanceRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const onChangeImmediateRef = useRef(onChangeImmediate)
|
||||
onChangeImmediateRef.current = onChangeImmediate
|
||||
const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const emitContentChange = useCallback((html: string) => {
|
||||
const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => {
|
||||
lastEmittedContent.current = html
|
||||
onChangeRef.current?.(html)
|
||||
// Cancel pending debounced HTML emit so it cannot overwrite with stale doc
|
||||
if (htmlDebounceRef.current) {
|
||||
clearTimeout(htmlDebounceRef.current)
|
||||
htmlDebounceRef.current = null
|
||||
}
|
||||
if (opts?.immediate && onChangeImmediateRef.current) {
|
||||
onChangeImmediateRef.current(html)
|
||||
} else {
|
||||
onChangeRef.current?.(html)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounced version for onUpdate — avoids calling getHTML() on every keystroke
|
||||
@@ -581,6 +676,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'))
|
||||
if (!hasImage) return false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const imageFiles = items
|
||||
.filter(item => item.type.startsWith('image/'))
|
||||
.map(item => item.getAsFile())
|
||||
@@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) continue
|
||||
if (!ed || ed.isDestroyed) continue
|
||||
// Insert at cursor; force immediate parent sync so save/ref don't drop the img
|
||||
const inserted = ed.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(ed.getHTML())
|
||||
const html = ed.getHTML()
|
||||
emitContentChange(html, { immediate: true })
|
||||
// Prefetch so the img is warm in the browser cache
|
||||
const preload = new window.Image()
|
||||
preload.src = url
|
||||
toast.success(t('notes.uploadSuccess') || 'Image ajoutée')
|
||||
} else {
|
||||
toast.error(t('notes.uploadFailed') || 'Insertion image échouée')
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('[editor] image paste failed:', err)
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return true
|
||||
}
|
||||
},
|
||||
// ProseMirror signature: (view, pos, event)
|
||||
handleClick: (view, pos, event) => {
|
||||
// Click on img → NodeSelection so Backspace/Delete and bubble toolbar work
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target || target.tagName !== 'IMG') return false
|
||||
if (!view.editable) return false
|
||||
try {
|
||||
let imagePos: number | null = null
|
||||
const nodeAt = view.state.doc.nodeAt(pos)
|
||||
if (nodeAt?.type.name === 'image') {
|
||||
imagePos = pos
|
||||
} else {
|
||||
const $pos = view.state.doc.resolve(pos)
|
||||
if ($pos.nodeAfter?.type.name === 'image') {
|
||||
imagePos = $pos.pos
|
||||
} else if ($pos.nodeBefore?.type.name === 'image') {
|
||||
imagePos = $pos.pos - $pos.nodeBefore.nodeSize
|
||||
} else {
|
||||
// Walk up: pos may sit inside / around the leaf
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
if ($pos.node(d).type.name === 'image') {
|
||||
imagePos = $pos.before(d)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imagePos == null) {
|
||||
// Last resort: posAtDOM on the img element
|
||||
const domPos = view.posAtDOM(target, 0)
|
||||
const $dom = view.state.doc.resolve(domPos)
|
||||
if ($dom.nodeAfter?.type.name === 'image') imagePos = $dom.pos
|
||||
else if (view.state.doc.nodeAt(domPos)?.type.name === 'image') imagePos = domPos
|
||||
}
|
||||
if (imagePos == null) return false
|
||||
const node = view.state.doc.nodeAt(imagePos)
|
||||
if (!node || node.type.name !== 'image') return false
|
||||
const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, imagePos))
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onUpdate: ({ editor: e, transaction }) => {
|
||||
// Image removed → immediate parent sync so a stale content prop cannot resurrect it
|
||||
if (transaction.docChanged && (lastEmittedContent.current || '').includes('<img')) {
|
||||
const html = e.getHTML()
|
||||
if (!html.includes('<img')) {
|
||||
emitContentChange(html, { immediate: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
// Debounced getHTML() — avoids traversing the whole doc on every keystroke
|
||||
emitContentChangeDebounced(e)
|
||||
if (!e.isEditable) return
|
||||
@@ -763,6 +921,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== undefined && content !== lastEmittedContent.current) {
|
||||
// While the user is editing, never clobber local deletes (e.g. removed image)
|
||||
// with a stale parent `content` that has not caught up yet.
|
||||
if (editor.isFocused) {
|
||||
return
|
||||
}
|
||||
const html = content || ''
|
||||
lastEmittedContent.current = html
|
||||
queueMicrotask(() => {
|
||||
@@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
}
|
||||
})
|
||||
}
|
||||
if (content !== undefined) {
|
||||
if (content !== undefined && !editor?.isFocused) {
|
||||
setCurrentNoteContent(content || '')
|
||||
}
|
||||
}, [content, editor, sourceUrl])
|
||||
@@ -1103,7 +1266,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (!editor) return
|
||||
editor.chain().focus().setImage({ src: url }).run()
|
||||
setSmartPasteExtended(null)
|
||||
emitContentChange(editor.getHTML())
|
||||
emitContentChange(editor.getHTML(), { immediate: true })
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
const handlePasteUrlVideo = useCallback((url: string) => {
|
||||
@@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
|
||||
{editorState.isImage && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
|
||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="25%"
|
||||
>
|
||||
25%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="50%"
|
||||
>
|
||||
50%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()}
|
||||
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
|
||||
title="100%"
|
||||
>
|
||||
100%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { selection } = editor.state
|
||||
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
|
||||
editor.chain().focus().deleteSelection().run()
|
||||
} else if (editor.isActive('image')) {
|
||||
// Fallback: delete the nearest selected image node
|
||||
editor.chain().focus().deleteSelection().run()
|
||||
}
|
||||
}}
|
||||
className="notion-bubble-btn rounded-md text-red-600 dark:text-red-400"
|
||||
title={t('richTextEditor.deleteImage') || t('common.delete') || 'Supprimer l\'image'}
|
||||
aria-label={t('richTextEditor.deleteImage') || 'Supprimer l\'image'}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{aiOpen && (
|
||||
@@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const menuInteracting = useRef(false)
|
||||
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
|
||||
|
||||
// Resolve by English command title — NEVER by array index (reorder-safe)
|
||||
const sc = (enTitle: string) => {
|
||||
const cmd = slashCommands.find(c => c.title === enTitle)
|
||||
if (!cmd) throw new Error(`Slash command missing: ${enTitle}`)
|
||||
return cmd
|
||||
}
|
||||
|
||||
const localCommands: SlashMenuItem[] = [
|
||||
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
|
||||
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
|
||||
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
|
||||
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
|
||||
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
||||
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
|
||||
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
|
||||
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
|
||||
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
|
||||
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
|
||||
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
|
||||
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
||||
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
||||
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
|
||||
{ ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
|
||||
{ ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
|
||||
{ ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
|
||||
{ ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] },
|
||||
{ ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] },
|
||||
{ ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] },
|
||||
{ ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
|
||||
{ ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
|
||||
{ ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
|
||||
{ ...sc('Table'), title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data', slashKeywords: ['table', 'tableau', 'grid', 'grille'] },
|
||||
{ ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
||||
{ ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
|
||||
{ ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
|
||||
{ ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
|
||||
{ ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
|
||||
{ ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
|
||||
{ ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
|
||||
{ ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
|
||||
{ ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
|
||||
{ ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
|
||||
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
|
||||
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
{ ...sc('Link Preview'), title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
|
||||
{ ...sc('Math'), title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
|
||||
{ ...sc('Columns'), title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
|
||||
{ ...sc("Écrire avec l'IA"), title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
toastAi(err)
|
||||
}
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
} else if (
|
||||
item.title === 'Suggest Charts'
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
|
||||
Reference in New Issue
Block a user