Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0ded49b
test: mobile and real-device test infrastructure
YousefED Aug 31, 2026
e34bc36
test(device): load BrowserStack config from the root .env file
YousefED Aug 31, 2026
f64263f
ci(device): pin actions and use the vp toolchain
YousefED Aug 31, 2026
3d82d24
ci(device): run the device suite on PRs instead of a nightly cron
YousefED Aug 31, 2026
05c862c
test(device): serve devices straight from the dev server
YousefED Aug 31, 2026
f418c96
test: assert touch emulation instead of silently patching it
YousefED Aug 31, 2026
ce326b7
test: keep layer-specific test code in its layer
YousefED Aug 31, 2026
5f15492
chore: blank the cache token in .env.sample
YousefED Aug 31, 2026
0945411
test(device): manage the tunnel with the official browserstack-local …
YousefED Aug 31, 2026
b017ecd
test(device): back DeviceSession with selenium-webdriver
YousefED Aug 31, 2026
03be09b
docs(device): document the integration shape in the README
YousefED Aug 31, 2026
bcb04f6
chore: format the device suite files
YousefED Aug 31, 2026
70998ab
ci(device): also run when a PR is retargeted
YousefED Aug 31, 2026
6698f6e
fix(ui): commit popover forms through submit, not a key handler
YousefED Aug 31, 2026
a90622b
fix(ui): one submit control per form, and reuse mergeRefs
YousefED Aug 31, 2026
0892097
test(ui): cover the composition guard, and drop two tests that couldn…
YousefED Aug 31, 2026
828cef5
fix(core): scan the selection for the link URL instead of probing a b…
YousefED Aug 31, 2026
797e3de
fix(ui): drop the composition guard — native submission already handl…
YousefED Aug 31, 2026
127aef0
test(device): link helpers next to the link tests, submitting via the…
YousefED Aug 31, 2026
f5541a3
test(device): submit the link popover by pressing the real Enter key
YousefED Aug 31, 2026
de4a058
docs(device): the IME action key is reachable on a local emulator
YousefED Sep 1, 2026
57ff973
fix(core): handle Enter via beforeinput on Android (#3001)
YousefED Aug 31, 2026
ec3291f
fix(core): also handle Enter delivered as a keypress on Android
YousefED Aug 31, 2026
038b361
test: run the form suites on the android instance too
YousefED Aug 31, 2026
b1ff1c7
test: carry the copypaste touch-emulation skips with the instance wid…
YousefED Aug 31, 2026
d39631f
test(device): local emulator/simulator backends behind one session in…
YousefED Sep 1, 2026
9b5e744
ci: run the Android emulator layer as normal CI
YousefED Sep 1, 2026
2f7dd2d
test(device): depend on vitest directly for its bin
YousefED Sep 1, 2026
829917b
test(device): iOS backend via Appium XCUITest, green locally on both …
YousefED Sep 1, 2026
5c9b761
docs(device): document the three-target architecture
YousefED Sep 1, 2026
09bc03a
test(device): discover the booted simulator race-free
YousefED Sep 1, 2026
d11db02
docs(device): codify how the suite relates to the e2e mobile tests
YousefED Sep 1, 2026
e407339
test(device): hand the simulator to workers through the filesystem
YousefED Sep 1, 2026
1cca5c9
test(device): press the IME's real on-screen Enter in the editor
YousefED Sep 1, 2026
505f885
test(device): drop the BrowserStack backend — the local targets cover it
YousefED Sep 1, 2026
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
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export NX_SELF_HOSTED_REMOTE_CACHE_SERVER=https://cache.nickthesick.com
export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=g8@ucL8em4*Z9TKXDY9OEX@!upf^Nz9
export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=
112 changes: 112 additions & 0 deletions .github/workflows/emulator-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Emulator tests

# The OS-emulator layer of the device suite (tests/device/): real Chrome and
# real Gboard on an Android emulator, and real iOS Safari on a simulator via
# Appium/XCUITest — driving flows no browser emulation can, including pressing
# the on-screen keyboard's IME action key. Free minutes, no credentials, so it
# runs as normal CI. See tests/device/README.md.
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:

concurrency:
group: emulator-tests-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
android-emulator:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: ".node-version"
cache: true

- name: Install dependencies
run: vp install

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Start playground dev server
run: |
vp run dev &
for _ in $(seq 1 120); do
if curl -sf http://127.0.0.1:5173/ > /dev/null; then exit 0; fi
sleep 2
done
echo "playground dev server never came up" >&2
exit 1

- name: Run device suite on the emulator
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 35
arch: x86_64
target: google_apis
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -no-audio -no-boot-anim
disable-animations: true
script: DEVICE_FILTER=local-android vp run test:device

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: emulator-test-screenshots
path: tests/device/.artifacts/
if-no-files-found: ignore

ios-simulator:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: ".node-version"
cache: true

- name: Install dependencies
run: vp install

- name: Start playground dev server
run: |
vp run dev &
for _ in $(seq 1 120); do
if curl -sf http://127.0.0.1:5173/ > /dev/null; then exit 0; fi
sleep 2
done
echo "playground dev server never came up" >&2
exit 1

- name: Run device suite on the simulator
run: DEVICE_FILTER=local-ios vp run test:device

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: simulator-test-screenshots
path: tests/device/.artifacts/
if-no-files-found: ignore
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"prestart": "vp run build",
"start": "vp run --filter @blocknote/example-editor preview",
"test": "vp run --filter \"@blocknote/*\" --filter \"docs\" test",
"test:device": "vp -C tests exec vitest run --config device/vitest.config.mts",
"format": "vp fmt",
"prepare": "vp config"
},
Expand Down
30 changes: 27 additions & 3 deletions packages/ariakit/src/input/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import { FormProvider as AriakitFormProvider } from "@ariakit/react";

