From c0a22cf7b0a57da6d852fc696f39abac96164834 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 4 Jun 2026 16:11:54 +0200 Subject: [PATCH 1/3] build: first round of blast radius --- .../blockManipulation/commands/mergeBlocks/mergeBlocks.ts | 2 ++ .../api/blockManipulation/commands/moveBlocks/moveBlocks.ts | 1 + .../api/blockManipulation/commands/nestBlock/nestBlock.ts | 2 ++ .../commands/replaceBlocks/util/fixColumnList.ts | 1 + .../api/blockManipulation/commands/splitBlock/splitBlock.ts | 1 + .../blockManipulation/commands/updateBlock/updateBlock.ts | 2 ++ packages/core/src/api/blockManipulation/getBlock/getBlock.ts | 1 + packages/core/src/api/clipboard/toClipboard/copyExtension.ts | 5 +++++ packages/core/src/api/exporters/markdown/htmlToMarkdown.ts | 1 + packages/core/src/api/getBlockInfoFromPos.ts | 1 + packages/core/src/api/nodeConversions/blockToNode.ts | 1 + packages/core/src/api/nodeConversions/fragmentToBlocks.ts | 1 + packages/core/src/api/nodeConversions/nodeToBlock.ts | 2 ++ packages/core/src/api/nodeUtil.ts | 1 + packages/core/src/api/parsers/html/parseHTML.ts | 1 + .../src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts | 1 + packages/core/src/blocks/defaultBlockHelpers.ts | 1 + packages/core/src/comments/extension.ts | 1 + packages/core/src/editor/Block.css | 1 + packages/core/src/editor/managers/ExportManager.ts | 1 + packages/core/src/editor/transformPasted.ts | 4 ++++ packages/core/src/extensions/Placeholder/Placeholder.ts | 1 + .../src/extensions/PreviousBlockType/PreviousBlockType.ts | 1 + packages/core/src/extensions/SideMenu/SideMenu.ts | 1 + packages/core/src/extensions/SideMenu/dragging.ts | 1 + .../core/src/extensions/SuggestionMenu/SuggestionMenu.ts | 2 ++ packages/core/src/extensions/TableHandles/TableHandles.ts | 1 + packages/core/src/extensions/TrailingNode/TrailingNode.ts | 1 + .../KeyboardShortcuts/KeyboardShortcutsExtension.ts | 2 ++ .../src/extensions/tiptap-extensions/UniqueID/UniqueID.ts | 1 + packages/core/src/pm-nodes/SpecialNode.test.ts | 1 + packages/core/src/schema/blocks/createSpec.ts | 2 ++ packages/core/src/schema/blocks/internal.ts | 1 + packages/core/src/y/extensions/YSync.ts | 1 + .../schemaMigration/migrationRules/moveColorAttributes.ts | 2 ++ packages/core/src/yjs/utils.ts | 1 + .../react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx | 2 ++ .../react/src/components/FilePanel/DefaultTabs/UploadTab.tsx | 1 + .../FormattingToolbar/DefaultButtons/AddCommentButton.tsx | 1 + .../FormattingToolbar/DefaultButtons/FileCaptionButton.tsx | 1 + .../FormattingToolbar/DefaultButtons/FileDeleteButton.tsx | 1 + .../FormattingToolbar/DefaultButtons/FilePreviewButton.tsx | 1 + .../FormattingToolbar/DefaultButtons/FileRenameButton.tsx | 1 + .../FormattingToolbar/DefaultButtons/TextAlignButton.tsx | 2 ++ .../FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx | 2 ++ .../FormattingToolbar/FormattingToolbarController.tsx | 1 + .../components/SideMenu/DefaultButtons/AddBlockButton.tsx | 1 + .../SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx | 1 + .../SideMenu/DragHandleMenu/DefaultItems/RemoveBlockItem.tsx | 1 + .../DragHandleMenu/DefaultItems/TableHeadersItem.tsx | 2 ++ .../components/TableHandles/ExtendButton/ExtendButton.tsx | 2 ++ .../TableCellMenu/DefaultButtons/ColorPicker.tsx | 2 ++ .../TableHandleMenu/DefaultButtons/ColorPicker.tsx | 2 ++ .../TableHandleMenu/DefaultButtons/TableHeaderButton.tsx | 2 ++ packages/react/src/schema/ReactBlockSpec.tsx | 1 + packages/xl-ai/src/prosemirror/agent.ts | 2 ++ packages/xl-ai/src/prosemirror/changeset.ts | 1 + .../src/extensions/DropCursor/multiColumnHandleDropPlugin.ts | 3 +++ 58 files changed, 84 insertions(+) diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index a76373127c..d37fb05005 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -177,6 +177,7 @@ const mergeBlocks = ( } // Save suggestion node content before reconstruction + // drops prev suggestionBefore and next suggestionAfter on merge const savedPrevSuggAfter = currentPrevInfo.suggestionAfter ? currentPrevInfo.suggestionAfter.node.copy( currentPrevInfo.suggestionAfter.node.content, @@ -236,6 +237,7 @@ const mergeBlocks = ( } // Create the new blockContainer with the prev block's ID and attributes + // create() skips validation; bad child order ships silently const newBlockContainer = currentPrevInfo.bnBlock.node.type.create( currentPrevInfo.bnBlock.node.attrs, newChildren, diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 24248cfc3c..f4b49be4b8 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -106,6 +106,7 @@ function updateBlockSelectionFromData( anchorBlockPos + data.headCellOffset, ); } else if (data.type === "node") { + // +1 assumes blockContent is first child; may be a leading suggestion node selection = NodeSelection.create(tr.doc, anchorBlockPos + 1); } else { const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode; diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index c995faeda1..aaaef246d5 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -39,6 +39,7 @@ function sinkItem( if (nodeBefore.type !== itemType) { return false; } + // lastChild may be a trailing suggestion node, not blockGroup const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2 const inner = Fragment.from(nestedBefore ? itemType.create() : null); @@ -102,6 +103,7 @@ function liftToOuterList( // There are siblings after the lifted items, which must become // children of the last item const blockBeingLifted = range.parent.child(range.endIndex - 1); + // lastChild may be a trailing suggestion node, not blockGroup const nestedAfter = blockBeingLifted.lastChild && blockBeingLifted.lastChild.type === groupType; // change 2 diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts index 3097851f47..32ed4782e4 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts @@ -18,6 +18,7 @@ export function isEmptyColumn(column: Node) { throw new Error("Invalid column: does not have child node."); } + // firstChild may be a suggestion node; childCount===1 below assumes no suggestions const blockContent = blockContainer.firstChild; if (!blockContent) { throw new Error("Invalid blockContainer: does not have child node."); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 785714fd13..a8c4a59f6b 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -47,6 +47,7 @@ export const splitBlockTr = ( // with the first block and the split happens at the blockContent boundary. let effectivePos = posInBlock; const $pos = tr.doc.resolve(posInBlock); + // dead guard — group is compound "suggestionBlockContent blockContent", never === if ($pos.parent.type.spec.group === "suggestionBlockContent") { effectivePos = info.blockContent.beforePos + 1; } diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index a3e2b3b0db..264fbe7285 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -127,6 +127,7 @@ export function updateBlockTr< // 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 + // nodeToBlock→blockToNode round-trip drops suggestion nodes const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema); const replacementNode = blockToNode( { @@ -302,6 +303,7 @@ function updateChildren< throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. + // inserts blockGroup before trailing suggestion, invalid content order tr.insert( blockInfo.blockContent.afterPos, pmSchema.nodes["blockGroup"].createChecked({}, childNodes), diff --git a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index c018c907a5..4db6384044 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -22,6 +22,7 @@ export function getBlock< typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; const pmSchema = getPmSchema(doc); + // suggested-deleted blocks resolve as live, no deletion flag const posInfo = getNodeById(id, doc); if (!posInfo) { return undefined; diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index e150af1309..7269dcd033 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -50,6 +50,7 @@ function fragmentToExternalHTML< (child) => child.type.isInGroup("bnBlock") || child.type.name === "blockGroup" || + // === "blockContent" misclassifies suggestion nodes (compound group) child.type.spec.group === "blockContent", ) === undefined; if (isWithinBlockContent) { @@ -118,9 +119,11 @@ export function selectedFragmentToHTML< // selected, e.g. an image block. if ( "node" in view.state.selection && + // === "blockContent" misclassifies suggestion nodes (compound group) (view.state.selection.node as Node).type.spec.group === "blockContent" ) { editor.transact((tr) => + // from-1 block expansion assumes blockContent adjacency; off with leading suggestion node tr.setSelection( new NodeSelection(tr.doc.resolve(view.state.selection.from - 1)), ), @@ -128,6 +131,7 @@ export function selectedFragmentToHTML< } // Uses default ProseMirror clipboard serialization. + // serializeForClipboard emits shadow nodes; paste re-creates them const clipboardHTML: string = view.serializeForClipboard( view.state.selection.content(), ).dom.innerHTML; @@ -268,6 +272,7 @@ export const createCopyToClipboardExtension = < // Expands the selection to the parent `blockContainer` node. editor.transact((tr) => + // from-1 block expansion assumes blockContent adjacency; off with leading suggestion node tr.setSelection( new NodeSelection( tr.doc.resolve(view.state.selection.from - 1), diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts index 7faa154dc6..716ad72650 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -45,6 +45,7 @@ function serializeChildren(node: Node, ctx: SerializeContext): string { } function serializeNode(node: Node, ctx: SerializeContext): string { + // no data-suggestion handling; shadow text duplicated to markdown if (node.nodeType === 3 /* Node.TEXT_NODE */) { return node.textContent || ""; } diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 4f340d98a4..31e3be1356 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -191,6 +191,7 @@ export function getBlockInfoWithManualOffset( afterPos: suggestionAfterPos, }; + // singular suggestionBefore/After, schema allows suggestionBlockContent* if (!foundBlockContent) { suggestionBefore = info; } else { diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index d970227a49..888ff40df4 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -321,6 +321,7 @@ function blockOrInlineContentToContentNode( /** * Converts a BlockNote block to a Prosemirror node. */ +// (Affects ~4) emits no suggestion nodes, round-trips drop suggestions export function blockToNode( block: PartialBlock, schema: Schema, diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 724b552bda..4fbae84809 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -22,6 +22,7 @@ export function fragmentToBlocks< fragment.descendants((node) => { const pmSchema = getPmSchema(node); if (node.type.name === "blockContainer") { + // firstChild may be a suggestion node, not blockContent or blockGroup if (node.firstChild?.type.name === "blockGroup") { // selection started within a block group // in this case the fragment starts with: diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 1a777ad892..9ffccc3f90 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -482,6 +482,7 @@ export function nodeToBlock< throw new UnreachableCaseError(blockConfig.content); } + // (Affects ~9) strips suggestion state, Block API is suggestion-blind const block = { id, type: blockConfig.type, @@ -604,6 +605,7 @@ export function prosemirrorSliceToSlicedBlocks< let firstNonSuggestionChild: Node | undefined; let blockGroupChild: Node | undefined; blockContainer.forEach((child) => { + // dead guard — group is compound "suggestionBlockContent blockContent", never === if (child.type.spec.group === "suggestionBlockContent") { return; // skip suggestion nodes } diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 3388c95413..6530b60b3e 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -17,6 +17,7 @@ export function getNodeById( } // Keeps traversing nodes if block with target ID has not been found. + // suggested-deleted blocks resolve as live, no deletion flag if (!isNodeBlock(node) || node.attrs.id !== id) { return true; } diff --git a/packages/core/src/api/parsers/html/parseHTML.ts b/packages/core/src/api/parsers/html/parseHTML.ts index 16e03f883a..a8fac6f305 100644 --- a/packages/core/src/api/parsers/html/parseHTML.ts +++ b/packages/core/src/api/parsers/html/parseHTML.ts @@ -23,6 +23,7 @@ export function HTMLToBlocks< // const doc = pmSchema.nodes["doc"].createAndFill()!; // and context: doc.resolve(3), + // parse may auto-create *--attributed shadow nodes from BlockNote HTML const parentNode = parser.parse(htmlNode, { topNode: pmSchema.nodes["blockGroup"].create(), }); diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index eb6b06dc7d..369002377e 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -28,6 +28,7 @@ function calculateListItemIndex( tr: Transaction, map: Map, ): { index: number; isFirst: boolean; hasStart: boolean } { + // firstChild may be a suggestion node, not blockContent const hasStart = !!node.firstChild!.attrs["start"]; // Fast path: previous sibling already in cache diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts index ccadf93e11..800359565c 100644 --- a/packages/core/src/blocks/defaultBlockHelpers.ts +++ b/packages/core/src/blocks/defaultBlockHelpers.ts @@ -71,6 +71,7 @@ export const defaultBlockToHTML = < if (node.type.name === "blockContainer") { // for regular blocks, get the toDOM spec from the blockContent node + // firstChild safe here; node built by suggestion-blind blockToNode node = node.firstChild!; } diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index c037e80ddf..23547eb383 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -30,6 +30,7 @@ function getUpdatedThreadPositions(doc: Node, markType: string) { const threadPositions = new Map(); // find all thread marks and store their position + create decoration for selected thread + // comment marks inside shadow nodes anchor threads to suggestion content doc.descendants((node, pos) => { node.marks.forEach((mark) => { if (mark.type.name === markType) { diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index afb9222e64..6614b4caa1 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -734,6 +734,7 @@ div[data-type="modification"] { text-decoration: none; } +/* no block-level suggestion-state styling (deletion rule disabled) */ .bn-root del, [DISABLED-data-node-deletion] { color: rgba(100, 90, 75, 0.3); diff --git a/packages/core/src/editor/managers/ExportManager.ts b/packages/core/src/editor/managers/ExportManager.ts index 3fe1ee2f0e..bf6fee875f 100644 --- a/packages/core/src/editor/managers/ExportManager.ts +++ b/packages/core/src/editor/managers/ExportManager.ts @@ -34,6 +34,7 @@ export class ExportManager< * @param blocks An array of blocks that should be serialized into HTML. * @returns The blocks, serialized as an HTML string. */ + // (Affects ~3) exports suggestion-blind Block JSON, drops shadow content public blocksToHTMLLossy( blocks: PartialBlock[] = this.editor.document, ): string { diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 4f0515df95..e94a150f3f 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -145,6 +145,7 @@ export function transformPasted(slice: Slice, view: EditorView) { } for (let i = 0; i < f.childCount; i++) { + // === "blockContent" misclassifies suggestion nodes (compound group) if (f.child(i).type.spec.group === "blockContent") { const content = [f.child(i)]; @@ -154,6 +155,7 @@ export function transformPasted(slice: Slice, view: EditorView) { i + 1 < f.childCount && f.child(i + 1).type.name === "blockGroup" // TODO ) { + // .child(0).child(0) assumes blockContent is first child of blockContainer const nestedChild = f .child(i + 1) .child(0) @@ -227,6 +229,7 @@ function retypeLeadingParagraphForEmptyTarget( const blockGroup = fragment.firstChild; const blockContainer = blockGroup?.firstChild; + // firstChild may be a suggestion node, not blockContent const leading = blockContainer?.firstChild; if ( blockGroup?.type.name !== "blockGroup" || @@ -238,6 +241,7 @@ function retypeLeadingParagraphForEmptyTarget( const retyped = target.type.create(target.attrs, leading.content); const newBlockContainer = blockContainer.copy( + // replaceChild(0,...) assumes index 0 is blockContent, not a suggestion node blockContainer.content.replaceChild(0, retyped), ); const newBlockGroup = blockGroup.copy( diff --git a/packages/core/src/extensions/Placeholder/Placeholder.ts b/packages/core/src/extensions/Placeholder/Placeholder.ts index b8ff2e14ed..90e39d341b 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.ts @@ -117,6 +117,7 @@ export const PlaceholderExtension = createExtension( // decoration for when there's only one empty block // positions are hardcoded for now + // hardcoded size===6 and positions 2,4 assume no suggestion nodes in empty doc if (state.doc.content.size === 6) { decs.push( Decoration.node(2, 4, { diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts index 714783ad28..290783ad6e 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts @@ -104,6 +104,7 @@ export const PreviousBlockTypeExtension = createExtension(() => { for (const node of newNodes) { const oldNode = oldNodesById.get(node.node.attrs.id); + // firstChild may be a suggestion node, not blockContent const oldContentNode = oldNode?.node.firstChild; const newContentNode = node.node.firstChild; diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index e98059b585..d36acac8ad 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -258,6 +258,7 @@ export class SideMenuView< blockContentBoundingBox.width, blockContentBoundingBox.height, ), + // resolves suggested-deleted/modified blocks as if live (no suggestion state) block: this.editor.getBlock( this.hoveredBlock!.getAttribute("data-id")!, )!, diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index f8ba326538..63bb784ddd 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -39,6 +39,7 @@ function blockPositionsFromSelection(selection: Selection, doc: Node) { // the same blocks again. If this happens, the anchor & head move out of the block content node they were originally // in. If the anchor should update but the head shouldn't and vice versa, it means the user selection is outside a // block content node, which should never happen. + // === "blockContent" misclassifies suggestion nodes (compound group) const selectionStartInBlockContent = doc.resolve(selection.from).node().type.spec.group === "blockContent"; const selectionEndInBlockContent = diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 4809607e6e..a52d39f4e8 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -309,6 +309,7 @@ export const SuggestionMenu = createExtension(({ editor }) => { transaction.getMeta("blur") || transaction.getMeta("pointer") || // Moving the caret before the character which triggered the menu should hide it. + // no cursor-in-suggestion-node guard (same-parent check is suggestion-blind) (prev.triggerCharacter !== undefined && newState.selection.from < prev.queryStartPos()) || // Moving the caret to a new block should hide the menu. @@ -381,6 +382,7 @@ export const SuggestionMenu = createExtension(({ editor }) => { const blockNode = findBlock(state.selection); if (blockNode) { return DecorationSet.create(state.doc, [ + // decoration spans whole container incl suggestion shadow nodes Decoration.node( blockNode.pos, blockNode.pos + blockNode.node.nodeSize, diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index d957056f4e..3253a690b0 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -273,6 +273,7 @@ export class TableHandlesView implements PluginView { ); if (editorHasBlockWithType(this.editor, "table")) { + // posBeforeNode+1 assumes blockContent is first child; off with leading suggestion node this.tablePos = pmNodeInfo.posBeforeNode + 1; tableBlock = block; } diff --git a/packages/core/src/extensions/TrailingNode/TrailingNode.ts b/packages/core/src/extensions/TrailingNode/TrailingNode.ts index 59f95d2bed..921fcd07cf 100644 --- a/packages/core/src/extensions/TrailingNode/TrailingNode.ts +++ b/packages/core/src/extensions/TrailingNode/TrailingNode.ts @@ -17,6 +17,7 @@ function shouldShowTrailingWidget(doc: PMNode, isEditable: boolean): boolean { const rootGroup = doc.lastChild; const lastBlock = rootGroup?.lastChild; + // firstChild may be a suggestion node, not blockContent const lastContent = lastBlock?.firstChild; return !( diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index ee0123bcc7..a36a827986 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -271,6 +271,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ bottomNestedPrevBlockInfo.blockContent.node.type.spec .content === "tableRow+" ) { + // cascading -1 arithmetic; off if a leading suggestion node precedes blockContent const tableBlockEndPos = blockInfo.bnBlock.beforePos - 1; const tableBlockContentEndPos = tableBlockEndPos - 1; const lastRowEndPos = tableBlockContentEndPos - 1; @@ -674,6 +675,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ nextBlockInfo.blockContent.node.type.spec.content === "tableRow+" ) { + // hardcoded +1 arithmetic; off if next block has a leading suggestion node const tableBlockStartPos = blockInfo.bnBlock.afterPos + 1; const tableBlockContentStartPos = tableBlockStartPos + 1; const firstRowStartPos = tableBlockContentStartPos + 1; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 54cb8b7340..a795c6f344 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -205,6 +205,7 @@ const UniqueID = Extension.create({ }); return; } + // dedupe may regenerate the wrong id when a suggestion creates a duplicate id // check if the node doesn’t exist in the old state const { deleted } = mapping.invert().mapResult(pos); const newNode = deleted && duplicatedNewIds.includes(id); diff --git a/packages/core/src/pm-nodes/SpecialNode.test.ts b/packages/core/src/pm-nodes/SpecialNode.test.ts index c4f3442f63..f4163b4a5e 100644 --- a/packages/core/src/pm-nodes/SpecialNode.test.ts +++ b/packages/core/src/pm-nodes/SpecialNode.test.ts @@ -78,6 +78,7 @@ describe("SuggestionNode - structural", () => { it("should have suggestion-paragraph type registered in the PM schema", () => { const editor = BlockNoteEditor.create(); const nodeTypes = Object.keys(editor.pmSchema.nodes); + // asserts suggestion-* names, real nodes are *--attributed expect(nodeTypes).toContain("suggestion-paragraph"); expect(nodeTypes).toContain("blockContainer"); expect(nodeTypes).toContain("blockGroup"); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 99bd7aad85..324ab4a612 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -258,6 +258,7 @@ export function addNodeAndExtensionsToSpec< // 1. isRequired: true prevents ProseMirror's DOMParser from auto-creating // suggestion nodes to satisfy optional content expressions // 2. Rendered as data-suggestion="true" on the wrapper div for HTML parsing + // isRequired sentinel hack blocks DOMParser auto-creating shadow nodes attrs["y-attributed"] = { isRequired: true, parseHTML: (element: HTMLElement) => { @@ -285,6 +286,7 @@ export function addNodeAndExtensionsToSpec< ]; }, renderHTML({ HTMLAttributes }) { + // shadow node gets editable contentDOM, no nodeView selectable fix const div = document.createElement("div"); return wrapInBlockStructure( { diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index 6a76d1ca05..15d1078573 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -101,6 +101,7 @@ export function getBlockFromPos< } // Gets parent blockContainer node const blockContainer = tipTapEditor.state.doc.resolve(pos!).node(); + // dead guard — real nodes are named "*--attributed", never "suggestion-" if (blockContainer.type.name.startsWith("suggestion-")) { // The blockContent is inside a suggestion node, which is inside a blockContainer. // Return a stub block since suggestion nodes are transparent to the Block API. diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts index 0acb7c8d10..c05fcdc032 100644 --- a/packages/core/src/y/extensions/YSync.ts +++ b/packages/core/src/y/extensions/YSync.ts @@ -122,6 +122,7 @@ export const YSyncExtension = createExtension( nodeName: string, kinds: { delete: boolean; insert: boolean; format: boolean }, ) => { + // attributedNodes gates shadows on blockSpecs and delete only const result = Boolean( editor.schema.blockSpecs[nodeName] && kinds.delete, ); diff --git a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts index 0866c3523c..16a7daac5a 100644 --- a/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts +++ b/packages/core/src/yjs/extensions/schemaMigration/migrationRules/moveColorAttributes.ts @@ -73,11 +73,13 @@ export const moveColorAttributes: MigrationRule = (fragment, tr) => { node.type.name === "blockContainer" && targetBlockContainers.has(node.attrs.id) ) { + // nodeAt(pos+1) assumes blockContent is first child (legacy input has no suggestions) const el = tr.doc.nodeAt(pos + 1); if (!el) { throw new Error("No element found"); } + // setNodeMarkup(pos+1) assumes blockContent is first child (legacy input has no suggestions) tr.setNodeMarkup(pos + 1, undefined, { // preserve existing attributes ...el.attrs, diff --git a/packages/core/src/yjs/utils.ts b/packages/core/src/yjs/utils.ts index ac8fa857b4..220d0301b2 100644 --- a/packages/core/src/yjs/utils.ts +++ b/packages/core/src/yjs/utils.ts @@ -83,6 +83,7 @@ export function _blocksToProsemirrorNode< editor: BlockNoteEditor, blocks: PartialBlock[], ) { + // blockToNode round-trip drops suggestion nodes const pmNodes = blocks.map((b) => blockToNode(b, editor.pmSchema)); const doc = editor.pmSchema.topNodeType.create( diff --git a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx index fd9ab688bf..d9e8f1d6b6 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx @@ -41,6 +41,7 @@ export const EmbedTab = < (event: KeyboardEvent) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); + // untracked prop edit on a possibly suggested file block editor.updateBlock(block.id, { props: { name: filenameFromURL(currentURL), @@ -53,6 +54,7 @@ export const EmbedTab = < ); const handleURLClick = useCallback(() => { + // untracked prop edit on a possibly suggested file block editor.updateBlock(block.id, { props: { name: filenameFromURL(currentURL), diff --git a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx index 64d5a1c74f..77b0a97bd3 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx @@ -62,6 +62,7 @@ export const UploadTab = < }, }; } + // untracked prop edit on a possibly suggested file block editor.updateBlock(props.blockId, updateData); } catch (e) { setUploadFailed(true); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx index 470a50dcba..83a7cea12e 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx @@ -19,6 +19,7 @@ export const AddCommentButtonInner = () => { const { store } = useExtension(FormattingToolbarExtension); const onClick = useCallback(() => { + // comment can anchor to a suggested-deleted range comments.startPendingComment(); store.setState(false); }, [comments, store]); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx index 309cc10e13..0655af2451 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileCaptionButton.tsx @@ -68,6 +68,7 @@ export const FileCaptionButton = () => { caption: "string", }) ) { + // untracked prop edit on a possibly suggested file block editor.updateBlock(block.id, { props: { caption: event.currentTarget.value, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx index 9e2272c594..a46f161a6e 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileDeleteButton.tsx @@ -54,6 +54,7 @@ export const FileDeleteButton = () => { const onClick = useCallback(() => { if (block !== undefined) { editor.focus(); + // untracked delete on a possibly suggested file block editor.removeBlocks([block.id]); } }, [block, editor]); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx index 3902651e30..b1522cd235 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FilePreviewButton.tsx @@ -60,6 +60,7 @@ export const FilePreviewButton = () => { showPreview: "boolean", }) ) { + // untracked prop edit on a possibly suggested file block editor.updateBlock(block.id, { props: { showPreview: !block.props.showPreview, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx index 87c30fef22..7b5808996d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/FileRenameButton.tsx @@ -68,6 +68,7 @@ export const FileRenameButton = () => { name: "string", }) ) { + // untracked prop edit on a possibly suggested file block editor.updateBlock(block.id, { props: { name: event.currentTarget.value, diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx index a21f1006cb..c80bf1435d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/TextAlignButton.tsx @@ -108,6 +108,7 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { textAlignment: defaultProps.textAlignment, }) ) { + // untracked prop edit; block may be suggested-deleted/modified editor.updateBlock(block, { props: { textAlignment: textAlignment }, }); @@ -135,6 +136,7 @@ export const TextAlignButton = (props: { textAlignment: TextAlignment }) => { newTable[row].cells[col].props.textAlignment = textAlignment; }); + // untracked content edit on a possibly suggested table block editor.updateBlock(block, { type: "table", content: { diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index d9bb12a00a..ae2a98e4e3 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -167,6 +167,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { return filteredItems.map((item) => { const Icon = item.icon; + // reads suggestion-stripped type; wrong current type for suggested-modify const typesMatch = item.type === firstSelectedBlock.type; const propsMatch = Object.entries(item.props || {}).filter( @@ -181,6 +182,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { editor.focus(); editor.transact(() => { for (const block of selectedBlocks) { + // untracked type change; block may be suggested-deleted/modified editor.updateBlock(block, { type: item.type as any, props: item.props as any, diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index a10469eab1..4a7f0eabd8 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -69,6 +69,7 @@ export const FormattingToolbarController = (props: { const placement = useEditorState({ editor, selector: ({ editor }) => { + // resolves active block with no concept of suggestion state const block = editor.getTextCursorPosition().block; if ( diff --git a/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx index ac1209fad6..d09d80fa94 100644 --- a/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx +++ b/packages/react/src/components/SideMenu/DefaultButtons/AddBlockButton.tsx @@ -27,6 +27,7 @@ export const AddBlockButton = () => { return; } + // untracked insert on a possibly suggested-deleted block const blockContent = block.content; const isBlockEmpty = blockContent !== undefined && diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx index 97694194f7..3ff9536405 100644 --- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/BlockColorsItem.tsx @@ -55,6 +55,7 @@ export const BlockColorsItem = (props: { children: ReactNode }) => { }) ? { color: block.props.textColor, + // untracked prop edit; block may be suggested-deleted/modified setColor: (color) => editor.updateBlock(block, { type: block.type, diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/RemoveBlockItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/RemoveBlockItem.tsx index 12ade8b4ae..84ae65429e 100644 --- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/RemoveBlockItem.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/RemoveBlockItem.tsx @@ -28,6 +28,7 @@ export const RemoveBlockItem = (props: { children: ReactNode }) => { selectedBlocks && selectedBlocks.some((b) => b.id === block.id) ? selectedBlocks : [block]; + // untracked delete; bypasses suggestion system on a possibly suggested block editor.removeBlocks(blocksToRemove); }} > diff --git a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/TableHeadersItem.tsx b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/TableHeadersItem.tsx index 51211b9e06..2d0a5e4a23 100644 --- a/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/TableHeadersItem.tsx +++ b/packages/react/src/components/SideMenu/DragHandleMenu/DefaultItems/TableHeadersItem.tsx @@ -44,6 +44,7 @@ export const TableRowHeaderItem = (props: { children: ReactNode }) => { className={"bn-menu-item"} checked={isHeaderRow} onClick={() => { + // untracked prop edit on a possibly suggested table block editor.updateBlock(block, { content: { ...block.content, @@ -95,6 +96,7 @@ export const TableColumnHeaderItem = (props: { children: ReactNode }) => { className={"bn-menu-item"} checked={isHeaderColumn} onClick={() => { + // untracked prop edit on a possibly suggested table block editor.updateBlock(block, { content: { ...block.content, diff --git a/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx index 9b863bde47..85a1f70952 100644 --- a/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx +++ b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx @@ -99,6 +99,7 @@ export const ExtendButton = ( return; } + // untracked content edit on a possibly suggested table block editor.updateBlock(block, { type: "table", content: { @@ -159,6 +160,7 @@ export const ExtendButton = ( newNumCells > 0 && newNumCells !== currentNumCells ) { + // untracked content edit on a possibly suggested table block editor.updateBlock(block, { type: "table", content: { diff --git a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx index 36e1ecca2f..f20f3983d3 100644 --- a/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableCellMenu/DefaultButtons/ColorPicker.tsx @@ -33,6 +33,7 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { return; } + // reads only new content; suggested-modify old content is invisible const newTable = block.content.rows.map((row) => { return { ...row, @@ -46,6 +47,7 @@ export const ColorPickerButton = (props: { children?: ReactNode }) => { newTable[rowIndex].cells[colIndex].props.backgroundColor = color; } + // untracked content edit on a possibly suggested table block editor.updateBlock(block, { type: "table", content: { diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/ColorPicker.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/ColorPicker.tsx index 812a44ec35..72399b2b48 100644 --- a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/ColorPicker.tsx +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/ColorPicker.tsx @@ -63,6 +63,7 @@ export const ColorPickerButton = < return; } + // reads only new content; suggested-modify old content is invisible const newTable = block.content.rows.map((row) => { return { ...row, @@ -78,6 +79,7 @@ export const ColorPickerButton = < } }); + // untracked content edit on a possibly suggested table block editor.updateBlock(block, { type: "table", content: { diff --git a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/TableHeaderButton.tsx b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/TableHeaderButton.tsx index 285f251b48..8cd08ee3a3 100644 --- a/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/TableHeaderButton.tsx +++ b/packages/react/src/components/TableHandles/TableHandleMenu/DefaultButtons/TableHeaderButton.tsx @@ -55,6 +55,7 @@ export const TableHeaderRowButton = < className={"bn-menu-item"} checked={isHeaderRow} onClick={() => { + // untracked prop edit on a possibly suggested table block editor.updateBlock(block, { ...block, content: { @@ -110,6 +111,7 @@ export const TableHeaderColumnButton = < className={"bn-menu-item"} checked={isHeaderColumn} onClick={() => { + // untracked prop edit on a possibly suggested table block editor.updateBlock(block, { ...block, content: { diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index da722395b3..8fbbe8692c 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -266,6 +266,7 @@ export function createReactBlockSpec< return output; }, render(block, editor) { + // nodeView wires real node only, shadow stays vanilla editable if (this.renderType === "nodeView") { return ReactNodeViewRenderer( (props: NodeViewProps) => { diff --git a/packages/xl-ai/src/prosemirror/agent.ts b/packages/xl-ai/src/prosemirror/agent.ts index f0c5f0063a..3866831f86 100644 --- a/packages/xl-ai/src/prosemirror/agent.ts +++ b/packages/xl-ai/src/prosemirror/agent.ts @@ -186,6 +186,7 @@ export function getStepsAsAgent(inputTr: Transform) { const stepIndex = tr.steps.length; if (isReplacing) { const $pos = tr.doc.resolve(tr.mapping.map(from)); + // isBlock true for shadow nodes, may mark suggestion content if ($pos.nodeAfter?.isBlock) { // mark the entire node as deleted. This can be needed for inline nodes or table cells tr.addNodeMark($pos.pos, pmSchema.mark("y-attributed-delete", {})); @@ -216,6 +217,7 @@ export function getStepsAsAgent(inputTr: Transform) { ) { return true; } + // isBlock true for shadow nodes, may mark suggestion content if (node.isBlock) { tr.addNodeMark(pos, pmSchema.mark("y-attributed-insert", {})); } diff --git a/packages/xl-ai/src/prosemirror/changeset.ts b/packages/xl-ai/src/prosemirror/changeset.ts index 7f40964016..eab66ce5eb 100644 --- a/packages/xl-ai/src/prosemirror/changeset.ts +++ b/packages/xl-ai/src/prosemirror/changeset.ts @@ -128,6 +128,7 @@ function addMissingChanges( } const createEncoder = (doc: Node, updatedDoc: Node) => { + // encoder ignores --attributed shadow nodes, diffs suggestion content // this encoder makes sure unchanged table cells stay intact, // without this, prosemirror-changeset would too eagerly // return changes across table cells (this is covered in test cases). diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index e93b266634..6f6a7ef4b3 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -36,6 +36,7 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle empty slice drops } + // nodeToBlock strips suggestion nodes const draggedBlock = nodeToBlock( slice.content.child(0), editor.pmSchema, @@ -47,6 +48,7 @@ export function createMultiColumnHandleDropPlugin( .resolve(blockInfo.bnBlock.beforePos) .node(); + // nodeToBlock strips suggestion nodes const columnList = nodeToBlock( parentBlock, editor.pmSchema, @@ -111,6 +113,7 @@ export function createMultiColumnHandleDropPlugin( }); } else { // Create new columnList with blocks as columns + // nodeToBlock strips suggestion nodes const block = nodeToBlock(blockInfo.bnBlock.node, editor.pmSchema); // The user is dropping next to the original block being dragged - do From ba7403445e78c9393de3a5cc4897c72f71d2e9e1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 4 Jun 2026 16:53:17 +0200 Subject: [PATCH 2/3] build: annotate suggestion blast radius and add summary Add root-cause counter at getBlockInfoFromPos (suggestionBefore/After unused by callers), census-pass position annotations, comment cleanup, and a SUGGESTIONS_BLAST_RADIUS.md covering the conceptual fallout. --- packages/core/SUGGESTIONS_BLAST_RADIUS.md | 150 ++++++++++++++++++ .../commands/updateBlock/updateBlock.ts | 1 - .../api/blockManipulation/insertContentAt.ts | 1 + .../blockManipulation/selections/selection.ts | 2 + packages/core/src/api/getBlockInfoFromPos.ts | 1 + .../src/api/nodeConversions/blockToNode.ts | 2 +- .../src/api/nodeConversions/nodeToBlock.ts | 2 +- .../NumberedListItem/IndexingPlugin.ts | 3 + packages/core/src/comments/extension.ts | 1 + .../core/src/extensions/DropCursor/utils.ts | 2 + .../NodeSelectionKeyboard.ts | 2 + packages/core/src/yjs/utils.ts | 1 - .../DropCursor/multiColumnHandleDropPlugin.ts | 3 - 13 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 packages/core/SUGGESTIONS_BLAST_RADIUS.md diff --git a/packages/core/SUGGESTIONS_BLAST_RADIUS.md b/packages/core/SUGGESTIONS_BLAST_RADIUS.md new file mode 100644 index 0000000000..820588200f --- /dev/null +++ b/packages/core/SUGGESTIONS_BLAST_RADIUS.md @@ -0,0 +1,150 @@ +# Suggestion content-spec blast radius + +This document tracks the conceptual fallout of the `blockContainer` content-spec +change and complements the inline `// ...` annotations scattered across the +codebase. Search the source for the root-cause counter comments +(`(Affects ~N callsites)`) to find the systemic entry points. + +## The change + +`packages/core/src/pm-nodes/BlockContainer.ts:27` + +``` +- content: "blockContent blockGroup?" ++ content: "suggestionBlockContent* blockContent suggestionBlockContent* blockGroup?" +``` + +A `blockContainer` can now hold 0-N `suggestionBlockContent` nodes both **before** +and **after** its `blockContent`. These extra nodes hold the *old* content of a +block that has a pending "modify" suggestion. + +## Two distinct suggestion mechanisms + +1. **Block-level marks** — `y-attributed-insert` / `y-attributed-format` / + `y-attributed-delete` (`SuggestionMarks.ts`), allowed on `blockContainer`, + `blockGroup`, `column`, table nodes, etc. These mark a *real* block/structure + as inserted, deleted, or modified. +2. **Shadow nodes** — `${type}--attributed` nodes (e.g. `paragraph--attributed`), + created in `schema/blocks/createSpec.ts`. Group is the COMPOUND string + `"suggestionBlockContent blockContent"`. They sit inside a `blockContainer` + alongside `blockContent` and hold the old content of a modified block. + +## Footguns + +- **Compound group string.** `node.type.spec.group === "blockContent"` and + `=== "suggestionBlockContent"` are BOTH `false` for a shadow node, because the + spec string is `"suggestionBlockContent blockContent"`. Always use + `node.type.isInGroup("blockContent")` / `isInGroup("suggestionBlockContent")`. + Several pre-existing guards (`splitBlock.ts:50`, `internal.ts:104`, + `nodeToBlock.ts:608`) are effectively dead because of strict-equality or a + `"suggestion-"` name prefix that the real `*--attributed` nodes never match. +- **Naming.** Real nodes are `*--attributed`, NOT `suggestion-*`. + `SpecialNode.test.ts` asserts the non-existent `suggestion-*` names. +- **`firstChild` is no longer `blockContent`.** Any code reading + `blockContainer.firstChild`, `nodeAt(pos + 1)`, or `childCount === 1` on a + container now risks landing on / miscounting a leading shadow node. +- **Editable shadow content.** Shadow nodes are `selectable: false` but render + with an editable `contentDOM` and have NO node view, so + `applyNonSelectableBlockFix` never runs. A caret/text-selection can land + inside a shadow node, which makes those positions genuinely reachable. + +## What is safe vs. exposed + +- **Safe:** positions derived from `getBlockInfo*` (`bnBlock`, `blockContent`, + `childContainer` carry suggestion-aware offsets); walks filtered by the + `bnBlock` group (shadow nodes are not `bnBlock`); operations at the + blockGroup / column / columnList sibling level (their children are + containers, never shadow nodes); DOM and Block-JSON code paths; + freshly-parsed, suggestion-free fragments. +- **Exposed:** raw caret/click positions that read `$pos.parent` or step across + a node boundary (`$pos.after()`, `pos ± 1`, `nodeBefore`/`nodeAfter`) without + re-deriving from `getBlockInfo`. + +## Conceptual questions to resolve + +### What is inside `editor.document`? + +`editor.document` is produced by `nodeToBlock` (`(Affects ~23 callsites)`), which +builds `Block = {id, type, props, content, children}` only. It **strips all +suggestion state**: shadow nodes are dropped and `y-attributed-*` marks are +ignored. Consequences: + +- `block.content` is always the *new* content; the old (shadow) content is + invisible to the entire Block API. +- The public `Block` type has no field describing suggestion state. There is no + supported way to ask "is this block inserted/deleted/modified?". +- Every consumer of `editor.document` / `getSelection().blocks` / `getBlock` is + suggestion-blind, including all public export APIs (`ExportManager`). + +### What does a "deleted" block mean? + +A suggested-deleted block still: + +- has its `data-id`, +- is in the `bnBlock` group, +- resolves through `getNodeById` / `getBlock` / `forEachBlock` as a normal, + live block, with **no deletion flag**. + +So menus and commands happily read and mutate content the user believes is +deleted. This is the most acute hazard: the API cannot distinguish a live block +from one pending deletion. + +### How do menu items interact with this? + +Every side-menu / formatting-toolbar / table-handle action resolves a +suggestion-blind `Block` and mutates it through `updateBlock` / `removeBlocks` / +`insertBlocks`, none of which are suggestion-aware. So: + +- Type conversion, color, alignment, file ops on a suggested block are + **untracked** edits that corrupt the diff. +- The "+" button inserts an untracked block next to tracked content. +- Drag-and-drop moves a suggested block and `deleteSelection`s the origin, + again untracked. +- `BlockTypeSelect` etc. display the *new* content's type, never reflecting that + the block is a tracked change. + +When suggestions are enabled, these need to be either disabled or routed through +accept/reject semantics. + +### How would the unique-id extension rewrite ids? + +`UniqueID.ts` assigns / dedupes `data-id`. Shadow nodes do not carry their own +`data-id` (it lives on the outer `blockContainer`), so they should not be +assigned ids. But dedupe logic (`UniqueID.ts:208`) can regenerate an id when a +suggestion transiently produces a duplicate, potentially detaching a block from +its tracked history. Verify dedupe ignores shadow-bearing transitions. + +### Multi-column + +- `column` content is `"blockContainer+"` and `column` carries `y-attributed-*` + marks, so a whole column can be a tracked insert/delete. +- `multiColumnDropCursor.ts` / `ColumnResizeExtension.ts` operate at the + column / columnList level (safe for shadow ordering) but resolve blocks via + `getNodeById` (suggestion-blind). +- `multiColumnHandleDropPlugin.ts` reconstructs an entire `columnList` from + `nodeToBlock` output, so any suggestion state inside the moved columns is + dropped on drop. This is the highest-risk multi-column path. + +### Round-trips and serialization + +- `blockToNode` (`(Affects ~7 callsites)`) emits only `[contentNode, groupNode]`, + so any Block-JSON round-trip drops suggestions. +- Clipboard copy uses ProseMirror's native serializer on the raw slice, which + *does* emit shadow nodes — so copy/paste can duplicate or inject shadow + content, and the `text/html` / markdown clipboard branches (Block-JSON based) + disagree with the `blocknote/html` branch about whether suggestions exist. + +### Schema-validity hazards + +- `updateBlock` inserts a `blockGroup` at `blockContent.afterPos`, which is + *before* a trailing shadow node — violating the `... blockGroup?` ordering. +- `mergeBlocks` is partly suggestion-aware but drops `prev.suggestionBefore` and + `next.suggestionAfter`, and reconstructs with `type.create` (no validation). +- `BlockInfo.suggestionBefore` / `suggestionAfter` are singular while the schema + allows `*`; containers with ≥2 shadows on a side are not fully represented. + +## Where to look + +- Systemic counters: `nodeToBlock.ts`, `blockToNode.ts`, `getBlockInfoFromPos.ts` + (the `(Affects ~N callsites)` comments). +- Everything else: grep for the single-line `// ... suggestion ...` annotations. diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 264fbe7285..7dbafe2c80 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -127,7 +127,6 @@ export function updateBlockTr< // 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 - // nodeToBlock→blockToNode round-trip drops suggestion nodes const existingBlock = nodeToBlock(blockInfo.bnBlock.node, pmSchema); const replacementNode = blockToNode( { diff --git a/packages/core/src/api/blockManipulation/insertContentAt.ts b/packages/core/src/api/blockManipulation/insertContentAt.ts index 3409583a37..2787cf50bf 100644 --- a/packages/core/src/api/blockManipulation/insertContentAt.ts +++ b/packages/core/src/api/blockManipulation/insertContentAt.ts @@ -47,6 +47,7 @@ export function insertContentAt( // replace an empty paragraph by an inserted image // instead of inserting the image below the paragraph if (from === to && isOnlyBlockContent) { + // parent may be an empty suggestion shadow node, not blockContent const { parent } = tr.doc.resolve(from); const isEmptyTextBlock = parent.isTextblock && !parent.type.spec.code && !parent.childCount; diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index e5bd761918..fc23c438d7 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -236,6 +236,7 @@ export function getSelectionCutBlocks(tr: Transaction, expandToWords = false) { // the correct information about whether content is cut at the start or end of a block // if the end is at the end of a node (|

) move it forward so we include all closing tags (

|) + // end.parent may be a suggestion shadow node when crossing a block boundary while (end.parentOffset >= end.parent.nodeSize - 2 && end.depth > 0) { end = tr.doc.resolve(end.pos + 1); } @@ -251,6 +252,7 @@ export function getSelectionCutBlocks(tr: Transaction, expandToWords = false) { } // if the start is at the end of a node (|

