Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
2552 lines
112 KiB
TypeScript
2552 lines
112 KiB
TypeScript
'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 { InteractiveDemoExtension, insertInteractiveDemoAtSelection } from './tiptap-interactive-demo-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 { isTextSelection, type Editor } from '@tiptap/core'
|
||
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, Trash2, Clapperboard
|
||
} from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { toast } from 'sonner'
|
||
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
|
||
|
||
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
|
||
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
|
||
onChangeImmediate?: (content: string) => void
|
||
className?: string
|
||
placeholder?: string
|
||
onImageUpload?: (file: File) => Promise<string>
|
||
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
|
||
isFavorite?: 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({
|
||
// Treat image as a single selectable unit (Backspace/Delete must remove it)
|
||
atom: true,
|
||
selectable: true,
|
||
draggable: true,
|
||
|
||
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;` }
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
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 — 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: '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
|
||
{ 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: 'Interactive Demo', description: 'AI pedagogical step-by-step demo', icon: Clapperboard, category: 'IA Note', isAi: true, command: () => {},
|
||
},
|
||
{
|
||
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<string> {
|
||
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<Editor | null>(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<RichTextEditorHandle, RichTextEditorProps>(
|
||
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
|
||
const { t, language } = 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<string | null>(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<string>(content || '')
|
||
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, opts?: { immediate?: boolean }) => {
|
||
lastEmittedContent.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
|
||
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,
|
||
InteractiveDemoExtension,
|
||
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()
|
||
event.stopPropagation()
|
||
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 || 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) {
|
||
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 (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, 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
|
||
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) {
|
||
// 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(() => {
|
||
if (!editor.isDestroyed) {
|
||
editor.commands.setContent(html)
|
||
requestAnimationFrame(() => {
|
||
applyClipRtlDirection(editor, { sourceUrl })
|
||
})
|
||
}
|
||
})
|
||
}
|
||
if (content !== undefined && !editor?.isFocused) {
|
||
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 handleGenerateInteractiveDemo = useCallback(async () => {
|
||
if (!editor || !editor.isEditable) return
|
||
const consented = await requestAiConsent()
|
||
if (!consented) return
|
||
|
||
// Prefer HTML so TipTap math nodes (data-latex) reach formula extractors
|
||
let content = ''
|
||
let selection: string | null = null
|
||
try {
|
||
content = editor.getHTML() || editor.state.doc.textContent || ''
|
||
const { from, to, empty } = editor.state.selection
|
||
if (!empty) {
|
||
const parts: string[] = []
|
||
editor.state.doc.nodesBetween(from, to, (node) => {
|
||
const latex = (node.attrs as { latex?: string } | undefined)?.latex
|
||
if (typeof latex === 'string' && latex.trim()) {
|
||
parts.push(
|
||
node.type.name === 'mathEquationBlock'
|
||
? `$$${latex}$$`
|
||
: `$${latex}$`
|
||
)
|
||
return false
|
||
}
|
||
if (node.isText && node.text) {
|
||
parts.push(node.text)
|
||
}
|
||
return true
|
||
})
|
||
selection = parts.join(' ').trim() || null
|
||
}
|
||
} catch (err) {
|
||
console.warn('[interactive-demo] content extract fallback', err)
|
||
content = editor.state.doc.textContent || ''
|
||
}
|
||
|
||
const sourceForWords = (selection || editor.state.doc.textContent || '').trim()
|
||
const words = sourceForWords.split(/\s+/).filter(Boolean).length
|
||
if (words < 20) {
|
||
toast.error(
|
||
t('interactiveDemo.needMoreText') ||
|
||
'Sélectionne au moins ~20 mots (ou écris plus de contenu) pour générer une démo'
|
||
)
|
||
return
|
||
}
|
||
|
||
const toastId = toast.loading(
|
||
t('interactiveDemo.generating') || 'Génération de la démo interactive…'
|
||
)
|
||
try {
|
||
const { generateInteractiveDemo } = await import(
|
||
'@/lib/ai/services/interactive-demo-client.service'
|
||
)
|
||
const result = await generateInteractiveDemo({
|
||
content,
|
||
selection,
|
||
lang: language || 'fr',
|
||
noteId: noteId || undefined,
|
||
})
|
||
if (!result.ok) {
|
||
if (result.quotaExceeded) {
|
||
toast.error(t('interactiveDemo.quotaExceeded') || 'Quota IA insuffisant', {
|
||
id: toastId,
|
||
})
|
||
} else {
|
||
toast.error(
|
||
typeof result.error === 'string'
|
||
? result.error
|
||
: t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||
{ id: toastId }
|
||
)
|
||
}
|
||
return
|
||
}
|
||
if (!insertInteractiveDemoAtSelection(editor, result.demo)) {
|
||
toast.error(
|
||
t('interactiveDemo.insertFailed') || 'Impossible d’insérer la démo',
|
||
{ id: toastId }
|
||
)
|
||
return
|
||
}
|
||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||
toast.success(
|
||
t('interactiveDemo.generateSuccess') || 'Démo interactive insérée',
|
||
{ id: toastId }
|
||
)
|
||
} catch (err) {
|
||
console.error('[interactive-demo] generate error', err)
|
||
toast.error(
|
||
t('interactiveDemo.generateFailed') || 'Échec de la génération',
|
||
{ id: toastId }
|
||
)
|
||
}
|
||
}, [editor, requestAiConsent, t, language, noteId])
|
||
|
||
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(`<a href="${url}">${url}</a>`).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(), { immediate: true })
|
||
}, [editor, emitContentChange])
|
||
|
||
const handlePasteUrlVideo = useCallback((url: string) => {
|
||
if (!editor) return
|
||
const { from, to, empty } = editor.state.selection
|
||
if (empty) {
|
||
editor.chain().focus().insertContent(`🎥 <a href="${url}">${t('richTextEditor.slashVideo') || 'Vidéo'} (${url})</a>`).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 (
|
||
<div className={cn('notion-editor-wrapper', className)}>
|
||
{editor && (
|
||
<BubbleMenu
|
||
editor={editor}
|
||
className="notion-bubble-menu"
|
||
appendTo={() => document.body}
|
||
shouldShow={({ editor: e, state, from, to, view, element }) => {
|
||
if (!e.isEditable || e.isActive('codeBlock')) return false
|
||
|
||
const { selection } = state
|
||
if (selection instanceof AllSelection) return false
|
||
if (selection instanceof NodeSelection) {
|
||
return selection.node.type.name === 'image'
|
||
}
|
||
if (!isTextSelection(selection) || selection.empty) return false
|
||
|
||
const selectedText = state.doc.textBetween(from, to)
|
||
if (!selectedText.length) return false
|
||
|
||
// setContent à l’ouverture sélectionne parfois tout le document
|
||
const docSize = state.doc.content.size
|
||
if (docSize > 4 && from <= 1 && to >= docSize - 1) return false
|
||
|
||
return view.hasFocus() || element.contains(document.activeElement)
|
||
}}
|
||
>
|
||
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
|
||
</BubbleMenu>
|
||
)}
|
||
|
||
{editor && (
|
||
<SlashCommandMenu
|
||
editor={editor}
|
||
onInsertImage={imageInsert.requestInsert}
|
||
onSuggestCharts={handleOpenChartSuggestions}
|
||
onGenerateInteractiveDemo={handleGenerateInteractiveDemo}
|
||
/>
|
||
)}
|
||
|
||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||
|
||
<EditorContent editor={editor} />
|
||
|
||
{editor && showFindReplace && (
|
||
<FindReplaceBar editor={editor} onClose={() => setShowFindReplace(false)} />
|
||
)}
|
||
|
||
{editor && linkPreviewUrl !== null && (
|
||
<div className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/30" onClick={() => setLinkPreviewUrl(null)}>
|
||
<div className="bg-popover rounded-xl border border-border shadow-lg w-96 max-w-[90vw] p-4" onClick={(e) => e.stopPropagation()} dir="auto">
|
||
<div className="text-sm font-medium mb-3">{t('richTextEditor.linkPreviewModalTitle')}</div>
|
||
<input
|
||
type="url"
|
||
autoFocus
|
||
value={linkPreviewUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<div className="flex justify-end gap-2 mt-3">
|
||
<button onClick={() => setLinkPreviewUrl(null)} className="px-3 py-1.5 text-sm rounded-md hover:bg-muted transition-colors">
|
||
{t('richTextEditor.imageModalCancel')}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (linkPreviewUrl.trim()) {
|
||
insertLinkPreview(editor, linkPreviewUrl.trim())
|
||
setLinkPreviewUrl(null)
|
||
}
|
||
}}
|
||
disabled={!linkPreviewUrl.trim()}
|
||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||
>
|
||
{t('richTextEditor.linkPreviewModalInsert')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{editor && blockMenuState && (
|
||
<BlockActionMenu
|
||
editor={editor}
|
||
anchorRect={blockMenuState.anchor}
|
||
blockPos={blockMenuState.pos}
|
||
blockNode={blockMenuState.node}
|
||
noteId={noteId}
|
||
sourceNoteTitle={noteTitle}
|
||
onBlockReferenceCopied={handleBlockReferenceCopied}
|
||
onClose={closeBlockActionMenu}
|
||
/>
|
||
)}
|
||
|
||
{smartPasteMenu && (
|
||
<SmartPasteMenu
|
||
anchor={smartPasteMenu.anchor}
|
||
reference={smartPasteMenu.reference}
|
||
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
|
||
onLive={() => { void handleSmartPasteLive() }}
|
||
onPlain={() => { void handleSmartPastePlain() }}
|
||
onClose={closeSmartPasteMenu}
|
||
/>
|
||
)}
|
||
|
||
{smartPasteExtended && (
|
||
<SmartPasteExtendedMenu
|
||
type={smartPasteExtended.type}
|
||
text={smartPasteExtended.text}
|
||
anchor={smartPasteExtended.anchor}
|
||
isImage={smartPasteExtended.isImage}
|
||
isVideo={smartPasteExtended.isVideo}
|
||
onLink={() => 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 && (
|
||
<MobileEditorToolbar
|
||
editor={editor}
|
||
onOpenActionSheet={() => setActionSheetOpen(true)}
|
||
onInsertImage={imageInsert.requestInsert as any}
|
||
/>
|
||
)}
|
||
|
||
{editor && actionSheetOpen && (
|
||
<MobileActionSheet
|
||
editor={editor}
|
||
isOpen={actionSheetOpen}
|
||
onClose={() => setActionSheetOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{imageInsert.open && (
|
||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||
)}
|
||
|
||
{chartSuggestionsOpen && editor && (
|
||
<ChartSuggestionsDialog
|
||
isOpen={chartSuggestionsOpen}
|
||
content={currentNoteContent}
|
||
selection={editor.state.selection.empty ? null : editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ')}
|
||
onClose={() => setChartSuggestionsOpen(false)}
|
||
onSelectChart={handleSelectChart}
|
||
/>
|
||
)}
|
||
|
||
<BlockPicker
|
||
isOpen={blockPickerOpen}
|
||
onClose={() => setBlockPickerOpen(false)}
|
||
currentNoteId={noteId}
|
||
onSelectBlock={handleSelectBlock}
|
||
/>
|
||
|
||
<NoteLinkPicker
|
||
isOpen={noteLinkPickerOpen}
|
||
query={noteLinkQuery}
|
||
currentNoteId={noteId}
|
||
onClose={() => {
|
||
setNoteLinkPickerOpen(false)
|
||
noteLinkRangeRef.current = null
|
||
}}
|
||
onSelect={handleSelectNoteLink}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
)
|
||
|
||
function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
|
||
const { t } = useLanguage()
|
||
const [url, setUrl] = useState('')
|
||
const [preview, setPreview] = useState<string | null>(null)
|
||
const [error, setError] = useState('')
|
||
const inputRef = useRef<HTMLInputElement>(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 (
|
||
<div className="notion-overlay" onClick={onCancel}>
|
||
<div className="notion-image-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="text-sm font-medium mb-3">{t('richTextEditor.imageModalTitle')}</div>
|
||
<input
|
||
ref={inputRef}
|
||
type="url"
|
||
value={url}
|
||
onChange={(e) => { 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 && (
|
||
<button onClick={() => setPreview(url.trim())} className="text-xs text-muted-foreground hover:text-foreground transition-colors underline mt-2">
|
||
{t('richTextEditor.imageModalPreview')}
|
||
</button>
|
||
)}
|
||
{preview && (
|
||
<div className="mt-2 rounded-lg overflow-hidden border border-border max-h-40">
|
||
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
|
||
</div>
|
||
)}
|
||
{error && <div className="mt-1.5 text-xs text-destructive">{error}</div>}
|
||
<div className="flex justify-end gap-2 mt-4">
|
||
<button onClick={onCancel} className="notion-modal-btn">{t('richTextEditor.imageModalCancel')}</button>
|
||
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">{t('richTextEditor.imageModalInsert')}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<HTMLInputElement>(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(/^<p>([\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 (
|
||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||
<input
|
||
ref={linkInputRef}
|
||
type="url"
|
||
value={linkUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
|
||
{editorState.isLink && (
|
||
<button onClick={() => { editor.chain().focus().extendMarkRange('link').unsetLink().run(); setLinkOpen(false) }} className="notion-bubble-btn">
|
||
<ExternalLink className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
<button onClick={() => { setLinkOpen(false); editor.chain().focus().run() }} className="notion-bubble-btn"><X className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
|
||
{marks.map((m, i) => (
|
||
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn rounded-md', m.active && 'notion-bubble-btn-active')}>
|
||
<m.icon className="w-3.5 h-3.5" />
|
||
</button>
|
||
))}
|
||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editorState.isLink && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
|
||
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn rounded-md', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
|
||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||
<button onClick={() => wrapSelectionInBlock('toggleBlock')} title="Section repliable" className="notion-bubble-btn rounded-md">
|
||
<ChevronsRightLeft className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button onClick={() => wrapSelectionInBlock('calloutBlock')} title="Encadré" className="notion-bubble-btn rounded-md">
|
||
<MessageSquareWarning className="w-3.5 h-3.5" />
|
||
</button>
|
||
{editorState.isImage && (
|
||
<>
|
||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||
<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 && (
|
||
<div className="notion-ai-submenu">
|
||
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
||
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-primary" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
||
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
||
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
|
||
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
||
{translateOpen && (
|
||
<div className="notion-ai-lang-picker">
|
||
{TRANSLATE_TARGET_API_VALUES.map((apiValue) => (
|
||
<button key={apiValue} className="notion-ai-lang-item" onClick={() => handleAI('translate', apiValue)}>
|
||
{t(`richTextEditor.translateTargets.${apiValue}`)}
|
||
</button>
|
||
))}
|
||
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
|
||
<input
|
||
className="notion-inline-input text-xs flex-1 min-w-0"
|
||
placeholder={t('ai.action.customLang') || 'Autre...'}
|
||
value={customLang}
|
||
onChange={e => setCustomLang(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||
{onSuggestCharts && (
|
||
<button className="notion-ai-subitem" onClick={() => { setAiOpen(false); onSuggestCharts() }}><BarChart3 className="w-3.5 h-3.5 text-blue-500" /><span>{t('ai.action.generateChart') || 'Générer un graphique'}</span></button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{aiModal && (
|
||
<div className="notion-ai-result-overlay" onClick={() => setAiModal(null)}>
|
||
<div className="notion-ai-result-modal" onClick={e => e.stopPropagation()}>
|
||
<div className="notion-ai-result-header">
|
||
<span className="text-sm font-semibold">
|
||
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
|
||
</span>
|
||
<button onClick={() => setAiModal(null)} className="notion-bubble-btn ml-auto"><X className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
{aiModal.type === 'preview' && (
|
||
<div className="notion-ai-result-section">
|
||
<span className="notion-ai-result-label">{t('ai.result.original') || 'Original'}</span>
|
||
<p className="text-sm text-muted-foreground line-through opacity-60 mt-1">{aiModal.origText}</p>
|
||
</div>
|
||
)}
|
||
<div className="notion-ai-result-section">
|
||
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
|
||
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(aiModal.html) }} />
|
||
</div>
|
||
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
|
||
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
|
||
{aiModal.type === 'preview' && (
|
||
<button onClick={applyAiResult} className="notion-modal-btn notion-modal-btn-primary">{t('ai.result.apply') || 'Appliquer'}</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SlashPreview({ itemTitle, t }: { itemTitle: string; t: (k: string) => string }) {
|
||
switch (itemTitle) {
|
||
case 'Table':
|
||
case 'Tableau':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashTable') || 'Tableau'}</div>
|
||
<div className="slash-preview-grid">
|
||
<div className="slash-preview-grid-header"></div>
|
||
<div className="slash-preview-grid-header"></div>
|
||
<div className="slash-preview-grid-header"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
<div className="slash-preview-grid-cell"></div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewTableTip') || 'Organisez vos données en lignes et colonnes.'}</p>
|
||
</div>
|
||
)
|
||
case 'Database':
|
||
case 'Base de données':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashDatabase') || 'Base de Données'}</div>
|
||
<div className="slash-preview-db">
|
||
<div className="slash-preview-db-row header">
|
||
<span>{t('structuredViews.propertyName') || 'Nom'}</span>
|
||
<span>{t('structuredViews.propertyStatus') || 'Statut'}</span>
|
||
</div>
|
||
<div className="slash-preview-db-row">
|
||
<span>{t('wizard.taskA') || 'Tâche A'}</span>
|
||
<span className="badge badge-todo">{t('structuredViews.filterTodo') || 'À faire'}</span>
|
||
</div>
|
||
<div className="slash-preview-db-row">
|
||
<span>{t('wizard.taskB') || 'Tâche B'}</span>
|
||
<span className="badge badge-done">{t('structuredViews.filterDone') || 'Fait'}</span>
|
||
</div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewDatabaseTip') || 'Ajoutez des colonnes et des vues Kanban structurées.'}</p>
|
||
</div>
|
||
)
|
||
case 'Suggest Charts':
|
||
case 'Suggest Chart':
|
||
case 'Graphiques IA':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashCharts') || 'Graphique IA'}</div>
|
||
<div className="slash-preview-chart">
|
||
<div className="chart-bar" style={{ height: '40%' }}></div>
|
||
<div className="chart-bar" style={{ height: '75%' }}></div>
|
||
<div className="chart-bar" style={{ height: '50%' }}></div>
|
||
<div className="chart-bar" style={{ height: '90%' }}></div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewChartsTip') || 'Générez un graphique interactif à partir de votre texte.'}</p>
|
||
</div>
|
||
)
|
||
case 'Living Block':
|
||
case 'Bloc vivant':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashLivingBlock') || 'Bloc Vivant'}</div>
|
||
<div className="slash-preview-live">
|
||
<div className="live-note">Note A</div>
|
||
<div className="live-sync-line"></div>
|
||
<div className="live-note">Note B</div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewLivingBlockTip') || 'Synchronisez du contenu entre plusieurs notes.'}</p>
|
||
</div>
|
||
)
|
||
case 'Diagramme':
|
||
case 'Diagram':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashDiagram') || 'Diagramme'}</div>
|
||
<div className="slash-preview-excalidraw">
|
||
<div className="excalidraw-circle"></div>
|
||
<div className="excalidraw-arrow"></div>
|
||
<div className="excalidraw-rect"></div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewDiagramTip') || 'Esquissez des concepts ou générez des diagrammes via IA.'}</p>
|
||
</div>
|
||
)
|
||
case 'Présentation':
|
||
case 'Presentation':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashSlides') || 'Présentation'}</div>
|
||
<div className="slash-preview-slides">
|
||
<div className="slide-item"></div>
|
||
<div className="slide-item active"></div>
|
||
<div className="slide-item"></div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewSlidesTip') || 'Créez des présentations interactives exportables.'}</p>
|
||
</div>
|
||
)
|
||
case 'Code Block':
|
||
case 'Code':
|
||
case 'Bloc de code':
|
||
return (
|
||
<div className="slash-preview-box">
|
||
<div className="slash-preview-title">{t('richTextEditor.slashCode') || 'Bloc de Code'}</div>
|
||
<div className="slash-preview-code">
|
||
<div className="flex gap-1 mb-1">
|
||
<span className="code-dot red"></span>
|
||
<span className="code-dot yellow"></span>
|
||
<span className="code-dot green"></span>
|
||
</div>
|
||
<div className="code-line green"></div>
|
||
<div className="code-line blue"></div>
|
||
</div>
|
||
<p className="slash-preview-tip">{t('richTextEditor.previewCodeTip') || 'Ajoutez du code avec coloration syntaxique.'}</p>
|
||
</div>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateInteractiveDemo }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void; onGenerateInteractiveDemo: () => 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<SlashCategoryId | null>(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<HTMLDivElement>(null)
|
||
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
||
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[] = [
|
||
{ ...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') || 'Proposer des graphiques', description: t('richTextEditor.slashChartsDesc') || 'Suggérer des graphiques d’après la note', categoryId: 'ai' },
|
||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc lié', description: t('richTextEditor.slashLivingBlockDesc') || 'Ouvrir un passage d’une autre note à côté', 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('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Parcours pédagogique dans la note', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||
{ ...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'),
|
||
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.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); return }
|
||
let html = data.reformulatedText || data.text || ''
|
||
// Clean up excessive whitespace
|
||
html = html
|
||
.replace(/<p>\s*<\/p>/g, '')
|
||
.replace(/(<p[^>]*>)\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<string, number>
|
||
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'
|
||
|| item.title === (t('richTextEditor.slashCharts') || 'Proposer des graphiques')
|
||
) {
|
||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||
} else if (
|
||
item.title === 'Interactive Demo'
|
||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas')
|
||
) {
|
||
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
|
||
} 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, onGenerateInteractiveDemo, 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<string, number>
|
||
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<SlashCategoryId, SlashMenuItem[]>)
|
||
|
||
const categories = baseFiltered.reduce((acc, item) => {
|
||
const id = item.categoryId
|
||
if (!acc[id]) acc[id] = []
|
||
acc[id].push(item)
|
||
return acc
|
||
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
|
||
|
||
// 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', 'Bloc lié', 'Linked block', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
|
||
].includes(selectedItem.title)
|
||
|
||
return createPortal(
|
||
<>
|
||
{aiWriterMode ? (
|
||
<div
|
||
ref={menuRef}
|
||
className="notion-slash-menu"
|
||
style={{ top: coords.top, left: coords.left, minWidth: '360px' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
dir="auto"
|
||
>
|
||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30">
|
||
<Sparkles size={16} className="text-brand-accent flex-shrink-0" />
|
||
<input
|
||
type="text"
|
||
value={aiWriterPrompt}
|
||
onChange={(e) => 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 && <Loader2 size={14} className="animate-spin text-brand-accent flex-shrink-0" />}
|
||
</div>
|
||
{!aiWriterLoading && aiWriterPrompt.trim() && (
|
||
<button
|
||
onClick={() => void handleAiWriterSubmit()}
|
||
className="w-full px-3 py-2 text-xs font-medium text-brand-accent hover:bg-brand-accent/10 transition-colors text-left"
|
||
>
|
||
{t('general.send') || 'Générer'} ↵
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
ref={menuRef}
|
||
className="notion-slash-menu"
|
||
style={{ top: coords.top, left: coords.left }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Category tabs */}
|
||
{!query && totalVisible > 1 && (
|
||
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
|
||
<button
|
||
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
|
||
>
|
||
{t('richTextEditor.slashTabAll')}
|
||
</button>
|
||
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
|
||
<button
|
||
key={catId}
|
||
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
|
||
>
|
||
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
|
||
{slashCategoryLabel(catId, t)}
|
||
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Header hint */}
|
||
<div className="notion-slash-header">
|
||
<span>{t('richTextEditor.slashHint')}</span>
|
||
</div>
|
||
|
||
{aiLoading && (
|
||
<div className="notion-slash-loading">
|
||
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
||
<span>{t('richTextEditor.slashLoading')}</span>
|
||
</div>
|
||
)}
|
||
{!aiLoading && visibleSectionIds.map((catId) => {
|
||
const items = catId === ('frequent' as any)
|
||
? frequentCommands.map(c => ({ ...c, isFavorite: true }))
|
||
: categories[catId]!
|
||
|
||
return (
|
||
<div key={catId} className="notion-slash-section">
|
||
<div className={cn(
|
||
'notion-slash-label',
|
||
catId === 'ai' && 'notion-slash-label-ai',
|
||
catId === ('frequent' as any) && 'text-amber-500 font-semibold'
|
||
)}>
|
||
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
||
{catId === ('frequent' as any) ? (t('richTextEditor.frequentCommands') || '★ Fréquents') : slashCategoryLabel(catId, t)}
|
||
</div>
|
||
{items.map((item) => {
|
||
flatIndex++
|
||
const idx = flatIndex
|
||
const isSelected = idx === selectedIndex
|
||
return (
|
||
<button
|
||
key={`${catId}-${item.title}-${item.shortcut ?? ''}-${item.isFavorite ? 'fav' : 'normal'}`}
|
||
ref={isSelected ? selectedItemRef : null}
|
||
className={cn(
|
||
'notion-slash-item',
|
||
isSelected && 'notion-slash-item-selected',
|
||
item.isFavorite && 'notion-slash-item-favorite'
|
||
)}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => handleSelect(item)}
|
||
onMouseEnter={() => setSelectedIndex(idx)}
|
||
>
|
||
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
|
||
<item.icon className="w-4 h-4" />
|
||
</div>
|
||
<div className="notion-slash-content">
|
||
<span className="notion-slash-title">{item.title}</span>
|
||
<span className="notion-slash-desc">{item.description}</span>
|
||
</div>
|
||
{item.shortcut && (
|
||
<span className="notion-slash-shortcut">{item.shortcut}</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{showPreview && (
|
||
<div
|
||
className="notion-slash-preview"
|
||
style={{ top: previewCoords.top, left: previewCoords.left }}
|
||
>
|
||
<SlashPreview itemTitle={selectedItem.title} t={t} />
|
||
</div>
|
||
)}
|
||
</>,
|
||
document.body
|
||
)
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|