/** * Editor toolbar — issues Tiptap chain commands (no execCommand). * * Active state is read from the live editor via `useEditorState`, so toggles * accurately reflect the current selection without manually re-rendering. */ import { useState, type ReactNode } from 'react' import { type Editor, useEditorState } from '@tiptap/react' import { AlignCenter, AlignLeft, AlignRight, Bold, Heading1, Heading2, Heading3, Highlighter, Indent, Italic, Link as LinkIcon, List, ListOrdered, Minus, Outdent, Palette, Quote, Redo2, RemoveFormatting, Strikethrough, Underline as UnderlineIcon, Undo2, } from 'lucide-react' const ZOOM_PRESETS = [ { label: '75%', value: 0.75 }, { label: '100%', value: 1 }, { label: '125%', value: 1.25 }, { label: '150%', value: 1.5 }, ] const TEXT_COLOR_OPTIONS = [ { label: 'Default', value: '' }, { label: 'Gray', value: '#6b7280' }, { label: 'Red', value: '#dc2626' }, { label: 'Orange', value: '#ea580c' }, { label: 'Yellow', value: '#ca8a04' }, { label: 'Green', value: '#16a34a' }, { label: 'Blue', value: '#2563eb' }, { label: 'Purple', value: '#7c3aed' }, ] const HIGHLIGHT_OPTIONS = [ { label: 'None', value: '' }, { label: 'Blue', value: '#bfdbfe' }, { label: 'Yellow', value: '#fde68a' }, { label: 'Green', value: '#bbf7d0' }, { label: 'Pink', value: '#fbcfe8' }, ] function Divider() { return (
) } function ToolbarButton({ onClick, disabled, active, title, children, }: { onClick: () => void disabled?: boolean active?: boolean title: string children: ReactNode }) { return ( ) } export interface DocumentsTiptapToolbarProps { editor: Editor | null disabled?: boolean canvasZoom: number onCanvasZoomChange: (z: number) => void } const EMPTY_TOOLBAR_STATE = { canUndo: false, canRedo: false, isBold: false, isItalic: false, isUnderline: false, isStrike: false, isH1: false, isH2: false, isH3: false, isBlockquote: false, isBulletList: false, isOrderedList: false, alignLeft: false, alignCenter: false, alignRight: false, } export function DocumentsTiptapToolbar({ editor, disabled, canvasZoom, onCanvasZoomChange, }: DocumentsTiptapToolbarProps) { const [zoomOpen, setZoomOpen] = useState(false) const [colorOpen, setColorOpen] = useState(false) const [highlightOpen, setHighlightOpen] = useState(false) const iconSize = 'h-4 w-4' /** * Read live editor state so toggles reflect the actual selection. Falls * back to no-op flags when `editor` is null (initial render before the * Tiptap instance is ready) or when the underlying ProseMirror view has * been torn down — that happens transiently during StrictMode double-mount * in dev and when `docId` changes (which swaps the Y.Doc and forces the * editor to be re-created). Calling `e.can()` on a destroyed editor throws * "Cannot read properties of null (reading 'can')" inside Tiptap. */ const state = useEditorState({ editor, selector: ({ editor: e }) => { if (!e || e.isDestroyed || !e.view) { return EMPTY_TOOLBAR_STATE } try { return { canUndo: e.can().chain().undo().run(), canRedo: e.can().chain().redo().run(), isBold: e.isActive('bold'), isItalic: e.isActive('italic'), isUnderline: e.isActive('underline'), isStrike: e.isActive('strike'), isH1: e.isActive('heading', { level: 1 }), isH2: e.isActive('heading', { level: 2 }), isH3: e.isActive('heading', { level: 3 }), isBlockquote: e.isActive('blockquote'), isBulletList: e.isActive('bulletList'), isOrderedList: e.isActive('orderedList'), alignLeft: e.isActive({ textAlign: 'left' }), alignCenter: e.isActive({ textAlign: 'center' }), alignRight: e.isActive({ textAlign: 'right' }), } } catch { return EMPTY_TOOLBAR_STATE } }, }) ?? EMPTY_TOOLBAR_STATE /** Formatting actions require a mounted editor and an enabled connection/role. */ const formattingLocked = disabled || !editor const run = (fn: (chain: ReturnType) => unknown) => { if (!editor || disabled) return fn(editor.chain().focus()) } const normalizedZoom = ZOOM_PRESETS.find((p) => Math.abs(p.value - canvasZoom) < 0.001)?.label ?? `${Math.round(canvasZoom * 100)}%` return (
run((c) => c.undo().run())} disabled={formattingLocked || !state.canUndo} title="Undo (⌘Z)" > run((c) => c.redo().run())} disabled={formattingLocked || !state.canRedo} title="Redo (⌘⇧Z)" > run((c) => c.toggleHeading({ level: 1 }).run())} disabled={formattingLocked} active={state.isH1} title="Heading 1" > run((c) => c.toggleHeading({ level: 2 }).run())} disabled={formattingLocked} active={state.isH2} title="Heading 2" > run((c) => c.toggleHeading({ level: 3 }).run())} disabled={formattingLocked} active={state.isH3} title="Heading 3" > run((c) => c.toggleBold().run())} disabled={formattingLocked} active={state.isBold} title="Bold (⌘B)" > run((c) => c.toggleItalic().run())} disabled={formattingLocked} active={state.isItalic} title="Italic (⌘I)" > run((c) => c.toggleUnderline().run())} disabled={formattingLocked} active={state.isUnderline} title="Underline (⌘U)" > run((c) => c.toggleStrike().run())} disabled={formattingLocked} active={state.isStrike} title="Strikethrough" >
{colorOpen && !formattingLocked ? ( <>
) : null}
{highlightOpen && !formattingLocked ? ( <>
) : null}
run((c) => c.toggleBlockquote().run())} disabled={formattingLocked} active={state.isBlockquote} title="Block quote" > run((c) => c.setTextAlign('left').run())} disabled={formattingLocked} active={state.alignLeft} title="Align left" > run((c) => c.setTextAlign('center').run())} disabled={formattingLocked} active={state.alignCenter} title="Align center" > run((c) => c.setTextAlign('right').run())} disabled={formattingLocked} active={state.alignRight} title="Align right" > run((c) => c.toggleBulletList().run())} disabled={formattingLocked} active={state.isBulletList} title="Bulleted list" > run((c) => c.toggleOrderedList().run())} disabled={formattingLocked} active={state.isOrderedList} title="Numbered list" > { if (formattingLocked || !editor) return const previousUrl = editor.getAttributes('link').href as string | undefined const url = window.prompt('Link URL', previousUrl ?? '') if (url === null) return if (url === '') { run((c) => c.extendMarkRange('link').unsetLink().run()) return } run((c) => c.extendMarkRange('link').setLink({ href: url }).run()) }} disabled={formattingLocked} title="Link" > run((c) => c.setHorizontalRule().run())} disabled={formattingLocked} title="Horizontal rule" > run((c) => c.sinkListItem('listItem').run())} disabled={formattingLocked} title="Indent list item" > run((c) => c.liftListItem('listItem').run())} disabled={formattingLocked} title="Outdent list item" > run((c) => c.unsetAllMarks().clearNodes().run())} disabled={formattingLocked} title="Clear formatting" >
{zoomOpen && ( <> ))}
)} ) }