diff --git a/.env.sample b/.env.sample
index bce33191f8..f498554192 100644
--- a/.env.sample
+++ b/.env.sample
@@ -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
\ No newline at end of file
+export NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN=
diff --git a/.github/workflows/emulator-tests.yml b/.github/workflows/emulator-tests.yml
new file mode 100644
index 0000000000..f1dbba8883
--- /dev/null
+++ b/.github/workflows/emulator-tests.yml
@@ -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
diff --git a/package.json b/package.json
index 0323f0ce02..0a1cf2a882 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/packages/ariakit/src/input/Form.tsx b/packages/ariakit/src/input/Form.tsx
index bf964aee66..819bf4f3c7 100644
--- a/packages/ariakit/src/input/Form.tsx
+++ b/packages/ariakit/src/input/Form.tsx
@@ -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 {children};
+ return (
+
+
+
+ );
};
diff --git a/packages/ariakit/src/input/TextInput.tsx b/packages/ariakit/src/input/TextInput.tsx
index 555961faf0..35b02b92d0 100644
--- a/packages/ariakit/src/input/TextInput.tsx
+++ b/packages/ariakit/src/input/TextInput.tsx
@@ -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,
@@ -23,7 +23,6 @@ export const TextInput = forwardRef<
disabled,
onKeyDown,
onChange,
- onSubmit,
autoComplete,
"aria-activedescendant": ariaActivedescendant,
rightSection,
@@ -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(null);
+ const setRefs = useMergeRefs([inputRef, ref]);
+ useEffect(() => {
+ if (autoFocus) {
+ inputRef.current?.focus({ preventScroll: true });
+ }
+ }, [autoFocus]);
+
return (
<>
{props.label && {label}}
@@ -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}
/>
diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css
index 59974a6d60..6212efe74c 100644
--- a/packages/ariakit/src/style.css
+++ b/packages/ariakit/src/style.css
@@ -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;
+}
diff --git a/packages/core/src/editor/managers/StyleManager.ts b/packages/core/src/editor/managers/StyleManager.ts
index e412160e4a..6e802a4c17 100644
--- a/packages/core/src/editor/managers/StyleManager.ts
+++ b/packages/core/src/editor/managers/StyleManager.ts
@@ -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;
});
}
diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index 4d1758094a..58f4675ffd 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -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,
@@ -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;
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() {
diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts
index 094671d920..b503d01eb9 100644
--- a/packages/core/src/i18n/locales/ar.ts
+++ b/packages/core/src/i18n/locales/ar.ts
@@ -406,5 +406,6 @@ export const ar: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "إرسال",
},
};
diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index bf77a36a01..29b9eaee64 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -440,5 +440,6 @@ export const de: Dictionary = {
},
generic: {
ctrl_shortcut: "Strg",
+ form_submit: "Absenden",
},
};
diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts
index e5386f3020..76f636bd75 100644
--- a/packages/core/src/i18n/locales/en.ts
+++ b/packages/core/src/i18n/locales/en.ts
@@ -421,5 +421,6 @@ export const en = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Submit",
},
};
diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts
index 743a1be05c..a878b27efd 100644
--- a/packages/core/src/i18n/locales/es.ts
+++ b/packages/core/src/i18n/locales/es.ts
@@ -419,5 +419,6 @@ export const es: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Enviar",
},
};
diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts
index 6b2783ab68..81d1d442bc 100644
--- a/packages/core/src/i18n/locales/fa.ts
+++ b/packages/core/src/i18n/locales/fa.ts
@@ -390,5 +390,6 @@ export const fa = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "ارسال",
},
};
diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts
index ad605db24a..5f2f00559c 100644
--- a/packages/core/src/i18n/locales/fr.ts
+++ b/packages/core/src/i18n/locales/fr.ts
@@ -467,5 +467,6 @@ export const fr: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Envoyer",
},
};
diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts
index 4662a94202..e62f1afcb5 100644
--- a/packages/core/src/i18n/locales/he.ts
+++ b/packages/core/src/i18n/locales/he.ts
@@ -421,5 +421,6 @@ export const he: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "שליחה",
},
};
diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts
index 03eb016eed..649ef6c621 100644
--- a/packages/core/src/i18n/locales/hr.ts
+++ b/packages/core/src/i18n/locales/hr.ts
@@ -435,5 +435,6 @@ export const hr: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Pošalji",
},
};
diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts
index 913b2324b0..f5fee52314 100644
--- a/packages/core/src/i18n/locales/is.ts
+++ b/packages/core/src/i18n/locales/is.ts
@@ -435,5 +435,6 @@ export const is: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Senda",
},
};
diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts
index 44be22c1bd..b6d76420e0 100644
--- a/packages/core/src/i18n/locales/it.ts
+++ b/packages/core/src/i18n/locales/it.ts
@@ -443,5 +443,6 @@ export const it: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Invia",
},
};
diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts
index ead1f2fb30..a1bc799d42 100644
--- a/packages/core/src/i18n/locales/ja.ts
+++ b/packages/core/src/i18n/locales/ja.ts
@@ -461,5 +461,6 @@ export const ja: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "送信",
},
};
diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts
index 2981ff1c36..15cf0cc0fb 100644
--- a/packages/core/src/i18n/locales/ko.ts
+++ b/packages/core/src/i18n/locales/ko.ts
@@ -434,5 +434,6 @@ export const ko: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "제출",
},
};
diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts
index da599e017c..0be0755e38 100644
--- a/packages/core/src/i18n/locales/nl.ts
+++ b/packages/core/src/i18n/locales/nl.ts
@@ -422,5 +422,6 @@ export const nl: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Verzenden",
},
};
diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts
index 72efc096ed..1242b9f6a2 100644
--- a/packages/core/src/i18n/locales/no.ts
+++ b/packages/core/src/i18n/locales/no.ts
@@ -439,5 +439,6 @@ export const no: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Send inn",
},
};
diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts
index d00039633c..fc4ff44055 100644
--- a/packages/core/src/i18n/locales/pl.ts
+++ b/packages/core/src/i18n/locales/pl.ts
@@ -412,5 +412,6 @@ export const pl: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Wyślij",
},
};
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index fe719ce023..72caf58af3 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -414,5 +414,6 @@ export const pt: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Enviar",
},
};
diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts
index a4a7987dfc..26faa60bbf 100644
--- a/packages/core/src/i18n/locales/ru.ts
+++ b/packages/core/src/i18n/locales/ru.ts
@@ -465,5 +465,6 @@ export const ru: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Отправить",
},
};
diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts
index 4e73dc7eca..7aff94394b 100644
--- a/packages/core/src/i18n/locales/sk.ts
+++ b/packages/core/src/i18n/locales/sk.ts
@@ -419,5 +419,6 @@ export const sk = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Odoslať",
},
};
diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts
index e9d379ac0b..ce9aee6a8e 100644
--- a/packages/core/src/i18n/locales/uk.ts
+++ b/packages/core/src/i18n/locales/uk.ts
@@ -445,5 +445,6 @@ export const uk: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Надіслати",
},
};
diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts
index 13aee55a73..984f9a844b 100644
--- a/packages/core/src/i18n/locales/uz.ts
+++ b/packages/core/src/i18n/locales/uz.ts
@@ -455,5 +455,6 @@ export const uz: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Yuborish",
},
};
diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts
index 8733fbf0ba..48295ebff7 100644
--- a/packages/core/src/i18n/locales/vi.ts
+++ b/packages/core/src/i18n/locales/vi.ts
@@ -420,5 +420,6 @@ export const vi: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "Gửi",
},
};
diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts
index 5ac37a80c7..9be4dc9fc0 100644
--- a/packages/core/src/i18n/locales/zh-tw.ts
+++ b/packages/core/src/i18n/locales/zh-tw.ts
@@ -462,5 +462,6 @@ export const zhTW: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "提交",
},
};
diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts
index 3f4c90bb56..78498d0e68 100644
--- a/packages/core/src/i18n/locales/zh.ts
+++ b/packages/core/src/i18n/locales/zh.ts
@@ -462,5 +462,6 @@ export const zh: Dictionary = {
},
generic: {
ctrl_shortcut: "Ctrl",
+ form_submit: "提交",
},
};
diff --git a/packages/core/src/util/browser.ts b/packages/core/src/util/browser.ts
index d070115c2a..d8961d526d 100644
--- a/packages/core/src/util/browser.ts
+++ b/packages/core/src/util/browser.ts
@@ -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
diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css
index beb3c8182f..28974e2a23 100644
--- a/packages/mantine/src/blocknoteStyles.css
+++ b/packages/mantine/src/blocknoteStyles.css
@@ -257,6 +257,19 @@ on touch devices (e.g. the mobile formatting toolbar). */
font-size: 12px;
}
+/* On touch devices, enlarge the form-popover inputs (e.g. the link popover's
+ URL field). The 16px font-size is load-bearing: iOS Safari auto-zooms the
+ page when focusing an input with a smaller computed font-size, and that zoom
+ perturbs the visual viewport the mobile toolbar positions itself from. The
+ taller min-height also gives a comfortable tap target. */
+@media (pointer: coarse) {
+ .bn-form-popover .mantine-TextInput-input,
+ .bn-form-popover .mantine-FileInput-input {
+ font-size: 16px;
+ min-height: 40px;
+ }
+}
+
.bn-form-popover .mantine-FileInput-input:hover {
background-color: var(--bn-colors-hovered-background);
}
@@ -806,3 +819,23 @@ we just don't display it in CSS instead. */
.bn-mantine .bn-badge .mantine-Chip-iconWrapper {
display: none;
}
+
+/* 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;
+}
diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx
index 6c85286e7b..f39ec593fa 100644
--- a/packages/mantine/src/components.tsx
+++ b/packages/mantine/src/components.tsx
@@ -3,6 +3,7 @@ import { Badge, BadgeGroup } from "./badge/Badge.js";
import { Card, CardSection, ExpandSectionsPrompt } from "./comments/Card.js";
import { Comment } from "./comments/Comment.js";
import { Editor } from "./comments/Editor.js";
+import { Form } from "./form/Form.js";
import { TextInput } from "./form/TextInput.js";
import {
Menu,
@@ -89,7 +90,7 @@ export const components: Components = {
Group: BadgeGroup,
},
Form: {
- Root: (props) => {props.children}
,
+ Root: Form,
TextInput: TextInput,
},
Menu: {
diff --git a/packages/mantine/src/form/Form.tsx b/packages/mantine/src/form/Form.tsx
new file mode 100644
index 0000000000..f0cc1e7d0e
--- /dev/null
+++ b/packages/mantine/src/form/Form.tsx
@@ -0,0 +1,32 @@
+import { assertEmpty } from "@blocknote/core";
+import { ComponentProps, useDictionary } from "@blocknote/react";
+
+export const Form = (props: ComponentProps["Generic"]["Form"]["Root"]) => {
+ const { children, onSubmit, omitSubmitButton, ...rest } = props;
+ const dict = useDictionary();
+
+ assertEmpty(rest);
+
+ return (
+
+ );
+};
diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx
index c1630fa17f..4d2e2bcb7f 100644
--- a/packages/mantine/src/form/TextInput.tsx
+++ b/packages/mantine/src/form/TextInput.tsx
@@ -1,8 +1,8 @@
import { TextInput as MantineTextInput } from "@mantine/core";
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,
@@ -20,7 +20,6 @@ export const TextInput = forwardRef<
disabled,
onKeyDown,
onChange,
- onSubmit,
autoComplete,
"aria-activedescendant": ariaActivedescendant,
rightSection,
@@ -29,6 +28,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(null);
+ const setRefs = useMergeRefs([inputRef, ref]);
+ useEffect(() => {
+ if (autoFocus) {
+ inputRef.current?.focus({ preventScroll: true });
+ }
+ }, [autoFocus]);
+
return (
diff --git a/packages/mantine/src/popover/Popover.tsx b/packages/mantine/src/popover/Popover.tsx
index 9a10c4ce44..35a19590cf 100644
--- a/packages/mantine/src/popover/Popover.tsx
+++ b/packages/mantine/src/popover/Popover.tsx
@@ -13,6 +13,12 @@ export const Popover = (
) => {
const { open, onOpenChange, position, portalRoot, children, ...rest } = props;
+ // A `portalRoot` is only passed by the mobile toolbar, which renders its
+ // popovers into its own container — so it doubles as "this popover belongs
+ // to the mobile toolbar", which is what the two behaviours below actually
+ // depend on. Named here so the reason isn't hidden behind an unrelated prop.
+ const isMobileToolbarPopover = !!portalRoot;
+
assertEmpty(rest);
return (
@@ -22,7 +28,13 @@ export const Popover = (
portalProps={portalRoot ? { target: portalRoot } : undefined}
// Do not move focus to the dropdown on mobile, as it blurs the editor's
// contentEditable and dismisses the on-screen keyboard.
- trapFocus={portalRoot ? false : undefined}
+ trapFocus={isMobileToolbarPopover ? false : undefined}
+ // Keep the dropdown visible through virtual-keyboard viewport resizes on
+ // mobile: hideDetached (default true) reacts to the resize by setting
+ // display:none on the dropdown, which blurs its focused input and
+ // dismisses the on-screen keyboard (the input then unmounts with the
+ // toolbar, so the whole UI collapses).
+ hideDetached={isMobileToolbarPopover ? false : undefined}
opened={open}
onChange={onOpenChange}
position={position}
diff --git a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
index 9c824ba8bf..0462bc89a4 100644
--- a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
+++ b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
@@ -7,7 +7,7 @@ import {
StyleSchema,
filenameFromURL,
} from "@blocknote/core";
-import { ChangeEvent, KeyboardEvent, useCallback, useState } from "react";
+import { ChangeEvent, useCallback, useState } from "react";
import { useComponentsContext } from "../../../editor/ComponentsContext.js";
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
@@ -37,25 +37,7 @@ export const EmbedTab = <
[],
);
- const handleURLEnter = useCallback(
- (event: KeyboardEvent) => {
- if (event.key === "Enter" && !event.nativeEvent.isComposing) {
- event.preventDefault();
- if (!editor.getBlock(props.blockId)) {
- return;
- }
- editor.updateBlock(props.blockId, {
- props: {
- name: filenameFromURL(currentURL),
- url: currentURL,
- } as any,
- });
- }
- },
- [editor, props.blockId, currentURL],
- );
-
- const handleURLClick = useCallback(() => {
+ const embedURL = useCallback(() => {
if (!editor.getBlock(props.blockId)) {
return;
}
@@ -73,17 +55,26 @@ export const EmbedTab = <
return (
-
+ {/*
+ The embed button below is this form's submit control, so `Form.Root`
+ must not add its own — a screen reader would announce two separate
+ actions for the one thing this panel does. It stays outside the
+ `