diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index c0578d7f38..7fc2a413c8 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, @@ -454,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); @@ -463,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 new file mode 100644 index 0000000000..1dadb64533 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/backspace.test.ts.snap @@ -0,0 +1,696 @@ +// 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", + }, +] +`; + +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 new file mode 100644 index 0000000000..d5ebea4c2c --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/backspace.test.ts @@ -0,0 +1,202 @@ +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)); +} + +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" }, + ], + }, + { + 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: "universe", + }, + ], + }, + { + type: "column" as const, + 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", + }, + ]); + + // 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(); + }); +}); + +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(); + }); +});