/** * direction — which way a language is written, and where that answer goes. * * ⚠⚠ ONE LIST. Whether a language is written right to left is decided on the * SERVER, by `wire.IsRTL` (backend/pkg/pluginkit/wire/langpack.go — the * primary-language list plus the script subtags), and it reaches the browser * as the `rtl` flag on each row of the offered-language list * (`/api/public/branding` → `ui_locales`, folded in by `lib/uiLocales`). * This file only READS that flag. It must never grow a table of its own: * `Intl.Locale#getTextInfo()` looks like a shortcut and is one — to a second * list (the browser's CLDR copy) that Firefox does not ship at all and that * would disagree with the install review about the same pack. * * The two catalogues the product ships (English, Turkish) are left to right, * and a code nobody offers is left to right: a language whose words are not * on screen does not get to turn the screen around. * * ⚠⚠ THE RULE, for every surface: `dir` follows the language whose words the * surface is showing. * * - A whole page (the admin panel, the drive, the public share/PIN/drop * pages, the signing wizard) carries it on ``, DERIVED from * `` by `syncDocumentDir` — every place that already sets the * page's language (web/src/i18n, PublicLinkPage) sets its direction with * it, without having to know that direction exists. * - An embedded explorer carries it on its own root, from ITS locale — not * from the host page. An Arabic explorer inside an English page reads * right to left; an English explorer inside an Arabic page reads left to * right. Laying English out mirrored because the page around it is Arabic * (or the reverse) scrambles exactly the words the component exists to * show, and the host already chose the component's language by setting * `locale`. * - Anything Teleported out of that root (menus, popovers, the tour, the * quick-look hint) carries `dir` again, because `` is not inside the * explorer and would hand it the HOST's direction. `useLocale().dir` is the * value to bind. * * ⚠⚠ Direction is about the INTERFACE. Document space — a PDF page, an image, * a drawing, a crop box — has its own coordinates and never mirrors (see * lib/pdfFieldsGeom). Nothing here may be applied to those. */ import { computed, watch } from 'vue'; import { availableLocales, localesVersion, normalizeLocaleCode } from './uiLocales'; export type TextDirection = 'ltr' | 'rtl'; /** * The offered codes the server flagged right to left. * * ⚠ Read through a widened row type: the flag is added to * `PublicLocaleOption` by the language-pack platform, and a row that does not * carry it (an older server, the two built-ins) is simply left to right. * `localesVersion` is read so the set is re-derived the moment the list — and * with it a pack's flag — arrives or changes. */ const rtlCodes = computed(() => { void localesVersion.value; const out = new Set(); for (const row of availableLocales() as Array<{ code: string; rtl?: boolean }>) { if (row.rtl === true) out.add(row.code); } return out; }); /** `'rtl'` when the server says this language is written right to left. */ export function localeDir(code: string | null | undefined): TextDirection { const c = normalizeLocaleCode(code ?? ''); return c && rtlCodes.value.has(c) ? 'rtl' : 'ltr'; } /** * Keep `root`'s `dir` derived from its `lang`, for as long as the page lives. * * ⚠ An observer on `lang`, not a call from each place that sets the language: * there are several (the admin panel's language decision, the public link * page, and whatever sets it next), and a direction that each of them had to * remember to set is a direction one of them forgets. The observer's callback * runs as a microtask — before the next paint — so the words and the layout * turn together. * * ⚠ Also re-applied when the language LIST changes: a pack's language can be * on `` before the list saying it is right to left has arrived * (the admin panel holds a stored choice while the list is in flight). * * Only for a host that owns the whole page. An embed never calls this — the * host page's direction is the host's business (see the rule above). * * @returns a stop function (tests, hot reload). */ export function syncDocumentDir( root: HTMLElement | null = typeof document !== 'undefined' ? document.documentElement : null, ): () => void { if (!root) return () => {}; const apply = () => { const want = localeDir(root.getAttribute('lang')); if (root.getAttribute('dir') !== want) root.setAttribute('dir', want); }; apply(); const mo = typeof MutationObserver !== 'undefined' ? new MutationObserver(apply) : null; mo?.observe(root, { attributes: true, attributeFilter: ['lang'] }); const stop = watch(localesVersion, apply); return () => { mo?.disconnect(); stop(); }; } /* ── interaction geometry ─────────────────────────────────────────────── */ /* * ⚠⚠ Pointer events and `getBoundingClientRect()` are PHYSICAL: x grows to the * right whatever the page's direction. Every gesture that means "toward the * end of the line" — widening a column by its trailing edge, moving a column * later, opening a menu beside its anchor — has to translate between the two. * These helpers are that translation, in one place; a component that does its * own `dir === 'rtl' ? … : …` arithmetic is a component that will get one of * the four cases wrong. * * ⚠⚠ Never for DOCUMENT space (a PDF page, an image, a crop): a page's x is * the page's own and is not mirrored (lib/pdfFieldsGeom). */ /** * The direction an element is actually laid out in: its nearest explicit * `dir="ltr|rtl"`. `dir="auto"` (user text isolated with ``-like * behaviour) is skipped — it says nothing about the layout around it. */ export function dirOfElement(el: Element | null | undefined): TextDirection { const host = el?.closest?.('[dir="rtl"], [dir="ltr"]'); return host?.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr'; } /** +1 for the physical x axis in LTR, -1 in RTL: "toward the inline end". */ export function inlineSign(dir: TextDirection): 1 | -1 { return dir === 'rtl' ? -1 : 1; } /** * A horizontal arrow key as a step along the line: +1 = toward the inline END * (→ in LTR, ← in RTL), -1 = toward the start, 0 = not a horizontal arrow. * The key a person presses moves things the way the arrow on it points. */ export function inlineKeyStep(key: string, dir: TextDirection): -1 | 0 | 1 { const physical = key === 'ArrowRight' ? 1 : key === 'ArrowLeft' ? -1 : 0; if (physical === 0) return 0; return (physical * inlineSign(dir)) as -1 | 1; } /** The x of a box's inline START edge — its left in LTR, its right in RTL. */ export function inlineStartX(r: { left: number; right: number }, dir: TextDirection): number { return dir === 'rtl' ? r.right : r.left; } /** The x of a box's inline END edge — its right in LTR, its left in RTL. */ export function inlineEndX(r: { left: number; right: number }, dir: TextDirection): number { return dir === 'rtl' ? r.left : r.right; } /** * The physical `left` of a floating box opened at `anchor` (a viewport x): * it opens toward the inline END — down-right of the pointer in LTR, * down-LEFT in RTL — flips to the other side of the anchor when that would * overflow the viewport, and clamps when neither side fits. * * ⚠ Written ONCE, as the LTR rule, and mirrored by mapping x → viewport − x: * the RTL behaviour is the LTR behaviour seen in a mirror by construction, * and the LTR answer is byte-for-byte what the menus did before RTL existed. */ export function openAlongInline( anchor: number, size: number, viewport: number, dir: TextDirection, margin = 8, ): number { const a = dir === 'rtl' ? viewport - anchor : anchor; let left = a; if (a + size > viewport - margin) { const flipped = a - size; left = flipped >= margin ? flipped : Math.max(margin, viewport - size - margin); } return dir === 'rtl' ? viewport - left - size : left; } /** * Like `openAlongInline`, without the flip: the box starts at `anchor` and is * only pushed back inside the viewport (popovers that hang from a chip and * must stay beside it). Same mirror construction. */ export function clampAlongInline( anchor: number, size: number, viewport: number, dir: TextDirection, margin = 8, ): number { const a = dir === 'rtl' ? viewport - anchor : anchor; const left = a + size > viewport - margin ? Math.max(margin, viewport - margin - size) : a; return dir === 'rtl' ? viewport - left - size : left; } /* ── text that must read left to right inside right-to-left text ───────── */ const LRI = '\u2066'; const RLI = '\u2067'; const FSI = '\u2068'; const PDI = '\u2069'; /** * The runs of a right-to-left sentence that are MACHINE text and read left to * right whatever surrounds them: * * - a number pair — `3 / 10`, `1.2 MB / 5 GB`. Spaced out, the slash is a * neutral between two numbers and the bidi algorithm resolves it to the * paragraph's direction, so an RTL line draws `10 / 3`; * - a `word:value` token — the search syntax `tag:…`, a URL * (`https://…`), a Windows path (`C:\Users\…`), the token syntax a * refusal names (`root:://`). Its colon sits between * Latin and Arabic letters and lands on the wrong side: `tag:وسم` draws * as `وسم:tag`, and `root:://` loses its closing `>` to * the far left of the run, mirrored into `<`; * - an absolute PATH — `/var/lib/filex/report.pdf`. Its LEADING slash is a * neutral between the Arabic before it and the Latin after it, so it * takes the paragraph's direction and is drawn at the RIGHT-hand END of * the run: the line reads `var/lib/filex/report.pdf/`. Measured in * Chromium at `dir="rtl"` (v0.43.0): two rectangles, the second of them * the six pixels of that slash, against one when the run is isolated. * ⚠ ONE segment is a path too — `/informe.pdf`, a file at the root of * a storage, which a notification body names that way. It was left out * (the rule wanted two), and the admin Notifications page drew it as * `informe.pdf/` in Arabic (v0.43.0 pack agent). A single segment is * isolated only when its slash STARTS something: not after a Latin * letter or digit (`and/or`, `km/h`, `24/09`, `TCP/IP`), not after * another slash, dot or colon, and only when the segment holds a Latin * letter or digit — so a lone `/` between two Arabic words, `و/أو`, * and a spaced `3 / 10` (the rule above answers that one) stay as * they are. A trailing full stop is the sentence's, not the path's. * ⚠ And the segment has to END there — no colon after it. In * `/المستندات/tag:2026/…` the slash before `tag` is preceded by an * Arabic letter and so starts a one-segment path; left unchecked it * took `/tag`, and the `word:value` token after it — the run that * actually needs isolating — was cut in two. * * Each is wrapped in LEFT-TO-RIGHT ISOLATE … POP DIRECTIONAL ISOLATE * (U+2066 … U+2069), which works where markup cannot go — an input's * placeholder, a `title`, an `aria-label` — and is invisible everywhere. * A number that `isolateValue` already wrapped still counts: the pattern * steps over an isolate mark on either side of it. * * ⚠ Called ONLY for a right-to-left language — never on its own. `foreignText` * below is the one entry point that knows which languages those are; in LTR * the text comes back untouched, so the English and Turkish strings are * byte-for-byte what they were. * * ⚠⚠ IDEMPOTENT. Text now reaches a screen through more than one of these * gates (a catalogue sentence through `useLocale().t`, a server's sentence * through `foreignText`, a failure said by lib/errorWords, a notification * composed by the reader), and a run that is already isolated must not * collect a second pair of marks — nested isolates draw the same but make a * string that no longer equals the one a test wrote. */ const LTR_RUNS = /[\u2066-\u2069]?\d[\d.,]*(?:\s?[A-Za-z%]{1,3})?[\u2066-\u2069]?\s*\/\s*[\u2066-\u2069]?\d[\d.,]*(?:\s?[A-Za-z%]{1,3})?[\u2066-\u2069]?|[A-Za-z][\w+.-]*:[^\s,\u060C)\u2066-\u2069]+|\/(?:[A-Za-z0-9_.~%+-]+\/)+[A-Za-z0-9_.~%+-]*|(? { if (wrapped(m)) return m; const before = whole[offset - 1]; const after = whole[offset + m.length]; if ((before === LRI || before === FSI) && after === PDI) return m; return LRI + m + PDI; }); } /** * ⚠⚠ TEXT FILEX DID NOT WRITE, made readable in `code`'s direction. * * A sentence the SERVER composed (`server.*`, reaching the browser as the * `message` of a refusal), an installed app's own words, a notification built * from what happened — none of it passes through `useLocale().t` or the admin * panel's post-translation hook, so none of it was isolated. It is exactly * the text that carries machine runs: a path, a URL, a command, a token * syntax. * * Measured in the Arabic admin panel (v0.43.0 pack round, the local Arabic * test pack): `server.token.scope_unknown` names the syntax * `root:://`, and the closing `>` — a neutral between the Latin run and the Arabic after * it — resolved to the paragraph's direction, jumped to the FAR LEFT of the * run and mirrored into `<`, so the line read `…://