Files
Momento/memento-note/components/rich-text-editor.tsx
Antigravity 7fedfa8f50
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m26s
CI / Deploy production (on server) (push) Has been skipped
feat: colonnes multi-colonnes (layout côte à côte style Notion)
- Architecture nested nodes: columns container → column children
- CSS seamless (pas de bordures, fin séparateur vertical entre colonnes)
- isolating: true sur les deux nœuds (curseur reste dans sa colonne)
- Commands addColumnBefore/addColumnAfter/deleteColumn
- Slash menu + drag handle + raccourci Mod+Shift+L
- i18n FR/EN complet
2026-06-14 18:49:15 +00:00

2080 lines
91 KiB
TypeScript

'use client'
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { createPortal } from 'react-dom'
import { useLanguage } from '@/lib/i18n'
import { useEditor, EditorContent, useEditorState } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import TiptapLink from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography'
import { ChartExtension } from './tiptap-chart-extension'
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
import { UniqueIdExtension } from './tiptap-unique-id-extension'
import { LiveBlockExtension } from './tiptap-live-block-extension'
import { StructuredViewBlockExtension, insertStructuredViewBlockAtSelection } from './tiptap-structured-view-block-extension'
import { ToggleExtension, insertToggleBlock } from './tiptap-toggle-extension'
import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension'
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension'
import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker'
import { EditorBlockDragHandle } from './editor-block-drag-handle'
import { BlockActionMenu } from './block-action-menu'
import { SmartPasteMenu } from './smart-paste-menu'
import { SmartPasteExtendedMenu } from './smart-paste-extended-menu'
import { MobileEditorToolbar } from './mobile-editor-toolbar'
import { MobileActionSheet } from './mobile-action-sheet'
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
import { BlockSelectionExtension } from '@/lib/editor/block-selection-extension'
import { MarkdownPasteExtension } from '@/lib/editor/markdown-paste-extension'
import { TurnIntoShortcutExtension } from '@/lib/editor/turn-into-shortcut-extension'
import { UndoRedoFeedbackExtension } from '@/lib/editor/undo-redo-feedback-extension'
import type { Node as PMNode } from '@tiptap/pm/model'
import { detectTextDirection } from '@/lib/clip/rtl-content'
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
import { openNotePeek } from '@/lib/note-peek-sync'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Link2,
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
triggerChartSuggestions: () => void
insertCitation: (
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
) => boolean
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
}
export interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<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
aiOption?: 'clarify' | 'shorten' | 'improve'
command: (editor: Editor, range?: any) => void
}
type SlashCategoryId = 'text' | 'media' | 'data' | 'embed' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['text', 'media', 'data', 'embed', 'ai']
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
switch (id) {
case 'text': return t('richTextEditor.slashCatText') || 'Texte'
case 'media': return t('richTextEditor.slashCatMedia') || 'Médias'
case 'data': return t('richTextEditor.slashCatData') || 'Données'
case 'embed': return t('richTextEditor.slashCatEmbed') || 'Intégré'
case 'ai': return t('richTextEditor.slashCatAi') || 'IA Note'
}
}
/** Sent to /api/ai/reformulate as target language (unchanged for prompt compatibility). */
const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'] as const
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: '100%',
parseHTML: element => element.style.width || element.getAttribute('width') || '100%',
renderHTML: attributes => {
if (!attributes.width) return {}
return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` }
}
}
}
}
})
const slashCommands: SlashItem[] = [
// Basic blocks
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
// Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting
{ title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() },
{ title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() },
{ title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() },
// IA Note
{ title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
{ title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } },
{ title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } },
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } },
// Formatting extensions
{ title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() },
{ title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() },
{ title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() },
{ title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() },
{ title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() },
{ title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() },
{ title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() },
// AI Tools
{
title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => {
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } })
window.dispatchEvent(event)
}
},
{
title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => {
const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } })
window.dispatchEvent(event)
}
},
{
title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', icon: BarChart3, category: 'IA Note', isAi: true, command: (e) => {
// Handler will be called by SlashCommandMenu
}
},
{
title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', icon: Link2, category: 'Basic blocks', shortcut: '/bloc',
command: (_e) => {
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
}
},
{
title: 'Database', description: 'Inline database', icon: Database, category: 'Basic blocks', shortcut: '/database',
command: (e) => { insertStructuredViewBlockAtSelection(e) },
},
{
title: 'Toggle', description: 'Collapsible section', icon: ChevronsRightLeft, category: 'Basic blocks', shortcut: '>',
command: (e) => { insertToggleBlock(e) },
},
{
title: 'Callout', description: 'Highlighted info box', icon: MessageSquareWarning, category: 'Basic blocks', shortcut: '!',
command: (e) => { insertCalloutBlock(e, 'info') },
},
{
title: 'Outline', description: 'Table of contents from headings', icon: ListTree, category: 'Basic blocks', shortcut: '/toc',
command: (e) => { insertOutlineBlock(e) },
},
{
title: 'Link Preview', description: 'Embed a URL as a rich card', icon: Link2, category: 'Basic blocks', shortcut: '/link',
command: (e) => {
window.dispatchEvent(new CustomEvent('memento-open-link-preview'))
},
},
{
title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$',
command: (e) => { insertMathEquation(e) },
},
{
title: 'Columns', description: 'Side-by-side layout', icon: Columns3, category: 'Basic blocks', shortcut: '/cols',
command: (e) => { insertColumnsBlock(e, 2) },
},
]
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, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
const [blockMenuState, setBlockMenuState] = useState<{
anchor: DOMRect
pos: number
node: PMNode | null
} | null>(null)
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
const smartPastePendingRef = useRef<{
reference: ParsedBlockReference
blockPos: number
blockNode: PMNode
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
} | null>(null)
const [smartPasteMenu, setSmartPasteMenu] = useState<{
anchor: { top: number; left: number }
reference: ParsedBlockReference
sourceNoteTitle?: string
} | null>(null)
const [smartPasteExtended, setSmartPasteExtended] = useState<{
type: 'url' | 'code'
text: string
anchor: { top: number; left: number }
isImage?: boolean
isVideo?: boolean
} | null>(null)
const [isMobile, setIsMobile] = useState(false)
const [actionSheetOpen, setActionSheetOpen] = useState(false)
const [showFindReplace, setShowFindReplace] = useState(false)
const [linkPreviewUrl, setLinkPreviewUrl] = useState<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 emitContentChange = useCallback((html: string) => {
lastEmittedContent.current = html
onChangeRef.current?.(html)
}, [])
useEffect(() => {
if (typeof window === 'undefined') return
const checkMobile = () => setIsMobile(window.innerWidth < 768)
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Listen to the slash-command event to open the BlockPicker
useEffect(() => {
const openHandler = () => setBlockPickerOpen(true)
window.addEventListener('memento-open-block-picker', openHandler)
return () => window.removeEventListener('memento-open-block-picker', openHandler)
}, [])
const handleSelectBlockRef = useRef<(block: BlockSuggestion) => void>(() => {})
const insertCitationRef = useRef<(payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean }) => boolean>(() => false)
useEffect(() => {
const insertHandler = (event: Event) => {
const block = (event as CustomEvent<{ block: BlockSuggestion }>).detail?.block
if (block) handleSelectBlockRef.current(block)
}
const citationHandler = (event: Event) => {
const detail = (event as CustomEvent<{ noteId: string; noteTitle: string; excerpt: string; atEnd?: boolean }>).detail
if (!detail) return
insertCitationRef.current(detail, { atEnd: detail.atEnd !== false })
}
window.addEventListener('memento-insert-live-block', insertHandler)
window.addEventListener('memento-insert-citation', citationHandler)
return () => {
window.removeEventListener('memento-insert-live-block', insertHandler)
window.removeEventListener('memento-insert-citation', citationHandler)
}
}, [])
const handleSelectNoteLink = useCallback((selected: NoteLinkOption) => {
const ed = editorInstanceRef.current
if (!ed) {
setNoteLinkPickerOpen(false)
return
}
let range = noteLinkRangeRef.current
if (!range) {
const { from } = ed.state.selection
const start = Math.max(0, from - 80)
const textBefore = ed.state.doc.textBetween(start, from, '\n', '')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
range = { from: from - match[0].length, to: from }
}
}
if (!range) {
setNoteLinkPickerOpen(false)
return
}
const title = (selected.title || t('documentInfo.network.untitled')).trim()
const href = `/home?openNote=${encodeURIComponent(selected.id)}`
ed.chain()
.focus()
.deleteRange({ from: range.from, to: range.to })
.insertContent({
type: 'text',
text: title,
marks: [{
type: 'link',
attrs: {
href,
target: '_blank',
rel: 'noopener noreferrer',
},
}],
})
.insertContent(' ')
.run()
const html = ed.getHTML()
emitContentChange(html)
setNoteLinkPickerOpen(false)
noteLinkRangeRef.current = null
if (noteId) {
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
detail: { noteId, reason: 'note-link' },
}))
}
}, [emitContentChange, noteId, t])
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Underline,
TiptapLink.configure({ openOnClick: false, autolink: true }),
Highlight.configure({ multicolor: false }),
CustomImage.configure({ inline: false, allowBase64: true }),
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
Superscript,
Subscript,
Typography,
ChartExtension,
UniqueIdExtension,
...globalDragHandleExtensions,
SmartPasteExtension,
BlockSelectionExtension,
MarkdownPasteExtension,
TurnIntoShortcutExtension,
UndoRedoFeedbackExtension,
LiveBlockExtension,
StructuredViewBlockExtension,
ToggleExtension,
CalloutExtension,
OutlineExtension,
FindReplaceExtension,
LinkPreviewExtension,
MathEquationExtension,
InlineMathExtension,
ColumnsExtension,
ColumnNode,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
const level = node.attrs.level
if (level === 1) return t('richTextEditor.placeholderH1') || 'Titre principal...'
if (level === 2) return t('richTextEditor.placeholderH2') || 'Titre de section...'
if (level === 3) return t('richTextEditor.placeholderH3') || 'Sous-titre...'
}
if (node.type.name === 'taskItem') {
return t('richTextEditor.placeholderTodo') || 'Ajouter une tâche...'
}
if (node.type.name === 'codeBlock') {
return t('richTextEditor.placeholderCode') || 'Écrire du code...'
}
if (node.type.name === 'blockquote') {
return t('richTextEditor.placeholderQuote') || 'Saisir une citation...'
}
return placeholder || t('richTextEditor.placeholderText') || t('richTextEditor.placeholder') || "Tapez '/' pour insérer un bloc..."
}
}),
],
content: content || '',
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: { class: 'notion-editor tiptap' },
handleDOMEvents: {
keydown: (view, event) => {
if (event.defaultPrevented) return false
if (event.key !== 'Enter' && event.key !== ' ') return false
const { from, empty } = view.state.selection
if (!empty) return false
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
if (!/\/(database|db|vue|tableau|structured)$/i.test(textBefore)) return false
event.preventDefault()
const slashIdx = textBefore.lastIndexOf('/')
const deleteFrom = from - (textBefore.length - slashIdx)
const ed = editorInstanceRef.current
if (!ed) return false
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
if (!insertStructuredViewBlockAtSelection(ed, notebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
return true
},
click: (_view, event) => {
const link = (event.target as HTMLElement).closest('a[href]')
if (!link) return false
const href = link.getAttribute('href') || ''
const noteIdMatch = href.match(/[?&]openNote=([^&#]+)/)
if (noteIdMatch) {
event.preventDefault()
event.stopPropagation()
const targetId = decodeURIComponent(noteIdMatch[1])
const blockMatch = href.match(/#block-([^&#]+)/)
openNotePeek({
noteId: targetId,
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
})
return true
}
if (href.startsWith('http://') || href.startsWith('https://')) {
event.preventDefault()
event.stopPropagation()
window.open(href, '_blank', 'noopener,noreferrer')
return true
}
return false
},
},
handlePaste: (_view, event) => {
if (!onImageUpload) return false
const items = Array.from(event.clipboardData?.items || [])
const hasImage = items.some(item => item.type.startsWith('image/'))
if (!hasImage) return false
event.preventDefault()
const imageFiles = items
.filter(item => item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
void (async () => {
for (const file of imageFiles) {
try {
toast.info(t('notes.uploading'))
const url = await onImageUpload(file)
const ed = editorInstanceRef.current
if (!ed) continue
const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(ed.getHTML())
}
} catch {
toast.error(t('notes.uploadFailed'))
}
}
})()
return true
}
},
onUpdate: ({ editor: e }) => {
emitContentChange(e.getHTML())
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) {
editor.commands.setContent(content || '')
lastEmittedContent.current = content || ''
// TipTap #7338 : dir explicite rtl sur listes (pas auto) après chargement HTML
requestAnimationFrame(() => {
applyClipRtlDirection(editor, { sourceUrl })
})
}
if (content !== undefined) {
setCurrentNoteContent(content || '')
}
}, [content, editor, sourceUrl])
// Chart suggestion handlers
const handleOpenChartSuggestions = useCallback(async () => {
if (!editor || !editor.isEditable) return
const consented = await requestAiConsent()
if (!consented) return
setChartSuggestionsOpen(true)
}, [editor, requestAiConsent])
const insertCitationInEditor = useCallback((
payload: { noteId: string; noteTitle: string; excerpt: string },
options?: { atEnd?: boolean }
) => {
if (!editor || !editor.isEditable) return false
const plainExcerpt = stripHtmlToPlainText(payload.excerpt)
if (!plainExcerpt) return false
const isRtl = detectTextDirection(`${payload.noteTitle}\n${plainExcerpt}`) === 'rtl'
const rtlAttrs = isRtl ? { dir: 'rtl' as const, lang: 'fa' as const } : {}
const chain = editor.chain()
if (options?.atEnd !== false) {
chain.focus('end')
} else {
chain.focus()
}
chain.insertContent([
{ type: 'paragraph', content: [] },
{
type: 'blockquote',
attrs: rtlAttrs.dir ? { dir: rtlAttrs.dir } : {},
content: [{
type: 'paragraph',
attrs: rtlAttrs,
content: [{ type: 'text', text: plainExcerpt }],
}],
},
{
type: 'paragraph',
attrs: rtlAttrs,
content: [
{ type: 'text', text: '— ' },
{
type: 'text',
text: payload.noteTitle,
marks: [{ type: 'link', attrs: { href: `/home?openNote=${payload.noteId}`, target: '_blank', rel: 'noopener noreferrer' } }],
},
],
},
]).scrollIntoView().run()
emitContentChange(editor.getHTML())
return true
}, [editor, emitContentChange])
const insertLiveBlockInEditor = useCallback((block: BlockSuggestion, options?: { atEnd?: boolean }) => {
if (!editor || !editor.isEditable) return false
const chain = editor.chain()
if (options?.atEnd !== false) {
chain.focus('end')
} else {
chain.focus()
}
chain.insertContent({
type: 'liveBlock',
attrs: {
sourceNoteId: block.noteId,
blockId: block.blockId,
snapshotContent: block.content,
sourceNoteTitle: block.noteTitle,
},
}).scrollIntoView().run()
emitContentChange(editor.getHTML())
return true
}, [editor, emitContentChange])
insertCitationRef.current = insertCitationInEditor
useImperativeHandle(ref, () => ({
getEditor: () => editor,
triggerChartSuggestions: () => {
if (editor) {
handleOpenChartSuggestions()
}
},
insertCitation: insertCitationInEditor,
insertLiveBlock: insertLiveBlockInEditor,
}), [editor, handleOpenChartSuggestions, insertCitationInEditor, insertLiveBlockInEditor])
const handleSelectChart = useCallback((chartContent: string) => {
if (!editor || !editor.isEditable) return
try {
console.log('[handleSelectChart] Inserting chart type:', chartContent.split('\n')[0])
// 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()
console.log('[handleSelectChart] Chart inserted after selection')
} 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())
}, [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"
{...({
tippyOptions: {
appendTo: () => document.body,
zIndex: 99999,
fallbackPlacements: ['bottom', 'top']
}
} as any)}
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection
const isImage = e.isActive('image')
return (from !== to && !e.isActive('codeBlock')) || isImage
}}
>
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
<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}
/>
)}
{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'))
}
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>
{editorState.isImage && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
</>
)}
{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: 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 }: { itemTitle: string }) {
switch (itemTitle) {
case 'Table':
case 'Tableau':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">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">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">Base de Données</div>
<div className="slash-preview-db">
<div className="slash-preview-db-row header">
<span>Nom</span>
<span>Statut</span>
</div>
<div className="slash-preview-db-row">
<span>Tâche A</span>
<span className="badge badge-todo">À faire</span>
</div>
<div className="slash-preview-db-row">
<span>Tâche B</span>
<span className="badge badge-done">Fait</span>
</div>
</div>
<p className="slash-preview-tip">Ajoutez des colonnes et des vues Kanban structurées.</p>
</div>
)
case 'Suggest Charts':
case 'Suggest Chart':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">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">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">Bloc Vivant (Transclusion)</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">Synchronisez du contenu en temps réel entre plusieurs notes.</p>
</div>
)
case 'Diagramme':
case 'Diagram':
return (
<div className="slash-preview-box">
<div className="slash-preview-title">Diagramme Excalidraw</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">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">Présentation Slides</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">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">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">Ajoutez du code avec coloration syntaxique automatique.</p>
</div>
)
default:
return null
}
}
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [activeCategory, setActiveCategory] = useState<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 menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
const menuInteracting = useRef(false)
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'embed' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{
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)
}, [])
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) {
deleteSlashText(); closeMenu(); setAiLoading(true)
try {
const consented = await requestAiConsent()
if (!consented) return
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption, t)
editor.chain().focus().setContent(result).run()
} catch (err) {
console.error('AI slash error:', err)
toastAi(err)
}
finally { setAiLoading(false) }
} else if (item.title === 'Suggest Charts') {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()
const currentNotebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent])
// Charger les favoris fréquents lors de l'ouverture
useEffect(() => {
if (!isOpen) return
try {
const usageStr = localStorage.getItem('memento-slash-command-usage') || '{}'
const usage = JSON.parse(usageStr) as Record<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 (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 && 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
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) } // reset category filter on fresh open
}
else if (isOpen) closeMenu()
}
editor.on('update', handler)
editor.on('selectionUpdate', handler)
return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) }
}, [editor, isOpen, closeMenu])
if (!isOpen || filtered.length === 0) return null
let flatIndex = -1
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
const totalVisible = sectionIds.length
const visibleSectionIds = [...sectionIds]
// Si pas de query, ajouter la section Favoris au début
const hasFavorites = !q && !activeCategory && frequentCommands.length > 0
if (hasFavorites) {
visibleSectionIds.unshift('frequent' as any)
}
const selectedItem = filtered[selectedIndex]
const showPreview = selectedItem && [
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
].includes(selectedItem.title)
return createPortal(
<>
<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) ? '★ 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} />
</div>
)}
</>,
document.body
)
}