diff --git a/app/components/editor/TiptapArticleEditor.tsx b/app/components/editor/TiptapArticleEditor.tsx index 8e279b2..2586a8f 100644 --- a/app/components/editor/TiptapArticleEditor.tsx +++ b/app/components/editor/TiptapArticleEditor.tsx @@ -12,6 +12,7 @@ import CodeBlockView from './nodeviews/CodeBlockView' import ImageView from './nodeviews/ImageView' import MathBlockView from './nodeviews/MathBlockView' import MathInlineView from './nodeviews/MathInlineView' +import TiptapBubbleMenu from './TiptapBubbleMenu' import TiptapToolbar from './TiptapToolbar' import './tiptap-editor.css' @@ -141,6 +142,7 @@ export default function TiptapArticleEditor({ )} + {editor && } (null) + // Re-render on transactions so active states track the selection. + const [, bump] = useReducer((x: number) => x + 1, 0) + + useEffect(() => { + const reset = () => setLinkMode(false) + editor.on('transaction', bump) + editor.on('selectionUpdate', reset) + return () => { + editor.off('transaction', bump) + editor.off('selectionUpdate', reset) + } + }, [editor]) + + useEffect(() => { + if (linkMode) linkInputRef.current?.focus() + }, [linkMode]) + + const applyLink = () => { + const url = linkDraft.trim() + if (!url) { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + } else { + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + } + setLinkMode(false) + } + + const marks = [ + { id: 'bold', label: 'Bold', icon: BoldIcon, action: () => editor.chain().focus().toggleBold().run(), active: () => editor.isActive('bold') }, + { id: 'italic', label: 'Italic', icon: ItalicIcon, action: () => editor.chain().focus().toggleItalic().run(), active: () => editor.isActive('italic') }, + { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, action: () => editor.chain().focus().toggleStrike().run(), active: () => editor.isActive('strike') }, + { id: 'code', label: 'Inline Code', icon: CodeBracketIcon, action: () => editor.chain().focus().toggleCode().run(), active: () => editor.isActive('code') }, + ] + + return ( + { + if (!instance.isEditable) return false + const { selection } = state + // Node selections (images, math, diagrams) have their own affordances. + if (selection instanceof NodeSelection) return false + if (instance.isActive('codeBlock')) return false + if (!selection.empty) return true + return instance.isActive('link') + }} + className="flex items-center gap-0.5 rounded-lg border border-stone-200 dark:border-stone-600 bg-white dark:bg-stone-800 p-1 shadow-lg" + > + {linkMode ? ( +
+ setLinkDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + applyLink() + } else if (e.key === 'Escape') { + e.preventDefault() + setLinkMode(false) + editor.commands.focus() + } + }} + placeholder="https://…" + className="w-56 rounded border border-stone-200 dark:border-stone-600 bg-white dark:bg-stone-900 px-2 py-1 text-xs text-stone-900 dark:text-white placeholder-stone-400 focus:outline-none focus:ring-1 focus:ring-stone-900 dark:focus:ring-stone-100" + aria-label="Link URL" + /> + + {editor.isActive('link') && ( + + )} +
+ ) : ( + <> + {marks.map((mark) => { + const IconComponent = mark.icon + const active = mark.active() + return ( + + ) + })} + + + )} +
+ ) +} diff --git a/package.json b/package.json index 8f491c9..4a424c7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@react-router/node": "7.15.0", "@react-router/serve": "7.15.0", "@tiptap/core": "3.28.0", + "@tiptap/extension-bubble-menu": "3.28.0", "@tiptap/extension-code": "3.28.0", "@tiptap/extension-code-block": "3.28.0", "@tiptap/extension-code-block-lowlight": "3.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b0a11f..5376bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@tiptap/core': specifier: 3.28.0 version: 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/extension-bubble-menu': + specifier: 3.28.0 + version: 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) '@tiptap/extension-code': specifier: 3.28.0 version: 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0)) @@ -2655,6 +2658,12 @@ packages: peerDependencies: '@tiptap/core': 3.29.2 + '@tiptap/extension-bubble-menu@3.28.0': + resolution: {integrity: sha512-7AUNoHj2K4XLKCgW4uspeD/ENejPu2BeHvLTsiOoIO+XXDNSi2j0bbaIS8f2s/qnFirscwp/sbznp1qph/76qg==} + peerDependencies: + '@tiptap/core': 3.28.0 + '@tiptap/pm': 3.28.0 + '@tiptap/extension-bubble-menu@3.29.2': resolution: {integrity: sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==} peerDependencies: @@ -8907,6 +8916,12 @@ snapshots: dependencies: '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/extension-bubble-menu@3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.28.0(@tiptap/pm@3.28.0) + '@tiptap/pm': 3.28.0 + '@tiptap/extension-bubble-menu@3.29.2(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0)': dependencies: '@floating-ui/dom': 1.8.0