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; + }, }; }, }); 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 () => {