Skip to content

Commit 826ae2f

Browse files
committed
feat: suggestion node transparency, keyboard/merge/move/split support, and comprehensive tests
- BlockContainer content spec: change suggestionBlockContent? to * for multiple suggestion nodes - getBlockInfoFromPos: track suggestionBefore/suggestionAfter, add isSelectionAtBlockStart/isSelectionAtBlockEnd helpers - KeyboardShortcutsExtension: use selection helpers to account for suggestion nodes at block boundaries - mergeBlocks: preserve suggestion nodes during block merging - moveBlocks: PM-level move preserving internal node structure - splitBlock: redirect splits inside suggestion nodes to blockContent - nodeToBlock/prosemirrorSliceToSlicedBlocks: skip suggestion nodes when finding structural children - createSpec: suggestion nodes now have parseHTML/renderHTML with data-suggestion attribute for HTML round-trip fidelity - SpecialNode.test.ts: 20 tests covering structural, HTML parsing transparency, nodeToBlock, export, round-trip, DOMParser, and getBlockInfo interaction - SpecialNodeOperations.test.ts: additional operation-level tests - App.tsx example: multi-block demo with leading/trailing suggestions
1 parent 02b1f17 commit 826ae2f

11 files changed

Lines changed: 2364 additions & 64 deletions

File tree

