From dab88449dc2016033591f51e90cedb8031c4a3fb 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 | 43 ++++++ .../KeyboardShortcutsExtension.test.ts | 125 ++++++++++++++++++ .../KeyboardShortcutsExtension.ts | 18 ++- .../block/createReactMathBlockSpec.test.tsx | 16 ++- 4 files changed, 199 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..7763fa9b4a 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -87,6 +87,40 @@ export function getNodeId(node: Node, doc: Node): string { return id; } +/** + * Retrieves the position just before the top-level block that a document + * boundary position borders on: the first block for the position at the very + * start of the doc, and the last block for the position at the very end. + * Returns `undefined` for any other position, as well as for docs that aren't + * shaped as expected (a single `blockGroup` of blocks, see the `doc` node spec). + * @param doc The ProseMirror doc. + * @param pos An integer position in the document. + */ +function getDocBoundaryBlockPos(doc: Node, pos: number) { + const atStart = pos <= 0; + if (!atStart && pos < doc.content.size) { + return undefined; + } + + const blockGroup = doc.firstChild; + const node = atStart ? blockGroup?.firstChild : blockGroup?.lastChild; + if ( + !blockGroup || + blockGroup.type.name !== "blockGroup" || + !node || + !node.type.isInGroup("bnBlock") + ) { + return undefined; + } + + return { + // The `blockGroup` starts at position 0, so its content starts at 1 and + // ends just before the doc's end. + posBeforeNode: atStart ? 1 : doc.content.size - 1 - node.nodeSize, + node, + }; +} + /** * Retrieves the position just before the nearest block node in a ProseMirror * doc, relative to a position. If the position is within a block node or its @@ -126,6 +160,15 @@ export function getNearestBlockPos(doc: Node, pos: number) { node = $pos.node(depth); } + // The document's boundary positions (0 and `doc.content.size`) lie outside + // every block node, as they sit around the `blockGroup` holding the top-level + // blocks. They're where an `AllSelection`'s endpoints sit, so they're + // expected rather than exceptional, and each borders a top-level block. + const boundaryBlockPos = getDocBoundaryBlockPos(doc, pos); + if (boundaryBlockPos) { + return boundaryBlockPos; + } + // 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..d4f5151f6d 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -81,6 +81,18 @@ function createEditor( return editor; } +function createEditorWithBlocks( + blocks: { id: string; type: string; content: string }[], +) { + const editor = BlockNoteEditor.create({ + schema, + initialContent: blocks as any, + }); + 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 @@ -90,6 +102,31 @@ function pressKeys(editor: BlockNoteEditor, keys: string) { editor._tiptapEditor.commands.keyboardShortcut(keys); } +/** + * Dispatches a keydown event straight through ProseMirror's `handleKeyDown` + * prop. Unlike `pressKeys`, this keeps selection-only changes: tiptap's + * `keyboardShortcut` command replays just the steps of the transaction the + * shortcut produced, and a transaction that only moves the selection has none. + */ +function pressKey( + editor: BlockNoteEditor, + key: string, + modifiers: { mod?: boolean; shift?: boolean } = {}, +) { + const view = editor.prosemirrorView!; + const event = new KeyboardEvent("keydown", { + key, + // `Mod` is Cmd on macOS and Ctrl elsewhere - tests run in jsdom, which + // isn't macOS. + ctrlKey: modifiers.mod ?? false, + shiftKey: modifiers.shift ?? false, + bubbles: true, + cancelable: true, + }); + + return view.someProp("handleKeyDown", (f) => f(view, event)) ?? false; +} + function countHardBreaks(editor: BlockNoteEditor) { let count = 0; editor._tiptapEditor.state.doc.descendants((node) => { @@ -202,3 +239,91 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); }); + +describe("KeyboardShortcutsExtension select all", () => { + // Select-all used to have no keybinding at all, so it fell through to the + // browser's native `contenteditable` handling. That can't map a whole-editor + // DOM selection onto the document when a block renders non-editable content + // before its editable content - which check list items do, as they render + // their checkbox before the paragraph holding the block's inline content. + // ProseMirror discarded the resulting DOM selection, so a document starting + // with a check list item stayed unselected and Backspace only edited the + // block the cursor was in. + it("selects the whole document on Mod-a", () => { + const editor = createEditorWithBlocks([ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "checkListItem", content: "Check 2" }, + { id: "block-2", type: "paragraph", content: "Hello world" }, + ]); + + pressKey(editor, "a", { mod: true }); + + const { selection, doc } = editor._tiptapEditor.state; + expect(selection.from).toBe(0); + expect(selection.to).toBe(doc.content.size); + + editor._tiptapEditor.destroy(); + }); + + it("clears a document starting with check list items on Mod-a + Backspace", () => { + const editor = createEditorWithBlocks([ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "checkListItem", content: "Check 2" }, + { id: "block-2", type: "paragraph", content: "Hello world" }, + ]); + + pressKey(editor, "a", { mod: true }); + pressKey(editor, "Backspace"); + + expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]); + expect(editor.document[0].content).toEqual([]); + + editor._tiptapEditor.destroy(); + }); + + it("clears a document of only check list items on Mod-a + Backspace", () => { + const editor = createEditorWithBlocks([ + { id: "block-0", type: "checkListItem", content: "Check 1" }, + { id: "block-1", type: "checkListItem", content: "Check 2" }, + ]); + + pressKey(editor, "a", { mod: true }); + pressKey(editor, "Backspace"); + + expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]); + expect(editor.document[0].content).toEqual([]); + + editor._tiptapEditor.destroy(); + }); + + it("clears a document of paragraphs on Mod-a + Backspace", () => { + const editor = createEditorWithBlocks([ + { id: "block-0", type: "paragraph", content: "Hello" }, + { id: "block-1", type: "paragraph", content: "World" }, + ]); + + pressKey(editor, "a", { mod: true }); + pressKey(editor, "Backspace"); + + expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]); + expect(editor.document[0].content).toEqual([]); + + editor._tiptapEditor.destroy(); + }); + + 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" }, + ]); + + pressKey(editor, "a", { mod: true }); + + 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..4dea0cf6fb 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 { AllSelection, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -953,6 +953,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ return { Backspace: handleBackspace, Delete: handleDelete, + // Selects the whole document. Without this, select-all falls through to + // the browser's native `contenteditable` handling, which can't map a + // whole-editor DOM selection onto the document when a block renders + // non-editable content before its editable content (e.g. a check list + // item, which renders its checkbox before the paragraph holding the + // block's inline content). ProseMirror then discards that DOM selection, + // leaving the selection where it was, so a following Backspace only + // edits the current block instead of clearing the document. + "Mod-a": () => + this.editor.commands.command(({ tr, dispatch }) => { + if (dispatch) { + tr.setSelection(new AllSelection(tr.doc)); + } + + return true; + }), 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..026e0beb5c 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -264,14 +264,26 @@ 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("selects the whole document on Ctrl/Cmd+A while the popup is closed", () => { + expect(isPopupOpen("math")).toBe(false); + + // Select-all isn't swallowed by the block either, but the editor handles + // it itself rather than leaving it to the browser - so it's marked + // handled and selects the whole document, hidden source included. + 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();