1231 lines
56 KiB
TypeScript
1231 lines
56 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 } 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 { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
|
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
|
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
|
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 { 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
|
|
} 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
|
|
}
|
|
|
|
interface RichTextEditorProps {
|
|
content?: string
|
|
onChange?: (content: string) => void
|
|
className?: string
|
|
placeholder?: string
|
|
onImageUpload?: (file: File) => Promise<string>
|
|
noteId?: 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 = 'basic' | 'media' | 'formatting' | 'ai'
|
|
|
|
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
|
|
|
|
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
|
|
|
|
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
|
|
switch (id) {
|
|
case 'basic': return t('richTextEditor.slashCatBasic')
|
|
case 'media': return t('richTextEditor.slashCatMedia')
|
|
case 'formatting': return t('richTextEditor.slashCatFormatting')
|
|
case 'ai': return t('richTextEditor.slashCatAi')
|
|
}
|
|
}
|
|
|
|
/** 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'))
|
|
}
|
|
},
|
|
]
|
|
|
|
async function aiReformulate(text: string, option: string, 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, sourceUrl }, ref) {
|
|
const { t } = useLanguage()
|
|
const { requestAiConsent } = useAiConsent()
|
|
const imageInsert = useImageInsert()
|
|
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
|
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)
|
|
}, [])
|
|
|
|
// 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', '\0')
|
|
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,
|
|
LiveBlockExtension,
|
|
ClipArticleExtension,
|
|
RtlPreserveExtension,
|
|
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
|
],
|
|
content: content || '',
|
|
immediatelyRender: false,
|
|
editorProps: {
|
|
attributes: { class: 'notion-editor' },
|
|
handleDOMEvents: {
|
|
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()
|
|
window.open(
|
|
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
|
'_blank',
|
|
'noopener,noreferrer'
|
|
)
|
|
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 editorInstance = editorInstanceRef.current
|
|
if (!editorInstance) continue
|
|
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
|
if (inserted) {
|
|
emitContentChange(editorInstance.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', '\0')
|
|
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])
|
|
|
|
// 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
|
|
|
|
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} />}
|
|
|
|
<EditorContent editor={editor} />
|
|
|
|
{imageInsert.open && (
|
|
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
|
)}
|
|
|
|
{chartSuggestionsOpen && (
|
|
<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 [, setTick] = useState(0)
|
|
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)
|
|
|
|
useEffect(() => {
|
|
if (!editor) return
|
|
const h = () => setTick(t => t + 1)
|
|
editor.on('transaction', h)
|
|
editor.on('selectionUpdate', h)
|
|
return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) }
|
|
}, [editor])
|
|
|
|
useEffect(() => {
|
|
if (linkOpen && linkInputRef.current) linkInputRef.current.focus()
|
|
}, [linkOpen])
|
|
|
|
if (!editor) return null
|
|
|
|
const marks = [
|
|
{ icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run(), title: t('richTextEditor.bold') },
|
|
{ icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run(), title: t('richTextEditor.italic') },
|
|
{ icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run(), title: t('richTextEditor.underline') },
|
|
{ icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run(), title: t('richTextEditor.strike') },
|
|
{ icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run(), title: t('richTextEditor.code') },
|
|
{ icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run(), title: t('richTextEditor.highlight') },
|
|
{ icon: SuperscriptIcon, active: editor.isActive('superscript'), action: () => editor.chain().focus().toggleSuperscript().run(), title: t('richTextEditor.superscript') },
|
|
{ icon: SubscriptIcon, active: editor.isActive('subscript'), 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, 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>
|
|
{editor.isActive('link') && (
|
|
<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', editor.isActive('link') && '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>
|
|
{editor.isActive('image') && (
|
|
<>
|
|
<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 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 [aiLoading, setAiLoading] = useState(false)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const selectedItemRef = useRef<HTMLButtonElement>(null)
|
|
const menuInteracting = useRef(false)
|
|
|
|
const localCommands: SlashMenuItem[] = [
|
|
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'basic' },
|
|
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'basic' },
|
|
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'basic' },
|
|
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'basic' },
|
|
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
|
|
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'formatting' },
|
|
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'formatting' },
|
|
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'formatting' },
|
|
{ ...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: 'formatting' },
|
|
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'formatting' },
|
|
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'formatting' },
|
|
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'formatting' },
|
|
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'formatting' },
|
|
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'formatting' },
|
|
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
|
|
{ ...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: 'basic' },
|
|
{
|
|
title: t('richTextEditor.slashNoteLink'),
|
|
description: t('richTextEditor.slashNoteLinkDesc'),
|
|
icon: Link2,
|
|
categoryId: 'basic' 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) => {
|
|
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)
|
|
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 {
|
|
deleteSlashText(); item.command(editor); closeMenu()
|
|
}
|
|
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t])
|
|
|
|
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
|
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
|
|
|
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
|
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
|
|
|
|
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 = filtered.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])
|
|
|
|
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)
|
|
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])
|
|
|
|
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])
|
|
|
|
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
|
|
|
|
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 && sectionIds.map((catId) => {
|
|
const items = categories[catId]!
|
|
return (
|
|
<div key={catId} className="notion-slash-section">
|
|
<div className={cn('notion-slash-label', catId === 'ai' && 'notion-slash-label-ai')}>
|
|
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
|
|
{slashCategoryLabel(catId, t)}
|
|
</div>
|
|
{items.map((item) => {
|
|
flatIndex++
|
|
const idx = flatIndex
|
|
const isSelected = idx === selectedIndex
|
|
return (
|
|
<button
|
|
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
|
|
ref={isSelected ? selectedItemRef : null}
|
|
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
|
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>,
|
|
document.body
|
|
)
|
|
}
|
|
|
|
|
|
|
|
|
|
|