From 08a814c3a9704d442dc7f5487185afd314b6337b Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 7 Apr 2026 10:35:26 +0200 Subject: [PATCH 1/2] fix(core): add missing selectionAtBlockStart guard in backspace columnList handler The "move to end of prev columnList" backspace handler was missing a check for whether the cursor is at the start of the block. This caused mid-text backspace next to a columnList to move the entire block into the column instead of deleting a character. Co-Authored-By: Claude Opus 4.6 --- .../KeyboardShortcutsExtension.ts | 7 + .../__snapshots__/backspace.test.ts.snap | 350 ++++++++++++++++++ .../src/test/commands/backspace.test.ts | 85 +++++ 3 files changed, 442 insertions(+) create mode 100644 packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap create mode 100644 packages/xl-multi-column/src/test/commands/backspace.test.ts diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index c0578d7f38..1d8ba7a136 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -128,6 +128,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + const selectionAtBlockStart = + state.selection.from === + blockInfo.blockContent.beforePos + 1; + if (!selectionAtBlockStart) { + return false; + } + const prevBlockInfo = getPrevBlockInfo( state.doc, blockInfo.bnBlock.beforePos, diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap new file mode 100644 index 0000000000..b9dbc29e4c --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap @@ -0,0 +1,350 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Backspace with multi-column > backspace at block start should move block into last column 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col1", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col2", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": " world", + "type": "text", + }, + ], + "id": "below-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Backspace with multi-column > mid-text backspace next to columnList should not move block 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col1", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col2", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello world", + "type": "text", + }, + ], + "id": "below-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Backspace with multi-column > second backspace should merge into previous block in column 1`] = ` +[ + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col1", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "col2", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello world", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/xl-multi-column/src/test/commands/backspace.test.ts b/packages/xl-multi-column/src/test/commands/backspace.test.ts new file mode 100644 index 0000000000..76959e2e70 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/backspace.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { setupTestEnv } from "../setupTestEnv.js"; +import { BlockNoteEditor } from "@blocknote/core"; + +const getEditor = setupTestEnv(); + +function pressBackspace(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Backspace", + code: "Backspace", + keyCode: 8, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +const threeColumnsWithParagraphBelow = [ + { + type: "columnList" as const, + children: [ + { + type: "column" as const, + children: [{ id: "col1-para", type: "paragraph" as const, content: "col1" }], + }, + { + type: "column" as const, + children: [{ id: "col2-para", type: "paragraph" as const, content: "col2" }], + }, + { + type: "column" as const, + children: [{ id: "col3-para", type: "paragraph" as const, content: "hello" }], + }, + ], + }, +]; + +describe("Backspace with multi-column", () => { + it("mid-text backspace next to columnList should not move block", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + ...threeColumnsWithParagraphBelow, + { id: "below-para", type: "paragraph" as const, content: "hello world" }, + ]); + + // Place cursor at offset 5 (after "hello", mid-text). + editor.setTextCursorPosition("below-para", "start"); + const view = editor._tiptapEditor.view; + const startPos = editor.transact((tr) => tr.selection.$from.pos); + view.dispatch( + view.state.tr.setSelection( + (view.state.selection.constructor as any).create(view.state.doc, startPos + 5), + ), + ); + + pressBackspace(editor); + expect(editor.document).toMatchSnapshot(); + }); + + it("backspace at block start should move block into last column", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + ...threeColumnsWithParagraphBelow, + { id: "below-para", type: "paragraph" as const, content: " world" }, + ]); + + editor.setTextCursorPosition("below-para", "start"); + pressBackspace(editor); + expect(editor.document).toMatchSnapshot(); + }); + + it("second backspace should merge into previous block in column", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + ...threeColumnsWithParagraphBelow, + { id: "below-para", type: "paragraph" as const, content: " world" }, + ]); + + editor.setTextCursorPosition("below-para", "start"); + pressBackspace(editor); + pressBackspace(editor); + expect(editor.document).toMatchSnapshot(); + }); +}); From 031c6fe74f151520240c657475631d1b497b11e5 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 7 Apr 2026 10:40:07 +0200 Subject: [PATCH 2/2] fix(core): add missing selectionAtBlockEnd guard in delete columnList handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug as the backspace handler — the "move first block from next columnList" delete handler was missing a selectionAtBlockEnd check, causing mid-text delete next to a columnList to incorrectly move blocks. Also adds 3 delete tests mirroring the backspace tests, and marks the mid-text tests with TODOs for vitest browser mode migration. Co-Authored-By: Claude Opus 4.6 --- .../KeyboardShortcutsExtension.ts | 11 +- .../__snapshots__/backspace.test.ts.snap | 346 ++++++++++++++++++ .../src/test/commands/backspace.test.ts | 127 ++++++- 3 files changed, 477 insertions(+), 7 deletions(-) diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 1d8ba7a136..7fc2a413c8 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -461,8 +461,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the previous block is a columnList, moves the current block to - // the end of the last column in it. + // If the next block is a columnList, moves the first block from its + // first column to after the current block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -470,6 +470,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + const selectionAtBlockEnd = + state.selection.from === + blockInfo.blockContent.afterPos - 1; + if (!selectionAtBlockEnd) { + return false; + } + const nextBlockInfo = getNextBlockInfo( state.doc, blockInfo.bnBlock.beforePos, diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap index b9dbc29e4c..1dadb64533 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap @@ -348,3 +348,349 @@ exports[`Backspace with multi-column > second backspace should merge into previo }, ] `; + +exports[`Delete with multi-column > delete at block end should move first column block out 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello ", + "type": "text", + }, + ], + "id": "above-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "world", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "universe", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "planet", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Delete with multi-column > mid-text delete next to columnList should not move block 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello world", + "type": "text", + }, + ], + "id": "above-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "world", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "universe", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "planet", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Delete with multi-column > second delete should merge first column block into paragraph 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "hello ", + "type": "text", + }, + ], + "id": "above-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "world", + "type": "text", + }, + ], + "id": "col1-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "universe", + "type": "text", + }, + ], + "id": "col2-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "planet", + "type": "text", + }, + ], + "id": "col3-para", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/xl-multi-column/src/test/commands/backspace.test.ts b/packages/xl-multi-column/src/test/commands/backspace.test.ts index 76959e2e70..d5ebea4c2c 100644 --- a/packages/xl-multi-column/src/test/commands/backspace.test.ts +++ b/packages/xl-multi-column/src/test/commands/backspace.test.ts @@ -16,32 +16,87 @@ function pressBackspace(editor: BlockNoteEditor) { view.someProp("handleKeyDown", (f: any) => f(view, event)); } +function pressDelete(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Delete", + code: "Delete", + keyCode: 46, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + const threeColumnsWithParagraphBelow = [ { type: "columnList" as const, children: [ { type: "column" as const, - children: [{ id: "col1-para", type: "paragraph" as const, content: "col1" }], + children: [ + { id: "col1-para", type: "paragraph" as const, content: "col1" }, + ], + }, + { + type: "column" as const, + children: [ + { id: "col2-para", type: "paragraph" as const, content: "col2" }, + ], + }, + { + type: "column" as const, + children: [ + { id: "col3-para", type: "paragraph" as const, content: "hello" }, + ], + }, + ], + }, +]; + +const threeColumnsWithParagraphAbove = [ + { + type: "columnList" as const, + children: [ + { + type: "column" as const, + children: [ + { id: "col1-para", type: "paragraph" as const, content: "world" }, + ], }, { type: "column" as const, - children: [{ id: "col2-para", type: "paragraph" as const, content: "col2" }], + children: [ + { + id: "col2-para", + type: "paragraph" as const, + content: "universe", + }, + ], }, { type: "column" as const, - children: [{ id: "col3-para", type: "paragraph" as const, content: "hello" }], + children: [ + { id: "col3-para", type: "paragraph" as const, content: "planet" }, + ], }, ], }, ]; describe("Backspace with multi-column", () => { + // TODO: When migrating to vitest browser mode, replace this test with + // a version that presses Backspace 5 times from offset 5 in "hello world" + // and asserts " world" remains. Character deletion relies on contentEditable + // which is not available in jsdom. it("mid-text backspace next to columnList should not move block", () => { const editor = getEditor(); editor.replaceBlocks(editor.document, [ ...threeColumnsWithParagraphBelow, - { id: "below-para", type: "paragraph" as const, content: "hello world" }, + { + id: "below-para", + type: "paragraph" as const, + content: "hello world", + }, ]); // Place cursor at offset 5 (after "hello", mid-text). @@ -50,7 +105,10 @@ describe("Backspace with multi-column", () => { const startPos = editor.transact((tr) => tr.selection.$from.pos); view.dispatch( view.state.tr.setSelection( - (view.state.selection.constructor as any).create(view.state.doc, startPos + 5), + (view.state.selection.constructor as any).create( + view.state.doc, + startPos + 5, + ), ), ); @@ -83,3 +141,62 @@ describe("Backspace with multi-column", () => { expect(editor.document).toMatchSnapshot(); }); }); + +describe("Delete with multi-column", () => { + // TODO: When migrating to vitest browser mode, replace this test with + // a version that presses Delete 5 times from offset 6 in "hello world" + // and asserts "hello " remains. Character deletion relies on contentEditable + // which is not available in jsdom. + it("mid-text delete next to columnList should not move block", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + id: "above-para", + type: "paragraph" as const, + content: "hello world", + }, + ...threeColumnsWithParagraphAbove, + ]); + + // Place cursor at offset 6 (after "hello ", mid-text). + editor.setTextCursorPosition("above-para", "start"); + const view = editor._tiptapEditor.view; + const startPos = editor.transact((tr) => tr.selection.$from.pos); + view.dispatch( + view.state.tr.setSelection( + (view.state.selection.constructor as any).create( + view.state.doc, + startPos + 6, + ), + ), + ); + + pressDelete(editor); + expect(editor.document).toMatchSnapshot(); + }); + + it("delete at block end should move first column block out", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { id: "above-para", type: "paragraph" as const, content: "hello " }, + ...threeColumnsWithParagraphAbove, + ]); + + editor.setTextCursorPosition("above-para", "end"); + pressDelete(editor); + expect(editor.document).toMatchSnapshot(); + }); + + it("second delete should merge first column block into paragraph", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { id: "above-para", type: "paragraph" as const, content: "hello " }, + ...threeColumnsWithParagraphAbove, + ]); + + editor.setTextCursorPosition("above-para", "end"); + pressDelete(editor); + pressDelete(editor); + expect(editor.document).toMatchSnapshot(); + }); +});