Skip to content

[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741

Open
mayrang wants to merge 26 commits into
facebook:mainfrom
mayrang:feat/7787-ruby-annotation
Open

[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741
mayrang wants to merge 26 commits into
facebook:mainfrom
mayrang:feat/7787-ruby-annotation

Conversation

@mayrang

@mayrang mayrang commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a playground-level RubyNode (extends TextNode in token mode) with full keyboard navigation, IME composition support, and a floating annotation editor. The node renders as <ruby><rt> on export and imports the same structure back.

What changed

RubyNode (nodes/RubyNode.ts) — a token-mode TextNode subclass with an __annotation field. The DOM is a wrapper <span> containing an inner <span data-ruby-annotation="..."> so the annotation text renders via CSS ::before. exportDOM produces standard <ruby>...<rt>...</rt></ruby>.

RubyExtension (plugins/RubyExtension/index.ts) — registers arrow-key, backspace, and composition handlers. Arrow keys skip over consecutive ruby groups atomically — ruby base text is in token mode, so the cursor can't meaningfully stop on it (any keystroke would be redirected to a neighbor). Skipping the entire contiguous group avoids a confusing dead-stop. Shift+arrow extends selection past them. A SELECTION_CHANGE handler nudges collapsed cursors off non-composing ruby nodes to prevent the caret from landing inside a token.

Floating editor (plugins/FloatingRubyEditorPlugin/) — follows the FloatingLinkEditorPlugin pattern. The editor appears anchored to the selection when creating new ruby (toolbar trigger) or to the ruby DOM element when clicking an existing one. Enter confirms, Escape dismisses. While active, the FloatingTextFormatToolbar is suppressed so the two don't overlap.

Core changes (LexicalEvents.ts, LexicalUtils.ts) — targeted fixes for IME composition on token-mode TextNode subclasses:

  1. $onCompositionEndImpl now detects when composition ends on a token node and redirects the composed text to the adjacent TextNode via selection.insertText, instead of letting markDirty silently discard it. Uses the actual selection.anchor.offset to decide direction — offset 0 redirects to the previous sibling, offset=textLen to the next.
  2. $handleInput skips $shouldPreventDefaultAndInsertText during insertCompositionText on token nodes, so the browser's native composition UI stays intact until composition ends.
  3. $updateTextNodeFromDOMContent bails early when a composing token node's DOM is synced, preventing the reconciler from reverting mid-composition input.
  4. $handleCompositionStart now redirects composition away from token/segmented nodes by inserting COMPOSITION_START_CHAR, which triggers the existing insertText boundary logic to create an adjacent TextNode. This prevents composition from starting inside nodes that can't accept text modifications — the root cause of Bug: input Chinese character after mention break mention entity and repeat characters #6296 (mention entity destruction on CJK input). Combined with (1), this provides two layers: pre-composition redirect at start, and post-composition redirect at end as a fallback.

These core changes are generic — any token-mode or segmented TextNode subclass benefits from them, not just RubyNode.

Toolbar

The ルビ katakana text button is replaced with an SVG icon (ruby.svg) consistent with other toolbar icons. The button only activates when text is selected; clicking with an existing ruby in the selection removes it.

Closes #7787, #6296

Test plan

  • 87 unit tests (RubyNode.test.ts) — arrow skip, Shift+arrow selection, consecutive ruby group walk, line boundary fallback, backspace, guard conditions.
  • 23 unit tests (RubyComposition.test.ts) — composition end redirect on token nodes, mid-composition DOM stability, composed text placement, edge cases (solo ruby, paragraph-first ruby, offset 0 redirect).
  • 4 unit tests (LexicalUtils.test.ts) — STATIC_NODE_CONFIG_CACHE restoration.
  • 20 e2e tests (Ruby.spec.mjs) across Chromium/Firefox/WebKit — insert, DOM structure, arrow navigation, backspace/delete, select-all, toggle off, copy-paste, JSON round-trip, exportDOM, Shift+arrow selection, line boundary navigation.
  • tsc --noEmit / prettier / eslint clean.
  • Manual playground (Chrome, Firefox, Safari on macOS) — all scenarios below pass on all three browsers:
Manual test scenarios (all three browsers)

Setup — paste this structure into each test:

document.addEventListener('copy', (e) => { e.preventDefault(); e.clipboardData.setData('text/html', '前<ruby>漢<rt>かん</rt></ruby><ruby>字<rt>じ</rt></ruby>後'); }, {once: true}); document.execCommand('copy');

Arrow navigation

  • 前|漢 → Right → 漢|字 (one move across ruby boundary)
  • 字|後 → Right → 後| (ruby → plain text)
  • |前 → Right → 前|漢 (plain text → ruby)
  • 漢|字 → Left → 前|漢 (ruby boundary, backward)
  • Shift+Right extends selection past ruby
  • Cmd+Right jumps to end of line

Typing at boundaries

  • 前|漢 (ruby start) → type → inserted outside ruby, after
  • 字|後 (ruby end) → type → inserted outside ruby, before

Backspace / Delete

  • 前|漢 → Backspace → deleted
  • 字|後 → Delete → deleted
  • Select ruby → Backspace → ruby node removed

Selection + delete

  • Drag-select 漢字 → Delete → both rubies deleted, 前後 remains
  • Drag-select 漢字後 → Delete → ruby + plain text deleted, remains
  • Cmd+A → Delete → all deleted, empty paragraph

Copy / paste

  • Select ruby → copy → paste → ruby structure preserved
  • External <ruby> HTML paste → converted to RubyNode
  • <rp> tags in pasted HTML → ignored correctly

Undo / Redo

  • Create ruby via toolbar → Cmd+Z → ruby unwrapped
  • Cmd+Z → Cmd+Shift+Z → ruby restored

Ruby create / remove

  • Select text → ruby toolbar icon → floating editor → type annotation → Enter → ruby created
  • Click existing ruby → floating editor → edit annotation → Enter → annotation updated
  • Click existing ruby → trash button → unwrapped to plain text
  • No selection → ruby toolbar icon → nothing happens

Serialization

  • JSON export contains type: "ruby", annotation: "かん", text: "漢"
  • JSON round-trip (export → parseEditorState → setEditorState) → no visual change
  • HTML export produces <ruby>漢<rt>かん</rt></ruby>

Edge cases

  • Paragraph-start ruby: Left at |漢 → no movement
  • Paragraph-end ruby: Right at 字| → no movement
  • Ruby-only paragraph: select → delete → empty paragraph remains
  • Three consecutive rubies: each boundary navigable in one arrow press

Design notes

Package placement: RubyNode lives in lexical-playground, not in a dedicated @lexical/ruby package. The node and extension are structured for extraction if there's interest — RubyNode has no playground dependencies, and RubyExtension only depends on @lexical/html for the import rule.

Token-mode TextNode vs. inline ElementNode: The original design was an inline ElementNode subclass (like LinkNode) with the base text as a child TextNode. During implementation this turned out to be impractical for several reasons:

  • Cursor enters the element freely, but ruby base text shouldn't be directly editable — changing 漢 to something else while keeping annotation かん doesn't make sense as a default editing model. Token mode gives this atomicity without extra cursor-trapping logic.
  • IME composition on a child TextNode inside an inline ElementNode creates a cascade of issues with Lexical's reconciler and browser selection normalization at element boundaries. The reconciler tries to restore DOM state mid-composition, and Safari in particular normalizes the cursor back into the element across compositionstart / compositionend. With token mode, composition is handled by the core token-redirect path (which this PR fixes) rather than fighting per-element boundary conditions.
  • Selection across multiple ruby elements produces partial-element selections that need extensive normalization. Token mode treats each ruby as an atomic point in the selection, matching how <ruby> behaves in HTML — base text and annotation are a single unit.

The tradeoff is that formatting the base text independently (bold only on the kanji) isn't possible without unwrapping, but this matches standard <ruby> semantics.

Composition visual limitation (Safari): When IME composition starts immediately after a ruby node, Safari normalizes the cursor into the preceding ruby's inner <span> rather than the adjacent TextNode. The composed text therefore appears inside the ruby DOM. Since the annotation is rendered via ::after on the same element, showing both the annotation and the composing text in the same space would overlap. The extension detects this via compositionstart / compositionupdate listeners and adds a --composing CSS class that hides the ::after annotation until compositionend. The result is that the previous ruby's furigana temporarily disappears during the first character's composition, then reappears once composition ends and the text is redirected to its own TextNode.

We tried several approaches to avoid this:

  • Moving the cursor to the adjacent TextNode programmatically before composition: Safari re-normalizes it back into the ruby span, undoing the move.
  • Inserting a ZWSP gap TextNode between the ruby and the next node to give Safari a landing target: the gap triggers $normalizeTextNode which merges it into the adjacent text, and if guarded with toggleUnmergeable(), a transform loop removes and re-creates it indefinitely.
  • Modifying DOM during compositionstart: breaks the IME state entirely, producing garbled input.

Hiding the annotation during composition was the least-bad option — functional correctness is preserved, and the visual disruption lasts only for the first composed character.

mayrang added 15 commits June 24, 2026 05:28
… token mode)

RubyNode as TextNode subclass with token mode for Japanese/Chinese furigana.
Safari IME composition handled via isCompositionOnToken guard in $handleInput
that lets browser manage DOM text during composition on token nodes.

- RubyNode: token mode, CSS display:ruby, data-ruby-annotation for ::after
- $nudgeOffRuby: selection nudge with isComposing() skip guard
- isCompositionOnToken: guard before $shouldPreventDefaultAndInsertText
- Token markDirty skip during composition in $updateTextNodeFromDOMContent
- Toolbar toggle, HTML import rule, 9 unit tests

Ref: facebook#7787
…atomically

Arrow keys at ruby boundaries now skip all adjacent ruby nodes in one
press instead of getting stuck. Separate KEY_ARROW handlers prevent
$nudgeOffRuby (click normalization) from interfering with keyboard nav.

Ref: facebook#7787
… adjacent

Each arrow press now skips exactly one ruby node. Between adjacent rubies,
the cursor lands stably and the next press exits in the pressed direction
without getting stuck.

Ref: facebook#7787
- Rewrite $skipRubyOnArrow to walk through all adjacent ruby nodes
  and land on the first non-ruby TextNode beyond the group
- Add event.preventDefault() so the browser doesn't override the
  Lexical selection with its own caret movement
- Fix tests: combine selection-set + dispatch into single editor.update
  to avoid reconciliation moving selection between discrete updates
…ift+arrow selection, CSS tuning

- createDOM returns wrapper span + inner span with getDOMSlot redirect
- Backspace at ruby boundary deletes the preceding ruby node
- Shift+arrow skips ruby groups (focus-only movement) with normalization workaround
- Composition class toggle for hiding annotation during IME input
- CSS: position:relative + ::after for furigana, baseline alignment
…t + arrow key selection improvements

Handle composition ending on token-mode RubyNode by redirecting the
composed text to the adjacent TextNode via selection.insertText, fixing
Safari/Firefox IME input.

Improve Shift+arrow ruby skipping: walk through consecutive ruby groups
instead of checking only the immediate sibling, fixing Safari where DOM
selection normalization places focus on the RubyNode. Use safe offsets
to prevent lexical normalization from pulling focus back onto the ruby.

Handle arrow navigation at line boundaries when ruby is the first/last
node, fixing Firefox where the browser default gets stuck on wrapper spans.
…ion tests

Unit tests (41 cases): arrow skip, Shift+arrow selection extension,
consecutive ruby group walk, line boundary fallback, backspace handler,
guard conditions (modifier keys, non-collapsed, text middle).

E2e tests (20 cases): toolbar insert, DOM structure, arrow skip,
backspace/delete atomic deletion, Shift+arrow across single and
consecutive ruby groups, line boundary navigation when ruby is
first/last/only child, copy-paste, JSON serialization, toggle off.
…oolbar icon

Replace window.prompt() ruby annotation input with a floating inline
editor (FloatingRubyEditorPlugin), following the FloatingLinkEditorPlugin
pattern. The editor appears anchored to the selection when creating new
ruby, or to the ruby DOM element when editing existing annotations.

- Add FloatingRubyEditorPlugin with @floating-ui/react positioning
- Click on existing ruby node opens editor with current annotation
- Toolbar button opens editor only when text is selected
- Hide FloatingTextFormatToolbar while ruby editor is active
- Replace ルビ katakana toolbar text with ruby.svg icon
- Enter to confirm, Escape to dismiss, focusout auto-close
Round 3 review identified that removing getStaticNodeConfig() caching
and createEditor invalid-node-class validation was unintentional scope
creep from the ruby branch. Restore both to match origin/main.
$onCompositionEndImpl always set selection to textLen before insertText,
which means token redirect always went to the next sibling. When
composing at offset 0 (e.g. start of a token node), the redirect should
go to the previous sibling instead. Use the selection's actual offset
when the anchor is already on the composition node.
Six new tests covering COMPOSITION_END redirect on token nodes in edge
cases: offset 0 (redirect to previous sibling), paragraph-first ruby,
and solo ruby (no adjacent TextNode). All fail on main (no token
redirect path) and pass with the $onCompositionEndImpl token redirect.
…st.for

Remove dead guard after assert, unify arrow test update splitting to
single-update pattern. 23 tests, same coverage, ~180 fewer lines.
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview Jun 26, 2026 12:12pm
lexical-playground Ready Ready Preview Jun 26, 2026 12:12pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 23, 2026

@potatowagon potatowagon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.

Assessment: Needs fixes before merge (CI failing) — but the feature design is solid.

What this PR does

Major new feature: Ruby annotation node with floating editor for the playground. Ruby annotations (<ruby>漢<rt>かん</rt></ruby>) are essential for CJK text to show pronunciation guides (furigana/pinyin). Implementation includes:

  • RubyNode (nodes/RubyNode.ts): TextNode subclass in token mode with $config() protocol, annotation state via createState, dual DOM representation (wrapper span > inner span with data-ruby-annotation CSS annotation via ::after), semantic exportDOM producing <ruby>/<rt>.
  • RubyExtension (plugins/RubyExtension/index.ts): Comprehensive extension handling arrow key navigation (skips ruby groups atomically), Shift+arrow selection extension, backspace deletion, $nudgeOffRuby for selection normalization, Safari IME composition guards, and DOMImportExtension for <ruby> HTML import.
  • FloatingRubyEditorPlugin: Floating UI editor (annotation input) triggered via toolbar button or click on existing ruby node.
  • Core Lexical changes (LexicalEvents.ts, LexicalUtils.ts): Modifies $handleInput, $handleCompositionStart, $onCompositionEndImpl, and $updateTextNodeFromDOMContent to support composition on token nodes — redirecting composed text to adjacent TextNodes instead of mutating the token.

What I checked

  1. Architecture: Token-mode TextNode with wrapper DOM and DOMSlot is the right pattern for inline decorators that carry text. The $config() protocol usage is correct.
  2. Arrow navigation: The $skipRubyOnArrow logic correctly walks consecutive ruby groups and handles boundary conditions (ruby as first/last/only child → moves to parent element point).
  3. Composition handling: The Safari IME composition guard ($nudgeOffRuby skips when composing, $updateTextNodeFromDOMContent early-returns for composing tokens) is critical — prevents data loss during CJK input.
  4. Core changes: The modifications to $onCompositionEndImpl (now returns boolean to indicate token-redirect) and $handleInput (new isCompositionOnToken guard) are well-scoped. The token redirect at composition-end correctly uses selection.insertText(data) which handles the token boundary insertion natively.
  5. Test coverage: Excellent — 764-line e2e spec (20+ tests), 1395-line unit test, 534-line composition test. Covers arrow skip, shift+arrow, backspace, copy/paste, toggle on/off, serialization, exportDOM, consecutive rubies, boundary cases, guard conditions.

CI Status — ❌ TWO FAILURES

  1. Integrity (lint): no-shadow error in RubyExtension/index.ts line 229 — variable sel shadows the sel import from @lexical/html. Easy fix: rename the local variable (e.g., domSel or nativeSel).

  2. E2e tests (11 failures): Multiple Ruby e2e tests failing with assertion errors — "Ruby DOM has wrapper span with inner annotated span", "Arrow left skips over ruby node", etc. These are the PR's own new tests failing, suggesting the floating editor or DOM structure in the real browser environment differs slightly from what the tests expect. Needs investigation.

www compatibility

No www compat concerns — all playground-only changes except the core LexicalEvents.ts / LexicalUtils.ts modifications. The core changes are additive (new code paths for token+composing) and don't alter existing behavior for non-token nodes. The $onCompositionEndImpl return type change from void to boolean is internal (not exported).

Suggestions

  • Fix the lint no-shadow error (rename local sel to nativeSel on line 229 of RubyExtension/index.ts)
  • Investigate the 11 e2e test failures — likely a timing or DOM structure mismatch
  • Consider whether the core LexicalEvents.ts changes should be in a separate PR for easier review/rollback (they're the highest-risk part)

Overall excellent work — comprehensive feature with thorough test coverage and proper IME handling. Just needs the CI fixes.

…ursorAt in Ruby e2e tests

Home/End keys land at token-mode ruby boundaries in Chromium (e.g. A:1
instead of A:0), making moveRight/moveLeft step counts incorrect. Use
editor.update + node.select for deterministic cursor placement.
@mayrang

mayrang commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

IME work is always a handful, but this ruby one was something else 😂

@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Jun 24, 2026
The ruby toolbar UI initialization timing differs under yjs
collaboration, causing 1s timeout on the floating editor locator.
…bkit e2e compatibility

Webkit doesn't reliably select a single character via Home → selectCharacters(right, 1) on short strings, causing ruby toolbar insertion to timeout. Use programmatic node.select(0, 1) instead for the 3 affected tests. Also removes a redundant selectNodeText call in the adjacent ruby test.
mayrang added 4 commits June 24, 2026 22:00
Replace bare window.getSelection() with getDOMSelection(editor._window)
for Shadow DOM / iframe compatibility. Consolidate unit test patterns:
editor.read() return values, test.for for modifier-key guards.
…xpansion, rp fallback

- Remove stale isRubyClick guard in CLICK_COMMAND handler
- Preserve format/style when unwrapping ruby nodes via $toggleRuby and $unwrapRubiesInSelection
- Use $getNodeByKey in handleDelete to avoid selection-dependent lookup
- Expand $nudgeOffRuby to handle mid-offsets and walk consecutive ruby nodes
- Add <rp> fallback tags in exportDOM for ruby-unsupported environments
… nudge expansion, rp fallback"

This reverts commit 17d8356.
…elete, rp fallback

- Remove stale isRubyClick guard in CLICK_COMMAND handler
- Preserve format/style when unwrapping ruby nodes via $toggleRuby and $unwrapRubiesInSelection
- Use $getNodeByKey in handleDelete to avoid selection-dependent lookup
- Add <rp> fallback tags in exportDOM for ruby-unsupported environments
- Copy format/style from source text when creating ruby via $toggleRuby
- Add role="group" and aria-label to ruby DOM wrapper for screen readers
- Add aria-labels to floating editor input and buttons
- Add Enter/Space keyboard activation to floating editor buttons

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Haven't had a chance to take a close look at the code yet but the functionality here is really cool! Seems to work well, was surprised that copy and paste of ruby html from wikipedia worked perfectly.

When IME composition starts on a token or segmented TextNode (e.g.
MentionNode, RubyNode), the browser writes composed text directly
into the node's DOM — breaking segmented nodes (entity destruction)
and losing input on token nodes (revert on compositionEnd).

Add a guard in $handleCompositionStart so that composition on these
restricted nodes triggers COMPOSITION_START_CHAR insertion, which
creates an adjacent TextNode via the existing insertText boundary
logic and redirects composition there. Fixes facebook#6296.
@mayrang

mayrang commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — hope it helps users who need ruby annotations!

While auditing open IME/composition issues, I found that #6296 is still reproducible on current main. Added a commit that fixes it.

When IME composition starts on a segmented TextNode (like MentionNode), the browser writes composed text directly into the node's DOM, which triggers the segmented replacement and destroys the entity mid-composition. $handleCompositionStart now redirects composition to a new TextNode via COMPOSITION_START_CHAR — the same mechanism already used for element cursors and format mismatches.

Tested with Chinese pinyin and Japanese IME on Chrome, Firefox, and Safari.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: support of ruby characters

3 participants