Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b6969e1
fix(mantine): keep mobile popovers visible through virtual-keyboard r…
YousefED Aug 29, 2026
48dd5c4
fix(core): handle Enter via beforeinput on Android
YousefED Aug 29, 2026
4bf902a
feat(core): public focus API with floating-UI-aware tracking
YousefED Aug 29, 2026
dc91569
fix(core): read the link URL just inside the selection start
YousefED Aug 29, 2026
37d1131
test: android-emulated browser instance for mobile e2e coverage
YousefED Aug 29, 2026
e9d8496
test: real-device suite on BrowserStack
YousefED Aug 29, 2026
9b1878e
Fixed link button input issue on iOS
matthewlipski Aug 19, 2026
da2d44a
test: cover the iOS input auto-zoom fix
YousefED Aug 31, 2026
f7374a1
refactor(core): simplify the focus tracker and rename includeFloatingUI
YousefED Aug 31, 2026
5607528
test(core): browser tests for the focus events
YousefED Aug 31, 2026
3d7e3d4
docs(core): document isWithinEditor's dependency on the mount topology
YousefED Aug 31, 2026
8773391
feat(react): useEditorFocus hook
YousefED Aug 31, 2026
cdb2a5d
test: pin the subscription behaviour of the focus hooks
YousefED Aug 31, 2026
c4a2d9c
docs(core): explain what the parent hop in isWithinEditor is actually…
YousefED Aug 31, 2026
5adc96a
docs(core): condense the isWithinEditor comment
YousefED Aug 31, 2026
9a56aed
fix(ui): don't scroll the page when a popover input autofocuses
YousefED Aug 31, 2026
60fedd3
fix(ui): make Enter submit popover forms on Android
YousefED Aug 31, 2026
5a1f88d
test: behavioural test for link submission, not an attribute check
YousefED Aug 31, 2026
0085a9b
docs(device): record the IME action key as a manual release check
YousefED Aug 31, 2026
2d71334
fix(ui): give popover forms a real submit path
YousefED Aug 31, 2026
12a8a4a
docs(ui): mark enterkeyhint as belt-and-braces next to the form fix
YousefED Aug 31, 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
56 changes: 56 additions & 0 deletions .github/workflows/device-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Device tests

# Real-device tests on BrowserStack: nightly, and on demand. Requires the
# BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY repository secrets; see
# tests/device/README.md.
on:
schedule:
- cron: "30 3 * * *"
workflow_dispatch:
inputs:
device_filter:
description: "Substring of a device id from tests/device/devices.ts"
required: false
default: ""

jobs:
device-tests:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Start playground dev server
run: |
pnpm run dev &
for i 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 tests
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
DEVICE_FILTER: ${{ inputs.device_filter }}
run: pnpm run test:device

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: device-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": "pnpm --dir tests exec vitest run --config device/vitest.config.mts",
"format": "vp fmt",
"prepare": "vp config"
},
Expand Down
15 changes: 13 additions & 2 deletions packages/ariakit/src/input/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ import { assertEmpty } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";

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

assertEmpty(rest);

return <AriakitFormProvider>{children}</AriakitFormProvider>;
return (
<AriakitFormProvider>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit?.();
}}
>
{children}
</form>
</AriakitFormProvider>
);
};
35 changes: 32 additions & 3 deletions packages/ariakit/src/input/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {

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

export const TextInput = forwardRef<
HTMLInputElement,
Expand All @@ -32,6 +32,29 @@ 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 = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === "function") {
ref(element);
} else if (ref) {
ref.current = element;
}
},
[ref],
);
useEffect(() => {
if (autoFocus) {
inputRef.current?.focus({ preventScroll: true });
}
}, [autoFocus]);

return (
<>
{props.label && <AriakitFormLabel name={name}>{label}</AriakitFormLabel>}
Expand All @@ -43,10 +66,16 @@ export const TextInput = forwardRef<
className || "",
variant === "large" ? "bn-ak-input-large" : "",
)}
ref={ref}
// Belt-and-braces alongside the <form> in Form.Root. The form is what
// should make the browser treat Enter as a submit; this states it
// outright, so the behaviour doesn't rest on how Chromium scopes its
// "is there a next field to jump to" lookup. Removable once that's
// confirmed on a device — the tell is the keyboard's action key: an
// arrow means it still wants to advance focus.
enterKeyHint="done"
ref={setRefs}
name={name}
value={value}
autoFocus={autoFocus}
placeholder={placeholder}
disabled={disabled}
onKeyDown={onKeyDown}
Expand Down
57 changes: 55 additions & 2 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,15 @@ export class BlockNoteEditor<
* Checks whether a DOM element belongs to this editor — either inside the
* editor's DOM tree or inside its portal container (used for floating UI
* elements like menus and toolbars).
*
* The first check starts at the content area's *parent*, so that UI the
* host app renders as `BlockNoteView` children counts too — React places
* those beside the content (see the "Static Formatting Toolbar" example).
*
* So the boundary is whatever the element passed to `editor.mount()` has as
* its parent. `BlockNoteView` always provides a wrapper; mounting bare into
* `<body>`, as the vanilla-JS docs do, makes the whole page count as within
* the editor.
*/
public isWithinEditor = (element: Element): boolean => {
return !!(
Expand All @@ -805,11 +814,25 @@ export class BlockNoteEditor<
);
};

public isFocused() {
public isFocused(options?: {
/**
* When true, the editor's own UI (toolbars, menus, popovers —
* everything portalled into `editor.portalElement`) also counts as
* focused, answering "is the user still interacting with this editor?".
* The default reports content-area focus only.
*/
includeEditorUI?: boolean;
}) {
if (this.headless) {
return false;
}
return this.prosemirrorView?.hasFocus() || false;
const contentFocused = this.prosemirrorView?.hasFocus() || false;
if (!options?.includeEditorUI) {
return contentFocused;
}
const active =
typeof document !== "undefined" ? document.activeElement : null;
return contentFocused || (!!active && this.isWithinEditor(active));
}

public headless = true;
Expand Down Expand Up @@ -1365,6 +1388,36 @@ export class BlockNoteEditor<
);
}

/**
* A callback function that runs whenever the editor's content area gains or
* loses DOM focus.
*
* Note that `focused: false` only means the content area itself blurred —
* focus may have moved into the editor's own UI (e.g. a toolbar
* popover's input).
*
* @param callback The callback to execute.
* @returns A function to remove the callback.
*/
public onFocusChange(
callback: (
editor: BlockNoteEditor<BSchema, ISchema, SSchema>,
context: { focused: boolean; event: FocusEvent },
) => void,
options?: {
/**
* When true, the editor's own UI (toolbars, menus, popovers —
* everything portalled into `editor.portalElement`) counts as focused,
* and the callback fires only when that combined focus state actually
* changes, after focus movement has settled. The default reports raw
* content-area focus/blur events.
*/
includeEditorUI?: boolean;
},
) {
return this._eventManager.onFocusChange(callback, options);
}

/**
* A callback function that runs when the editor has been mounted.
*
Expand Down
Loading
Loading