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/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 2f1e601a35..427a97df9e 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,771 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); }); + +describe("KeyboardShortcutsExtension Backspace", () => { + let editor: BlockNoteEditor | undefined; + + afterEach(() => { + editor?._tiptapEditor.destroy(); + editor = undefined; + }); + + function createEditor( + content: string, + type: + | "paragraph" + | "bulletListItem" + | "numberedListItem" + | "checkListItem" = "paragraph", + ) { + const instance = BlockNoteEditor.create({ + 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("merges without changing following siblings' nesting", () => { + const instance = createEditor(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", () => { + const instance = createEditor("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("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("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(""); + 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("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("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", + (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(); + }); + + 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 4d1758094a..f53a3d3cd0 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, @@ -65,30 +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 }) => { - 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 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 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); @@ -97,18 +75,13 @@ 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, - blockInfo.bnBlock.beforePos, + blockContainer.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*" - ) { + if (prevBlockInfo && !prevBlockInfo.isBlockContainer) { return false; } @@ -222,95 +195,65 @@ 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 + ? 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 +286,47 @@ 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 ( + !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 = () =>