Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";

import {
getBottomNestedBlockInfo,
Expand All @@ -22,15 +23,98 @@ import {
getBlockInfoFromSelection,
} from "../../../api/getBlockInfoFromPos.js";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { isAndroid } from "../../../util/browser.js";
import { FilePanelExtension } from "../../FilePanel/FilePanel.js";
import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js";

/**
* Runs the keymap chain for an Enter that never reached it (see the
* `blockNoteAndroidEnter` plugin below): flushes pending DOM observations
* first, then dispatches a synthesized Enter keydown through
* `handleKeyDown`.
*/
function dispatchSynthesizedEnter(view: EditorView, shiftKey: boolean): void {
(
view as EditorView & {
domObserver: { forceFlush(): void };
}
).domObserver.forceFlush();
view.someProp("handleKeyDown", (handler) =>
handler(
view,
new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
shiftKey,
}),
),
);
}

export const KeyboardShortcutsExtension = Extension.create<{
editor: BlockNoteEditor<any, any, any>;
tabBehavior: "prefer-navigate-ui" | "prefer-indent";
}>({
priority: 50,

addProseMirrorPlugins() {
return [
// On Android, Enter never reaches the keymap: the IME delivers it as a
// `beforeinput` (the keydown is keyCode 229), and prosemirror-view
// additionally ignores Enter keydowns on Android Chrome. ProseMirror's
// fallback — parsing the browser's native DOM split and synthesizing an
// Enter key event — fails to recognize the split in BlockNote's nested
// block DOM and corrupts the document instead (Enter inserting a space,
// doing nothing, or breaking tables — TypeCellOS/BlockNote#3001).
// Intercepting the `beforeinput` and running the keymap chain directly
// bypasses the fragile DOM diffing entirely.
new Plugin({
key: new PluginKey("blockNoteAndroidEnter"),
props: {
// Runs the keymap chain for an Enter that prosemirror-view's
// Android keydown bail skipped, with the parity that bail also
// skips: force-flushing pending DOM observations (including
// selection changes) before running key handlers — without it the
// synthesized Enter can run against a stale selection (e.g. a
// just-made cross-block selection that hasn't synced yet).
handleKeyPress: (view, event) => {
// A keypress for Enter only happens off a hardware/synthetic
// keyboard (the IME path is keyCode 229 + `beforeinput`, no
// keypress — handled below). prosemirror-view's own keypress
// handler would cancel the browser default for cross-block
// selections without doing anything (its cross-parent branch
// calls preventDefault but skips newline characters), turning
// Enter into a silent no-op — so take over before it runs.
if (!isAndroid() || view.composing || event.key !== "Enter") {
return false;
}
dispatchSynthesizedEnter(view, event.shiftKey);
return true;
},
handleDOMEvents: {
beforeinput: (view, event) => {
if (!isAndroid() || view.composing) {
return false;
}
if (
event.inputType !== "insertParagraph" &&
event.inputType !== "insertLineBreak"
) {
return false;
}
event.preventDefault();
dispatchSynthesizedEnter(
view,
event.inputType === "insertLineBreak",
);
return true;
},
},
},
}),
];
},

// TODO: The shortcuts need a refactor. Do we want to use a command priority
// design as there is now, or clump the logic into a single function?
addKeyboardShortcuts() {
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/util/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export function mergeCSSClasses(...classes: (string | false | undefined)[]) {
export const isSafari = () =>
/^((?!chrome|android).)*safari/i.test(navigator.userAgent);

export const isAndroid = () =>
typeof navigator !== "undefined" && /android/i.test(navigator.userAgent);

// Cached lazily on first call in a browser environment. Touch capability
// doesn't change during a session, so there's no need to re-run `matchMedia` on
// every call. We only cache once `navigator`/`window` are available, so a
Expand Down
20 changes: 19 additions & 1 deletion tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,16 @@ beforeEach(async () => {
await waitForSelector(EDITOR_SELECTOR);
});

// The android browser instance runs this suite too (see
// vite.config.browser.ts); a couple of tests use idioms that don't transfer:
const onAndroid = /android/i.test(navigator.userAgent);

describe("Check Keyboard Handlers' Behaviour", () => {
// Also covers the android instance: with a cross-block selection,
// prosemirror-view's Android keydown bail skips Enter handling and its own
// keypress handler cancels the browser default without doing anything —
// BlockNote's keypress interception (KeyboardShortcutsExtension) closes
// that hole. See also the cross-block case in mobile/androidEnter.test.tsx.
test("Check Enter when selection is not empty", async () => {
await focusOnEditor();
await insertHeading(1);
Expand All @@ -42,7 +51,10 @@ describe("Check Keyboard Handlers' Behaviour", () => {

await compareDocToSnapshot("enterSelectionNotEmpty");
});
test("Check Enter preserves marks", async () => {
// Skipped on the android instance: drives selection with coordinate
// double-clicks, a mouse idiom that doesn't translate to touch emulation at
// phone width.
test.skipIf(onAndroid)("Check Enter preserves marks", async () => {
await focusOnEditor();
await insertHeading(1);

Expand Down Expand Up @@ -313,6 +325,12 @@ describe("Check Keyboard Handlers' Behaviour", () => {
await insertParagraph();

await userEvent.keyboard("{ArrowUp}");
// ArrowUp crosses from an unnested line into an indented one, so its
// goal-x lands near the last character's boundary — which side it falls
// on varies with subpixel text metrics (flaky on the mobile-emulated
// instances). The test is about Delete at the *end* of the block; make
// that position explicit.
await userEvent.keyboard("{End}");
await userEvent.keyboard("{Delete}");

await compareDocToSnapshot("deleteShallowerBlock");
Expand Down
206 changes: 206 additions & 0 deletions tests/src/end-to-end/mobile/androidEnter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import App from "@examples/01-basic/testing/src/App";
import { describe, expect, test, vi } from "vite-plus/test";
import { render } from "vitest-browser-react";

import { userEvent } from "../../utils/context.js";
import {
BLOCK_CONTAINER_SELECTOR,
EDITOR_SELECTOR,
} from "../../utils/const.js";
import { focusOnEditor, waitForSelector } from "../../utils/editor.js";

// Runs in the "android" browser instance (Android UA + touch emulation at
// context level — see vite.config.browser.ts), which makes prosemirror-view
// take its Android code path: Enter keydowns are ignored there, and PM's own
// fallback — parsing the native DOM change — misparses BlockNote's nested
// block DOM and corrupts the document (TypeCellOS/BlockNote#3001: Enter
// inserting a space, doing nothing, or breaking tables). BlockNote
// intercepts both delivery routes instead (see KeyboardShortcutsExtension):
// `keypress` for hardware/synthetic keyboards, `beforeinput` for the IME.
// The tests below pin one route each.
describe("Enter on Android", () => {
test("keyboard-delivered Enter (keydown + keypress) splits the block", async () => {
await render(<App />);
await waitForSelector(EDITOR_SELECTOR);
await focusOnEditor();
await userEvent.keyboard("First line");

const blocksBefore = document.querySelectorAll(
BLOCK_CONTAINER_SELECTOR,
).length;
const textBefore = document.querySelector(EDITOR_SELECTOR)!.textContent;

await userEvent.keyboard("{Enter}");

await vi.waitFor(() => {
const blocks = document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length;
if (blocks !== blocksBefore + 1) {
throw new Error(
`Enter did not split the block (blocks ${blocksBefore} -> ${blocks})`,
);
}
});
// The classic #3001 misbehavior inserts a space or mangles text instead.
expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe(
textBefore,
);

await userEvent.keyboard("Second line");
await vi.waitFor(() => {
if (
!document
.querySelector(EDITOR_SELECTOR)!
.textContent!.includes("Second line")
) {
throw new Error("typing after Enter did not land in the new block");
}
});
});

// The IME path itself: real soft keyboards deliver Enter as keyCode 229 +
// `beforeinput: insertParagraph` with NO keypress, so the keypress
// interception (which covers hardware/synthetic keyboards, above) never
// runs. No automated input layer produces that exact trusted sequence — a
// synthetic InputEvent reaches prosemirror's handleDOMEvents all the same,
// so this pins the `beforeinput` interception the way IMEs actually invoke
// it. (Without the interception a synthetic event simply does nothing, so
// this fails red without the fix.)
test.skipIf(!/android/i.test(navigator.userAgent))(
"IME-delivered Enter (beforeinput, no keypress) splits the block",
async () => {
await render(<App />);
await waitForSelector(EDITOR_SELECTOR);
await focusOnEditor();
await userEvent.keyboard("Ime line");
const blocksBefore = document.querySelectorAll(
BLOCK_CONTAINER_SELECTOR,
).length;

document.querySelector(EDITOR_SELECTOR)!.dispatchEvent(
new InputEvent("beforeinput", {
inputType: "insertParagraph",
bubbles: true,
cancelable: true,
}),
);

await vi.waitFor(() => {
const blocks = document.querySelectorAll(
BLOCK_CONTAINER_SELECTOR,
).length;
if (blocks !== blocksBefore + 1) {
throw new Error(
`beforeinput Enter did not split (blocks ${blocksBefore} -> ${blocks})`,
);
}
});
expect(document.querySelector(EDITOR_SELECTOR)!.textContent).toBe(
"Ime line",
);
},
);

// With a NON-EMPTY cross-block selection, an Enter keydown+keypress pair
// (hardware or synthetic keyboard) used to be a silent no-op on Android:
// prosemirror-view's Android keydown bail skips Enter handling, and its own
// keypress handler then cancels the browser default for cross-parent
// selections without doing anything. BlockNote's `handleKeyPress`
// interception routes it through the keymap chain instead. The hole (and
// this test) is Android-only: everywhere else the keymap already handles
// Enter at keydown, so the keypress branch never matters — and on the
// iOS-emulated instance the setup itself is unreliable (typing after a
// settled Enter lands back in the previous block, a webkit-on-Linux
// emulation artifact the real-device suite doesn't show).
const onAndroid = /android/i.test(navigator.userAgent);
test.skipIf(!onAndroid)(
"Enter with a cross-block selection deletes it and splits",
async () => {
await render(<App />);
await waitForSelector(EDITOR_SELECTOR);
await focusOnEditor();
await userEvent.keyboard("First line");
await userEvent.keyboard("{Enter}");
// The split can settle asynchronously (iOS path); typing must land in the
// new block before the selection below can target both paragraphs.
await vi.waitFor(() => {
if (
!Array.from(document.querySelectorAll(`${EDITOR_SELECTOR} p`)).some(
(el) => el.textContent === "",
)
) {
throw new Error("Enter split not settled");
}
});
await userEvent.keyboard("Second line");
await vi.waitFor(() => {
const texts = Array.from(
document.querySelectorAll(`${EDITOR_SELECTOR} p`),
).map((el) => el.textContent);
if (!texts.includes("First line") || !texts.includes("Second line")) {
throw new Error(`paragraphs not settled: ${JSON.stringify(texts)}`);
}
});

// Select from mid-first-line to mid-second-line via a DOM range —
// arrow-key selection maps goal columns differently per engine, while
// ProseMirror syncs a programmatic range from `selectionchange` on all of
// them. Selects "ne" + "Sec" across the block boundary.
function textPosition(
paragraphText: string,
offset: number,
): [Text, number] {
const paragraph = Array.from(
document.querySelectorAll(`${EDITOR_SELECTOR} p`),
).find((el) => el.textContent === paragraphText);
if (!paragraph) {
throw new Error(
`paragraph ${JSON.stringify(paragraphText)} not found`,
);
}
const walker = document.createTreeWalker(
paragraph,
NodeFilter.SHOW_TEXT,
);
let consumed = 0;
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
const length = n.textContent!.length;
if (offset <= consumed + length) {
return [n as Text, offset - consumed];
}
consumed += length;
}
throw new Error(
`offset ${offset} beyond ${JSON.stringify(paragraphText)}`,
);
}
await vi.waitFor(() => {
const range = document.createRange();
range.setStart(...textPosition("First line", "First li".length));
range.setEnd(...textPosition("Second line", "Sec".length));
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
if (selection.isCollapsed) {
throw new Error("cross-block selection did not apply");
}
});

await userEvent.keyboard("{Enter}");

// The selected span ("ne" + "Sec") is deleted and the remainder split
// across two blocks: "First li" + "ond line".
await vi.waitFor(() => {
const text = document.querySelector(EDITOR_SELECTOR)!.textContent!;
if (text.includes("First line")) {
throw new Error(`Enter did not delete the selection: ${text}`);
}
if (!text.includes("First li") || !text.includes("ond line")) {
throw new Error(`unexpected text after Enter: ${text}`);
}
});
expect(
document.querySelectorAll(BLOCK_CONTAINER_SELECTOR).length,
).toBeGreaterThanOrEqual(2);
},
);
});
2 changes: 0 additions & 2 deletions tests/src/end-to-end/mobile/linkSubmit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { render } from "vitest-browser-react";
import { page, userEvent } from "../../utils/context.js";
import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js";
import { waitForSelector } from "../../utils/editor.js";
import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js";

const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar";

Expand All @@ -33,7 +32,6 @@ const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar";
// test below; the IME's choice itself stays a release-checklist item.

beforeEach(async () => {
ensureTouchEmulation();
await page.viewport(393, 727);
});

Expand Down
2 changes: 0 additions & 2 deletions tests/src/end-to-end/mobile/mobileToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { render } from "vitest-browser-react";
import { page, userEvent } from "../../utils/context.js";
import { EDITOR_SELECTOR, LINK_BUTTON_SELECTOR } from "../../utils/const.js";
import { focusOnEditor, waitForSelector } from "../../utils/editor.js";
import { ensureTouchEmulation } from "../../utils/ensureTouchEmulation.js";

const MOBILE_TOOLBAR_SELECTOR = ".bn-mobile-formatting-toolbar";
const LINK_POPOVER_SELECTOR = ".bn-form-popover";
Expand Down Expand Up @@ -41,7 +40,6 @@ function activeUrlInput() {
}

beforeEach(async () => {
ensureTouchEmulation();
await page.viewport(VIEWPORT_WIDTH, KEYBOARD_CLOSED);
await render(<App />);
await waitForSelector(EDITOR_SELECTOR);
Expand Down
Loading
Loading