Skip to content

Commit b2b0fed

Browse files
author
Artem Nistuley
committed
feat: rtl mixed bidi
1 parent f271df9 commit b2b0fed

12 files changed

Lines changed: 1079 additions & 32 deletions

File tree

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5597,9 +5597,13 @@ export class DomPainter {
55975597

55985598
// Pass isLink flag to skip applying inline color/decoration styles for links
55995599
applyRunStyles(elem as HTMLElement, run, isActiveLink);
5600+
const usePerRunRtlDir = shouldAssignPerRunRtlDir({
5601+
runText: textRun.text,
5602+
effectiveText,
5603+
});
56005604
// SD-3098 Word-parity: rtl-tagged runs get dir="rtl" so per-run bidi is isolated;
56015605
// non-rtl date-like runs in RTL context get dir="ltr" to prevent separator drift.
5602-
if (textRun.bidi?.rtl === true) {
5606+
if (textRun.bidi?.rtl === true && usePerRunRtlDir) {
56035607
elem.setAttribute('dir', 'rtl');
56045608
} else if (typeof textRun.text === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(textRun.text)) {
56055609
elem.setAttribute('dir', 'ltr');
@@ -8296,6 +8300,8 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => {
82968300
};
82978301

82988302
const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/;
8303+
const STRONG_RTL_CHAR_RE = /[\u0590-\u08FF]/;
8304+
const LATIN_DIGIT_NEUTRAL_ONLY_RE = /^[\s0-9A-Za-z./\-_:,+()]+$/;
82998305
const RLM = '\u200F';
83008306

83018307
// AIDEV-NOTE: SD-3098 Word-parity workaround for RTL date-like tokens. We inject
@@ -8309,3 +8315,20 @@ const normalizeRtlDateTokenForWordParity = (text: string): string => {
83098315
}
83108316
return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`);
83118317
};
8318+
8319+
const shouldAssignPerRunRtlDir = (opts: { runText: string | undefined; effectiveText: string }): boolean => {
8320+
const sample = (opts.runText ?? opts.effectiveText).trim();
8321+
if (!sample) {
8322+
return true;
8323+
}
8324+
if (RTL_DATE_LIKE_TOKEN_RE.test(sample)) {
8325+
return true;
8326+
}
8327+
if (STRONG_RTL_CHAR_RE.test(sample)) {
8328+
return true;
8329+
}
8330+
if (LATIN_DIGIT_NEUTRAL_ONLY_RE.test(sample)) {
8331+
return false;
8332+
}
8333+
return true;
8334+
};

packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,10 @@ describe('RTL date parity', () => {
6868
expect(span?.textContent).toBe(runText);
6969
});
7070

71-
// SD-3098: mixed runs on the same line - the bidiCompatible merge guard keeps
72-
// them as separate spans, so each can carry its own dir attribute.
73-
it('paints mixed rtl + ltr runs on the same line as separate spans with distinct dir attrs', () => {
71+
// Mixed runs remain separate spans. In RTL paragraphs we now avoid forcing
72+
// per-run dir="rtl" for plain Latin/digit tokens, so numeric rtl-tagged runs
73+
// can inherit paragraph direction without isolated run reordering.
74+
it('paints mixed rtl + ltr runs on the same line as separate spans with date-only ltr override', () => {
7475
const blockId = 'mixed';
7576
const ltrText = '-03-23';
7677
const rtlText = '2026';
@@ -110,13 +111,13 @@ describe('RTL date parity', () => {
110111
expect(spans.length).toBe(2);
111112
expect(spans[0].getAttribute('dir')).toBe('ltr');
112113
expect(spans[0].textContent).toBe(ltrText);
113-
expect(spans[1].getAttribute('dir')).toBe('rtl');
114+
expect(spans[1].getAttribute('dir')).toBeNull();
114115
expect(spans[1].textContent).toBe(rtlText);
115116
});
116117

117-
// SD-3098: rtl-tagged runs that are NOT date-like keep dir="rtl" but get no
118-
// RLM injection. Plain integers (`2026`) don't match the date regex.
119-
it('does not inject RLM into rtl runs whose text is not date-like', () => {
118+
// Rtl-tagged numeric runs in RTL paragraphs do not force dir="rtl" anymore.
119+
// This avoids undesired token inversion in mixed header/footer runs like "copy 2".
120+
it('does not force per-run rtl dir for non-date numeric runs in RTL paragraphs', () => {
120121
const blockId = 'rtl-numeric';
121122
const runText = '2026';
122123
const block: FlowBlock = {
@@ -133,7 +134,7 @@ describe('RTL date parity', () => {
133134
painter.paint(makeLayout(blockId), mount);
134135

135136
const span = mount.querySelector('.superdoc-line span');
136-
expect(span?.getAttribute('dir')).toBe('rtl');
137+
expect(span?.getAttribute('dir')).toBeNull();
137138
expect(span?.textContent).toBe(runText);
138139
expect(span?.textContent).not.toContain('\u200F');
139140
});

packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function computeCaretLayoutRectGeometry(
123123
includeDomFallback = true,
124124
): CaretLayoutRect | null {
125125
if (!layout) return null;
126+
const originalPos = pos;
126127

127128
// Geometry-based calculation from layout engine
128129
let effectivePos = pos;
@@ -214,30 +215,116 @@ export function computeCaretLayoutRectGeometry(
214215
const pageEl = getPageElementByIndex(painterHost ?? null, hit.pageIndex);
215216
const pageRect = pageEl?.getBoundingClientRect();
216217

217-
// Find span containing this pos and measure actual DOM position
218+
if (includeDomFallback && pageRect) {
219+
const selection = pageEl?.ownerDocument?.getSelection();
220+
if (selection?.rangeCount && selection.isCollapsed) {
221+
const nativeRange = selection.getRangeAt(0);
222+
if (typeof nativeRange.getBoundingClientRect === 'function') {
223+
const nativeRect = nativeRange.getBoundingClientRect();
224+
const inPageBounds =
225+
Number.isFinite(nativeRect.left) &&
226+
Number.isFinite(nativeRect.top) &&
227+
Number.isFinite(nativeRect.height) &&
228+
nativeRect.height > 0 &&
229+
nativeRect.left >= pageRect.left - 2 &&
230+
nativeRect.left <= pageRect.right + 2 &&
231+
nativeRect.top >= pageRect.top - 2 &&
232+
nativeRect.top <= pageRect.bottom + 2;
233+
const nativeX = (nativeRect.left - pageRect.left) / zoom;
234+
const nativeY = (nativeRect.top - pageRect.top) / zoom;
235+
const withinGeometrySanity = Math.abs(nativeX - result.x) <= 80;
236+
if (inPageBounds && withinGeometrySanity) {
237+
return {
238+
pageIndex: hit.pageIndex,
239+
x: nativeX,
240+
y: nativeY,
241+
height: line.lineHeight,
242+
};
243+
}
244+
}
245+
}
246+
}
247+
248+
// Find span containing this pos and measure actual DOM position.
249+
// Prefer a local line-scoped lookup first (fast path), then fall back to a
250+
// bounded page-level probe near the target PM position.
218251
let domCaretX: number | null = null;
219252
let domCaretY: number | null = null;
220-
const spanEls = pageEl?.querySelectorAll('span[data-pm-start][data-pm-end]');
221-
for (const spanEl of Array.from(spanEls ?? [])) {
222-
const pmStart = Number((spanEl as HTMLElement).dataset.pmStart);
223-
const pmEnd = Number((spanEl as HTMLElement).dataset.pmEnd);
224-
if (effectivePos >= pmStart && effectivePos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) {
225-
const textNode = spanEl.firstChild as Text;
226-
const charIndex = Math.min(effectivePos - pmStart, textNode.length);
227-
const rangeObj = document.createRange();
228-
rangeObj.setStart(textNode, charIndex);
229-
rangeObj.setEnd(textNode, charIndex);
230-
if (typeof rangeObj.getBoundingClientRect !== 'function') {
231-
break;
232-
}
233-
const rangeRect = rangeObj.getBoundingClientRect();
234-
if (pageRect) {
235-
domCaretX = (rangeRect.left - pageRect.left) / zoom;
236-
domCaretY = (rangeRect.top - pageRect.top) / zoom;
253+
const spanCandidates: HTMLElement[] = [];
254+
const pushUnique = (el: HTMLElement) => {
255+
if (!spanCandidates.includes(el)) spanCandidates.push(el);
256+
};
257+
const collectSpans = (root: ParentNode | null | undefined) => {
258+
if (!root) return;
259+
const spans = root.querySelectorAll('span[data-pm-start][data-pm-end]');
260+
for (const span of Array.from(spans)) {
261+
if (span instanceof HTMLElement) {
262+
pushUnique(span);
237263
}
264+
}
265+
};
266+
267+
const lineEls = pageEl?.querySelectorAll('.superdoc-line[data-pm-start][data-pm-end]');
268+
let localLineEl: HTMLElement | null = null;
269+
for (const lineEl of Array.from(lineEls ?? [])) {
270+
if (!(lineEl instanceof HTMLElement)) continue;
271+
const lineStart = Number(lineEl.dataset.pmStart);
272+
const lineEnd = Number(lineEl.dataset.pmEnd);
273+
if (!Number.isFinite(lineStart) || !Number.isFinite(lineEnd)) continue;
274+
if (effectivePos >= lineStart && effectivePos <= lineEnd) {
275+
localLineEl = lineEl;
238276
break;
239277
}
240278
}
279+
collectSpans(localLineEl);
280+
281+
if (spanCandidates.length === 0) {
282+
const MAX_PM_DISTANCE = 8;
283+
const pageSpans = pageEl?.querySelectorAll('span[data-pm-start][data-pm-end]');
284+
for (const span of Array.from(pageSpans ?? [])) {
285+
if (!(span instanceof HTMLElement)) continue;
286+
const pmStart = Number(span.dataset.pmStart);
287+
const pmEnd = Number(span.dataset.pmEnd);
288+
if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) continue;
289+
if (effectivePos >= pmStart && effectivePos <= pmEnd) {
290+
pushUnique(span);
291+
continue;
292+
}
293+
if (Math.abs(pmStart - effectivePos) <= MAX_PM_DISTANCE || Math.abs(pmEnd - effectivePos) <= MAX_PM_DISTANCE) {
294+
pushUnique(span);
295+
}
296+
}
297+
}
298+
299+
const domProbePositions = Array.from(new Set([originalPos, effectivePos, originalPos - 1, originalPos + 1])).filter(
300+
(candidate) => candidate >= 0,
301+
);
302+
303+
let resolved = false;
304+
for (const probePos of domProbePositions) {
305+
for (const spanEl of spanCandidates) {
306+
const pmStart = Number((spanEl as HTMLElement).dataset.pmStart);
307+
const pmEnd = Number((spanEl as HTMLElement).dataset.pmEnd);
308+
if (probePos >= pmStart && probePos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) {
309+
const textNode = spanEl.firstChild as Text;
310+
const charIndex = Math.min(probePos - pmStart, textNode.length);
311+
const rangeObj = document.createRange();
312+
rangeObj.setStart(textNode, charIndex);
313+
rangeObj.setEnd(textNode, charIndex);
314+
if (typeof rangeObj.getBoundingClientRect !== 'function') {
315+
continue;
316+
}
317+
const rangeRect = rangeObj.getBoundingClientRect();
318+
if (pageRect) {
319+
domCaretX = (rangeRect.left - pageRect.left) / zoom;
320+
domCaretY = (rangeRect.top - pageRect.top) / zoom;
321+
resolved = true;
322+
break;
323+
}
324+
}
325+
}
326+
if (resolved) break;
327+
}
241328

242329
// If we found a DOM caret position, prefer it to avoid residual drift
243330
if (includeDomFallback && domCaretX != null && domCaretY != null) {

0 commit comments

Comments
 (0)