From 38330976a3688d0df8a23f0ce90b92458329add8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 21 Aug 2026 13:55:46 +0200 Subject: [PATCH 1/6] Added `hide` middleware to `GenericPopover` --- .../KeyboardShortcutsExtension.test.ts | 76 +++++++++++++++++++ .../KeyboardShortcutsExtension.ts | 16 +++- 2 files changed, 91 insertions(+), 1 deletion(-) 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..068bdf6b74 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -1,3 +1,4 @@ +import { Selection, TextSelection } from "prosemirror-state"; import { describe, expect, it } from "vite-plus/test"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; @@ -110,6 +111,81 @@ function getTextContent(editor: BlockNoteEditor) { return text; } +describe("KeyboardShortcutsExtension Mod-a (select all)", () => { + // BlockNote disables TipTap's core extensions, so it has no default `Mod-a` + // binding and select-all used to rely on the browser's native behaviour. That + // native select-all collapses to a cursor when the editor's first element is + // non-editable - e.g. the checkbox `
` of a check list item as the first + // block - so `Mod-a` is now handled explicitly. These tests exercise the + // keymap path (not native selection) and would collapse before the fix. + function createSelectAllEditor( + blocks: { type: "paragraph" | "checkListItem"; content: string }[], + ) { + const editor = BlockNoteEditor.create({ + schema, + initialContent: blocks.map((block, index) => ({ + id: `block-${index}`, + ...block, + })), + }); + editor.mount(document.createElement("div")); + return editor; + } + + // Dispatches a real `Mod-a` keydown through ProseMirror's `handleKeyDown`, the + // path browsers use to invoke the keymap. TipTap's `keyboardShortcut` command + // doesn't reliably simulate modifier combos in jsdom, and prosemirror-keymap + // resolves `Mod` to `Ctrl` outside of a Mac environment (jsdom reports none). + function pressSelectAll(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + ctrlKey: true, + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); + } + + function expectWholeDocSelected(editor: BlockNoteEditor) { + const { selection, doc } = editor._tiptapEditor.state; + // Select-all spans all content as a `TextSelection` (from the first + // selectable position to the last), not an `AllSelection`. + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(Selection.atStart(doc).from); + expect(selection.to).toBe(Selection.atEnd(doc).to); + } + + it("selects the whole document", () => { + const editor = createSelectAllEditor([ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ]); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + + expectWholeDocSelected(editor); + + editor._tiptapEditor.destroy(); + }); + + it("selects the whole document when the first block is a check list item", () => { + const editor = createSelectAllEditor([ + { type: "checkListItem", content: "First" }, + { type: "paragraph", content: "Second" }, + ]); + // Place the cursor in a later block to make sure select-all still spans the + // whole document, not just the current block. + editor.setTextCursorPosition("block-1", "end"); + + pressSelectAll(editor); + + expectWholeDocSelected(editor); + + editor._tiptapEditor.destroy(); + }); +}); + describe("KeyboardShortcutsExtension hardBreakShortcut", () => { it("inserts a hard break on Shift-Enter by default", () => { const editor = createEditor("paragraph"); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..5d45672b48 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 { Selection, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -997,6 +997,20 @@ export const KeyboardShortcutsExtension = Extension.create<{ "Mod-z": () => this.options.editor.undo(), "Mod-y": () => this.options.editor.redo(), "Shift-Mod-z": () => this.options.editor.redo(), + "Mod-a": () => { + const view = this.editor.view; + const { doc, tr } = view.state; + // Use a `TextSelection` from the document start to end as an `AllSelection` creates from/ + // to positions outside a block, causing errors when calling e.g. `getBlock`. + const selection = TextSelection.between( + Selection.atStart(doc).$from, + Selection.atEnd(doc).$to, + ); + + view.dispatch(tr.setSelection(selection).scrollIntoView()); + + return true; + }, }; }, }); From fb8e60a1b2e60fd25503674ed1bc24bc82a1049c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 21 Aug 2026 14:57:57 +0200 Subject: [PATCH 2/6] Updated unit test --- .../math-block/src/block/createReactMathBlockSpec.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index d2d2e31796..d36127bc6b 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -264,12 +264,15 @@ 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); + // Ctrl/Cmd-a is the exception: select-all is handled explicitly by + // the global keymap (see KeyboardShortcutsExtension), not deferred to the + // browser, so it reports as handled rather than passing through. + expect(pressKey("a", { ctrlKey: true })).toBe(true); }); it("defers deletion keys to the default while the popup is open", async () => { From 78df53190d5d724ae381f43fb607eb393518a01f Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 24 Aug 2026 17:02:54 +0200 Subject: [PATCH 3/6] Made first Mod+A input select full block, second input selects full document --- .../KeyboardShortcutsExtension.test.ts | 117 +++++++++++++++++- .../KeyboardShortcutsExtension.ts | 46 +++++-- 2 files changed, 150 insertions(+), 13 deletions(-) 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 068bdf6b74..de14331867 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -1,6 +1,8 @@ import { Selection, TextSelection } from "prosemirror-state"; import { describe, expect, it } from "vite-plus/test"; +import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; +import { getNodeById } from "../../../api/nodeUtil.js"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; @@ -146,6 +148,15 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { view.someProp("handleKeyDown", (handler) => handler(view, event)); } + function pressBackspace(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Backspace", + code: "Backspace", + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); + } + function expectWholeDocSelected(editor: BlockNoteEditor) { const { selection, doc } = editor._tiptapEditor.state; // Select-all spans all content as a `TextSelection` (from the first @@ -155,7 +166,27 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { expect(selection.to).toBe(Selection.atEnd(doc).to); } - it("selects the whole document", () => { + function expectBlockContentSelected( + editor: BlockNoteEditor, + blockId: string, + ) { + const { selection, doc } = editor._tiptapEditor.state; + const blockInfo = getBlockInfo(getNodeById(blockId, doc)!); + if (!blockInfo.isBlockContainer) { + throw new Error(`Block ${blockId} is not a block container`); + } + // The current block's content is selected as a `TextSelection` spanning its + // full content, without reaching into neighbouring blocks. + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(blockInfo.blockContent.beforePos + 1); + expect(selection.to).toBe(blockInfo.blockContent.afterPos - 1); + } + + // Each test walks the full Notion-style flow: the first `Mod-a` selects the + // current block, the second expands to the whole document, and Backspace + // clears it (issue #2973 - the bug was specific to documents starting with a + // check list item). + it("escalates the selection and clears a paragraph-first document", () => { const editor = createSelectAllEditor([ { type: "paragraph", content: "First" }, { type: "paragraph", content: "Second" }, @@ -163,25 +194,103 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { editor.setTextCursorPosition("block-0", "end"); pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + pressSelectAll(editor); expectWholeDocSelected(editor); + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + editor._tiptapEditor.destroy(); }); - it("selects the whole document when the first block is a check list item", () => { + it("escalates the selection and clears a check-list-first document", () => { const editor = createSelectAllEditor([ { type: "checkListItem", content: "First" }, { type: "paragraph", content: "Second" }, ]); - // Place the cursor in a later block to make sure select-all still spans the - // whole document, not just the current block. + // Cursor starts in a later block to check select-all still spans the whole + // document, not just the current block. editor.setTextCursorPosition("block-1", "end"); pressSelectAll(editor); + expectBlockContentSelected(editor, "block-1"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); + + it("escalates the selection and clears an all-check-list document", () => { + const editor = createSelectAllEditor([ + { type: "checkListItem", content: "First" }, + { type: "checkListItem", content: "Second" }, + ]); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); + + it("escalates the selection and clears a document ending in a check list item", () => { + const editor = createSelectAllEditor([ + { type: "paragraph", content: "First" }, + { type: "checkListItem", content: "Second" }, + ]); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + pressSelectAll(editor); expectWholeDocSelected(editor); + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); + + it("keeps the block type when clearing a single-block document", () => { + const editor = createSelectAllEditor([ + { type: "checkListItem", content: "Only" }, + ]); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + // A single block can only ever have its content selected, so Backspace + // clears the content but (correctly) leaves the block type unchanged. + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "checkListItem", content: [] }), + ]); + 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 5d45672b48..59a1376fc5 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -999,15 +999,43 @@ export const KeyboardShortcutsExtension = Extension.create<{ "Shift-Mod-z": () => this.options.editor.redo(), "Mod-a": () => { const view = this.editor.view; - const { doc, tr } = view.state; - // Use a `TextSelection` from the document start to end as an `AllSelection` creates from/ - // to positions outside a block, causing errors when calling e.g. `getBlock`. - const selection = TextSelection.between( - Selection.atStart(doc).$from, - Selection.atEnd(doc).$to, - ); - - view.dispatch(tr.setSelection(selection).scrollIntoView()); + const { doc, selection, tr } = view.state; + + // Follows Notion: the first `Mod-a` selects the current block's content, + // and any subsequent `Mod-a` expands the selection to the whole + // document. We use `TextSelection`s rather than an `AllSelection` for the + // whole-document case as the latter creates from/to positions outside a + // block, causing errors when calling e.g. `getBlock`. + const blockInfo = getBlockInfoFromSelection(view.state); + const blockContentRange = blockInfo.isBlockContainer + ? { + from: blockInfo.blockContent.beforePos + 1, + to: blockInfo.blockContent.afterPos - 1, + } + : undefined; + + // Expands to the whole document when there's no selectable block content + // to select first, when the selection already extends beyond the current + // block, or when the current block's content is already fully selected. + const selectWholeDoc = + blockContentRange === undefined || + selection.from < blockContentRange.from || + selection.to > blockContentRange.to || + (selection.from === blockContentRange.from && + selection.to === blockContentRange.to); + + const nextSelection = selectWholeDoc + ? TextSelection.between( + Selection.atStart(doc).$from, + Selection.atEnd(doc).$to, + ) + : TextSelection.create( + doc, + blockContentRange.from, + blockContentRange.to, + ); + + view.dispatch(tr.setSelection(nextSelection).scrollIntoView()); return true; }, From fee55e182651b90a6236072fcf2a883a36e4d55c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 28 Sep 2026 18:37:45 +0200 Subject: [PATCH 4/6] Fixed tables edge case --- .../KeyboardShortcutsExtension.test.ts | 63 ++++++++++++++++-- .../KeyboardShortcutsExtension.ts | 65 ++++++++++++------- 2 files changed, 101 insertions(+), 27 deletions(-) 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 de14331867..b068f87029 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from "vite-plus/test"; import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; import { getNodeById } from "../../../api/nodeUtil.js"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; -import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { + defaultBlockSpecs, + type PartialBlock, +} from "../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../../schema/index.js"; @@ -120,9 +123,7 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { // non-editable - e.g. the checkbox `
` of a check list item as the first // block - so `Mod-a` is now handled explicitly. These tests exercise the // keymap path (not native selection) and would collapse before the fix. - function createSelectAllEditor( - blocks: { type: "paragraph" | "checkListItem"; content: string }[], - ) { + function createSelectAllEditor(blocks: PartialBlock[]) { const editor = BlockNoteEditor.create({ schema, initialContent: blocks.map((block, index) => ({ @@ -182,6 +183,30 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { expect(selection.to).toBe(blockInfo.blockContent.afterPos - 1); } + // Like `expectBlockContentSelected`, but for blocks whose content isn't + // directly inline (e.g. tables), where the selection snaps inward to the + // nearest inline positions rather than sitting exactly on the content bounds. + function expectSelectionWithinBlock( + editor: BlockNoteEditor, + blockId: string, + ) { + const { selection, doc } = editor._tiptapEditor.state; + const blockInfo = getBlockInfo(getNodeById(blockId, doc)!); + if (!blockInfo.isBlockContainer) { + throw new Error(`Block ${blockId} is not a block container`); + } + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.empty).toBe(false); + // The selection stays inside the block's content, without reaching into + // neighbouring blocks. + expect(selection.from).toBeGreaterThanOrEqual( + blockInfo.blockContent.beforePos + 1, + ); + expect(selection.to).toBeLessThanOrEqual( + blockInfo.blockContent.afterPos - 1, + ); + } + // Each test walks the full Notion-style flow: the first `Mod-a` selects the // current block, the second expands to the whole document, and Backspace // clears it (issue #2973 - the bug was specific to documents starting with a @@ -293,6 +318,36 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { editor._tiptapEditor.destroy(); }); + + it("escalates the selection and clears a document containing a table", () => { + const editor = createSelectAllEditor([ + { type: "paragraph", content: "Before" }, + { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }], + }, + }, + { type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("block-1", "start"); + + // A table's content node isn't directly inline, so the first `Mod-a` selects + // across its cells rather than producing an invalid whole-block selection. + pressSelectAll(editor); + expectSelectionWithinBlock(editor, "block-1"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); }); describe("KeyboardShortcutsExtension hardBreakShortcut", () => { diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 59a1376fc5..6939cee1ca 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1006,36 +1006,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ // document. We use `TextSelection`s rather than an `AllSelection` for the // whole-document case as the latter creates from/to positions outside a // block, causing errors when calling e.g. `getBlock`. + const wholeDocSelection = TextSelection.between( + Selection.atStart(doc).$from, + Selection.atEnd(doc).$to, + ); + + // Selection covering just the current block's content. `between` snaps + // to the nearest inline positions inside the block, so it also handles + // blocks whose content isn't directly inline - e.g. table blocks, where + // it spans from the first cell to the last. Blocks without any inline + // content (e.g. images) are ignored, as `between` would then spill + // outside the block, and select-all falls through to the whole document. + let blockSelection: Selection | undefined; const blockInfo = getBlockInfoFromSelection(view.state); - const blockContentRange = blockInfo.isBlockContainer - ? { - from: blockInfo.blockContent.beforePos + 1, - to: blockInfo.blockContent.afterPos - 1, - } - : undefined; + if (blockInfo.isBlockContainer) { + const contentFrom = blockInfo.blockContent.beforePos + 1; + const contentTo = blockInfo.blockContent.afterPos - 1; + const candidate = TextSelection.between( + doc.resolve(contentFrom), + doc.resolve(contentTo), + ); + + if ( + !candidate.empty && + candidate.from >= contentFrom && + candidate.to <= contentTo + ) { + blockSelection = candidate; + } + } // Expands to the whole document when there's no selectable block content // to select first, when the selection already extends beyond the current // block, or when the current block's content is already fully selected. const selectWholeDoc = - blockContentRange === undefined || - selection.from < blockContentRange.from || - selection.to > blockContentRange.to || - (selection.from === blockContentRange.from && - selection.to === blockContentRange.to); - - const nextSelection = selectWholeDoc - ? TextSelection.between( - Selection.atStart(doc).$from, - Selection.atEnd(doc).$to, + !blockSelection || + selection.from < blockSelection.from || + selection.to > blockSelection.to || + (selection.from === blockSelection.from && + selection.to === blockSelection.to); + + view.dispatch( + tr + .setSelection( + blockSelection && !selectWholeDoc + ? blockSelection + : wholeDocSelection, ) - : TextSelection.create( - doc, - blockContentRange.from, - blockContentRange.to, - ); - - view.dispatch(tr.setSelection(nextSelection).scrollIntoView()); + .scrollIntoView(), + ); return true; }, From aa3e18a9999355fc40808d26d2b37e198300f88e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 28 Sep 2026 18:53:39 +0200 Subject: [PATCH 5/6] Implemented PR feedback --- packages/core/src/editor/BlockNoteEditor.ts | 9 +++ .../managers/ExtensionManager/extensions.ts | 1 + .../KeyboardShortcutsExtension.test.ts | 58 ++++++++++++++++++- .../KeyboardShortcutsExtension.ts | 24 ++++---- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..89dfb700f2 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -257,6 +257,15 @@ export interface BlockNoteEditorOptions< */ setIdAttribute?: boolean; + /** + * Determines behavior when pressing Cmd+A (Mac) or Ctrl+A (Windows/Linux). + * - `"block-first"`: Selects the current block's content first, then the whole document on a subsequent press. + * - `"document"`: Selects the whole document immediately. + * Selecting the current block scrolls it into view; selecting the whole document preserves the scroll position. + * @default "block-first" + */ + selectAllBehavior?: "block-first" | "document"; + /** * Determines behavior when pressing Tab (or Shift-Tab) while multiple blocks are selected and a toolbar is open. * - `"prefer-navigate-ui"`: Changes focus to the toolbar. User must press Escape to close toolbar before indenting blocks. Better for keyboard accessibility. diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..cf31e22bdf 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, + selectAllBehavior: options.selectAllBehavior ?? "block-first", }), 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 b068f87029..8b9c0d30c9 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -8,7 +8,10 @@ import { defaultBlockSpecs, type PartialBlock, } from "../../../blocks/defaultBlocks.js"; -import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { + BlockNoteEditor, + type BlockNoteEditorOptions, +} from "../../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../../schema/index.js"; /** @@ -123,9 +126,17 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { // non-editable - e.g. the checkbox `
` of a check list item as the first // block - so `Mod-a` is now handled explicitly. These tests exercise the // keymap path (not native selection) and would collapse before the fix. - function createSelectAllEditor(blocks: PartialBlock[]) { + function createSelectAllEditor( + blocks: PartialBlock[], + selectAllBehavior?: BlockNoteEditorOptions< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + >["selectAllBehavior"], + ) { const editor = BlockNoteEditor.create({ schema, + selectAllBehavior, initialContent: blocks.map((block, index) => ({ id: `block-${index}`, ...block, @@ -207,6 +218,49 @@ describe("KeyboardShortcutsExtension Mod-a (select all)", () => { ); } + it("selects the block first when explicitly configured", () => { + const editor = createSelectAllEditor( + [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + "block-first", + ); + editor.setTextCursorPosition("block-0", "end"); + + pressSelectAll(editor); + expectBlockContentSelected(editor, "block-0"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + editor._tiptapEditor.destroy(); + }); + + it("selects the whole document immediately when configured", () => { + const editor = createSelectAllEditor( + [ + { type: "checkListItem", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + "document", + ); + editor.setTextCursorPosition("block-1", "end"); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressSelectAll(editor); + expectWholeDocSelected(editor); + + pressBackspace(editor); + expect(editor.document).toEqual([ + expect.objectContaining({ type: "paragraph", content: [] }), + ]); + + editor._tiptapEditor.destroy(); + }); + // Each test walks the full Notion-style flow: the first `Mod-a` selects the // current block, the second expands to the whole document, and Backspace // clears it (issue #2973 - the bug was specific to documents starting with a diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 6939cee1ca..a1d4899457 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -28,6 +28,7 @@ import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingTo export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; + selectAllBehavior: "block-first" | "document"; }>({ priority: 50, @@ -1001,7 +1002,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const view = this.editor.view; const { doc, selection, tr } = view.state; - // Follows Notion: the first `Mod-a` selects the current block's content, + // By default, the first `Mod-a` selects the current block's content, // and any subsequent `Mod-a` expands the selection to the whole // document. We use `TextSelection`s rather than an `AllSelection` for the // whole-document case as the latter creates from/to positions outside a @@ -1011,6 +1012,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ Selection.atEnd(doc).$to, ); + if (this.options.selectAllBehavior === "document") { + view.dispatch(tr.setSelection(wholeDocSelection)); + return true; + } + // Selection covering just the current block's content. `between` snaps // to the nearest inline positions inside the block, so it also handles // blocks whose content isn't directly inline - e.g. table blocks, where @@ -1046,15 +1052,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ (selection.from === blockSelection.from && selection.to === blockSelection.to); - view.dispatch( - tr - .setSelection( - blockSelection && !selectWholeDoc - ? blockSelection - : wholeDocSelection, - ) - .scrollIntoView(), - ); + if (blockSelection && !selectWholeDoc) { + tr.setSelection(blockSelection).scrollIntoView(); + } else { + tr.setSelection(wholeDocSelection); + } + + view.dispatch(tr); return true; }, From 45a24ce0aac1718f7dbedb76e177c28f96d6024c Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 28 Sep 2026 19:17:55 +0200 Subject: [PATCH 6/6] Fixed e2e test --- tests/src/utils/copypaste.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/src/utils/copypaste.ts b/tests/src/utils/copypaste.ts index d6d08991e0..158914a8dd 100644 --- a/tests/src/utils/copypaste.ts +++ b/tests/src/utils/copypaste.ts @@ -1,8 +1,10 @@ import { DOC_TRAILING_BLOCK_SELECTOR } from "./const.js"; import { MOD, userEvent } from "./context.js"; -export function selectAll() { - return userEvent.keyboard(`{${MOD}>}a{/${MOD}}`); +export async function selectAll() { + // The first press selects the current block; the second selects the document. + await userEvent.keyboard(`{${MOD}>}a{/${MOD}}`); + await userEvent.keyboard(`{${MOD}>}a{/${MOD}}`); } export async function copyPaste() {