import { toggleMark, setBlockType, wrapIn, lift, chainCommands } from 'prosemirror-commands'; import { wrapInList, liftListItem, sinkListItem } from 'prosemirror-schema-list'; import { undo, redo } from 'prosemirror-history'; import { addRowBefore, addRowAfter, addColumnBefore, addColumnAfter, deleteRow, deleteColumn, deleteTable, mergeCells, splitCell, toggleHeaderRow, } from 'prosemirror-tables'; import { Command, EditorState, TextSelection, NodeSelection } from 'prosemirror-state'; import { MarkType, NodeType, ResolvedPos, Attrs, Schema, Node as PMNode, Mark } from 'prosemirror-model'; import type { Alignment } from './schema'; import { MAX_INDENT, MULTILEVEL_STYLE } from './schema'; export type CommandName = | 'bold' | 'italic' | 'underline' | 'strike' | 'subscript' | 'superscript' | 'code' | 'paragraph' | 'heading' | 'blockquote' | 'align' | 'lineHeight' | 'bulletList' | 'orderedList' | 'taskList' | 'indent' | 'outdent' | 'setListStyle' | 'setListStart' | 'toggleImageCaption' | 'setImageAlign' | 'setImageWidth' | 'deleteImage' | 'updateImage' | 'setEmbedAlign' | 'deleteEmbed' | 'link' | 'unlink' | 'insertBookmark' | 'textColor' | 'bgColor' | 'fontFamily' | 'fontSize' | 'horizontalRule' | 'clearFormatting' | 'undo' | 'redo' | 'codeBlock' | 'setCodeBlockLanguage' | 'insertEmbed' | 'insertImage' | 'insertTable' | 'addRowBefore' | 'addRowAfter' | 'addColumnBefore' | 'addColumnAfter' | 'deleteRow' | 'deleteColumn' | 'mergeCells' | 'splitCell' | 'toggleHeaderRow' | 'toggleLayoutTable' | 'deleteTable' | 'changeCase' | 'insertMergeField' | 'insertMath'; export interface CommandAttrs { level?: number; align?: Alignment | null; href?: string; title?: string; target?: string | null; color?: string | null; family?: string | null; size?: string | null; /** insertImage / updateImage */ src?: string; alt?: string | null; /** insertImage / updateImage: figcaption text; empty/null = plain inline image */ caption?: string | null; /** insertTable */ rows?: number; cols?: number; withHeaderRow?: boolean; /** codeBlock */ language?: string | null; /** insertEmbed */ provider?: string | null; /** setListStyle: a CSS list-style-type, null = default */ style?: string | null; /** setListStart */ start?: number; /** setImageWidth: px, null = natural size */ width?: number | null; /** lineHeight: unitless multiplier ('1.5'), null = default */ lineHeight?: string | null; /** insertBookmark / insertMergeField: the anchor or field id */ id?: string; /** changeCase: which transform to apply */ textCase?: 'upper' | 'lower' | 'title' | 'sentence'; /** insertMergeField: human-readable label */ label?: string | null; /** insertMath: LaTeX source */ latex?: string; /** insertMath: inline vs block presentation */ mathDisplay?: 'inline' | 'block'; } /** Finds the extent of a mark around a resolved position (cursor in a link). */ export function getMarkRange( $pos: ResolvedPos, markType: MarkType ): { from: number; to: number } | null { const { parent, parentOffset } = $pos; const start = parent.childAfter(parentOffset); if (!start.node || !markType.isInSet(start.node.marks)) return null; let startIndex = start.index; let from = $pos.start() + start.offset; while (startIndex > 0 && markType.isInSet(parent.child(startIndex - 1).marks)) { startIndex -= 1; from -= parent.child(startIndex).nodeSize; } let endIndex = start.index + 1; let to = from + start.node.nodeSize; while (endIndex < parent.childCount && markType.isInSet(parent.child(endIndex).marks)) { to += parent.child(endIndex).nodeSize; endIndex += 1; } return { from, to }; } /** * Sets (attrs given) or removes (attrs null) an attribute-carrying mark such * as text color or font size. On an empty selection the mark is stored so it * applies to the next typed text. */ function setAttrMark(markType: MarkType, attrs: Attrs | null): Command { return (state, dispatch) => { const { empty, ranges, $from } = state.selection; if (empty && !(state.selection instanceof TextSelection && state.selection.$cursor)) { return false; } if (!empty && !state.selection.content().content.firstChild) return false; if (dispatch) { const tr = state.tr; if (empty) { tr.removeStoredMark(markType); if (attrs) tr.addStoredMark(markType.create(attrs)); } else { for (const range of ranges) { tr.removeMark(range.$from.pos, range.$to.pos, markType); if (attrs) tr.addMark(range.$from.pos, range.$to.pos, markType.create(attrs)); } tr.scrollIntoView(); } dispatch(tr); } return true; }; } /** Applies a link mark over the selection, or over the link under the cursor. */ function setLink(markType: MarkType, attrs: CommandAttrs): Command { return (state, dispatch) => { if (!attrs.href) return false; const { empty, $from, from, to } = state.selection; let range = { from, to }; if (empty) { const existing = getMarkRange($from, markType); if (!existing) return false; range = existing; } if (dispatch) { const tr = state.tr; tr.removeMark(range.from, range.to, markType); tr.addMark( range.from, range.to, markType.create({ href: attrs.href, title: attrs.title ?? null, target: attrs.target ?? null, }) ); dispatch(tr.scrollIntoView()); } return true; }; } /** Removes the link mark from the selection or the link under the cursor. */ function unsetLink(markType: MarkType): Command { return (state, dispatch) => { const { empty, $from, from, to } = state.selection; let range = { from, to }; if (empty) { const existing = getMarkRange($from, markType); if (!existing) return false; range = existing; } else if (!state.doc.rangeHasMark(from, to, markType)) { return false; } if (dispatch) { dispatch(state.tr.removeMark(range.from, range.to, markType)); } return true; }; } function findParentList( state: EditorState ): { node: import('prosemirror-model').Node; pos: number; depth: number } | null { const { $from } = state.selection; const { bullet_list: bullet, ordered_list: ordered, task_list: task } = state.schema.nodes; for (let depth = $from.depth; depth > 0; depth -= 1) { const node = $from.node(depth); if (node.type === bullet || node.type === ordered || node.type === task) { return { node, pos: $from.before(depth), depth }; } } return null; } /** The item node type a given list type expects as children. */ function itemTypeFor(listType: NodeType): NodeType { const { schema } = listType; return listType === schema.nodes.task_list ? schema.nodes.task_item : schema.nodes.list_item; } /** * Toggles a list: wraps when outside, lifts when already in the same list * type, converts in place when in another list type. Conversion also remaps * the item nodes (list_item <-> task_item) so the content stays valid. */ function toggleList(listType: NodeType, itemType: NodeType): Command { return (state, dispatch, view) => { const parentList = findParentList(state); if (parentList) { if (parentList.node.type === listType) { return liftListItem(itemTypeFor(parentList.node.type))(state, dispatch, view); } if (dispatch) { // Build the fully converted list and swap it in one step — converting // the list node or its items separately fails content validation. const fromItem = itemTypeFor(parentList.node.type); const items: import('prosemirror-model').Node[] = []; parentList.node.forEach(child => { items.push( child.type === fromItem && fromItem !== itemType ? itemType.create(null, child.content, child.marks) : child ); }); const converted = listType.create(parentList.node.attrs, items); const tr = state.tr; const { from, to } = state.selection; tr.replaceWith( parentList.pos, parentList.pos + parentList.node.nodeSize, converted ); // Item node sizes are identical, so the old positions stay valid. tr.setSelection(TextSelection.create(tr.doc, from, to)); dispatch(tr); } return true; } return wrapInList(listType)(state, dispatch, view); }; } function isInNode(state: EditorState, type: NodeType): boolean { const { $from } = state.selection; for (let depth = $from.depth; depth > 0; depth -= 1) { if ($from.node(depth).type === type) return true; } return false; } /** Wraps in blockquote, or lifts out when already inside one. */ function toggleBlockquote(type: NodeType): Command { return (state, dispatch, view) => { if (isInNode(state, type)) return lift(state, dispatch, view); return wrapIn(type)(state, dispatch, view); }; } /** Sets the `align` attribute on every textblock in the selection. */ function setAlignment(align: Alignment | null): Command { return (state, dispatch) => { const { from, to } = state.selection; const targets: { pos: number; node: import('prosemirror-model').Node }[] = []; state.doc.nodesBetween(from, to, (node, pos) => { if (node.isTextblock && node.type.spec.attrs && 'align' in node.type.spec.attrs) { targets.push({ pos, node }); } return true; }); if (targets.length === 0) return false; if (dispatch) { const tr = state.tr; for (const { pos, node } of targets) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, align }); } dispatch(tr); } return true; }; } /** Sets the `lineHeight` attribute on every textblock in the selection. */ function setLineHeight(lineHeight: string | null): Command { return (state, dispatch) => { const { from, to } = state.selection; const targets: { pos: number; node: PMNode }[] = []; state.doc.nodesBetween(from, to, (node, pos) => { if (node.isTextblock && node.type.spec.attrs && 'lineHeight' in node.type.spec.attrs) { targets.push({ pos, node }); } return true; }); if (targets.length === 0) return false; if (dispatch) { const tr = state.tr; for (const { pos, node } of targets) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, lineHeight }); } dispatch(tr); } return true; }; } /** Inserts a named in-text anchor (bookmark) at the selection. */ function insertBookmark(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { const id = attrs.id?.trim(); if (!id) return false; if (dispatch) { const bookmark = schema.nodes.bookmark.create({ id }); dispatch(state.tr.replaceSelectionWith(bookmark, false).scrollIntoView()); } return true; }; } /** Toggles the surrounding table between content and layout (presentation) mode. */ function toggleLayoutTable(): Command { return (state, dispatch) => { const { $from } = state.selection; for (let depth = $from.depth; depth > 0; depth -= 1) { const node = $from.node(depth); if (node.type === state.schema.nodes.table) { if (dispatch) { dispatch( state.tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, layout: !node.attrs.layout, }) ); } return true; } } return false; }; } function insertHorizontalRule(type: NodeType): Command { return (state, dispatch) => { if (dispatch) { dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView()); } return true; }; } /** Toggles between code block and paragraph. */ function toggleCodeBlock(schema: Schema, language: string | null): Command { return (state, dispatch, view) => { const { $from } = state.selection; if ($from.parent.type === schema.nodes.code_block) { return setBlockType(schema.nodes.paragraph)(state, dispatch, view); } return setBlockType(schema.nodes.code_block, { language })(state, dispatch, view); }; } /** Updates the language attr of the code block around the selection. */ function setCodeBlockLanguage(schema: Schema, language: string | null): Command { return (state, dispatch) => { const { $from } = state.selection; for (let depth = $from.depth; depth > 0; depth -= 1) { if ($from.node(depth).type === schema.nodes.code_block) { if (dispatch) { dispatch( state.tr.setNodeMarkup($from.before(depth), undefined, { language }) ); } return true; } } return false; }; } /** Inserts a provider embed. The src must already be an embed URL. */ function insertEmbed(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { if (!attrs.src) return false; const embed = schema.nodes.embed.create({ src: attrs.src, provider: attrs.provider ?? null, title: attrs.title ?? null, }); if (dispatch) { dispatch(state.tr.replaceSelectionWith(embed).scrollIntoView()); } return true; }; } /** * Inserts an image at the selection. With a non-empty caption it inserts a * captioned `figure` (like the alt/caption flow in toggleImageCaption); * otherwise a plain inline `image`. */ function insertImage(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { if (!attrs.src) return false; const src = attrs.src; const alt = attrs.alt ?? null; const title = attrs.title ?? null; const caption = attrs.caption != null ? String(attrs.caption).trim() : ''; const node = caption ? schema.nodes.figure.create({ src, alt, title, width: null, align: null }, schema.text(caption)) : schema.nodes.image.create({ src, alt, title }); if (dispatch) { dispatch(state.tr.replaceSelectionWith(node).scrollIntoView()); } return true; }; } /** * Updates the selected image/figure's src, alt and caption in place. A * non-empty caption promotes an inline image to a captioned figure; an empty * caption demotes a figure back to an inline image. Width/alignment are kept. */ function updateImage(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { const info = selectedImageInfo(state); if (!info || !attrs.src) return false; const src = attrs.src; const alt = attrs.alt ?? null; const title = attrs.title ?? null; const caption = attrs.caption != null ? String(attrs.caption).trim() : ''; const width = info.node.attrs.width ?? null; const align = info.isFigure ? info.node.attrs.align ?? null : null; if (dispatch) { const tr = state.tr; if (caption) { const figure = schema.nodes.figure.create( { src, alt, title, width, align }, schema.text(caption) ); if (info.isFigure) { tr.replaceWith(info.pos, info.pos + info.node.nodeSize, figure); tr.setSelection(NodeSelection.create(tr.doc, info.pos)); } else { const $pos = state.doc.resolve(info.pos); const parent = $pos.parent; const replaceWhole = parent.childCount === 1 && parent.type.name === 'paragraph'; const from = replaceWhole ? $pos.before() : info.pos; const to = replaceWhole ? $pos.after() : info.pos + info.node.nodeSize; tr.replaceWith(from, to, figure); tr.setSelection(NodeSelection.create(tr.doc, from)); } } else if (info.isFigure) { const image = schema.nodes.image.create({ src, alt, title, width }); tr.replaceWith( info.pos, info.pos + info.node.nodeSize, schema.nodes.paragraph.create(null, image) ); tr.setSelection(NodeSelection.create(tr.doc, info.pos + 1)); } else { tr.setNodeMarkup(info.pos, undefined, { src, alt, title, width }); if (state.selection instanceof NodeSelection) { tr.setSelection(NodeSelection.create(tr.doc, info.pos)); } } dispatch(tr.scrollIntoView()); } return true; }; } /** Inserts an empty rows × cols table, optionally with a header row. */ function insertTable(schema: Schema, attrs: CommandAttrs): Command { const rows = Math.max(1, attrs.rows ?? 3); const cols = Math.max(1, attrs.cols ?? 3); const withHeaderRow = attrs.withHeaderRow ?? true; return (state, dispatch) => { const { table, table_row: tableRow, table_cell: cell, table_header: header } = schema.nodes; const rowNodes = []; for (let r = 0; r < rows; r += 1) { const cellType = r === 0 && withHeaderRow ? header : cell; const cells = []; for (let c = 0; c < cols; c += 1) { cells.push(cellType.createAndFill()!); } rowNodes.push(tableRow.create(null, cells)); } if (dispatch) { dispatch(state.tr.replaceSelectionWith(table.create(null, rowNodes)).scrollIntoView()); } return true; }; } /** * Adjusts the `indent` attr on textblocks in the selection by ±1 step. * Returns false when nothing can change (e.g. outdent at level 0), letting * chained commands (blockquote lift) take over. */ function changeBlockIndent(delta: number): Command { return (state, dispatch) => { const { from, to } = state.selection; const targets: { pos: number; node: PMNode; next: number }[] = []; state.doc.nodesBetween(from, to, (node, pos) => { if (node.isTextblock && node.type.spec.attrs && 'indent' in node.type.spec.attrs) { const current = (node.attrs.indent as number) ?? 0; const next = Math.max(0, Math.min(MAX_INDENT, current + delta)); if (next !== current) targets.push({ pos, node, next }); } return true; }); if (targets.length === 0) return false; if (dispatch) { const tr = state.tr; for (const { pos, node, next } of targets) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next }); } dispatch(tr); } return true; }; } /** Sets the marker style (list-style-type) on the surrounding list. */ function setListStyle(style: string | null): Command { return (state, dispatch) => { const parentList = findParentList(state); if (!parentList) return false; let target = parentList; // Multilevel (legal) numbering covers the whole tree from the outermost // ordered list: apply it there, and clear it from wherever it sits. const { $from } = state.selection; const ordered = state.schema.nodes.ordered_list; for (let depth = 1; depth <= $from.depth; depth += 1) { const node = $from.node(depth); if ( node.type === ordered && (style === MULTILEVEL_STYLE || node.attrs.style === MULTILEVEL_STYLE) ) { target = { node, pos: $from.before(depth), depth }; break; } } if (dispatch) { dispatch( state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, style, }) ); } return true; }; } /** Sets the start number of the surrounding ordered list. */ function setListStart(start: number): Command { return (state, dispatch) => { const parentList = findParentList(state); if (!parentList || parentList.node.type !== state.schema.nodes.ordered_list) { return false; } if (dispatch) { dispatch( state.tr.setNodeMarkup(parentList.pos, undefined, { ...parentList.node.attrs, order: Math.max(1, Math.round(start)), }) ); } return true; }; } /** * The image (inline `image` or block `figure`) the selection points at: * a NodeSelection on either type, or a cursor inside a figure's caption. */ export function selectedImageInfo( state: EditorState ): { node: PMNode; pos: number; isFigure: boolean } | null { const { image, figure } = state.schema.nodes; const sel = state.selection; if (sel instanceof NodeSelection) { if (sel.node.type === image) return { node: sel.node, pos: sel.from, isFigure: false }; if (sel.node.type === figure) return { node: sel.node, pos: sel.from, isFigure: true }; } const { $from } = sel; for (let depth = $from.depth; depth > 0; depth -= 1) { if ($from.node(depth).type === figure) { return { node: $from.node(depth), pos: $from.before(depth), isFigure: true }; } } return null; } /** * Toggles the caption: inline image → figure (caption prefilled from alt, * cursor placed inside), figure → plain inline image (caption dropped). */ function toggleImageCaption(schema: Schema): Command { return (state, dispatch) => { const info = selectedImageInfo(state); if (!info) return false; if (dispatch) { const tr = state.tr; if (info.isFigure) { const { src, alt, title, width } = info.node.attrs; const image = schema.nodes.image.create({ src, alt, title, width }); tr.replaceWith( info.pos, info.pos + info.node.nodeSize, schema.nodes.paragraph.create(null, image) ); tr.setSelection(NodeSelection.create(tr.doc, info.pos + 1)); } else { const { src, alt, title, width } = info.node.attrs; const caption = alt ? schema.text(alt) : null; const figure = schema.nodes.figure.create( { src, alt, title, width, align: null }, caption ); // An inline image sits inside a textblock; if it is the block's only // child, replace the whole block, otherwise just the image. const $pos = state.doc.resolve(info.pos); const parent = $pos.parent; const replaceWhole = parent.childCount === 1 && parent.type.name === 'paragraph'; const from = replaceWhole ? $pos.before() : info.pos; const to = replaceWhole ? $pos.after() : info.pos + info.node.nodeSize; tr.replaceWith(from, to, figure); // Cursor into the caption (figure start + 1). tr.setSelection(TextSelection.create(tr.doc, from + 1 + figure.content.size)); } dispatch(tr.scrollIntoView()); } return true; }; } /** * Aligns the selected image. Inline images are promoted to a figure first, * since alignment is a block concept. */ function setImageAlign(schema: Schema, align: string | null): Command { return (state, dispatch) => { const info = selectedImageInfo(state); if (!info) return false; const clamped = align === 'left' || align === 'center' || align === 'right' ? align : null; if (dispatch) { const tr = state.tr; if (info.isFigure) { tr.setNodeMarkup(info.pos, undefined, { ...info.node.attrs, align: clamped }); if (state.selection instanceof NodeSelection) { tr.setSelection(NodeSelection.create(tr.doc, info.pos)); } } else { const { src, alt, title, width } = info.node.attrs; const figure = schema.nodes.figure.create({ src, alt, title, width, align: clamped }); const $pos = state.doc.resolve(info.pos); const parent = $pos.parent; const replaceWhole = parent.childCount === 1 && parent.type.name === 'paragraph'; const from = replaceWhole ? $pos.before() : info.pos; const to = replaceWhole ? $pos.after() : info.pos + info.node.nodeSize; tr.replaceWith(from, to, figure); tr.setSelection(NodeSelection.create(tr.doc, from)); } dispatch(tr.scrollIntoView()); } return true; }; } /** Sets the display width (px) of the selected image or figure. */ function setImageWidth(width: number | null): Command { return (state, dispatch) => { const info = selectedImageInfo(state); if (!info) return false; if (dispatch) { const tr = state.tr.setNodeMarkup(info.pos, undefined, { ...info.node.attrs, width: width && width > 0 ? Math.round(width) : null, }); // setNodeMarkup degrades a NodeSelection — restore it so follow-up // image commands still see the node as selected. if (state.selection instanceof NodeSelection) { tr.setSelection(NodeSelection.create(tr.doc, info.pos)); } dispatch(tr); } return true; }; } /** Deletes the selected image or the figure around the cursor. */ function deleteImage(): Command { return (state, dispatch) => { const info = selectedImageInfo(state); if (!info) return false; if (dispatch) { dispatch( state.tr.delete(info.pos, info.pos + info.node.nodeSize).scrollIntoView() ); } return true; }; } /** The selected embed node (a NodeSelection on an `embed`), or null. */ export function selectedEmbedInfo( state: EditorState ): { node: PMNode; pos: number } | null { const sel = state.selection; if (sel instanceof NodeSelection && sel.node.type === state.schema.nodes.embed) { return { node: sel.node, pos: sel.from }; } return null; } /** Aligns the selected video embed (left/center/right, or null to reset). */ function setEmbedAlign(align: string | null): Command { return (state, dispatch) => { const info = selectedEmbedInfo(state); if (!info) return false; const clamped = align === 'left' || align === 'center' || align === 'right' ? align : null; if (dispatch) { const tr = state.tr.setNodeMarkup(info.pos, undefined, { ...info.node.attrs, align: clamped, }); // setNodeMarkup degrades a NodeSelection — restore it so follow-up // align commands still see the embed as selected. tr.setSelection(NodeSelection.create(tr.doc, info.pos)); dispatch(tr.scrollIntoView()); } return true; }; } /** Deletes the selected video embed. */ function deleteEmbed(): Command { return (state, dispatch) => { const info = selectedEmbedInfo(state); if (!info) return false; if (dispatch) { dispatch(state.tr.delete(info.pos, info.pos + info.node.nodeSize).scrollIntoView()); } return true; }; } /** Strips all marks and stored marks from the selection. */ function clearFormatting(): Command { return (state, dispatch) => { const { from, to, empty } = state.selection; if (dispatch) { const tr = state.tr; if (!empty) tr.removeMark(from, to, null); tr.setStoredMarks([]); // Also reset alignment and line height on touched textblocks. state.doc.nodesBetween(from, to, (node, pos) => { if ( node.isTextblock && node.type.spec.attrs && 'align' in node.type.spec.attrs && (node.attrs.align || node.attrs.lineHeight) ) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, align: null, lineHeight: null }); } return true; }); dispatch(tr); } return true; }; } /** Transforms a string per the case mode. Title/sentence are word-aware. */ function transformCase(text: string, mode: 'upper' | 'lower' | 'title' | 'sentence'): string { switch (mode) { case 'upper': return text.toUpperCase(); case 'lower': return text.toLowerCase(); case 'title': // Capitalize the first letter of each word run. return text.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_m, sep, ch) => sep + ch.toUpperCase()); case 'sentence': // Capitalize the first letter after start or sentence-ending punctuation. return text .toLowerCase() .replace(/(^|[.!?]\s+)(\p{L})/gu, (_m, sep, ch) => sep + ch.toUpperCase()); } } /** * Changes the case of the selected text, preserving marks. No-op on an empty * selection. Replacements run back-to-front so earlier positions stay valid. */ function changeCase(mode: 'upper' | 'lower' | 'title' | 'sentence'): Command { return (state, dispatch) => { const { from, to, empty } = state.selection; if (empty) return false; const edits: { from: number; to: number; text: string; marks: readonly Mark[] }[] = []; state.doc.nodesBetween(from, to, (node, pos) => { if (!node.isText || !node.text) return; const start = Math.max(from, pos); const end = Math.min(to, pos + node.nodeSize); if (start >= end) return; const slice = node.text.slice(start - pos, end - pos); const next = transformCase(slice, mode); if (next !== slice) edits.push({ from: start, to: end, text: next, marks: node.marks }); }); if (edits.length === 0) return false; if (dispatch) { const tr = state.tr; for (const edit of edits.reverse()) { tr.replaceWith(edit.from, edit.to, state.schema.text(edit.text, edit.marks)); } dispatch(tr.scrollIntoView()); } return true; }; } /** Inserts a merge-field atom at the selection. */ function insertMergeField(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { const type = schema.nodes.merge_field; if (!type || !attrs.id) return false; if (dispatch) { const node = type.create({ id: attrs.id, label: attrs.label ?? null }); dispatch(state.tr.replaceSelectionWith(node).scrollIntoView()); } return true; }; } /** Inserts a math atom at the selection. */ function insertMath(schema: Schema, attrs: CommandAttrs): Command { return (state, dispatch) => { const type = schema.nodes.math; const latex = (attrs.latex ?? '').trim(); if (!type || !latex) return false; if (dispatch) { const node = type.create({ latex, display: attrs.mathDisplay ?? 'inline' }); dispatch(state.tr.replaceSelectionWith(node).scrollIntoView()); } return true; }; } /** * Resolves a named command against a schema. Returns null for unknown names * so callers can treat the registry as data. */ export function getCommand( state: EditorState, name: CommandName, attrs: CommandAttrs = {} ): Command | null { const { schema } = state; const { nodes, marks } = schema; switch (name) { case 'bold': return toggleMark(marks.bold); case 'italic': return toggleMark(marks.italic); case 'underline': return toggleMark(marks.underline); case 'strike': return toggleMark(marks.strike); case 'subscript': return toggleMark(marks.subscript); case 'superscript': return toggleMark(marks.superscript); case 'code': return toggleMark(marks.code); case 'paragraph': return setBlockType(nodes.paragraph); case 'heading': return setBlockType(nodes.heading, { level: attrs.level ?? 1 }); case 'blockquote': return toggleBlockquote(nodes.blockquote); case 'align': return setAlignment(attrs.align ?? null); case 'lineHeight': return setLineHeight(attrs.lineHeight ?? null); case 'bulletList': return toggleList(nodes.bullet_list, nodes.list_item); case 'orderedList': return toggleList(nodes.ordered_list, nodes.list_item); case 'taskList': return toggleList(nodes.task_list, nodes.task_item); case 'indent': // List item sink first, then paragraph/heading margin indent. return chainCommands( sinkListItem(nodes.task_item), sinkListItem(nodes.list_item), changeBlockIndent(1) ); case 'outdent': // List lift, then margin outdent, then blockquote lift. return chainCommands( liftListItem(nodes.task_item), liftListItem(nodes.list_item), changeBlockIndent(-1), lift ); case 'setListStyle': return setListStyle(attrs.style ?? null); case 'setListStart': return setListStart(attrs.start ?? 1); case 'toggleImageCaption': return toggleImageCaption(schema); case 'setImageAlign': return setImageAlign(schema, (attrs.align as string | null) ?? null); case 'setImageWidth': return setImageWidth(attrs.width ?? null); case 'deleteImage': return deleteImage(); case 'setEmbedAlign': return setEmbedAlign((attrs.align as string | null) ?? null); case 'deleteEmbed': return deleteEmbed(); case 'link': return setLink(marks.link, attrs); case 'unlink': return unsetLink(marks.link); case 'insertBookmark': return insertBookmark(schema, attrs); case 'textColor': return setAttrMark(marks.text_color, attrs.color ? { color: attrs.color } : null); case 'bgColor': return setAttrMark(marks.bg_color, attrs.color ? { color: attrs.color } : null); case 'fontFamily': return setAttrMark(marks.font_family, attrs.family ? { family: attrs.family } : null); case 'fontSize': return setAttrMark(marks.font_size, attrs.size ? { size: attrs.size } : null); case 'horizontalRule': return insertHorizontalRule(nodes.horizontal_rule); case 'clearFormatting': return clearFormatting(); case 'undo': return undo; case 'redo': return redo; case 'codeBlock': return toggleCodeBlock(schema, attrs.language ?? null); case 'setCodeBlockLanguage': return setCodeBlockLanguage(schema, attrs.language ?? null); case 'insertEmbed': return insertEmbed(schema, attrs); case 'insertImage': return insertImage(schema, attrs); case 'updateImage': return updateImage(schema, attrs); case 'insertTable': return insertTable(schema, attrs); case 'addRowBefore': return addRowBefore; case 'addRowAfter': return addRowAfter; case 'addColumnBefore': return addColumnBefore; case 'addColumnAfter': return addColumnAfter; case 'deleteRow': return deleteRow; case 'deleteColumn': return deleteColumn; case 'mergeCells': return mergeCells; case 'splitCell': return splitCell; case 'toggleHeaderRow': return toggleHeaderRow; case 'toggleLayoutTable': return toggleLayoutTable(); case 'deleteTable': return deleteTable; case 'changeCase': return changeCase(attrs.textCase ?? 'upper'); case 'insertMergeField': return insertMergeField(schema, attrs); case 'insertMath': return insertMath(schema, attrs); default: return null; } }