examples/01-basic/01-minimal/src/App.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,53 @@ export default function App() {
4545
// └─ blockContainer
4646
// ├─ suggestion-paragraph("Hello from suggestion-paragraph!")
4747
// └─ paragraph("Hello from blockContainer!")
48-
const blockContainer = nodes.blockContainer.create(
48+
const blockContainer1 = nodes.blockContainer.create(
4949
{ id: "block-1" },
5050
[suggestionParagraph, mainParagraph],
5151
);
5252

53-
const blockGroup = nodes.blockGroup.create(null, [blockContainer]);
53+
// Second block: paragraph with trailing suggestion
54+
const mainParagraph2 = nodes.paragraph.create(
55+
{
56+
backgroundColor: "default",
57+
textAlignment: "left",
58+
textColor: "default",
59+
},
60+
[editor.pmSchema.text("Second block main content")],
61+
);
62+
const trailingSuggestion = nodes["suggestion-paragraph"].create(
63+
{
64+
backgroundColor: "default",
65+
textAlignment: "left",
66+
textColor: "default",
67+
__suggestionData: "true",
68+
},
69+
[editor.pmSchema.text("Trailing suggestion text")],
70+
);
71+
const blockContainer2 = nodes.blockContainer.create(
72+
{ id: "block-2" },
73+
[mainParagraph2, trailingSuggestion],
74+
);
75+
76+
// Third block: plain paragraph (no suggestions)
77+
const mainParagraph3 = nodes.paragraph.create(
78+
{
79+
backgroundColor: "default",
80+
textAlignment: "left",
81+
textColor: "default",
82+
},
83+
[editor.pmSchema.text("Third block, no suggestions")],
84+
);
85+
const blockContainer3 = nodes.blockContainer.create(
86+
{ id: "block-3" },
87+
[mainParagraph3],
88+
);
89+
90+
const blockGroup = nodes.blockGroup.create(null, [
91+
blockContainer1,
92+
blockContainer2,
93+
blockContainer3,
94+
]);
5495
const newDoc = nodes.doc.create(null, [blockGroup]);
5596

5697
tr.replaceWith(0, tr.doc.content.size, newDoc.content);

packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,102 @@ const mergeBlocks = (
153153
);
154154
}
155155

156-
// TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
157-
dispatch(
158-
state.tr.delete(
159-
prevBlockInfo.blockContent.afterPos - 1,
160-
nextBlockInfo.blockContent.beforePos + 1,
161-
),
156+
const tr = state.tr;
157+
158+
// After a potential lift, positions may have changed. Re-resolve block
159+
// info from the transaction's current doc.
160+
const mappedPrevPos = tr.mapping.map(prevBlockInfo.bnBlock.beforePos);
161+
const mappedNextPos = tr.mapping.map(nextBlockInfo.bnBlock.beforePos);
162+
const currentPrevInfo = getBlockInfoFromResolvedPos(
163+
tr.doc.resolve(mappedPrevPos),
162164
);
165+
const currentNextInfo = getBlockInfoFromResolvedPos(
166+
tr.doc.resolve(mappedNextPos),
167+
);
168+
169+
if (!currentPrevInfo.isBlockContainer || !currentNextInfo.isBlockContainer) {
170+
// Fallback to original behavior if blocks are no longer containers
171+
tr.delete(
172+
tr.mapping.map(prevBlockInfo.blockContent.afterPos - 1),
173+
tr.mapping.map(nextBlockInfo.blockContent.beforePos + 1),
174+
);
175+
dispatch(tr);
176+
return true;
177+
}
178+
179+
// Save suggestion node content before reconstruction
180+
const savedPrevSuggAfter = currentPrevInfo.suggestionAfter
181+
? currentPrevInfo.suggestionAfter.node.copy(
182+
currentPrevInfo.suggestionAfter.node.content,
183+
)
184+
: null;
185+
const savedNextSuggBefore = currentNextInfo.suggestionBefore
186+
? currentNextInfo.suggestionBefore.node.copy(
187+
currentNextInfo.suggestionBefore.node.content,
188+
)
189+
: null;
190+
191+
// If no suggestion nodes are involved, use the original simple delete
192+
if (!savedPrevSuggAfter && !savedNextSuggBefore) {
193+
// TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
194+
tr.delete(
195+
currentPrevInfo.blockContent.afterPos - 1,
196+
currentNextInfo.blockContent.beforePos + 1,
197+
);
198+
dispatch(tr);
199+
return true;
200+
}
201+
202+
// Reconstruct the merged blockContainer preserving suggestion nodes.
203+
//
204+
// Strategy: Replace the range from prev block start to next block end
205+
// with a single reconstructed blockContainer containing:
206+
// [suggestionBefore?] [mergedBlockContent] [suggestionAfter?] [blockGroup?]
207+
208+
// Get the merged inline content by combining both paragraphs' content
209+
const mergedContent = currentPrevInfo.blockContent.node.content.append(
210+
currentNextInfo.blockContent.node.content,
211+
);
212+
213+
// Create the merged blockContent node (use prev block's type/attrs)
214+
const mergedBlockContent =
215+
currentPrevInfo.blockContent.node.copy(mergedContent);
216+
217+
// Build the children array for the reconstructed blockContainer
218+
const newChildren: Node[] = [];
219+
220+
// Leading suggestion from next block
221+
if (savedNextSuggBefore) {
222+
newChildren.push(savedNextSuggBefore);
223+
}
224+
225+
// Merged block content
226+
newChildren.push(mergedBlockContent);
227+
228+
// Trailing suggestion from prev block
229+
if (savedPrevSuggAfter) {
230+
newChildren.push(savedPrevSuggAfter);
231+
}
232+
233+
// blockGroup from prev block (next block's children were already lifted)
234+
if (currentPrevInfo.childContainer) {
235+
newChildren.push(currentPrevInfo.childContainer.node);
236+
}
237+
238+
// Create the new blockContainer with the prev block's ID and attributes
239+
const newBlockContainer = currentPrevInfo.bnBlock.node.type.create(
240+
currentPrevInfo.bnBlock.node.attrs,
241+
newChildren,
242+
);
243+
244+
// Replace the entire range from prev block to next block
245+
tr.replaceWith(
246+
currentPrevInfo.bnBlock.beforePos,
247+
currentNextInfo.bnBlock.afterPos,
248+
newBlockContainer,
249+
);
250+
251+
dispatch(tr);
163252
}
164253

165254
return true;

packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { Fragment, type Node, Slice } from "prosemirror-model";
12
import {
23
NodeSelection,
34
Selection,
45
TextSelection,
56
Transaction,
67
} from "prosemirror-state";
8+
import { ReplaceStep } from "prosemirror-transform";
79
import { CellSelection } from "prosemirror-tables";
810

911
import { Block } from "../../../../blocks/defaultBlocks.js";
@@ -135,7 +137,14 @@ function flattenColumns(
135137

136138
/**
137139
* Removes the given blocks from the editor, then inserts them before/after a
138-
* reference block.
140+
* reference block. Operates at the ProseMirror level to preserve internal node
141+
* structure (including suggestion nodes) that would be lost in a Block API
142+
* round-trip.
143+
*
144+
* When column blocks are involved, falls back to the Block API round-trip
145+
* because columns require structural flattening that is not compatible with
146+
* raw PM node copying.
147+
*
139148
* @param editor The BlockNote editor instance to move the blocks in.
140149
* @param blocks The blocks to move.
141150
* @param referenceBlock The reference block to insert the blocks before/after.
@@ -148,7 +157,7 @@ export function moveBlocks(
148157
referenceBlock: BlockIdentifier,
149158
placement: "before" | "after",
150159
) {
151-
editor.transact(() => {
160+
editor.transact((tr) => {
152161
// A `columnList` reference can be dissolved by `fixColumnList` when its
153162
// `column`s are removed, leaving its ID invalid for re-insertion. Anchor
154163
// to an adjacent block instead, which is unaffected by the removal.
@@ -164,8 +173,106 @@ export function moveBlocks(
164173
}
165174
}
166175

167-
editor.removeBlocks(blocks);
168-
editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
176+
// PM-level move: preserves suggestion nodes and other internal structure.
177+
const blockIds = blocks.map((b) =>
178+
typeof b === "string" ? b : b.id,
179+
);
180+
181+
// Check if any blocks involve columns — if so, fall back to Block API
182+
// round-trip since column flattening requires structural changes that
183+
// are not compatible with raw PM node preservation.
184+
const hasColumns = blocks.some(
185+
(b) => b.type === "column" || b.type === "columnList",
186+
) || blockIds.some((id) => {
187+
const posInfo = getNodeById(id, tr.doc);
188+
if (!posInfo) {
189+
return false;
190+
}
191+
// Check if any ancestor is a column or columnList
192+
const $pos = tr.doc.resolve(posInfo.posBeforeNode);
193+
for (let d = $pos.depth; d >= 0; d--) {
194+
const nodeName = $pos.node(d).type.name;
195+
if (nodeName === "column" || nodeName === "columnList") {
196+
return true;
197+
}
198+
}
199+
return false;
200+
});
201+
202+
if (hasColumns) {
203+
// Fallback: use Block API round-trip (does not preserve suggestion
204+
// nodes, but columns shouldn't contain suggestion nodes anyway)
205+
editor.removeBlocks(blocks);
206+
editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
207+
return;
208+
}
209+
210+
// Save copies of the raw PM nodes before any mutations.
211+
const pmNodeCopies: Node[] = [];
212+
for (const id of blockIds) {
213+
const posInfo = getNodeById(id, tr.doc);
214+
if (!posInfo) {
215+
throw new Error(`Block with ID ${id} not found`);
216+
}
217+
pmNodeCopies.push(posInfo.node.copy(posInfo.node.content));
218+
}
219+
220+
// Remove the blocks from the document. Iterate in reverse document order
221+
// so that earlier deletions don't shift the positions of later ones.
222+
const deletePositions: { from: number; to: number }[] = [];
223+
for (const id of blockIds) {
224+
const posInfo = getNodeById(id, tr.doc);
225+
if (!posInfo) {
226+
continue;
227+
}
228+
229+
// Check if this is the only child of a non-root blockGroup. If so,
230+
// delete the blockGroup wrapper instead of just the blockContainer.
231+
const $pos = tr.doc.resolve(posInfo.posBeforeNode);
232+
if (
233+
$pos.parent.type.name === "blockGroup" &&
234+
$pos.node($pos.depth - 1).type.name !== "doc" &&
235+
$pos.parent.childCount === 1
236+
) {
237+
deletePositions.push({
238+
from: $pos.before(),
239+
to: $pos.after(),
240+
});
241+
} else {
242+
deletePositions.push({
243+
from: posInfo.posBeforeNode,
244+
to: posInfo.posBeforeNode + posInfo.node.nodeSize,
245+
});
246+
}
247+
}
248+
249+
// Sort by position descending so we delete from end to start
250+
deletePositions.sort((a, b) => b.from - a.from);
251+
for (const { from, to } of deletePositions) {
252+
tr.delete(from, to);
253+
}
254+
255+
// Find the reference block position in the updated document
256+
const refId =
257+
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
258+
const refPosInfo = getNodeById(refId, tr.doc);
259+
if (!refPosInfo) {
260+
throw new Error(`Reference block with ID ${refId} not found after delete`);
261+
}
262+
263+
let insertPos = refPosInfo.posBeforeNode;
264+
if (placement === "after") {
265+
insertPos += refPosInfo.node.nodeSize;
266+
}
267+
268+
// Insert the saved PM nodes at the target position
269+
tr.step(
270+
new ReplaceStep(
271+
insertPos,
272+
insertPos,
273+
new Slice(Fragment.from(pmNodeCopies), 0, 0),
274+
),
275+
);
169276
});
170277
}
171278

packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ export const splitBlockTr = (
3939
if (!info.isBlockContainer) {
4040
return false;
4141
}
42+
43+
// If the cursor is inside a suggestion node, redirect the split position
44+
// to the start of the blockContent. Splitting inside a suggestion node
45+
// would create a blockContainer with only a suggestion fragment (no
46+
// blockContent), which violates the schema. Instead, the suggestion stays
47+
// with the first block and the split happens at the blockContent boundary.
48+
let effectivePos = posInBlock;
49+
const $pos = tr.doc.resolve(posInBlock);
50+
if ($pos.parent.type.spec.group === "suggestionBlockContent") {
51+
effectivePos = info.blockContent.beforePos + 1;
52+
}
53+
4254
const schema = getPmSchema(tr);
4355

4456
const types = [
@@ -52,7 +64,7 @@ export const splitBlockTr = (
5264
},
5365
];
5466

55-
tr.split(posInBlock, 2, types);
67+
tr.split(effectivePos, 2, types);
5668

5769
return true;
5870
};

0 commit comments

Comments
 (0)