'use client'; /** * NotionEditor — Notion-style WYSIWYG built on TipTap v3. * * Composition (vs `MarkdownEditor`): * - StarterKit (with H1-H4) + Placeholder + Markdown serialiser * - TaskList + TaskItem (GFM `- [ ]`) * - Table (+ Row/Header/Cell) * - CodeBlockLowlight (syntax highlight via lowlight's `common` pack) * - Highlight (`==text==`) * - GlobalDragHandle (Notion-style block grabbers) * - SlashExtension (own `/` menu — see SlashList.tsx) * - BubbleMenu (floating selection toolbar) * * Why a separate component instead of a variant on MarkdownEditor: * - Different baseline (no mentions, no slash-as-chip extension that * the chat composer depends on). * - ~30KB more deps (tables, lowlight, drag-handle) — would punish * every chat composer mount if added to the shared editor. * - Lazy chunk boundary stays clean — Skills / chat composer keep * their slim TipTap; document-preview pulls the heavy stack. */ import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import { BubbleMenu } from '@tiptap/react/menus'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Bold, Italic, Strikethrough, Code as CodeIcon, Highlighter, Link as LinkIcon, Underline as UnderlineIcon, type LucideIcon, } from 'lucide-react'; import { Kbd, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core'; import { useHotkey } from '@djangocfg/ui-core/hooks'; import { notionExtensions } from './extensions'; import { LinkDialog } from './LinkDialog'; import type { NotionEditorHandle, NotionEditorProps } from './types'; import './styles.css'; // Same markdown helper as MarkdownEditor — TipTap v3 augments Editor with // `getMarkdown()` when @tiptap/markdown is registered. interface MarkdownManager { serialize: (json: Record) => string; } function getMarkdown(editor: Editor): string { const withMd = editor as Editor & { getMarkdown?: () => string }; if (typeof withMd.getMarkdown === 'function') return withMd.getMarkdown(); const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined; if (!storage?.manager) return editor.getText(); return storage.manager.serialize(editor.getJSON()); } export const NotionEditor = forwardRef( function NotionEditor( { value, onChange, placeholder = "Type '/' for commands…", disabled = false, autoFocus = false, onSave, className = '', minHeight = 320, resolveLinkPreview, }, ref, ) { const isExternalUpdate = useRef(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false); // Build extensions once. Placeholder is captured by closure on first // render — same constraint as MarkdownEditor; mentions / slash items // don't change at runtime here. The link-preview resolver is likewise // captured on first build; pass a stable reference (it's a host method). const extensions = useMemo( () => notionExtensions({ placeholder, resolveLinkPreview }), [placeholder, resolveLinkPreview], ); const editor = useEditor({ immediatelyRender: false, editable: !disabled, extensions, content: value, contentType: 'markdown', onUpdate: ({ editor }) => { if (isExternalUpdate.current) return; onChange(getMarkdown(editor)); }, editorProps: { attributes: { class: 'notion-editor-content focus:outline-none', style: `min-height: ${minHeight}px`, }, }, }); // Sync external value → editor without looping back into onChange. useEffect(() => { if (!editor) return; const current = getMarkdown(editor); if (current === value) return; isExternalUpdate.current = true; editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false, }); isExternalUpdate.current = false; }, [value, editor]); useEffect(() => { editor?.setEditable(!disabled); }, [editor, disabled]); // Declarative autoFocus. useEffect(() => { if (!autoFocus || !editor) return; editor.commands.focus('end'); }, [autoFocus, editor]); // Cmd/Ctrl+S → save, scoped to the editor DOM via guard. const onSaveRef = useRef(onSave); onSaveRef.current = onSave; useHotkey( 'mod+s', () => { const h = onSaveRef.current; if (!h || !editor) return; const dom = editor.view.dom; const active = document.activeElement; if (!active || !dom.contains(active)) return; h(getMarkdown(editor)); }, { enabled: !!editor && !!onSave }, ); // Cmd/Ctrl+K → link prompt. Same focus-scope guard so we don't // collide with global command palettes higher up the tree. useHotkey( 'mod+k', () => { if (!editor) return; const dom = editor.view.dom; const active = document.activeElement; if (!active || !dom.contains(active)) return; setLinkDialogOpen(true); }, { enabled: !!editor }, ); useImperativeHandle( ref, (): NotionEditorHandle => ({ focus: () => { editor?.commands.focus(); }, moveCursorToEnd: () => { editor?.commands.focus('end'); }, getEditor: () => editor ?? null, }), [editor], ); return (
{editor ? ( <> setLinkDialogOpen(true)} /> ) : null}
); }, ); interface BubbleItem { icon: LucideIcon; title: string; /** Keyboard shortcut to display in the tooltip. macOS-style (⌘ B). */ shortcut: readonly string[]; isActive: () => boolean; run: () => void; } /** * Floating selection toolbar. `@tiptap/extension-bubble-menu` anchors * itself to the current selection; it auto-hides when the selection * collapses, so we only need to declare the buttons. Tooltips show the * keyboard chord — Tiptap installs the shortcuts itself (StarterKit). */ function BubbleSelectionToolbar({ editor, onOpenLink, }: { editor: Editor; onOpenLink: () => void; }) { const items: BubbleItem[] = [ { icon: Bold, title: 'Bold', shortcut: ['⌘', 'B'], isActive: () => editor.isActive('bold'), run: () => editor.chain().focus().toggleBold().run(), }, { icon: Italic, title: 'Italic', shortcut: ['⌘', 'I'], isActive: () => editor.isActive('italic'), run: () => editor.chain().focus().toggleItalic().run(), }, { icon: UnderlineIcon, title: 'Underline', shortcut: ['⌘', 'U'], isActive: () => editor.isActive('underline'), run: () => editor.chain().focus().toggleUnderline().run(), }, { icon: Strikethrough, title: 'Strike', shortcut: ['⌘', '⇧', 'X'], isActive: () => editor.isActive('strike'), run: () => editor.chain().focus().toggleStrike().run(), }, { icon: CodeIcon, title: 'Inline code', shortcut: ['⌘', 'E'], isActive: () => editor.isActive('code'), run: () => editor.chain().focus().toggleCode().run(), }, { icon: Highlighter, title: 'Highlight', shortcut: ['⌘', '⇧', 'H'], isActive: () => editor.isActive('highlight'), run: () => editor.chain().focus().toggleHighlight().run(), }, { icon: LinkIcon, title: 'Link', shortcut: ['⌘', 'K'], isActive: () => editor.isActive('link'), run: onOpenLink, }, ]; return ( { if (from === to) return false; if (editor.isActive('codeBlock')) return false; const text = editor.state.doc.textBetween(from, to, ' ').trim(); return text.length > 0; }} > {items.map((item) => { const Icon = item.icon; const active = item.isActive(); return ( {item.title} {item.shortcut.map((k) => ( {k} ))} ); })} ); }