import { EditorState } from 'prosemirror-state'; import { MarkType, Node as PMNode } from 'prosemirror-model'; import { undo, redo } from 'prosemirror-history'; import type { Alignment } from './schema'; import { getMarkRange, selectedImageInfo, selectedEmbedInfo } from './commands'; /** * Immutable snapshot of everything the toolbar needs to render. Computed once * per transaction in the host's dispatchTransaction and passed down as a * single reactive property. */ export interface ToolbarState { bold: boolean; italic: boolean; underline: boolean; strike: boolean; subscript: boolean; superscript: boolean; code: boolean; /** 'paragraph' | 'heading' for the innermost textblock at the cursor. */ blockType: string; headingLevel: number | null; align: Alignment | null; /** Line height ('1.5') of the textblock at the cursor, null = default. */ lineHeight: string | null; inBulletList: boolean; inOrderedList: boolean; inTaskList: boolean; inBlockquote: boolean; inTable: boolean; /** The surrounding table is a layout (presentation) table. */ tableLayout: boolean; /** Margin-indent level (0..MAX_INDENT) of the textblock at the cursor. */ blockIndent: number; /** list-style-type of the surrounding list ('' = default). */ listStyle: string | null; /** Start number of the surrounding ordered list. */ listStart: number | null; /** An image or figure is selected (or the cursor is in a caption). */ imageSelected: boolean; /** The selected image is a captioned figure. */ imageIsFigure: boolean; /** Alignment of the selected figure. */ imageAlign: string | null; /** A video embed is selected. */ embedSelected: boolean; /** Alignment of the selected embed. */ embedAlign: string | null; inCodeBlock: boolean; /** Language attr of the surrounding code block ('' = auto). */ codeBlockLanguage: string | null; canUndo: boolean; canRedo: boolean; linkActive: boolean; linkHref: string | null; textColor: string | null; bgColor: string | null; fontFamily: string | null; fontSize: string | null; /** True when the selection is a cursor (no range). */ selectionEmpty: boolean; /** * Whether the host's "paste as plain text" toggle is on. Not derivable from * editor state, so the host injects it after computeToolbarState(). */ pasteAsPlainText?: boolean; /** Whether the host is in fullscreen mode. Injected like pasteAsPlainText. */ fullscreen?: boolean; /** Whether block boundaries are outlined. Injected like pasteAsPlainText. */ showBlocks?: boolean; /** Whether the format painter is armed. Injected like pasteAsPlainText. */ formatPainter?: boolean; } function markActive(state: EditorState, type: MarkType): boolean { const { from, $from, to, empty } = state.selection; if (empty) return !!type.isInSet(state.storedMarks || $from.marks()); return state.doc.rangeHasMark(from, to, type); } function markAttr(state: EditorState, type: MarkType, attr: string): string | null { const { $from, from, to, empty } = state.selection; if (empty) { const mark = type.isInSet(state.storedMarks || $from.marks()); return mark ? ((mark.attrs[attr] as string) ?? null) : null; } // Range: report the first occurrence of the mark inside the selection. // (Boundary marks() lookups miss non-inclusive marks like link.) let found: string | null = null; state.doc.nodesBetween(from, to, node => { if (found !== null || !node.isText) return found === null; const mark = type.isInSet(node.marks); if (mark) found = (mark.attrs[attr] as string) ?? null; return found === null; }); return found; } export function computeToolbarState(state: EditorState): ToolbarState { const { schema } = state; const { $from, from, to, empty } = state.selection; // $from.parent is the doc (not a textblock) for AllSelection / block // NodeSelections; report the first textblock in the range instead so the // toolbar reflects what's actually selected. let parent = $from.parent; if (!parent.isTextblock) { let firstTextblock: PMNode | null = null; state.doc.nodesBetween(from, to, node => { if (!firstTextblock && node.isTextblock) firstTextblock = node; return !firstTextblock; }); if (firstTextblock) parent = firstTextblock; } const blockType = parent.type.name; const headingLevel = blockType === 'heading' ? (parent.attrs.level as number) : null; const align = parent.type.spec.attrs && 'align' in parent.type.spec.attrs ? ((parent.attrs.align as Alignment) ?? null) : null; let inBulletList = false; let inOrderedList = false; let inTaskList = false; let inBlockquote = false; let inTable = false; let tableLayout = false; let listStyle: string | null = null; let listStart: number | null = null; for (let depth = $from.depth; depth > 0; depth -= 1) { const node = $from.node(depth); if (node.type === schema.nodes.bullet_list) { inBulletList = true; if (listStyle === null) listStyle = (node.attrs.style as string) ?? null; } else if (node.type === schema.nodes.ordered_list) { inOrderedList = true; if (listStyle === null) listStyle = (node.attrs.style as string) ?? null; if (listStart === null) listStart = (node.attrs.order as number) ?? 1; } else if (node.type === schema.nodes.task_list) inTaskList = true; else if (node.type === schema.nodes.blockquote) inBlockquote = true; else if (node.type === schema.nodes.table) { inTable = true; tableLayout = !!node.attrs.layout; } } const imageInfo = selectedImageInfo(state); const embedInfo = selectedEmbedInfo(state); const linkActive = markActive(state, schema.marks.link); let linkHref: string | null = null; if (linkActive) { linkHref = markAttr(state, schema.marks.link, 'href'); if (linkHref === null && empty) { const range = getMarkRange($from, schema.marks.link); if (range) { const mark = schema.marks.link.isInSet( state.doc.resolve(range.from + 1).marks() ); linkHref = mark ? (mark.attrs.href as string) : null; } } } return { bold: markActive(state, schema.marks.bold), italic: markActive(state, schema.marks.italic), underline: markActive(state, schema.marks.underline), strike: markActive(state, schema.marks.strike), subscript: markActive(state, schema.marks.subscript), superscript: markActive(state, schema.marks.superscript), code: markActive(state, schema.marks.code), blockType, headingLevel, align, lineHeight: parent.type.spec.attrs && 'lineHeight' in parent.type.spec.attrs ? ((parent.attrs.lineHeight as string) ?? null) : null, inBulletList, inOrderedList, inTaskList, inBlockquote, inTable, tableLayout, blockIndent: parent.type.spec.attrs && 'indent' in parent.type.spec.attrs ? ((parent.attrs.indent as number) ?? 0) : 0, listStyle, listStart, imageSelected: imageInfo !== null, imageIsFigure: imageInfo?.isFigure ?? false, imageAlign: imageInfo?.isFigure ? ((imageInfo.node.attrs.align as string) ?? null) : null, embedSelected: embedInfo !== null, embedAlign: (embedInfo?.node.attrs.align as string) ?? null, inCodeBlock: blockType === 'code_block', codeBlockLanguage: blockType === 'code_block' ? ((parent.attrs.language as string) ?? null) : null, canUndo: undo(state), canRedo: redo(state), linkActive, linkHref, textColor: markAttr(state, schema.marks.text_color, 'color'), bgColor: markAttr(state, schema.marks.bg_color, 'color'), fontFamily: markAttr(state, schema.marks.font_family, 'family'), fontSize: markAttr(state, schema.marks.font_size, 'size'), selectionEmpty: empty, }; }