From 8ce8602149039d4497aca28f210b078a30e87070 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Thu, 14 May 2026 13:57:26 +0300 Subject: [PATCH 1/7] feat: rtl mixed bidi --- .../painters/dom/src/renderer.ts | 25 ++- .../painters/dom/src/rtl-date-parity.test.ts | 17 +- .../selection/CaretGeometry.ts | 123 ++++++++-- .../tests/CaretGeometry.test.ts | 180 ++++++++++++++- .../src/editors/v1/extensions/index.js | 10 +- .../extensions/mixed-bidi-backspace/index.js | 1 + .../mixed-bidi-backspace.js | 162 ++++++++++++++ .../mixed-bidi-backspace.test.js | 200 +++++++++++++++++ .../selection/fixtures/mixed-bidi-1.docx | Bin 0 -> 18089 bytes .../selection/fixtures/mixed-bidi-2.docx | Bin 0 -> 18267 bytes .../selection/rtl-arrow-key-movement.spec.ts | 211 ++++++++++++++++++ .../selection/rtl-click-positioning.spec.ts | 191 ++++++++++++++++ 12 files changed, 1088 insertions(+), 32 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/index.js create mode 100644 packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js create mode 100644 packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js create mode 100644 tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx create mode 100644 tests/behavior/tests/selection/fixtures/mixed-bidi-2.docx diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index d6438595d0..62e937b53b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5597,9 +5597,13 @@ export class DomPainter { // Pass isLink flag to skip applying inline color/decoration styles for links applyRunStyles(elem as HTMLElement, run, isActiveLink); + const usePerRunRtlDir = shouldAssignPerRunRtlDir({ + runText: textRun.text, + effectiveText, + }); // SD-3098 Word-parity: rtl-tagged runs get dir="rtl" so per-run bidi is isolated; // non-rtl date-like runs in RTL context get dir="ltr" to prevent separator drift. - if (textRun.bidi?.rtl === true) { + if (textRun.bidi?.rtl === true && usePerRunRtlDir) { elem.setAttribute('dir', 'rtl'); } else if (typeof textRun.text === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(textRun.text)) { elem.setAttribute('dir', 'ltr'); @@ -8296,6 +8300,8 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { }; const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/; +const STRONG_RTL_CHAR_RE = /[\u0590-\u08FF]/; +const LATIN_DIGIT_NEUTRAL_ONLY_RE = /^[\s0-9A-Za-z./\-_:,+()]+$/; const RLM = '\u200F'; // AIDEV-NOTE: SD-3098 Word-parity workaround for RTL date-like tokens. We inject @@ -8309,3 +8315,20 @@ const normalizeRtlDateTokenForWordParity = (text: string): string => { } return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`); }; + +const shouldAssignPerRunRtlDir = (opts: { runText: string | undefined; effectiveText: string }): boolean => { + const sample = (opts.runText ?? opts.effectiveText).trim(); + if (!sample) { + return true; + } + if (RTL_DATE_LIKE_TOKEN_RE.test(sample)) { + return true; + } + if (STRONG_RTL_CHAR_RE.test(sample)) { + return true; + } + if (LATIN_DIGIT_NEUTRAL_ONLY_RE.test(sample)) { + return false; + } + return true; +}; diff --git a/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts index dc278e4f1c..8d65060e1f 100644 --- a/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts +++ b/packages/layout-engine/painters/dom/src/rtl-date-parity.test.ts @@ -68,9 +68,10 @@ describe('RTL date parity', () => { expect(span?.textContent).toBe(runText); }); - // SD-3098: mixed runs on the same line - the bidiCompatible merge guard keeps - // them as separate spans, so each can carry its own dir attribute. - it('paints mixed rtl + ltr runs on the same line as separate spans with distinct dir attrs', () => { + // Mixed runs remain separate spans. In RTL paragraphs we now avoid forcing + // per-run dir="rtl" for plain Latin/digit tokens, so numeric rtl-tagged runs + // can inherit paragraph direction without isolated run reordering. + it('paints mixed rtl + ltr runs on the same line as separate spans with date-only ltr override', () => { const blockId = 'mixed'; const ltrText = '-03-23'; const rtlText = '2026'; @@ -110,13 +111,13 @@ describe('RTL date parity', () => { expect(spans.length).toBe(2); expect(spans[0].getAttribute('dir')).toBe('ltr'); expect(spans[0].textContent).toBe(ltrText); - expect(spans[1].getAttribute('dir')).toBe('rtl'); + expect(spans[1].getAttribute('dir')).toBeNull(); expect(spans[1].textContent).toBe(rtlText); }); - // SD-3098: rtl-tagged runs that are NOT date-like keep dir="rtl" but get no - // RLM injection. Plain integers (`2026`) don't match the date regex. - it('does not inject RLM into rtl runs whose text is not date-like', () => { + // Rtl-tagged numeric runs in RTL paragraphs do not force dir="rtl" anymore. + // This avoids undesired token inversion in mixed header/footer runs like "copy 2". + it('does not force per-run rtl dir for non-date numeric runs in RTL paragraphs', () => { const blockId = 'rtl-numeric'; const runText = '2026'; const block: FlowBlock = { @@ -133,7 +134,7 @@ describe('RTL date parity', () => { painter.paint(makeLayout(blockId), mount); const span = mount.querySelector('.superdoc-line span'); - expect(span?.getAttribute('dir')).toBe('rtl'); + expect(span?.getAttribute('dir')).toBeNull(); expect(span?.textContent).toBe(runText); expect(span?.textContent).not.toContain('\u200F'); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts index 353bee91e2..903ca2125d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/CaretGeometry.ts @@ -123,6 +123,7 @@ export function computeCaretLayoutRectGeometry( includeDomFallback = true, ): CaretLayoutRect | null { if (!layout) return null; + const originalPos = pos; // Geometry-based calculation from layout engine let effectivePos = pos; @@ -214,30 +215,116 @@ export function computeCaretLayoutRectGeometry( const pageEl = getPageElementByIndex(painterHost ?? null, hit.pageIndex); const pageRect = pageEl?.getBoundingClientRect(); - // Find span containing this pos and measure actual DOM position + if (includeDomFallback && pageRect) { + const selection = pageEl?.ownerDocument?.getSelection(); + if (selection?.rangeCount && selection.isCollapsed) { + const nativeRange = selection.getRangeAt(0); + if (typeof nativeRange.getBoundingClientRect === 'function') { + const nativeRect = nativeRange.getBoundingClientRect(); + const inPageBounds = + Number.isFinite(nativeRect.left) && + Number.isFinite(nativeRect.top) && + Number.isFinite(nativeRect.height) && + nativeRect.height > 0 && + nativeRect.left >= pageRect.left - 2 && + nativeRect.left <= pageRect.right + 2 && + nativeRect.top >= pageRect.top - 2 && + nativeRect.top <= pageRect.bottom + 2; + const nativeX = (nativeRect.left - pageRect.left) / zoom; + const nativeY = (nativeRect.top - pageRect.top) / zoom; + const withinGeometrySanity = Math.abs(nativeX - result.x) <= 80; + if (inPageBounds && withinGeometrySanity) { + return { + pageIndex: hit.pageIndex, + x: nativeX, + y: nativeY, + height: line.lineHeight, + }; + } + } + } + } + + // Find span containing this pos and measure actual DOM position. + // Prefer a local line-scoped lookup first (fast path), then fall back to a + // bounded page-level probe near the target PM position. let domCaretX: number | null = null; let domCaretY: number | null = null; - const spanEls = pageEl?.querySelectorAll('span[data-pm-start][data-pm-end]'); - for (const spanEl of Array.from(spanEls ?? [])) { - const pmStart = Number((spanEl as HTMLElement).dataset.pmStart); - const pmEnd = Number((spanEl as HTMLElement).dataset.pmEnd); - if (effectivePos >= pmStart && effectivePos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) { - const textNode = spanEl.firstChild as Text; - const charIndex = Math.min(effectivePos - pmStart, textNode.length); - const rangeObj = document.createRange(); - rangeObj.setStart(textNode, charIndex); - rangeObj.setEnd(textNode, charIndex); - if (typeof rangeObj.getBoundingClientRect !== 'function') { - break; - } - const rangeRect = rangeObj.getBoundingClientRect(); - if (pageRect) { - domCaretX = (rangeRect.left - pageRect.left) / zoom; - domCaretY = (rangeRect.top - pageRect.top) / zoom; + const spanCandidates: HTMLElement[] = []; + const pushUnique = (el: HTMLElement) => { + if (!spanCandidates.includes(el)) spanCandidates.push(el); + }; + const collectSpans = (root: ParentNode | null | undefined) => { + if (!root) return; + const spans = root.querySelectorAll('span[data-pm-start][data-pm-end]'); + for (const span of Array.from(spans)) { + if (span instanceof HTMLElement) { + pushUnique(span); } + } + }; + + const lineEls = pageEl?.querySelectorAll('.superdoc-line[data-pm-start][data-pm-end]'); + let localLineEl: HTMLElement | null = null; + for (const lineEl of Array.from(lineEls ?? [])) { + if (!(lineEl instanceof HTMLElement)) continue; + const lineStart = Number(lineEl.dataset.pmStart); + const lineEnd = Number(lineEl.dataset.pmEnd); + if (!Number.isFinite(lineStart) || !Number.isFinite(lineEnd)) continue; + if (effectivePos >= lineStart && effectivePos <= lineEnd) { + localLineEl = lineEl; break; } } + collectSpans(localLineEl); + + if (spanCandidates.length === 0) { + const MAX_PM_DISTANCE = 8; + const pageSpans = pageEl?.querySelectorAll('span[data-pm-start][data-pm-end]'); + for (const span of Array.from(pageSpans ?? [])) { + if (!(span instanceof HTMLElement)) continue; + const pmStart = Number(span.dataset.pmStart); + const pmEnd = Number(span.dataset.pmEnd); + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd)) continue; + if (effectivePos >= pmStart && effectivePos <= pmEnd) { + pushUnique(span); + continue; + } + if (Math.abs(pmStart - effectivePos) <= MAX_PM_DISTANCE || Math.abs(pmEnd - effectivePos) <= MAX_PM_DISTANCE) { + pushUnique(span); + } + } + } + + const domProbePositions = Array.from(new Set([originalPos, effectivePos, originalPos - 1, originalPos + 1])).filter( + (candidate) => candidate >= 0, + ); + + let resolved = false; + for (const probePos of domProbePositions) { + for (const spanEl of spanCandidates) { + const pmStart = Number((spanEl as HTMLElement).dataset.pmStart); + const pmEnd = Number((spanEl as HTMLElement).dataset.pmEnd); + if (probePos >= pmStart && probePos <= pmEnd && spanEl.firstChild?.nodeType === Node.TEXT_NODE) { + const textNode = spanEl.firstChild as Text; + const charIndex = Math.min(probePos - pmStart, textNode.length); + const rangeObj = document.createRange(); + rangeObj.setStart(textNode, charIndex); + rangeObj.setEnd(textNode, charIndex); + if (typeof rangeObj.getBoundingClientRect !== 'function') { + continue; + } + const rangeRect = rangeObj.getBoundingClientRect(); + if (pageRect) { + domCaretX = (rangeRect.left - pageRect.left) / zoom; + domCaretY = (rangeRect.top - pageRect.top) / zoom; + resolved = true; + break; + } + } + } + if (resolved) break; + } // If we found a DOM caret position, prefer it to avoid residual drift if (includeDomFallback && domCaretX != null && domCaretY != null) { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts index 74194d9c28..1437799495 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/CaretGeometry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from 'vitest'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; import type { FlowBlock, Layout, Line, Measure, ParaFragment } from '@superdoc/contracts'; import { computeCaretLayoutRectGeometry, type ComputeCaretLayoutRectGeometryDeps } from '../selection/CaretGeometry.js'; @@ -329,6 +329,184 @@ describe('CaretGeometry', () => { expect(result?.pageIndex).toBe(0); }); + it('uses native collapsed selection rect when in page bounds', () => { + const block = createMockParagraphBlock('1-para', 1, 12); + const line = createMockLine(1, 12, 16); + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('1-para', 10, 10, 200, 16, 0, 1, 1, 12); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const pageEl = mockDom.painterHost.querySelector('.superdoc-page') as HTMLElement; + expect(pageEl).toBeTruthy(); + + vi.spyOn(pageEl, 'getBoundingClientRect').mockReturnValue({ + left: 100, + top: 200, + right: 500, + bottom: 700, + width: 400, + height: 500, + x: 100, + y: 200, + toJSON: () => ({}), + } as DOMRect); + + const nativeRange = { + getBoundingClientRect: () => + ({ + left: 150, + top: 230, + right: 150, + bottom: 246, + width: 0, + height: 16, + x: 150, + y: 230, + toJSON: () => ({}), + }) as DOMRect, + }; + + vi.spyOn(pageEl.ownerDocument, 'getSelection').mockReturnValue({ + rangeCount: 1, + isCollapsed: true, + getRangeAt: () => nativeRange as Range, + } as Selection); + + const result = computeCaretLayoutRectGeometry(deps, 5, true); + expect(result).not.toBe(null); + expect(result?.x).toBeCloseTo(50, 3); + expect(result?.y).toBeCloseTo(30, 3); + }); + + it('does not use native selection rect when includeDomFallback is false', () => { + const block = createMockParagraphBlock('1-para', 1, 12); + const line = createMockLine(1, 12, 16); + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('1-para', 10, 10, 200, 16, 0, 1, 1, 12); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const pageEl = mockDom.painterHost.querySelector('.superdoc-page') as HTMLElement; + expect(pageEl).toBeTruthy(); + + vi.spyOn(pageEl, 'getBoundingClientRect').mockReturnValue({ + left: 100, + top: 200, + right: 500, + bottom: 700, + width: 400, + height: 500, + x: 100, + y: 200, + toJSON: () => ({}), + } as DOMRect); + + const nativeRange = { + getBoundingClientRect: () => + ({ + left: 150, + top: 230, + right: 150, + bottom: 246, + width: 0, + height: 16, + x: 150, + y: 230, + toJSON: () => ({}), + }) as DOMRect, + }; + + vi.spyOn(pageEl.ownerDocument, 'getSelection').mockReturnValue({ + rangeCount: 1, + isCollapsed: true, + getRangeAt: () => nativeRange as Range, + } as Selection); + + const result = computeCaretLayoutRectGeometry(deps, 5, false); + expect(result).not.toBe(null); + // includeDomFallback=false should keep geometry-path output, not native DOM selection coordinates. + expect(result?.x).not.toBeCloseTo(50, 3); + expect(result?.y).not.toBeCloseTo(30, 3); + }); + + it('ignores in-bounds native selection rect when it is far from geometry result', () => { + const block = createMockParagraphBlock('1-para', 1, 12); + const line = createMockLine(1, 12, 16); + const measure = createMockParagraphMeasure([line]); + const fragment = createMockParaFragment('1-para', 10, 10, 200, 16, 0, 1, 1, 12); + const layout = createMockLayout(fragment); + + const deps: ComputeCaretLayoutRectGeometryDeps = { + layout, + blocks: [block], + measures: [measure], + painterHost: mockDom.painterHost, + viewportHost: mockDom.viewportHost, + visibleHost: mockDom.visibleHost, + zoom: 1, + }; + + const pageEl = mockDom.painterHost.querySelector('.superdoc-page') as HTMLElement; + expect(pageEl).toBeTruthy(); + + vi.spyOn(pageEl, 'getBoundingClientRect').mockReturnValue({ + left: 100, + top: 200, + right: 500, + bottom: 700, + width: 400, + height: 500, + x: 100, + y: 200, + toJSON: () => ({}), + } as DOMRect); + + const nativeRange = { + getBoundingClientRect: () => + ({ + left: 480, + top: 230, + right: 480, + bottom: 246, + width: 0, + height: 16, + x: 480, + y: 230, + toJSON: () => ({}), + }) as DOMRect, + }; + + vi.spyOn(pageEl.ownerDocument, 'getSelection').mockReturnValue({ + rangeCount: 1, + isCollapsed: true, + getRangeAt: () => nativeRange as Range, + } as Selection); + + const result = computeCaretLayoutRectGeometry(deps, 5, true); + expect(result).not.toBe(null); + // Native rect is in page bounds but implausibly far from geometry; should be ignored. + expect(result?.x).not.toBeCloseTo(380, 3); + }); + it('handles virtualized content (no DOM element available)', () => { const block = createMockParagraphBlock('1-para', 1, 12); const line = createMockLine(1, 12, 16); diff --git a/packages/super-editor/src/editors/v1/extensions/index.js b/packages/super-editor/src/editors/v1/extensions/index.js index 5940506ddc..acf4a1e655 100644 --- a/packages/super-editor/src/editors/v1/extensions/index.js +++ b/packages/super-editor/src/editors/v1/extensions/index.js @@ -26,7 +26,7 @@ import { Text } from './text/index.js'; import { Run } from './run/index.js'; import { Paragraph } from './paragraph/index.js'; import { Heading } from './heading/index.js'; -import { CommentRangeStart, CommentRangeEnd, CommentReference, CommentsMark } from './comment/index.js'; +import { CommentRangeStart, CommentRangeEnd, CommentReference, CommentsMark, CommentsPlugin } from './comment/index.js'; import { FootnoteReference } from './footnote/index.js'; import { EndnoteReference } from './endnote/index.js'; import { TabNode } from './tab/index.js'; @@ -72,11 +72,10 @@ import { Underline } from './underline/index.js'; import { Highlight } from './highlight/index.js'; import { Strike } from './strike/index.js'; import { Link } from './link/index.js'; -import { TrackInsert, TrackDelete, TrackFormat, TrackChanges } from './track-changes/index.js'; +import { TrackInsert, TrackDelete, TrackFormat, TrackChanges, trackChangesHelpers } from './track-changes/index.js'; import { TextTransform } from './text-transform/index.js'; // Plugins -import { CommentsPlugin } from './comment/index.js'; import { Placeholder } from './placeholder/index.js'; import { PopoverPlugin } from './popover-plugin/index.js'; import { LinkedStyles } from './linked-styles/linked-styles.js'; @@ -86,13 +85,13 @@ import { CustomSelection } from './custom-selection/index.js'; import { PermissionRanges } from './permission-ranges/index.js'; import { Protection } from './protection/index.js'; import { VerticalNavigation } from './vertical-navigation/index.js'; +import { MixedBidiBackspace } from './mixed-bidi-backspace/index.js'; // Permissions import { PermStart, PermStartBlock } from './perm-start/index.js'; import { PermEnd, PermEndBlock } from './perm-end/index.js'; // Helpers -import { trackChangesHelpers } from './track-changes/index.js'; import { Diffing } from './diffing/index.js'; const getRichTextExtensions = () => { @@ -133,6 +132,7 @@ const getRichTextExtensions = () => { Image, NodeResizer, CustomSelection, + MixedBidiBackspace, MathInline, MathBlock, PassthroughInline, @@ -234,6 +234,7 @@ const getStarterExtensions = () => { PermissionRanges, Protection, VerticalNavigation, + MixedBidiBackspace, MathInline, MathBlock, PassthroughInline, @@ -323,6 +324,7 @@ export { PassthroughBlock, PermissionRanges, Protection, + MixedBidiBackspace, CrossReference, SequenceField, DocumentStatField, diff --git a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/index.js b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/index.js new file mode 100644 index 0000000000..6918605f83 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/index.js @@ -0,0 +1 @@ +export * from './mixed-bidi-backspace.js'; diff --git a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js new file mode 100644 index 0000000000..894121eef2 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js @@ -0,0 +1,162 @@ +// @ts-nocheck +import { Extension } from '@core/Extension.js'; + +const STRONG_RTL_CHAR_RE = /[\u0590-\u08FF]/; +const STRONG_LTR_CHAR_RE = /[A-Za-z\u00C0-\u024F]/; + +const isStrongRtl = (char) => STRONG_RTL_CHAR_RE.test(char); +const isStrongLtr = (char) => STRONG_LTR_CHAR_RE.test(char); + +const hasMixedDirectionBoundary = (leftChar, rightChar) => + (isStrongRtl(leftChar) && isStrongLtr(rightChar)) || (isStrongLtr(leftChar) && isStrongRtl(rightChar)); + +const resolveCaretPoint = (doc, range) => { + const rect = range.getBoundingClientRect(); + if (rect && Number.isFinite(rect.left) && Number.isFinite(rect.top)) { + // Collapsed ranges may transiently report a zero rect during render lag. + // In that case, fail-open instead of falling back to the parent box, + // which would produce an imprecise X and potentially a wrong boundary. + if (rect.width === 0 && rect.height === 0) { + return null; + } + const midY = rect.height > 0 ? rect.top + rect.height / 2 : rect.top; + return { x: rect.left, y: midY }; + } + + const node = + range.startContainer?.nodeType === Node.TEXT_NODE ? range.startContainer.parentElement : range.startContainer; + if (!node || !(node instanceof HTMLElement)) return null; + const fallbackRect = node.getBoundingClientRect(); + if (!fallbackRect) return null; + return { x: fallbackRect.left, y: fallbackRect.top + fallbackRect.height / 2 }; +}; + +const resolveLineElement = (doc, point) => { + const hit = doc.elementsFromPoint(point.x, point.y); + return hit.find((el) => (el instanceof HTMLElement ? el.classList.contains('superdoc-line') : false)); +}; + +const collectVisualChars = (lineEl, view, targetX = null) => { + const doc = lineEl.ownerDocument; + const nodeFilter = doc.defaultView?.NodeFilter; + if (!nodeFilter) return []; + const chars = []; + const walker = doc.createTreeWalker(lineEl, nodeFilter.SHOW_TEXT); + const RANGE_WINDOW_PX = 96; + const hasTargetX = Number.isFinite(targetX); + let node = walker.nextNode(); + + while (node) { + const textNode = /** @type {Text} */ (node); + const text = textNode.textContent ?? ''; + + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + if (!char || /\s/.test(char)) continue; + + let pmStart; + let pmEnd; + try { + pmStart = view.posAtDOM(textNode, i); + pmEnd = view.posAtDOM(textNode, i + 1); + } catch { + continue; + } + if (!Number.isFinite(pmStart) || !Number.isFinite(pmEnd) || pmEnd <= pmStart) continue; + + const range = doc.createRange(); + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + if (hasTargetX && rect.right < targetX - RANGE_WINDOW_PX) continue; + if (hasTargetX && rect.left > targetX + RANGE_WINDOW_PX) continue; + + chars.push({ + char, + pmStart, + pmEnd, + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, + }); + } + + node = walker.nextNode(); + } + + return chars; +}; + +const resolveBoundaryChars = (chars, caretPoint) => { + if (chars.length === 0) return null; + const sameBand = chars.filter((c) => Math.abs(c.centerY - caretPoint.y) <= 8); + const band = sameBand.length > 0 ? sameBand : chars; + band.sort((a, b) => a.centerX - b.centerX); + + let left = null; + let right = null; + for (const c of band) { + if (c.centerX < caretPoint.x) { + left = c; + continue; + } + right = c; + break; + } + + if (!left || !right) return null; + return { left, right }; +}; + +export const handleMixedBidiBackspace = (editor) => { + const { state, view } = editor; + const { selection } = state; + if (!selection?.empty) return false; + + const doc = view.dom.ownerDocument; + const nativeSelection = doc.getSelection(); + if (!nativeSelection || nativeSelection.rangeCount === 0) return false; + + const range = nativeSelection.getRangeAt(0); + if (!range.collapsed) return false; + + const caretPoint = resolveCaretPoint(doc, range); + if (!caretPoint) return false; + + const lineEl = resolveLineElement(doc, caretPoint); + if (!lineEl) return false; + const lineText = lineEl.textContent ?? ''; + const hasRtl = STRONG_RTL_CHAR_RE.test(lineText); + const hasLtr = STRONG_LTR_CHAR_RE.test(lineText); + if (!hasRtl || !hasLtr) return false; + + let chars = collectVisualChars(lineEl, view, caretPoint.x); + if (chars.length < 2) { + // Fallback to a full scan for correctness when the local window is too narrow. + chars = collectVisualChars(lineEl, view, null); + } + const boundary = resolveBoundaryChars(chars, caretPoint); + if (!boundary) return false; + + if (!hasMixedDirectionBoundary(boundary.left.char, boundary.right.char)) return false; + if (selection.from !== boundary.right.pmStart && selection.from !== boundary.left.pmEnd) return false; + + const tr = state.tr.delete(boundary.left.pmStart, boundary.left.pmEnd).scrollIntoView(); + view.dispatch(tr); + return true; +}; + +export const MixedBidiBackspace = Extension.create({ + name: 'mixedBidiBackspace', + + addShortcuts() { + return { + Backspace: () => handleMixedBidiBackspace(this.editor), + }; + }, +}); + +export const __TEST_ONLY__ = { + resolveCaretPoint, + hasMixedDirectionBoundary, +}; diff --git a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js new file mode 100644 index 0000000000..404677bf50 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest'; +import { __TEST_ONLY__, handleMixedBidiBackspace } from './mixed-bidi-backspace.js'; + +const makeRect = (left, top = 10, width = 8, height = 12) => ({ + left, + top, + width, + height, +}); + +const setupEditor = ({ text, charLefts, caretRect, selectionFrom, pmBase = 10 }) => { + const doc = document.implementation.createHTMLDocument('mixed-bidi-backspace'); + Object.defineProperty(doc, 'defaultView', { + value: { NodeFilter: { SHOW_TEXT: 4 } }, + configurable: true, + }); + const lineEl = doc.createElement('div'); + lineEl.className = 'superdoc-line'; + const textNode = doc.createTextNode(text); + lineEl.appendChild(textNode); + doc.body.appendChild(lineEl); + + doc.elementsFromPoint = vi.fn(() => [lineEl]); + + doc.createRange = vi.fn(() => { + const range = { + _node: null, + _start: 0, + _end: 0, + setStart(node, offset) { + this._node = node; + this._start = offset; + }, + setEnd(node, offset) { + this._node = node; + this._end = offset; + }, + getBoundingClientRect() { + if (this._node !== textNode) return makeRect(0, 0, 0, 0); + const chIndex = this._start; + const left = charLefts[chIndex]; + if (typeof left !== 'number') return makeRect(0, 0, 0, 0); + return makeRect(left); + }, + }; + return range; + }); + + const nativeRange = { + collapsed: true, + getBoundingClientRect: () => caretRect, + startContainer: textNode, + }; + + doc.getSelection = vi.fn(() => ({ + rangeCount: 1, + getRangeAt: () => nativeRange, + })); + + const dispatch = vi.fn(); + const tr = { + delete: vi.fn(() => tr), + scrollIntoView: vi.fn(() => tr), + }; + const view = { + dom: { ownerDocument: doc }, + dispatch, + posAtDOM: vi.fn((node, offset) => { + if (node !== textNode) throw new Error('unexpected node'); + return pmBase + offset; + }), + }; + const editor = { + state: { + selection: { empty: true, from: selectionFrom }, + tr, + }, + view, + }; + + return { editor, tr, dispatch }; +}; + +describe('mixed-bidi-backspace', () => { + it('deletes visual-left char on mixed-direction boundary', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'אA', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(true); + expect(tr.delete).toHaveBeenCalledWith(10, 11); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('fails open on non-mixed boundary (pure LTR)', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'AB', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(false); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + expect(editor.view.posAtDOM).not.toHaveBeenCalled(); + }); + + it('deletes visual-left char on inverse mixed boundary (LTR + RTL)', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'Aא', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(true); + expect(tr.delete).toHaveBeenCalledWith(10, 11); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('fails open when selectionFrom does not match boundary PM position', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'אA', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 999, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(false); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('fails open for punctuation bridge between RTL and LTR', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'א.A', + charLefts: [10, 20, 30], + caretRect: makeRect(30, 10, 1, 12), + selectionFrom: 12, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(false); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('fails open when caret is at visual start (no left char)', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'אA', + charLefts: [10, 20], + caretRect: makeRect(5, 10, 1, 12), + selectionFrom: 10, + pmBase: 10, + }); + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(false); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('fails open for non-collapsed selection', () => { + const { editor, tr, dispatch } = setupEditor({ + text: 'אA', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, + pmBase: 10, + }); + editor.state.selection.empty = false; + + const handled = handleMixedBidiBackspace(editor); + expect(handled).toBe(false); + expect(tr.delete).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('resolveCaretPoint returns null for zero-size rect', () => { + const doc = document.implementation.createHTMLDocument('caret-rect'); + const result = __TEST_ONLY__.resolveCaretPoint(doc, { + getBoundingClientRect: () => makeRect(0, 0, 0, 0), + startContainer: doc.body, + }); + expect(result).toBeNull(); + }); +}); diff --git a/tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx b/tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx new file mode 100644 index 0000000000000000000000000000000000000000..7a50a1df6e63cb792104bb09b9fa6d05021c0e94 GIT binary patch literal 18089 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 UK!W+pYDWX`gO(F5(O+l(4-HO{)c^nh literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/selection/fixtures/mixed-bidi-2.docx b/tests/behavior/tests/selection/fixtures/mixed-bidi-2.docx new file mode 100644 index 0000000000000000000000000000000000000000..96b367db35c55a8fe7a36261212edea773146cd4 GIT binary patch literal 18267 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 kj}=ASa-dqtdYUjzUv&5687h#hev3 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

54vV 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 { const direction = await insertedLine.evaluate((el) => getComputedStyle(el).direction); expect(direction).toBe('rtl'); }); + + test('ArrowLeft/ArrowRight at mixed-bidi boundary moves one visual step', async ({ superdoc }) => { + await superdoc.loadDocument(MIXED_BIDI_DOC_PATH); + await superdoc.waitForStable(); + + const boundaryPoint = await superdoc.page.evaluate(() => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')); + for (const line of lines) { + const spans = Array.from(line.querySelectorAll('span[data-pm-start][data-pm-end]')) as HTMLElement[]; + const rtlSpan = spans.find((span) => /[\u0590-\u05FF\u0600-\u06FF]/.test(span.textContent ?? '')); + const ltrSpan = spans.find((span) => /[A-Za-z]/.test(span.textContent ?? '')); + if (!rtlSpan || !ltrSpan) continue; + const ltrRect = ltrSpan.getBoundingClientRect(); + return { x: ltrRect.left + 2, y: ltrRect.top + ltrRect.height / 2 }; + } + return null; + }); + + expect(boundaryPoint).not.toBeNull(); + if (!boundaryPoint) return; + + await superdoc.page.mouse.click(boundaryPoint.x, boundaryPoint.y); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + const xBefore = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, before.from); + + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.waitForStable(); + + const afterRight = await superdoc.getSelection(); + const xAfterRight = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, afterRight.from); + + await superdoc.page.keyboard.press('ArrowLeft'); + await superdoc.waitForStable(); + + const afterLeft = await superdoc.getSelection(); + const xAfterLeft = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, afterLeft.from); + + expect(afterRight.from).not.toBe(before.from); + expect(Math.abs(xAfterRight - xBefore)).toBeGreaterThan(0.1); + expect(afterLeft.from).toBe(before.from); + expect(Math.abs(xAfterLeft - xBefore)).toBeLessThanOrEqual(1.0); + }); + + test('Shift+Arrow across mixed-bidi boundary keeps split non-overlapping selection rects', async ({ + superdoc, + browserName, + }) => { + test.fixme( + browserName === 'firefox', + 'Firefox paints mixed-bidi boundary selection with a different overlay geometry (no stable split-rect pattern).', + ); + + await superdoc.loadDocument(MIXED_BIDI_DOC_PATH); + await superdoc.waitForStable(); + + const boundaryPoint = await superdoc.page.evaluate(() => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')); + for (const line of lines) { + const spans = Array.from(line.querySelectorAll('span[data-pm-start][data-pm-end]')) as HTMLElement[]; + const rtlSpan = spans.find((span) => /[\u0590-\u05FF\u0600-\u06FF]/.test(span.textContent ?? '')); + const ltrSpan = spans.find((span) => /[A-Za-z]/.test(span.textContent ?? '')); + if (!rtlSpan || !ltrSpan) continue; + const ltrRect = ltrSpan.getBoundingClientRect(); + return { x: ltrRect.left + 2, y: ltrRect.top + ltrRect.height / 2 }; + } + return null; + }); + + expect(boundaryPoint).not.toBeNull(); + if (!boundaryPoint) return; + + await superdoc.page.mouse.click(boundaryPoint.x, boundaryPoint.y); + await superdoc.waitForStable(); + + const evaluateSplitRects = async () => + superdoc.page.evaluate(() => { + const layer = document.querySelector('.presentation-editor__selection-layer--local'); + if (!layer) return { hasSplit: false, rectCount: 0 }; + + const rects = Array.from(layer.children) + .map((child) => (child as HTMLElement).getBoundingClientRect()) + .filter((r) => r.width > 0 && r.height > 0) + .map((r) => ({ x: r.x, y: r.y, width: r.width, height: r.height })); + + const Y_SAME_LINE_THRESHOLD_PX = 3; + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + const a = rects[i]!; + const b = rects[j]!; + if (Math.abs(a.y - b.y) > Y_SAME_LINE_THRESHOLD_PX) continue; + const aRight = a.x + a.width; + const bRight = b.x + b.width; + const overlap = Math.max(0, Math.min(aRight, bRight) - Math.max(a.x, b.x)); + if (overlap === 0) { + return { hasSplit: true, rectCount: rects.length }; + } + } + } + + return { hasSplit: false, rectCount: rects.length }; + }); + + await superdoc.page.keyboard.down('Shift'); + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.page.keyboard.up('Shift'); + await superdoc.waitForStable(); + + const selAfterRight = await superdoc.getSelection(); + expect(selAfterRight.to - selAfterRight.from).toBeGreaterThan(0); + const splitAfterRight = await evaluateSplitRects(); + + await superdoc.page.mouse.click(boundaryPoint.x, boundaryPoint.y); + await superdoc.waitForStable(); + + await superdoc.page.keyboard.down('Shift'); + await superdoc.page.keyboard.press('ArrowLeft'); + await superdoc.page.keyboard.press('ArrowLeft'); + await superdoc.page.keyboard.up('Shift'); + await superdoc.waitForStable(); + + const selAfterLeft = await superdoc.getSelection(); + expect(selAfterLeft.to - selAfterLeft.from).toBeGreaterThan(0); + const splitAfterLeft = await evaluateSplitRects(); + + const bestRectCount = Math.max(splitAfterRight.rectCount, splitAfterLeft.rectCount); + expect(bestRectCount).toBeGreaterThan(0); + expect(splitAfterRight.hasSplit || splitAfterLeft.hasSplit).toBe(true); + }); + + test('Typing Latin in RTL mixed-bidi boundary does not cause caret drift/snap-back', async ({ superdoc }) => { + await superdoc.loadDocument(MIXED_BIDI_DOC_PATH); + await superdoc.waitForStable(); + + const boundaryPoint = await superdoc.page.evaluate(() => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')); + for (const line of lines) { + const spans = Array.from(line.querySelectorAll('span[data-pm-start][data-pm-end]')) as HTMLElement[]; + const rtlSpan = spans.find((span) => /[\u0590-\u05FF\u0600-\u06FF]/.test(span.textContent ?? '')); + const ltrSpan = spans.find((span) => /[A-Za-z]/.test(span.textContent ?? '')); + if (!rtlSpan || !ltrSpan) continue; + const ltrRect = ltrSpan.getBoundingClientRect(); + return { x: ltrRect.left + 2, y: ltrRect.top + ltrRect.height / 2 }; + } + return null; + }); + + expect(boundaryPoint).not.toBeNull(); + if (!boundaryPoint) return; + + const getCaret = async () => { + const sel = await superdoc.getSelection(); + const x = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x ?? null; + }, sel.from); + return { pos: sel.from, x }; + }; + + await superdoc.page.mouse.click(boundaryPoint.x, boundaryPoint.y); + await superdoc.waitForStable(); + + const c0 = await getCaret(); + expect(c0.x).not.toBeNull(); + if (c0.x == null) return; + + await superdoc.page.keyboard.insertText('A'); + await superdoc.waitForStable(); + const c1 = await getCaret(); + + await superdoc.page.keyboard.insertText('B'); + await superdoc.waitForStable(); + const c2 = await getCaret(); + + await superdoc.page.keyboard.insertText('C'); + await superdoc.waitForStable(); + const c3 = await getCaret(); + + expect(c1.x).not.toBeNull(); + expect(c2.x).not.toBeNull(); + expect(c3.x).not.toBeNull(); + if (c1.x == null || c2.x == null || c3.x == null) return; + + const d1 = c1.x - c0.x; + const d2 = c2.x - c1.x; + const d3 = c3.x - c2.x; + + // Boundary ambiguity can yield a zero delta for one keystroke; that's fine. + // Drift/snap-back signal is a direction reversal between non-zero steps. + const nonZeroSigns = [Math.sign(d1), Math.sign(d2), Math.sign(d3)].filter((s) => s !== 0); + if (nonZeroSigns.length >= 2) { + const first = nonZeroSigns[0]!; + expect(nonZeroSigns.every((s) => s === first)).toBe(true); + } + // PM position must still advance with typing even if visual X is near-stationary at boundary. + expect(c1.pos).toBeGreaterThan(c0.pos); + expect(c2.pos).toBeGreaterThan(c1.pos); + expect(c3.pos).toBeGreaterThan(c2.pos); + }); }); diff --git a/tests/behavior/tests/selection/rtl-click-positioning.spec.ts b/tests/behavior/tests/selection/rtl-click-positioning.spec.ts index 0b12e98b70..e4b241e2b3 100644 --- a/tests/behavior/tests/selection/rtl-click-positioning.spec.ts +++ b/tests/behavior/tests/selection/rtl-click-positioning.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from '../../fixtures/superdoc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DOC_PATH = path.resolve(__dirname, 'fixtures/rtl-mixed-bidi.docx'); +const MIXED_BIDI_DOC_PATH = path.resolve(__dirname, 'fixtures/mixed-bidi-2.docx'); test.skip(!fs.existsSync(DOC_PATH), 'RTL fixture not available'); @@ -124,4 +125,194 @@ test.describe('RTL click-to-position mapping', () => { expect(selRight.from).toBeGreaterThan(0); expect(selLeft.from).not.toBe(selRight.from); }); + + test('Backspace at mixed-bidi boundary mutates content from a boundary caret', async ({ superdoc, browserName }) => { + test.fixme( + browserName === 'firefox', + 'Firefox mixed-bidi boundary caret/backspace geometry differs from Chromium/WebKit in this fixture.', + ); + + await superdoc.loadDocument(MIXED_BIDI_DOC_PATH); + await superdoc.waitForStable(); + + const beforeText = await superdoc.getTextContent(); + + const boundaryPoint = await superdoc.page.evaluate(() => { + const lines = Array.from(document.querySelectorAll('.superdoc-line')); + for (const line of lines) { + const spans = Array.from(line.querySelectorAll('span[data-pm-start][data-pm-end]')) as HTMLElement[]; + const rtlSpan = spans.find((span) => /[\u0590-\u05FF\u0600-\u06FF]/.test(span.textContent ?? '')); + const ltrSpan = spans.find((span) => /[A-Za-z]/.test(span.textContent ?? '')); + if (!rtlSpan || !ltrSpan) continue; + + const ltrRect = ltrSpan.getBoundingClientRect(); + return { + x: ltrRect.left + 2, + y: ltrRect.top + ltrRect.height / 2, + }; + } + return null; + }); + + expect(boundaryPoint).not.toBeNull(); + if (!boundaryPoint) return; + + await superdoc.page.mouse.click(boundaryPoint.x, boundaryPoint.y); + await superdoc.waitForStable(); + + const beforeSel = await superdoc.getSelection(); + expect(beforeSel.from).toBe(beforeSel.to); + + const boundaryChars = await superdoc.page.evaluate(({ x, y }) => { + const resolveAt = (probeX: number) => { + const lineEl = document + .elementsFromPoint(probeX, y) + .find((el) => (el as HTMLElement).classList?.contains('superdoc-line')) as HTMLElement | undefined; + if (!lineEl) return null; + + type CharBox = { char: string; left: number; right: number; centerX: number; centerY: number }; + const chars: CharBox[] = []; + const doc = document; + const walker = doc.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (!ch || /\s/.test(ch)) continue; + const range = doc.createRange(); + range.setStart(node, i); + range.setEnd(node, i + 1); + const rect = range.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + chars.push({ + char: ch, + left: rect.left, + right: rect.right, + centerX: rect.left + rect.width / 2, + centerY: rect.top + rect.height / 2, + }); + } + } + node = walker.nextNode() as Text | null; + } + + const sameVisualBand = chars.filter((c) => Math.abs(c.centerY - y) <= 6); + const band = sameVisualBand.length > 0 ? sameVisualBand : chars; + if (band.length === 0) return null; + + band.sort((a, b) => a.centerX - b.centerX); + + let leftChar: CharBox | null = null; + let rightChar: CharBox | null = null; + let leftIndex = -1; + for (const c of band) { + if (c.centerX < probeX) { + if (!leftChar || c.centerX > leftChar.centerX) { + leftChar = c; + leftIndex = band.indexOf(c); + } + } + if (c.centerX >= probeX) { + if (!rightChar || c.centerX < rightChar.centerX) rightChar = c; + } + } + + return { + linePmStart: lineEl.getAttribute('data-pm-start'), + linePmEnd: lineEl.getAttribute('data-pm-end'), + visualSequence: band.map((c) => c.char).join(''), + visualLeftIndex: leftIndex, + visualLeftChar: leftChar?.char ?? null, + visualRightChar: rightChar?.char ?? null, + }; + }; + + const probes = [0, -1, -2, 1]; + for (const dx of probes) { + const resolved = resolveAt(x + dx); + if (resolved?.visualLeftChar) return resolved; + } + return resolveAt(x); + }, boundaryPoint); + + expect(boundaryChars).not.toBeNull(); + expect(boundaryChars?.visualLeftChar).not.toBeNull(); + + await superdoc.press('Backspace'); + await superdoc.waitForStable(); + + const afterText = await superdoc.getTextContent(); + const afterSel = await superdoc.getSelection(); + + expect(afterText).not.toBe(beforeText); + expect(afterText.length).toBe(beforeText.length - 1); + expect(afterSel.from).toBe(afterSel.to); + + const countChar = (text: string, char: string): number => { + let count = 0; + for (const ch of text) if (ch === char) count++; + return count; + }; + + const deletedChar = boundaryChars?.visualLeftChar; + if (deletedChar) { + const beforeDeletedCount = countChar(beforeText, deletedChar); + const afterDeletedCount = countChar(afterText, deletedChar); + expect(afterDeletedCount).toBe(beforeDeletedCount - 1); + } + + const controlChar = boundaryChars?.visualRightChar; + if (controlChar && controlChar !== deletedChar) { + const beforeControlCount = countChar(beforeText, controlChar); + const afterControlCount = countChar(afterText, controlChar); + expect(afterControlCount).toBe(beforeControlCount); + } + + const afterBoundaryLine = await superdoc.page.evaluate((linePmStart) => { + const lineEl = Array.from(document.querySelectorAll('.superdoc-line')).find( + (line) => line.getAttribute('data-pm-start') === linePmStart, + ) as HTMLElement | undefined; + if (!lineEl) return null; + + type CharBox = { char: string; centerX: number; centerY: number }; + const chars: CharBox[] = []; + const doc = document; + const walker = doc.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const text = node.textContent ?? ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (!ch || /\s/.test(ch)) continue; + const range = doc.createRange(); + range.setStart(node, i); + range.setEnd(node, i + 1); + const rect = range.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + chars.push({ char: ch, centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2 }); + } + } + node = walker.nextNode() as Text | null; + } + if (chars.length === 0) return ''; + + const centerY = chars.reduce((sum, c) => sum + c.centerY, 0) / chars.length; + const band = chars.filter((c) => Math.abs(c.centerY - centerY) <= 6); + const target = band.length > 0 ? band : chars; + target.sort((a, b) => a.centerX - b.centerX); + return target.map((c) => c.char).join(''); + }, boundaryChars?.linePmStart ?? null); + + if ( + boundaryChars?.visualSequence && + boundaryChars.visualLeftIndex >= 0 && + boundaryChars.visualLeftIndex < boundaryChars.visualSequence.length + ) { + const expectedSequence = + boundaryChars.visualSequence.slice(0, boundaryChars.visualLeftIndex) + + boundaryChars.visualSequence.slice(boundaryChars.visualLeftIndex + 1); + expect(afterBoundaryLine).toBe(expectedSequence); + } + }); }); From f87cd083202af0d565796a6a860b0c68aa1e002d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 09:05:58 -0300 Subject: [PATCH 2/7] refactor(super-editor): route mixed-bidi Backspace through handleBackspace chain The MixedBidiBackspace extension previously bound Backspace via addShortcuts and dispatched its own transaction. That bypassed the canonical handleBackspace chain in core/extensions/keymap.js, skipping: - dispatchHistoryBoundary (so undo grouping diverged from regular Backspace), - the tr.setMeta('inputType', 'deleteContentBackward') hop (tracked-changes helpers gate Backspace-specific wrapping on this meta - see trackChangesHelpers/trackedTransaction.js:447, trackChangesHelpers/replaceAroundStep.js:162), - deleteBlockSdtAtTextBlockStart for SDT block boundaries, - the rest of the specialized run-aware ladder. Refactor: - Expose mixedBidiBackspace as a chain command via addCommands(); shape matches the existing backspaceAcrossRuns / backspaceNextToRun pattern (factory returning ({state, view, tr, dispatch}) => boolean). - Insert into the Backspace chain in keymap.js after backspaceAcrossRuns (specialized cross-run handling) and before deleteSelection (generic fallback), guarded with `?? false` so the chain works when the extension is unregistered. - Split the detection logic into pure resolveMixedBidiBackspaceRange so the command body stays tiny. - Tests rewritten to call the chain command shape directly and pin: chain dry-run mode (dispatch=undefined) returns true without mutating tr, non-mixed boundaries fall through, both RTL+LTR and LTR+RTL boundaries handled, posAtDOM is skipped early for pure-LTR/RTL lines. --- .../src/editors/v1/core/extensions/keymap.js | 1 + .../mixed-bidi-backspace.js | 68 +++++++--- .../mixed-bidi-backspace.test.js | 124 +++++++----------- 3 files changed, 102 insertions(+), 91 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index 3cd3c93eb1..f094d10b7e 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -43,6 +43,7 @@ export const handleBackspace = (editor) => { () => commands.backspaceAtomBefore(), () => commands.backspaceNextToRun(), () => commands.backspaceAcrossRuns(), + () => commands.mixedBidiBackspace?.() ?? false, () => commands.deleteSelection(), () => commands.removeNumberingProperties(), () => commands.joinBackward(), diff --git a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js index 894121eef2..1a8b0f5fe2 100644 --- a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js +++ b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.js @@ -108,27 +108,35 @@ const resolveBoundaryChars = (chars, caretPoint) => { return { left, right }; }; -export const handleMixedBidiBackspace = (editor) => { - const { state, view } = editor; +/** + * Compute the visual-left delete range at a mixed-bidi RTL/LTR boundary. + * + * Returns the PM-position range to delete, or `null` when the caret is not + * on a mixed-direction boundary. Pure: does not mutate state or dispatch. + * + * @param {{ state: any, view: any }} args + * @returns {{ from: number, to: number } | null} + */ +const resolveMixedBidiBackspaceRange = ({ state, view }) => { const { selection } = state; - if (!selection?.empty) return false; + if (!selection?.empty) return null; - const doc = view.dom.ownerDocument; - const nativeSelection = doc.getSelection(); - if (!nativeSelection || nativeSelection.rangeCount === 0) return false; + const doc = view?.dom?.ownerDocument; + const nativeSelection = doc?.getSelection?.(); + if (!nativeSelection || nativeSelection.rangeCount === 0) return null; const range = nativeSelection.getRangeAt(0); - if (!range.collapsed) return false; + if (!range.collapsed) return null; const caretPoint = resolveCaretPoint(doc, range); - if (!caretPoint) return false; + if (!caretPoint) return null; const lineEl = resolveLineElement(doc, caretPoint); - if (!lineEl) return false; + if (!lineEl) return null; const lineText = lineEl.textContent ?? ''; const hasRtl = STRONG_RTL_CHAR_RE.test(lineText); const hasLtr = STRONG_LTR_CHAR_RE.test(lineText); - if (!hasRtl || !hasLtr) return false; + if (!hasRtl || !hasLtr) return null; let chars = collectVisualChars(lineEl, view, caretPoint.x); if (chars.length < 2) { @@ -136,22 +144,45 @@ export const handleMixedBidiBackspace = (editor) => { chars = collectVisualChars(lineEl, view, null); } const boundary = resolveBoundaryChars(chars, caretPoint); - if (!boundary) return false; + if (!boundary) return null; - if (!hasMixedDirectionBoundary(boundary.left.char, boundary.right.char)) return false; - if (selection.from !== boundary.right.pmStart && selection.from !== boundary.left.pmEnd) return false; + if (!hasMixedDirectionBoundary(boundary.left.char, boundary.right.char)) return null; + if (selection.from !== boundary.right.pmStart && selection.from !== boundary.left.pmEnd) return null; - const tr = state.tr.delete(boundary.left.pmStart, boundary.left.pmEnd).scrollIntoView(); - view.dispatch(tr); - return true; + return { from: boundary.left.pmStart, to: boundary.left.pmEnd }; }; +/** + * Mixed-bidi Backspace command. Slotted into the keymap Backspace chain so it + * inherits the chain's history boundary, inputType: deleteContentBackward meta, + * track-changes wrapping, protected-range guards, and SDT handling, instead of + * dispatching its own transaction. + * + * Returns true (chain stops) only when the caret is at a strong-RTL/strong-LTR + * boundary and the visual-left character is targeted for deletion. Otherwise + * returns false so the chain falls through to deleteSelection / joinBackward. + * + * @returns {import('@core/commands/types/index.js').Command} + */ +export const mixedBidiBackspace = + () => + ({ state, view, tr, dispatch }) => { + const range = resolveMixedBidiBackspaceRange({ state, view }); + if (!range) return false; + + if (dispatch) { + tr.delete(range.from, range.to); + tr.scrollIntoView(); + } + return true; + }; + export const MixedBidiBackspace = Extension.create({ name: 'mixedBidiBackspace', - addShortcuts() { + addCommands() { return { - Backspace: () => handleMixedBidiBackspace(this.editor), + mixedBidiBackspace, }; }, }); @@ -159,4 +190,5 @@ export const MixedBidiBackspace = Extension.create({ export const __TEST_ONLY__ = { resolveCaretPoint, hasMixedDirectionBoundary, + resolveMixedBidiBackspaceRange, }; diff --git a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js index 404677bf50..2b6497300a 100644 --- a/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js +++ b/packages/super-editor/src/editors/v1/extensions/mixed-bidi-backspace/mixed-bidi-backspace.test.js @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { __TEST_ONLY__, handleMixedBidiBackspace } from './mixed-bidi-backspace.js'; +import { __TEST_ONLY__, mixedBidiBackspace } from './mixed-bidi-backspace.js'; const makeRect = (left, top = 10, width = 8, height = 12) => ({ left, @@ -8,7 +8,7 @@ const makeRect = (left, top = 10, width = 8, height = 12) => ({ height, }); -const setupEditor = ({ text, charLefts, caretRect, selectionFrom, pmBase = 10 }) => { +const setupContext = ({ text, charLefts, caretRect, selectionFrom, pmBase = 10 }) => { const doc = document.implementation.createHTMLDocument('mixed-bidi-backspace'); Object.defineProperty(doc, 'defaultView', { value: { NodeFilter: { SHOW_TEXT: 4 } }, @@ -64,26 +64,21 @@ const setupEditor = ({ text, charLefts, caretRect, selectionFrom, pmBase = 10 }) }; const view = { dom: { ownerDocument: doc }, - dispatch, posAtDOM: vi.fn((node, offset) => { if (node !== textNode) throw new Error('unexpected node'); return pmBase + offset; }), }; - const editor = { - state: { - selection: { empty: true, from: selectionFrom }, - tr, - }, - view, + const state = { + selection: { empty: true, from: selectionFrom }, }; - return { editor, tr, dispatch }; + return { state, view, tr, dispatch }; }; -describe('mixed-bidi-backspace', () => { - it('deletes visual-left char on mixed-direction boundary', () => { - const { editor, tr, dispatch } = setupEditor({ +describe('mixedBidiBackspace (chain command)', () => { + it('returns true and mutates the chain tr on RTL+LTR boundary', () => { + const { state, view, tr, dispatch } = setupContext({ text: 'אA', charLefts: [10, 20], caretRect: makeRect(20, 10, 1, 12), @@ -91,30 +86,15 @@ describe('mixed-bidi-backspace', () => { pmBase: 10, }); - const handled = handleMixedBidiBackspace(editor); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch }); expect(handled).toBe(true); expect(tr.delete).toHaveBeenCalledWith(10, 11); - expect(dispatch).toHaveBeenCalledTimes(1); + expect(tr.scrollIntoView).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); // chain owns dispatch }); - it('fails open on non-mixed boundary (pure LTR)', () => { - const { editor, tr, dispatch } = setupEditor({ - text: 'AB', - charLefts: [10, 20], - caretRect: makeRect(20, 10, 1, 12), - selectionFrom: 11, - pmBase: 10, - }); - - const handled = handleMixedBidiBackspace(editor); - expect(handled).toBe(false); - expect(tr.delete).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); - expect(editor.view.posAtDOM).not.toHaveBeenCalled(); - }); - - it('deletes visual-left char on inverse mixed boundary (LTR + RTL)', () => { - const { editor, tr, dispatch } = setupEditor({ + it('returns true and mutates the chain tr on LTR+RTL boundary', () => { + const { state, view, tr } = setupContext({ text: 'Aא', charLefts: [10, 20], caretRect: makeRect(20, 10, 1, 12), @@ -122,79 +102,77 @@ describe('mixed-bidi-backspace', () => { pmBase: 10, }); - const handled = handleMixedBidiBackspace(editor); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch: vi.fn() }); expect(handled).toBe(true); expect(tr.delete).toHaveBeenCalledWith(10, 11); - expect(dispatch).toHaveBeenCalledTimes(1); }); - it('fails open when selectionFrom does not match boundary PM position', () => { - const { editor, tr, dispatch } = setupEditor({ - text: 'אA', + it('returns false without mutating tr on pure LTR (chain falls through)', () => { + const { state, view, tr, dispatch } = setupContext({ + text: 'AB', charLefts: [10, 20], caretRect: makeRect(20, 10, 1, 12), - selectionFrom: 999, + selectionFrom: 11, pmBase: 10, }); - const handled = handleMixedBidiBackspace(editor); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch }); expect(handled).toBe(false); expect(tr.delete).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(tr.scrollIntoView).not.toHaveBeenCalled(); + expect(view.posAtDOM).not.toHaveBeenCalled(); // early-out skips DOM scan }); - it('fails open for punctuation bridge between RTL and LTR', () => { - const { editor, tr, dispatch } = setupEditor({ - text: 'א.A', - charLefts: [10, 20, 30], - caretRect: makeRect(30, 10, 1, 12), - selectionFrom: 12, + it('returns false on non-empty selection (chain falls through)', () => { + const { state, view, tr } = setupContext({ + text: 'אA', + charLefts: [10, 20], + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, pmBase: 10, }); + state.selection.empty = false; - const handled = handleMixedBidiBackspace(editor); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch: vi.fn() }); expect(handled).toBe(false); expect(tr.delete).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); }); - it('fails open when caret is at visual start (no left char)', () => { - const { editor, tr, dispatch } = setupEditor({ + // SD-2933 / SD-2767: the chain's `dispatch` parameter is undefined during dry-run + // probing. The command must still return true without mutating tr in that mode. + // chain.first uses this to detect whether a command CAN handle the operation. + it('does not mutate tr when dispatch is undefined (chain dry-run probe)', () => { + const { state, view, tr } = setupContext({ text: 'אA', charLefts: [10, 20], - caretRect: makeRect(5, 10, 1, 12), - selectionFrom: 10, + caretRect: makeRect(20, 10, 1, 12), + selectionFrom: 11, pmBase: 10, }); - const handled = handleMixedBidiBackspace(editor); - expect(handled).toBe(false); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch: undefined }); + expect(handled).toBe(true); expect(tr.delete).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); + expect(tr.scrollIntoView).not.toHaveBeenCalled(); }); - it('fails open for non-collapsed selection', () => { - const { editor, tr, dispatch } = setupEditor({ - text: 'אA', - charLefts: [10, 20], - caretRect: makeRect(20, 10, 1, 12), - selectionFrom: 11, + it('returns false when caret is not at the boundary (e.g. mid-word)', () => { + const { state, view, tr } = setupContext({ + text: 'אAB', + charLefts: [10, 20, 30], + caretRect: makeRect(30, 10, 1, 12), + selectionFrom: 12, // past the boundary pmBase: 10, }); - editor.state.selection.empty = false; - const handled = handleMixedBidiBackspace(editor); + const handled = mixedBidiBackspace()({ state, view, tr, dispatch: vi.fn() }); expect(handled).toBe(false); - expect(tr.delete).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); }); - it('resolveCaretPoint returns null for zero-size rect', () => { - const doc = document.implementation.createHTMLDocument('caret-rect'); - const result = __TEST_ONLY__.resolveCaretPoint(doc, { - getBoundingClientRect: () => makeRect(0, 0, 0, 0), - startContainer: doc.body, - }); - expect(result).toBeNull(); + it('exposes hasMixedDirectionBoundary helper for direct testing', () => { + expect(__TEST_ONLY__.hasMixedDirectionBoundary('א', 'A')).toBe(true); + expect(__TEST_ONLY__.hasMixedDirectionBoundary('A', 'א')).toBe(true); + expect(__TEST_ONLY__.hasMixedDirectionBoundary('A', 'B')).toBe(false); + expect(__TEST_ONLY__.hasMixedDirectionBoundary('א', 'ש')).toBe(false); }); }); From 6ad79d950b4b477340d3192abc06b73c5b8023a9 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 09:06:39 -0300 Subject: [PATCH 3/7] refactor(painter-dom): move run direction heuristic into features/inline-direction The shouldAssignPerRunRtlDir / normalizeRtlDateTokenForWordParity helpers and their regexes (RTL_DATE_LIKE_TOKEN_RE, STRONG_RTL_CHAR_RE, LATIN_DIGIT_NEUTRAL_ONLY_RE) lived at the bottom of renderer.ts. After #3307 moved the rtl-paragraph feature folder to inline-direction with an explicit axis scope, the renderer was the wrong home: these helpers are paint-time decisions about how to project w:rPr/w:rtl onto a rendered span's dir attribute, which is exactly what features/inline-direction owns. Extract into features/inline-direction/run-direction.ts: - Combine the two-step decision (set dir=rtl? else set dir=ltr for date-like?) into a single resolveRunDirectionAttribute helper that returns 'rtl' | 'ltr' | null. - Expose normalizeRtlDateTokenForWordParity alongside since it shares RTL_DATE_LIKE_TOKEN_RE. - Inline the decision table as JSDoc, explicitly scoping the heuristic to current SD-3098 fixtures and pointing at the spec sections plus the known follow-up gaps (w:dir, w:bdo, w:lang/@bidi numeric, presentation forms). Renderer collapses the per-span direction logic to one helper call. 22 new unit tests in run-direction.test.ts cover both branches of the rtl-tagged decision table, the date-like ltr fallback for non-tagged runs, and the regex coverage smoke tests. --- .../src/features/inline-direction/index.ts | 8 + .../inline-direction/run-direction.test.ts | 211 ++++++++++++++++++ .../inline-direction/run-direction.ts | 99 ++++++++ .../painters/dom/src/renderer.ts | 58 ++--- 4 files changed, 332 insertions(+), 44 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.test.ts create mode 100644 packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.ts diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts index 14ab684072..2492bf20f2 100644 --- a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts @@ -20,3 +20,11 @@ */ export { applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js'; +export { + resolveRunDirectionAttribute, + normalizeRtlDateTokenForWordParity, + RTL_DATE_LIKE_TOKEN_RE, + STRONG_RTL_CHAR_RE, + LATIN_DIGIT_NEUTRAL_ONLY_RE, + type RunDirAttribute, +} from './run-direction.js'; diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.test.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.test.ts new file mode 100644 index 0000000000..417c2229f7 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveRunDirectionAttribute, + normalizeRtlDateTokenForWordParity, + RTL_DATE_LIKE_TOKEN_RE, + STRONG_RTL_CHAR_RE, + LATIN_DIGIT_NEUTRAL_ONLY_RE, +} from './run-direction.js'; + +describe('resolveRunDirectionAttribute', () => { + describe('rtl-tagged runs', () => { + it('returns "rtl" for Hebrew text', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'שלום', + effectiveText: 'שלום', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('returns "rtl" for Arabic text', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'مرحبا', + effectiveText: 'مرحبا', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('returns null for Latin-only text (Word-parity: §17.3.2.30 unspecified)', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'Hello', + effectiveText: 'Hello', + isRtlTagged: true, + }), + ).toBe(null); + }); + + it('returns null for digit-only text', () => { + expect( + resolveRunDirectionAttribute({ + runText: '2026', + effectiveText: '2026', + isRtlTagged: true, + }), + ).toBe(null); + }); + + it('returns "rtl" for date-like numeric (isolates the date as RTL unit)', () => { + expect( + resolveRunDirectionAttribute({ + runText: '2026-03-15', + effectiveText: '2026-03-15', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('returns "rtl" for mixed strong-RTL + Latin (Hebrew present)', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'first שלום', + effectiveText: 'first שלום', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('returns "rtl" for empty text (honor source signal when no content)', () => { + expect( + resolveRunDirectionAttribute({ + runText: '', + effectiveText: '', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('returns "rtl" for whitespace-only text', () => { + expect( + resolveRunDirectionAttribute({ + runText: ' ', + effectiveText: ' ', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + // Fail-safe: anything that doesn't match the Latin/digit/neutral set OR the + // strong-RTL set still honors the source signal. East Asian, presentation + // forms, symbols outside the neutral set all fall into this branch. + it('returns "rtl" for text that is neither Latin nor strong-RTL', () => { + expect( + resolveRunDirectionAttribute({ + runText: '世界', + effectiveText: '世界', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + + it('uses effectiveText when runText is undefined', () => { + expect( + resolveRunDirectionAttribute({ + runText: undefined, + effectiveText: 'שלום', + isRtlTagged: true, + }), + ).toBe('rtl'); + }); + }); + + describe('non-rtl-tagged runs', () => { + it('returns "ltr" for date-like numeric (Word-parity in RTL paragraph)', () => { + expect( + resolveRunDirectionAttribute({ + runText: '2026-03-15', + effectiveText: '2026-03-15', + isRtlTagged: false, + }), + ).toBe('ltr'); + }); + + it('returns null for plain Latin (let paragraph + UBA decide)', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'Hello', + effectiveText: 'Hello', + isRtlTagged: false, + }), + ).toBe(null); + }); + + it('returns null for Hebrew text without w:rtl (paragraph context resolves)', () => { + expect( + resolveRunDirectionAttribute({ + runText: 'שלום', + effectiveText: 'שלום', + isRtlTagged: false, + }), + ).toBe(null); + }); + + it('returns null when runText is undefined (no date pattern to match)', () => { + expect( + resolveRunDirectionAttribute({ + runText: undefined, + effectiveText: '2026-03-15', + isRtlTagged: false, + }), + ).toBe(null); + }); + }); +}); + +describe('normalizeRtlDateTokenForWordParity', () => { + const RLM = '\u200F'; + + it('wraps separators with RLM in date-like text', () => { + expect(normalizeRtlDateTokenForWordParity('2026-03-15')).toBe(`2026${RLM}-${RLM}03${RLM}-${RLM}15`); + }); + + it('handles slash separators', () => { + expect(normalizeRtlDateTokenForWordParity('15/03/2026')).toBe(`15${RLM}/${RLM}03${RLM}/${RLM}2026`); + }); + + it('handles dot separators', () => { + expect(normalizeRtlDateTokenForWordParity('1.2.3')).toBe(`1${RLM}.${RLM}2${RLM}.${RLM}3`); + }); + + it('wraps the leading sign too (no special-case for leading "-")', () => { + // Implementation is text.replace(/[./-]/g, ...). The leading sign is also + // a `-`, so it gets RLM-wrapped. This matches the pre-extraction behavior. + expect(normalizeRtlDateTokenForWordParity('-2026-03')).toBe(`${RLM}-${RLM}2026${RLM}-${RLM}03`); + }); + + it('returns unchanged for non-date text', () => { + expect(normalizeRtlDateTokenForWordParity('Hello world')).toBe('Hello world'); + expect(normalizeRtlDateTokenForWordParity('2026')).toBe('2026'); // no separator + expect(normalizeRtlDateTokenForWordParity('שלום')).toBe('שלום'); + }); +}); + +describe('regex coverage smoke tests', () => { + it('RTL_DATE_LIKE_TOKEN_RE matches numeric dates', () => { + expect(RTL_DATE_LIKE_TOKEN_RE.test('2026-03-15')).toBe(true); + expect(RTL_DATE_LIKE_TOKEN_RE.test('15/03/2026')).toBe(true); + expect(RTL_DATE_LIKE_TOKEN_RE.test('1.2.3')).toBe(true); + expect(RTL_DATE_LIKE_TOKEN_RE.test('-2026-03')).toBe(true); + expect(RTL_DATE_LIKE_TOKEN_RE.test('2026')).toBe(false); // no separator + expect(RTL_DATE_LIKE_TOKEN_RE.test('a-b-c')).toBe(false); + }); + + it('STRONG_RTL_CHAR_RE matches Hebrew and Arabic core blocks', () => { + expect(STRONG_RTL_CHAR_RE.test('שלום')).toBe(true); + expect(STRONG_RTL_CHAR_RE.test('مرحبا')).toBe(true); + expect(STRONG_RTL_CHAR_RE.test('Hello')).toBe(false); + expect(STRONG_RTL_CHAR_RE.test('2026')).toBe(false); + }); + + it('LATIN_DIGIT_NEUTRAL_ONLY_RE matches Latin + digit + neutral chars', () => { + expect(LATIN_DIGIT_NEUTRAL_ONLY_RE.test('Hello world')).toBe(true); + expect(LATIN_DIGIT_NEUTRAL_ONLY_RE.test('copy 2')).toBe(true); + expect(LATIN_DIGIT_NEUTRAL_ONLY_RE.test('a/b-c.d')).toBe(true); + expect(LATIN_DIGIT_NEUTRAL_ONLY_RE.test('שלום')).toBe(false); + expect(LATIN_DIGIT_NEUTRAL_ONLY_RE.test('Hello שלום')).toBe(false); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.ts new file mode 100644 index 0000000000..291bfdaf03 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/run-direction.ts @@ -0,0 +1,99 @@ +/** + * Run-level direction helpers for DomPainter. + * + * These helpers encode paint-time decisions about how to project the OOXML + * `w:rPr/w:rtl` signal onto a rendered span's `dir` attribute, plus a narrow + * Word-parity workaround for RTL-tagged date-like numeric runs. + * + * The heuristic is intentionally scoped to current Word-parity fixtures + * (SD-3098 mixed-bidi date tokens). It is NOT a full implementation of + * §17.3.2.30 semantics - notably absent: `w:dir` embedding (§17.3.2.8), + * `w:bdo` override (§17.3.2.3), and `w:lang/@bidi` Hebrew vs Arabic numeric + * differences. Those gaps are tracked separately; see SD-2767 follow-ups. + * + * @spec ECMA-376 §17.3.2.30 (rtl), §17.17.4 (boolean property) + */ + +/** + * Matches numeric date-like tokens such as `2026-03-15`, `15/03/2026`, `1.2.3`. + * Used by both the run direction resolver and the paint-time RLM injection + * for Word parity on RTL date strings. + */ +export const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/; + +/** + * Matches strong-RTL characters in the Hebrew / Arabic / Syriac core blocks. + * + * Known gap: misses Hebrew presentation forms (FB1D-FB4F) and Arabic + * presentation forms (FB50-FDFF, FE70-FEFF). Tracked under SD-2767 follow-ups. + */ +export const STRONG_RTL_CHAR_RE = /[\u0590-\u08FF]/; + +/** + * Matches runs whose content is exclusively Latin / digit / neutral. Used as + * the "skip per-run dir=rtl" guard: per §17.3.2.30, behavior of w:rtl on + * strongly LTR text is unspecified, and Word's empirical output for these + * runs does not visually reorder. + */ +export const LATIN_DIGIT_NEUTRAL_ONLY_RE = /^[\s0-9A-Za-z./\-_:,+()]+$/; + +const RLM = '\u200F'; + +/** + * Word-parity workaround for RTL date-like tokens. + * + * Word internally injects RLM around numeric separators in RTL date strings, + * preserving LTR order for the digits while keeping the run RTL. The browser's + * UBA alone does not match this. We mirror Word by injecting RLM at paint + * time only - the DOM text differs from the PM model and from the exported + * OOXML, which both keep the original separators. + * + * Intentionally narrow: only matches numeric date-like patterns so other + * numeric content is unaffected. Scope is current SD-3098 fixtures. + */ +export const normalizeRtlDateTokenForWordParity = (text: string): string => { + if (!RTL_DATE_LIKE_TOKEN_RE.test(text)) { + return text; + } + return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`); +}; + +/** + * Compute the `dir` attribute (if any) to apply to a rendered run span. + * + * Decision table: + * - rtl-tagged + empty text -> 'rtl' (no content to classify, honor source signal) + * - rtl-tagged + date-like numeric -> 'rtl' (isolates the date as a unit) + * - rtl-tagged + contains strong-RTL chars -> 'rtl' (standard case) + * - rtl-tagged + only Latin/digit/neutral -> null (per §17.3.2.30, unspecified; + * Word does not visually reorder these, so omit dir to inherit paragraph) + * - rtl-tagged + other (e.g. East Asian, presentation forms) -> 'rtl' (fail-safe) + * - NOT rtl-tagged + date-like numeric text -> 'ltr' (Word-parity: keeps date + * LTR-classified within an RTL paragraph context so digits don't drift) + * - NOT rtl-tagged + anything else -> null (let paragraph + UBA decide) + */ +export type RunDirAttribute = 'rtl' | 'ltr' | null; + +export const resolveRunDirectionAttribute = (opts: { + /** Original run text from the model. */ + runText: string | undefined; + /** Post-token-resolution text used for rendering (e.g. field token expansion). */ + effectiveText: string; + /** True when the source OOXML carries `w:rPr/w:rtl`. */ + isRtlTagged: boolean; +}): RunDirAttribute => { + if (opts.isRtlTagged) { + const sample = (opts.runText ?? opts.effectiveText).trim(); + if (!sample) return 'rtl'; + if (RTL_DATE_LIKE_TOKEN_RE.test(sample)) return 'rtl'; + if (STRONG_RTL_CHAR_RE.test(sample)) return 'rtl'; + if (LATIN_DIGIT_NEUTRAL_ONLY_RE.test(sample)) return null; + return 'rtl'; + } + + if (typeof opts.runText === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(opts.runText)) { + return 'ltr'; + } + + return null; +}; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 62e937b53b..44ed6f6c4a 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -125,7 +125,12 @@ import { stampBetweenBorderDataset, type BetweenBorderInfo, } from './features/paragraph-borders/index.js'; -import { applyRtlStyles, shouldUseSegmentPositioning } from './features/inline-direction/index.js'; +import { + applyRtlStyles, + shouldUseSegmentPositioning, + resolveRunDirectionAttribute, + normalizeRtlDateTokenForWordParity, +} from './features/inline-direction/index.js'; import { convertOmmlToMathml } from './features/math/index.js'; /** @@ -5597,16 +5602,16 @@ export class DomPainter { // Pass isLink flag to skip applying inline color/decoration styles for links applyRunStyles(elem as HTMLElement, run, isActiveLink); - const usePerRunRtlDir = shouldAssignPerRunRtlDir({ + // SD-3098 Word-parity: run-level dir attribute decision lives in the + // inline-direction feature. See resolveRunDirectionAttribute for the + // decision table (rtl-tagged + content classification + date-like). + const dirAttr = resolveRunDirectionAttribute({ runText: textRun.text, effectiveText, + isRtlTagged: textRun.bidi?.rtl === true, }); - // SD-3098 Word-parity: rtl-tagged runs get dir="rtl" so per-run bidi is isolated; - // non-rtl date-like runs in RTL context get dir="ltr" to prevent separator drift. - if (textRun.bidi?.rtl === true && usePerRunRtlDir) { - elem.setAttribute('dir', 'rtl'); - } else if (typeof textRun.text === 'string' && RTL_DATE_LIKE_TOKEN_RE.test(textRun.text)) { - elem.setAttribute('dir', 'ltr'); + if (dirAttr) { + elem.setAttribute('dir', dirAttr); } const commentAnnotations = textRun.comments; const hasAnyComment = !!commentAnnotations?.length; @@ -7326,8 +7331,7 @@ export class DomPainter { wrapper.dataset.layoutEpoch = String(this.layoutEpoch); this.applySdtDataset(wrapper, sdt); - const appearance = - sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; + const appearance = sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; if (appearance === 'hidden') { wrapper.dataset.appearance = 'hidden'; // No alias label and no chrome: see CSS rule keyed off @@ -8298,37 +8302,3 @@ const resolveRunText = (run: Run, context: FragmentRenderContext): string => { } return run.text ?? ''; }; - -const RTL_DATE_LIKE_TOKEN_RE = /^-?\d+(?:[./-]\d+)+$/; -const STRONG_RTL_CHAR_RE = /[\u0590-\u08FF]/; -const LATIN_DIGIT_NEUTRAL_ONLY_RE = /^[\s0-9A-Za-z./\-_:,+()]+$/; -const RLM = '\u200F'; - -// AIDEV-NOTE: SD-3098 Word-parity workaround for RTL date-like tokens. We inject -// RLM around separators at paint time only (DOM text), never into PM/model/export. -// Word reorders numerics inside RTL date strings via internal RLM treatment; the -// browser's UBA does not. This is intentionally narrow - only matches date-like -// numeric patterns - so non-date numeric content is unaffected. -const normalizeRtlDateTokenForWordParity = (text: string): string => { - if (!RTL_DATE_LIKE_TOKEN_RE.test(text)) { - return text; - } - return text.replace(/[./-]/g, (separator) => `${RLM}${separator}${RLM}`); -}; - -const shouldAssignPerRunRtlDir = (opts: { runText: string | undefined; effectiveText: string }): boolean => { - const sample = (opts.runText ?? opts.effectiveText).trim(); - if (!sample) { - return true; - } - if (RTL_DATE_LIKE_TOKEN_RE.test(sample)) { - return true; - } - if (STRONG_RTL_CHAR_RE.test(sample)) { - return true; - } - if (LATIN_DIGIT_NEUTRAL_ONLY_RE.test(sample)) { - return false; - } - return true; -}; From 7108806e8b94f2df9020a765839f8c41c17419c7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 09:06:40 -0300 Subject: [PATCH 4/7] docs(contracts): clarify RunBidiContext.rtl is a source signal, not a paint directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per §17.3.2.30, w:rPr/w:rtl does two things at the model level: forces the complex-script formatting stack, and acts as a directionality override for weak/neutral characters (NOT a forced visual flip of strong-LTR text - §17.3.2.30 explicitly says behavior on strong-LTR is unspecified). Update the JSDoc on RunBidiContext.rtl to make explicit: - rtl: true is the source signal (w:rPr/w:rtl was set in OOXML) - It is NOT a directive that every consumer must project to dir="rtl" in the rendered DOM - The painter decides the DOM projection per its Word-parity rules (resolveRunDirectionAttribute in features/inline-direction) - Exporters must preserve rtl: true on round-trip regardless of paint decisions, since dropping it would lose source semantics Doc-only change. No code or type signature changes. --- .../contracts/src/direction-context.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/direction-context.ts b/packages/layout-engine/contracts/src/direction-context.ts index b4f956333a..e533d52c5e 100644 --- a/packages/layout-engine/contracts/src/direction-context.ts +++ b/packages/layout-engine/contracts/src/direction-context.ts @@ -114,7 +114,23 @@ export type ParagraphDirectionContext = { * w:bdo (§17.3.2.3 override). */ export type RunBidiContext = { - /** w:rPr/w:rtl. Forces complex-script formatting; see RunScriptContext. */ + /** + * w:rPr/w:rtl. Preserves the source OOXML signal that the run carries + * the `w:rtl` flag. Per §17.3.2.30, `w:rtl` does two things at the model + * level: + * 1. Forces the complex-script formatting stack (bCs, iCs, szCs, + * rFonts/@cs). See RunScriptContext for the formatting half. + * 2. Acts as a Character Directionality Override for weak/neutral + * characters in the run (NOT a forced visual flip of strong-LTR text; + * §17.3.2.30 explicitly says behavior on strong-LTR is unspecified). + * + * `rtl: true` is the source signal, NOT a directive that every consumer + * must project to `dir="rtl"` in the rendered DOM. The painter decides + * the DOM projection per its Word-parity rules (see + * `features/inline-direction/resolveRunDirectionAttribute`). Exporters + * must preserve `rtl: true` on round-trip regardless of paint decisions, + * since dropping it would lose the source `w:rPr/w:rtl` semantics. + */ rtl: boolean; /** w:dir; bidi embedding direction (RLE/LRE). Wave 1c. */ embedding?: BaseDirection; From 37a6a5cf2c75762c01721ad09189e144276c6414 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 10:45:27 -0300 Subject: [PATCH 5/7] test(behavior): tighten rtl-dates body assertion to pin new latin-only contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body span[dir="rtl"] assertion in rtl-dates-word-parity.spec.ts was silently passing via the header span: the selector `.superdoc-page .superdoc-fragment .superdoc-line span[dir="rtl"]` matched the header (nested inside `.superdoc-page`) when the body run no longer got dir="rtl" after resolveRunDirectionAttribute moved to the new latin-only branch. Tighten the selector to `.superdoc-page > .superdoc-fragment` so it walks body fragments only, and flip the assertion to pin the new contract: a body rtl-tagged digit-only "2026" run does NOT receive a per-run dir attribute. Test still passes; a future regression that re-adds dir="rtl" to the run will now fail loudly. Per ECMA-376 §17.3.2.30, w:rtl on strongly-LTR text is unspecified behavior; we match Word's empirical rendering by leaving the run to the paragraph direction and UBA. --- .../formatting/rtl-dates-word-parity.spec.ts | 19 +++++++++++------- .../selection/fixtures/mixed-bidi-1.docx | Bin 18089 -> 0 bytes 2 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx diff --git a/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts b/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts index fb0dc361b5..c97ff9446a 100644 --- a/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts +++ b/tests/behavior/tests/formatting/rtl-dates-word-parity.spec.ts @@ -14,14 +14,19 @@ test('rtl dates render in the same visual order as Word', async ({ superdoc }) = const headerText = await headerRuns.last().evaluate((el) => el.textContent ?? ''); expect(headerText.includes('\u200F/\u200F')).toBe(true); - const bodyDateRuns = superdoc.page - .locator('.superdoc-page .superdoc-fragment .superdoc-line span') - .filter({ hasText: '-03-23' }); + const bodyFragments = superdoc.page.locator('.superdoc-page > .superdoc-fragment'); + const bodyDateRuns = bodyFragments.locator('.superdoc-line span').filter({ hasText: '-03-23' }); await expect(bodyDateRuns.first()).toHaveAttribute('dir', 'ltr'); - const bodyRtlNumericRun = superdoc.page - .locator('.superdoc-page .superdoc-fragment .superdoc-line span[dir="rtl"]') - .filter({ hasText: '2026' }) + // SD-2933: rtl-tagged digit-only runs (e.g. a standalone "2026") fall into the + // latin-only branch of resolveRunDirectionAttribute and intentionally do NOT + // receive a per-run dir attribute. The paragraph direction carries them via + // UBA, matching Word's empirical rendering. Per ECMA-376 §17.3.2.30, w:rtl on + // strongly-LTR text is unspecified behavior. + const bodyNumericRun = bodyFragments + .locator('.superdoc-line span') + .filter({ hasText: /^2026$/ }) .first(); - await expect(bodyRtlNumericRun).toBeVisible(); + await expect(bodyNumericRun).toBeVisible(); + await expect(bodyNumericRun).not.toHaveAttribute('dir', 'rtl'); }); diff --git a/tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx b/tests/behavior/tests/selection/fixtures/mixed-bidi-1.docx deleted file mode 100644 index 7a50a1df6e63cb792104bb09b9fa6d05021c0e94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18089 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 UK!W+pYDWX`gO(F5(O+l(4-HO{)c^nh From f7c878aed1c10d3f7da68cb62cf72816e53fb3fb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 10:45:33 -0300 Subject: [PATCH 6/7] test(super-editor): pin Backspace command-chain ordering handleBackspace builds an ordered command chain. The position of mixedBidiBackspace matters: it must run after backspaceAcrossRuns (specialized run-aware handling first) and before deleteSelection (generic fallback). Also, inputType: deleteContentBackward meta must be set before any specialized handler runs - track-changes Backspace wrapping in trackChangesHelpers/trackedTransaction.js gates on it. Add 4 unit tests that walk the chain with spies and assert the expected call order, the meta hop position, and graceful fallthrough when the MixedBidiBackspace extension is not registered (chain uses `?? false`). If the chain order changes in the future, this test fails loudly and the author has to justify the new ordering. --- .../extensions/keymap-backspace-chain.test.js | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js new file mode 100644 index 0000000000..cd9d480fcc --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from 'vitest'; +import { handleBackspace } from './keymap.js'; + +/** + * Pins the ordering of commands in the Backspace chain. + * + * The chain shape matters because: + * - `inputType: deleteContentBackward` meta must be set before any specialized + * handler runs (track-changes Backspace gating depends on it). + * - The mixed-bidi handler must run after the run-aware ladder so it does not + * intercept Backspace inside SDT blocks or atomic-before cases. + * - It must run before the generic `deleteSelection` / `joinBackward` fallbacks. + * + * If the chain order changes, this test fails loudly and the author must + * justify the new ordering. + */ +describe('handleBackspace chain ordering', () => { + const makeEditor = () => { + const callLog = []; + const setMetaLog = []; + + const tr = { + setMeta: vi.fn((key, value) => { + setMetaLog.push({ key, value }); + return tr; + }), + }; + + // Each command spy records its name and returns false so the chain + // walks through every entry; the dispatchHistoryBoundary helper at the + // top of handleBackspace dispatches the closeHistory tr separately. + const make = (name) => () => { + callLog.push(name); + return false; + }; + + const commands = { + undoInputRule: make('undoInputRule'), + deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), + backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), + backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), + backspaceAtomBefore: make('backspaceAtomBefore'), + backspaceNextToRun: make('backspaceNextToRun'), + backspaceAcrossRuns: make('backspaceAcrossRuns'), + mixedBidiBackspace: make('mixedBidiBackspace'), + deleteSelection: make('deleteSelection'), + removeNumberingProperties: make('removeNumberingProperties'), + joinBackward: make('joinBackward'), + selectNodeBackward: make('selectNodeBackward'), + }; + + const editor = { + view: { state: { tr }, dispatch: vi.fn() }, + commands: { + first: vi.fn((build) => { + const fns = build({ commands, tr }); + for (const fn of fns) { + const result = fn(); + if (result) return result; + } + return false; + }), + }, + }; + + return { editor, callLog, setMetaLog }; + }; + + it('walks the chain in the expected order when no command handles', () => { + const { editor, callLog } = makeEditor(); + handleBackspace(editor); + expect(callLog).toEqual([ + 'undoInputRule', + // step 2 sets inputType meta and returns false (no command call) + 'deleteBlockSdtAtTextBlockStart', + 'backspaceEmptyRunParagraph', + 'backspaceSkipEmptyRun', + 'backspaceAtomBefore', + 'backspaceNextToRun', + 'backspaceAcrossRuns', + 'mixedBidiBackspace', + 'deleteSelection', + 'removeNumberingProperties', + 'joinBackward', + 'selectNodeBackward', + ]); + }); + + it('sets inputType: deleteContentBackward meta before specialized handlers', () => { + const { editor, callLog, setMetaLog } = makeEditor(); + handleBackspace(editor); + + expect(setMetaLog).toContainEqual({ key: 'inputType', value: 'deleteContentBackward' }); + + // Meta must be set BEFORE the run-aware handlers run, otherwise track-changes + // Backspace wrapping in trackChangesHelpers/trackedTransaction.js cannot + // identify the tr as a Backspace. + const sdtIndex = callLog.indexOf('deleteBlockSdtAtTextBlockStart'); + expect(sdtIndex).toBeGreaterThanOrEqual(0); + // Spy log only records command calls, not the meta-setter step; verify + // meta-setter happens at chain position 1 by reconstructing the chain + // walk (undoInputRule at 0, meta-setter at 1, then SDT at 2). + expect(callLog[0]).toBe('undoInputRule'); + expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); + }); + + it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { + const { editor, callLog } = makeEditor(); + handleBackspace(editor); + + const acrossRunsIndex = callLog.indexOf('backspaceAcrossRuns'); + const mixedIndex = callLog.indexOf('mixedBidiBackspace'); + const deleteSelectionIndex = callLog.indexOf('deleteSelection'); + + expect(acrossRunsIndex).toBeGreaterThanOrEqual(0); + expect(mixedIndex).toBeGreaterThan(acrossRunsIndex); + expect(deleteSelectionIndex).toBeGreaterThan(mixedIndex); + }); + + it('tolerates missing mixedBidiBackspace command (extension not registered)', () => { + const { editor, callLog } = makeEditor(); + // Simulate the extension being absent: drop the mixedBidiBackspace key + // from the commands map. The chain uses `?? false` so it should keep walking. + delete editor.commands.first.mock.calls; // reset just in case + const editorWithoutCommand = { + ...editor, + commands: { + first: vi.fn((build) => { + const tr = editor.view.state.tr; + const commands = new Proxy( + {}, + { + get(_, name) { + if (name === 'mixedBidiBackspace') return undefined; + return () => { + callLog.push(name); + return false; + }; + }, + }, + ); + const fns = build({ commands, tr }); + for (const fn of fns) { + const result = fn(); + if (result) return result; + } + return false; + }), + }, + }; + + expect(() => handleBackspace(editorWithoutCommand)).not.toThrow(); + expect(callLog).toContain('backspaceAcrossRuns'); + expect(callLog).toContain('deleteSelection'); + expect(callLog).not.toContain('mixedBidiBackspace'); + }); +}); From 6818d1a0277a4f276b3488f6771bbc90231e5711 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 16 May 2026 11:02:52 -0300 Subject: [PATCH 7/7] fix(super-editor): gate native-selection caret refinement to local caret only (SD-3170) PresentationEditor.#computeCaretLayoutRect unconditionally passed includeDomFallback=true to the geometry helper. The native-selection refinement added in this PR reads the browser's collapsed selection rect and prefers it over geometry when within an 80px sanity window. That's only sound when the requested pos IS the local user's caret. Two callers ask the same function about arbitrary positions: - RemoteCursorManager (exposed via the closure at PresentationEditor:4681) queries each remote collaborator's head. With the gate off, a remote cursor on the same line as the local cursor and within ~80px would render at the local caret's position. - Vertical-arrow navigation binary-searches candidate positions on the next line. Probes near the local cursor could converge to the local position rather than the candidate. Fix: add a pure helper shouldUseNativeCaretFallback(selection, pos) that returns true only when the selection is collapsed AND its head equals the requested pos. Wire it into #computeCaretLayoutRect so the flag is enabled only for the local caret. The helper lives in selection/native-caret-fallback.ts so it's independently testable; the geometry function in CaretGeometry.ts stays a pure helper with no knowledge of editor state. 6 unit tests cover the gate's decision table (null/undefined selection, range vs collapsed, matching vs non-matching pos, boundary at pos 0). --- .../presentation-editor/PresentationEditor.ts | 10 ++++- .../selection/native-caret-fallback.test.ts | 38 +++++++++++++++++ .../selection/native-caret-fallback.ts | 42 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9ceea34d17..1fc9cb50a1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -75,6 +75,7 @@ import { debugLog, updateSelectionDebugHud, type SelectionDebugHudState } from ' import { renderCellSelectionOverlay } from './selection/CellSelectionOverlay.js'; import { renderCaretOverlay, renderSelectionRects } from './selection/LocalSelectionOverlayRendering.js'; import { computeCaretLayoutRectGeometry as computeCaretLayoutRectGeometryFromHelper } from './selection/CaretGeometry.js'; +import { shouldUseNativeCaretFallback } from './selection/native-caret-fallback.js'; import { computeCaretRectFromVisibleTextOffset as computeCaretRectFromVisibleTextOffsetFromHelper, computeSelectionRectsFromVisibleTextOffsets as computeSelectionRectsFromVisibleTextOffsetsFromHelper, @@ -9785,9 +9786,16 @@ export class PresentationEditor extends EventEmitter { /** * Compute caret position, preferring DOM when available, falling back to geometry. + * + * SD-3170: the native-selection refinement inside computeCaretLayoutRectGeometry + * reads the browser's collapsed selection rect and prefers it over geometry. + * That's only sound when the requested `pos` is the local user's actual caret. + * Arbitrary-position queries (remote collaborator cursors, vertical-arrow + * navigation binary search) must not get the local rect substituted in. */ #computeCaretLayoutRect(pos: number): { pageIndex: number; x: number; y: number; height: number } | null { - const geometry = this.#computeCaretLayoutRectGeometry(pos, true); + const useNativeFallback = shouldUseNativeCaretFallback(this.editor?.state?.selection, pos); + const geometry = this.#computeCaretLayoutRectGeometry(pos, useNativeFallback); let dom: { pageIndex: number; x: number; y: number } | null = null; try { dom = this.#computeDomCaretPageLocal(pos); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.test.ts new file mode 100644 index 0000000000..a3e13d3827 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { shouldUseNativeCaretFallback } from './native-caret-fallback.js'; + +describe('shouldUseNativeCaretFallback', () => { + it('returns false when selection is null', () => { + expect(shouldUseNativeCaretFallback(null, 5)).toBe(false); + }); + + it('returns false when selection is undefined', () => { + expect(shouldUseNativeCaretFallback(undefined, 5)).toBe(false); + }); + + it('returns false when selection is not collapsed', () => { + // Even if the requested pos equals one of the selection endpoints, a range + // selection means the user has multiple positions selected. The native + // refinement should not fire. + expect(shouldUseNativeCaretFallback({ empty: false, head: 5 }, 5)).toBe(false); + }); + + it('returns false when requested pos differs from selection head', () => { + // SD-3170: arbitrary-position queries (remote cursors, vertical-nav + // binary-search probes) must not get the native-selection rect. + expect(shouldUseNativeCaretFallback({ empty: true, head: 5 }, 6)).toBe(false); + expect(shouldUseNativeCaretFallback({ empty: true, head: 10 }, 4)).toBe(false); + }); + + it('returns true only for the local collapsed caret', () => { + expect(shouldUseNativeCaretFallback({ empty: true, head: 5 }, 5)).toBe(true); + expect(shouldUseNativeCaretFallback({ empty: true, head: 0 }, 0)).toBe(true); + }); + + it('treats head 0 distinctly from no selection', () => { + // Boundary check: head: 0 with pos: 0 is a valid local caret. + expect(shouldUseNativeCaretFallback({ empty: true, head: 0 }, 0)).toBe(true); + // pos -1 (impossible PM position) is never the local caret. + expect(shouldUseNativeCaretFallback({ empty: true, head: 0 }, -1)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.ts new file mode 100644 index 0000000000..448873a356 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/selection/native-caret-fallback.ts @@ -0,0 +1,42 @@ +/** + * Pure gate for the native-selection refinement in + * {@link computeCaretLayoutRectGeometry}. + * + * The native-selection refinement (added in SD-2933) reads the browser's + * collapsed selection rect and prefers it over the geometry-computed caret + * when within a sanity bound. That refinement is only sound when the + * requested `pos` IS the local user's actual caret. + * + * Two callers in PresentationEditor ask {@link computeCaretLayoutRect} about + * positions that are NOT the local caret: + * + * - {@link RemoteCursorManager} queries each remote collaborator's head. + * - Vertical-arrow navigation binary-searches candidate positions on the + * next line to pick the one closest to the previous horizontal X. + * + * Without this gate, the native-selection refinement would substitute the + * LOCAL caret's rect for those queries when they happen to fall within the + * sanity window. Remote cursors would render at the local caret's position; + * vertical navigation would converge to the local caret. + * + * SD-3170 tracks this. + * + * @param selection - The current ProseMirror selection (or null/undefined + * when no editor is attached). + * @param pos - The position the caller is asking about. + * @returns true only when the selection is collapsed AND its caret head is + * exactly the requested position. + */ +export type CaretFallbackSelection = { + empty: boolean; + head: number; +}; + +export const shouldUseNativeCaretFallback = ( + selection: CaretFallbackSelection | null | undefined, + pos: number, +): boolean => { + if (!selection) return false; + if (!selection.empty) return false; + return selection.head === pos; +};