Skip to content

Commit dab8844

Browse files
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 `<p>` 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.
1 parent ea5d803 commit dab8844

4 files changed

Lines changed: 199 additions & 3 deletions

File tree

packages/core/src/api/getBlockInfoFromPos.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,40 @@ export function getNodeId(node: Node, doc: Node): string {
8787
return id;
8888
}
8989

90+
/**
91+
* Retrieves the position just before the top-level block that a document
92+
* boundary position borders on: the first block for the position at the very
93+
* start of the doc, and the last block for the position at the very end.
94+
* Returns `undefined` for any other position, as well as for docs that aren't
95+
* shaped as expected (a single `blockGroup` of blocks, see the `doc` node spec).
96+
* @param doc The ProseMirror doc.
97+
* @param pos An integer position in the document.
98+
*/
99+
function getDocBoundaryBlockPos(doc: Node, pos: number) {
100+
const atStart = pos <= 0;
101+
if (!atStart && pos < doc.content.size) {
102+
return undefined;
103+
}
104+
105+
const blockGroup = doc.firstChild;
106+
const node = atStart ? blockGroup?.firstChild : blockGroup?.lastChild;
107+
if (
108+
!blockGroup ||
109+
blockGroup.type.name !== "blockGroup" ||
110+
!node ||
111+
!node.type.isInGroup("bnBlock")
112+
) {
113+
return undefined;
114+
}
115+
116+
return {
117+
// The `blockGroup` starts at position 0, so its content starts at 1 and
118+
// ends just before the doc's end.
119+
posBeforeNode: atStart ? 1 : doc.content.size - 1 - node.nodeSize,
120+
node,
121+
};
122+
}
123+
90124
/**
91125
* Retrieves the position just before the nearest block node in a ProseMirror
92126
* 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) {
126160
node = $pos.node(depth);
127161
}
128162

163+
// The document's boundary positions (0 and `doc.content.size`) lie outside
164+
// every block node, as they sit around the `blockGroup` holding the top-level
165+
// blocks. They're where an `AllSelection`'s endpoints sit, so they're
166+
// expected rather than exceptional, and each borders a top-level block.
167+
const boundaryBlockPos = getDocBoundaryBlockPos(doc, pos);
168+
if (boundaryBlockPos) {
169+
return boundaryBlockPos;
170+
}
171+
129172
// If the position doesn't lie within a block node, we instead find the
130173
// position of the next closest one. If the position is beyond the last block,
131174
// we return the position of the last block. While running `doc.descendants`

packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ function createEditor(
8181
return editor;
8282
}
8383

84+
function createEditorWithBlocks(
85+
blocks: { id: string; type: string; content: string }[],
86+
) {
87+
const editor = BlockNoteEditor.create({
88+
schema,
89+
initialContent: blocks as any,
90+
});
91+
editor.mount(document.createElement("div"));
92+
editor.setTextCursorPosition(blocks[0].id, "end");
93+
return editor;
94+
}
95+
8496
/**
8597
* Simulates a keyboard shortcut by dispatching a keydown event through the
8698
* editor's `handleKeyDown` props, which is how ProseMirror invokes the
@@ -90,6 +102,31 @@ function pressKeys(editor: BlockNoteEditor<any, any, any>, keys: string) {
90102
editor._tiptapEditor.commands.keyboardShortcut(keys);
91103
}
92104

105+
/**
106+
* Dispatches a keydown event straight through ProseMirror's `handleKeyDown`
107+
* prop. Unlike `pressKeys`, this keeps selection-only changes: tiptap's
108+
* `keyboardShortcut` command replays just the steps of the transaction the
109+
* shortcut produced, and a transaction that only moves the selection has none.
110+
*/
111+
function pressKey(
112+
editor: BlockNoteEditor<any, any, any>,
113+
key: string,
114+
modifiers: { mod?: boolean; shift?: boolean } = {},
115+
) {
116+
const view = editor.prosemirrorView!;
117+
const event = new KeyboardEvent("keydown", {
118+
key,
119+
// `Mod` is Cmd on macOS and Ctrl elsewhere - tests run in jsdom, which
120+
// isn't macOS.
121+
ctrlKey: modifiers.mod ?? false,
122+
shiftKey: modifiers.shift ?? false,
123+
bubbles: true,
124+
cancelable: true,
125+
});
126+
127+
return view.someProp("handleKeyDown", (f) => f(view, event)) ?? false;
128+
}
129+
93130
function countHardBreaks(editor: BlockNoteEditor<any, any, any>) {
94131
let count = 0;
95132
editor._tiptapEditor.state.doc.descendants((node) => {
@@ -202,3 +239,91 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => {
202239
editor._tiptapEditor.destroy();
203240
});
204241
});
242+
243+
describe("KeyboardShortcutsExtension select all", () => {
244+
// Select-all used to have no keybinding at all, so it fell through to the
245+
// browser's native `contenteditable` handling. That can't map a whole-editor
246+
// DOM selection onto the document when a block renders non-editable content
247+
// before its editable content - which check list items do, as they render
248+
// their checkbox before the paragraph holding the block's inline content.
249+
// ProseMirror discarded the resulting DOM selection, so a document starting
250+
// with a check list item stayed unselected and Backspace only edited the
251+
// block the cursor was in.
252+
it("selects the whole document on Mod-a", () => {
253+
const editor = createEditorWithBlocks([
254+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
255+
{ id: "block-1", type: "checkListItem", content: "Check 2" },
256+
{ id: "block-2", type: "paragraph", content: "Hello world" },
257+
]);
258+
259+
pressKey(editor, "a", { mod: true });
260+
261+
const { selection, doc } = editor._tiptapEditor.state;
262+
expect(selection.from).toBe(0);
263+
expect(selection.to).toBe(doc.content.size);
264+
265+
editor._tiptapEditor.destroy();
266+
});
267+
268+
it("clears a document starting with check list items on Mod-a + Backspace", () => {
269+
const editor = createEditorWithBlocks([
270+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
271+
{ id: "block-1", type: "checkListItem", content: "Check 2" },
272+
{ id: "block-2", type: "paragraph", content: "Hello world" },
273+
]);
274+
275+
pressKey(editor, "a", { mod: true });
276+
pressKey(editor, "Backspace");
277+
278+
expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]);
279+
expect(editor.document[0].content).toEqual([]);
280+
281+
editor._tiptapEditor.destroy();
282+
});
283+
284+
it("clears a document of only check list items on Mod-a + Backspace", () => {
285+
const editor = createEditorWithBlocks([
286+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
287+
{ id: "block-1", type: "checkListItem", content: "Check 2" },
288+
]);
289+
290+
pressKey(editor, "a", { mod: true });
291+
pressKey(editor, "Backspace");
292+
293+
expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]);
294+
expect(editor.document[0].content).toEqual([]);
295+
296+
editor._tiptapEditor.destroy();
297+
});
298+
299+
it("clears a document of paragraphs on Mod-a + Backspace", () => {
300+
const editor = createEditorWithBlocks([
301+
{ id: "block-0", type: "paragraph", content: "Hello" },
302+
{ id: "block-1", type: "paragraph", content: "World" },
303+
]);
304+
305+
pressKey(editor, "a", { mod: true });
306+
pressKey(editor, "Backspace");
307+
308+
expect(editor.document.map((block) => block.type)).toEqual(["paragraph"]);
309+
expect(editor.document[0].content).toEqual([]);
310+
311+
editor._tiptapEditor.destroy();
312+
});
313+
314+
it("returns every block from getSelection while everything is selected", () => {
315+
const editor = createEditorWithBlocks([
316+
{ id: "block-0", type: "checkListItem", content: "Check 1" },
317+
{ id: "block-1", type: "paragraph", content: "Hello world" },
318+
]);
319+
320+
pressKey(editor, "a", { mod: true });
321+
322+
expect(editor.getSelection()?.blocks.map((block) => block.type)).toEqual([
323+
"checkListItem",
324+
"paragraph",
325+
]);
326+
327+
editor._tiptapEditor.destroy();
328+
});
329+
});

packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Extension } from "@tiptap/core";
22
import { Fragment, Node } from "prosemirror-model";
3-
import { TextSelection } from "prosemirror-state";
3+
import { AllSelection, TextSelection } from "prosemirror-state";
44

55
import {
66
getBottomNestedBlockInfo,
@@ -953,6 +953,22 @@ export const KeyboardShortcutsExtension = Extension.create<{
953953
return {
954954
Backspace: handleBackspace,
955955
Delete: handleDelete,
956+
// Selects the whole document. Without this, select-all falls through to
957+
// the browser's native `contenteditable` handling, which can't map a
958+
// whole-editor DOM selection onto the document when a block renders
959+
// non-editable content before its editable content (e.g. a check list
960+
// item, which renders its checkbox before the paragraph holding the
961+
// block's inline content). ProseMirror then discards that DOM selection,
962+
// leaving the selection where it was, so a following Backspace only
963+
// edits the current block instead of clearing the document.
964+
"Mod-a": () =>
965+
this.editor.commands.command(({ tr, dispatch }) => {
966+
if (dispatch) {
967+
tr.setSelection(new AllSelection(tr.doc));
968+
}
969+
970+
return true;
971+
}),
956972
Enter: () => handleEnter(),
957973
"Shift-Enter": () => handleEnter(true),
958974
// Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the

packages/math-block/src/block/createReactMathBlockSpec.test.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,26 @@ describe("Math block source popup keyboard handling", () => {
264264
expect(isPopupOpen("math")).toBe(false);
265265

266266
// Single-character keys are only blocked when no Ctrl/Cmd is held, so
267-
// shortcuts pass through - keeping copy/select-all/find working.
267+
// shortcuts pass through - keeping copy/find working.
268268
// (Cut/paste also pass through; that's a known limitation.)
269269
expect(pressKey("c", { ctrlKey: true })).toBe(false);
270-
expect(pressKey("a", { ctrlKey: true })).toBe(false);
271270
expect(pressKey("f", { ctrlKey: true })).toBe(false);
272271
expect(pressKey("v", { metaKey: true })).toBe(false);
273272
});
274273

274+
it("selects the whole document on Ctrl/Cmd+A while the popup is closed", () => {
275+
expect(isPopupOpen("math")).toBe(false);
276+
277+
// Select-all isn't swallowed by the block either, but the editor handles
278+
// it itself rather than leaving it to the browser - so it's marked
279+
// handled and selects the whole document, hidden source included.
280+
expect(pressKey("a", { ctrlKey: true })).toBe(true);
281+
282+
const { selection, doc } = editor._tiptapEditor.state;
283+
expect(selection.from).toBe(0);
284+
expect(selection.to).toBe(doc.content.size);
285+
});
286+
275287
it("defers deletion keys to the default while the popup is open", async () => {
276288
pressKey("Enter");
277289
await flush();

0 commit comments

Comments
 (0)