Story 6-2 — Markdown roundtrip export/import: - lib/editor/markdown-export.ts: tiptapHTMLToMarkdown, markdownToHTML, looksLikeMarkdown - lib/editor/markdown-paste-extension.ts: TipTap extension paste Markdown → blocs - note-editor-toolbar.tsx: export .md + import .md (file picker) - rich-text-editor.tsx: intégration MarkdownPasteExtension - 40 tests unitaires markdown-export.test.ts Story 6-3 — Brainstorm PPTX + Canvas: - lib/brainstorm/export-pptx.ts: génération PPTX 5 slides (pptxgenjs) - app/api/brainstorm/[sessionId]/export-pptx/route.ts: route POST protégée - brainstorm-page.tsx: bouton PPTX, auto-select session, fix emoji, fix router.replace - wave-canvas.tsx: fitTrigger recentrage, légende bas-droite Onboarding activation wizard (Story 6-1): - components/onboarding/: wizard multi-étapes, hints éditeur - app/api/onboarding/: route PATCH onboarding - prisma/migrations: champs onboarding user Locales: 15 langues mises à jour (brainstorm, markdown, onboarding keys) Sprint: 6-1 done, 6-2 review, 6-3 review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1961 lines
84 KiB
TypeScript
1961 lines
84 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 { 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
|
|
} 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) },
|
|
},
|
|
]
|
|
|
|
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 [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', '\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,
|
|
...globalDragHandleExtensions,
|
|
SmartPasteExtension,
|
|
BlockSelectionExtension,
|
|
MarkdownPasteExtension,
|
|
TurnIntoShortcutExtension,
|
|
UndoRedoFeedbackExtension,
|
|
LiveBlockExtension,
|
|
StructuredViewBlockExtension,
|
|
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', '\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])
|
|
|
|
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 && 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)}
|
|
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'] },
|
|
{
|
|
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
|
|
)
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|