|) move it forwards so we drop all closing tags (|

) + // start.parent may be a suggestion shadow node when crossing a block boundary while (start.parentOffset >= start.parent.nodeSize - 2 && start.depth > 0) { start = tr.doc.resolve(start.pos + 1); } diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 31e3be1356..c898ba4e91 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -41,6 +41,7 @@ export type BlockInfo = { * Whether bnBlock is a blockContainer node */ isBlockContainer: true; + // (Affects ~58 callsites) consumers use blockContent/bnBlock but never branch on suggestionBefore/After /** * A suggestion node that appears before the blockContent, if present. * Suggestion nodes have group "suggestionBlockContent" and are used for diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 888ff40df4..258ea93e19 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -321,7 +321,7 @@ function blockOrInlineContentToContentNode( /** * Converts a BlockNote block to a Prosemirror node. */ -// (Affects ~4) emits no suggestion nodes, round-trips drop suggestions +// (Affects ~7 callsites) emits no suggestion nodes, round-trips drop suggestions export function blockToNode( block: PartialBlock, schema: Schema, diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 9ffccc3f90..f921b51c69 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -482,7 +482,7 @@ export function nodeToBlock< throw new UnreachableCaseError(blockConfig.content); } - // (Affects ~9) strips suggestion state, Block API is suggestion-blind + // (Affects ~23 callsites) strips suggestion state, Block API is suggestion-blind const block = { id, type: blockConfig.type, diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index 369002377e..9099d743f8 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -94,6 +94,7 @@ function calculateListItemIndex( isFirst = false; } else { // Start of a new list + // firstChild may be a suggestion node, not blockContent index = (lastInChain.node.firstChild!.attrs["start"] || 1) - 1; isFirst = true; } @@ -157,6 +158,7 @@ function getDecorations( if ( node.type.name === "blockContainer" && + // firstChild may be a suggestion node, not blockContent node.firstChild!.type.name === "numberedListItem" ) { const { index, isFirst, hasStart } = calculateListItemIndex( @@ -169,6 +171,7 @@ function getDecorations( // Search only the numberedListItem node range, not the full // blockContainer (which includes nested blockGroups whose // decorations could falsely match). + // pos + 1 assumes blockContent is first child, not a suggestion node const blockNode = tr.doc.nodeAt(pos + 1)!; const existingDecorations = nextDecorationSet.find( pos + 1, diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index 23547eb383..5141a13611 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -229,6 +229,7 @@ export const CommentsExtension = createExtension( return false; } + // click may land in a suggestion shadow node, resolving its comment marks const node = view.state.doc.nodeAt(pos); if (!node) { diff --git a/packages/core/src/extensions/DropCursor/utils.ts b/packages/core/src/extensions/DropCursor/utils.ts index 25e3985238..8e7d00e170 100644 --- a/packages/core/src/extensions/DropCursor/utils.ts +++ b/packages/core/src/extensions/DropCursor/utils.ts @@ -58,8 +58,10 @@ export function getBlockDropRect( return null; } + // nodeBefore may be a suggestion node, not blockContent const before = $pos.nodeBefore; + // nodeAfter may be a suggestion node, not blockContent const after = $pos.nodeAfter; if (!before && !after) { diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts index 357780f44a..2fe139a5a2 100644 --- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts +++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboard.ts @@ -49,6 +49,7 @@ export const NodeSelectionKeyboardExtension = createExtension( const tr = view.state.tr; view.dispatch( tr + // $to.after() may land before a trailing suggestion node .insert( view.state.tr.selection.$to.after(), view.state.schema.nodes["paragraph"].createChecked(), @@ -56,6 +57,7 @@ export const NodeSelectionKeyboardExtension = createExtension( .setSelection( new TextSelection( tr.doc.resolve( + // +1 may resolve inside a trailing suggestion node view.state.tr.selection.$to.after() + 1, ), ), diff --git a/packages/core/src/yjs/utils.ts b/packages/core/src/yjs/utils.ts index 220d0301b2..ac8fa857b4 100644 --- a/packages/core/src/yjs/utils.ts +++ b/packages/core/src/yjs/utils.ts @@ -83,7 +83,6 @@ export function _blocksToProsemirrorNode< editor: BlockNoteEditor, blocks: PartialBlock[], ) { - // blockToNode round-trip drops suggestion nodes const pmNodes = blocks.map((b) => blockToNode(b, editor.pmSchema)); const doc = editor.pmSchema.topNodeType.create( diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 6f6a7ef4b3..e93b266634 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -36,7 +36,6 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle empty slice drops } - // nodeToBlock strips suggestion nodes const draggedBlock = nodeToBlock( slice.content.child(0), editor.pmSchema, @@ -48,7 +47,6 @@ export function createMultiColumnHandleDropPlugin( .resolve(blockInfo.bnBlock.beforePos) .node(); - // nodeToBlock strips suggestion nodes const columnList = nodeToBlock( parentBlock, editor.pmSchema, @@ -113,7 +111,6 @@ export function createMultiColumnHandleDropPlugin( }); } else { // Create new columnList with blocks as columns - // nodeToBlock strips suggestion nodes const block = nodeToBlock(blockInfo.bnBlock.node, editor.pmSchema); // The user is dropping next to the original block being dragged - do From 5dbb5131e75753a1d482e8ab5a1cc2485a034a17 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 4 Jun 2026 17:03:30 +0200 Subject: [PATCH 3/3] build: remove blast radius summary file --- packages/core/SUGGESTIONS_BLAST_RADIUS.md | 150 ---------------------- 1 file changed, 150 deletions(-) delete mode 100644 packages/core/SUGGESTIONS_BLAST_RADIUS.md diff --git a/packages/core/SUGGESTIONS_BLAST_RADIUS.md b/packages/core/SUGGESTIONS_BLAST_RADIUS.md deleted file mode 100644 index 820588200f..0000000000 --- a/packages/core/SUGGESTIONS_BLAST_RADIUS.md +++ /dev/null @@ -1,150 +0,0 @@ -# Suggestion content-spec blast radius - -This document tracks the conceptual fallout of the `blockContainer` content-spec -change and complements the inline `// ...` annotations scattered across the -codebase. Search the source for the root-cause counter comments -(`(Affects ~N callsites)`) to find the systemic entry points. - -## The change - -`packages/core/src/pm-nodes/BlockContainer.ts:27` - -``` -- content: "blockContent blockGroup?" -+ content: "suggestionBlockContent* blockContent suggestionBlockContent* blockGroup?" -``` - -A `blockContainer` can now hold 0-N `suggestionBlockContent` nodes both **before** -and **after** its `blockContent`. These extra nodes hold the *old* content of a -block that has a pending "modify" suggestion. - -## Two distinct suggestion mechanisms - -1. **Block-level marks** — `y-attributed-insert` / `y-attributed-format` / - `y-attributed-delete` (`SuggestionMarks.ts`), allowed on `blockContainer`, - `blockGroup`, `column`, table nodes, etc. These mark a *real* block/structure - as inserted, deleted, or modified. -2. **Shadow nodes** — `${type}--attributed` nodes (e.g. `paragraph--attributed`), - created in `schema/blocks/createSpec.ts`. Group is the COMPOUND string - `"suggestionBlockContent blockContent"`. They sit inside a `blockContainer` - alongside `blockContent` and hold the old content of a modified block. - -## Footguns - -- **Compound group string.** `node.type.spec.group === "blockContent"` and - `=== "suggestionBlockContent"` are BOTH `false` for a shadow node, because the - spec string is `"suggestionBlockContent blockContent"`. Always use - `node.type.isInGroup("blockContent")` / `isInGroup("suggestionBlockContent")`. - Several pre-existing guards (`splitBlock.ts:50`, `internal.ts:104`, - `nodeToBlock.ts:608`) are effectively dead because of strict-equality or a - `"suggestion-"` name prefix that the real `*--attributed` nodes never match. -- **Naming.** Real nodes are `*--attributed`, NOT `suggestion-*`. - `SpecialNode.test.ts` asserts the non-existent `suggestion-*` names. -- **`firstChild` is no longer `blockContent`.** Any code reading - `blockContainer.firstChild`, `nodeAt(pos + 1)`, or `childCount === 1` on a - container now risks landing on / miscounting a leading shadow node. -- **Editable shadow content.** Shadow nodes are `selectable: false` but render - with an editable `contentDOM` and have NO node view, so - `applyNonSelectableBlockFix` never runs. A caret/text-selection can land - inside a shadow node, which makes those positions genuinely reachable. - -## What is safe vs. exposed - -- **Safe:** positions derived from `getBlockInfo*` (`bnBlock`, `blockContent`, - `childContainer` carry suggestion-aware offsets); walks filtered by the - `bnBlock` group (shadow nodes are not `bnBlock`); operations at the - blockGroup / column / columnList sibling level (their children are - containers, never shadow nodes); DOM and Block-JSON code paths; - freshly-parsed, suggestion-free fragments. -- **Exposed:** raw caret/click positions that read `$pos.parent` or step across - a node boundary (`$pos.after()`, `pos ± 1`, `nodeBefore`/`nodeAfter`) without - re-deriving from `getBlockInfo`. - -## Conceptual questions to resolve - -### What is inside `editor.document`? - -`editor.document` is produced by `nodeToBlock` (`(Affects ~23 callsites)`), which -builds `Block = {id, type, props, content, children}` only. It **strips all -suggestion state**: shadow nodes are dropped and `y-attributed-*` marks are -ignored. Consequences: - -- `block.content` is always the *new* content; the old (shadow) content is - invisible to the entire Block API. -- The public `Block` type has no field describing suggestion state. There is no - supported way to ask "is this block inserted/deleted/modified?". -- Every consumer of `editor.document` / `getSelection().blocks` / `getBlock` is - suggestion-blind, including all public export APIs (`ExportManager`). - -### What does a "deleted" block mean? - -A suggested-deleted block still: - -- has its `data-id`, -- is in the `bnBlock` group, -- resolves through `getNodeById` / `getBlock` / `forEachBlock` as a normal, - live block, with **no deletion flag**. - -So menus and commands happily read and mutate content the user believes is -deleted. This is the most acute hazard: the API cannot distinguish a live block -from one pending deletion. - -### How do menu items interact with this? - -Every side-menu / formatting-toolbar / table-handle action resolves a -suggestion-blind `Block` and mutates it through `updateBlock` / `removeBlocks` / -`insertBlocks`, none of which are suggestion-aware. So: - -- Type conversion, color, alignment, file ops on a suggested block are - **untracked** edits that corrupt the diff. -- The "+" button inserts an untracked block next to tracked content. -- Drag-and-drop moves a suggested block and `deleteSelection`s the origin, - again untracked. -- `BlockTypeSelect` etc. display the *new* content's type, never reflecting that - the block is a tracked change. - -When suggestions are enabled, these need to be either disabled or routed through -accept/reject semantics. - -### How would the unique-id extension rewrite ids? - -`UniqueID.ts` assigns / dedupes `data-id`. Shadow nodes do not carry their own -`data-id` (it lives on the outer `blockContainer`), so they should not be -assigned ids. But dedupe logic (`UniqueID.ts:208`) can regenerate an id when a -suggestion transiently produces a duplicate, potentially detaching a block from -its tracked history. Verify dedupe ignores shadow-bearing transitions. - -### Multi-column - -- `column` content is `"blockContainer+"` and `column` carries `y-attributed-*` - marks, so a whole column can be a tracked insert/delete. -- `multiColumnDropCursor.ts` / `ColumnResizeExtension.ts` operate at the - column / columnList level (safe for shadow ordering) but resolve blocks via - `getNodeById` (suggestion-blind). -- `multiColumnHandleDropPlugin.ts` reconstructs an entire `columnList` from - `nodeToBlock` output, so any suggestion state inside the moved columns is - dropped on drop. This is the highest-risk multi-column path. - -### Round-trips and serialization - -- `blockToNode` (`(Affects ~7 callsites)`) emits only `[contentNode, groupNode]`, - so any Block-JSON round-trip drops suggestions. -- Clipboard copy uses ProseMirror's native serializer on the raw slice, which - *does* emit shadow nodes — so copy/paste can duplicate or inject shadow - content, and the `text/html` / markdown clipboard branches (Block-JSON based) - disagree with the `blocknote/html` branch about whether suggestions exist. - -### Schema-validity hazards - -- `updateBlock` inserts a `blockGroup` at `blockContent.afterPos`, which is - *before* a trailing shadow node — violating the `... blockGroup?` ordering. -- `mergeBlocks` is partly suggestion-aware but drops `prev.suggestionBefore` and - `next.suggestionAfter`, and reconstructs with `type.create` (no validation). -- `BlockInfo.suggestionBefore` / `suggestionAfter` are singular while the schema - allows `*`; containers with ≥2 shadows on a side are not fully represented. - -## Where to look - -- Systemic counters: `nodeToBlock.ts`, `blockToNode.ts`, `getBlockInfoFromPos.ts` - (the `(Affects ~N callsites)` comments). -- Everything else: grep for the single-line `// ... suggestion ...` annotations.