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
49 changes: 21 additions & 28 deletions examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ export const IMG_SRC_BASE =
export const IMG_SRC_NEW =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%234ecdc4'/></svg>";

// Shared note for the concurrent table scenarios: structural table merges are
// a known pre-existing limitation of the collaboration layer, independent of
// the suggestion/diff rendering.
const CONCURRENT_TABLE_FEEDBACK: Feedback[] = [
{
severity: "low",
note:
"Concurrent structural edits to the same table don't merge " +
"conflict-free — simultaneous row/column changes can combine into an " +
"inconsistent table. A follow-up is a dedicated table CRDT in Yjs + " +
"BlockNote. (Pre-existing collaboration issue, not specific to diffs.)",
},
];

// Shared 2×2 table baseline used by most of the table scenarios.
const TABLE_2X2 = {
id: "table",
Expand Down Expand Up @@ -895,12 +909,6 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "single",
id: "table-merge-cells",
feedback: [
{
severity: "low",
note: "The diff shows a phantom extra 'deleted column' that isn't actually part of the merge.",
},
],
title: "Merge cells",
category: "Tables",
description: "Merge the two top-row cells into one (colspan 2).",
Expand Down Expand Up @@ -1149,6 +1157,7 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "concurrent",
id: "concurrent-table-row-and-column",
feedback: CONCURRENT_TABLE_FEEDBACK,
title: "Add row vs add column",
category: "Tables",
description: "A adds a row while B adds a column.",
Expand Down Expand Up @@ -1177,6 +1186,7 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "concurrent",
id: "concurrent-table-addcol-vs-addrow",
feedback: CONCURRENT_TABLE_FEEDBACK,
title: "Add column vs add row",
category: "Tables",
description: "A adds a column while B adds a row.",
Expand Down Expand Up @@ -1205,18 +1215,10 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "concurrent",
id: "concurrent-table-row-vs-column",
feedback: [
{
severity: "high",
note: "Crashes — prosemirror-tables' fixTables treats the suggestion-marked table as malformed and feeds y-prosemirror a delta Yjs can't apply (lib0 'Unexpected case'). Confirmed via a fixTables on/off loop (25/25 crashes on, 0/25 off); fix is to block fixTablesKey transactions while suggestions are active, mirroring AIExtension during ai-writing.",
},
],
feedback: CONCURRENT_TABLE_FEEDBACK,
title: "Delete row vs add column",
category: "Tables",
description:
"A deletes a row while B adds a column — known to crash the merge " +
"(prosemirror-tables fixTables).",
knownCrash: true,
description: "A deletes a row while B adds a column.",
initial: [TABLE_2X2],
applyA: (editor) =>
editor.updateBlock("table", {
Expand All @@ -1237,12 +1239,7 @@ export const scenarios: SuggestionScenario[] = [
kind: "concurrent",
id: "concurrent-table-delcol-vs-addrow",
title: "Delete column vs add row",
feedback: [
{
severity: "high",
note: "Diff seems weird and A2 in wrong place",
},
],
feedback: CONCURRENT_TABLE_FEEDBACK,
category: "Tables",
description: "A deletes a column while B adds a row.",
initial: [TABLE_2X2],
Expand Down Expand Up @@ -1270,6 +1267,7 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "concurrent",
id: "concurrent-table-seq-col-then-row",
feedback: CONCURRENT_TABLE_FEEDBACK,
title: "A adds column then row, B adds column",
category: "Tables",
description: "A adds a column and then a row (two edits); B adds a column.",
Expand Down Expand Up @@ -1306,6 +1304,7 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "concurrent",
id: "concurrent-table-seq-row-then-col",
feedback: CONCURRENT_TABLE_FEEDBACK,
title: "A adds row then column, B adds row",
category: "Tables",
description: "A adds a row and then a column (two edits); B adds a row.",
Expand Down Expand Up @@ -1623,12 +1622,6 @@ export const scenarios: SuggestionScenario[] = [
editor.replaceBlocks(editor.document, [
{ type: "paragraph", content: "(all content removed)" },
]),
feedback: [
{
severity: "high",
note: "the 'all content removed' paragraph should show up below or above the document, not inside it",
},
],
},

// --- Merge / split ---
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/blocks/Table/TableExtension.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { callOrReturn, Extension, getExtensionField } from "@tiptap/core";
import { TextSelection } from "prosemirror-state";
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
import {
columnResizing,
fixTablesKey,
goToNextCell,
isInTable,
moveCellForward,
Expand All @@ -17,7 +18,8 @@ export const EMPTY_CELL_HEIGHT = 31;
export const TableExtension = Extension.create({
name: "BlockNoteTableExtension",

addProseMirrorPlugins: () => {
addProseMirrorPlugins() {
const editor = this.editor;
return [
columnResizing({
cellMinWidth: RESIZE_MIN_WIDTH,
Expand All @@ -28,6 +30,18 @@ export const TableExtension = Extension.create({
View: null,
}),
tableEditing(),
new Plugin({
key: new PluginKey("blocknote-fix-tables-gate"),
// `tableEditing()` appends a normalizing `fixTables` transaction
// whenever it sees an "inconsistent" table. A rendered diff shows
// inconsistent tables *on purpose* (deleted row/column copies next to
// their replacements), and the editor is read-only while it does — so
// letting the fix run would silently rewrite the very diff being
// displayed. A read-only editor shouldn't self-normalize at all:
// block `fixTables` transactions while the editor isn't editable.
filterTransaction: (tr) =>
!(tr.getMeta(fixTablesKey) && !editor.isEditable),
}),
];
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ function createDiffEditor() {
function blocksFromText(text: string): Block<any, any, any>[] {
const e = BlockNoteEditor.create();
e.mount(document.createElement("div"));
e.replaceBlocks(e.document, [{ type: "paragraph", content: text }]);
// Stable id: the before/after docs a diff compares represent the *same*
// logical block at two points in time, and blocks keep their id across
// edits. Minting a fresh id per call would make `blockMatchNodes` treat the
// two versions as different blocks (replace) instead of one edited block.
e.replaceBlocks(e.document, [
{ id: "diff-block", type: "paragraph", content: text },
]);
const blocks = e.document;
e.unmount();
return blocks;
Expand Down
99 changes: 13 additions & 86 deletions packages/core/src/y/extensions/blockMatchNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,79 +41,6 @@ const hasBlockGroup = (d: schema.Unwrap<typeof $prosemirrorDelta>): boolean => {
return false;
};

function getTableDimensions(
d: schema.Unwrap<typeof $prosemirrorDelta>,
): { rows: number; cols: number } | null {
if (d.name !== "table") {
return null;
}

// Collect all rows with their cells' colspan/rowspan values.
const rows: Array<Array<{ colspan: number; rowspan: number }>> = [];
for (const op of (d as any).children) {
if (delta.$insertOp.check(op)) {
for (const tr of op.insert as Array<
schema.Unwrap<typeof $prosemirrorDelta>
>) {
if (tr.name !== "tableRow") {
return null;
}
const cells: Array<{ colspan: number; rowspan: number }> = [];
for (const trOp of (tr as any).children) {
if (delta.$insertOp.check(trOp)) {
for (const td of trOp.insert as Array<
schema.Unwrap<typeof $prosemirrorDelta>
>) {
if (td.name !== "tableCell" && td.name !== "tableHeader") {
return null;
}
cells.push({
colspan: Number(td.attrs.colspan) || 1,
rowspan: Number(td.attrs.rowspan) || 1,
});
}
}
}
rows.push(cells);
}
}
}

if (rows.length === 0) {
return null;
}

// Build an occupancy grid to determine the true column count.
// Each entry in `grid[r]` tracks which columns are already occupied
// (by a cell from a previous row with rowspan > 1).
const grid: boolean[][] = [];
for (let r = 0; r < rows.length; r++) {
if (!grid[r]) {
grid[r] = [];
}
let col = 0;
for (const cell of rows[r]) {
// Skip columns already occupied by a rowspan from above.
while (grid[r][col]) {
col++;
}
// Mark all slots this cell occupies.
for (let dr = 0; dr < cell.rowspan; dr++) {
if (!grid[r + dr]) {
grid[r + dr] = [];
}
for (let dc = 0; dc < cell.colspan; dc++) {
grid[r + dr][col + dc] = true;
}
}
col += cell.colspan;
}
}

const numCols = Math.max(...grid.map((row) => row.length));
return { rows: rows.length, cols: numCols };
}

/**
* BlockNote's node-pairing policy for y-prosemirror's `matchNodes` option
* (forwarded to `lib0/delta.diff`). This is the schema-specific bit that lives
Expand Down Expand Up @@ -148,6 +75,19 @@ export const blockMatchNodes = (
return true;
}

// Two containers with *different* block ids are different blocks, no matter
// how similar their content — pairing them would diff one block into the
// other in place. That rendered e.g. a full-document replacement as edits
// *inside* the first deleted block instead of a separately inserted one.
// Only enforced when both sides carry an id, so content converted from
// outside the editor (which may not have ids yet) still pairs by shape.
// Delta attrs are op-wrapped ({ type, value }) — compare the values.
const idA = (a as any).attrs?.id?.value;
const idB = (b as any).attrs?.id?.value;
if (idA && idB && idA !== idB) {
return false;
}

const childA = firstChild(a);
const childB = firstChild(b);

Expand All @@ -165,18 +105,5 @@ export const blockMatchNodes = (
return false;
}

if (childA?.name === "table" && childB?.name === "table") {
const dimA = getTableDimensions(childA);
const dimB = getTableDimensions(childB);
if (
dimA !== null &&
dimB !== null &&
dimA.rows !== dimB.rows &&
dimA.cols !== dimB.cols
) {
return false;
}
}

return true;
};
Original file line number Diff line number Diff line change
Expand Up @@ -978,11 +978,22 @@ exports[`suggestion mode: remove all blocks 2`] = `
exports[`suggestion mode: remove all blocks 3`] = `
"<doc>
<blockGroup>
<blockContainer id="1">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">
<y-attributed-delete userIds="">Only block</y-attributed-delete>
</paragraph>
</blockContainer>
<y-attributed-delete userIds="">
<blockContainer id="p0">
<y-attributed-delete userIds="">
<paragraph backgroundColor="default" textAlignment="left" textColor="default">
<y-attributed-delete userIds="">Only block</y-attributed-delete>
</paragraph>
</y-attributed-delete>
</blockContainer>
</y-attributed-delete>
<y-attributed-insert userIds="">
<blockContainer id="1">
<y-attributed-insert userIds="">
<paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph>
</y-attributed-insert>
</blockContainer>
</y-attributed-insert>
</blockGroup>
</doc>"
`;
Expand Down
Loading
Loading