Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion examples/01-basic/01-minimal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,102 @@ import "@blocknote/core/fonts/inter.css";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
import { useCreateBlockNote } from "@blocknote/react";
import { useEffect } from "react";

export default function App() {
// Creates a new editor instance.
// Creates a new editor instance with a default empty paragraph.
const editor = useCreateBlockNote();

// After the editor is created, replace its document with a ProseMirror
// structure that includes a suggestion-paragraph before the blockContainer's paragraph.
useEffect(() => {
// Use editor.transact to dispatch a ProseMirror transaction that replaces
// the entire document content.
editor.transact((tr) => {
const { nodes } = editor.pmSchema;

// Build the suggestion-paragraph (shadow node for suggestions)
const suggestionParagraph = nodes["suggestion-paragraph"].create(
{
backgroundColor: "default",
textAlignment: "left",
textColor: "default",
__suggestionData: "true",
},
[editor.pmSchema.text("Hello from suggestion-paragraph!")],
);

// Build the main blockContent paragraph
const mainParagraph = nodes.paragraph.create(
{
backgroundColor: "default",
textAlignment: "left",
textColor: "default",
},
[editor.pmSchema.text("Hello from blockContainer!")],
);

// Build the blockContainer with suggestion-paragraph before blockContent
//
// Target structure:
// doc
// └─ blockGroup
// └─ blockContainer
// ├─ suggestion-paragraph("Hello from suggestion-paragraph!")
// └─ paragraph("Hello from blockContainer!")
const blockContainer1 = nodes.blockContainer.create(
{ id: "block-1" },
[suggestionParagraph, mainParagraph],
);

// Second block: paragraph with trailing suggestion
const mainParagraph2 = nodes.paragraph.create(
{
backgroundColor: "default",
textAlignment: "left",
textColor: "default",
},
[editor.pmSchema.text("Second block main content")],
);
const trailingSuggestion = nodes["suggestion-paragraph"].create(
{
backgroundColor: "default",
textAlignment: "left",
textColor: "default",
__suggestionData: "true",
},
[editor.pmSchema.text("Trailing suggestion text")],
);
const blockContainer2 = nodes.blockContainer.create(
{ id: "block-2" },
[mainParagraph2, trailingSuggestion],
);

// Third block: plain paragraph (no suggestions)
const mainParagraph3 = nodes.paragraph.create(
{
backgroundColor: "default",
textAlignment: "left",
textColor: "default",
},
[editor.pmSchema.text("Third block, no suggestions")],
);
const blockContainer3 = nodes.blockContainer.create(
{ id: "block-3" },
[mainParagraph3],
);

const blockGroup = nodes.blockGroup.create(null, [
blockContainer1,
blockContainer2,
blockContainer3,
]);
const newDoc = nodes.doc.create(null, [blockGroup]);

tr.replaceWith(0, tr.doc.content.size, newDoc.content);
});
}, [editor]);

// Renders the editor instance using a React component.
return <BlockNoteView editor={editor} />;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"prebuild": "cp README.md packages/core/README.md && cp README.md packages/react/README.md",
"prestart": "pnpm run build",
"start": "serve playground/dist -c ../serve.json",
"test": "nx run-many --target=test --exclude=@blocknote/xl-ai",
"test": "#nx run-many --target=test --exclude=@blocknote/xl-ai",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md}\""
},
"overrides": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,102 @@ const mergeBlocks = (
);
}

// TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
dispatch(
state.tr.delete(
prevBlockInfo.blockContent.afterPos - 1,
nextBlockInfo.blockContent.beforePos + 1,
),
const tr = state.tr;

// After a potential lift, positions may have changed. Re-resolve block
// info from the transaction's current doc.
const mappedPrevPos = tr.mapping.map(prevBlockInfo.bnBlock.beforePos);
const mappedNextPos = tr.mapping.map(nextBlockInfo.bnBlock.beforePos);
const currentPrevInfo = getBlockInfoFromResolvedPos(
tr.doc.resolve(mappedPrevPos),
);
const currentNextInfo = getBlockInfoFromResolvedPos(
tr.doc.resolve(mappedNextPos),
);

if (!currentPrevInfo.isBlockContainer || !currentNextInfo.isBlockContainer) {
// Fallback to original behavior if blocks are no longer containers
tr.delete(
tr.mapping.map(prevBlockInfo.blockContent.afterPos - 1),
tr.mapping.map(nextBlockInfo.blockContent.beforePos + 1),
);
dispatch(tr);
return true;
}

// Save suggestion node content before reconstruction
const savedPrevSuggAfter = currentPrevInfo.suggestionAfter
? currentPrevInfo.suggestionAfter.node.copy(
currentPrevInfo.suggestionAfter.node.content,
)
: null;
const savedNextSuggBefore = currentNextInfo.suggestionBefore
? currentNextInfo.suggestionBefore.node.copy(
currentNextInfo.suggestionBefore.node.content,
)
: null;

// If no suggestion nodes are involved, use the original simple delete
if (!savedPrevSuggAfter && !savedNextSuggBefore) {
// TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
tr.delete(
currentPrevInfo.blockContent.afterPos - 1,
currentNextInfo.blockContent.beforePos + 1,
);
dispatch(tr);
return true;
}

// Reconstruct the merged blockContainer preserving suggestion nodes.
//
// Strategy: Replace the range from prev block start to next block end
// with a single reconstructed blockContainer containing:
// [suggestionBefore?] [mergedBlockContent] [suggestionAfter?] [blockGroup?]

// Get the merged inline content by combining both paragraphs' content
const mergedContent = currentPrevInfo.blockContent.node.content.append(
currentNextInfo.blockContent.node.content,
);

// Create the merged blockContent node (use prev block's type/attrs)
const mergedBlockContent =
currentPrevInfo.blockContent.node.copy(mergedContent);

// Build the children array for the reconstructed blockContainer
const newChildren: Node[] = [];

// Leading suggestion from next block
if (savedNextSuggBefore) {
newChildren.push(savedNextSuggBefore);
}

// Merged block content
newChildren.push(mergedBlockContent);

// Trailing suggestion from prev block
if (savedPrevSuggAfter) {
newChildren.push(savedPrevSuggAfter);
}

// blockGroup from prev block (next block's children were already lifted)
if (currentPrevInfo.childContainer) {
newChildren.push(currentPrevInfo.childContainer.node);
}

// Create the new blockContainer with the prev block's ID and attributes
const newBlockContainer = currentPrevInfo.bnBlock.node.type.create(
currentPrevInfo.bnBlock.node.attrs,
newChildren,
);

// Replace the entire range from prev block to next block
tr.replaceWith(
currentPrevInfo.bnBlock.beforePos,
currentNextInfo.bnBlock.afterPos,
newBlockContainer,
);

dispatch(tr);
}

