Skip to content

feat: rtl mixed bidi#3320

Merged
caio-pizzol merged 7 commits into
mainfrom
artem/rtl-v12
May 16, 2026
Merged

feat: rtl mixed bidi#3320
caio-pizzol merged 7 commits into
mainfrom
artem/rtl-v12

Conversation

@artem-harbour
Copy link
Copy Markdown
Contributor

@artem-harbour artem-harbour commented May 15, 2026

Linear: SD-2933

This PR closes the mixed-bidi hardening milestone for RTL editing.

Included:

  • Renderer update for per-run dir assignment in mixed RTL/LTR content to improve token order stability.
  • Caret geometry hardening at bidi boundaries (native selection fallback + scoped DOM probing) to reduce caret drift/snap issues.
  • New mixed-bidi-backspace extension for visual-left deletion at RTL/LTR boundaries with fail-open behavior.
  • Expanded unit and behavior coverage for mixed-bidi click, arrow navigation, selection, and backspace scenarios.

Result:

  • Improves mixed-bidi typing/caret/deletion reliability in body and header/footer surfaces.
  • Preserves non-bidi flows via conservative guards and fallback behavior.
  • Keeps scope focused on production-usable stability for current fixtures.

Notes

  • Mixed-bidi still has corner cases.
  • Recommend deferring deeper investigation/fixes until real reports arrive, and then handling them through a normal bug queue.
  • Reason: mixed-bidi fixes are often high-risk and can regress unrelated editor behavior.
  • Also, mixed-bidi behavior in Word itself is frequently inconsistent/unpredictable across edge scenarios.

@codecov-commenter
Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@caio-pizzol caio-pizzol self-assigned this May 15, 2026
@artem-harbour artem-harbour marked this pull request as ready for review May 15, 2026 17:12
@artem-harbour artem-harbour requested a review from a team as a code owner May 15, 2026 17:12
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 15, 2026

SD-2933

SD-3169

SD-3170

@artem-harbour artem-harbour requested a review from caio-pizzol May 15, 2026 17:12
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ce8602149

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

…space 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.
…ine-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.
… paint directive

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.
…y contract

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.
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.
Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @artemn1stuley, good direction here.

I pushed a few follow-up commits to keep this aligned with the RTL architecture: Backspace now uses the normal Backspace flow, the run direction logic moved into the inline-direction feature, the run RTL type is documented, and the date + Backspace tests were tightened.

lgtm

@caio-pizzol caio-pizzol enabled auto-merge May 16, 2026 13:53
…ret 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).
@caio-pizzol caio-pizzol merged commit d9cadd5 into main May 16, 2026
69 checks passed
@caio-pizzol caio-pizzol deleted the artem/rtl-v12 branch May 16, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants