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/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/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
+ `