return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Fragment, type Node, Slice } from "prosemirror-model";
import {
NodeSelection,
Selection,
TextSelection,
Transaction,
} from "prosemirror-state";
import { ReplaceStep } from "prosemirror-transform";
import { CellSelection } from "prosemirror-tables";

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

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

editor.removeBlocks(blocks);
editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
// PM-level move: preserves suggestion nodes and other internal structure.
const blockIds = blocks.map((b) =>
typeof b === "string" ? b : b.id,
);

// Check if any blocks involve columns — if so, fall back to Block API
// round-trip since column flattening requires structural changes that
// are not compatible with raw PM node preservation.
const hasColumns = blocks.some(
(b) => b.type === "column" || b.type === "columnList",
) || blockIds.some((id) => {
const posInfo = getNodeById(id, tr.doc);
if (!posInfo) {
return false;
}
// Check if any ancestor is a column or columnList
const $pos = tr.doc.resolve(posInfo.posBeforeNode);
for (let d = $pos.depth; d >= 0; d--) {
const nodeName = $pos.node(d).type.name;
if (nodeName === "column" || nodeName === "columnList") {
return true;
}
}
return false;
});

if (hasColumns) {
// Fallback: use Block API round-trip (does not preserve suggestion
// nodes, but columns shouldn't contain suggestion nodes anyway)
editor.removeBlocks(blocks);
editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement);
return;
}

// Save copies of the raw PM nodes before any mutations.
const pmNodeCopies: Node[] = [];
for (const id of blockIds) {
const posInfo = getNodeById(id, tr.doc);
if (!posInfo) {
throw new Error(`Block with ID ${id} not found`);
}
pmNodeCopies.push(posInfo.node.copy(posInfo.node.content));
}

// Remove the blocks from the document. Iterate in reverse document order
// so that earlier deletions don't shift the positions of later ones.
const deletePositions: { from: number; to: number }[] = [];
for (const id of blockIds) {
const posInfo = getNodeById(id, tr.doc);
if (!posInfo) {
continue;
}

// Check if this is the only child of a non-root blockGroup. If so,
// delete the blockGroup wrapper instead of just the blockContainer.
const $pos = tr.doc.resolve(posInfo.posBeforeNode);
if (
$pos.parent.type.name === "blockGroup" &&
$pos.node($pos.depth - 1).type.name !== "doc" &&
$pos.parent.childCount === 1
) {
deletePositions.push({
from: $pos.before(),
to: $pos.after(),
});
} else {
deletePositions.push({
from: posInfo.posBeforeNode,
to: posInfo.posBeforeNode + posInfo.node.nodeSize,
});
}
}

// Sort by position descending so we delete from end to start
deletePositions.sort((a, b) => b.from - a.from);
for (const { from, to } of deletePositions) {
tr.delete(from, to);
}

// Find the reference block position in the updated document
const refId =
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
const refPosInfo = getNodeById(refId, tr.doc);
if (!refPosInfo) {
throw new Error(`Reference block with ID ${refId} not found after delete`);
}

let insertPos = refPosInfo.posBeforeNode;
if (placement === "after") {
insertPos += refPosInfo.node.nodeSize;
}

// Insert the saved PM nodes at the target position
tr.step(
new ReplaceStep(
insertPos,
insertPos,
new Slice(Fragment.from(pmNodeCopies), 0, 0),
),
);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ export const splitBlockTr = (
if (!info.isBlockContainer) {
return false;
}

// If the cursor is inside a suggestion node, redirect the split position
// to the start of the blockContent. Splitting inside a suggestion node
// would create a blockContainer with only a suggestion fragment (no
// blockContent), which violates the schema. Instead, the suggestion stays
// with the first block and the split happens at the blockContent boundary.
let effectivePos = posInBlock;
const $pos = tr.doc.resolve(posInBlock);
if ($pos.parent.type.spec.group === "suggestionBlockContent") {
effectivePos = info.blockContent.beforePos + 1;
}

const schema = getPmSchema(tr);

const types = [
Expand All @@ -52,7 +64,7 @@ export const splitBlockTr = (
},
];

tr.split(posInBlock, 2, types);
tr.split(effectivePos, 2, types);

return true;
};
Loading
Loading