[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741
[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741mayrang wants to merge 26 commits into
Conversation
… 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
…remove empty CSS rule
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
potatowagon
left a comment
There was a problem hiding this comment.
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 viacreateState, dual DOM representation (wrapper span > inner span withdata-ruby-annotationCSS annotation via::after), semanticexportDOMproducing<ruby>/<rt>. - RubyExtension (
plugins/RubyExtension/index.ts): Comprehensive extension handling arrow key navigation (skips ruby groups atomically), Shift+arrow selection extension, backspace deletion,$nudgeOffRubyfor selection normalization, Safari IME composition guards, andDOMImportExtensionfor<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$updateTextNodeFromDOMContentto support composition on token nodes — redirecting composed text to adjacent TextNodes instead of mutating the token.
What I checked
- 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. - Arrow navigation: The
$skipRubyOnArrowlogic correctly walks consecutive ruby groups and handles boundary conditions (ruby as first/last/only child → moves to parent element point). - Composition handling: The Safari IME composition guard (
$nudgeOffRubyskips when composing,$updateTextNodeFromDOMContentearly-returns for composing tokens) is critical — prevents data loss during CJK input. - Core changes: The modifications to
$onCompositionEndImpl(now returnsbooleanto indicate token-redirect) and$handleInput(newisCompositionOnTokenguard) are well-scoped. The token redirect at composition-end correctly usesselection.insertText(data)which handles the token boundary insertion natively. - 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
-
Integrity (lint):
no-shadowerror inRubyExtension/index.tsline 229 — variableselshadows theselimport from@lexical/html. Easy fix: rename the local variable (e.g.,domSelornativeSel). -
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-shadowerror (rename localseltonativeSelon line 229 of RubyExtension/index.ts) - Investigate the 11 e2e test failures — likely a timing or DOM structure mismatch
- Consider whether the core
LexicalEvents.tschanges 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.
|
IME work is always a handful, but this ruby one was something else 😂 |
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.
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
left a comment
There was a problem hiding this comment.
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.
|
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. |
Description
Adds a playground-level
RubyNode(extendsTextNodein 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-modeTextNodesubclass with an__annotationfield. The DOM is a wrapper<span>containing an inner<span data-ruby-annotation="...">so the annotation text renders via CSS::before.exportDOMproduces 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. ASELECTION_CHANGEhandler nudges collapsed cursors off non-composing ruby nodes to prevent the caret from landing inside a token.Floating editor (
plugins/FloatingRubyEditorPlugin/) — follows theFloatingLinkEditorPluginpattern. 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, theFloatingTextFormatToolbaris suppressed so the two don't overlap.Core changes (
LexicalEvents.ts,LexicalUtils.ts) — targeted fixes for IME composition on token-modeTextNodesubclasses:$onCompositionEndImplnow detects when composition ends on a token node and redirects the composed text to the adjacentTextNodeviaselection.insertText, instead of lettingmarkDirtysilently discard it. Uses the actualselection.anchor.offsetto decide direction — offset 0 redirects to the previous sibling, offset=textLen to the next.$handleInputskips$shouldPreventDefaultAndInsertTextduringinsertCompositionTexton token nodes, so the browser's native composition UI stays intact until composition ends.$updateTextNodeFromDOMContentbails early when a composing token node's DOM is synced, preventing the reconciler from reverting mid-composition input.$handleCompositionStartnow redirects composition away from token/segmented nodes by insertingCOMPOSITION_START_CHAR, which triggers the existinginsertTextboundary logic to create an adjacentTextNode. 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
TextNodesubclass benefits from them, not justRubyNode.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
RubyNode.test.ts) — arrow skip, Shift+arrow selection, consecutive ruby group walk, line boundary fallback, backspace, guard conditions.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).LexicalUtils.test.ts) —STATIC_NODE_CONFIG_CACHErestoration.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 test scenarios (all three browsers)
Setup — paste this structure into each test:
Arrow navigation
前|漢→ Right →漢|字(one move across ruby boundary)字|後→ Right →後|(ruby → plain text)|前→ Right →前|漢(plain text → ruby)漢|字→ Left →前|漢(ruby boundary, backward)Typing at boundaries
前|漢(ruby start) → typeあ→ inserted outside ruby, after前字|後(ruby end) → typeあ→ inserted outside ruby, before後Backspace / Delete
前|漢→ Backspace →前deleted字|後→ Delete →後deleted漢→ Backspace → ruby node removedSelection + delete
漢字→ Delete → both rubies deleted,前後remains漢字後→ Delete → ruby + plain text deleted,前remainsCopy / paste
<ruby>HTML paste → converted to RubyNode<rp>tags in pasted HTML → ignored correctlyUndo / Redo
Ruby create / remove
Serialization
type: "ruby",annotation: "かん",text: "漢"<ruby>漢<rt>かん</rt></ruby>Edge cases
|漢→ no movement字|→ no movementDesign notes
Package placement:
RubyNodelives inlexical-playground, not in a dedicated@lexical/rubypackage. The node and extension are structured for extraction if there's interest —RubyNodehas no playground dependencies, andRubyExtensiononly depends on@lexical/htmlfor the import rule.Token-mode TextNode vs. inline ElementNode: The original design was an inline
ElementNodesubclass (likeLinkNode) with the base text as a childTextNode. During implementation this turned out to be impractical for several reasons:TextNodeinside an inlineElementNodecreates 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 acrosscompositionstart/compositionend. With token mode, composition is handled by the core token-redirect path (which this PR fixes) rather than fighting per-element boundary conditions.<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 adjacentTextNode. The composed text therefore appears inside the ruby DOM. Since the annotation is rendered via::afteron the same element, showing both the annotation and the composing text in the same space would overlap. The extension detects this viacompositionstart/compositionupdatelisteners and adds a--composingCSS class that hides the::afterannotation untilcompositionend. 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 ownTextNode.We tried several approaches to avoid this:
TextNodeprogrammatically before composition: Safari re-normalizes it back into the ruby span, undoing the move.TextNodebetween the ruby and the next node to give Safari a landing target: the gap triggers$normalizeTextNodewhich merges it into the adjacent text, and if guarded withtoggleUnmergeable(), a transform loop removes and re-creates it indefinitely.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.