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..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,5 +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"; @@ -110,6 +113,188 @@ 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 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 + // 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); + } + + 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" }, + ]); + 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 check-list-first document", () => { + const editor = createSelectAllEditor([ + { type: "checkListItem", content: "First" }, + { type: "paragraph", content: "Second" }, + ]); + // 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(); + }); +}); + 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..59a1376fc5 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,48 @@ 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, 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; + }, }; }, }); 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 () => {