Skip to content
Closed
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
94 changes: 94 additions & 0 deletions packages/xl-multi-column/src/extensions/DropCursor/dropHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Block, BlockNoteEditor } from "@blocknote/core";
import {
getNodeById,
removeAndInsertBlocks,
updateBlock as updateBlockLowLevel,
} from "@blocknote/core";

import { computeColumnListChildrenAfterDrop } from "./util/computeColumnListChildren.js";

/**
* Handles dropping a block (or column) onto the left/right edge of an
* existing column, inserting a new column next to it.
*
* The removal of the dragged item and the update of `columnList`'s children
* happen in a single transaction, with column/columnList collapsing
* ("fixColumns") disabled for the removal step. `newChildren` is computed
* up-front and already accounts for any column left empty by the removal, so
* letting `fixColumnList` also run on the removal step would collapse (and
* invalidate the id of) the very `columnList` we're about to update.
*/
export function dropOntoColumn(
editor: BlockNoteEditor<any, any, any>,
params: {
columnList: Block<any, any, any>;
targetColumnId: string;
draggedBlock: Block<any, any, any>;
position: "left" | "right";
},
) {
const { columnList, targetColumnId, draggedBlock, position } = params;

if (targetColumnId === draggedBlock.id) {
return;
}

const newChildren = computeColumnListChildrenAfterDrop(
columnList,
draggedBlock,
targetColumnId,
position,
);

editor.transact((tr) => {
if (getNodeById(draggedBlock.id, tr.doc)) {
removeAndInsertBlocks(tr, [draggedBlock.id], [], { fixColumns: false });
}

updateBlockLowLevel(tr, columnList.id, { children: newChildren });
});
}

