'use client' import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react' import { createPortal } from 'react-dom' import { useLanguage } from '@/lib/i18n' import { sanitizeRichHtml } from '@/lib/sanitize-content' import { useEditor, EditorContent, useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' import TiptapLink from '@tiptap/extension-link' import Highlight from '@tiptap/extension-highlight' import Image from '@tiptap/extension-image' import TextAlign from '@tiptap/extension-text-align' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import { TableRow } from '@tiptap/extension-table-row' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import Superscript from '@tiptap/extension-superscript' import Subscript from '@tiptap/extension-subscript' import Typography from '@tiptap/extension-typography' import { ChartExtension } from './tiptap-chart-extension' import { ChartSuggestionsDialog } from './chart-suggestions-dialog' import { UniqueIdExtension } from './tiptap-unique-id-extension' import { LiveBlockExtension } from './tiptap-live-block-extension' import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension' import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension' import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension' import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension' import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar' import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension' import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension' import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension' import { BlockPicker, type BlockSuggestion } from './block-picker' import { EditorBlockDragHandle } from './editor-block-drag-handle' import { BlockActionMenu } from './block-action-menu' import { SmartPasteMenu } from './smart-paste-menu' import { SmartPasteExtendedMenu } from './smart-paste-extended-menu' import { MobileEditorToolbar } from './mobile-editor-toolbar' import { MobileActionSheet } from './mobile-action-sheet' import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension' import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle' import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference' import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection' import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension' import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension' import { MarkdownPasteExtension } from '@/lib/editor/markdown-paste-extension' import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension' import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension' import type { Node as PMNode } from '@tiptap/pm/model' import { detectTextDirection } from '@/lib/clip/rtl-content' import { stripHtmlToPlainText } from '@/lib/text/plain-text' import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker' import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction' import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync' import { openNotePeek } from '@/lib/note-peek-sync' import { useAiConsent } from '@/components/legal/ai-consent-provider' import type { Editor } from '@tiptap/core' import type { EditorState } from '@tiptap/pm/state' import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Link2, Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink, 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 } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' export interface RichTextEditorHandle { getEditor: () => Editor | null triggerChartSuggestions: () => void insertCitation: ( payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean } ) => boolean insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean } export interface RichTextEditorProps { content?: string onChange?: (content: string) => void className?: string placeholder?: string onImageUpload?: (file: File) => Promise noteId?: string notebookId?: string | null noteTitle?: string /** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */ sourceUrl?: string | null } interface RichTextEditorRef { triggerChartSuggestions: () => void } type SlashItem = { title: string description: string icon: any category?: string shortcut?: string isImage?: boolean isAi?: boolean aiOption?: 'clarify' | 'shorten' | 'improve' | 'write' command: (editor: Editor, range?: any) => void } type SlashCategoryId = 'text' | 'media' | 'data' | 'embed' | 'ai' type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] } const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['text', 'media', 'data', 'embed', 'ai'] function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string { switch (id) { case 'text': return t('richTextEditor.slashCatText') || 'Texte' case 'media': return t('richTextEditor.slashCatMedia') || 'Médias' case 'data': return t('richTextEditor.slashCatData') || 'Données' case 'embed': return t('richTextEditor.slashCatEmbed') || 'Intégré' case 'ai': return t('richTextEditor.slashCatAi') || 'IA Note' } } /** Sent to /api/ai/reformulate as target language (unchanged for prompt compatibility). */ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'] as const const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__' const CustomImage = Image.extend({ addAttributes() { return { ...this.parent?.(), width: { default: '100%', parseHTML: element => element.style.width || element.getAttribute('width') || '100%', renderHTML: attributes => { if (!attributes.width) return {} return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` } } } } } }) const slashCommands: SlashItem[] = [ // Basic blocks { 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: () => TBL, 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: '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() }, // Media { title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } }, // Formatting { title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() }, { title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() }, { title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() }, // IA Note { title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, { title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } }, { title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } }, { title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, // Formatting extensions { title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() }, { title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() }, { title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() }, { title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() }, { title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() }, { title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() }, { title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() }, // AI Tools { title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => { const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } }) window.dispatchEvent(event) } }, { title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => { const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } }) window.dispatchEvent(event) } }, { title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', icon: BarChart3, category: 'IA Note', isAi: true, command: (e) => { // Handler will be called by SlashCommandMenu } }, { title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', icon: Link2, category: 'Basic blocks', shortcut: '/bloc', command: (_e) => { window.dispatchEvent(new CustomEvent('memento-open-block-picker')) } }, { title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database', command: (e) => { insertStructuredViewBlockAtSelection(e) }, }, { title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>', command: (e) => { insertToggleBlock(e) }, }, { title: 'Callout', description: 'Highlighted info box', icon: MessageSquareWarning, category: 'Basic blocks', shortcut: '!', command: (e) => { insertCalloutBlock(e, 'info') }, }, { title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc', command: (e) => { insertOutlineBlock(e) }, }, { title: 'Link Preview', description: 'Embed a URL as a rich card', icon: Link2, category: 'Basic blocks', shortcut: '/link', command: (e) => { window.dispatchEvent(new CustomEvent('memento-open-link-preview')) }, }, { title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$', command: (e) => { insertMathEquation(e) }, }, { title: 'Columns', description: 'Side-by-side layout', icon: Columns3, category: 'Basic blocks', shortcut: '/cols', command: (e) => { insertColumnsBlock(e, 2) }, }, { title: 'Écrire avec l\'IA', description: 'Générer du contenu au curseur', icon: Sparkles, category: 'IA Note', isAi: true, aiOption: 'write', command: () => { } }, ] async function aiReformulate(text: string, option: string, t: any, language?: string): Promise { const res = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, option, format: 'html', language }), }) const data = await res.json() if (!res.ok) { if (data?.errorKey === 'ai.wordCountMin') { throw new Error(t('ai.wordCountMin') || `Minimum ${data?.params?.min || 10} mots requis (${data?.params?.current || 0} actuels)`) } if (data?.errorKey === 'ai.wordCountMax') { throw new Error(t('ai.wordCountMax') || `Maximum ${data?.params?.max || 500} mots (${data?.params?.current || 0} actuels)`) } if (data?.errorKey === 'ai.featureLocked') { throw new Error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite le plan PRO.') } if (data?.errorKey === 'ai.quotaExceeded') { throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.') } if (data?.quotaExceeded) { throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.') } const serverMsg = typeof data?.error === 'string' && !data.error.includes(':') ? data.error.trim() : AI_REFORMULATE_FALLBACK throw new Error(serverMsg) } return data.reformulatedText || data.text || text } function useImageInsert() { const [open, setOpen] = useState(false) const editorRef = useRef(null) const requestInsert = useCallback((editor: Editor) => { editorRef.current = editor setOpen(true) }, []) const confirm = useCallback((url: string) => { if (url.trim() && editorRef.current) { editorRef.current.chain().focus().setImage({ src: url.trim() }).run() } setOpen(false) editorRef.current = null }, []) const cancel = useCallback(() => { setOpen(false) editorRef.current = null }, []) return { open, requestInsert, confirm, cancel } } export const RichTextEditor = forwardRef( function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) { const { t } = useLanguage() const { requestAiConsent } = useAiConsent() const imageInsert = useImageInsert() const [blockPickerOpen, setBlockPickerOpen] = useState(false) const [blockMenuState, setBlockMenuState] = useState<{ anchor: DOMRect pos: number node: PMNode | null } | null>(null) const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 }) const smartPastePendingRef = useRef<{ reference: ParsedBlockReference blockPos: number blockNode: PMNode blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string } } | null>(null) const [smartPasteMenu, setSmartPasteMenu] = useState<{ anchor: { top: number; left: number } reference: ParsedBlockReference sourceNoteTitle?: string } | null>(null) const [smartPasteExtended, setSmartPasteExtended] = useState<{ type: 'url' | 'code' text: string anchor: { top: number; left: number } isImage?: boolean isVideo?: boolean } | null>(null) const [isMobile, setIsMobile] = useState(false) const [actionSheetOpen, setActionSheetOpen] = useState(false) const [showFindReplace, setShowFindReplace] = useState(false) const [linkPreviewUrl, setLinkPreviewUrl] = useState(null) useEffect(() => { const handler = () => setLinkPreviewUrl('') window.addEventListener('memento-open-link-preview', handler) return () => window.removeEventListener('memento-open-link-preview', handler) }, []) useEffect(() => { const handleFindShortcut = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) { const target = e.target as HTMLElement if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return e.preventDefault() setShowFindReplace(v => !v) } } document.addEventListener('keydown', handleFindShortcut) return () => document.removeEventListener('keydown', handleFindShortcut) }, []) const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false) const [noteLinkQuery, setNoteLinkQuery] = useState('') const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null) const noteLinkPickerOpenRef = useRef(false) noteLinkPickerOpenRef.current = noteLinkPickerOpen const lastEmittedContent = useRef(content || '') const editorInstanceRef = useRef(null) const onChangeRef = useRef(onChange) onChangeRef.current = onChange const htmlDebounceRef = useRef | null>(null) const emitContentChange = useCallback((html: string) => { lastEmittedContent.current = html onChangeRef.current?.(html) }, []) // Debounced version for onUpdate — avoids calling getHTML() on every keystroke const emitContentChangeDebounced = useCallback((editor: any) => { if (htmlDebounceRef.current) clearTimeout(htmlDebounceRef.current) htmlDebounceRef.current = setTimeout(() => { emitContentChange(editor.getHTML()) }, 400) }, [emitContentChange]) useEffect(() => { if (typeof window === 'undefined') return const checkMobile = () => setIsMobile(window.innerWidth < 768) checkMobile() window.addEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile) }, []) // Listen to the slash-command event to open the BlockPicker useEffect(() => { const openHandler = () => setBlockPickerOpen(true) window.addEventListener('memento-open-block-picker', openHandler) return () => window.removeEventListener('memento-open-block-picker', openHandler) }, []) const handleSelectBlockRef = useRef<(block: BlockSuggestion) => void>(() => {}) const insertCitationRef = useRef<(payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean }) => boolean>(() => false) useEffect(() => { const insertHandler = (event: Event) => { const block = (event as CustomEvent<{ block: BlockSuggestion }>).detail?.block if (block) handleSelectBlockRef.current(block) } const citationHandler = (event: Event) => { const detail = (event as CustomEvent<{ noteId: string; noteTitle: string; excerpt: string; atEnd?: boolean }>).detail if (!detail) return insertCitationRef.current(detail, { atEnd: detail.atEnd !== false }) } window.addEventListener('memento-insert-live-block', insertHandler) window.addEventListener('memento-insert-citation', citationHandler) return () => { window.removeEventListener('memento-insert-live-block', insertHandler) window.removeEventListener('memento-insert-citation', citationHandler) } }, []) const handleSelectNoteLink = useCallback((selected: NoteLinkOption) => { const ed = editorInstanceRef.current if (!ed) { setNoteLinkPickerOpen(false) return } let range = noteLinkRangeRef.current if (!range) { const { from } = ed.state.selection const start = Math.max(0, from - 80) const textBefore = ed.state.doc.textBetween(start, from, '\n', '') const match = textBefore.match(/\[\[([^\]]*)$/) if (match) { range = { from: from - match[0].length, to: from } } } if (!range) { setNoteLinkPickerOpen(false) return } const title = (selected.title || t('documentInfo.network.untitled')).trim() const href = `/home?openNote=${encodeURIComponent(selected.id)}` ed.chain() .focus() .deleteRange({ from: range.from, to: range.to }) .insertContent({ type: 'text', text: title, marks: [{ type: 'link', attrs: { href, target: '_blank', rel: 'noopener noreferrer', }, }], }) .insertContent(' ') .run() const html = ed.getHTML() emitContentChange(html) setNoteLinkPickerOpen(false) noteLinkRangeRef.current = null if (noteId) { window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, { detail: { noteId, reason: 'note-link' }, })) } }, [emitContentChange, noteId, t]) const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }), Underline, TiptapLink.configure({ openOnClick: false, autolink: true }), Highlight.configure({ multicolor: false }), CustomImage.configure({ inline: false, allowBase64: true }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }), TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell, Superscript, Subscript, Typography, ChartExtension, UniqueIdExtension, ...globalDragHandleExtensions, SmartPasteExtension, BlockSelectionExtension, MarkdownPasteExtension, TurnIntoShortcutExtension, UndoRedoFeedbackExtension, LiveBlockExtension, StructuredViewBlockExtension, ToggleExtension, CalloutExtension, OutlineExtension, FindReplaceExtension, LinkPreviewExtension, MathEquationExtension, InlineMathExtension, ColumnsExtension, ColumnNode, ClipArticleExtension, RtlPreserveExtension, Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === 'heading') { const level = node.attrs.level if (level === 1) return t('richTextEditor.placeholderH1') || 'Titre principal...' if (level === 2) return t('richTextEditor.placeholderH2') || 'Titre de section...' if (level === 3) return t('richTextEditor.placeholderH3') || 'Sous-titre...' } if (node.type.name === 'taskItem') { return t('richTextEditor.placeholderTodo') || 'Ajouter une tâche...' } if (node.type.name === 'codeBlock') { return t('richTextEditor.placeholderCode') || 'Écrire du code...' } if (node.type.name === 'blockquote') { return t('richTextEditor.placeholderQuote') || 'Saisir une citation...' } return placeholder || t('richTextEditor.placeholderText') || t('richTextEditor.placeholder') || "Tapez '/' pour insérer un bloc..." } }), ], content: content || '', immediatelyRender: false, shouldRerenderOnTransaction: false, editorProps: { attributes: { class: 'notion-editor tiptap' }, handleDOMEvents: { keydown: (view, event) => { if (event.defaultPrevented) return false if (event.key !== 'Enter' && event.key !== ' ') return false const { from, empty } = view.state.selection if (!empty) return false const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n') if (!/\/(database|db|vue|tableau|structured)$/i.test(textBefore)) return false event.preventDefault() const slashIdx = textBefore.lastIndexOf('/') const deleteFrom = from - (textBefore.length - slashIdx) const ed = editorInstanceRef.current if (!ed) return false ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run() if (!insertStructuredViewBlockAtSelection(ed, notebookId)) { toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.') } return true }, click: (_view, event) => { const link = (event.target as HTMLElement).closest('a[href]') if (!link) return false const href = link.getAttribute('href') || '' const noteIdMatch = href.match(/[?&]openNote=([^&#]+)/) if (noteIdMatch) { event.preventDefault() event.stopPropagation() const targetId = decodeURIComponent(noteIdMatch[1]) const blockMatch = href.match(/#block-([^&#]+)/) openNotePeek({ noteId: targetId, blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined, }) return true } if (href.startsWith('http://') || href.startsWith('https://')) { event.preventDefault() event.stopPropagation() window.open(href, '_blank', 'noopener,noreferrer') return true } return false }, }, handlePaste: (_view, event) => { if (!onImageUpload) return false const items = Array.from(event.clipboardData?.items || []) const hasImage = items.some(item => item.type.startsWith('image/')) if (!hasImage) return false event.preventDefault() const imageFiles = items .filter(item => item.type.startsWith('image/')) .map(item => item.getAsFile()) .filter((file): file is File => file !== null) void (async () => { for (const file of imageFiles) { try { toast.info(t('notes.uploading')) const url = await onImageUpload(file) const ed = editorInstanceRef.current if (!ed) continue const inserted = ed.chain().focus().setImage({ src: url }).run() if (inserted) { emitContentChange(ed.getHTML()) } } catch { toast.error(t('notes.uploadFailed')) } } })() return true } }, onUpdate: ({ editor: e }) => { // Debounced getHTML() — avoids traversing the whole doc on every keystroke emitContentChangeDebounced(e) if (!e.isEditable) return const { from, empty } = e.state.selection if (!empty) return const start = Math.max(0, from - 80) const textBefore = e.state.doc.textBetween(start, from, '\n', '') const match = textBefore.match(/\[\[([^\]]*)$/) if (match) { noteLinkRangeRef.current = { from: from - match[0].length, to: from } setNoteLinkQuery(match[1]) setNoteLinkPickerOpen(true) } else if (!noteLinkPickerOpenRef.current) { setNoteLinkPickerOpen(false) noteLinkRangeRef.current = null } }, onCreate: ({ editor: e }) => { requestAnimationFrame(() => { applyClipRtlDirection(e, { sourceUrl }) }) }, }) useEffect(() => { editorInstanceRef.current = editor ?? null }, [editor]) useEffect(() => { if (!editor) return editor.storage.liveBlock.hostNoteId = noteId ?? null }, [editor, noteId]) useEffect(() => { if (!editor) return if ((editor.storage as any).structuredViewBlock) { (editor.storage as any).structuredViewBlock.notebookId = notebookId ?? null } }, [editor, notebookId]) useEffect(() => { if (!editor) return editor.storage.smartPaste.onPaste = (view, event) => { const clipboardText = event.clipboardData?.getData('text/plain') ?? '' let blockRef = parseBlockReferenceFromText(clipboardText) if (!blockRef) { const recalled = recallLastBlockReference() if (recalled && (!clipboardText.trim() || clipboardText.trim() === recalled.raw)) { blockRef = recalled } } if (blockRef) { const emptyParagraph = getEmptyParagraphAtSelection(view.state) if (!emptyParagraph) return false event.preventDefault() event.stopPropagation() const coords = view.coordsAtPos(view.state.selection.from) smartPastePendingRef.current = { reference: blockRef, blockPos: emptyParagraph.pos, blockNode: emptyParagraph.node, } queueMicrotask(() => { setSmartPasteMenu({ anchor: { top: coords.bottom, left: coords.left }, reference: blockRef, }) }) void fetch( `/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`, ) .then((res) => (res.ok ? res.json() : null)) .then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => { if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return const recalled = recallLastBlockReference() const sessionFallback = recalled?.raw === blockRef.raw ? { content: recalled.blockContent?.trim() ?? '', sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '', } : { content: '', sourceNoteTitle: '' } const exists = Boolean(data?.exists) || sessionFallback.content.length > 0 const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '') const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || '' smartPastePendingRef.current!.blockStatus = { exists, content, sourceNoteTitle, } setSmartPasteMenu((prev) => prev?.reference.raw === blockRef.raw ? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle } : prev, ) }) .catch(() => {}) return true } // Détection d'URL HTTP(S) cliquable ou média const isUrl = /^https?:\/\/[^\s]+$/i.test(clipboardText.trim()) if (isUrl) { const url = clipboardText.trim() event.preventDefault() event.stopPropagation() const coords = view.coordsAtPos(view.state.selection.from) const isImage = /\.(jpeg|jpg|gif|png|svg|webp)($|\?)/i.test(url) const isVideo = /(youtube\.com|youtu\.be|vimeo\.com|streamable\.com)/i.test(url) || /\.(mp4|webm|ogg)($|\?)/i.test(url) setSmartPasteExtended({ type: 'url', text: url, anchor: { top: coords.bottom, left: coords.left }, isImage, isVideo, }) return true } // Détection de code source technique const hasCodeSpecialChars = /[{}[\];]/.test(clipboardText) const hasCodeKeywords = /(const\s+\w+\s*=|let\s+\w+\s*=|function\s+\w*\(|import\s+.*from|class\s+\w+|def\s+\w+\(|public\s+class\s+\w+|#include\s+<|import\s+react|var\s+\w+\s*=)/.test(clipboardText) const isMultiline = clipboardText.split('\n').length > 1 if ((hasCodeSpecialChars && hasCodeKeywords) || (isMultiline && hasCodeSpecialChars && clipboardText.includes('('))) { event.preventDefault() event.stopPropagation() const coords = view.coordsAtPos(view.state.selection.from) setSmartPasteExtended({ type: 'code', text: clipboardText, anchor: { top: coords.bottom, left: coords.left }, }) return true } return false } return () => { editor.storage.smartPaste.onPaste = null } }, [editor]) // Chart suggestions dialog state const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false) const [currentNoteContent, setCurrentNoteContent] = useState(content || '') useEffect(() => { if (editor && content !== undefined && content !== lastEmittedContent.current) { const html = content || '' lastEmittedContent.current = html queueMicrotask(() => { if (!editor.isDestroyed) { editor.commands.setContent(html) requestAnimationFrame(() => { applyClipRtlDirection(editor, { sourceUrl }) }) } }) } if (content !== undefined) { setCurrentNoteContent(content || '') } }, [content, editor, sourceUrl]) // Chart suggestion handlers const handleOpenChartSuggestions = useCallback(async () => { if (!editor || !editor.isEditable) return const consented = await requestAiConsent() if (!consented) return setChartSuggestionsOpen(true) }, [editor, requestAiConsent]) const insertCitationInEditor = useCallback(( payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean } ) => { if (!editor || !editor.isEditable) return false const plainExcerpt = stripHtmlToPlainText(payload.excerpt) if (!plainExcerpt) return false const isRtl = detectTextDirection(`${payload.noteTitle}\n${plainExcerpt}`) === 'rtl' const rtlAttrs = isRtl ? { dir: 'rtl' as const, lang: 'fa' as const } : {} const chain = editor.chain() if (options?.atEnd !== false) { chain.focus('end') } else { chain.focus() } chain.insertContent([ { type: 'paragraph', content: [] }, { type: 'blockquote', attrs: rtlAttrs.dir ? { dir: rtlAttrs.dir } : {}, content: [{ type: 'paragraph', attrs: rtlAttrs, content: [{ type: 'text', text: plainExcerpt }], }], }, { type: 'paragraph', attrs: rtlAttrs, content: [ { type: 'text', text: '— ' }, { type: 'text', text: payload.noteTitle, marks: [{ type: 'link', attrs: { href: `/home?openNote=${payload.noteId}`, target: '_blank', rel: 'noopener noreferrer' } }], }, ], }, ]).scrollIntoView().run() emitContentChange(editor.getHTML()) return true }, [editor, emitContentChange]) const insertLiveBlockInEditor = useCallback((block: BlockSuggestion, options?: { atEnd?: boolean }) => { if (!editor || !editor.isEditable) return false const chain = editor.chain() if (options?.atEnd !== false) { chain.focus('end') } else { chain.focus() } chain.insertContent({ type: 'liveBlock', attrs: { sourceNoteId: block.noteId, blockId: block.blockId, snapshotContent: block.content, sourceNoteTitle: block.noteTitle, }, }).scrollIntoView().run() emitContentChange(editor.getHTML()) return true }, [editor, emitContentChange]) insertCitationRef.current = insertCitationInEditor useImperativeHandle(ref, () => ({ getEditor: () => editor, triggerChartSuggestions: () => { if (editor) { handleOpenChartSuggestions() } }, insertCitation: insertCitationInEditor, insertLiveBlock: insertLiveBlockInEditor, }), [editor, handleOpenChartSuggestions, insertCitationInEditor, insertLiveBlockInEditor]) const handleSelectChart = useCallback((chartContent: string) => { if (!editor || !editor.isEditable) return try { // [debug removed] // Get current selection const { from, to, empty } = editor.state.selection // Insert chart AFTER the selected text (not replace it) // First insert a paragraph for spacing, then the chart, then another paragraph editor.chain() .focus() .insertContentAt(to, [ { type: 'paragraph', content: [] }, { type: 'chartBlock', attrs: { code: chartContent, language: 'chart' } }, { type: 'paragraph', content: [] } ]) .run() // [debug removed] } catch (error) { console.error('[handleSelectChart] Failed:', error) toast.error('Failed to insert chart: ' + (error as Error).message) } }, [editor]) const handleSelectBlock = useCallback(async (block: BlockSuggestion) => { setBlockPickerOpen(false) if (!editor) return if (noteId) { try { await fetch('/api/blocks/embed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceNoteId: block.noteId, blockId: block.blockId, targetNoteId: noteId }), }) } catch { // Non-fatal } } editor.chain().focus().insertContent({ type: 'liveBlock', attrs: { sourceNoteId: block.noteId, blockId: block.blockId, snapshotContent: block.content, sourceNoteTitle: block.noteTitle, }, }).run() emitContentChange(editor.getHTML()) }, [editor, noteId, emitContentChange]) handleSelectBlockRef.current = handleSelectBlock const openBlockActionMenu = useCallback((anchorRect: DOMRect) => { if (!editor) return const block = resolveBlockAtDragHandle(editor) if (!block) return dragBlockRef.current = block setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node }) }, [editor]) const closeBlockActionMenu = useCallback(() => { setBlockMenuState(null) }, []) const closeSmartPasteMenu = useCallback(() => { smartPastePendingRef.current = null setSmartPasteMenu(null) }, []) const handleBlockReferenceCopied = useCallback((html: string) => { emitContentChange(html) if (noteId) { window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, { detail: { noteId, reason: 'block-reference-copy' }, })) } }, [emitContentChange, noteId]) const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => { const cached = smartPastePendingRef.current?.blockStatus if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) { return cached } const recalled = recallLastBlockReference() const sessionFallback = recalled?.raw === reference.raw ? { content: recalled.blockContent?.trim() ?? '', sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '', } : { content: '', sourceNoteTitle: '' } const res = await fetch( `/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`, ) if (!res.ok) { return { exists: sessionFallback.content.length > 0, content: sessionFallback.content, sourceNoteTitle: sessionFallback.sourceNoteTitle, } } const data = await res.json() if (data.exists) { return { exists: true, content: data.content ?? '', sourceNoteTitle: data.sourceNoteTitle ?? '', } } return { exists: sessionFallback.content.length > 0, content: sessionFallback.content || data.content || '', sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '', } }, []) const handleSmartPasteLive = useCallback(async () => { const pending = smartPastePendingRef.current const ed = editorInstanceRef.current if (!pending || !ed) { closeSmartPasteMenu() return } const status = await fetchBlockStatus(pending.reference) if (noteId) { try { await fetch('/api/blocks/embed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceNoteId: pending.reference.sourceNoteId, blockId: pending.reference.blockId, targetNoteId: noteId, }), }) } catch { // Non-fatal } } const liveBlockType = ed.schema.nodes.liveBlock if (liveBlockType) { ed.chain() .focus() .command(({ tr, dispatch }) => { const node = liveBlockType.create({ sourceNoteId: pending.reference.sourceNoteId, blockId: pending.reference.blockId, snapshotContent: status.content, sourceNoteTitle: status.sourceNoteTitle, }) tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node) if (dispatch) dispatch(tr) return true }) .run() emitContentChange(ed.getHTML()) if (noteId) { window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, { detail: { noteId, reason: 'smart-paste-live-block' }, })) } } closeSmartPasteMenu() }, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId]) const handleSmartPastePlain = useCallback(async () => { const pending = smartPastePendingRef.current const ed = editorInstanceRef.current if (!pending || !ed) { closeSmartPasteMenu() return } const status = await fetchBlockStatus(pending.reference) const linkHref = pending.reference.raw.match(/^https?:\/\//) ? pending.reference.raw : `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}` const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw ed.chain() .focus() .insertContent({ type: 'text', text: linkText, marks: [{ type: 'link', attrs: { href: linkHref, target: '_blank', rel: 'noopener noreferrer', }, }], }) .run() emitContentChange(ed.getHTML()) closeSmartPasteMenu() }, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus]) const handlePasteUrlLink = useCallback((url: string) => { if (!editor) return const { from, to, empty } = editor.state.selection if (empty) { editor.chain().focus().insertContent(`${url}`).run() } else { editor.chain().focus().setLink({ href: url }).run() } setSmartPasteExtended(null) emitContentChange(editor.getHTML()) }, [editor, emitContentChange]) const handlePasteUrlImage = useCallback((url: string) => { if (!editor) return editor.chain().focus().setImage({ src: url }).run() setSmartPasteExtended(null) emitContentChange(editor.getHTML()) }, [editor, emitContentChange]) const handlePasteUrlVideo = useCallback((url: string) => { if (!editor) return const { from, to, empty } = editor.state.selection if (empty) { editor.chain().focus().insertContent(`🎥 ${t('richTextEditor.slashVideo') || 'Vidéo'} (${url})`).run() } else { editor.chain().focus().setLink({ href: url }).run() } setSmartPasteExtended(null) emitContentChange(editor.getHTML()) }, [editor, emitContentChange, t]) const handlePasteCodeBlock = useCallback((code: string) => { if (!editor) return editor.chain().focus().insertContent({ type: 'codeBlock', content: [{ type: 'text', text: code }] }).run() setSmartPasteExtended(null) emitContentChange(editor.getHTML()) }, [editor, emitContentChange]) const handlePastePlain = useCallback((text: string) => { if (!editor) return editor.chain().focus().insertContent(text).run() setSmartPasteExtended(null) emitContentChange(editor.getHTML()) }, [editor, emitContentChange]) return (
{editor && ( document.body, zIndex: 99999, fallbackPlacements: ['bottom', 'top'] } } as any)} shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => { const { from, to } = state.selection const isImage = e.isActive('image') return (from !== to && !e.isActive('codeBlock')) || isImage }} > )} {editor && } {editor && showFindReplace && ( setShowFindReplace(false)} /> )} {editor && linkPreviewUrl !== null && (
setLinkPreviewUrl(null)}>
e.stopPropagation()} dir="auto">
{t('richTextEditor.linkPreviewModalTitle')}
setLinkPreviewUrl(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter') { e.preventDefault() if (linkPreviewUrl.trim()) { insertLinkPreview(editor, linkPreviewUrl.trim()) setLinkPreviewUrl(null) } } if (e.key === 'Escape') setLinkPreviewUrl(null) }} placeholder="https://..." className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30" />
)} {editor && blockMenuState && ( )} {smartPasteMenu && ( { void handleSmartPasteLive() }} onPlain={() => { void handleSmartPastePlain() }} onClose={closeSmartPasteMenu} /> )} {smartPasteExtended && ( handlePasteUrlLink(smartPasteExtended.text)} onLinkPreview={() => { insertLinkPreview(editor, smartPasteExtended.text); setSmartPasteExtended(null) }} onImage={() => handlePasteUrlImage(smartPasteExtended.text)} onVideo={() => handlePasteUrlVideo(smartPasteExtended.text)} onCodeBlock={() => handlePasteCodeBlock(smartPasteExtended.text)} onPlain={() => handlePastePlain(smartPasteExtended.text)} onClose={() => setSmartPasteExtended(null)} /> )} {editor && isMobile && ( setActionSheetOpen(true)} onInsertImage={imageInsert.requestInsert} /> )} {editor && actionSheetOpen && ( setActionSheetOpen(false)} /> )} {imageInsert.open && ( )} {chartSuggestionsOpen && editor && ( setChartSuggestionsOpen(false)} onSelectChart={handleSelectChart} /> )} setBlockPickerOpen(false)} currentNoteId={noteId} onSelectBlock={handleSelectBlock} /> { setNoteLinkPickerOpen(false) noteLinkRangeRef.current = null }} onSelect={handleSelectNoteLink} />
) } ) function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) { const { t } = useLanguage() const [url, setUrl] = useState('') const [preview, setPreview] = useState(null) const [error, setError] = useState('') const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) const handleConfirm = () => { if (!url.trim()) return if (!/^https?:\/\/.+/i.test(url.trim())) { setError(t('richTextEditor.imageModalInvalidUrl')); return } onConfirm(url.trim()) } return (
e.stopPropagation()}>
{t('richTextEditor.imageModalTitle')}
{ setUrl(e.target.value); setError(''); setPreview(null) }} onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }} placeholder={t('richTextEditor.imageUrlPlaceholder')} className="notion-modal-input" /> {url.trim() && !preview && ( )} {preview && (
{ setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
)} {error &&
{error}
}
) } function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onSuggestCharts?: () => void }) { const { t, language } = useLanguage() const { requestAiConsent } = useAiConsent() const toastAiError = (err: unknown) => { const msg = err instanceof Error ? err.message : '' toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed')) } // Wrap the current text selection in a container block (toggle or callout) const wrapSelectionInBlock = useCallback((blockType: string) => { if (!editor) return const { from, to } = editor.state.selection if (from === to) return // Get the selected slice (all blocks in the range) const slice = editor.state.doc.slice(from, to) const content = slice.content // Create the container node with the selected content inside const containerNode = editor.state.schema.nodes[blockType].create( blockType === 'calloutBlock' ? { type: 'info' } : { opened: true }, content, ) // Replace the selection with the container const tr = editor.state.tr tr.replaceRangeWith(from, to, containerNode) editor.view.dispatch(tr) }, [editor]) const [aiOpen, setAiOpen] = useState(false) const [aiLoading, setAiLoading] = useState(false) const [linkOpen, setLinkOpen] = useState(false) const [linkUrl, setLinkUrl] = useState('') const linkInputRef = useRef(null) const [translateOpen, setTranslateOpen] = useState(false) const [customLang, setCustomLang] = useState('') const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null) const editorState = useEditorState({ editor, selector: (ctx) => ({ isBold: ctx.editor?.isActive('bold') ?? false, isItalic: ctx.editor?.isActive('italic') ?? false, isUnderline: ctx.editor?.isActive('underline') ?? false, isStrike: ctx.editor?.isActive('strike') ?? false, isCode: ctx.editor?.isActive('code') ?? false, isHighlight: ctx.editor?.isActive('highlight') ?? false, isSuperscript: ctx.editor?.isActive('superscript') ?? false, isSubscript: ctx.editor?.isActive('subscript') ?? false, isLink: ctx.editor?.isActive('link') ?? false, isImage: ctx.editor?.isActive('image') ?? false, selectionFrom: ctx.editor?.state?.selection?.from ?? 0, selectionTo: ctx.editor?.state?.selection?.to ?? 0, }) }) useEffect(() => { if (linkOpen && linkInputRef.current) linkInputRef.current.focus() }, [linkOpen]) if (!editor || !editorState) return null const marks = [ { icon: Bold, active: editorState.isBold, action: () => editor.chain().focus().toggleBold().run(), title: t('richTextEditor.bold') }, { icon: Italic, active: editorState.isItalic, action: () => editor.chain().focus().toggleItalic().run(), title: t('richTextEditor.italic') }, { icon: UnderlineIcon, active: editorState.isUnderline, action: () => editor.chain().focus().toggleUnderline().run(), title: t('richTextEditor.underline') }, { icon: Strikethrough, active: editorState.isStrike, action: () => editor.chain().focus().toggleStrike().run(), title: t('richTextEditor.strike') }, { icon: Code, active: editorState.isCode, action: () => editor.chain().focus().toggleCode().run(), title: t('richTextEditor.code') }, { icon: Highlighter, active: editorState.isHighlight, action: () => editor.chain().focus().toggleHighlight().run(), title: t('richTextEditor.highlight') }, { icon: SuperscriptIcon, active: editorState.isSuperscript, action: () => editor.chain().focus().toggleSuperscript().run(), title: t('richTextEditor.superscript') }, { icon: SubscriptIcon, active: editorState.isSubscript, action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') }, ] const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => { const { from, to } = editor.state.selection const text = editor.state.doc.textBetween(from, to, ' ') if (!text || text.split(/\s+/).length < 2) return const consented = await requestAiConsent() if (!consented) return setAiLoading(true) setAiOpen(false) setTranslateOpen(false) try { const lang = option === 'translate' ? (targetLang || language) : language const result = await aiReformulate(text, option, t, lang) window.dispatchEvent(new Event('ai-usage-changed')) if (option === 'explain') { setAiModal({ type: 'explain', origText: text, html: result, from, to }) } else { setAiModal({ type: 'preview', origText: text, html: result, from, to }) } } catch (err) { console.error('AI error:', err) toastAiError(err) } finally { setAiLoading(false) } } const applyAiResult = () => { if (!aiModal || !editor) { setAiModal(null); return } const clean = aiModal.html.replace(/^

([\s\S]*)<\/p>$/, '$1').trim() editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, clean).run() setAiModal(null) } const openLinkEditor = () => { const existing = editor.getAttributes('link').href || '' setLinkUrl(existing) setLinkOpen(true) setAiOpen(false) } const applyLink = () => { if (linkUrl.trim()) { editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.trim() }).run() } else { editor.chain().focus().extendMarkRange('link').unsetLink().run() } setLinkOpen(false) } if (linkOpen) { return (

setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }} placeholder={t('richTextEditor.linkPlaceholder')} className="notion-inline-input" /> {editorState.isLink && ( )}
) } return (
{marks.map((m, i) => ( ))}
{editorState.isImage && ( <>
)} {aiOpen && (
{translateOpen && (
{TRANSLATE_TARGET_API_VALUES.map((apiValue) => ( ))}
setCustomLang(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }} />
)} {onSuggestCharts && ( )}
)} {aiModal && (
setAiModal(null)}>
e.stopPropagation()}>
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
{aiModal.type === 'preview' && (
{t('ai.result.original') || 'Original'}

{aiModal.origText}

)}
{aiModal.type === 'preview' && {t('ai.result.suggestion') || 'Suggestion'}}
{aiModal.type === 'preview' && ( )}
)}
) } function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => string }) { switch (itemTitle) { case 'Table': case 'Tableau': return (
{t('richTextEditor.slashTable') || 'Tableau'}

{t('richTextEditor.previewTableTip') || 'Organisez vos données en lignes et colonnes.'}

) case 'Database': case 'Base de données': return (
{t('richTextEditor.slashDatabase') || 'Base de Données'}
{t('structuredViews.propertyName') || 'Nom'} {t('structuredViews.propertyStatus') || 'Statut'}
{t('wizard.taskA') || 'Tâche A'} {t('structuredViews.filterTodo') || 'À faire'}
{t('wizard.taskB') || 'Tâche B'} {t('structuredViews.filterDone') || 'Fait'}

{t('richTextEditor.previewDatabaseTip') || 'Ajoutez des colonnes et des vues Kanban structurées.'}

) case 'Suggest Charts': case 'Suggest Chart': case 'Graphiques IA': return (
{t('richTextEditor.slashCharts') || 'Graphique IA'}

{t('richTextEditor.previewChartsTip') || 'Générez un graphique interactif à partir de votre texte.'}

) case 'Living Block': case 'Bloc vivant': return (
{t('richTextEditor.slashLivingBlock') || 'Bloc Vivant'}
Note A
Note B

{t('richTextEditor.previewLivingBlockTip') || 'Synchronisez du contenu entre plusieurs notes.'}

) case 'Diagramme': case 'Diagram': return (
{t('richTextEditor.slashDiagram') || 'Diagramme'}

{t('richTextEditor.previewDiagramTip') || 'Esquissez des concepts ou générez des diagrammes via IA.'}

) case 'Présentation': case 'Presentation': return (
{t('richTextEditor.slashSlides') || 'Présentation'}

{t('richTextEditor.previewSlidesTip') || 'Créez des présentations interactives exportables.'}

) case 'Code Block': case 'Code': case 'Bloc de code': return (
{t('richTextEditor.slashCode') || 'Bloc de Code'}

{t('richTextEditor.previewCodeTip') || 'Ajoutez du code avec coloration syntaxique.'}

) default: return null } } function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) { const { t } = useLanguage() const { requestAiConsent } = useAiConsent() const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [activeCategory, setActiveCategory] = useState(null) const [coords, setCoords] = useState({ top: 0, left: 0 }) const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' }) const [aiLoading, setAiLoading] = useState(false) const [aiWriterMode, setAiWriterMode] = useState(false) const aiWriterModeRef = useRef(false) const [aiWriterPrompt, setAiWriterPrompt] = useState('') const [aiWriterLoading, setAiWriterLoading] = useState(false) const menuRef = useRef(null) const selectedItemRef = useRef(null) const menuInteracting = useRef(false) const [frequentCommands, setFrequentCommands] = useState([]) 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'] }, { title: t('richTextEditor.slashNoteLink'), description: t('richTextEditor.slashNoteLinkDesc'), icon: Link2, categoryId: 'embed' as SlashCategoryId, command: (e) => { e.chain().focus().insertContent('[[').run() }, }, ] const closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null); setAiWriterMode(false); aiWriterModeRef.current = false }, []) const handleAiWriterSubmit = useCallback(async () => { if (!aiWriterPrompt.trim()) return setAiWriterLoading(true) try { const res = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: '', option: 'write', format: 'html', writePrompt: aiWriterPrompt.trim(), }), }) const data = await res.json() if (!res.ok) { toast.error(data.error || 'Erreur'); return } let html = data.reformulatedText || data.text || '' // Clean up excessive whitespace html = html .replace(/

\s*<\/p>/g, '') .replace(/(]*>)\s+/g, '$1') .replace(/\s+(<\/p>)/g, '$1') .replace(/\n{3,}/g, '\n\n') .trim() closeMenu() // Stream the content paragraph by paragraph const paragraphs = html.match(/<(?:p|h[1-3]|ul|ol|div|blockquote|pre)[^>]*>[\s\S]*?<\/(?:p|h[1-3]|ul|ol|div|blockquote|pre)>/gi) || [html] for (let i = 0; i < paragraphs.length; i++) { editor.chain().focus().insertContent(paragraphs[i]).run() if (i < paragraphs.length - 1) { await new Promise(r => setTimeout(r, 120)) } } } catch (e: any) { toast.error(e.message || 'Erreur') } finally { setAiWriterLoading(false) } }, [aiWriterPrompt, editor, closeMenu]) const deleteSlashText = useCallback(() => { const { from, to } = editor.state.selection const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const slashIdx = textBefore.lastIndexOf('/') if (slashIdx >= 0) { const deleteFrom = from - (textBefore.length - slashIdx) editor.chain().focus().deleteRange({ from: deleteFrom, to }).run() } }, [editor]) const handleSelect = useCallback(async (item: SlashMenuItem) => { // Persister l'historique d'usage pour la section Favoris try { const usageStr = localStorage.getItem('memento-slash-command-usage') || '{}' const usage = JSON.parse(usageStr) as Record usage[item.title] = (usage[item.title] || 0) + 1 localStorage.setItem('memento-slash-command-usage', JSON.stringify(usage)) } catch (e) { console.warn('Slash usage count error:', e) } const toastAi = (err: unknown) => { const msg = err instanceof Error ? err.message : '' toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed')) } if (item.isImage) { deleteSlashText(); closeMenu(); onInsertImage(editor) } else if (item.isAi && item.aiOption === 'write') { aiWriterModeRef.current = true setAiWriterMode(true) setAiWriterPrompt('') deleteSlashText() const { from } = editor.state.selection const c = editor.view.coordsAtPos(from) setCoords({ top: c.bottom + 8, left: c.left }) } else if (item.isAi && item.aiOption) { deleteSlashText(); closeMenu(); setAiLoading(true) try { const consented = await requestAiConsent() if (!consented) return const allText = editor.state.doc.textContent if (!allText || allText.split(/\s+/).length < 5) return const result = await aiReformulate(allText, item.aiOption, t) editor.chain().focus().setContent(result).run() } catch (err) { console.error('AI slash error:', err) toastAi(err) } finally { setAiLoading(false) } } else if (item.title === 'Suggest Charts') { deleteSlashText(); closeMenu(); onSuggestCharts() } else if (item.title === t('richTextEditor.slashDatabase')) { deleteSlashText(); closeMenu() const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) { toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.') } } else { deleteSlashText(); item.command(editor); closeMenu() } }, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent]) // Charger les favoris fréquents lors de l'ouverture useEffect(() => { if (!isOpen) return try { const usageStr = localStorage.getItem('memento-slash-command-usage') || '{}' const usage = JSON.parse(usageStr) as Record const sorted = localCommands .filter(c => (usage[c.title] || 0) > 0) .sort((a, b) => (usage[b.title] || 0) - (usage[a.title] || 0)) .slice(0, 4) setFrequentCommands(sorted) } catch (e) { setFrequentCommands([]) } }, [isOpen]) const presentCategoryIds = new Set(localCommands.map(c => c.categoryId)) const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id)) const q = query.toLowerCase() const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || (c.shortcut?.toLowerCase().includes(q) ?? false) || (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false) ) const baseFiltered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered // Injecter les favoris tout en haut si pas de recherche ni filtre de catégorie actif const filtered = !q && !activeCategory && frequentCommands.length > 0 ? [...frequentCommands.map(c => ({ ...c, isFavorite: true })), ...baseFiltered] : baseFiltered const availableCategoriesInSearch = textFiltered.reduce((acc, item) => { const id = item.categoryId if (!acc[id]) acc[id] = [] acc[id].push(item) return acc }, {} as Record) const categories = baseFiltered.reduce((acc, item) => { const id = item.categoryId if (!acc[id]) acc[id] = [] acc[id].push(item) return acc }, {} as Record) // Auto-scroll selected item into view useEffect(() => { selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [selectedIndex]) // Ajustement dynamique de la position de la preview useEffect(() => { if (!isOpen) return const menuWidth = 320 const previewWidth = 280 const padding = 12 const wouldOverflowRight = coords.left + menuWidth + previewWidth + padding > window.innerWidth if (wouldOverflowRight) { setPreviewCoords({ top: coords.top, left: coords.left - previewWidth - padding, side: 'left', }) } else { setPreviewCoords({ top: coords.top, left: coords.left + menuWidth + padding, side: 'right', }) } }, [coords, isOpen]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return if (aiWriterModeRef.current) return // Let the AI writer input handle keystrokes if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return } if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault() const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)] const currentIndex = availableTabs.indexOf(activeCategory) const nextIndex = e.key === 'ArrowRight' ? (currentIndex + 1) % availableTabs.length : (currentIndex - 1 + availableTabs.length) % availableTabs.length setActiveCategory(availableTabs[nextIndex]) setSelectedIndex(0) return } if (e.key === 'Enter') { e.preventDefault() const item = filtered[selectedIndex] if (item) { handleSelect(item) } else if (/^(database|db|vue|tableau|structured)$/i.test(query)) { deleteSlashText() closeMenu() const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) { toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.') } } return } if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return } if (e.key === 'Tab') { e.preventDefault() const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)] const nextIndex = (availableTabs.indexOf(activeCategory) + 1) % availableTabs.length setActiveCategory(availableTabs[nextIndex]) setSelectedIndex(0) return } } document.addEventListener('keydown', handleKeyDown, true) return () => document.removeEventListener('keydown', handleKeyDown, true) }, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch, query, deleteSlashText, editor, t]) useEffect(() => { if (!isOpen) return const { from } = editor.state.selection const c = editor.view.coordsAtPos(from) // Check if menu would overflow bottom const menuHeight = menuRef.current?.offsetHeight || 300 const wouldOverflow = c.bottom + menuHeight + 20 > window.innerHeight if (wouldOverflow) { setCoords({ top: c.top - menuHeight - 8, left: c.left }) } else { setCoords({ top: c.bottom + 8, left: c.left }) } }, [isOpen, editor, query, filtered.length, coords.left]) useEffect(() => { const handleClick = (e: MouseEvent) => { if (isOpen && !aiWriterModeRef.current && menuRef.current && !menuRef.current.contains(e.target as Node)) { closeMenu() } } document.addEventListener('click', handleClick) return () => document.removeEventListener('click', handleClick) }, [isOpen, closeMenu]) useEffect(() => { const handler = () => { // Ignore events fired while user clicks inside the menu if (menuInteracting.current) return if (aiWriterModeRef.current) return // Synchronous check via ref const { from, empty } = editor.state.selection if (!empty) { if (isOpen) closeMenu(); return } const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const m = text.match(/\/([^\s/]*)$/) if (m) { setQuery(m[1]) setSelectedIndex(0) if (!isOpen) { setIsOpen(true); setActiveCategory(null) } } else if (isOpen) closeMenu() } editor.on('update', handler) editor.on('selectionUpdate', handler) return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) } }, [editor, isOpen, closeMenu, aiWriterMode]) if (!aiWriterMode && (!isOpen || filtered.length === 0)) return null let flatIndex = -1 const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0) const totalVisible = sectionIds.length const visibleSectionIds = [...sectionIds] // Si pas de query, ajouter la section Favoris au début const hasFavorites = !q && !activeCategory && frequentCommands.length > 0 if (hasFavorites) { visibleSectionIds.unshift('frequent' as any) } const selectedItem = filtered[selectedIndex] const showPreview = selectedItem && [ 'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code' ].includes(selectedItem.title) return createPortal( <> {aiWriterMode ? (

e.stopPropagation()} dir="auto" >
setAiWriterPrompt(e.target.value)} onKeyDown={(e) => { e.stopPropagation() if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void handleAiWriterSubmit() } if (e.key === 'Escape') closeMenu() }} autoFocus disabled={aiWriterLoading} placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'} className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" /> {aiWriterLoading && }
{!aiWriterLoading && aiWriterPrompt.trim() && ( )}
) : (
e.stopPropagation()} > {/* Category tabs */} {!query && totalVisible > 1 && (
e.preventDefault()}> {allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => ( ))}
)} {/* Header hint */}
{t('richTextEditor.slashHint')}
{aiLoading && (
{t('richTextEditor.slashLoading')}
)} {!aiLoading && visibleSectionIds.map((catId) => { const items = catId === ('frequent' as any) ? frequentCommands.map(c => ({ ...c, isFavorite: true })) : categories[catId]! return (
{catId === 'ai' && } {catId === ('frequent' as any) ? (t('richTextEditor.frequentCommands') || '★ Fréquents') : slashCategoryLabel(catId, t)}
{items.map((item) => { flatIndex++ const idx = flatIndex const isSelected = idx === selectedIndex return ( ) })}
) })}
)} {showPreview && (
)} , document.body ) }