From 5adfe7ef58da353edb0cc413dfeb29664cd5cb29 Mon Sep 17 00:00:00 2001 From: Asodariyasujal Date: Sun, 16 Aug 2026 13:20:24 +0530 Subject: [PATCH] fix(core): bind Mod-a to select all the document BlockNote had no `Mod-a` binding, so select-all was left to the browser's native `contenteditable` handling and ProseMirror had to rebuild a document selection from the DOM selection it produced. That fails when a block puts non-editable content first, which check list items do: the checkbox div sits ahead of the `

` holding the block's content. So in a document starting with a check list item, ProseMirror could not map the DOM selection to a valid position and dropped it, leaving the caret in place - Backspace then only edited that one block instead of clearing the document. Now `Mod-a` sets an `AllSelection` itself, which selects every block type reliably and deletes down to a single empty paragraph. Also stops `getNearestBlockPos` warning for the positions at the very start and end of the doc, which is where an `AllSelection` ends. --- packages/core/src/api/getBlockInfoFromPos.ts | 25 +++++ .../KeyboardShortcutsExtension.test.ts | 103 +++++++++++++++++- .../KeyboardShortcutsExtension.ts | 5 + .../block/createReactMathBlockSpec.test.tsx | 15 ++- 4 files changed, 142 insertions(+), 6 deletions(-) diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..764cdc5779 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -126,6 +126,31 @@ export function getNearestBlockPos(doc: Node, pos: number) { node = $pos.node(depth); } + // The doc's boundary positions (0 and `doc.content.size`) sit around the + // `blockGroup` holding the top-level blocks, so they're outside every block. + // Unlike the positions handled below, they're expected rather than + // exceptional, as they're where an `AllSelection` starts & ends. + const atDocStart = pos <= 0; + if (atDocStart || pos >= doc.content.size) { + // Position 1 is just before the `blockGroup`'s first child, and + // `doc.content.size - 1` just after its last. + const $insideBlockGroup = doc.resolve( + atDocStart ? 1 : doc.content.size - 1, + ); + const boundaryNode = atDocStart + ? $insideBlockGroup.nodeAfter + : $insideBlockGroup.nodeBefore; + + if (boundaryNode?.type.isInGroup("bnBlock")) { + return { + posBeforeNode: atDocStart + ? $insideBlockGroup.pos + : $insideBlockGroup.pos - boundaryNode.nodeSize, + node: boundaryNode, + }; + } + } + // If the position doesn't lie within a block node, we instead find the // position of the next closest one. If the position is beyond the last block, // we return the position of the last block. While running `doc.descendants` 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..d6c622750a 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -81,13 +81,37 @@ function createEditor( return editor; } +/** Creates a mounted editor with the cursor at the end of the first block. */ +function createEditorWithBlocks( + blocks: ((typeof schema)["PartialBlock"] & { id: string })[], +) { + const editor = BlockNoteEditor.create({ schema, initialContent: blocks }); + editor.mount(document.createElement("div")); + editor.setTextCursorPosition(blocks[0].id, "end"); + return editor; +} + /** - * Simulates a keyboard shortcut by dispatching a keydown event through the - * editor's `handleKeyDown` props, which is how ProseMirror invokes the - * keymap plugins created by `addKeyboardShortcuts`. + * Simulates a keyboard shortcut (e.g. "Enter", "Mod-a") via ProseMirror's + * `handleKeyDown` prop, and returns whether it was handled. Can't go via + * TipTap's `keyboardShortcut` command, which replays only the shortcut's steps + * - so it drops shortcuts that just move the selection. */ function pressKeys(editor: BlockNoteEditor, keys: string) { - editor._tiptapEditor.commands.keyboardShortcut(keys); + const lastSeparatorIndex = keys.lastIndexOf("-"); + const modifiers = keys.slice(0, lastSeparatorIndex); + const event = new KeyboardEvent("keydown", { + key: keys.slice(lastSeparatorIndex + 1), + // `Mod` is Cmd on macOS and Ctrl elsewhere - tests run in jsdom, which + // isn't macOS. + ctrlKey: modifiers.includes("Mod") || modifiers.includes("Ctrl"), + shiftKey: modifiers.includes("Shift"), + cancelable: true, + }); + + const view = editor._tiptapEditor.view; + + return view.someProp("handleKeyDown", (f) => f(view, event)) ?? false; } function countHardBreaks(editor: BlockNoteEditor) { @@ -202,3 +226,74 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); }); + +describe("KeyboardShortcutsExtension select all", () => { + // Select-all used to have no keybinding, so it fell through to the browser. + // ProseMirror couldn't map the resulting DOM selection onto a document + // starting with a check list item (which renders its checkbox before its + // content), so it stayed unselected and Backspace only edited one block. + const checkListItemFirst = [ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "checkListItem", content: "Check 2" }, + { id: "block-2", type: "paragraph", content: "Hello world" }, + ] as const; + + it("selects the whole document on Mod-a", () => { + const editor = createEditorWithBlocks([...checkListItemFirst]); + + expect(pressKeys(editor, "Mod-a")).toBe(true); + + const { selection, doc } = editor._tiptapEditor.state; + expect(selection.from).toBe(0); + expect(selection.to).toBe(doc.content.size); + + editor._tiptapEditor.destroy(); + }); + + it.each([ + ["starting with check list items", [...checkListItemFirst]], + [ + "of only check list items", + [ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "checkListItem", content: "Check 2" }, + ] as const, + ], + [ + "of paragraphs", + [ + { id: "block-0", type: "paragraph", content: "Hello" }, + { id: "block-1", type: "paragraph", content: "World" }, + ] as const, + ], + ])("clears a document %s on Mod-a + Backspace", (_, blocks) => { + const editor = createEditorWithBlocks([...blocks]); + + pressKeys(editor, "Mod-a"); + pressKeys(editor, "Backspace"); + + // The schema refills the emptied doc with a single default block. + expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]); + expect(editor.document[0].content).toEqual([]); + + editor._tiptapEditor.destroy(); + }); + + // A whole-document selection's endpoints lie outside any block, which + // `getNearestBlockPos` still has to resolve. + it("returns every block from getSelection while everything is selected", () => { + const editor = createEditorWithBlocks([ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "paragraph", content: "Hello world" }, + ]); + + pressKeys(editor, "Mod-a"); + + expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([ + "checkListItem", + "paragraph", + ]); + + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..030cadbbdb 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -953,6 +953,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ return { Backspace: handleBackspace, Delete: handleDelete, + // Taken over from TipTap's `Keymap` extension, which BlockNote doesn't + // load. Without it, ProseMirror has to derive the selection from the + // browser's, which fails for blocks that render non-editable content + // first - like a check list item's checkbox. + "Mod-a": () => this.editor.commands.selectAll(), Enter: () => handleEnter(), "Shift-Enter": () => handleEnter(true), // Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index d2d2e31796..d887a2dca3 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -264,14 +264,25 @@ describe("Math block source popup keyboard handling", () => { expect(isPopupOpen("math")).toBe(false); // Single-character keys are only blocked when no Ctrl/Cmd is held, so - // shortcuts pass through - keeping copy/select-all/find working. + // shortcuts pass through - keeping copy/find working. // (Cut/paste also pass through; that's a known limitation.) expect(pressKey("c", { ctrlKey: true })).toBe(false); - expect(pressKey("a", { ctrlKey: true })).toBe(false); expect(pressKey("f", { ctrlKey: true })).toBe(false); expect(pressKey("v", { metaKey: true })).toBe(false); }); + it("defers select-all to the editor while the popup is closed", () => { + expect(isPopupOpen("math")).toBe(false); + + // Not swallowed by the block either, but reported as handled since the + // editor binds it - and it selects the whole doc, not just this block. + expect(pressKey("a", { ctrlKey: true })).toBe(true); + + const { selection, doc } = editor._tiptapEditor.state; + expect(selection.from).toBe(0); + expect(selection.to).toBe(doc.content.size); + }); + it("defers deletion keys to the default while the popup is open", async () => { pressKey("Enter"); await flush();