feat: rtl mixed bidi#3320
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
b2b0fed to
45b3ed0
Compare
45b3ed0 to
8ce8602
Compare
There was a problem hiding this comment.
💡 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.
caio-pizzol
left a comment
There was a problem hiding this comment.
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
…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).
Linear: SD-2933
This PR closes the mixed-bidi hardening milestone for RTL editing.
Included:
dirassignment in mixed RTL/LTR content to improve token order stability.mixed-bidi-backspaceextension for visual-left deletion at RTL/LTR boundaries with fail-open behavior.Result:
Notes