From 6fc70c4b649221b16f4ea1ae42cbc5097e5646b4 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 24 Sep 2026 19:50:07 +0200 Subject: [PATCH 1/3] Added `backspaceBehavior` editor option --- .../commands/mergeBlocks/mergeBlocks.test.ts | 23 +- .../commands/mergeBlocks/mergeBlocks.ts | 80 +- packages/core/src/editor/BlockNoteEditor.ts | 12 + .../managers/ExtensionManager/extensions.ts | 1 + .../KeyboardShortcutsExtension.test.ts | 705 +++++++++++++++++- .../KeyboardShortcutsExtension.ts | 203 +++-- 6 files changed, 901 insertions(+), 123 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts index ebe8ae9eff..9bb35616d5 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts @@ -91,18 +91,27 @@ describe("Test mergeBlocks", () => { expect(result).toBeUndefined(); }); - // We expect a no-op for each of the remaining tests as merging should only - // happen for blocks which both have inline content. We also expect - // `mergeBlocks` to return false as TipTap commands should do that instead of - // throwing an error, when the command cannot be executed. it("First block is empty", () => { getEditor().setTextCursorPosition("paragraph-8"); const originalDocument = getEditor().document; const ret = mergeBlocks(getPosBeforeSelectedBlock()); - expect(getEditor().document).toEqual(originalDocument); - expect(ret).toBeFalsy(); + expect(getEditor().document).toEqual( + originalDocument + .filter((block) => block.id !== "paragraph-8") + .map((block) => + block.id === "empty-paragraph" + ? { + ...block, + content: originalDocument.find( + (source) => source.id === "paragraph-8", + )!.content, + } + : block, + ), + ); + expect(ret).toBe(true); }); it("Inline content & no content", () => { @@ -132,7 +141,7 @@ describe("Test mergeBlocks", () => { const ret = mergeBlocks(getPosBeforeSelectedBlock()); expect(getEditor().document).toEqual(originalDocument); - expect(ret).toBeFalsy(); + expect(ret).toBe(false); }); it("Table content & inline content", () => { diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..e5cecc7711 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,5 +1,6 @@ -import { Node } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; +import { Fragment, Node } from "prosemirror-model"; +import { Command } from "@tiptap/core"; +import { EditorState, Selection, Transaction } from "prosemirror-state"; import { BlockInfo, @@ -107,7 +108,6 @@ const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { return ( prevBlockInfo.isBlockContainer && prevBlockInfo.blockContent.node.type.spec.content === "inline*" && - prevBlockInfo.blockContent.node.childCount > 0 && nextBlockInfo.isBlockContainer && nextBlockInfo.blockContent.node.type.spec.content === "inline*" ); @@ -115,7 +115,7 @@ const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { const mergeBlocks = ( state: EditorState, - dispatch: ((args?: any) => any) | undefined, + dispatch: ((tr: Transaction) => void) | undefined, prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo, ) => { @@ -165,14 +165,65 @@ const mergeBlocks = ( return true; }; -export const mergeBlocksCommand = - (posBetweenBlocks: number) => - ({ +type ContentBlockInfo = Extract; + +/** Merge a first child into its parent, promoting descendants into its place. */ +function mergeIntoParent( + state: EditorState, + dispatch: ((tr: Transaction) => void) | undefined, + parent: ContentBlockInfo, + child: ContentBlockInfo, +): boolean { + if (!parent.childContainer || !canMerge(parent, child)) { + return false; + } + const content = child.blockContent.node.content; + if ( + content.size > 0 && + (parent.blockContent.node.type.spec.content !== "inline*" || + !parent.blockContent.node.type.validContent( + parent.blockContent.node.content.append(content), + )) + ) { + return false; + } + + if (dispatch) { + const tr = state.tr; + if (parent.childContainer.node.childCount === 1 && !child.childContainer) { + tr.delete( + parent.childContainer.beforePos, + parent.childContainer.afterPos, + ); + } else { + tr.replaceWith( + child.bnBlock.beforePos, + child.bnBlock.afterPos, + child.childContainer?.node.content ?? Fragment.empty, + ); + } + const cursorPos = parent.blockContent.afterPos - 1; + if (content.size > 0) { + tr.insert(cursorPos, content); + } + tr.setSelection(Selection.near(tr.doc.resolve(cursorPos), -1)); + dispatch(tr.scrollIntoView()); + } + return true; +} + +/** + * Merges into the previous sibling's deepest descendant, or into the parent + * when the position is before its first child. Both blocks must support inline + * content; incompatible blocks return false for the caller to handle. + */ +export function mergeBlocksCommand(posBetweenBlocks: number): Command { + return ({ state, dispatch, }: { state: EditorState; - dispatch: ((args?: any) => any) | undefined; + dispatch: ((tr: Transaction) => void) | undefined; }) => { const $pos = state.doc.resolve(posBetweenBlocks); const nextBlockInfo = getBlockInfoFromResolvedPos($pos); @@ -183,6 +234,18 @@ export const mergeBlocksCommand = ); if (!prevBlockInfo) { + if ( + nextBlockInfo.isBlockContainer && + nextBlockInfo.blockContent.node.type.spec.content === "inline*" + ) { + const parent = getParentBlockInfo( + state.doc, + nextBlockInfo.bnBlock.beforePos, + ); + if (parent?.isBlockContainer) { + return mergeIntoParent(state, dispatch, parent, nextBlockInfo); + } + } return false; } @@ -197,3 +260,4 @@ export const mergeBlocksCommand = return mergeBlocks(state, dispatch, bottomNestedBlockInfo, nextBlockInfo); }; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bc30d58092..a991dd6c40 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -266,6 +266,18 @@ export interface BlockNoteEditorOptions< */ tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; + /** + * Determines behavior when pressing Backspace at the start of a nested paragraph. + * - `"unindent"`: Unindents the block before allowing it to merge backward. + * - `"merge"`: Skips unindenting and uses the normal backward merge behavior. + * First children merge into their parent; empty first children are removed. + * Falls back to unindenting if neither merging nor deletion is possible. + * Applies to both empty and nonempty blocks. Other block types are still + * converted to paragraphs first, and Shift-Tab always unindents. + * @default "unindent" + */ + backspaceBehavior?: "unindent" | "merge"; + /** * Allows enabling / disabling features of tables. * diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..d0db5be05f 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -107,6 +107,7 @@ export function getDefaultTiptapExtensions( KeyboardShortcutsExtension.configure({ editor: editor, tabBehavior: options.tabBehavior, + backspaceBehavior: options.backspaceBehavior ?? "unindent", }), BlockGroup.configure({ domAttributes: options.domAttributes, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 2f1e601a35..961ae3b544 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -1,14 +1,21 @@ -import { describe, expect, it } from "vite-plus/test"; +/** + * @vitest-environment jsdom + */ +import { closeHistory } from "@tiptap/pm/history"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { mergeBlocksCommand } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { getBlockInfoFromSelection } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; -import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { + defaultBlockSpecs, + PartialBlock, +} from "../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../../schema/index.js"; -/** - * @vitest-environment jsdom - */ - // The `hardBreakShortcut` setting lives on the block spec's implementation // (`schema.blockSpecs[type].implementation.meta`), not on the block config in // `schema.blockSchema`. These blocks verify that the Enter / Shift-Enter @@ -202,3 +209,689 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); }); + +describe("KeyboardShortcutsExtension backspaceBehavior", () => { + let editor: BlockNoteEditor | undefined; + + afterEach(() => { + editor?._tiptapEditor.destroy(); + editor = undefined; + }); + + function createEditor( + backspaceBehavior: "unindent" | "merge" | undefined, + content: string, + type: + | "paragraph" + | "bulletListItem" + | "numberedListItem" + | "checkListItem" = "paragraph", + ) { + const instance = BlockNoteEditor.create({ + backspaceBehavior, + trailingBlock: false, + initialContent: [ + { + id: "parent", + content: "Parent", + children: [ + { id: "before", content: "Before" }, + { id: "current", type, content }, + { id: "after", content: "After" }, + ], + }, + ], + }); + editor = instance; + instance.mount(document.createElement("div")); + instance.setTextCursorPosition("current", "start"); + return instance; + } + + function pressKey( + instance: BlockNoteEditor, + key: "Backspace" | "Shift-Tab" | "Delete", + ) { + const event = new KeyboardEvent("keydown", { + key: key === "Shift-Tab" ? "Tab" : key, + shiftKey: key === "Shift-Tab", + bubbles: true, + cancelable: true, + }); + const view = instance.prosemirrorView; + view.someProp("handleKeyDown", (handler) => handler(view, event)); + } + + function expectUnindented(instance: BlockNoteEditor) { + expect(instance.document.map((block) => block.id)).toEqual([ + "parent", + "current", + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before"]); + expect( + instance.getBlock("current")?.children.map((block) => block.id), + ).toEqual(["after"]); + } + + describe.each(["", "Current"])("Backspace with content %j", (content) => { + it.each([undefined, "unindent"] as const)( + "unindents with backspaceBehavior=%s", + (behavior) => { + const instance = createEditor(behavior, content); + pressKey(instance, "Backspace"); + expectUnindented(instance); + expect(instance.getBlock("current")?.content).toEqual( + content ? [{ type: "text", text: content, styles: {} }] : [], + ); + }, + ); + + it("merges without changing following siblings' nesting", () => { + const instance = createEditor("merge", content); + pressKey(instance, "Backspace"); + + expect(instance.document.map((block) => block.id)).toEqual(["parent"]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "after"]); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("before")?.content).toEqual([ + { type: "text", text: `Before${content}`, styles: {} }, + ]); + expect(instance.getTextCursorPosition().block.id).toBe("before"); + }); + }); + + it("still unindents with Shift-Tab in merge mode", () => { + const instance = createEditor("merge", "Current"); + pressKey(instance, "Shift-Tab"); + expectUnindented(instance); + }); + + it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)( + "converts %s to a paragraph before merging", + (type) => { + const instance = createEditor("merge", "Current", type); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")?.type).toBe("paragraph"); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "current", "after"]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("before")?.content).toEqual([ + { type: "text", text: "BeforeCurrent", styles: {} }, + ]); + }, + ); + + it("does not merge when the cursor is inside the block", () => { + const instance = createEditor("merge", "Current"); + instance.setTextCursorPosition("current", "end"); + // The shortcut must leave normal character deletion to the browser. + pressKey(instance, "Backspace"); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "current", "after"]); + expect(instance.getBlock("current")?.content).toEqual([ + { type: "text", text: "Current", styles: {} }, + ]); + }); + + function createScenario(initialContent: PartialBlock[]) { + const instance = createEditor("merge", ""); + instance.replaceBlocks(instance.document, initialContent); + instance.setTextCursorPosition("current", "start"); + return instance; + } + + describe.each(["paragraph", "image", "table"] as const)( + "first child of %s", + (type) => { + it.each([false, true])( + "deletes an empty first child (only child: %s)", + (onlyChild) => { + const instance = createScenario([ + { + id: "parent", + ...parentSpec(type), + children: [ + { id: "current", content: "" }, + ...(onlyChild ? [] : [{ id: "after", content: "After" }]), + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(onlyChild ? [] : ["after"]); + expect(instance.getBlock("parent")?.type).toBe(type); + expectSelectionAtEnd(instance, "parent", type); + instance.prosemirrorView.state.doc.check(); + }, + ); + + it("preserves descendants when removing an empty first child", () => { + const instance = createScenario([ + { + id: "parent", + ...parentSpec(type), + children: [ + { + id: "current", + children: [{ id: "grandchild", content: "Keep me" }], + }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["grandchild", "after"]); + expect(instance.getBlock("grandchild")?.content).toEqual([ + { type: "text", text: "Keep me", styles: {} }, + ]); + instance.prosemirrorView.state.doc.check(); + expectSelectionAtEnd(instance, "parent", type); + }); + }, + ); + + it("merges into a paragraph nested under a non-text previous sibling", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { + id: "image", + type: "image", + children: [{ id: "target", content: "Target" }], + }, + { id: "current", content: "Current" }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("target")?.content).toEqual([ + { type: "text", text: "TargetCurrent", styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["image", "after"]); + }); + + it("keeps the only root block", () => { + const instance = createScenario([{ id: "current", content: "" }]); + pressKey(instance, "Backspace"); + expect(instance.document.map((block) => block.id)).toEqual(["current"]); + }); + + it.each(["", "Parent"])( + "merges the first child's text into parent %j", + (content) => { + const instance = createScenario([ + { + id: "parent", + content, + children: [ + { + id: "current", + content: "Current", + children: [{ id: "grandchild", content: "Keep" }], + }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("parent")?.content).toEqual([ + { type: "text", text: `${content}Current`, styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["grandchild", "after"]); + expect(instance.getTextCursorPosition().block.id).toBe("parent"); + expect(instance.prosemirrorView.state.selection.$from.parentOffset).toBe( + content.length, + ); + }, + ); + + it("unindents nonempty first children of non-text parents without losing text", () => { + const instance = createScenario([ + { + id: "parent", + type: "image", + children: [{ id: "current", content: "Keep" }], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.document.map((block) => block.id)).toEqual([ + "parent", + "current", + ]); + expect(instance.getBlock("current")?.content).toEqual([ + { type: "text", text: "Keep", styles: {} }, + ]); + }); + + it.each(["", "Current"])( + "handles an empty previous sibling with current text %j", + (content) => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { id: "before", content: "", props: { textAlignment: "right" } }, + { id: "current", content }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "after"]); + instance.prosemirrorView.state.doc.check(); + expect(instance.getTextCursorPosition().block.id).toBe("before"); + expect(instance.getBlock("before")?.props).toMatchObject({ + textAlignment: "right", + }); + expect(instance.getBlock("before")?.content).toEqual( + content ? [{ type: "text", text: content, styles: {} }] : [], + ); + expect(instance.prosemirrorView.state.selection.$from.parentOffset).toBe( + 0, + ); + }, + ); + + it("unindents nonempty blocks after a table when merging is impossible", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { + id: "table", + type: "table", + content: { type: "tableContent", rows: [{ cells: ["Cell"] }] }, + }, + { id: "current", content: "Keep" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.document.map((block) => block.id)).toEqual([ + "parent", + "current", + ]); + expect(instance.getBlock("table")?.type).toBe("table"); + expect(instance.getBlock("current")?.content).toEqual([ + { type: "text", text: "Keep", styles: {} }, + ]); + }); + + it.each(["image", "table"] as const)( + "deletes empty blocks after %s while preserving descendants", + (type) => { + const previous: PartialBlock = + type === "table" + ? { + id: "previous", + type, + content: { type: "tableContent", rows: [{ cells: ["Cell"] }] }, + } + : { id: "previous", type }; + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + previous, + { + id: "current", + children: [{ id: "grandchild", content: "Keep" }], + }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["previous", "grandchild", "after"]); + expectSelectionAtEnd(instance, "previous", type); + expect(instance.getBlock("grandchild")?.content).toEqual([ + { type: "text", text: "Keep", styles: {} }, + ]); + instance.prosemirrorView.state.doc.check(); + }, + ); + + it("merges a deeply nested only child and can undo the operation", () => { + const instance = createScenario([ + { + id: "root", + content: "Root", + children: [ + { + id: "parent", + content: "Parent", + children: [{ id: "current", content: "Current" }], + }, + ], + }, + ]); + const before = instance.document; + instance.prosemirrorView.dispatch( + closeHistory(instance.prosemirrorView.state.tr), + ); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("parent")?.children).toEqual([]); + expect(instance.getBlock("parent")?.content).toEqual([ + { type: "text", text: "ParentCurrent", styles: {} }, + ]); + expect(instance.prosemirrorView.state.selection.$from.parentOffset).toBe(6); + instance.undo(); + expect(instance.document).toEqual(before); + }); + + it.each(["", "Current"])( + "the merge command handles a first child with text %j directly", + (content) => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { id: "current", content }, + { id: "after", content: "After" }, + ], + }, + ]); + const pos = getBlockInfoFromSelection(instance.prosemirrorView.state) + .bnBlock.beforePos; + const command = mergeBlocksCommand(pos); + const before = instance.document; + expect(instance._tiptapEditor.can().command(command)).toBe(true); + expect(instance.document).toEqual(before); + expect(instance._tiptapEditor.commands.command(command)).toBe(true); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("parent")?.content).toEqual([ + { type: "text", text: `Parent${content}`, styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["after"]); + }, + ); + + it("the merge command rejects incompatible parents without mutating", () => { + const instance = createScenario([ + { + id: "parent", + type: "image", + children: [{ id: "current", content: "Keep" }], + }, + ]); + const pos = getBlockInfoFromSelection(instance.prosemirrorView.state) + .bnBlock.beforePos; + const before = instance.document; + expect( + instance._tiptapEditor.commands.command(mergeBlocksCommand(pos)), + ).toBe(false); + expect(instance.document).toEqual(before); + }); + + it.each(["image", "table"] as const)( + "the merge command rejects %s on either side and as a parent", + (type) => { + const nonText: PartialBlock = + type === "image" + ? { id: "non-text", type } + : { + id: "non-text", + type, + content: { type: "tableContent", rows: [{ cells: ["Cell"] }] }, + }; + const instance = createScenario([{ id: "current", content: "" }]); + for (const content of ["", "Text"]) { + for (const blocks of [ + [nonText, { id: "current", content }], + [ + { id: "text", content }, + { ...nonText, id: "current" }, + ], + [{ ...nonText, children: [{ id: "current", content }] }], + ]) { + instance.replaceBlocks(instance.document, blocks); + const pos = instance.prosemirrorView.state.doc; + let beforePos: number | undefined; + pos.descendants((node, position) => { + if (node.attrs.id === "current") { + beforePos = position; + } + }); + if (beforePos === undefined) { + throw new Error("Missing test block"); + } + const before = instance.document; + const command = mergeBlocksCommand(beforePos); + expect(instance._tiptapEditor.can().command(command)).toBe(false); + expect(instance._tiptapEditor.commands.command(command)).toBe(false); + expect(instance.document).toEqual(before); + } + } + }, + ); + + function expectSelectionAtEnd( + instance: BlockNoteEditor, + id: string, + type: "paragraph" | "image" | "table", + ) { + expect(instance.getTextCursorPosition().block.id).toBe(id); + const selection = instance.prosemirrorView.state.selection; + if (type === "image") { + expect(selection).toBeInstanceOf(NodeSelection); + } else { + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.empty).toBe(true); + expect(selection.$from.parentOffset).toBe( + selection.$from.parent.content.size, + ); + if (type === "table") { + expect(["Cell", "Last"]).toContain(selection.$from.parent.textContent); + expect( + selection.$from.node(selection.$from.depth - 1).type.name, + ).toMatch(/table.*Cell/i); + } + } + } + + it("replaces a preceding image with a nonempty paragraph and keeps its descendants", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { id: "image", type: "image" }, + { + id: "current", + content: "Keep", + children: [{ id: "child", content: "Child" }], + }, + { id: "after", content: "After" }, + ], + }, + ]); + const current = instance.getBlock("current"); + pressKey(instance, "Backspace"); + expect(instance.getBlock("image")).toBeUndefined(); + expect(instance.getBlock("current")).toEqual(current); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["current", "after"]); + expect(instance.getTextCursorPosition().block.id).toBe("current"); + expect(instance.prosemirrorView.state.selection.$from.parentOffset).toBe(0); + }); + + it("deletes selected text before attempting to merge or unindent", () => { + const instance = createEditor("merge", "Current"); + const start = instance.prosemirrorView.state.selection.from; + instance.prosemirrorView.dispatch( + instance.prosemirrorView.state.tr.setSelection( + TextSelection.create( + instance.prosemirrorView.state.doc, + start, + start + 3, + ), + ), + ); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")?.content).toEqual([ + { type: "text", text: "rent", styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "current", "after"]); + }); + + it.each(["unindent", "merge"] as const)( + "Delete still merges forward with backspaceBehavior=%s", + (behavior) => { + const instance = createEditor(behavior, "Current"); + instance.setTextCursorPosition("before", "end"); + pressKey(instance, "Delete"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("before")?.content).toEqual([ + { type: "text", text: "BeforeCurrent", styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "after"]); + }, + ); + + it.each(["image", "table"] as const)( + "promotes descendants of an empty only child under %s and supports undo", + (type) => { + const instance = createScenario([ + { + id: "parent", + ...parentSpec(type), + children: [ + { + id: "current", + children: [ + { + id: "child", + content: "Keep", + children: [{ id: "grandchild", content: "Deep" }], + }, + ], + }, + ], + }, + ]); + const before = instance.document; + const child = instance.getBlock("child"); + instance.prosemirrorView.dispatch( + closeHistory(instance.prosemirrorView.state.tr), + ); + pressKey(instance, "Backspace"); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["child"]); + expect(instance.getBlock("child")).toEqual(child); + expectSelectionAtEnd(instance, "parent", type); + instance.prosemirrorView.state.doc.check(); + instance.undo(); + expect(instance.document).toEqual(before); + }, + ); + + function parentSpec(type: "paragraph" | "image" | "table"): PartialBlock { + if (type === "table") { + return { + type, + content: { type: "tableContent", rows: [{ cells: ["First", "Last"] }] }, + }; + } + return { type }; + } + + it("selects the last cell of a deeply nested preceding table", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { + id: "before", + content: "Before", + children: [ + { + id: "table", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A", "B"] }, { cells: ["C", "Last"] }], + }, + }, + ], + }, + { id: "current" }, + { id: "after", content: "After" }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "after"]); + expectSelectionAtEnd(instance, "table", "table"); + }); + + it("preserves inline styles and target properties when merging into the parent", () => { + const instance = createScenario([ + { + id: "parent", + props: { textAlignment: "right" }, + content: [{ type: "text", text: "Parent", styles: { bold: true } }], + children: [ + { + id: "current", + props: { textAlignment: "center" }, + content: [ + { type: "text", text: "Child", styles: { italic: true } }, + ], + }, + ], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.getBlock("parent")?.props).toMatchObject({ + textAlignment: "right", + }); + expect(instance.getBlock("parent")?.content).toEqual([ + { type: "text", text: "Parent", styles: { bold: true } }, + { type: "text", text: "Child", styles: { italic: true } }, + ]); + expect(instance.getBlock("current")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..7b46eefce0 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -28,6 +28,7 @@ import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingTo export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; + backspaceBehavior: "unindent" | "merge"; }>({ priority: 50, @@ -68,6 +69,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ // Removes a level of nesting if the block is indented if the selection is at the start of the block. () => commands.command(({ state, tr }) => { + if (this.options.backspaceBehavior === "merge") { + return false; + } + const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { return false; @@ -87,8 +92,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // Merges block with the previous one if it isn't indented, and the selection is at the start of the - // block. The target block for merging must contain inline content. + // Merges with the previous block at the start of the block. Nested blocks + // reach this step when backspaceBehavior is "merge". The target must + // contain inline content. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -97,19 +103,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const { bnBlock: blockContainer, blockContent } = blockInfo; - const prevBlockInfo = getPrevBlockInfo( - state.doc, - blockInfo.bnBlock.beforePos, - ); - // If the previous block has no inline content, it can't be merged. - // It's instead deleted, which is done later in the chan, so we - // return early here. - if ( - !prevBlockInfo || - !prevBlockInfo.isBlockContainer || - prevBlockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return false; + if (this.options.backspaceBehavior !== "merge") { + const prevBlockInfo = getPrevBlockInfo( + state.doc, + blockContainer.beforePos, + ); + if ( + !prevBlockInfo?.isBlockContainer || + prevBlockInfo.blockContent.node.type.spec.content !== "inline*" + ) { + return false; + } } const selectionAtBlockStart = @@ -222,95 +226,66 @@ export const KeyboardShortcutsExtension = Extension.create<{ return true; }), - // Deletes the current block if it's an empty block with inline content, - // and moves the selection to the previous block. + // Removes an empty inline block when merging is impossible. Its + // children take its place; the cursor moves to the preceding block. () => - commands.command(({ state }) => { + commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if ( + !blockInfo.isBlockContainer || + !state.selection.empty || + blockInfo.blockContent.node.type.spec.content !== "inline*" || + blockInfo.blockContent.node.content.size !== 0 + ) { return false; } - - const blockEmpty = - blockInfo.blockContent.node.childCount === 0 && - blockInfo.blockContent.node.type.spec.content === "inline*"; - - if (blockEmpty) { - const prevBlockInfo = getPrevBlockInfo( - state.doc, - blockInfo.bnBlock.beforePos, - ); - if (!prevBlockInfo) { - return false; - } - const bottomNestedPrevBlockInfo = getBottomNestedBlockInfo( - state.doc, - prevBlockInfo, - ); - if (!bottomNestedPrevBlockInfo.isBlockContainer) { - return false; - } - if ( - !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.isBlockContainer - ) { - return false; - } - - let chainedCommands = chain(); - - // Moves the children the current block. - if (blockInfo.childContainer) { - chainedCommands.insertContentAt( - blockInfo.bnBlock.afterPos, - blockInfo.childContainer?.node.content, - ); - } - + const prevBlockInfo = getPrevBlockInfo( + state.doc, + blockInfo.bnBlock.beforePos, + ); + const parent = + !prevBlockInfo && this.options.backspaceBehavior === "merge" + ? getParentBlockInfo(state.doc, blockInfo.bnBlock.beforePos) + : undefined; + const target = prevBlockInfo + ? getBottomNestedBlockInfo(state.doc, prevBlockInfo) + : parent; + if (!target?.isBlockContainer) { + return false; + } + if (dispatch) { if ( - bottomNestedPrevBlockInfo.blockContent.node.type.spec - .content === "tableRow+" + parent?.childContainer?.node.childCount === 1 && + !blockInfo.childContainer ) { - const tableBlockEndPos = blockInfo.bnBlock.beforePos - 1; - const tableBlockContentEndPos = tableBlockEndPos - 1; - const lastRowEndPos = tableBlockContentEndPos - 1; - const lastCellEndPos = lastRowEndPos - 1; - const lastCellParagraphEndPos = lastCellEndPos - 1; - - chainedCommands = chainedCommands.setTextSelection( - lastCellParagraphEndPos, - ); - } else if ( - bottomNestedPrevBlockInfo.blockContent.node.type.spec - .content === "" - ) { - chainedCommands = chainedCommands.setNodeSelection( - bottomNestedPrevBlockInfo.blockContent.beforePos, + tr.delete( + parent.childContainer.beforePos, + parent.childContainer.afterPos, ); } else { - const blockContentEndPos = - bottomNestedPrevBlockInfo.blockContent.afterPos - 1; - - chainedCommands = - chainedCommands.setTextSelection(blockContentEndPos); + tr.replaceWith( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + blockInfo.childContainer?.node.content ?? Fragment.empty, + ); } - - return chainedCommands - .deleteRange({ - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, - }) - .scrollIntoView() - .run(); + tr.setSelection( + target.blockContent.node.type.spec.content === "" + ? NodeSelection.create(tr.doc, target.blockContent.beforePos) + : TextSelection.near( + tr.doc.resolve(target.blockContent.afterPos - 1), + -1, + ), + ); + tr.scrollIntoView(); } - - return false; + return true; }), // Deletes previous block if it contains no content and isn't a table, // when the selection is empty and at the start of the block. Moves the // current block into the deleted block's place. () => - commands.command(({ state }) => { + commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { @@ -343,24 +318,48 @@ export const KeyboardShortcutsExtension = Extension.create<{ bottomBlock.blockContent.node.childCount === 0); if (prevBlockNotTableAndNoContent) { - return chain() - .cut( - { - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, - }, + if (dispatch) { + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + tr.replaceWith( + bottomBlock.bnBlock.beforePos, bottomBlock.bnBlock.afterPos, - ) - .deleteRange({ - from: bottomBlock.bnBlock.beforePos, - to: bottomBlock.bnBlock.afterPos, - }) - .run(); + blockInfo.bnBlock.node, + ); + tr.setSelection( + TextSelection.near( + tr.doc.resolve(bottomBlock.bnBlock.beforePos + 2), + ), + ); + tr.scrollIntoView(); + } + return true; } } return false; }), + // If no merge/deletion is possible (for example beside a table or + // under an image), unindent instead of trapping the cursor or losing text. + () => + commands.command(({ state, tr }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + this.options.backspaceBehavior !== "merge" || + !blockInfo.isBlockContainer || + !state.selection.empty || + state.selection.from !== blockInfo.blockContent.beforePos + 1 + ) { + return false; + } + return liftItem( + tr, + tr.doc.type.schema.nodes["blockContainer"], + tr.doc.type.schema.nodes["blockGroup"], + ); + }), ]); const handleDelete = () => From 6e2bc11727d5600d94889293678fcb33b3a1c0ce Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 25 Sep 2026 15:41:35 +0200 Subject: [PATCH 2/3] Removed config, made `"merge"` behavior default --- packages/core/src/editor/BlockNoteEditor.ts | 12 -- .../managers/ExtensionManager/extensions.ts | 1 - .../KeyboardShortcutsExtension.test.ts | 156 +++++++++++++----- .../KeyboardShortcutsExtension.ts | 53 +----- 4 files changed, 124 insertions(+), 98 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a991dd6c40..bc30d58092 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -266,18 +266,6 @@ export interface BlockNoteEditorOptions< */ tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; - /** - * Determines behavior when pressing Backspace at the start of a nested paragraph. - * - `"unindent"`: Unindents the block before allowing it to merge backward. - * - `"merge"`: Skips unindenting and uses the normal backward merge behavior. - * First children merge into their parent; empty first children are removed. - * Falls back to unindenting if neither merging nor deletion is possible. - * Applies to both empty and nonempty blocks. Other block types are still - * converted to paragraphs first, and Shift-Tab always unindents. - * @default "unindent" - */ - backspaceBehavior?: "unindent" | "merge"; - /** * Allows enabling / disabling features of tables. * diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index d0db5be05f..853cca2493 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -107,7 +107,6 @@ export function getDefaultTiptapExtensions( KeyboardShortcutsExtension.configure({ editor: editor, tabBehavior: options.tabBehavior, - backspaceBehavior: options.backspaceBehavior ?? "unindent", }), BlockGroup.configure({ domAttributes: options.domAttributes, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 961ae3b544..427a97df9e 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -210,7 +210,7 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { }); }); -describe("KeyboardShortcutsExtension backspaceBehavior", () => { +describe("KeyboardShortcutsExtension Backspace", () => { let editor: BlockNoteEditor | undefined; afterEach(() => { @@ -219,7 +219,6 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { }); function createEditor( - backspaceBehavior: "unindent" | "merge" | undefined, content: string, type: | "paragraph" @@ -228,7 +227,6 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { | "checkListItem" = "paragraph", ) { const instance = BlockNoteEditor.create({ - backspaceBehavior, trailingBlock: false, initialContent: [ { @@ -276,20 +274,8 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { } describe.each(["", "Current"])("Backspace with content %j", (content) => { - it.each([undefined, "unindent"] as const)( - "unindents with backspaceBehavior=%s", - (behavior) => { - const instance = createEditor(behavior, content); - pressKey(instance, "Backspace"); - expectUnindented(instance); - expect(instance.getBlock("current")?.content).toEqual( - content ? [{ type: "text", text: content, styles: {} }] : [], - ); - }, - ); - it("merges without changing following siblings' nesting", () => { - const instance = createEditor("merge", content); + const instance = createEditor(content); pressKey(instance, "Backspace"); expect(instance.document.map((block) => block.id)).toEqual(["parent"]); @@ -304,8 +290,8 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { }); }); - it("still unindents with Shift-Tab in merge mode", () => { - const instance = createEditor("merge", "Current"); + it("still unindents with Shift-Tab", () => { + const instance = createEditor("Current"); pressKey(instance, "Shift-Tab"); expectUnindented(instance); }); @@ -313,7 +299,7 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)( "converts %s to a paragraph before merging", (type) => { - const instance = createEditor("merge", "Current", type); + const instance = createEditor("Current", type); pressKey(instance, "Backspace"); expect(instance.getBlock("current")?.type).toBe("paragraph"); expect( @@ -328,7 +314,7 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { ); it("does not merge when the cursor is inside the block", () => { - const instance = createEditor("merge", "Current"); + const instance = createEditor("Current"); instance.setTextCursorPosition("current", "end"); // The shortcut must leave normal character deletion to the browser. pressKey(instance, "Backspace"); @@ -341,7 +327,7 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { }); function createScenario(initialContent: PartialBlock[]) { - const instance = createEditor("merge", ""); + const instance = createEditor(""); instance.replaceBlocks(instance.document, initialContent); instance.setTextCursorPosition("current", "start"); return instance; @@ -750,7 +736,7 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { }); it("deletes selected text before attempting to merge or unindent", () => { - const instance = createEditor("merge", "Current"); + const instance = createEditor("Current"); const start = instance.prosemirrorView.state.selection.from; instance.prosemirrorView.dispatch( instance.prosemirrorView.state.tr.setSelection( @@ -770,21 +756,18 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { ).toEqual(["before", "current", "after"]); }); - it.each(["unindent", "merge"] as const)( - "Delete still merges forward with backspaceBehavior=%s", - (behavior) => { - const instance = createEditor(behavior, "Current"); - instance.setTextCursorPosition("before", "end"); - pressKey(instance, "Delete"); - expect(instance.getBlock("current")).toBeUndefined(); - expect(instance.getBlock("before")?.content).toEqual([ - { type: "text", text: "BeforeCurrent", styles: {} }, - ]); - expect( - instance.getBlock("parent")?.children.map((block) => block.id), - ).toEqual(["before", "after"]); - }, - ); + it("Delete still merges forward", () => { + const instance = createEditor("Current"); + instance.setTextCursorPosition("before", "end"); + pressKey(instance, "Delete"); + expect(instance.getBlock("current")).toBeUndefined(); + expect(instance.getBlock("before")?.content).toEqual([ + { type: "text", text: "BeforeCurrent", styles: {} }, + ]); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["before", "after"]); + }); it.each(["image", "table"] as const)( "promotes descendants of an empty only child under %s and supports undo", @@ -894,4 +877,103 @@ describe("KeyboardShortcutsExtension backspaceBehavior", () => { ]); expect(instance.getBlock("current")).toBeUndefined(); }); + + it.each([false, true])( + "matches PR examples 2 and 4 (previous block has children: %s)", + (nestedTarget) => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: nestedTarget ? [{ id: "target", content: "Target" }] : [], + }, + { + id: "current", + content: "Current", + children: [{ id: "child", content: "Child" }], + }, + ]); + pressKey(instance, "Backspace"); + expect(instance.document.map((block) => block.id)).toEqual([ + "parent", + "child", + ]); + const target = nestedTarget ? "target" : "parent"; + expect(instance.getBlock(target)?.content).toEqual([ + { + type: "text", + text: `${nestedTarget ? "Target" : "Parent"}Current`, + styles: {}, + }, + ]); + expect(instance.getTextCursorPosition().block.id).toBe(target); + expect(instance.prosemirrorView.state.selection.$from.parentOffset).toBe( + 6, + ); + expect(instance.getBlock("child")?.content).toEqual([ + { type: "text", text: "Child", styles: {} }, + ]); + }, + ); + + it("deletes a selected nested image", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { id: "current", type: "image" }, + { id: "after", content: "After" }, + ], + }, + ]); + const info = getBlockInfoFromSelection(instance.prosemirrorView.state); + if (!info.isBlockContainer) { + throw new Error("Expected image block"); + } + instance.prosemirrorView.dispatch( + instance.prosemirrorView.state.tr.setSelection( + NodeSelection.create( + instance.prosemirrorView.state.doc, + info.blockContent.beforePos, + ), + ), + ); + pressKey(instance, "Backspace"); + expect(instance.getBlock("current")).toBeUndefined(); + expect( + instance.getBlock("parent")?.children.map((block) => block.id), + ).toEqual(["after"]); + }); + + it("does nothing at the start of a nested table cell", () => { + const instance = createScenario([ + { + id: "parent", + content: "Parent", + children: [ + { id: "current", ...parentSpec("table") }, + { id: "after", content: "After" }, + ], + }, + ]); + let pos: number | undefined; + instance.prosemirrorView.state.doc.descendants((node, nodePos) => { + if (node.isText && node.text === "First") { + pos = nodePos; + } + }); + if (pos === undefined) { + throw new Error("Expected table cell text"); + } + instance.prosemirrorView.dispatch( + instance.prosemirrorView.state.tr.setSelection( + TextSelection.create(instance.prosemirrorView.state.doc, pos), + ), + ); + const before = instance.document; + pressKey(instance, "Backspace"); + expect(instance.document).toEqual(before); + expect(instance.prosemirrorView.state.selection.from).toBe(pos); + }); }); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 7b46eefce0..935d0cc07c 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -28,7 +28,6 @@ import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingTo export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; - backspaceBehavior: "unindent" | "merge"; }>({ priority: 50, @@ -66,35 +65,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // Removes a level of nesting if the block is indented if the selection is at the start of the block. - () => - commands.command(({ state, tr }) => { - if (this.options.backspaceBehavior === "merge") { - return false; - } - - const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { - return false; - } - const { blockContent } = blockInfo; - - const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; - - if (selectionAtBlockStart) { - return liftItem( - tr, - tr.doc.type.schema.nodes["blockContainer"], - tr.doc.type.schema.nodes["blockGroup"], - ); - } - - return false; - }), - // Merges with the previous block at the start of the block. Nested blocks - // reach this step when backspaceBehavior is "merge". The target must - // contain inline content. + // Merges at the start of the block, into the preceding sibling + // (or its deepest descendant) or parent. Both must have inline content. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -103,19 +75,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const { bnBlock: blockContainer, blockContent } = blockInfo; - if (this.options.backspaceBehavior !== "merge") { - const prevBlockInfo = getPrevBlockInfo( - state.doc, - blockContainer.beforePos, - ); - if ( - !prevBlockInfo?.isBlockContainer || - prevBlockInfo.blockContent.node.type.spec.content !== "inline*" - ) { - return false; - } - } - const selectionAtBlockStart = state.selection.from === blockContent.beforePos + 1; const selectionEmpty = state.selection.empty; @@ -243,10 +202,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - const parent = - !prevBlockInfo && this.options.backspaceBehavior === "merge" - ? getParentBlockInfo(state.doc, blockInfo.bnBlock.beforePos) - : undefined; + const parent = !prevBlockInfo + ? getParentBlockInfo(state.doc, blockInfo.bnBlock.beforePos) + : undefined; const target = prevBlockInfo ? getBottomNestedBlockInfo(state.doc, prevBlockInfo) : parent; @@ -347,7 +305,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); if ( - this.options.backspaceBehavior !== "merge" || !blockInfo.isBlockContainer || !state.selection.empty || state.selection.from !== blockInfo.blockContent.beforePos + 1 From 9e70a545afdd3a8a7d71e5a183b50df1f5aa5e3e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 25 Sep 2026 18:38:11 +0200 Subject: [PATCH 3/3] Fixed regression --- .../KeyboardShortcuts/KeyboardShortcutsExtension.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 935d0cc07c..f53a3d3cd0 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -75,6 +75,16 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const { bnBlock: blockContainer, blockContent } = blockInfo; + // Crossing a column-list boundary moves the block into the last + // column first; the following handler owns that operation. + const prevBlockInfo = getPrevBlockInfo( + state.doc, + blockContainer.beforePos, + ); + if (prevBlockInfo && !prevBlockInfo.isBlockContainer) { + return false; + } + const selectionAtBlockStart = state.selection.from === blockContent.beforePos + 1; const selectionEmpty = state.selection.empty;