Add a "show invisibles" formatting-marks toggle - #1141
Conversation
Adds an opt-in capability (`showInvisibles`, off by default) that reveals formatting marks while composing, Word/Google-Docs style: `¶` at the end of each paragraph and heading, and `↵` at every soft return (Shift+Enter). When enabled, a toolbar button toggles the marks; they start hidden. The `↵` mark can't be done in CSS alone — browsers don't paint ::before/::after on a bare `<br>`, and a run of `<br><br>` offers no element to hang a marker on. A `ShowInvisiblesExtension` replaces `LineBreakNode` with a `MarkableLineBreakNode` whose editor DOM wraps the `<br>` in a markable span, while `exportDOM` still emits a plain `<br>` so serialized content is byte-identical. The wrapper is `contenteditable="false"` so it stays atomic to the browser's native Selection.modify — deleting and extending across a soft return behave exactly like a bare `<br>`. Marks are pseudo-elements scoped to the editor only (never the rendered `.lexxy-content`), so they never leak into the saved value or text selections. The empty-paragraph placeholder `<br>` is Lexical's managed break, not a LineBreakNode, so blank lines show `¶` and never `↵`. Covered by Playwright tests across Chromium, Firefox and WebKit.
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “show invisibles” capability to Lexxy (enabled via config/attribute) that lets writers toggle formatting marks while composing, without changing serialized HTML.
Changes:
- Introduces a
MarkableLineBreakNodeand aShowInvisiblesExtensionto render soft returns as markable editor-only DOM while exporting plain<br>. - Adds editor-scoped CSS for
¶(paragraph ends) and↵(soft returns), plus a toolbar toggle button. - Adds Playwright coverage and a dedicated fixture page for the feature.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
test/browser/tests/formatting/show_invisibles.test.js |
New Playwright suite covering toggle behavior, DOM rendering, serialization, and editing across soft returns. |
test/browser/fixtures/show-invisibles.html |
Fixture enabling show-invisibles for browser tests. |
src/nodes/markable_line_break_node.js |
New node that wraps soft returns in a markable span for editor-only rendering. |
src/extensions/show_invisibles_extension.js |
New extension wiring node replacement + toolbar toggle/class management. |
src/elements/toolbar_icons.js |
Adds pilcrow-style toolbar icon for the new toggle. |
src/elements/editor.js |
Registers the new extension in the editor’s base extension set. |
src/config/lexxy.js |
Adds showInvisibles default preset option (disabled by default). |
docs/configuration.md |
Documents the new showInvisibles option and expected behavior. |
app/assets/stylesheets/lexxy-editor.css |
Adds editor-scoped pseudo-element styling for formatting marks. |
Files not reviewed (1)
- src/elements/toolbar_icons.js: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| initializeToolbar(toolbar) { | ||
| this.#button = this.#createButton() | ||
| this.#insertButton(toolbar) | ||
| this.#listeners.track(registerEventListener(this.#button, "click", this.#toggle)) | ||
| } |
There was a problem hiding this comment.
Good catch on the toggle state. Fixed in 417f784: #createButton now derives aria-pressed from the live lexxy-editor__content--show-invisibles class via a #marksVisible getter, so the button can never disagree with the editor even if the content element is reused with marks still on.
On the leaked listeners: I don't think this one manifests. Extensions (and therefore each extension instance) is recreated per connectedCallback (new Extensions(this)), and initializeToolbars() runs exactly once per instance — only from #attachToolbar() during #initialize(). So a given ShowInvisiblesExtension gets initializeToolbar called once and is torn down via dispose() when its Extensions is disposed on reset. #clearPreviousExtensionToolbarButtons clears buttons left by previous Extensions instances on a persistent/external toolbar, not repeat calls on the same instance. On top of that, registerEventListener retains only WeakRefs to the element and listener, and ListenerBin tears them down on dispose(), so there's nothing to accumulate. Happy to add an explicit re-entrancy guard if you'd prefer the method be idempotent regardless.
There was a problem hiding this comment.
Follow-up after a self-review pass: I walked the #marksVisible change back in fb4a589 and went back to a plain aria-pressed="false" at creation. Tracing the lifecycle more carefully, the content element is recreated fresh on every connect — disconnectedCallback → #reset() removes and nulls editorContentElement, so by the time initializeToolbar/#createButton runs the content element never carries the marks class yet. The getter therefore only ever computed the constant false, so it was dead weight rather than added safety. Runtime state is still managed in #toggle, which flips the class and aria-pressed together. Net: the original concern doesn't manifest, and the code stays minimal.
The toolbar button hardcoded aria-pressed="false" on creation. If the content element is ever reused with the marks class still applied, the button would claim "off" while marks are visible. Read the actual class instead so the button can't disagree with the editor. Addresses review comment on src/extensions/show_invisibles_extension.js:32
Remove the #marksVisible getter. The content element is recreated fresh on every connect (#reset nulls it on disconnect), so the toolbar button is always built against a class-free element and aria-pressed="false" at creation is correct — the getter only ever computed that constant. Runtime state stays managed in #toggle. Also trim the markable-line-break comment and the showInvisibles docs entry to the load-bearing facts.
|
RDY for feedback / review @jorgemanrubia |
What
Adds an opt-in "show invisibles" capability — a Word/Google-Docs-style toggle that reveals formatting marks while composing:
¶at the end of each paragraph and heading (hard returns)↵at every soft return (Shift+Enter)It's off by default. Enable it per editor or via a preset:
When enabled, a toolbar button toggles the marks on and off; they start hidden. Blank lines show
¶(never↵).Why this needs the gem (and can't be pure CSS)
A CSS-only approximation can do
¶(viap::after), but the soft-return↵can't be done in CSS: browsers don't paint::before/::afteron a bare<br>, and a run of<br><br>offers no element to hang a marker on. The clean fix is to render the line break's editor DOM as a markable element while keepingexportDOMa plain<br>, so stored content is untouched.How
ShowInvisiblesExtension(aLexxyExtension, enabled only whenshowInvisiblesis truthy and rich text is on) replacesLineBreakNodewith aMarkableLineBreakNode:createDOM()wraps the<br>in<span class="lexxy-line-break" contenteditable="false">, which CSS can mark.exportDOM()still emits a plain<br>— serialized HTML is byte-identical, so the Action Text round-trip is unchanged.contenteditable="false"keeps the wrapper atomic to the browser's nativeSelection.modify, so deleting/extending across a soft return behaves exactly like a bare<br>(without it the caret lands inside the span and the break resists deletion)..lexxy-editor__content--show-invisibles), never the rendered.lexxy-content, so they never leak into the saved value or into text selections.<br>is Lexical's managed break (not aLineBreakNode), so it's never wrapped — blank lines naturally show¶and never↵.Tests
New Playwright suite (
test/browser/tests/formatting/show_invisibles.test.js), green on Chromium, Firefox and WebKit:aria-pressed<br>; consecutive breaks each marked↵/¶glyphs render only when toggled on; never leak into the valueFull existing Playwright suite still passes; the capability is inert by default, so there's no impact unless
show-invisiblesis set.Happy to adjust the shape (option name, button placement/icon, whether to also mark
·spaces / tabs).