Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/core/src/api/getBlockInfoFromPos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -90,6 +102,31 @@ function pressKeys(editor: BlockNoteEditor<any, any, any>, 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<any, any, any>,
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<any, any, any>) {
let count = 0;
editor._tiptapEditor.state.doc.descendants((node) => {
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions packages/math-block/src/block/createReactMathBlockSpec.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down