@harakah/remotion-arabic 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +4 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +108 -7
- package/package.json +1 -1
- package/src/index.tsx +106 -6
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 Harakah (حركة) contributors
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Joining-aware Arabic text animation for Remotion. Private, not published.
|
|
|
6
6
|
- `splitArabic(text, "letter" | "word")`: drop-in for `text.split("")` / `[...text]` / `Array.from(text)`. It returns ZWJ-padded grapheme tokens with لا kept as one token, tashkeel attached, and spaces as `" "`.
|
|
7
7
|
- `mapArabicWords(splitArabic(text), (ch, i) => <span key={i}>…</span>)`: keeps words together when you animate per letter. Each word goes in a nowrap box, so wrapped lines never break mid-word. Your callback runs unchanged with the original global index, so the stagger is kept. `harakah-fix fix --wrap-words` writes this for you.
|
|
8
8
|
- `<ArabicText text="…" preset="rise" />`: renders with RTL and `lang="ar"`, staggered per letter or per word. Words sit in nowrap boxes so lines never break mid-word.
|
|
9
|
+
- Safari / WebKit (the Player on iPhone, iPad, Mac Safari): with a font that has no U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…), WebKit draws a letter + ZWJ in a fallback font. After mount there, `<ArabicText>` switches each Arabic word to the clip method: every letter box shows the whole word, aria-hidden and clipped to that letter's slice, plus one visually hidden copy for screen readers. It re-measures when fonts load and on resize. Renders (Chromium) and server output keep the ZWJ boxes, byte-for-byte unchanged. `joinMode="zwj" | "clip"` forces one method.
|
|
10
|
+
- في Safari ومع خط لا يحتوي U+200D، يرسم المكوّن الكلمة كاملة في كل صندوق ويقصّها على حدود الحرف، فتبقى متصلة.
|
|
9
11
|
- `formatDigits(n | string, "eastern" | "western" | "keep")` gives `١٬٢٥٠٬٠٠٠`, and leaves Latin runs such as `iPhone 17` untouched.
|
|
10
12
|
- Eastern output always uses ٬ (U+066C) and ٫ (U+066B), whatever the renderer's ICU data.
|
|
11
13
|
- Eastern digits typed with Latin separators (`١,٢٥٠`) are corrected too.
|
|
@@ -15,5 +17,7 @@ Joining-aware Arabic text animation for Remotion. Private, not published.
|
|
|
15
17
|
- يرسم خط Alexandria الفاصلة ٬ بشكل الفاصلة اللاتينية، وهذه الدالة تصلح ذلك.
|
|
16
18
|
- `isolateLatin(text)` adds FSI/PDI around Latin runs.
|
|
17
19
|
|
|
20
|
+
`splitArabic()` returns strings, so it cannot do the clip method: in Safari, its ZWJ tokens join only with fonts that map U+200D (Noto Naskh Arabic does; `harakah-fix doctor` warns about local fonts that don't). For the Player in Safari, prefer `<ArabicText>`, or animate per word there.
|
|
21
|
+
|
|
18
22
|
It is a single self-contained file (`src/index.tsx`), so `harakah-fix fix` can vendor it into a project as `src/harakah-arabic.tsx`.
|
|
19
23
|
Set `REMOTION_HARAKAH_REFERENCE=1` (render-time env) to make `splitArabic` return running text. `harakah-fix compare` uses this for its join verdict.
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
11
11
|
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
12
12
|
*
|
|
13
|
+
* Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
|
|
14
|
+
* the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
|
|
15
|
+
* <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
|
|
16
|
+
* letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
|
|
17
|
+
*
|
|
13
18
|
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
14
19
|
*/
|
|
15
20
|
import React from "react";
|
|
@@ -93,7 +98,16 @@ export interface ArabicTextProps {
|
|
|
93
98
|
tokenStyle?: React.CSSProperties;
|
|
94
99
|
className?: string;
|
|
95
100
|
as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
|
|
101
|
+
/**
|
|
102
|
+
* How letter boxes keep their joins. "auto" (default): ZWJ padding, and in Safari/WebKit outside a render the
|
|
103
|
+
* clip method (the whole word in every box, clipped to the letter's slice), which works with fonts that have no
|
|
104
|
+
* U+200D. "zwj" / "clip" force one. طريقة وصل الحروف: تلقائي (الافتراضي)، أو zwj، أو clip.
|
|
105
|
+
*/
|
|
106
|
+
joinMode?: JoinMode;
|
|
96
107
|
}
|
|
108
|
+
export type JoinMode = "auto" | "zwj" | "clip";
|
|
109
|
+
/** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
|
|
110
|
+
export declare function needsClipJoin(ua?: string): boolean;
|
|
97
111
|
/**
|
|
98
112
|
* Drop-in animated Arabic text: RTL, lang="ar", joined letters, staggered motion.
|
|
99
113
|
* مكوّن نص عربي متحرك: اتجاه من اليمين، lang="ar"، حروف متصلة، وحركة متتابعة.
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* @harakah/remotion-arabic: joining-aware Arabic text animation for Remotion.
|
|
4
4
|
* حركة للنص العربي في Remotion: تحريك حرفاً حرفاً أو كلمة كلمة مع بقاء الحروف متصلة.
|
|
@@ -11,10 +11,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
11
11
|
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
12
12
|
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
13
13
|
*
|
|
14
|
+
* Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
|
|
15
|
+
* the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
|
|
16
|
+
* <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
|
|
17
|
+
* letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
|
|
18
|
+
*
|
|
14
19
|
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
15
20
|
*/
|
|
16
21
|
import React from "react";
|
|
17
|
-
import { continueRender, delayRender, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
22
|
+
import { continueRender, delayRender, Easing, getRemotionEnvironment, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
18
23
|
const ZWJ = "\u200D";
|
|
19
24
|
const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
|
|
20
25
|
const ARABIC_LETTER = /[\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9]/;
|
|
@@ -233,6 +238,56 @@ export function mapArabicWords(tokens, render, wordStyle) {
|
|
|
233
238
|
flush();
|
|
234
239
|
return out;
|
|
235
240
|
}
|
|
241
|
+
/** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
|
|
242
|
+
export function needsClipJoin(ua = typeof navigator === "undefined" ? "" : navigator.userAgent || "") {
|
|
243
|
+
return /AppleWebKit\//.test(ua) && !/(Chrome|Chromium|Edg|OPR|Android)\//.test(ua);
|
|
244
|
+
}
|
|
245
|
+
const PURE_ARABIC = /^[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9\u0640]+$/;
|
|
246
|
+
const FONT_PROPS = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "fontStretch", "fontVariationSettings", "fontFeatureSettings", "fontKerning", "fontVariantLigatures", "fontVariantNumeric", "fontSynthesis", "fontOpticalSizing", "letterSpacing", "wordSpacing", "textRendering"];
|
|
247
|
+
const SR_ONLY = { position: "absolute", width: 1, height: 1, margin: -1, padding: 0, overflow: "hidden", clip: "rect(0 0 0 0)", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 };
|
|
248
|
+
// Measure each token's slice of the plain word (Range on a hidden copy with the token box's computed font), tiled at
|
|
249
|
+
// the midpoints between neighbouring glyph runs so the slices recompose the word exactly.
|
|
250
|
+
function measureWord(first, toks) {
|
|
251
|
+
const word = toks.join("");
|
|
252
|
+
const m = document.createElement("span");
|
|
253
|
+
m.setAttribute("aria-hidden", "true");
|
|
254
|
+
m.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;z-index:-1;white-space:pre;direction:rtl;unicode-bidi:isolate;pointer-events:none;padding:0;border:0;margin:0;transform:none";
|
|
255
|
+
const cs = getComputedStyle(first);
|
|
256
|
+
for (const k of FONT_PROPS)
|
|
257
|
+
try {
|
|
258
|
+
m.style[k] = cs[k];
|
|
259
|
+
}
|
|
260
|
+
catch { /* unsupported */ }
|
|
261
|
+
m.lang = "ar";
|
|
262
|
+
m.textContent = word;
|
|
263
|
+
document.body.appendChild(m);
|
|
264
|
+
try {
|
|
265
|
+
const t = m.firstChild, mr = m.getBoundingClientRect(), W = mr.width, r = document.createRange();
|
|
266
|
+
if (!(W > 0))
|
|
267
|
+
return null;
|
|
268
|
+
let o = 0;
|
|
269
|
+
const sl = [];
|
|
270
|
+
for (let i = 0; i < toks.length; i++) {
|
|
271
|
+
r.setStart(t, o);
|
|
272
|
+
r.setEnd(t, o + toks[i].length);
|
|
273
|
+
o += toks[i].length;
|
|
274
|
+
const b = r.getBoundingClientRect();
|
|
275
|
+
if (!(b.width > 0))
|
|
276
|
+
return null;
|
|
277
|
+
sl.push({ i, l: b.left - mr.left, r: b.right - mr.left });
|
|
278
|
+
}
|
|
279
|
+
const byX = [...sl].sort((a, b) => a.l - b.l), out = new Array(toks.length);
|
|
280
|
+
const pl = parseFloat(cs.paddingLeft) || 0, pt = parseFloat(cs.paddingTop) || 0;
|
|
281
|
+
byX.forEach((q, n) => {
|
|
282
|
+
const s = n ? (byX[n - 1].r + q.l) / 2 : 0, e = n < byX.length - 1 ? (q.r + byX[n + 1].l) / 2 : W;
|
|
283
|
+
out[q.i] = { s, e, W, pl, pt };
|
|
284
|
+
});
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
m.remove();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
236
291
|
const PRESETS = {
|
|
237
292
|
rise: (p) => ({ opacity: p, transform: `translateY(${((1 - p) * 0.45).toFixed(4)}em)` }),
|
|
238
293
|
fade: (p) => ({ opacity: p }),
|
|
@@ -245,7 +300,7 @@ const PRESETS = {
|
|
|
245
300
|
*
|
|
246
301
|
* @example <ArabicText text="صناعة المحتوى العربي" preset="rise" stagger={2} />
|
|
247
302
|
*/
|
|
248
|
-
export const ArabicText = ({ text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false, digits = "eastern", style, tokenStyle, className, as = "div", }) => {
|
|
303
|
+
export const ArabicText = ({ text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false, digits = "eastern", style, tokenStyle, className, as = "div", joinMode = "auto", }) => {
|
|
249
304
|
const frame = useCurrentFrame();
|
|
250
305
|
const { fps } = useVideoConfig();
|
|
251
306
|
const tokens = splitArabic(digits === "keep" ? text : formatDigits(text, digits), by);
|
|
@@ -270,17 +325,63 @@ export const ArabicText = ({ text, by = "letter", delay = 0, stagger, duration =
|
|
|
270
325
|
});
|
|
271
326
|
let n = 0;
|
|
272
327
|
const single = tokens.length === 1;
|
|
273
|
-
|
|
328
|
+
// Clip method (Safari/WebKit): decided after mount, so server output, the first paint and renders stay ZWJ boxes.
|
|
329
|
+
const root = React.useRef(null);
|
|
330
|
+
const [clip, setClip] = React.useState(false);
|
|
331
|
+
const [slices, setSlices] = React.useState({});
|
|
332
|
+
const [epoch, setEpoch] = React.useState(0);
|
|
333
|
+
const clipWords = by === "letter" && !single ? words.map((w, wi) => (w.tokens.length >= 2 && PURE_ARABIC.test(w.tokens.join("").replace(/\u200D/g, "")) ? wi : -1)).filter((wi) => wi >= 0) : [];
|
|
334
|
+
const fontKey = [style, tokenStyle].map((x) => (x ? [x.fontFamily, x.fontSize, x.fontWeight, x.fontStyle, x.letterSpacing, x.fontVariationSettings, x.fontFeatureSettings].join("|") : "")).join("/");
|
|
335
|
+
const textKey = words.map((w) => w.tokens.join("")).join(" ");
|
|
336
|
+
React.useLayoutEffect(() => {
|
|
337
|
+
const on = joinMode === "clip" || (joinMode === "auto" && needsClipJoin() && !getRemotionEnvironment().isRendering);
|
|
338
|
+
setClip(on);
|
|
339
|
+
if (!on)
|
|
340
|
+
return;
|
|
341
|
+
const bump = () => setEpoch((e) => e + 1);
|
|
342
|
+
const fonts = typeof document !== "undefined" ? document.fonts : null;
|
|
343
|
+
fonts?.addEventListener?.("loadingdone", bump);
|
|
344
|
+
fonts?.ready?.then(bump, () => { });
|
|
345
|
+
window.addEventListener("resize", bump);
|
|
346
|
+
return () => { fonts?.removeEventListener?.("loadingdone", bump); window.removeEventListener("resize", bump); };
|
|
347
|
+
}, [joinMode]);
|
|
348
|
+
React.useLayoutEffect(() => {
|
|
349
|
+
if (!clip || !root.current || !clipWords.length) {
|
|
350
|
+
if (Object.keys(slices).length)
|
|
351
|
+
setSlices({});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const next = {};
|
|
355
|
+
for (const wi of clipWords) {
|
|
356
|
+
const first = root.current.querySelector(`[data-hz-word="${wi}"] > [data-hz-tok]`);
|
|
357
|
+
const sl = first && measureWord(first, words[wi].tokens.map((t) => t.replace(/\u200D/g, "")));
|
|
358
|
+
if (sl)
|
|
359
|
+
next[wi] = sl;
|
|
360
|
+
}
|
|
361
|
+
if (JSON.stringify(next) !== JSON.stringify(slices))
|
|
362
|
+
setSlices(next);
|
|
363
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
364
|
+
}, [clip, textKey, fontKey, epoch]);
|
|
365
|
+
return (_jsx(Tag, { ref: root, dir: "rtl", lang: "ar", className: className, style: { direction: "rtl", ...style }, children: parts.map((part, pi) => {
|
|
274
366
|
if (part === " ")
|
|
275
367
|
return _jsx(React.Fragment, { children: " " }, `s${pi}`);
|
|
276
|
-
const w = words[
|
|
368
|
+
const wi = part, w = words[wi], sl = clip ? slices[wi] : undefined;
|
|
369
|
+
const word = sl ? w.tokens.join("").replace(/\u200D/g, "") : "";
|
|
277
370
|
const inner = w.tokens.map((t, k) => {
|
|
278
371
|
const local = frame - delay - n++ * gap;
|
|
279
372
|
const p = springy
|
|
280
373
|
? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
|
|
281
374
|
: interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
|
|
282
|
-
|
|
375
|
+
const motion = animate ? animate(p, w.start + k, t) : PRESETS[preset](p);
|
|
376
|
+
if (sl) {
|
|
377
|
+
// the box keeps the letter's slice width; a hidden strut holds the line height and baseline, and the
|
|
378
|
+
// whole word (aria-hidden) is offset and clipped to the slice; the first box carries the readable copy
|
|
379
|
+
const q = sl[k], plain = t.replace(/\u200D/g, ""), pad = "-0.8em", last = q.e >= q.W - 0.01, firstX = q.s <= 0.01;
|
|
380
|
+
const inset = `inset(${pad} ${last ? pad : `${q.W - q.e}px`} ${pad} ${firstX ? pad : `${q.s}px`})`;
|
|
381
|
+
return (_jsxs("span", { "data-hz-tok": "", style: { display: "inline-block", whiteSpace: "pre", ...tokenStyle, ...motion, position: "relative", boxSizing: "content-box", width: q.e - q.s }, children: [_jsx("span", { "aria-hidden": "true", style: { display: "inline", visibility: "hidden" }, children: plain }), _jsx("span", { "aria-hidden": "true", style: { display: "inline", position: "absolute", left: q.pl - q.s, top: q.pt, width: q.W, whiteSpace: "pre", direction: "rtl", unicodeBidi: "isolate", pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none", margin: 0, padding: 0, border: 0, clipPath: inset, WebkitClipPath: inset }, children: word }), k === 0 ? _jsx("span", { style: SR_ONLY, children: word }) : null] }, k));
|
|
382
|
+
}
|
|
383
|
+
return (_jsx("span", { "data-hz-tok": "", style: { display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...motion }, children: t }, k));
|
|
283
384
|
});
|
|
284
|
-
return single || w.tokens.length === 1 ? _jsx(React.Fragment, { children: inner }, `w${pi}`) : _jsx("span", { style: { display: "inline-block", whiteSpace: "nowrap" }, children: inner }, `w${pi}`);
|
|
385
|
+
return single || w.tokens.length === 1 ? _jsx(React.Fragment, { children: inner }, `w${pi}`) : _jsx("span", { "data-hz-word": wi, style: { display: "inline-block", whiteSpace: "nowrap" }, children: inner }, `w${pi}`);
|
|
285
386
|
}) }));
|
|
286
387
|
};
|
package/package.json
CHANGED
package/src/index.tsx
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* لماذا: تقسيم النص بـ split('') مع inline-block يضع كل حرف في صندوق مستقل فيظهر بشكله المنفصل.
|
|
11
11
|
* الدالة splitArabic() تعيد الحروف مع واصل صفري (ZWJ) وتُبقي «لا» حرفاً واحداً، فتبقى الكلمة متصلة.
|
|
12
12
|
*
|
|
13
|
+
* Safari / WebKit (the Remotion Player on iPhone, iPad and Mac Safari) draws a letter + ZWJ in a fallback font when
|
|
14
|
+
* the font has no cmap entry for U+200D (Alexandria, IBM Plex Sans Arabic, Cairo, Tajawal…). There, after mount,
|
|
15
|
+
* <ArabicText> switches each Arabic word to the clip method: every letter box shows the whole word, clipped to that
|
|
16
|
+
* letter's slice, so the word is shaped once by the font. Renders (Chromium) keep the ZWJ boxes, unchanged.
|
|
17
|
+
*
|
|
13
18
|
* Self-contained (no dependency on harakah-fix) so it can be vendored as a single file.
|
|
14
19
|
*/
|
|
15
20
|
import React from "react";
|
|
16
|
-
import { continueRender, delayRender, Easing, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
21
|
+
import { continueRender, delayRender, Easing, getRemotionEnvironment, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
17
22
|
|
|
18
23
|
const ZWJ = "\u200D";
|
|
19
24
|
const TRANSPARENT = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED]/;
|
|
@@ -247,6 +252,56 @@ export interface ArabicTextProps {
|
|
|
247
252
|
tokenStyle?: React.CSSProperties;
|
|
248
253
|
className?: string;
|
|
249
254
|
as?: "div" | "h1" | "h2" | "h3" | "p" | "span";
|
|
255
|
+
/**
|
|
256
|
+
* How letter boxes keep their joins. "auto" (default): ZWJ padding, and in Safari/WebKit outside a render the
|
|
257
|
+
* clip method (the whole word in every box, clipped to the letter's slice), which works with fonts that have no
|
|
258
|
+
* U+200D. "zwj" / "clip" force one. طريقة وصل الحروف: تلقائي (الافتراضي)، أو zwj، أو clip.
|
|
259
|
+
*/
|
|
260
|
+
joinMode?: JoinMode;
|
|
261
|
+
}
|
|
262
|
+
export type JoinMode = "auto" | "zwj" | "clip";
|
|
263
|
+
|
|
264
|
+
/** Safari / WebKit, where a letter + ZWJ is drawn in a fallback font if the font has no U+200D. */
|
|
265
|
+
export function needsClipJoin(ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent || ""): boolean {
|
|
266
|
+
return /AppleWebKit\//.test(ua) && !/(Chrome|Chromium|Edg|OPR|Android)\//.test(ua);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
type Slice = { s: number; e: number; W: number; pl: number; pt: number };
|
|
270
|
+
const PURE_ARABIC = /^[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0620-\u064A\u066E-\u06D3\u06FA-\u06FF\u0750-\u077F\u08A0-\u08C9\u0640]+$/;
|
|
271
|
+
const FONT_PROPS = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "fontStretch", "fontVariationSettings", "fontFeatureSettings", "fontKerning", "fontVariantLigatures", "fontVariantNumeric", "fontSynthesis", "fontOpticalSizing", "letterSpacing", "wordSpacing", "textRendering"] as const;
|
|
272
|
+
const SR_ONLY: React.CSSProperties = { position: "absolute", width: 1, height: 1, margin: -1, padding: 0, overflow: "hidden", clip: "rect(0 0 0 0)", clipPath: "inset(50%)", whiteSpace: "nowrap", border: 0 };
|
|
273
|
+
|
|
274
|
+
// Measure each token's slice of the plain word (Range on a hidden copy with the token box's computed font), tiled at
|
|
275
|
+
// the midpoints between neighbouring glyph runs so the slices recompose the word exactly.
|
|
276
|
+
function measureWord(first: HTMLElement, toks: string[]): Slice[] | null {
|
|
277
|
+
const word = toks.join("");
|
|
278
|
+
const m = document.createElement("span");
|
|
279
|
+
m.setAttribute("aria-hidden", "true");
|
|
280
|
+
m.style.cssText = "position:fixed;left:0;top:0;visibility:hidden;z-index:-1;white-space:pre;direction:rtl;unicode-bidi:isolate;pointer-events:none;padding:0;border:0;margin:0;transform:none";
|
|
281
|
+
const cs = getComputedStyle(first);
|
|
282
|
+
for (const k of FONT_PROPS) try { (m.style as unknown as Record<string, string>)[k] = (cs as unknown as Record<string, string>)[k]; } catch { /* unsupported */ }
|
|
283
|
+
m.lang = "ar";
|
|
284
|
+
m.textContent = word;
|
|
285
|
+
document.body.appendChild(m);
|
|
286
|
+
try {
|
|
287
|
+
const t = m.firstChild as Text, mr = m.getBoundingClientRect(), W = mr.width, r = document.createRange();
|
|
288
|
+
if (!(W > 0)) return null;
|
|
289
|
+
let o = 0;
|
|
290
|
+
const sl: { i: number; l: number; r: number }[] = [];
|
|
291
|
+
for (let i = 0; i < toks.length; i++) {
|
|
292
|
+
r.setStart(t, o); r.setEnd(t, o + toks[i].length); o += toks[i].length;
|
|
293
|
+
const b = r.getBoundingClientRect();
|
|
294
|
+
if (!(b.width > 0)) return null;
|
|
295
|
+
sl.push({ i, l: b.left - mr.left, r: b.right - mr.left });
|
|
296
|
+
}
|
|
297
|
+
const byX = [...sl].sort((a, b) => a.l - b.l), out: Slice[] = new Array(toks.length);
|
|
298
|
+
const pl = parseFloat(cs.paddingLeft) || 0, pt = parseFloat(cs.paddingTop) || 0;
|
|
299
|
+
byX.forEach((q, n) => {
|
|
300
|
+
const s = n ? (byX[n - 1].r + q.l) / 2 : 0, e = n < byX.length - 1 ? (q.r + byX[n + 1].l) / 2 : W;
|
|
301
|
+
out[q.i] = { s, e, W, pl, pt };
|
|
302
|
+
});
|
|
303
|
+
return out;
|
|
304
|
+
} finally { m.remove(); }
|
|
250
305
|
}
|
|
251
306
|
|
|
252
307
|
const PRESETS: Record<Preset, (p: number) => React.CSSProperties> = {
|
|
@@ -264,7 +319,7 @@ const PRESETS: Record<Preset, (p: number) => React.CSSProperties> = {
|
|
|
264
319
|
*/
|
|
265
320
|
export const ArabicText: React.FC<ArabicTextProps> = ({
|
|
266
321
|
text, by = "letter", delay = 0, stagger, duration = 18, preset = "rise", animate, springy = false,
|
|
267
|
-
digits = "eastern", style, tokenStyle, className, as = "div",
|
|
322
|
+
digits = "eastern", style, tokenStyle, className, as = "div", joinMode = "auto",
|
|
268
323
|
}) => {
|
|
269
324
|
const frame = useCurrentFrame();
|
|
270
325
|
const { fps } = useVideoConfig();
|
|
@@ -283,23 +338,68 @@ export const ArabicText: React.FC<ArabicTextProps> = ({
|
|
|
283
338
|
});
|
|
284
339
|
let n = 0;
|
|
285
340
|
const single = tokens.length === 1;
|
|
341
|
+
// Clip method (Safari/WebKit): decided after mount, so server output, the first paint and renders stay ZWJ boxes.
|
|
342
|
+
const root = React.useRef<HTMLElement | null>(null);
|
|
343
|
+
const [clip, setClip] = React.useState(false);
|
|
344
|
+
const [slices, setSlices] = React.useState<Record<number, Slice[]>>({});
|
|
345
|
+
const [epoch, setEpoch] = React.useState(0);
|
|
346
|
+
const clipWords = by === "letter" && !single ? words.map((w, wi) => (w.tokens.length >= 2 && PURE_ARABIC.test(w.tokens.join("").replace(/\u200D/g, "")) ? wi : -1)).filter((wi) => wi >= 0) : [];
|
|
347
|
+
const fontKey = [style, tokenStyle].map((x) => (x ? [x.fontFamily, x.fontSize, x.fontWeight, x.fontStyle, x.letterSpacing, x.fontVariationSettings, x.fontFeatureSettings].join("|") : "")).join("/");
|
|
348
|
+
const textKey = words.map((w) => w.tokens.join("")).join(" ");
|
|
349
|
+
React.useLayoutEffect(() => {
|
|
350
|
+
const on = joinMode === "clip" || (joinMode === "auto" && needsClipJoin() && !getRemotionEnvironment().isRendering);
|
|
351
|
+
setClip(on);
|
|
352
|
+
if (!on) return;
|
|
353
|
+
const bump = () => setEpoch((e) => e + 1);
|
|
354
|
+
const fonts = typeof document !== "undefined" ? (document.fonts as unknown as EventTarget & { ready?: Promise<unknown> }) : null;
|
|
355
|
+
fonts?.addEventListener?.("loadingdone", bump);
|
|
356
|
+
fonts?.ready?.then(bump, () => {});
|
|
357
|
+
window.addEventListener("resize", bump);
|
|
358
|
+
return () => { fonts?.removeEventListener?.("loadingdone", bump); window.removeEventListener("resize", bump); };
|
|
359
|
+
}, [joinMode]);
|
|
360
|
+
React.useLayoutEffect(() => {
|
|
361
|
+
if (!clip || !root.current || !clipWords.length) { if (Object.keys(slices).length) setSlices({}); return; }
|
|
362
|
+
const next: Record<number, Slice[]> = {};
|
|
363
|
+
for (const wi of clipWords) {
|
|
364
|
+
const first = root.current.querySelector(`[data-hz-word="${wi}"] > [data-hz-tok]`) as HTMLElement | null;
|
|
365
|
+
const sl = first && measureWord(first, words[wi].tokens.map((t) => t.replace(/\u200D/g, "")));
|
|
366
|
+
if (sl) next[wi] = sl;
|
|
367
|
+
}
|
|
368
|
+
if (JSON.stringify(next) !== JSON.stringify(slices)) setSlices(next);
|
|
369
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
370
|
+
}, [clip, textKey, fontKey, epoch]);
|
|
286
371
|
return (
|
|
287
|
-
<Tag dir="rtl" lang="ar" className={className} style={{ direction: "rtl", ...style }}>
|
|
372
|
+
<Tag ref={root} dir="rtl" lang="ar" className={className} style={{ direction: "rtl", ...style }}>
|
|
288
373
|
{parts.map((part, pi) => {
|
|
289
374
|
if (part === " ") return <React.Fragment key={`s${pi}`}>{" "}</React.Fragment>;
|
|
290
|
-
const
|
|
375
|
+
const wi = part as number, w = words[wi], sl = clip ? slices[wi] : undefined;
|
|
376
|
+
const word = sl ? w.tokens.join("").replace(/\u200D/g, "") : "";
|
|
291
377
|
const inner = w.tokens.map((t, k) => {
|
|
292
378
|
const local = frame - delay - n++ * gap;
|
|
293
379
|
const p = springy
|
|
294
380
|
? spring({ frame: local, fps, durationInFrames: duration, config: { damping: 200 } })
|
|
295
381
|
: interpolate(local, [0, duration], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.2, 0.8, 0.2, 1) });
|
|
382
|
+
const motion = animate ? animate(p, w.start + k, t) : PRESETS[preset](p);
|
|
383
|
+
if (sl) {
|
|
384
|
+
// the box keeps the letter's slice width; a hidden strut holds the line height and baseline, and the
|
|
385
|
+
// whole word (aria-hidden) is offset and clipped to the slice; the first box carries the readable copy
|
|
386
|
+
const q = sl[k], plain = t.replace(/\u200D/g, ""), pad = "-0.8em", last = q.e >= q.W - 0.01, firstX = q.s <= 0.01;
|
|
387
|
+
const inset = `inset(${pad} ${last ? pad : `${q.W - q.e}px`} ${pad} ${firstX ? pad : `${q.s}px`})`;
|
|
388
|
+
return (
|
|
389
|
+
<span key={k} data-hz-tok="" style={{ display: "inline-block", whiteSpace: "pre", ...tokenStyle, ...motion, position: "relative", boxSizing: "content-box", width: q.e - q.s }}>
|
|
390
|
+
<span aria-hidden="true" style={{ display: "inline", visibility: "hidden" }}>{plain}</span>
|
|
391
|
+
<span aria-hidden="true" style={{ display: "inline", position: "absolute", left: q.pl - q.s, top: q.pt, width: q.W, whiteSpace: "pre", direction: "rtl", unicodeBidi: "isolate", pointerEvents: "none", userSelect: "none", WebkitUserSelect: "none", margin: 0, padding: 0, border: 0, clipPath: inset, WebkitClipPath: inset }}>{word}</span>
|
|
392
|
+
{k === 0 ? <span style={SR_ONLY}>{word}</span> : null}
|
|
393
|
+
</span>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
296
396
|
return (
|
|
297
|
-
<span key={k} style={{ display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...
|
|
397
|
+
<span key={k} data-hz-tok="" style={{ display: "inline-block", whiteSpace: single ? "normal" : "pre", ...tokenStyle, ...motion }}>
|
|
298
398
|
{t}
|
|
299
399
|
</span>
|
|
300
400
|
);
|
|
301
401
|
});
|
|
302
|
-
return single || w.tokens.length === 1 ? <React.Fragment key={`w${pi}`}>{inner}</React.Fragment> : <span key={`w${pi}`} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{inner}</span>;
|
|
402
|
+
return single || w.tokens.length === 1 ? <React.Fragment key={`w${pi}`}>{inner}</React.Fragment> : <span key={`w${pi}`} data-hz-word={wi} style={{ display: "inline-block", whiteSpace: "nowrap" }}>{inner}</span>;
|
|
303
403
|
})}
|
|
304
404
|
</Tag>
|
|
305
405
|
);
|