diff --git a/docs/content/docs/features/blocks/index.mdx b/docs/content/docs/features/blocks/index.mdx
index 7e07b6f773..0d45dd124e 100644
--- a/docs/content/docs/features/blocks/index.mdx
+++ b/docs/content/docs/features/blocks/index.mdx
@@ -35,6 +35,8 @@ type DefaultProps = {
};
```
+In the editor, `"left"` means the logical start of the block and `"right"` means the logical end. They appear left/right in LTR blocks and right/left in RTL blocks; the stored values and default remain unchanged.
+
## Configuring Default Blocks
Some default blocks can be configured with options. For example, headings can be configured to have different available levels:
diff --git a/examples/01-basic/18-rtl/.bnexample.json b/examples/01-basic/18-rtl/.bnexample.json
new file mode 100644
index 0000000000..384a9d57b5
--- /dev/null
+++ b/examples/01-basic/18-rtl/.bnexample.json
@@ -0,0 +1,6 @@
+{
+ "playground": true,
+ "docs": false,
+ "author": "",
+ "tags": ["Basic"]
+}
diff --git a/examples/01-basic/18-rtl/README.md b/examples/01-basic/18-rtl/README.md
new file mode 100644
index 0000000000..07a2ab2ba5
--- /dev/null
+++ b/examples/01-basic/18-rtl/README.md
@@ -0,0 +1,11 @@
+# Right-to-left (RTL)
+
+A Hebrew-localized editor with Hebrew, Arabic, and mixed English text. Use the Hebrew dictionary, Mantine's `DirectionProvider`, and `dir="rtl"` on `BlockNoteView` to localize the UI and mirror the layout while keeping code left-to-right. The provider uses `initialDirection="rtl"` and `detectDirection={false}` so this example stays RTL independently of the surrounding playground.
+
+**Try it out!** Edit the nested lists, open a block menu, edit the link, or type `/` and `:sm` in the empty paragraph to try the slash menu and emoji picker.
+
+**Relevant Docs:**
+
+- [Editor Setup](/docs/getting-started/editor-setup)
+- [Localization](/docs/features/localization)
+- [Mantine RTL setup](https://mantine.dev/styles/rtl/)
diff --git a/examples/01-basic/18-rtl/index.html b/examples/01-basic/18-rtl/index.html
new file mode 100644
index 0000000000..e9af7968f7
--- /dev/null
+++ b/examples/01-basic/18-rtl/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ Right-to-left (RTL)
+
+
+
+
+
+
+
diff --git a/examples/01-basic/18-rtl/main.tsx b/examples/01-basic/18-rtl/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/01-basic/18-rtl/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/01-basic/18-rtl/package.json b/examples/01-basic/18-rtl/package.json
new file mode 100644
index 0000000000..803f5df677
--- /dev/null
+++ b/examples/01-basic/18-rtl/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@blocknote/example-basic-rtl",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/01-basic/18-rtl/src/App.tsx b/examples/01-basic/18-rtl/src/App.tsx
new file mode 100644
index 0000000000..851816bf3a
--- /dev/null
+++ b/examples/01-basic/18-rtl/src/App.tsx
@@ -0,0 +1,73 @@
+import "@blocknote/core/fonts/inter.css";
+import { he } from "@blocknote/core/locales";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import { DirectionProvider } from "@mantine/core";
+
+export default function App() {
+ const editor = useCreateBlockNote({
+ dictionary: he,
+ initialContent: [
+ {
+ type: "heading",
+ props: { level: 2 },
+ content: "שלום — مرحباً",
+ },
+ {
+ type: "paragraph",
+ content: "עורך מימין לשמאל — محرر من اليمين إلى اليسار",
+ },
+ {
+ type: "paragraph",
+ content: "עברית, العربية ו-English (123) — טקסט מעורב / نص مختلط.",
+ },
+ {
+ type: "bulletListItem",
+ content: "רשימה עם פריטים מקוננים",
+ children: [
+ { type: "bulletListItem", content: "פריט פנימי — بند فرعي" },
+ {
+ type: "bulletListItem",
+ content: "English inside an RTL list (456)",
+ },
+ ],
+ },
+ { type: "numberedListItem", content: "الخطوة الأولى — שלב ראשון" },
+ { type: "numberedListItem", content: "الخطوة الثانية — שלב שני" },
+ { type: "checkListItem", content: "משימה לבדיקה — مهمة للتجربة" },
+ { type: "quote", content: "اقتباس قصير — ציטוט קצר" },
+ {
+ type: "toggleListItem",
+ content: "פרטים נוספים — تفاصيل إضافية",
+ children: [
+ {
+ type: "paragraph",
+ content: "אפשר לערוך גם כאן — يمكنك الكتابة هنا أيضاً.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: [
+ { type: "text", text: "קישור לדוגמה — ", styles: {} },
+ {
+ type: "link",
+ href: "https://www.blocknotejs.org",
+ content: "توثيق BlockNote",
+ },
+ ],
+ },
+ { type: "codeBlock", content: 'const greeting = "שלום — مرحباً";' },
+ { type: "paragraph" },
+ ],
+ });
+
+ // Keep Mantine's direction context and the editor's DOM direction in sync,
+ // without detecting or changing the surrounding playground's direction.
+ return (
+
+
+
+ );
+}
diff --git a/examples/01-basic/18-rtl/tsconfig.json b/examples/01-basic/18-rtl/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/01-basic/18-rtl/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/01-basic/18-rtl/vite-env.d.ts b/examples/01-basic/18-rtl/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/01-basic/18-rtl/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/01-basic/18-rtl/vite.config.ts b/examples/01-basic/18-rtl/vite.config.ts
new file mode 100644
index 0000000000..cbf6ff2ffc
--- /dev/null
+++ b/examples/01-basic/18-rtl/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/packages/ariakit/src/input/TextInput.tsx b/packages/ariakit/src/input/TextInput.tsx
index 7f23029a27..28af0e150a 100644
--- a/packages/ariakit/src/input/TextInput.tsx
+++ b/packages/ariakit/src/input/TextInput.tsx
@@ -14,6 +14,7 @@ export const TextInput = forwardRef<
const {
className,
name,
+ dir,
label,
variant,
icon,
@@ -48,6 +49,7 @@ export const TextInput = forwardRef<
)}
ref={setRefs}
name={name}
+ dir={dir}
value={value}
placeholder={placeholder}
disabled={disabled}
diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css
index 2f91a7803f..3e6e498802 100644
--- a/packages/ariakit/src/style.css
+++ b/packages/ariakit/src/style.css
@@ -194,7 +194,7 @@
height: 100%;
width: 18px;
padding: 0;
- margin-left: 4px;
+ margin-inline-start: 4px;
cursor: col-resize;
}
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 237d298a08..aa92ae5e3c 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -70,7 +70,7 @@ BASIC STYLES
.bn-block-content::before {
content: "";
- margin-right: 0;
+ margin-inline-end: 0;
transition: all 0.2s;
/* Workaround for selection issue on Chrome, see #1588 and also here:
https://discuss.prosemirror.net/t/mouse-down-selection-behaviour-different-on-chrome/8426
@@ -101,7 +101,20 @@ NESTED BLOCKS
*/
.bn-block-group .bn-block-group {
- margin-left: 24px;
+ margin-inline-start: 24px;
+}
+
+/* Only explicit block direction overrides choose the children's indentation
+ edge; otherwise preserve inherited CSS direction (:dir follows HTML dir).
+ Toggles wrap their content and children in a shared frame/slot. */
+.bn-block:has(> .bn-block-content[dir]:dir(rtl)) > .bn-block-group,
+.bn-toggle-frame:has(> .bn-toggle-slot > .bn-block-content[dir]:dir(rtl)) {
+ direction: rtl;
+}
+
+.bn-block:has(> .bn-block-content[dir]:dir(ltr)) > .bn-block-group,
+.bn-toggle-frame:has(> .bn-toggle-slot > .bn-block-content[dir]:dir(ltr)) {
+ direction: ltr;
}
/* Attribution wrappers use display: contents; descendant selectors also reach
@@ -116,7 +129,7 @@ NESTED BLOCKS
content: " ";
display: inline;
position: absolute;
- left: -20px;
+ inset-inline-start: -20px;
height: 100%;
transition: all 0.2s 0.1s;
}
@@ -180,12 +193,12 @@ NESTED BLOCKS
}
.bn-block-outer[data-prev-depth-change] {
- margin-left: calc(10px * var(--x));
+ margin-inline-start: calc(10px * var(--x));
}
.bn-block-outer[data-prev-depth-change]
.bn-block-outer[data-prev-depth-change] {
- margin-left: 0;
+ margin-inline-start: 0;
}
/* HEADINGS*/
@@ -282,10 +295,10 @@ NESTED BLOCKS
/* QUOTES */
[data-content-type="quote"] blockquote {
- border-left: 2px solid var(--bn-colors-side-menu, rgb(125, 121, 122));
+ border-inline-start: 2px solid var(--bn-colors-side-menu, rgb(125, 121, 122));
color: var(--bn-colors-highlights-gray-text, #646362);
margin: 0;
- padding-left: 1em;
+ padding-inline-start: 1em;
}
[data-content-type="quote"] blockquote:where(.dark, .dark *) {
@@ -303,7 +316,7 @@ NESTED BLOCKS
/* LISTS */
.bn-block-content::before {
- margin-right: 0;
+ margin-inline-end: 0;
content: "";
}
@@ -312,7 +325,7 @@ NESTED BLOCKS
display: flex;
justify-content: center;
min-width: 24px;
- padding-right: 4px;
+ padding-inline-end: 4px;
}
[data-content-type="numberedListItem"] {
@@ -361,7 +374,7 @@ NESTED BLOCKS
display: flex;
justify-content: center;
min-width: 24px;
- padding-right: 4px;
+ padding-inline-end: 4px;
}
/* Checked */
@@ -372,8 +385,8 @@ NESTED BLOCKS
.bn-block-content[data-content-type="checkListItem"] > div > input {
cursor: pointer;
height: 24px;
- margin-left: 4px;
- margin-right: 8px;
+ margin-inline-start: 4px;
+ margin-inline-end: 8px;
margin-block: 0;
width: 12px;
}
@@ -406,6 +419,18 @@ NESTED BLOCKS
transform: rotate(90deg);
}
+/* A closed toggle points into the text; an open one still points down. */
+.bn-toggle-button:dir(rtl):not([aria-expanded="true"]),
+.bn-toggle-frame:has(> .bn-toggle-slot > .bn-block-content[dir]:dir(rtl))
+ > .bn-toggle-button:not([aria-expanded="true"]) {
+ transform: rotate(180deg);
+}
+
+.bn-toggle-frame:has(> .bn-toggle-slot > .bn-block-content[dir]:dir(ltr))
+ > .bn-toggle-button:not([aria-expanded="true"]) {
+ transform: none;
+}
+
/* The frame of the built-in toggle blocks (see `createToggleFrame`): the
chevron, then the block's content, and the children below, across both
columns. The slot is `display: contents`, so the content and the child
@@ -455,7 +480,7 @@ NESTED BLOCKS
font-size: 16px;
color: var(--bn-colors-highlights-gray-text, #646362);
font-weight: normal;
- margin-left: 22px;
+ margin-inline-start: 22px;
padding-inline: 2px;
width: fit-content;
}
@@ -541,6 +566,9 @@ NESTED BLOCKS
border-radius: 8px;
}
.bn-block-content[data-content-type="codeBlock"] > pre {
+ /* Source code keeps its writing direction inside an RTL document. */
+ direction: ltr;
+ text-align: left;
--bn-suggestion-surface: rgb(22 22 22);
white-space: pre;
overflow-x: auto;
@@ -1085,8 +1113,9 @@ NESTED BLOCKS
/* TEXT ALIGNMENT */
[data-text-alignment="left"] {
+ /* Keep the existing values, but align to the reading direction. */
justify-content: flex-start !important;
- text-align: left !important;
+ text-align: start !important;
}
[data-text-alignment="center"] {
@@ -1096,7 +1125,7 @@ NESTED BLOCKS
[data-text-alignment="right"] {
justify-content: flex-end !important;
- text-align: right !important;
+ text-align: end !important;
}
[data-text-alignment="justify"] {
diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css
index be9ed67002..a207104b3e 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -138,7 +138,7 @@
.bn-editor [data-content-type="table"] th {
font-weight: bold;
- text-align: left;
+ text-align: start;
}
.bn-editor [data-content-type="table"] th > p,
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts
index e987fa4e68..ea5b014546 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.browser.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, expect, it } from "vite-plus/test";
+import { afterEach, expect, it, vi } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { SideMenuExtension } from "./SideMenu.js";
@@ -8,6 +8,41 @@ import { unsetDragImage } from "./dragging.js";
const cleanup: (() => void)[] = [];
+it.each(["ltr", "rtl"] as const)(
+ "anchors the %s drag preview at its inline start edge",
+ (direction) => {
+ const container = document.createElement("div");
+ container.dir = direction;
+ document.body.appendChild(container);
+ cleanup.push(() => container.remove());
+ const editor = BlockNoteEditor.create({
+ initialContent: [
+ { id: "dragged", type: "paragraph", content: "שלום — Hello" },
+ ],
+ });
+ editor.mount(container);
+ cleanup.push(() => editor._tiptapEditor.destroy());
+ cleanup.push(() => unsetDragImage(editor.prosemirrorView.root));
+ const dataTransfer = new DataTransfer();
+ const setDragImage = vi.spyOn(dataTransfer, "setDragImage");
+
+ editor
+ .getExtension(SideMenuExtension)!
+ .blockDragStart(
+ { dataTransfer, clientY: 0 },
+ editor.getBlock("dragged")!,
+ );
+
+ const preview = document.querySelector(".bn-drag-preview")!;
+ expect(preview.offsetWidth).toBeGreaterThan(0);
+ expect(setDragImage).toHaveBeenCalledWith(
+ preview,
+ direction === "rtl" ? preview.offsetWidth : 0,
+ 0,
+ );
+ },
+);
+
it("drags only the child when its parent has a single-block selection", () => {
const container = document.createElement("div");
document.body.appendChild(container);
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index 40f9e24872..b0719c9823 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -105,6 +105,8 @@ function setDragImage(view: EditorView, from: number, to = from) {
// the snapshot in Firefox and Safari. Taller than the cap, it would reach
// into the page from above.
Object.assign(preview.style, {
+ // The preview is mounted outside the editor's direction-inheriting tree.
+ direction: getComputedStyle(view.dom).direction,
background: "transparent",
position: "absolute",
top: "-10000px",
@@ -226,6 +228,12 @@ export function dragStart<
e.dataTransfer.setData("text/html", externalHTML);
e.dataTransfer.setData("text/plain", plainText);
e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setDragImage(dragImageElement!, 0, 0);
+ const dragImage = dragImageElement!;
+ // Anchor at the gutter-facing edge: RTL blocks extend left of the pointer.
+ const offsetX =
+ getComputedStyle(view.dom).direction === "rtl"
+ ? dragImage.getBoundingClientRect().width
+ : 0;
+ e.dataTransfer.setDragImage(dragImage, offsetX, 0);
}
}
diff --git a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts
index 6ae5547bb3..6884166374 100644
--- a/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandles.browser.test.ts
@@ -197,6 +197,31 @@ describe("TableHandlesView mouse handling", () => {
});
});
+ it.each(["ltr", "rtl"] as const)(
+ "%s offers column extension beyond the table's inline end",
+ (direction) => {
+ mountPoint.dir = direction;
+ moveMouseOver(tableCell(0, 2));
+ const wrapper = queryElement('[data-id="table-0"] .tableWrapper');
+ const rect = queryElement(
+ '[data-id="table-0"] tbody',
+ ).getBoundingClientRect();
+ wrapper.dispatchEvent(
+ new MouseEvent("mousemove", {
+ bubbles: true,
+ clientX: direction === "rtl" ? rect.left - 5 : rect.right + 5,
+ clientY: rect.top + rect.height / 2,
+ }),
+ );
+ expect(tableHandlesState()).toMatchObject({
+ show: true,
+ showAddOrRemoveColumnsButton: true,
+ rowIndex: undefined,
+ colIndex: undefined,
+ });
+ },
+ );
+
it("hides the handles when the mouse leaves the table", () => {
moveMouseOver(tableCell(0, 0));
expect(tableHandlesState()?.show).toBe(true);
diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts
index 086b3609ff..67c1e651d6 100644
--- a/packages/core/src/extensions/TableHandles/TableHandles.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandles.ts
@@ -9,6 +9,7 @@ import {
deleteColumn,
deleteRow,
mergeCells,
+ setCellAttr,
splitCell,
} from "prosemirror-tables";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
@@ -42,6 +43,7 @@ import {
import {
BlockFromConfigNoChildren,
BlockSchemaWithBlock,
+ TableCellProps,
} from "../../schema/index.js";
import { getBlockFromElement } from "../blockDOM.js";
@@ -289,28 +291,34 @@ export class TableHandlesView implements PluginView {
?.querySelector(".table-widgets-container") as HTMLElement;
if (target?.type === "wrapper") {
- // if we're just to the right or below the table, show the extend buttons
+ // Show the extend buttons just beyond the table's inline end or bottom.
// (this is a bit hacky. It would probably be cleaner to render the extend buttons in the Table NodeView instead)
const belowTable =
event.clientY >= tableRect.bottom - 1 && // -1 to account for fractions of pixels in "bottom"
event.clientY < tableRect.bottom + 20;
- const toRightOfTable =
- event.clientX >= tableRect.right - 1 &&
- event.clientX < tableRect.right + 20;
+ const rtl = getComputedStyle(target.tbodyNode).direction === "rtl";
+ const beyondInlineEnd = rtl
+ ? event.clientX < tableRect.left
+ : event.clientX > tableRect.right;
+ const nearInlineEnd = rtl
+ ? event.clientX <= tableRect.left + 1 &&
+ event.clientX > tableRect.left - 20
+ : event.clientX >= tableRect.right - 1 &&
+ event.clientX < tableRect.right + 20;
const hideHandles =
// always hide handles when the actively hovered table changed
this.state?.block?.id !== tableBlock.id ||
// make sure we don't hide existing handles (keep col / row index) when
- // we're hovering just above or to the right of a table
- event.clientX > tableRect.right ||
+ // we're hovering just above or beyond the inline end of a table
+ beyondInlineEnd ||
event.clientY > tableRect.bottom;
this.state = {
...this.state!,
show: true,
showAddOrRemoveRowsButton: belowTable,
- showAddOrRemoveColumnsButton: toRightOfTable,
+ showAddOrRemoveColumnsButton: nearInlineEnd,
referencePosTable: tableRect,
block: tableBlock,
widgetContainer,
@@ -1128,6 +1136,23 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
});
},
+ /** Updates alignment without replacing the table or moving the selection. */
+ setCellTextAlignment(textAlignment: TableCellProps["textAlignment"]) {
+ if (isTableCellSelection(editor.prosemirrorState.selection)) {
+ // setCellAttr skips the whole selection when its anchor already matches.
+ // A mixed selection still needs its other cells updated.
+ editor.transact((tr) => {
+ if (isTableCellSelection(tr.selection)) {
+ tr.selection.forEachCell((_cell, pos) => {
+ tr.setNodeAttribute(pos, "textAlignment", textAlignment);
+ });
+ }
+ });
+ return true;
+ }
+ return editor.exec(setCellAttr("textAlignment", textAlignment));
+ },
+
/**
* Gets the start and end cells of the current cell selection.
* @returns The start and end cells of the current cell selection.
diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css
index 5a7a165fd9..b2b0d19481 100644
--- a/packages/mantine/src/blocknoteStyles.css
+++ b/packages/mantine/src/blocknoteStyles.css
@@ -131,6 +131,19 @@
height: 52px;
}
+/* Icon placement follows the wrapper's layout, not the field's language.
+ Use physical padding here so dir="ltr"/"auto" on the input cannot move
+ the icon's reserved space to the opposite edge. */
+.bn-mantine .mantine-TextInput-wrapper:dir(ltr) .mantine-TextInput-input {
+ padding-left: var(--input-padding-inline-start);
+ padding-right: var(--input-padding-inline-end);
+}
+
+.bn-mantine .mantine-TextInput-wrapper:dir(rtl) .mantine-TextInput-input {
+ padding-left: var(--input-padding-inline-end);
+ padding-right: var(--input-padding-inline-start);
+}
+
/* Mantine Tooltip component base styles */
.bn-mantine .mantine-Tooltip-tooltip {
background-color: transparent;
@@ -413,7 +426,7 @@ on touch devices (e.g. the mobile formatting toolbar). */
min-width: 0;
flex-direction: column;
justify-content: flex-start;
- padding-right: 16px;
+ padding-inline-end: 16px;
}
.bn-mt-suggestion-menu-item-title {
@@ -567,7 +580,7 @@ on touch devices (e.g. the mobile formatting toolbar). */
.bn-mantine .bn-extend-button-add-remove-columns {
height: 100%;
width: 18px;
- margin-left: 4px;
+ margin-inline-start: 4px;
cursor: col-resize;
}
diff --git a/packages/mantine/src/form/TextInput.tsx b/packages/mantine/src/form/TextInput.tsx
index 9b05e66bbb..ad36dc6b55 100644
--- a/packages/mantine/src/form/TextInput.tsx
+++ b/packages/mantine/src/form/TextInput.tsx
@@ -11,6 +11,7 @@ export const TextInput = forwardRef<
const {
className,
name,
+ dir,
label,
variant,
icon,
@@ -42,6 +43,7 @@ export const TextInput = forwardRef<
)}
ref={setRefs}
name={name}
+ dir={dir}
label={label}
leftSection={icon}
value={value}
diff --git a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
index 52ef673e3b..5604b26b81 100644
--- a/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
+++ b/packages/react/src/components/FilePanel/DefaultTabs/EmbedTab.tsx
@@ -75,7 +75,7 @@ export const EmbedTab = <
}
>
{
return (
{
return (
}
autoFocus={true}
placeholder={dict.link_toolbar.form.url_placeholder}
@@ -85,6 +86,7 @@ export const EditLinkMenuItems = (
}
placeholder={dict.link_toolbar.form.title_placeholder}
value={currentText}
diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx
index e18cef058f..a48f2f92a1 100644
--- a/packages/react/src/components/SideMenu/SideMenuController.tsx
+++ b/packages/react/src/components/SideMenu/SideMenuController.tsx
@@ -5,6 +5,7 @@ import { FC, useCallback, useMemo } from "react";
import { PortalElementOverride } from "../../editor/PortalElementOverride.js";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
+import { useEditorDirection } from "../../hooks/useEditorDirection.js";
import { useExtensionState } from "../../hooks/useExtension.js";
import { BlockPopover } from "../Popovers/BlockPopover.js";
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
@@ -68,6 +69,9 @@ export const SideMenuController = (props: {
portalElement?: HTMLElement;
}) => {
const editor = useBlockNoteEditor();
+ // Follow the editor's layout direction, not the language of the hovered
+ // block. Mixed-language documents keep one gutter.
+ const direction = useEditorDirection();
const state = useExtensionState(SideMenuExtension, {
selector: (state) => {
return state !== undefined
@@ -125,9 +129,9 @@ export const SideMenuController = (props: {
...props.floatingUIOptions,
useFloatingOptions: {
open: show,
- placement: "left-start",
+ placement: direction === "rtl" ? "right-start" : "left-start",
// Vertically centers the menu on the block's first line. On the
- // "left-start" placement, the cross axis is the vertical one.
+ // left/right placements, the cross axis is the vertical one.
middleware: [
offset({
crossAxis: block ? getBlockOffset(editor, block) : 0,
@@ -151,7 +155,14 @@ export const SideMenuController = (props: {
...props.floatingUIOptions?.elementProps,
},
}),
- [props.floatingUIOptions, show, block, editor, whileElementsMounted],
+ [
+ props.floatingUIOptions,
+ show,
+ block,
+ editor,
+ direction,
+ whileElementsMounted,
+ ],
);
const Component = props.sideMenu || SideMenu;
diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx b/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx
index 802aa30503..635ad772bb 100644
--- a/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx
+++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx
@@ -278,7 +278,19 @@ function InlineEmojiPickerContent(props: {
key === "ArrowDown" ||
key === "ArrowUp"
) {
- const nextPosition = moveEmojiPosition(selectedPosition, key, rows);
+ // Follow the rendered grid, not the dictionary language: an English
+ // picker can still be embedded in an RTL editor (and vice versa).
+ const direction =
+ rootRef.current &&
+ getComputedStyle(rootRef.current).direction === "rtl"
+ ? "rtl"
+ : "ltr";
+ const nextPosition = moveEmojiPosition(
+ selectedPosition,
+ key,
+ rows,
+ direction,
+ );
if (nextPosition === selectedPosition) {
return;
}
@@ -303,6 +315,7 @@ function InlineEmojiPickerContent(props: {
};
}, [
rows,
+ rootRef,
editorDOMElement,
insertEmoji,
query,
diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts
index 848e55caba..b736efe653 100644
--- a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts
+++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts
@@ -115,6 +115,22 @@ describe("emoji picker model", () => {
},
);
+ it.each([
+ ["ArrowLeft", { row: 0, column: 0 }, { row: 0, column: 1 }],
+ ["ArrowRight", { row: 0, column: 1 }, { row: 0, column: 0 }],
+ ["ArrowLeft", { row: 0, column: 8 }, { row: 1, column: 0 }],
+ ["ArrowRight", { row: 1, column: 0 }, { row: 0, column: 8 }],
+ ] as const)("moves visually %s in RTL from %j to %j", (key, from, to) => {
+ expect(moveEmojiPosition(from, key, rows, "rtl")).toEqual(to);
+ });
+
+ it.each([
+ ["ArrowRight", { row: 0, column: 0 }],
+ ["ArrowLeft", { row: 1, column: 1 }],
+ ] as const)("preserves position at the RTL %s boundary", (key, position) => {
+ expect(moveEmojiPosition(position, key, rows, "rtl")).toBe(position);
+ });
+
it.each(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] as const)(
"preserves position identity for %s when there is no row",
(key) => {
diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts
index 5d7727b85a..93d4618750 100644
--- a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts
+++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts
@@ -82,12 +82,23 @@ export function moveEmojiPosition(
position: EmojiPosition,
key: "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp",
rows: EmojiPickerRow[],
+ direction: "ltr" | "rtl" = "ltr",
): EmojiPosition {
const row = rows[position.row];
if (!row) {
return position;
}
+ // Columns follow DOM order, which runs right-to-left in an RTL grid. Keep
+ // vertical movement and row wrapping unchanged, but mirror horizontal keys.
+ if (direction === "rtl") {
+ if (key === "ArrowLeft") {
+ key = "ArrowRight";
+ } else if (key === "ArrowRight") {
+ key = "ArrowLeft";
+ }
+ }
+
switch (key) {
case "ArrowLeft": {
if (position.column > 0) {
diff --git a/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx
index 9b863bde47..21466b9efa 100644
--- a/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx
+++ b/packages/react/src/components/TableHandles/ExtendButton/ExtendButton.tsx
@@ -17,6 +17,7 @@ import { RiAddFill } from "react-icons/ri";
import { useComponentsContext } from "../../../editor/ComponentsContext.js";
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
+import { useEditorDirection } from "../../../hooks/useEditorDirection.js";
import {
useExtension,
useExtensionState,
@@ -44,6 +45,7 @@ export const ExtendButton = (
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor();
+ const direction = useEditorDirection();
const tableHandles = useExtension(TableHandlesExtension);
const block = useExtensionState(TableHandlesExtension, {
@@ -125,9 +127,10 @@ export const ExtendButton = (
movedMouse.current = true;
const diff =
- (props.orientation === "addOrRemoveColumns"
- ? event.clientX
- : event.clientY) - editingState.startPos;
+ props.orientation === "addOrRemoveColumns"
+ ? (event.clientX - editingState.startPos) *
+ (direction === "rtl" ? -1 : 1)
+ : event.clientY - editingState.startPos;
const numCroppedCells =
props.orientation === "addOrRemoveColumns"
@@ -199,7 +202,7 @@ export const ExtendButton = (
return () => {
window.removeEventListener("mousemove", callback);
};
- }, [block, editingState, editor, props.orientation, tableHandles]);
+ }, [block, direction, editingState, editor, props.orientation, tableHandles]);
// Stops mouse movements from extending columns/rows when the mouse is
// released. Also extends columns/rows by 1 if the mouse wasn't moved enough
diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx
index 4e4d61f10e..b064eb5ec9 100644
--- a/packages/react/src/components/TableHandles/TableHandlesController.tsx
+++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx
@@ -11,6 +11,8 @@ import { FC, useCallback, useMemo, useState } from "react";
import { autoUpdate, offset, ReferenceElement, size } from "@floating-ui/react";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
+import { useEditorDirection } from "../../hooks/useEditorDirection.js";
+import { tableHandleSide } from "./hooks/tableHandlePlacement.js";
import { useExtensionState } from "../../hooks/useExtension.js";
import { PortalElementOverride } from "../../editor/PortalElementOverride.js";
import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js";
@@ -40,6 +42,7 @@ export const TableHandlesController = <
portalElement?: HTMLElement;
}) => {
const editor = useBlockNoteEditor();
+ const direction = useEditorDirection();
const [onlyShownElement, setOnlyShownElement] = useState<
| "rowTableHandle"
@@ -193,7 +196,7 @@ export const TableHandlesController = <
state.show &&
state.rowIndex !== undefined &&
(!onlyShownElement || onlyShownElement === "rowTableHandle"),
- placement: "left",
+ placement: tableHandleSide(direction, "start"),
middleware: [offset(-10)],
whileElementsMounted,
},
@@ -281,7 +284,7 @@ export const TableHandlesController = <
state.showAddOrRemoveColumnsButton &&
(!onlyShownElement ||
onlyShownElement === "extendColumnsButton"),
- placement: "right",
+ placement: tableHandleSide(direction, "end"),
whileElementsMounted,
middleware: [
size({
@@ -304,7 +307,7 @@ export const TableHandlesController = <
},
}
: undefined,
- [onlyShownElement, state, whileElementsMounted],
+ [direction, onlyShownElement, state, whileElementsMounted],
);
if (!state) {
diff --git a/packages/react/src/components/TableHandles/hooks/tableHandlePlacement.ts b/packages/react/src/components/TableHandles/hooks/tableHandlePlacement.ts
new file mode 100644
index 0000000000..3af422c5e1
--- /dev/null
+++ b/packages/react/src/components/TableHandles/hooks/tableHandlePlacement.ts
@@ -0,0 +1,7 @@
+/** Table handles follow inline edges; Floating UI accepts physical sides. */
+export function tableHandleSide(
+ direction: "ltr" | "rtl",
+ edge: "start" | "end",
+): "left" | "right" {
+ return (direction === "ltr") === (edge === "start") ? "left" : "right";
+}
diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx
index 881141c013..f4064a9a80 100644
--- a/packages/react/src/editor/ComponentsContext.tsx
+++ b/packages/react/src/editor/ComponentsContext.tsx
@@ -361,6 +361,7 @@ export type ComponentProps = {
TextInput: {
className?: string;
name: string;
+ dir?: "ltr" | "rtl" | "auto";
label?: string;
variant?: "default" | "large";
icon: ReactNode;
diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css
index 5046c97352..fcc11be3a2 100644
--- a/packages/react/src/editor/styles.css
+++ b/packages/react/src/editor/styles.css
@@ -1,5 +1,11 @@
@import url("@blocknote/core/style.css");
+/* Adapters apply className to either the input or its wrapper. */
+input.bn-embed-url-input,
+.bn-embed-url-input input {
+ direction: ltr;
+}
+
/* Give TipTap's empty content element the contentRef's minimum dimensions so
native input lands inside it, not beside it (#2142). Leave populated content
inline so text and formatting can wrap normally. */
@@ -160,7 +166,7 @@
.bn-block:not(:has(.bn-toggle-wrapper))
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed])::before {
- border-left: 1px solid var(--bn-colors-side-menu);
+ border-inline-start: 1px solid var(--bn-colors-side-menu);
}
/* Placeholder styling */
@@ -527,6 +533,16 @@ SideMenuController offsets its position to keep it centered on the line. */
touch-action: pan-x;
}
+/* Indentation and start/end alignment follow the reading direction. */
+.bn-indent-button:dir(rtl) svg,
+.bn-text-align-button:dir(rtl):is(
+ [data-test="alignTextLeft"],
+ [data-test="alignTextRight"]
+ )
+ svg {
+ transform: scaleX(-1);
+}
+
@media (prefers-reduced-motion: reduce) {
.bn-mobile-formatting-toolbar {
transition: none;
diff --git a/packages/react/src/hooks/useEditorDirection.browser.test.tsx b/packages/react/src/hooks/useEditorDirection.browser.test.tsx
new file mode 100644
index 0000000000..b9a59d529a
--- /dev/null
+++ b/packages/react/src/hooks/useEditorDirection.browser.test.tsx
@@ -0,0 +1,40 @@
+import { BlockNoteEditor } from "@blocknote/core";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, test } from "vite-plus/test";
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { useEditorDirection } from "./useEditorDirection.js";
+
+function Probe() {
+ return ;
+}
+
+test("direction follows committed React dir updates without remounting the editor", async () => {
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const editor = BlockNoteEditor.create();
+ try {
+ let editorDOM: HTMLElement | undefined;
+ for (const direction of ["ltr", "rtl", "ltr"] as const) {
+ flushSync(() =>
+ root.render(
+
+
+ ,
+ ),
+ );
+ await expect
+ .poll(() => container.querySelector("output")?.textContent)
+ .toBe(direction);
+ if (editorDOM) {
+ expect(editor.domElement).toBe(editorDOM);
+ }
+ editorDOM = editor.domElement;
+ }
+ } finally {
+ flushSync(() => root.unmount());
+ editor._tiptapEditor.destroy();
+ container.remove();
+ }
+});
diff --git a/packages/react/src/hooks/useEditorDirection.test.tsx b/packages/react/src/hooks/useEditorDirection.test.tsx
new file mode 100644
index 0000000000..a425ee4d61
--- /dev/null
+++ b/packages/react/src/hooks/useEditorDirection.test.tsx
@@ -0,0 +1,25 @@
+// @vitest-environment node
+import { BlockNoteEditor } from "@blocknote/core";
+import { renderToString } from "react-dom/server";
+import { expect, test } from "vite-plus/test";
+import { BlockNoteContext } from "../editor/BlockNoteContext.js";
+import { useEditorDirection } from "./useEditorDirection.js";
+
+function Probe() {
+ return ;
+}
+
+test("server rendering uses the direction fallback without DOM access", () => {
+ const editor = BlockNoteEditor.create();
+ try {
+ expect(
+ renderToString(
+
+
+ ,
+ ),
+ ).toBe("");
+ } finally {
+ editor._tiptapEditor.destroy();
+ }
+});
diff --git a/packages/react/src/hooks/useEditorDirection.ts b/packages/react/src/hooks/useEditorDirection.ts
new file mode 100644
index 0000000000..d0d1552daf
--- /dev/null
+++ b/packages/react/src/hooks/useEditorDirection.ts
@@ -0,0 +1,19 @@
+import { useEditorDOMElement } from "./useEditorDomElement.js";
+import { useState } from "react";
+import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js";
+
+/** The editor's layout direction, including an inherited `dir` or CSS direction. */
+export function useEditorDirection(): "ltr" | "rtl" {
+ const element = useEditorDOMElement();
+ const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
+ // Read after every commit: React applies dir/style props after rendering.
+ // External DOM mutations without a React render are intentionally unobserved.
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- Direction can change on any commit, without the editor element changing.
+ useIsomorphicLayoutEffect(() => {
+ // eslint-disable-next-line react/set-state-in-effect -- Synchronize with the committed DOM before painting, not derived React data.
+ setDirection(
+ element && getComputedStyle(element).direction === "rtl" ? "rtl" : "ltr",
+ );
+ });
+ return direction;
+}
diff --git a/packages/react/src/hooks/useEditorDomElement.ts b/packages/react/src/hooks/useEditorDomElement.ts
index 2eaf058b8e..b0589257eb 100644
--- a/packages/react/src/hooks/useEditorDomElement.ts
+++ b/packages/react/src/hooks/useEditorDomElement.ts
@@ -18,7 +18,7 @@ export function useEditorDOMElement(editor?: BlockNoteEditor) {
return useEditorState({
editor,
- selector: (ctx) => ctx.editor.domElement,
+ selector: (ctx) => ctx.editor?.domElement,
equalityFn: (a, b) => a === b,
on: "mount",
});
diff --git a/packages/shadcn/src/form/TextInput.tsx b/packages/shadcn/src/form/TextInput.tsx
index 72b5abb6d1..d46b7bf56b 100644
--- a/packages/shadcn/src/form/TextInput.tsx
+++ b/packages/shadcn/src/form/TextInput.tsx
@@ -12,6 +12,7 @@ export const TextInput = forwardRef<
const {
className,
name,
+ dir,
label,
variant: _variant,
icon, // TODO: implement
@@ -55,6 +56,7 @@ export const TextInput = forwardRef<
className={cn(className, "h-auto border-none p-0")}
id={label}
name={name}
+ dir={dir}
placeholder={placeholder}
disabled={disabled}
value={value}
diff --git a/packages/shadcn/src/tableHandle/ExtendButton.tsx b/packages/shadcn/src/tableHandle/ExtendButton.tsx
index 493e6da0f5..2552c23aba 100644
--- a/packages/shadcn/src/tableHandle/ExtendButton.tsx
+++ b/packages/shadcn/src/tableHandle/ExtendButton.tsx
@@ -24,7 +24,7 @@ export const ExtendButton = forwardRef<
className,
"h-full w-full p-0 text-gray-400",
className?.includes("extend-button-add-remove-columns")
- ? "ml-1"
+ ? "ms-1"
: "mt-1",
className?.includes("extend-button-editing")
? "bg-accent text-accent-foreground"
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 4ceffe6f7a..9896ec08e3 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -316,6 +316,24 @@ export const examples = {
readme:
"This example shows how to disable the automatic creation of a trailing block at the end of the editor by setting the `trailingBlock` option to `false`.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)",
},
+ {
+ projectSlug: "rtl",
+ fullSlug: "basic/rtl",
+ pathFromRoot: "examples/01-basic/18-rtl",
+ config: {
+ playground: true,
+ docs: false,
+ author: "",
+ tags: ["Basic"],
+ },
+ title: "Right-to-left (RTL)",
+ group: {
+ pathFromRoot: "examples/01-basic",
+ slug: "basic",
+ },
+ readme:
+ 'A Hebrew-localized editor with Hebrew, Arabic, and mixed English text. Use the Hebrew dictionary, Mantine\'s `DirectionProvider`, and `dir="rtl"` on `BlockNoteView` to localize the UI and mirror the layout while keeping code left-to-right. The provider uses `initialDirection="rtl"` and `detectDirection={false}` so this example stays RTL independently of the surrounding playground.\n\n**Try it out!** Edit the nested lists, open a block menu, edit the link, or type `/` and `:sm` in the empty paragraph to try the slash menu and emoji picker.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Localization](/docs/features/localization)\n- [Mantine RTL setup](https://mantine.dev/styles/rtl/)',
+ },
{
projectSlug: "testing",
fullSlug: "basic/testing",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1df42384e4..9a59a9e083 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1166,6 +1166,49 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/01-basic/18-rtl:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.7.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.7.0)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/01-basic/testing:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/rtl/rtl.test.tsx b/tests/src/end-to-end/rtl/rtl.test.tsx
new file mode 100644
index 0000000000..40e3c25329
--- /dev/null
+++ b/tests/src/end-to-end/rtl/rtl.test.tsx
@@ -0,0 +1,239 @@
+import { type PartialBlock } from "@blocknote/core";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import { afterEach, expect, test } from "vite-plus/test";
+import { cleanup, render } from "vitest-browser-react";
+
+import { page, userEvent } from "../../utils/context.js";
+import { waitForSelector } from "../../utils/editor.js";
+import {
+ waitForEmojiPickerLayout,
+ waitForSelectedEmoji,
+} from "../../utils/emojipicker.js";
+import { mouseSequence, moveMouseOverElement } from "../../utils/mouse.js";
+
+const content: PartialBlock[] = [
+ {
+ id: "intro",
+ type: "paragraph",
+ content: "עברית, العربية ו-English (123).",
+ },
+ {
+ id: "right",
+ type: "image",
+ props: {
+ textAlignment: "right",
+ previewWidth: 80,
+ url: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='40'%3E%3Crect width='80' height='40' fill='%2386b4ef'/%3E%3C/svg%3E",
+ },
+ },
+ { id: "empty", type: "paragraph" },
+];
+
+function Editor({
+ dir,
+ initialContent = content,
+}: {
+ dir: "rtl" | "ltr";
+ initialContent?: PartialBlock[];
+}) {
+ const editor = useCreateBlockNote({ initialContent });
+ return (
+
+
+
+ );
+}
+
+function block(id: string) {
+ const element = document.querySelector(
+ `.bn-block[data-id="${id}"] > .bn-block-content`,
+ );
+ if (!element) {
+ throw new Error(`Missing block ${id}`);
+ }
+ return element;
+}
+
+afterEach(async () => {
+ await mouseSequence([{ type: "move", x: 1, y: 1 }]);
+ await cleanup();
+});
+
+test.each(["rtl", "ltr"] as const)(
+ "%s editor layout and side menu",
+ async (dir) => {
+ await render();
+ await waitForSelector(".bn-editor");
+ const rightBlock = block("right");
+ const image = rightBlock.querySelector("img")!;
+ const end = dir === "rtl" ? "left" : "right";
+ expect
+ .soft(
+ Math.abs(
+ rightBlock.getBoundingClientRect()[end] -
+ image.getBoundingClientRect()[end],
+ ),
+ )
+ .toBeLessThan(1);
+
+ const intro = block("intro").getBoundingClientRect();
+ await mouseSequence([
+ {
+ type: "move",
+ x: intro.x + intro.width / 2,
+ y: intro.y + intro.height / 2,
+ },
+ ]);
+ await expect
+ .poll(() => {
+ const menu = document
+ .querySelector(".bn-side-menu")
+ ?.getBoundingClientRect();
+ return menu
+ ? dir === "rtl"
+ ? menu.left >= intro.right - 1
+ : menu.right <= intro.left + 1
+ : false;
+ })
+ .toBe(true);
+ },
+);
+
+test("RTL emoji navigation follows the visible grid without moving the caret", async () => {
+ await render();
+ await userEvent.click(block("empty"));
+ await userEvent.keyboard(":sm");
+ await waitForEmojiPickerLayout();
+ const first = await waitForSelectedEmoji();
+ const firstX = first.getBoundingClientRect().x;
+ const selection = window.getSelection()!;
+ const anchorNode = selection.anchorNode;
+ const anchorOffset = selection.anchorOffset;
+ await userEvent.keyboard("{ArrowLeft}");
+ await expect
+ .poll(
+ () =>
+ document
+ .querySelector("[aria-rowindex] .bn-frimousse-emoji[data-selected]")
+ ?.getBoundingClientRect().x,
+ )
+ .toBeLessThan(firstX);
+ expect(selection.anchorNode).toBe(anchorNode);
+ expect(selection.anchorOffset).toBe(anchorOffset);
+ const selected = await waitForSelectedEmoji();
+ const emoji = selected.textContent!;
+ await userEvent.keyboard("{Enter}");
+ await expect.poll(() => block("empty").textContent).toBe(`${emoji} `);
+});
+
+test("RTL link editor saves mixed-language URLs and labels", async () => {
+ await render(
+ ,
+ );
+ const link = block("intro").querySelector("a")!.getBoundingClientRect();
+ await mouseSequence([
+ { type: "move", x: link.x + link.width / 2, y: link.y + link.height / 2 },
+ ]);
+ await userEvent.click(
+ page.getByRole("button", { name: "Edit link", exact: true }),
+ );
+ const url = await waitForSelector('input[name="url"]');
+ const title = await waitForSelector('input[name="title"]');
+ await userEvent.fill(url, "https://example.com/שלום?q=123#section");
+ await userEvent.fill(title, "קישור — رابط (123)");
+ await userEvent.keyboard("{Enter}");
+ await expect
+ .poll(() => block("intro").querySelector("a")?.getAttribute("href"))
+ .toBe("https://example.com/שלום?q=123#section");
+ expect(block("intro").querySelector("a")?.textContent).toBe(
+ "קישור — رابط (123)",
+ );
+});
+
+test("RTL image embedding accepts a mixed-language URL", async () => {
+ await render(
+ ,
+ );
+ await userEvent.click(
+ block("media").querySelector(".bn-add-file-button")!,
+ );
+ const input = await waitForSelector('input[data-test="embed-input"]');
+ await userEvent.fill(input, "https://example.com/שלום?q=123#section");
+ await userEvent.keyboard("{Enter}");
+ await expect
+ .poll(() => document.querySelector('input[data-test="embed-input"]'))
+ .toBeNull();
+ await expect
+ .poll(() => block("media").querySelector("img")?.getAttribute("src"))
+ .toBe("https://example.com/שלום?q=123#section");
+});
+
+test.each(["rtl", "ltr"] as const)(
+ "%s table extension adds columns by click and outward drag",
+ async (dir) => {
+ await render(
+ ,
+ );
+ const table = block("table").querySelector("table")!;
+ await moveMouseOverElement(table.querySelectorAll("td")[2]);
+ const columns = await waitForSelector(
+ ".bn-extend-button-add-remove-columns",
+ );
+ await userEvent.click(columns);
+ await expect
+ .poll(() => table.querySelectorAll("tr")[0].children.length)
+ .toBe(4);
+ await moveMouseOverElement(
+ table.querySelectorAll("tr")[0].lastElementChild!,
+ );
+ const extender = await waitForSelector(
+ ".bn-extend-button-add-remove-columns",
+ );
+ const rect = extender.getBoundingClientRect();
+ await mouseSequence([
+ { type: "move", x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
+ { type: "down" },
+ ]);
+ await mouseSequence([
+ {
+ type: "move",
+ x: rect.x + rect.width / 2 + (dir === "rtl" ? -120 : 120),
+ y: rect.y + rect.height / 2,
+ steps: 10,
+ },
+ ]);
+ await mouseSequence([{ type: "up" }]);
+ await expect
+ .poll(() => table.querySelectorAll("tr")[0].children.length)
+ .toBe(5);
+ },
+);
diff --git a/tests/src/utils/emojipicker.ts b/tests/src/utils/emojipicker.ts
index 399d2949d2..19bb6330f7 100644
--- a/tests/src/utils/emojipicker.ts
+++ b/tests/src/utils/emojipicker.ts
@@ -6,7 +6,8 @@ export async function waitForSelectedEmoji() {
return vi.waitFor(
() => {
const btn = document.querySelector(
- `${EMOJI_PICKER_SELECTOR} .bn-frimousse-emoji[data-selected]`,
+ // Ignore Frimousse's hidden measurement row, which repeats one emoji.
+ `${EMOJI_PICKER_SELECTOR} [aria-rowindex] .bn-frimousse-emoji[data-selected]`,
);
const activeDescendant = document
.querySelector(EDITOR_SELECTOR)