From c27a92c2f9603ec81f6cf5fdb3b830a0d62b8aef Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 11 Nov 2026 18:44:41 +0100 Subject: [PATCH 1/2] fix: improve core editor RTL layout and interactions --- examples/01-basic/18-rtl/.bnexample.json | 6 + examples/01-basic/18-rtl/README.md | 11 + examples/01-basic/18-rtl/index.html | 17 ++ examples/01-basic/18-rtl/main.tsx | 11 + examples/01-basic/18-rtl/package.json | 30 +++ examples/01-basic/18-rtl/src/App.tsx | 73 ++++++ examples/01-basic/18-rtl/tsconfig.json | 32 +++ examples/01-basic/18-rtl/vite-env.d.ts | 1 + examples/01-basic/18-rtl/vite.config.ts | 35 +++ packages/ariakit/src/input/TextInput.tsx | 2 + packages/ariakit/src/style.css | 2 +- packages/core/src/editor/Block.css | 61 +++-- .../SideMenu/SideMenu.browser.test.ts | 37 ++- .../core/src/extensions/SideMenu/dragging.ts | 10 +- .../TableHandles/TableHandles.browser.test.ts | 25 ++ .../extensions/TableHandles/TableHandles.ts | 20 +- packages/mantine/src/blocknoteStyles.css | 17 +- packages/mantine/src/form/TextInput.tsx | 2 + .../FilePanel/DefaultTabs/EmbedTab.tsx | 2 +- .../LinkToolbar/EditLinkMenuItems.tsx | 2 + .../SideMenu/SideMenuController.tsx | 17 +- .../EmojiPicker/InlineEmojiPicker.tsx | 15 +- .../EmojiPicker/emojiPickerModel.test.ts | 16 ++ .../EmojiPicker/emojiPickerModel.ts | 11 + .../ExtendButton/ExtendButton.tsx | 11 +- .../TableHandles/TableHandlesController.tsx | 9 +- .../hooks/tableHandlePlacement.ts | 7 + .../react/src/editor/ComponentsContext.tsx | 1 + packages/react/src/editor/styles.css | 8 +- .../hooks/useEditorDirection.browser.test.tsx | 40 +++ .../src/hooks/useEditorDirection.test.tsx | 25 ++ .../react/src/hooks/useEditorDirection.ts | 19 ++ .../react/src/hooks/useEditorDomElement.ts | 2 +- packages/shadcn/src/form/TextInput.tsx | 2 + .../shadcn/src/tableHandle/ExtendButton.tsx | 2 +- playground/src/examples.gen.tsx | 18 ++ pnpm-lock.yaml | 43 ++++ tests/src/end-to-end/rtl/rtl.test.tsx | 238 ++++++++++++++++++ tests/src/utils/emojipicker.ts | 3 +- 39 files changed, 839 insertions(+), 44 deletions(-) create mode 100644 examples/01-basic/18-rtl/.bnexample.json create mode 100644 examples/01-basic/18-rtl/README.md create mode 100644 examples/01-basic/18-rtl/index.html create mode 100644 examples/01-basic/18-rtl/main.tsx create mode 100644 examples/01-basic/18-rtl/package.json create mode 100644 examples/01-basic/18-rtl/src/App.tsx create mode 100644 examples/01-basic/18-rtl/tsconfig.json create mode 100644 examples/01-basic/18-rtl/vite-env.d.ts create mode 100644 examples/01-basic/18-rtl/vite.config.ts create mode 100644 packages/react/src/components/TableHandles/hooks/tableHandlePlacement.ts create mode 100644 packages/react/src/hooks/useEditorDirection.browser.test.tsx create mode 100644 packages/react/src/hooks/useEditorDirection.test.tsx create mode 100644 packages/react/src/hooks/useEditorDirection.ts create mode 100644 tests/src/end-to-end/rtl/rtl.test.tsx 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..bd3d042080 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; } @@ -388,7 +401,7 @@ NESTED BLOCKS } .bn-block-content[data-text-alignment="right"] { - justify-content: flex-end; + justify-content: right; } /* Toggle */ @@ -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,7 +1113,8 @@ NESTED BLOCKS /* TEXT ALIGNMENT */ [data-text-alignment="left"] { - justify-content: flex-start !important; + /* Alignment choices are physical, unlike the surrounding layout. */ + justify-content: left !important; text-align: left !important; } @@ -1095,7 +1124,7 @@ NESTED BLOCKS } [data-text-alignment="right"] { - justify-content: flex-end !important; + justify-content: right !important; text-align: right !important; } 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..4932164393 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -289,28 +289,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, 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 = < } > } 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..cb4484d0cd 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 */ 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 {useEditorDirection()}; +} + +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 {useEditorDirection()}; +} + +test("server rendering uses the direction fallback without DOM access", () => { + const editor = BlockNoteEditor.create(); + try { + expect( + renderToString( + + + , + ), + ).toBe("ltr"); + } 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..e1cb0e6b28 --- /dev/null +++ b/tests/src/end-to-end/rtl/rtl.test.tsx @@ -0,0 +1,238 @@ +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")!; + expect + .soft( + Math.abs( + rightBlock.getBoundingClientRect().right - + image.getBoundingClientRect().right, + ), + ) + .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) From aeb7bd53d2077af41d426cacf74330022db4a7fb Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 12 Nov 2026 16:30:41 +0100 Subject: [PATCH 2/2] fix: map editor alignment to logical reading edges --- docs/content/docs/features/blocks/index.mdx | 2 ++ packages/core/src/editor/Block.css | 12 ++++++------ packages/core/src/editor/editor.css | 2 +- .../extensions/TableHandles/TableHandles.ts | 19 +++++++++++++++++++ .../DefaultButtons/NestBlockButtons.tsx | 4 ++-- packages/react/src/editor/styles.css | 10 ++++++++++ tests/src/end-to-end/rtl/rtl.test.tsx | 5 +++-- 7 files changed, 43 insertions(+), 11 deletions(-) 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/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index bd3d042080..aa92ae5e3c 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -401,7 +401,7 @@ NESTED BLOCKS } .bn-block-content[data-text-alignment="right"] { - justify-content: right; + justify-content: flex-end; } /* Toggle */ @@ -1113,9 +1113,9 @@ NESTED BLOCKS /* TEXT ALIGNMENT */ [data-text-alignment="left"] { - /* Alignment choices are physical, unlike the surrounding layout. */ - justify-content: left !important; - text-align: left !important; + /* Keep the existing values, but align to the reading direction. */ + justify-content: flex-start !important; + text-align: start !important; } [data-text-alignment="center"] { @@ -1124,8 +1124,8 @@ NESTED BLOCKS } [data-text-alignment="right"] { - justify-content: right !important; - text-align: right !important; + justify-content: flex-end !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/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index 4932164393..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"; @@ -1134,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/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx index 7bc87c45ec..ba26542298 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/NestBlockButtons.tsx @@ -58,7 +58,7 @@ export const NestBlockButton = () => { return ( { return (