diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f0793fbc07db..91acb81d1730 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -202,6 +202,7 @@ import { MarkdownExternalLinkContent } from "./chat/MarkdownExternalLink"; import { MarkdownFileLink } from "./chat/MarkdownFileLink"; import { canUseMarkdownFileShellActions } from "./chat/MarkdownFileLink.logic"; import { handleMarkdownFragmentClick, rehypeHeadingIds } from "./chat/markdownFragments"; +import { rehypeTextDirection } from "./chat/markdownTextDirection"; import { hastHasText, hastPlainTextDeep, plainHastText } from "./chat/markdownHast.logic"; import { extractCodeBlock, @@ -384,11 +385,13 @@ function normalizeMarkdownLinkHrefKey(href: string): string { const CHAT_MARKDOWN_RENDER_REHYPE_PLUGINS = [ ...CHAT_MARKDOWN_REHYPE_PLUGINS, rehypeHeadingIds, + rehypeTextDirection, ] satisfies NonNullable; -const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [rehypeHeadingIds] satisfies NonNullable< - ReactMarkdownOptions["rehypePlugins"] ->; +const CHAT_MARKDOWN_LITERAL_HTML_REHYPE_PLUGINS = [ + rehypeHeadingIds, + rehypeTextDirection, +] satisfies NonNullable; const NO_MARKDOWN_EDITORS: ReadonlyArray = []; @@ -975,7 +978,11 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; } return ( -
+

{props.children}; } +function CommandPaletteTitle({ title }: { title: ReactNode }) { + return ( + + {title} + + ); +} + function DisabledCommandPaletteResultRow(props: { item: CommandPaletteActionItem | CommandPaletteSubmenuItem; }) { @@ -164,7 +173,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + {props.item.threadContentMatch ? ( @@ -178,7 +187,7 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + )} {props.item.titleTrailingContent} @@ -214,7 +223,7 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + {props.item.threadContentMatch ? ( @@ -228,7 +237,7 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + )} {props.item.titleTrailingContent} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 389c97394f37..751e7e36b8f0 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -10,7 +10,8 @@ import { import type { ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; import { createElement, createRef, useEffect, useState } from "react"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; @@ -352,3 +353,63 @@ describe("composer context undo lifetime", () => { }, ); }); + +describe("an Arabic line", () => { + async function focusEnd(editor: Editor) { + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + } + + it("leaves inline code through its start with ArrowRight, which points back", async () => { + // Positions: اكتب at 1-5, sumBy at 6-11, الان at 12-16. + await using screen = await mountEditor("اكتب `sumBy` الان"); + const { editor } = screen; + await focusEnd(editor); + const caret = () => ({ + head: editor.state.selection.head, + inCode: (editor.state.storedMarks ?? editor.state.selection.$head.marks()).length > 0, + }); + const visited = [caret()]; + while (visited.at(-1)!.head > 5) { + const before = visited.at(-1)!; + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => { + const after = caret(); + expect(after.head < before.head || after.inCode !== before.inCode).toBe(true); + }); + visited.push(caret()); + } + expect(visited.some((stop) => stop.inCode)).toBe(true); + }); + + it("shows the backticks on either side of inline code it is editing", async () => { + await using screen = await mountEditor("اكتب `sumBy` الان"); + const { editor } = screen; + await act(async () => { + editor.commands.setTextSelection("اكتب su".length + 1); + }); + const code = editor.view.dom.querySelector("code")!; + const word = document.createRange(); + word.selectNodeContents([...code.childNodes].find((node) => node instanceof Text)!); + const text = word.getBoundingClientRect(); + const [open, close] = [...editor.view.dom.querySelectorAll(".composer-rich-marker")].map( + (marker) => marker.getBoundingClientRect(), + ); + expect(open!.right).toBeLessThanOrEqual(text.left + 1); + expect(close!.left).toBeGreaterThanOrEqual(text.right - 1); + }); + + it("starts the next line on the right", async () => { + await using screen = await mountEditor("اكتب الدالة"); + const { editor } = screen; + await focusEnd(editor); + await userEvent.keyboard("{Shift>}{Enter}{/Shift}"); + await vi.waitFor(() => expect(editor.state.doc.childCount).toBe(2)); + const box = editor.view.dom.getBoundingClientRect(); + expect(editor.view.coordsAtPos(editor.state.selection.head).left).toBeGreaterThan( + box.left + box.width / 2, + ); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index f03335afb9aa..824d061496b9 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -56,6 +56,7 @@ import { pastedPathQueryLength, } from "~/composer-logic"; import { useStableCallback } from "~/hooks/useStableCallback"; +import { caretLineDirection, ComposerTextDirectionExtension } from "~/composer-text-direction"; import { collectComposerPromptInlineTokens, selectionTouchesMentionBoundary, @@ -264,6 +265,7 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { const chip = ( } onClick={() => actions.openMention(path)} aria-label={`Preview ${path}`} @@ -727,6 +729,7 @@ const ComposerMarkerPlugin = new Plugin({ function decorationsForSelection(state: EditorState): DecorationSet { const { doc, selection } = state; const caretSide = caretTakesMarksBefore(state) ? 3 : 0; + const rtl = caretLineDirection(state) === "rtl"; const decorations: Decoration[] = []; if (!selection.empty) { doc.nodesBetween(selection.from, selection.to, (node, pos) => { @@ -750,8 +753,19 @@ function decorationsForSelection(state: EditorState): DecorationSet { marker.className = "composer-rich-marker"; marker.textContent = text; marker.setAttribute("aria-hidden", "true"); + // Inline code is isolated left to right. In a right-to-left line its + // opening backtick joins the closing one inside the isolate, or the line + // would put both on the same side of the code. + const codeMarks = + rtl && baseSide === -1 && /^`+$/.test(text) + ? doc.resolve(at).nodeAfter?.marks.filter((mark) => mark.type.spec.code) + : undefined; decorations.push( - Decoration.widget(at, marker, { side, key: `marker-${at}-${side}-${marker.textContent}` }), + Decoration.widget(at, marker, { + side, + key: `marker-${at}-${side}-${marker.textContent}`, + ...(codeMarks?.length ? { marks: codeMarks } : {}), + }), ); } } @@ -852,6 +866,7 @@ function createComposerExtensions(richText: boolean) { ...(richText ? {} : { bold: false, strike: false }), }), ComposerUndoGroupingExtension, + ComposerTextDirectionExtension, ComposerMentionExtension, ComposerSkillExtension, ComposerCitationExtension, @@ -1146,6 +1161,8 @@ function ComposerPromptEditorTiptapInner( editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { + // In a right-to-left line the start is on the right. + const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; const boundary = event.metaKey ? event.key === "ArrowLeft" || event.key === "ArrowRight" ? "lineboundary" @@ -1171,7 +1188,7 @@ function ComposerPromptEditorTiptapInner( event.preventDefault(); event.stopPropagation(); const backward = - event.key === "Home" || event.key === "ArrowLeft" || event.key === "ArrowUp"; + event.key === "Home" || event.key === "ArrowUp" || event.key === backArrow; selection.modify( event.shiftKey ? "extend" : "move", backward ? "backward" : "forward", @@ -1206,7 +1223,7 @@ function ComposerPromptEditorTiptapInner( view.state.selection.empty ) { const { $from } = view.state.selection; - const direction = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 1; + const direction = event.key === "ArrowUp" || event.key === backArrow ? -1 : 1; const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight"; // At the document's ends, native Up/Down can cross a marker without // changing the document position. Record the stop so DOM selection diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fd646e146c47..56861a5d9a0d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -35,6 +35,7 @@ import { } from "../WorkspaceBreadcrumb"; import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; +import { textDirection } from "~/textDirection"; import { useClientSettings } from "../../hooks/useSettings"; interface ChatHeaderProps { @@ -342,6 +343,7 @@ export const ChatHeader = memo(function ChatHeader({ {renamingTitle !== null ? ( } > -

+

{activeThreadTitle}

} + render={ +

+ } > {activeThreadTitle} diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx index a2ccdc1e470d..2a0dd80354f3 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx +++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx @@ -60,6 +60,7 @@ export function MarkdownCodeBlockFrame({ }) { return ( } className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} @@ -356,6 +357,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ ) : ( } aria-label={`File options for ${label}`} aria-haspopup="menu" diff --git a/apps/web/src/components/chat/markdownTextDirection.test.ts b/apps/web/src/components/chat/markdownTextDirection.test.ts new file mode 100644 index 000000000000..821e1f8a92e1 --- /dev/null +++ b/apps/web/src/components/chat/markdownTextDirection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { rehypeTextDirection } from "./markdownTextDirection"; + +const text = (value: string) => ({ type: "text", value }); +const element = (tagName: string, children: object[]) => ({ + type: "element", + tagName, + properties: {} as Record, + children, +}); + +function directed(...blocks: ReturnType[]) { + rehypeTextDirection()({ type: "root", children: blocks }); + return blocks; +} + +describe("rehypeTextDirection", () => { + it("keeps raw HTML list items apart when counting words", () => { + const [list] = directed( + element("ul", [element("li", [text("مرحبا")]), element("li", [text("hello world")])]), + ); + expect(list!.properties.dir).toBe("ltr"); + }); + + it("leaves inline code out of the count and isolates it", () => { + const code = element("code", [text("useEffect useLayoutEffect React")]); + const [paragraph] = directed(element("p", [text("استخدم "), code, text(" هنا")])); + expect(paragraph!.properties.dir).toBe("rtl"); + expect(code.properties.dir).toBe("ltr"); + }); + + it("leaves code blocks to their own left-to-right frame", () => { + const code = element("code", [text("// احسب المجموع هنا")]); + const [pre] = directed(element("pre", [code])); + expect(pre!.properties.dir).toBeUndefined(); + expect(code.properties.dir).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/chat/markdownTextDirection.ts b/apps/web/src/components/chat/markdownTextDirection.ts new file mode 100644 index 000000000000..ffdbde76c114 --- /dev/null +++ b/apps/web/src/components/chat/markdownTextDirection.ts @@ -0,0 +1,67 @@ +import { textDirection } from "~/textDirection"; + +type HastNode = { + type?: string; + tagName?: string; + value?: string; + properties?: Record; + children?: HastNode[]; +}; + +// Raw HTML such as `
  • a
  • b
  • ` has no whitespace between its +// blocks, so they add their own to keep the words apart. +const BLOCK_TAGS = new Set([ + "blockquote", + "br", + "details", + "div", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "li", + "ol", + "p", + "summary", + "table", + "td", + "th", + "tr", + "ul", +]); + +function proseText(node: HastNode): string { + if (node.type === "text") return node.value ?? ""; + if (node.tagName === "code") return " "; + const text = (node.children ?? []).map(proseText).join(""); + return node.tagName && BLOCK_TAGS.has(node.tagName) ? ` ${text} ` : text; +} + +function isolateInlineCode(node: HastNode): void { + if (node.tagName === "pre") return; + if (node.tagName === "code") { + node.properties = { ...node.properties, dir: "ltr" }; + return; + } + node.children?.forEach(isolateInlineCode); +} + +/** + * Gives each top-level block the direction of its own words, so an Arabic or + * Hebrew reply reads right to left beside English ones. Lines inside a list or + * quote follow it, keeping markers and rules on one side. Inline code is left + * out of the count and isolated left to right, so its punctuation stays put. + */ +export function rehypeTextDirection() { + return (tree: HastNode) => { + for (const block of tree.children ?? []) { + if (block.type !== "element" || block.tagName === "pre") continue; + const dir = textDirection(proseText(block)); + if (dir) block.properties = { ...block.properties, dir }; + } + isolateInlineCode(tree); + }; +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index a73ca0725a92..740a7c8c9987 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -70,6 +70,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampForm import type { SidebarThreadSummary } from "../../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; +import { textDirection } from "~/textDirection"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { animateSidebarLayoutChanges, @@ -940,6 +941,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onRenameTitleChange(event.target.value)} onFocus={(event) => event.currentTarget.select()} @@ -953,7 +955,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {thread.title} diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 35d8b50c202b..3d449a238106 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -126,9 +126,14 @@ function codePairCaretPlugin(type: MarkType) { */ export const ComposerCodeExtension = Code.extend({ excludes: "code", - // ArrowRight leaves code through the caret stops at styled edges, so the - // stock exit (inserting a space at the end of a line) is not needed. + // The forward arrow leaves code through the caret stops at styled edges, so + // the stock exit (inserting a space at the end of a line) is not needed. exitable: false, + // Code reads left to right inside an Arabic or Hebrew line, and the isolate + // keeps its punctuation from trading places with the text around it. + addOptions() { + return { ...this.parent!(), HTMLAttributes: { dir: "ltr" } }; + }, addProseMirrorPlugins() { return [...(this.parent?.() ?? []), codePairCaretPlugin(this.type)]; }, diff --git a/apps/web/src/composer-text-direction.test.ts b/apps/web/src/composer-text-direction.test.ts new file mode 100644 index 000000000000..b8b03fafde47 --- /dev/null +++ b/apps/web/src/composer-text-direction.test.ts @@ -0,0 +1,62 @@ +import { getSchemaByResolvedExtensions, resolveExtensions } from "@tiptap/core"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerCodeExtension, + ComposerListExtensions, +} from "./composer-rich-text-doc"; +import { composerTextDirections } from "./composer-text-direction"; + +const schema = getSchemaByResolvedExtensions( + resolveExtensions([ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + code: false, + }), + ComposerCodeExtension, + ComposerCodeBlockExtension, + ...ComposerListExtensions, + ...ComposerBlockExtensions, + ]), +); + +function directions(value: string) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + return composerTextDirections(doc).blocks; +} + +describe("composerTextDirections", () => { + it("does not count inline code toward a line's direction", () => { + expect(directions("`useEffect` و `useLayoutEffect` في `React`")).toEqual(["rtl"]); + }); + + it("gives a list one direction, from all of its items", () => { + expect(directions("- Vitest\n- اختبر الدالة الجديدة\n- وأضف تعليق")).toEqual(["rtl"]); + }); + + it("continues the line above until a line has letters", () => { + expect(directions("اكتب الدالة\n\n42\nfix it")).toEqual(["rtl", "rtl", "rtl", "ltr"]); + }); + + it("keeps a code block left to right inside Arabic text", () => { + expect(directions("اكتب الدالة\n```\n// احسب مجموع القيم هنا\n```\nثم اختبرها")).toEqual([ + "rtl", + "ltr", + "rtl", + ]); + }); +}); diff --git a/apps/web/src/composer-text-direction.ts b/apps/web/src/composer-text-direction.ts new file mode 100644 index 000000000000..87ac55a86d90 --- /dev/null +++ b/apps/web/src/composer-text-direction.ts @@ -0,0 +1,90 @@ +import { Extension } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import { textDirection, type TextDirection } from "~/textDirection"; + +interface ComposerTextDirections { + /** One entry per top-level block. */ + readonly blocks: ReadonlyArray; + readonly decorations: DecorationSet; +} + +// Nodes are immutable and blocks are shared between documents, so a keystroke +// only recounts the block it changed, and every reader of one document shares +// one result. +const blockDirections = new WeakMap(); +const docDirections = new WeakMap(); + +function proseText(block: ProseMirrorNode): string { + let text = ""; + block.descendants((node) => { + if (node.isText) { + text += node.marks.some((mark) => mark.type.spec.code) ? " " : node.text; + } else if (node.isBlock || node.isAtom) { + text += " "; + } + }); + return text; +} + +function blockDirection(block: ProseMirrorNode): TextDirection | null { + let direction = blockDirections.get(block); + if (direction === undefined) { + direction = textDirection(proseText(block)) ?? null; + blockDirections.set(block, direction); + } + return direction; +} + +/** + * Lines inside a list or quote follow the block around them, which keeps the + * bullets and the quote rule on the same side as the text. A line without + * letters yet continues the one above, so a new line after Arabic starts on + * the right. Code blocks stay left to right. + */ +export function composerTextDirections(doc: ProseMirrorNode): ComposerTextDirections { + const cached = docDirections.get(doc); + if (cached) return cached; + const blocks: Array = []; + const decorations: Decoration[] = []; + let previous: TextDirection | null = null; + doc.forEach((block, offset) => { + if (block.type.spec.code) { + blocks.push("ltr"); + return; + } + const dir = blockDirection(block) ?? previous; + blocks.push(dir); + if (dir) decorations.push(Decoration.node(offset, offset + block.nodeSize, { dir })); + previous = dir; + }); + const directions = { blocks, decorations: DecorationSet.create(doc, decorations) }; + docDirections.set(doc, directions); + return directions; +} + +export function caretLineDirection(state: EditorState): TextDirection | null { + return composerTextDirections(state.doc).blocks[state.selection.$head.index(0)] ?? null; +} + +export const ComposerTextDirectionExtension = Extension.create({ + name: "composer-text-direction", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("composer-text-direction"), + props: { + decorations: (state) => composerTextDirections(state.doc).decorations, + // On Chrome, ProseMirror reads the arrow keys' direction from the + // editor root, so the root follows the line holding the caret. + attributes: (state): Record => { + const dir = caretLineDirection(state); + return dir ? { dir } : {}; + }, + }, + }), + ]; + }, +}); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 354a096d6b23..01fa601ab666 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1848,7 +1848,7 @@ code { custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -1859,7 +1859,7 @@ code { nested ol without its own widened marker doesn't inherit the outer one. */ :is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; - padding-left: var(--list-gutter, 1.25rem); + padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1912,7 +1912,7 @@ code { } .composer-tiptap ul { - padding-left: max(1.25rem, 2ch); + padding-inline-start: max(1.25rem, 2ch); } :is(.chat-markdown, .composer-tiptap) ul ul { @@ -1941,7 +1941,8 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); + margin-block: 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -1979,8 +1980,8 @@ code { } :is(.chat-markdown, .composer-tiptap) blockquote { - border-left: 2px solid var(--contrast-border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--contrast-border); + padding-inline-start: 0.8rem; color: var(--contrast-muted-foreground); } @@ -2132,7 +2133,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th { @@ -2401,7 +2402,7 @@ code { } .composer-tiptap ul[data-type="taskList"] ul[data-type="taskList"] { - padding-left: 1.375rem; + padding-inline-start: 1.375rem; } .composer-tiptap li[data-checked] { diff --git a/apps/web/src/textDirection.test.ts b/apps/web/src/textDirection.test.ts new file mode 100644 index 000000000000..a104133799c5 --- /dev/null +++ b/apps/web/src/textDirection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { textDirection } from "./textDirection"; + +describe("textDirection", () => { + it.each([ + ["Arabic", "اكتب دالة تجمع الأرقام", "rtl"], + ["Hebrew", "תקן את הבאג", "rtl"], + ["English", "fix the failing test", "ltr"], + [ + "Arabic that opens with an English term", + "StrictMode: يعيد الـ render مرتين في التطوير", + "rtl", + ], + ["English quoting one Arabic word", "translate the label مرحبا for the login screen", "ltr"], + ["a tie, by its first word", "React مكتبة", "ltr"], + ["a tie that opens in Arabic", "مكتبة React", "rtl"], + ["Arabic around numbers and punctuation", "عدّل السطر 42، ثم شغّل الاختبارات (3)", "rtl"], + ])("reads %s", (_, text, direction) => { + expect(textDirection(text)).toBe(direction); + }); + + it("leaves text without letters to its surroundings", () => { + expect(textDirection("42 — 7 ✓")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/textDirection.ts b/apps/web/src/textDirection.ts new file mode 100644 index 000000000000..0f5d863a938e --- /dev/null +++ b/apps/web/src/textDirection.ts @@ -0,0 +1,31 @@ +export type TextDirection = "ltr" | "rtl"; + +// Hebrew through Arabic Extended-A, their presentation forms, and the +// supplementary right-to-left blocks. +const RTL_LETTER = /[֐-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; +const LETTER = /\p{L}/u; + +/** + * The base direction for a block of prose, or undefined when it has no letters. + * + * `dir="auto"` follows the first letter, which lays out an Arabic line that + * opens with an English term (`StrictMode: يعيد الـ render مرتين`) left to + * right, and developers write that way constantly. Counting words keeps such a + * line right to left, while English with one quoted Arabic word stays left to + * right. A tie goes to the first word, as `dir="auto"` would. + */ +export function textDirection(text: string): TextDirection | undefined { + let rtlWords = 0; + let ltrWords = 0; + let first: TextDirection | undefined; + for (const word of text.split(/\s+/)) { + const letter = LETTER.exec(word)?.[0]; + if (!letter) continue; + const direction = RTL_LETTER.test(letter) ? "rtl" : "ltr"; + first ??= direction; + if (direction === "rtl") rtlWords += 1; + else ltrWords += 1; + } + if (rtlWords === ltrWords) return first; + return rtlWords > ltrWords ? "rtl" : "ltr"; +}