[lexical-playground][lexical-website] Feature: Non-printing marks (#8592)#8594
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
The font option is clever, that was the solution I came to the last time I thought about solving this but I didn't realize it would be so straightforward to implement! |
Review: Non-Printing Marks ExtensionReviewed by: Navi (AI review assistant for @potatowagon) SummaryReplaces the existing What I Verified
Concerns / Blockers
RecommendationNeeds changes — resolve merge conflicts and get full CI passing. The implementation quality is high, but it can't be merged in its current state. Once rebased and green, this looks safe to approve (playground-only, opt-in, no core API changes). — via Navi on behalf of @potatowagon |
…akExtension to VisibleNonPrintingExtension Mechanical rename — file, identifiers, setting key, CSS class / data attribute, extension name, doc snippet. No logic change. Sets up the extension to host additional non-printing character categories (¶, →, etc.) in follow-up commits per facebook#8592.
…acebook#8592) Adds a `data-` attribute to each `ParagraphNode` via a `DOMRenderExtension` override; CSS `::after` renders the visible `¶`. Hides the trailing `<br>` placeholder of empty paragraphs so the marker sits on the same line. To avoid overlapping with the editor placeholder when the root is empty, the extension's register syncs a root-level marker attribute from `$canShowPlaceholder` on every editor update; CSS swaps the empty paragraph's marker to a zero-width space when that root flag is set, preserving line height without surfacing ¶.
Adds a `data-` attribute to each `TabNode` via a `DOMRenderExtension` override; CSS `::before` renders the visible `→` ahead of the tab character. Same pattern as the paragraph mark. Paragraph indentation set through `TabIndentationPlugin` (a margin change) is not a tab character and therefore stays unmarked, matching Word / Docs.
…eadings/list items/quotes, fix trailing line break, add space marker - Share `data-lexical-visible-non-printing-block` attribute across `ParagraphNode` / `HeadingNode` / `ListItemNode` / `QuoteNode` via a `$createBlockDOM` helper, so the `¶` marker covers all block surfaces. - Move `¶` to `position: absolute` with `:has(> br:last-child)` overriding `bottom: 0`, so the trailing placeholder `<br>` added by `$reconcileElementTerminatingLineBreak` shares the empty line instead of bumping the marker onto a new one. Direct-child selector keeps the `LineBreakNode` wrapper span's inner `<br>` from matching. - Center the tab `→` marker via absolute positioning so it sits in the middle of the tab whitespace rather than at the left edge. - Render a `·` dot on every space character via an inline WOFF2 with `unicode-range: U+0020`, gated by a new `data-lexical-visible-non-printing-active` attribute toggled in `register` so disabling the extension restores normal space rendering. Zero DOM mutation on `TextNode` — IME, selection, and caret stay untouched.
…es (facebook#8592) - Collapse 4 block `domOverride` calls into a single `domOverride<ElementNode>([ParagraphNode, HeadingNode, ListItemNode, QuoteNode], …)`, and type the `$createBlockDOM` helper as `(_node: ElementNode, …)` instead of `unknown`. - Move the empty-root listener registration inside the `effect` callback so it tears down (and clears the stale `data-…-empty-root` attribute) when the extension is disabled, instead of running forever. - Factor `disabledForEditor: ctx => ctx.get(VisibleNonPrintingDisabled)` into a shared object and drop the unused `visible-non-printing-linebreak` className on the wrapper span. - Document in JSDoc that headings/list items/quotes share the block marker, that indent via `INDENT_CONTENT_COMMAND` is not a `TabNode` and therefore unmarked, and that the space marker is a `unicode-range: U+0020` font swap with no DOM mutation. Add the WOFF2 glyph spec / regeneration pointer on the inline `@font-face` block.
…es (facebook#8594) - Replace the manual ancestor walk in `$skipForCodeChild` with `$findMatchingParent(node, $isCodeNode)`. - Tighten `disabledForEditor`: drop the ad-hoc structural type for the `ctx` argument and annotate the value with `satisfies DOMOverrideOptions` so the predicate inherits the official override-config typing. - Move the empty-root / active-attribute lifecycle from inline `getRootElement()` reads inside the `effect` into `registerRootListener`, so the attributes follow root mount / unmount instead of relying on the root being attached when `effect` first runs. The `effect` keeps the signal-driven `$setRenderContextValue` and gates the listener registration on `disabled`. - Cap the tab arrow at `font-size: 0.7em` so a large block font size doesn't render a glyph wider than the tab whitespace and overlap adjacent text. - Add `packages/lexical-playground/scripts/build-space-dot-font.py` (fontTools) so the inline WOFF2 in the theme CSS has a reproducible source, and update the CSS comment to point at it.
b758859 to
268dae7
Compare
…es (facebook#8594) - Replace the manual ancestor walk in `$skipForCodeChild` with `$findMatchingParent(node, $isCodeNode)`. - Tighten `disabledForEditor`: drop the ad-hoc structural type for the `ctx` argument and annotate the value with `satisfies DOMOverrideOptions` so the predicate inherits the official override-config typing. - Move the empty-root / active-attribute lifecycle from inline `getRootElement()` reads inside the `effect` into `registerRootListener`, so the attributes follow root mount / unmount instead of relying on the root being attached when `effect` first runs. The `effect` keeps the signal-driven `$setRenderContextValue` and gates the listener registration on `disabled`. - Cap the tab arrow at `font-size: 0.7em` so a large block font size doesn't render a glyph wider than the tab whitespace and overlap adjacent text. - Add `packages/lexical-playground/scripts/build-space-dot-font.py` (fontTools) so the inline WOFF2 in the theme CSS has a reproducible source, and update the CSS comment to point at it.
268dae7 to
44050f7
Compare
|
Rebased onto |
|
Follow-up review by Navi (potatowagon's AI assistant) Since last review, this PR has been force-pushed with review fixes (6 new commits). Key changes:
Code quality assessment:
CI status: Nearly all green. Two checks still pending ( Previous concern (merge conflicts): RESOLVED. PR is now mergeable. Verdict: Looks good to land once CI completes. The rename from LineBreak→NonPrinting is a clean extension of the feature. The implementation correctly handles all non-printing character categories without breaking IME, selection, or caret behavior. Playground-only change with no library impact. |
etrepum
left a comment
There was a problem hiding this comment.
I think the spacing isn't quite right, here's a doc to show how it behaves. Probably need some different CSS for tabs and the font character space isn't in the right place.
Is it possible for the replacement font to just remap the space to https://en.wikipedia.org/wiki/Interpunct U+00B7 or do we need to actually define the glyph in the font? Might be tricky to have something that looks great cross-platform because we are using system fonts here which all have different font metrics
It also doesn't currently work for specificity reasons when style="font-family: …" is directly on the span
| # above the inline `src`). Run this from the repo root: | ||
| # | ||
| # python3 packages/lexical-playground/scripts/build-space-dot-font.py | ||
| # | ||
| # Requires `fonttools` and `brotli`: | ||
| # | ||
| # pip3 install --user fonttools brotli | ||
| # |
There was a problem hiding this comment.
This could be simplified with https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies
There was a problem hiding this comment.
Done — added PEP 723 inline metadata, uv run auto-installs fonttools and brotli.
|
I noticed that a dot appears, as if it were a space, but when I select the Tab char Screen.Recording.2026-05-31.at.02.23.26.movP.S. Only in Chrome |
|
I think that glitch can be fixed if the TabNode spans did not inherit the LexicalSpaceDot font-family |
|
Probably what we should do is apply that font only to TextNode that are not TabNode rather than cascading it through |
Oh, right! The same goes for LineBreakNode although this glitch doesn't reproduce with it, it doesn't hurt for cleanliness |
|
LineBreakNode isn't a TextNode subclass, but TabNode is. |
…d middot Replace the square space-dot glyph with a circular middot traced from quadratic Beziers (the native TrueType curve), and fix the left side bearing so the dot is actually centered in the U+0020 advance. The outline was already drawn centered (xMin=70, xMax=190 in a 260 unit advance), but hmtx left it with lsb=0. Browsers position the outline by its advertised lsb, so the mismatch shifted the dot ~70 units left of center. Deriving lsb from the glyph's xMin keeps it centered. https://claude.ai/code/session_01CK34V8SxJzxynLry29TsLH
|
@levensta thanks for catching the TabNode selection artifact. @etrepum thanks for the cleanup with |
|
I had missed that case and pushed a bit early, I think this latest commit should sort out the known quirks playground link
|
|
This version looks good to me, is there anything else you'd like to do before merge? |
|
Follow-up review by Navi (potatowagon's AI assistant) Status update: The regression flagged by @mayrang (space-dot marker not appearing on default paragraphs) has been addressed in the latest commits. What was fixed:
What I verified:
Assessment: Ready to approve. The regression is resolved, CI is green, maintainer has approved. This is a well-designed extension that adds visible markers for spaces (·), tabs (→), linebreaks (↵), and paragraph ends (¶) without modifying text content — preserving IME/composition behavior. |
|
Manual-tested locally and everything looks good on my end. |
potatowagon
left a comment
There was a problem hiding this comment.
Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.
LGTM ✅ — Feature: Non-printing marks (visible whitespace) for the playground.
What this does: Adds a "Non-printing marks" toggle to the playground toolbar that shows visible representations of spaces (dots), tabs (arrows), and newlines (pilcrow marks). Implements via a custom VisibleNonPrintingExtension that uses a signal-based approach, a custom font (SpaceDot — tiny WOFF2 inline font rendering a middot for space characters), and CSS decoration for tabs/paragraphs.
What I checked:
- ✅ Architecture: Uses the extension system correctly —
defineExtensionwith signal-based reactivity and a toolbar extension interface. - ✅ Font generation: Includes a Python script (
build-space-dot-font.py) to regenerate the WOFF2 from source — good reproducibility. - ✅ No library changes: This is entirely in the playground package + website docs. No changes to core
lexicalor other library packages. - ✅ CSS isolation: Styles are scoped to
.PlaygroundEditorTheme__class names with the feature toggle. - ✅ www compat: No library changes, playground/docs only.
- ✅ CI: All checks green.
Safe to land. Nice playground feature addition with clean implementation. The font-based approach for space dots is creative and avoids DOM manipulation overhead.


Description
Turns the playground's
VisibleLineBreakExtensionintoVisibleNonPrintingExtension, covering every non-printing mark Word's "Show formatting marks" surfaces — paragraph¶, line break↵, tab→, space·. Off by default; toggled from Settings.What's marked
↵— wraps eachLineBreakNode's<br>in a<span>carrying the marker, and exposes the inner<br>through$getDOMSlotso selection / caret keep targeting the canonical element. Skipped insideCodeNode.¶— shareddata-lexical-visible-non-printing-blockonParagraphNode,HeadingNode,ListItemNode,QuoteNodevia a single typeddomOverride<ElementNode>([...]). Rendered as CSS::after. The marker isposition: absolute;:has(> br:last-child)snaps it tobottom: 0when the block ends with the placeholder<br>$reconcileElementTerminatingLineBreakadds for Shift+Enter, so the marker shares the empty line instead of bumping onto a new one. The direct-child selector keeps a wrappedLineBreakNode's inner<br>from accidentally matching.→—data-attribute onTabNoderendered via::before, centered withposition: absolute; transform: translate(-50%, -50%)so the arrow sits in the middle of the tab whitespace. Tab pressed on a block-start caret goes throughINDENT_CONTENT_COMMAND(handled byTabIndentationExtension) and produces noTabNode, so indent is intentionally not marked — matching Word's separation of indent from literal tab.·— an inline 304-byte WOFF2 withunicode-range: U+0020remaps only the space glyph to a centered middle dot. Gated bydata-lexical-visible-non-printing-activeon the editor root, toggled from thedisabledsignal. Zero DOM mutation onTextNode, so IME composition, selection, and caret behaviour stay intact. The CSS block has the glyph spec and a regeneration pointer.Trade-off on the space marker
Shipping an inline font asset for what is visually a CSS marker is unusual. Considered (a) wrapping each space in DOM (broke caret / selection); (b) a
background-imagegradient (needs a monospace font); (c)font-feature-settings: cv11-style stylistic alternates (require a specific font family). Theunicode-rangefont swap landed as the only zero-DOM-mutation option that handles variable-width fonts and IME. If there's a cleaner pattern, point at it.Runtime toggle
disabledis a signal; flipping it mirrors into the editor render context via$setRenderContextValue, thedisabledForEditorpredicate recomputes for each override, and the render config recompiles so DOM is recreated without restarting the editor. The empty-root listener is registered inside theeffectcallback so it tears down (and clears any staledata-…-empty-rootattribute) on disable.Partial fix for #8592 — covers
¶ ↵ → ·with the runtime toggle. The issue also calls out ZWSP (U+200B), WJ (U+2060), NBSP (U+00A0), and SHY (U+00AD) for Asian-language and typography use; those zero-width / soft characters need a different mechanism than theunicode-rangefont swap used here, so leaving the issue open for follow-up.Test plan
pnpm tsc --noEmit -p tsconfig.json— cleanpnpm flow— clean¶— regular<p>text</p>→text¶inline. Shift+Enter at end: trailing<br>shares the empty line with¶. Empty root: playground placeholder visible, no¶.¶—¶renders on each surface; floating link editor and drag handle anchor correctly (the unconditionalposition: relativeon the marker selector is not a regression).↵— wrapped<br>shows↵; inside a code block the wrap is skipped.→— typingfoo\tbarshowsfoo → barwith the arrow centered in the tab whitespace; pressing Tab on an empty line indents without rendering→.·— every U+0020 renders as a centered dot; non-Latin text (Korean / CJK) renders normally via system-ui fallback; Markdown shortcuts (#,>,-,[t](url)), autolink, IME composition, backspace / delete, and copy-paste continue to treat U+0020 as space.data-…-active/data-…-empty-rootattributes from the root.The surface area is wide — flag any case I missed and I'll patch.