/**
* Handles dropping a block onto the left/right edge of a block that isn't
* inside a column, wrapping both blocks in a new `columnList`.
*/
export function dropOntoBlock(
editor: BlockNoteEditor<any, any, any>,
params: {
targetBlock: Block<any, any, any>;
draggedBlock: Block<any, any, any>;
position: "left" | "right";
},
) {
const { targetBlock, draggedBlock, position } = params;

if (targetBlock.id === draggedBlock.id) {
return;
}

const blocks =
position === "left"
? [draggedBlock, targetBlock]
: [targetBlock, draggedBlock];

editor.transact((tr) => {
if (getNodeById(draggedBlock.id, tr.doc)) {
removeAndInsertBlocks(tr, [draggedBlock.id], []);
}

Comment on lines +75 to +79

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect removeAndInsertBlocks and the fixColumns option semantics.
fd -t f 'removeAndInsertBlocks*' -E node_modules | while IFS= read -r f; do
  echo "== $f"; ast-grep outline "$f" --items all
done

rg -n --type=ts -C 6 'fixColumns' -g '!**/node_modules/**'
rg -n --type=ts -C 4 'function (fixColumnList|fixColumns)' -g '!**/node_modules/**'

Repository: TypeCellOS/BlockNote

Length of output: 244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files matching dropHandlers / multi-column =="
fd -i 'dropHandlers\.ts|multi.*column|column' packages | head -100

echo "== line count for target =="
fd -t f 'dropHandlers\.ts' packages -x sh -c 'echo "--- $1"; wc -l "$1"' sh {}

echo "== exact target excerpt =="
target="$(fd -t f 'dropHandlers\.ts' packages | head -1)"
if [ -n "${target:-}" ]; then
  sed -n '1,140p' "$target" | cat -n
fi

echo "== removeAndInsertBlocks source =="
rg -n --type=ts -C 8 'removeAndInsertBlocks|getNodeById|getNodes' packages | head -200

Repository: TypeCellOS/BlockNote

Length of output: 24577


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== core replaceBlocks util =="
fd -t f 'replaceBlocks.*|fixColumnList.*|blockManipulation' packages/core/src packages -x sh -c 'echo "--- $1"; wc -l "$1"' sh {} | rg -n 'replaceBlocks|fixColumnList|blockManipulation' || true

echo "== removeAndInsertBlocks source candidates =="
fd -t f . packages/core packages/xl-multi-column | rg 'replaceBlocks|fixColumn|manipulation' | xargs -r sed -n '1,220p' | nl -ba | sed -n '1,360p'

echo "== core export/import locations for removeAndInsertBlocks =="
rg -n --type=ts -C 3 'removeAndInsertBlocks|export \{|export \*|blockManipulation' packages/core/src packages/xl-multi-column/src | head -240

Repository: TypeCellOS/BlockNote

Length of output: 3419


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts =="
sed -n '1,180p' packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts | cat -n

echo "== packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts =="
sed -n '1,220p' packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts | cat -n

echo "== relevant tests mentioning column collapsing removal order =="
rg -n --type=ts -C 8 'fixColumns|last.*column|columnList|removeAndInsertBlocks' packages/core/src/api/blockManipulation/commands/replaceBlocks packages/xl-multi-column/src/test/commands packages/xl-multi-column/src/test/commands/util | sed -n '1,260p'

Repository: TypeCellOS/BlockNote

Length of output: 43034


Disable column collapsing before wrapping the target block.

removeAndInsertBlocks default-enables fixColumns; removing draggedBlock.id can collapse its empty columnList before the second call tries to wrap targetBlock.id. Pass fixColumns: false to the first removal in dropOntoBlock so the target block remains present for the subsequent insert.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/xl-multi-column/src/extensions/DropCursor/dropHandlers.ts` around
lines 75 - 79, In dropOntoBlock, update the first removeAndInsertBlocks call
that removes draggedBlock.id to pass fixColumns: false, preventing column
cleanup before the subsequent operation wraps targetBlock.id. Leave the later
insertion behavior unchanged.

removeAndInsertBlocks<any, any, any>(
tr,
[targetBlock.id],
[
{
type: "columnList",
children: blocks.map((block) => ({
type: "column" as const,
children: [block],
})),
},
],
);
});
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import type { BlockNoteEditor } from "@blocknote/core";
import {
UniqueID,
createExtension,
getBlockInfo,
nodeToBlock,
} from "@blocknote/core";
import { createExtension, getBlockInfo, nodeToBlock } from "@blocknote/core";
import { Plugin } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { dropOntoBlock, dropOntoColumn } from "./dropHandlers.js";
import { detectEdgePosition } from "./multiColumnDropCursor.js";

/**
Expand Down Expand Up @@ -79,69 +75,21 @@ export function createMultiColumnHandleDropPlugin(
});
}

const index = columnList.children.findIndex(
(b) => b.id === blockInfo.bnBlock.node.attrs.id,
);

const newChildren = columnList.children
// If the dragged block is in one of the columns, remove it.
.map((column) => ({
...column,
children: column.children.filter(
(block) => block.id !== draggedBlock.id,
),
}))
// Remove empty columns (can happen when dragged block is removed).
.filter((column) => column.children.length > 0)
// Insert the dragged block in the correct position.
.toSpliced(edgePos.position === "left" ? index : index + 1, 0, {
type: "column",
children: [draggedBlock],
props: {},
content: undefined,
id: UniqueID.options.generateID(),
});

if (editor.getBlock(draggedBlock.id)) {
editor.removeBlocks([draggedBlock]);
}

editor.updateBlock(columnList, {
children: newChildren,
dropOntoColumn(editor, {
columnList,
targetColumnId: blockInfo.bnBlock.node.attrs.id,
draggedBlock,
position: edgePos.position,
});
} else {
// Create new columnList with blocks as columns
const block = nodeToBlock(blockInfo.bnBlock.node, view.state.doc);

// The user is dropping next to the original block being dragged - do
// nothing.
if (block.id === draggedBlock.id) {
return true;
}

const blocks =
edgePos.position === "left"
? [draggedBlock, block]
: [block, draggedBlock];

if (editor.getBlock(draggedBlock.id)) {
editor.removeBlocks([draggedBlock]);
}

editor.replaceBlocks(
[block],
[
{
type: "columnList",
children: blocks.map((b) => {
return {
type: "column",
children: [b],
};
}),
},
],
);
dropOntoBlock(editor, {
targetBlock: block,
draggedBlock,
position: edgePos.position,
});
}

return true; // Prevent default ProseMirror drop behavior
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vite-plus/test";

import { computeColumnListChildrenAfterDrop } from "./computeColumnListChildren.js";

const paragraph = (id: string, content = "") => ({
id,
type: "paragraph" as const,
props: {},
content,
children: [],
});

const column = (id: string, children: ReturnType<typeof paragraph>[]) => ({
id,
type: "column" as const,
props: { width: 1 },
content: undefined,
children,
});

describe("computeColumnListChildrenAfterDrop", () => {
it("keeps the source column when it still has other children", () => {
const columnList = {
id: "column-list",
type: "columnList",
props: {},
content: undefined,
children: [
column("column-a", [
paragraph("dragged", "Dragged"),
paragraph("stays", "Stays"),
]),
column("column-b", [paragraph("target", "Target")]),
],
} as any;

const result = computeColumnListChildrenAfterDrop(
columnList,
columnList.children[0].children[0],
"column-b",
"left",
);

expect(result.map((c: any) => c.id)).toEqual([
"column-a",
expect.any(String),
"column-b",
]);
expect(result[0].children.map((b: any) => b.id)).toEqual(["stays"]);
expect(result[1].children.map((b: any) => b.id)).toEqual(["dragged"]);
});

it("drops the source column entirely once its only block is dragged away (crash trigger)", () => {
const columnList = {
id: "column-list",
type: "columnList",
props: {},
content: undefined,
children: [
column("column-a", [paragraph("dragged", "Dragged")]),
column("column-b", [paragraph("target", "Target")]),
],
} as any;

const result = computeColumnListChildrenAfterDrop(
columnList,
columnList.children[0].children[0],
"column-b",
"left",
);

expect(result.map((c: any) => c.id)).not.toContain("column-a");
expect(result.map((c: any) => c.id)).toEqual([
expect.any(String),
"column-b",
]);
expect(result[0].children.map((b: any) => b.id)).toEqual(["dragged"]);
});

it("resolves the target index against the post-filter list, not the original one (left edge)", () => {
const columnList = {
id: "column-list",
type: "columnList",
props: {},
content: undefined,
children: [
column("column-a", [paragraph("dragged", "Dragged")]),
column("column-b", [paragraph("b", "B")]),
column("column-c", [paragraph("c", "C")]),
],
} as any;

const result = computeColumnListChildrenAfterDrop(
columnList,
columnList.children[0].children[0],
"column-c",
"left",
);

expect(result.map((c: any) => c.id)).toEqual([
"column-b",
expect.any(String),
"column-c",
]);
});

it("resolves the target index against the post-filter list, not the original one (right edge)", () => {
const columnList = {
id: "column-list",
type: "columnList",
props: {},
content: undefined,
children: [
column("column-a", [paragraph("dragged", "Dragged")]),
column("column-b", [paragraph("b", "B")]),
column("column-c", [paragraph("c", "C")]),
],
} as any;

const result = computeColumnListChildrenAfterDrop(
columnList,
columnList.children[0].children[0],
"column-c",
"right",
);

expect(result.map((c: any) => c.id)).toEqual([
"column-b",
"column-c",
expect.any(String),
]);
});

it("removes the dragged item from the top level when it is itself a column", () => {
const draggedColumn = column("column-a", [paragraph("dragged", "Dragged")]);
const columnList = {
id: "column-list",
type: "columnList",
props: {},
content: undefined,
children: [
draggedColumn,
column("column-b", [paragraph("target", "Target")]),
],
} as any;

const result = computeColumnListChildrenAfterDrop(
columnList,
draggedColumn as any,
"column-b",
"left",
);

expect(result.map((c: any) => c.id)).toEqual([
expect.any(String),
"column-b",
]);
expect(result[0].id).not.toBe("column-a");
expect(result[0].children.map((b: any) => b.id)).toEqual(["dragged"]);
});
});
Loading