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.