UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

686 lines (620 loc) 23.5 kB
import { Fragment, type NodeType, type Node as PMNode, Slice, } from "prosemirror-model"; import { TextSelection, Transaction } from "prosemirror-state"; import { TableMap } from "prosemirror-tables"; import { ReplaceStep, Transform } from "prosemirror-transform"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockIdentifier, BlockSchema, } from "../../../../schema/blocks/types.js"; import type { InlineContentSchema } from "../../../../schema/inlineContent/types.js"; import type { StyleSchema } from "../../../../schema/styles/types.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { type BlockInfo, getBlockInfoFromResolvedPos, } from "../../../getBlockInfoFromPos.js"; import { blockToNode, inlineContentToNodes, tableContentToNodes, } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getPmSchema } from "../../../pmUtil.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( posBeforeBlock: number, block: PartialBlock<BSchema, I, S>, ) => { return ({ tr, dispatch, }: { tr: Transaction; dispatch?: () => void; }): boolean => { if (dispatch) { updateBlockTr(tr, posBeforeBlock, block); } return true; }; }; export function updateBlockTr< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( tr: Transform | Transaction, posBeforeBlock: number, block: PartialBlock<BSchema, I, S>, replaceFromPos?: number, replaceToPos?: number, ) { const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock)); let cellAnchor: CellAnchor | null = null; if (blockInfo.blockNoteType === "table") { cellAnchor = captureCellAnchor(tr); } const pmSchema = getPmSchema(tr); if ( replaceFromPos !== undefined && replaceToPos !== undefined && replaceFromPos > replaceToPos ) { throw new Error("Invalid replaceFromPos or replaceToPos"); } // Adds blockGroup node with child blocks if necessary. const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType]; const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") ? newNodeType : pmSchema.nodes["blockContainer"]; if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) { const replaceFromOffset = replaceFromPos !== undefined && replaceFromPos > blockInfo.blockContent.beforePos && replaceFromPos < blockInfo.blockContent.afterPos ? replaceFromPos - blockInfo.blockContent.beforePos - 1 : undefined; const replaceToOffset = replaceToPos !== undefined && replaceToPos > blockInfo.blockContent.beforePos && replaceToPos < blockInfo.blockContent.afterPos ? replaceToPos - blockInfo.blockContent.beforePos - 1 : undefined; updateChildren(block, tr, blockInfo); // The code below determines the new content of the block. // or "keep" to keep as-is updateBlockContentNode( block, tr, oldNodeType, newNodeType, blockInfo, replaceFromOffset, replaceToOffset, ); } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) { updateChildren(block, tr, blockInfo); // old node was a bnBlock type (like column or columnList) and new block as well // No op, we just update the bnBlock below (at end of function) and have already updated the children } else { // switching from blockContainer to non-blockContainer or v.v. // currently breaking for column slash menu items converting empty block // to column. // currently, we calculate the new node and replace the entire node with the desired new node. // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc); const replacementNode = blockToNode( { children: existingBlock.children, // if no children are passed in, use existing children ...block, }, pmSchema, ); replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc tr.replaceWith( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, replacementNode, ); return; } // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing // attributes. Uses minimal steps so that an unchanged container (e.g. when // only children or content changed) doesn't emit a step at all. setNodeMarkupMinimal(tr, blockInfo.bnBlock.beforePos, newBnBlockNodeType, { ...block.props, }); if (cellAnchor) { restoreCellAnchor(tr, blockInfo, cellAnchor); } } function updateBlockContentNode< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >( block: PartialBlock<BSchema, I, S>, tr: Transform, oldNodeType: NodeType, newNodeType: NodeType, blockInfo: { childContainer?: | { node: PMNode; beforePos: number; afterPos: number } | undefined; blockContent: { node: PMNode; beforePos: number; afterPos: number }; }, replaceFromOffset?: number, replaceToOffset?: number, ) { const pmSchema = getPmSchema(tr); let content: PMNode[] | "keep" = "keep"; // Has there been any custom content provided? if (block.content) { if (typeof block.content === "string") { // Adds a single text node with no marks to the content. content = inlineContentToNodes( [block.content], pmSchema, newNodeType.name, ); } else if (Array.isArray(block.content)) { // Adds a text node with the provided styles converted into marks to the content, // for each InlineContent object. content = inlineContentToNodes(block.content, pmSchema, newNodeType.name); } else if (block.content.type === "tableContent") { content = tableContentToNodes(block.content, pmSchema); } else { throw new UnreachableCaseError(block.content.type); } } else { // no custom content has been provided, use existing content IF possible // Since some block types contain inline content and others don't, // we either need to call setNodeMarkup to just update type & // attributes, or replaceWith to replace the whole blockContent. const oldContent = blockInfo.blockContent.node.content; if (oldNodeType.spec.content === "") { // keep old content, because it's empty anyway and should be compatible with // any newContentType } else if (newNodeType.spec.content === oldNodeType.spec.content) { // keep old content, because the content type is the same and should be compatible } else if (newNodeType.validContent(oldContent)) { // The content-type strings differ (e.g. "inline*" vs a plain block's // "text*"), but the existing content is still valid for the new type // (e.g. plain, unstyled text), so keep it rather than clearing it. } else if (newNodeType.inlineContent && oldNodeType.inlineContent) { // Both blocks hold text, but the existing content isn't directly valid // for the new type (e.g. converting styled/complex inline content into a // plain block that disallows formatting marks and inline nodes). Preserve // the text, dropping the styling the new type can't represent. const text = blockInfo.blockContent.node.textContent; content = text.length > 0 ? [pmSchema.text(text)] : []; } else { // the content type changed and is incompatible, replace the previous content content = []; } } // Now, changes the blockContent node type and adds the provided props // as attributes. Also preserves all existing attributes that are // compatible with the new type. // // Use either setNodeMarkup or replaceWith depending on whether the // content is being replaced or not. if (content === "keep") { // only update the type and attributes, keeping the content as-is setNodeMarkupMinimal(tr, blockInfo.blockContent.beforePos, newNodeType, { ...block.props, }); } else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) { // Update the markup of the containing node, then get its (possibly shifted) // position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, blockInfo.blockContent.beforePos, newNodeType, { ...block.props }, ); const start = contentBeforePos + 1 + (replaceFromOffset ?? 0); const end = contentBeforePos + 1 + (replaceToOffset ?? blockInfo.blockContent.node.content.size); // for content like table cells (where the blockcontent has nested PM nodes), // we need to figure out the correct openStart and openEnd for the slice when replacing const contentDepth = tr.doc.resolve(contentBeforePos).depth; const startDepth = tr.doc.resolve(start).depth; const endDepth = tr.doc.resolve(end).depth; tr.replace( start, end, new Slice( Fragment.from(content), startDepth - contentDepth - 1, endDepth - contentDepth - 1, ), ); } else if ( newNodeType === oldNodeType || newNodeType.validContent(blockInfo.blockContent.node.content) ) { // The new type can hold the existing content, so we can update the markup // first and then diff the content. This keeps both steps minimal. // // First update the markup (type & attributes) of the content node using // minimal steps (avoids replacing the whole node just to change attrs), then // get its (possibly shifted) position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, blockInfo.blockContent.beforePos, newNodeType, { ...block.props }, ); // Then replace only the part of the content that actually changed, keeping // any shared prefix/suffix untouched. replaceContentMinimal(tr, contentBeforePos, Fragment.from(content)); } else { // The content type is incompatible with the new node type (e.g. switching // between inline content, table content, and no content). We can't update // the markup in-place, so replace the whole content node atomically. tr.replaceWith( blockInfo.blockContent.beforePos, blockInfo.blockContent.afterPos, newNodeType.createChecked( { ...blockInfo.blockContent.node.attrs, ...block.props, }, content, ), ); } } /** * Replaces the content of the node at `nodePos` with `newContent`, only * touching the range that actually differs between the old and new content. * * - For textblocks (inline content), this diffs at the character level using * `Fragment.findDiffStart`/`findDiffEnd`, so e.g. changing a single word only * replaces that word rather than the entire paragraph. * - For nested content (like tables or blockGroups), the diff is snapped to * whole top-level children (rows / blocks), so only the changed children are * replaced while unchanged leading/trailing children are left untouched. */ function replaceContentMinimal( tr: Transform, nodePos: number, newContent: Fragment, ) { const node = tr.doc.nodeAt(nodePos); if (!node) { throw new RangeError("No node at given position"); } const oldContent = node.content; // Position of the first child inside the node. const contentStart = nodePos + 1; if (node.isTextblock) { // Inline content: diff at the character/token level. A flat slice (no open // depth) is valid because the children are inline leaves. const diffStart = oldContent.findDiffStart(newContent); if (diffStart === null) { return; } // `findDiffEnd` returns ends in TWO separate coordinate systems: `a` is an // offset into the OLD content, `b` is an offset into the NEW content. They // are NOT interchangeable when the two fragments differ in size. const diffEnd = oldContent.findDiffEnd(newContent)!; let { a: oldEnd, b: newEnd } = diffEnd; // The shared prefix (`diffStart`) and shared suffix can overlap, e.g. when // inserting/deleting a run of text at a boundary. When that happens an end // can fall before `diffStart`. Push the ends forward so neither precedes the // shared prefix, keeping the OLD and NEW ranges aligned by the same amount // (each coordinate system is checked independently, then the larger shift is // applied to both so the suffix stays in sync). const shift = Math.max(0, diffStart - oldEnd, diffStart - newEnd); if (shift > 0) { oldEnd += shift; newEnd += shift; } tr.replace( // OLD-doc range to remove: uses old-content offsets. contentStart + diffStart, contentStart + oldEnd, // Replacement: cut the NEW content using new-content offsets. `diffStart` // is valid here because it lies within the shared prefix (identical in // both fragments), and `newEnd` is a new-content offset. new Slice(newContent.cut(diffStart, newEnd), 0, 0), ); return; } // Nested/block content (table rows, child blocks, ...): snap the diff to whole // top-level children so the replacement slice is always a valid sequence of // complete children (open depth 0). const oldChildCount = oldContent.childCount; const newChildCount = newContent.childCount; // Number of identical leading children. let startIndex = 0; while ( startIndex < oldChildCount && startIndex < newChildCount && oldContent.child(startIndex).eq(newContent.child(startIndex)) ) { startIndex++; } // Number of identical trailing children (not overlapping the shared prefix). let oldEndIndex = oldChildCount; let newEndIndex = newChildCount; while ( oldEndIndex > startIndex && newEndIndex > startIndex && oldContent.child(oldEndIndex - 1).eq(newContent.child(newEndIndex - 1)) ) { oldEndIndex--; newEndIndex--; } // Nothing changed. if (startIndex === oldEndIndex && startIndex === newEndIndex) { return; } // Convert child indices to document positions. let from = contentStart; for (let i = 0; i < startIndex; i++) { from += oldContent.child(i).nodeSize; } let to = from; for (let i = startIndex; i < oldEndIndex; i++) { to += oldContent.child(i).nodeSize; } // Collect the changed replacement children. const replacement: PMNode[] = []; for (let i = startIndex; i < newEndIndex; i++) { replacement.push(newContent.child(i)); } // Use a strict ReplaceStep (rather than the lenient `tr.replace`) so that // invalid content for the parent node type (e.g. a columnList that would end // up with non-column children) throws instead of being silently coerced. tr.step( new ReplaceStep(from, to, new Slice(Fragment.from(replacement), 0, 0)), ); } /** * Updates the type and/or attributes of the node at `pos` using the smallest * possible set of steps. * * - If neither the type nor any attribute changes, no step is emitted. * - If only attributes change (type stays the same), an `AttrStep` is emitted * for each changed attribute. These are minimal steps that don't touch the * node's content. * - If the type changes, `setNodeMarkup` is used, which keeps the node's content * via a `ReplaceAroundStep`. */ function setNodeMarkupMinimal( tr: Transform, pos: number, newType: NodeType, newAttrs: Record<string, unknown>, ) { const node = tr.doc.nodeAt(pos); if (!node) { throw new RangeError("No node at given position"); } // Only consider attributes that are actually valid for the target node type. // `block.props` may contain props that belong to the content node but not the // container (or vice versa), and these should be ignored here. const validAttrs = newType.spec.attrs ?? {}; const filteredNewAttrs: Record<string, unknown> = {}; for (const attr of Object.keys(newAttrs)) { if (attr in validAttrs) { filteredNewAttrs[attr] = newAttrs[attr]; } } const mergedAttrs = { ...node.attrs, ...filteredNewAttrs }; if (node.type === newType) { // Only emit AttrSteps for attributes that actually changed. for (const attr of Object.keys(mergedAttrs)) { if (mergedAttrs[attr] !== node.attrs[attr]) { tr.setNodeAttribute(pos, attr, mergedAttrs[attr]); } } return; } // Type changed - setNodeMarkup keeps the content via a ReplaceAroundStep. tr.setNodeMarkup(pos, newType, mergedAttrs); } /** * Applies a minimal markup update to the node at `pos`, then returns `pos` * re-mapped through only the steps that update added. * * `setNodeMarkupMinimal` may add a step (e.g. a ReplaceAroundStep when the node * type changes), which leaves the original `pos` stale. Because `tr` may already * contain steps from earlier ops (other `updateBlock` calls sharing the same * transaction), we map through only the steps added here — not the whole * `tr.mapping` — using a -1 (before) bias so the position stays anchored before * the node rather than being pushed past it. */ function setNodeMarkupMinimalAndRemap( tr: Transform, pos: number, newType: NodeType, newAttrs: Record<string, unknown>, ): number { const baseMapLen = tr.mapping.maps.length; setNodeMarkupMinimal(tr, pos, newType, newAttrs); return tr.mapping.slice(baseMapLen).map(pos, -1); } function updateChildren< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(block: PartialBlock<BSchema, I, S>, tr: Transform, blockInfo: BlockInfo) { const pmSchema = getPmSchema(tr); if (block.children !== undefined && block.children.length > 0) { const childNodes = block.children.map((child) => { const node = blockToNode(child, pmSchema); node.check(); // `blockToNode` is lenient; validate before mutating the doc return node; }); // Checks if a blockGroup node already exists. if (blockInfo.childContainer) { // Replaces the child nodes in the existing blockGroup, only touching the // range that actually changed (keeping unchanged leading/trailing // children untouched). replaceContentMinimal( tr, blockInfo.childContainer.beforePos, Fragment.from(childNodes), ); } else { if (!blockInfo.isBlockContainer) { throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. tr.insert( blockInfo.blockContent.afterPos, pmSchema.nodes["blockGroup"].createChecked({}, childNodes), ); } } } export function updateBlock< BSchema extends BlockSchema = any, I extends InlineContentSchema = any, S extends StyleSchema = any, >( tr: Transform, blockToUpdate: BlockIdentifier, update: PartialBlock<BSchema, I, S>, replaceFromPos?: number, replaceToPos?: number, ): Block<BSchema, I, S> { const id = typeof blockToUpdate === "string" ? blockToUpdate : blockToUpdate.id; const posInfo = getNodeById(id, tr.doc); if (!posInfo) { throw new Error(`Block with ID ${id} not found`); } updateBlockTr( tr, posInfo.posBeforeNode, update, replaceFromPos, replaceToPos, ); const blockContainerNode = tr.doc .resolve(posInfo.posBeforeNode + 1) // TODO: clean? .node(); return nodeToBlock(blockContainerNode, tr.doc); } type CellAnchor = { row: number; col: number; offset: number }; /** * Captures the cell anchor from the current selection. * @param tr - The transaction to capture the cell anchor from. * * @returns The cell anchor, or null if no cell is selected. */ export function captureCellAnchor(tr: Transform): CellAnchor | null { const sel = "selection" in tr ? tr.selection : null; if (!(sel instanceof TextSelection)) { return null; } const $head = tr.doc.resolve(sel.head); // Find enclosing cell and table let cellDepth = -1; let tableDepth = -1; for (let d = $head.depth; d >= 0; d--) { const name = $head.node(d).type.name; if (cellDepth < 0 && (name === "tableCell" || name === "tableHeader")) { cellDepth = d; } if (name === "table") { tableDepth = d; break; } } if (cellDepth < 0 || tableDepth < 0) { return null; } // Absolute positions (before the cell) const cellPos = $head.before(cellDepth); const tablePos = $head.before(tableDepth); const table = tr.doc.nodeAt(tablePos); if (!table || table.type.name !== "table") { return null; } // Visual grid position via TableMap (handles spans) const map = TableMap.get(table); const rel = cellPos - (tablePos + 1); // relative to inside table const idx = map.map.indexOf(rel); if (idx < 0) { return null; } const row = Math.floor(idx / map.width); const col = idx % map.width; // Caret offset relative to the start of paragraph text const paraPos = cellPos + 1; // pos BEFORE tableParagraph const textStart = paraPos + 1; // start of paragraph text const offset = Math.max(0, sel.head - textStart); return { row, col, offset }; } function restoreCellAnchor( tr: Transform | Transaction, blockInfo: BlockInfo, a: CellAnchor, ): boolean { if (blockInfo.blockNoteType !== "table") { return false; } // 1) Resolve the table node in the current document let tablePos = -1; if (blockInfo.isBlockContainer) { // Prefer the blockContent position when available (points directly at the PM table node) tablePos = tr.mapping.map(blockInfo.blockContent.beforePos); } else { // Fallback: scan within the mapped bnBlock range to find the inner table node const start = tr.mapping.map(blockInfo.bnBlock.beforePos); const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0); tr.doc.nodesBetween(start, end, (node, pos) => { if (node.type.name === "table") { tablePos = pos; return false; } return true; }); } const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null; if (!table || table.type.name !== "table") { return false; } // 2) Clamp row/col to the table’s current grid const map = TableMap.get(table); const row = Math.max(0, Math.min(a.row, map.height - 1)); const col = Math.max(0, Math.min(a.col, map.width - 1)); // 3) Compute the absolute position of the target cell (pos BEFORE the cell) const cellIndex = row * map.width + col; const relCellPos = map.map[cellIndex]; // relative to (tablePos + 1) if (relCellPos == null) { return false; } const cellPos = tablePos + 1 + relCellPos; // 4) Place the caret inside the cell, clamping the text offset const textPos = cellPos + 1; const textNode = tr.doc.nodeAt(textPos); const textStart = textPos + 1; const max = textNode ? textNode.content.size : 0; const head = textStart + Math.max(0, Math.min(a.offset, max)); if ("selection" in tr) { tr.setSelection(TextSelection.create(tr.doc, head)); } return true; }