Skip to content

[lexical-playground][lexical-website] Feature: Non-printing marks (#8592)#8594

Merged
etrepum merged 13 commits into
facebook:mainfrom
mayrang:feat/8592-visible-non-printing
May 31, 2026
Merged

[lexical-playground][lexical-website] Feature: Non-printing marks (#8592)#8594
etrepum merged 13 commits into
facebook:mainfrom
mayrang:feat/8592-visible-non-printing

Conversation

@mayrang

@mayrang mayrang commented May 30, 2026

Copy link
Copy Markdown
Contributor

Description

Turns the playground's VisibleLineBreakExtension into VisibleNonPrintingExtension, 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

  • Line break — wraps each LineBreakNode's <br> in a <span> carrying the marker, and exposes the inner <br> through $getDOMSlot so selection / caret keep targeting the canonical element. Skipped inside CodeNode.
  • Paragraph — shared data-lexical-visible-non-printing-block on ParagraphNode, HeadingNode, ListItemNode, QuoteNode via a single typed domOverride<ElementNode>([...]). Rendered as CSS ::after. The marker is position: absolute; :has(> br:last-child) snaps it to bottom: 0 when the block ends with the placeholder <br> $reconcileElementTerminatingLineBreak adds for Shift+Enter, so the marker shares the empty line instead of bumping onto a new one. The direct-child selector keeps a wrapped LineBreakNode's inner <br> from accidentally matching.
  • Tab data- attribute on TabNode rendered via ::before, centered with position: absolute; transform: translate(-50%, -50%) so the arrow sits in the middle of the tab whitespace. Tab pressed on a block-start caret goes through INDENT_CONTENT_COMMAND (handled by TabIndentationExtension) and produces no TabNode, so indent is intentionally not marked — matching Word's separation of indent from literal tab.
  • Space · — an inline 304-byte WOFF2 with unicode-range: U+0020 remaps only the space glyph to a centered middle dot. Gated by data-lexical-visible-non-printing-active on the editor root, toggled from the disabled signal. Zero DOM mutation on TextNode, 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-image gradient (needs a monospace font); (c) font-feature-settings: cv11-style stylistic alternates (require a specific font family). The unicode-range font 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

disabled is a signal; flipping it mirrors into the editor render context via $setRenderContextValue, the disabledForEditor predicate recomputes for each override, and the render config recompiles so DOM is recreated without restarting the editor. The empty-root listener is registered inside the effect callback so it tears down (and clears any stale data-…-empty-root attribute) 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 the unicode-range font swap used here, so leaving the issue open for follow-up.

Test plan

  • pnpm tsc --noEmit -p tsconfig.json — clean
  • pnpm flow — clean
  • prettier + eslint clean on changed files
  • Manual playground (Chrome, macOS):
    • Paragraph — regular <p>text</p>text¶ inline. Shift+Enter at end: trailing <br> shares the empty line with . Empty root: playground placeholder visible, no .
    • Heading / List item / Quote renders on each surface; floating link editor and drag handle anchor correctly (the unconditional position: relative on the marker selector is not a regression).
    • Line break — wrapped <br> shows ; inside a code block the wrap is skipped.
    • Tab — typing foo\tbar shows foo → bar with the arrow centered in the tab whitespace; pressing Tab on an empty line indents without rendering .
    • Space · — 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.
    • Toggle — turning the Settings switch off restores normal rendering and clears the data-…-active / data-…-empty-root attributes from the root.

The surface area is wide — flag any case I missed and I'll patch.

@vercel

vercel Bot commented May 30, 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, Comment May 31, 2026 6:44am
lexical-playground Ready Ready Preview, Comment May 31, 2026 6:44am

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 May 30, 2026
@etrepum

etrepum commented May 30, 2026

Copy link
Copy Markdown
Collaborator

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!

@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.

Merge conflicts need to be resolved but overall this looks good! The screenshot has the font at default 16 and then 28
Screenshot 2026-05-30 at 07 42 57

Comment thread packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts Outdated
Comment thread packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts Outdated
Comment thread packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts Outdated
Comment thread packages/lexical-playground/src/themes/PlaygroundEditorTheme.css Outdated
@potatowagon

Copy link
Copy Markdown
Contributor

Review: Non-Printing Marks Extension

Reviewed by: Navi (AI review assistant for @potatowagon)

Summary

Replaces the existing VisibleLineBreakExtension with a broader VisibleNonPrintingExtension that surfaces visual markers for multiple non-printing characters: line breaks (), paragraph/block ends (), tabs (), and spaces (· via an inline WOFF2 font with unicode-range: U+0020).

What I Verified

  • Extension architecture: Correctly uses DOMRenderExtension + domOverride pattern — no node subclassing needed. The disabledForEditor gating via createRenderState + $setRenderContextValue is well-structured.
  • Code block skip logic: $skipForCodeChild correctly walks ancestor chain to avoid marking inside CodeNode (code blocks have their own visual structure).
  • Block node markers: ParagraphNode, HeadingNode, ListItemNode, QuoteNode all get the data-lexical-visible-non-printing-block attribute. CSS ::after with position: absolute and the :has(> br:last-child) selector for trailing line break alignment is clever.
  • Tab markers: position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) correctly centers the arrow in the tab whitespace.
  • Space rendering via font: Zero DOM mutation approach using a custom WOFF2 (304 bytes) is elegant — IME, selection, and caret behavior stay intact. Gated by data-lexical-visible-non-printing-active attribute on the editor root.
  • Empty root handling: The syncEmptyRootAttr listener properly removes the pilcrow marker when the editor is empty (placeholder state), avoiding a confusing on an empty editor.
  • Cleanup: The effect() callback returns the registerUpdateListener cleanup, and when disabled, removes the root attribute and short-circuits. Good lifecycle management.
  • Settings integration: isVisibleLineBreakisVisibleNonPrinting rename is consistent across appSettings.ts, Settings.tsx, and Editor.tsx.

Concerns / Blockers

  1. Merge conflicts — etrepum noted merge conflicts need resolving. This must be rebased before merging.
  2. CI coverage — Only CLA + Vercel preview checks have run. The full CI suite (unit tests, e2e across browsers) hasn't been triggered (likely needs first-time contributor workflow approval). Can't verify browser compatibility without those runs.
  3. No dedicated unit/e2e tests — The extension is playground-only and off by default, but given the complexity of DOM override interactions (especially with IME and selection), at least one basic test verifying the markers appear/disappear when toggling would increase confidence.

Recommendation

Needs 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

mayrang added 5 commits May 31, 2026 01:47
…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.
mayrang added a commit to mayrang/lexical that referenced this pull request May 30, 2026
…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.
@mayrang mayrang force-pushed the feat/8592-visible-non-printing branch from b758859 to 268dae7 Compare May 30, 2026 17:07
mayrang added a commit to mayrang/lexical that referenced this pull request May 30, 2026
…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.
@mayrang mayrang force-pushed the feat/8592-visible-non-printing branch from 268dae7 to 44050f7 Compare May 30, 2026 17:18
@mayrang

mayrang commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto origin/main; the conflicts folded into useSynchronizeSettings.ts where the signal sync had moved. The 28pt screenshot caught a real issue — at large block font sizes the glyph started overlapping adjacent text — so the tab ::before now caps at font-size: 0.7em.

@potatowagon

Copy link
Copy Markdown
Contributor

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:

  1. Renamed VisibleLineBreakExtensionVisibleNonPrintingExtension — reflects the expanded scope (now covers linebreaks, paragraph marks, tabs, and spaces). All references in App.tsx, Settings.tsx, appSettings.ts, and useSynchronizeSettings.ts updated.

  2. Extended coverage beyond linebreaks:

    • ParagraphNode / HeadingNode / ListItemNode / QuoteNode — ¶ marker via CSS ::after with data-lexical-visible-non-printing-block attribute
    • TabNode — → marker via CSS ::before with centered positioning
    • Space (U+0020) — inline WOFF2 font with unicode-range: U+0020 remaps space glyph to a middle-dot (·). Zero DOM mutation on TextNode, preserving IME/selection behavior.
  3. Added build-space-dot-font.py — reproducible script to regenerate the inline WOFF2 payload using fontTools. Good engineering practice for maintaining embedded binary assets.

  4. disabledForEditor pattern — overrides are removed from the render pipeline entirely when disabled (not just no-oping), which is the correct approach for performance.

Code quality assessment:

  • Well-documented — extensive JSDoc explaining the rendering strategy for each node type
  • Uses correct extension patterns (domOverride, ``, createRenderState)
  • `` correctly excludes code blocks from visible marks
  • The WOFF2-based space dot approach is clever — avoids per-character DOM wrapping that would break IME

CI status: Nearly all green. Two checks still pending (check_should_run + canary e2e). Previous run showed merge conflicts were the blocker — those are now resolved (mergeable: MERGEABLE).

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 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.

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

Image
Comment on lines +12 to +19
# 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
#

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — added PEP 723 inline metadata, uv run auto-installs fonttools and brotli.

@levensta

levensta commented May 30, 2026

Copy link
Copy Markdown
Contributor

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.mov

P.S. Only in Chrome

@etrepum

etrepum commented May 30, 2026

Copy link
Copy Markdown
Collaborator

I think that glitch can be fixed if the TabNode spans did not inherit the LexicalSpaceDot font-family

@etrepum

etrepum commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Probably what we should do is apply that font only to TextNode that are not TabNode rather than cascading it through

@levensta

levensta commented May 30, 2026

Copy link
Copy Markdown
Contributor

I think that glitch can be fixed if the TabNode spans did not inherit the LexicalSpaceDot font-family

Oh, right! The same goes for LineBreakNode although this glitch doesn't reproduce with it, it doesn't hurt for cleanliness

@etrepum

etrepum commented May 30, 2026

Copy link
Copy Markdown
Collaborator

LineBreakNode isn't a TextNode subclass, but TabNode is.

@etrepum etrepum added the do-not-merge-yet This PR may be approved but might need another round of changes before merge label May 31, 2026
…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
@mayrang

mayrang commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

@levensta thanks for catching the TabNode selection artifact.

@etrepum thanks for the cleanup with $decorateDOM. After pulling these commits I tried the playground in Chrome and the space marker (·) doesn't show on default paragraphs (no toolbar font applied) — applyTextFontPrepend early-returns when dom.style.fontFamily === '', and the editor-root font-family rule that previously covered those spans is gone. Is the "only when necessary" intent to scope the marker to inline-font-styled spans, or is the default-span path still WIP? Let me know which way you want to take it.

@etrepum

etrepum commented May 31, 2026

Copy link
Copy Markdown
Collaborator

I had missed that case and pushed a bit early, I think this latest commit should sort out the known quirks playground link

Screenshot 2026-05-30 at 23 47 42
@etrepum etrepum removed the do-not-merge-yet This PR may be approved but might need another round of changes before merge label May 31, 2026
@etrepum

etrepum commented May 31, 2026

Copy link
Copy Markdown
Collaborator

This version looks good to me, is there anything else you'd like to do before merge?

@potatowagon

Copy link
Copy Markdown
Contributor

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:

  • Added --font-family CSS custom property to body, .PlaygroundEditorTheme__textCode, and .PlaygroundEditorTheme__code — provides a cascading fallback for the space-dot font prepend logic
  • $decorateDOM for TextNode/TabNode now falls back to 'var(--font-family)' when no inline font-family is set, preventing the early-return when dom.style.fontFamily === ''
  • TabNode decoration unified with TextNode via shared $decorateDOM override (since TabNode extends TextNode)

What I verified:

  • The do-not-merge-yet label has been removed
  • CI is all green (30+ jobs pass; only mac-webkit and collab-mac-webkit still pending — typical slow runners)
  • Core tests, unit tests, integration tests all pass
  • etrepum has approved and is asking if anything else is needed before merge
  • The font prepend logic correctly handles the three cases: inline font-family present, node.__style font-family, and no font-family (var fallback)
  • unicode-range: U+0020 scoping is preserved — only the space glyph gets substituted
  • Code blocks are properly excluded from linebreak wrapping via $skipForCodeChild

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.

@mayrang

mayrang commented May 31, 2026

Copy link
Copy Markdown
Contributor Author

Manual-tested locally and everything looks good on my end.

@etrepum etrepum added this pull request to the merge queue May 31, 2026
Merged via the queue into facebook:main with commit d902767 May 31, 2026
34 checks passed

@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.

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 — defineExtension with 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 lexical or 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.

@etrepum etrepum mentioned this pull request Jun 25, 2026
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

5 participants