Skip to content

Add a "show invisibles" formatting-marks toggle - #1141

Open
romansklenar wants to merge 4 commits into
basecamp:mainfrom
romansklenar:show-invisibles
Open

Add a "show invisibles" formatting-marks toggle#1141
romansklenar wants to merge 4 commits into
basecamp:mainfrom
romansklenar:show-invisibles

Conversation

@romansklenar

@romansklenar romansklenar commented Jun 17, 2026

Copy link
Copy Markdown

What

Screenshot 2026-06-17 at 23 49 26

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:

<lexxy-editor show-invisibles="true"></lexxy-editor>

When enabled, a toolbar button toggles the marks on and off; they start hidden. Blank lines show (never ).

Composing with marks on: First paragraph¶, a blank line, Soft break here↵, after the break↵, a lone , then two breaks above¶.

Why this needs the gem (and can't be pure CSS)

A CSS-only approximation can do (via p::after), but the soft-return can't be done in CSS: browsers don't paint ::before/::after on 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 keeping exportDOM a plain <br>, so stored content is untouched.

How

  • ShowInvisiblesExtension (a LexxyExtension, enabled only when showInvisibles is truthy and rich text is on) replaces LineBreakNode with a MarkableLineBreakNode:
    • 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 native Selection.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).
  • The marks are pseudo-elements scoped to the editor only (.lexxy-editor__content--show-invisibles), never the rendered .lexxy-content, so they never leak into the saved value or into text selections.
  • The empty-paragraph placeholder <br> is Lexical's managed break (not a LineBreakNode), so it's never wrapped — blank lines naturally show and never .
  • A toolbar button (pilcrow icon) toggles the editor-only CSS class.

Tests

New Playwright suite (test/browser/tests/formatting/show_invisibles.test.js), green on Chromium, Firefox and WebKit:

  • button presence gated on the option; toggle flips the class + aria-pressed
  • soft return renders a markable element but serializes as <br>; consecutive breaks each marked
  • empty-paragraph placeholder is not a markable soft return
  • / glyphs render only when toggled on; never leak into the value
  • saved-HTML round-trip unchanged
  • backspace / forward-delete / select-and-replace across a soft return; no console errors

Full existing Playwright suite still passes; the capability is inert by default, so there's no impact unless show-invisibles is set.


Happy to adjust the shape (option name, button placement/icon, whether to also mark · spaces / tabs).

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.
@romansklenar
romansklenar marked this pull request as ready for review June 17, 2026 21:51
Copilot AI review requested due to automatic review settings June 17, 2026 21:51

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

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

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 MarkableLineBreakNode and a ShowInvisiblesExtension to 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.

Comment on lines +28 to +32
initializeToolbar(toolbar) {
this.#button = this.#createButton()
this.#insertButton(toolbar)
this.#listeners.track(registerEventListener(this.#button, "click", this.#toggle))
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.
Copilot AI review requested due to automatic review settings June 23, 2026 09:16
@romansklenar

Copy link
Copy Markdown
Author

RDY for feedback / review @jorgemanrubia

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • src/elements/toolbar_icons.js: Generated file

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants