UNPKG

@blocknote/core

Version:

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

183 lines (168 loc) 5.96 kB
import { $prosemirrorDelta } from "@y/prosemirror"; import * as delta from "lib0/delta"; import * as schema from "lib0/schema"; /** * Canonical name of a content delta's first block child (the child carried by an * insert op), or `null`. For a BlockNote `blockContainer` (content * `blockContent blockGroup?`) this is its block-content type (paragraph, * heading, image, ...). */ const firstChild = ( d: schema.Unwrap<typeof $prosemirrorDelta>, ): schema.Unwrap<typeof $prosemirrorDelta> | null => { for (const op of (d as any).children) { if (delta.$insertOp.check(op)) { for (const it of op.insert) { if (delta.$deltaAny.check(it)) { return it; } } } } return null; }; /** * Whether a `blockContainer` delta carries a child `blockGroup` — i.e. the block * has nested children. A container's content is `blockContent blockGroup?`, so * this is what tells a leaf block apart from a parent. */ const hasBlockGroup = (d: schema.Unwrap<typeof $prosemirrorDelta>): boolean => { for (const op of (d as any).children) { if (delta.$insertOp.check(op)) { for (const it of op.insert) { if (delta.$deltaAny.check(it) && it.name === "blockGroup") { return true; } } } } return false; }; function getTableDimensions( d: schema.Unwrap<typeof $prosemirrorDelta>, ): { rows: number; cols: number } | null { if (d.name !== "table") { return null; } // Collect all rows with their cells' colspan/rowspan values. const rows: Array<Array<{ colspan: number; rowspan: number }>> = []; for (const op of (d as any).children) { if (delta.$insertOp.check(op)) { for (const tr of op.insert as Array< schema.Unwrap<typeof $prosemirrorDelta> >) { if (tr.name !== "tableRow") { return null; } const cells: Array<{ colspan: number; rowspan: number }> = []; for (const trOp of (tr as any).children) { if (delta.$insertOp.check(trOp)) { for (const td of trOp.insert as Array< schema.Unwrap<typeof $prosemirrorDelta> >) { if (td.name !== "tableCell" && td.name !== "tableHeader") { return null; } cells.push({ colspan: Number(td.attrs.colspan) || 1, rowspan: Number(td.attrs.rowspan) || 1, }); } } } rows.push(cells); } } } if (rows.length === 0) { return null; } // Build an occupancy grid to determine the true column count. // Each entry in `grid[r]` tracks which columns are already occupied // (by a cell from a previous row with rowspan > 1). const grid: boolean[][] = []; for (let r = 0; r < rows.length; r++) { if (!grid[r]) { grid[r] = []; } let col = 0; for (const cell of rows[r]) { // Skip columns already occupied by a rowspan from above. while (grid[r][col]) { col++; } // Mark all slots this cell occupies. for (let dr = 0; dr < cell.rowspan; dr++) { if (!grid[r + dr]) { grid[r + dr] = []; } for (let dc = 0; dc < cell.colspan; dc++) { grid[r + dr][col + dc] = true; } } col += cell.colspan; } } const numCols = Math.max(...grid.map((row) => row.length)); return { rows: rows.length, cols: numCols }; } /** * BlockNote's node-pairing policy for y-prosemirror's `matchNodes` option * (forwarded to `lib0/delta.diff`). This is the schema-specific bit that lives * in userland - the binding itself stays schema-agnostic. * * A `blockContainer` holds exactly one block content (`blockContent * blockGroup?`). Diffing a *type change* of that content as an in-place child * delete+insert would, under a suggestion, tombstone the old content next to the * new one => two block-contents in one container => schema-invalid. So we * declare a container's identity to be its first block-content child's type: * when that changes, the two containers are reported as *different*, the PM->Y * diff replaces the whole container, and the deleted + inserted containers sit * as siblings in the blockGroup (`blockGroupChild+` allows that). Each carries * the `y-attributed-*` node mark - which `blockContainer` already whitelists - * so no schema change and no storage transform are needed. A plain text edit * keeps the same first-child type => same identity => the diff descends and * merges as usual. * * @param a removed (old) node * @param b inserted (new) node * @returns whether `a` and `b` are the same node (diff in place) vs different (replace) */ export const blockMatchNodes = ( a: schema.Unwrap<typeof $prosemirrorDelta>, b: schema.Unwrap<typeof $prosemirrorDelta>, ): boolean => { if (a.name !== b.name) { return false; } if (a.name !== "blockContainer") { return true; } const childA = firstChild(a); const childB = firstChild(b); if (childA?.name !== childB?.name) { return false; } // A change in nesting is structural too: if one container gains or loses a // child `blockGroup`, diffing it in place would insert/delete the blockGroup as // a sibling of the block content inside a single container — schema-invalid. // Treat it as different so the whole container is replaced instead, same as a // content-type change. Keeps concurrent nesting merges (e.g. two users nesting // a block under the same parent) from producing a lopsided in-place result. if (hasBlockGroup(a) !== hasBlockGroup(b)) { return false; } if (childA?.name === "table" && childB?.name === "table") { const dimA = getTableDimensions(childA); const dimB = getTableDimensions(childB); if ( dimA !== null && dimB !== null && dimA.rows !== dimB.rows && dimA.cols !== dimB.cols ) { return false; } } return true; };