import { assertEmpty } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { ComponentProps, useDictionary } from "@blocknote/react";

export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => {
const { children, ...rest } = props;
const { children, onSubmit, omitSubmitButton, ...rest } = props;
const dict = useDictionary();

assertEmpty(rest);

return <AriakitFormProvider>{children}</AriakitFormProvider>;
return (
<AriakitFormProvider>
<form
onSubmit={(event) => {
// These forms have no action — a real submission would navigate.
event.preventDefault();
onSubmit?.();
}}
>
{children}
{/*
Gives the form a submit button, which is what makes Enter submit it at
all once a caller renders more than one field (see the `onSubmit`
contract in `ComponentsContext`). Visually hidden rather than absent,
so assistive technology still has a labelled control to activate.
*/}
{!omitSubmitButton && (
<button className={"bn-form-submit"} tabIndex={-1} type={"submit"}>
{dict.generic.form_submit}
</button>
)}
</form>
</AriakitFormProvider>
);
};
22 changes: 16 additions & 6 deletions packages/ariakit/src/input/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
} from "@ariakit/react";

import { assertEmpty, mergeCSSClasses } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { forwardRef } from "react";
import { ComponentProps, useMergeRefs } from "@blocknote/react";
import { forwardRef, useEffect, useRef } from "react";

export const TextInput = forwardRef<
HTMLInputElement,
Expand All @@ -23,7 +23,6 @@ export const TextInput = forwardRef<
disabled,
onKeyDown,
onChange,
onSubmit,
autoComplete,
"aria-activedescendant": ariaActivedescendant,
rightSection,
Expand All @@ -32,6 +31,19 @@ export const TextInput = forwardRef<

assertEmpty(rest);

// Focus with `preventScroll`, rather than the native `autofocus`: these
// inputs live in popovers that floating-ui positions *after* mount, so the
// browser's scroll-into-view runs while the popover is still at its
// pre-positioned spot and yanks the page (on mobile, right out from under
// the block being edited).
const inputRef = useRef<HTMLInputElement | null>(null);
const setRefs = useMergeRefs([inputRef, ref]);
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus({ preventScroll: true });
}
}, [autoFocus]);

return (
<>
{props.label && <AriakitFormLabel name={name}>{label}</AriakitFormLabel>}
Expand All @@ -43,15 +55,13 @@ export const TextInput = forwardRef<
className || "",
variant === "large" ? "bn-ak-input-large" : "",
)}
ref={ref}
ref={setRefs}
name={name}
value={value}
autoFocus={autoFocus}
placeholder={placeholder}
disabled={disabled}
onKeyDown={onKeyDown}
onChange={onChange}
onSubmit={onSubmit}
autoComplete={autoComplete}
aria-activedescendant={ariaActivedescendant}
/>
Expand Down
20 changes: 20 additions & 0 deletions packages/ariakit/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,23 @@
.bn-ariakit .bn-thread.selected .bn-ak-expand-sections-prompt {
color: var(--bn-colors-selected-text);
}

/* The submit button `Form.Root` renders so that Enter reaches the form
* regardless of how many fields a popover has. It carries no visual design of
* its own - the popovers commit on Enter - but it stays in the accessibility
* tree with a real label, so screen readers and voice control have a submit
* control to operate. It is out of the tab order: keeping a control nobody can
* see as a tab stop would strand sighted keyboard users on invisible focus,
* and Enter already submits for them.
*/
.bn-form-submit {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
21 changes: 20 additions & 1 deletion packages/core/src/editor/managers/StyleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,26 @@ export class StyleManager<
*/
public getSelectedLinkUrl() {
return this.editor.transact((tr) => {
return this.getLinkMarkAtPos(tr.selection.from)?.href;
const { from, to, empty } = tr.selection;
if (empty) {
return this.getLinkMarkAtPos(from)?.href;
}
// For a non-empty selection, probing a single boundary position is
// fragile twice over: `marks()` excludes a link at its left edge, and
// browsers disagree by a position on where a selection over a link
// starts. Scan the selected range for the first link mark instead.
let href: string | undefined;
tr.doc.nodesBetween(from, to, (node) => {
if (href !== undefined) {
return false;
}
const linkMark = node.marks.find((mark) => mark.type.name === "link");
if (linkMark) {
href = linkMark.attrs.href;
}
return href === undefined;
});
return href;
});
}

Expand Down
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
1 change: 1 addition & 0 deletions packages/core/src/i18n/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,5 +406,6 @@ export const ar: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
form_submit: "إرسال",
},
};
1 change: 1 addition & 0 deletions packages/core/src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,5 +440,6 @@ export const de: Dictionary = {
},
generic: {
ctrl_shortcut: "Strg",
form_submit: "Absenden",
},
};
Loading
Loading