From 9f19549125c687fd23245a1565847d22e7307ea4 Mon Sep 17 00:00:00 2001 From: Roman Sklenar Date: Wed, 17 Jun 2026 20:48:00 +0200 Subject: [PATCH 1/3] Add a "show invisibles" formatting-marks toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
`, and a run of `

` offers no element to hang a marker on. A `ShowInvisiblesExtension` replaces `LineBreakNode` with a `MarkableLineBreakNode` whose editor DOM wraps the `
` in a markable span, while `exportDOM` still emits a plain `
` 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 `
`. 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 `
` is Lexical's managed break, not a LineBreakNode, so blank lines show `¶` and never `↵`. Covered by Playwright tests across Chromium, Firefox and WebKit. --- app/assets/stylesheets/lexxy-editor.css | 24 +++ docs/configuration.md | 1 + src/config/lexxy.js | 1 + src/elements/editor.js | 4 +- src/elements/toolbar_icons.js | 5 + src/extensions/show_invisibles_extension.js | 65 ++++++++ src/nodes/markable_line_break_node.js | 28 ++++ test/browser/fixtures/show-invisibles.html | 20 +++ .../tests/formatting/show_invisibles.test.js | 144 ++++++++++++++++++ 9 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 src/extensions/show_invisibles_extension.js create mode 100644 src/nodes/markable_line_break_node.js create mode 100644 test/browser/fixtures/show-invisibles.html create mode 100644 test/browser/tests/formatting/show_invisibles.test.js diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index a80e99d21..9df39201b 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -425,6 +425,30 @@ outline: 2px dashed var(--lexxy-color-selected-dark); } +/* Formatting marks ("show invisibles"): painted only in the editor, never in the + rendered .lexxy-content. The glyphs are pseudo-elements, so they stay out of the + serialized value and out of text selections. */ +:where(.lexxy-editor__content--show-invisibles) { + :is(p, h1, h2, h3, h4, h5, h6)::after, + .lexxy-line-break::before { + color: var(--lexxy-color-ink-light); + -webkit-user-select: none; + + @supports (user-select: none) { + user-select: none; + } + } + + :is(p, h1, h2, h3, h4, h5, h6)::after { + content: "¶"; + font-weight: normal; + } + + .lexxy-line-break::before { + content: "↵"; + } +} + :where([data-lexical-cursor]) { animation: blink 1s infinite; block-size: 1lh; diff --git a/docs/configuration.md b/docs/configuration.md index 07522f3ab..40a9cbb08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,6 +50,7 @@ Editors support the following options, configurable using presets and element at - `multiLine`: Pass `false` to force single line editing. - `permittedAttachmentTypes`: Restrict the editor to a specific allowlist of attachment content types. Unset (the default) permits any content type. Example: ``. - `richText`: Pass `false` to disable rich text editing. +- `showInvisibles`: Pass `true` to enable the "show invisibles" capability — a Word/Google-Docs-style toolbar toggle that reveals formatting marks while composing: `¶` at the end of each paragraph and heading, and `↵` at each soft return (`Shift`+`Enter`). Disabled by default. The marks are painted only inside the editor (never in the rendered content) and never alter the saved HTML. The toggle starts off; the writer clicks the toolbar button to reveal the marks. Example: ``. The toolbar is considered part of the editor for `lexxy:focus` and `lexxy:blur` events. If the toolbar registers event or lexical handlers, it should expose a `dispose()` function which will be called on editor disconnect. diff --git a/src/config/lexxy.js b/src/config/lexxy.js index 4899b5c2c..14980ae30 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -15,6 +15,7 @@ const presets = new Configuration({ multiLine: true, permittedAttachmentTypes: null, richText: true, + showInvisibles: false, toolbar: { upload: "both" }, diff --git a/src/elements/editor.js b/src/elements/editor.js index bb501ef06..12d7b5b84 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -41,6 +41,7 @@ import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" import { LinkOpenerExtension } from "../extensions/link_opener_extension.js" import { PreventLexicalTripleClickExtension } from "../extensions/prevent_lexical_triple_click_extension.js" +import { ShowInvisiblesExtension } from "../extensions/show_invisibles_extension.js" import { nextFrame } from "../helpers/timing_helper.js" @@ -194,7 +195,8 @@ export class LexicalEditorElement extends HTMLElement { AttachmentsExtension, FormatEscapeExtension, LinkOpenerExtension, - PreventLexicalTripleClickExtension + PreventLexicalTripleClickExtension, + ShowInvisiblesExtension ] } diff --git a/src/elements/toolbar_icons.js b/src/elements/toolbar_icons.js index 5867b13b9..576362a32 100644 --- a/src/elements/toolbar_icons.js +++ b/src/elements/toolbar_icons.js @@ -119,5 +119,10 @@ export default { "overflow": ` + `, + + "showInvisibles": + ` + ` } diff --git a/src/extensions/show_invisibles_extension.js b/src/extensions/show_invisibles_extension.js new file mode 100644 index 000000000..930d87e5c --- /dev/null +++ b/src/extensions/show_invisibles_extension.js @@ -0,0 +1,65 @@ +import { LineBreakNode, defineExtension } from "lexical" +import LexxyExtension from "./lexxy_extension" +import { MarkableLineBreakNode } from "../nodes/markable_line_break_node" +import ToolbarIcons from "../elements/toolbar_icons" +import { createElement } from "../helpers/html_helper" +import { ListenerBin, registerEventListener } from "../helpers/listener_helper" + +export const SHOW_INVISIBLES_CLASS = "lexxy-editor__content--show-invisibles" + +export class ShowInvisiblesExtension extends LexxyExtension { + #button + #listeners = new ListenerBin() + + get enabled() { + return this.editorElement.supportsRichText && this.editorConfig.get("showInvisibles") + } + + get lexicalExtension() { + return defineExtension({ + name: "lexxy/show-invisibles", + nodes: [ + MarkableLineBreakNode, + { replace: LineBreakNode, with: () => new MarkableLineBreakNode(), withKlass: MarkableLineBreakNode } + ] + }) + } + + initializeToolbar(toolbar) { + this.#button = this.#createButton() + this.#insertButton(toolbar) + this.#listeners.track(registerEventListener(this.#button, "click", this.#toggle)) + } + + dispose() { + this.#listeners.dispose() + } + + #createButton() { + return createElement("button", { + type: "button", + name: "show-invisibles", + class: "lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end", + title: "Show formatting marks", + "aria-pressed": "false" + }, ToolbarIcons.showInvisibles) + } + + #insertButton(toolbar) { + const pushRight = toolbar.querySelector(".lexxy-editor__toolbar-button--push-right") + if (pushRight) { + pushRight.insertAdjacentElement("beforebegin", this.#button) + } else { + toolbar.appendChild(this.#button) + } + } + + #toggle = () => { + const visible = this.#contentElement.classList.toggle(SHOW_INVISIBLES_CLASS) + this.#button.setAttribute("aria-pressed", visible.toString()) + } + + get #contentElement() { + return this.editorElement.editorContentElement + } +} diff --git a/src/nodes/markable_line_break_node.js b/src/nodes/markable_line_break_node.js new file mode 100644 index 000000000..301e2471b --- /dev/null +++ b/src/nodes/markable_line_break_node.js @@ -0,0 +1,28 @@ +import { LineBreakNode } from "lexical" + +// Renders a soft return as a markable element in the editor DOM so a formatting +// mark can be painted on it via CSS. Browsers don't draw ::before/::after on a +// bare
, and a run of

offers no element to hang a marker on, so the +//
is wrapped in a span the stylesheet can target. exportDOM still emits a +// plain
, leaving serialized content untouched. +export class MarkableLineBreakNode extends LineBreakNode { + $config() { + return this.config("markable_line_break", { extends: LineBreakNode }) + } + + createDOM() { + const element = document.createElement("span") + element.className = "lexxy-line-break" + // contenteditable="false" makes the wrapper atomic to the browser's native + // Selection.modify, so deleting/extending across the break behaves exactly + // like a bare
. Without it the caret can land inside the span and the + // break resists deletion. + element.contentEditable = "false" + element.appendChild(document.createElement("br")) + return element + } + + exportDOM() { + return { element: document.createElement("br") } + } +} diff --git a/test/browser/fixtures/show-invisibles.html b/test/browser/fixtures/show-invisibles.html new file mode 100644 index 000000000..9935ae907 --- /dev/null +++ b/test/browser/fixtures/show-invisibles.html @@ -0,0 +1,20 @@ + + + + + + Lexxy Test — Show Invisibles + + + +
+
+ +
+ +
+
+ + + + diff --git a/test/browser/tests/formatting/show_invisibles.test.js b/test/browser/tests/formatting/show_invisibles.test.js new file mode 100644 index 000000000..899763293 --- /dev/null +++ b/test/browser/tests/formatting/show_invisibles.test.js @@ -0,0 +1,144 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { assertEditorHtml, startMonitoringConsole } from "../../helpers/assertions.js" + +const SHOW_INVISIBLES_CLASS = "lexxy-editor__content--show-invisibles" + +function button(page) { + return page.locator("lexxy-toolbar button[name='show-invisibles']") +} + +async function pseudoContent(locator, pseudo) { + return locator.evaluate( + (el, pseudo) => window.getComputedStyle(el, pseudo).content, + pseudo, + ) +} + +test.describe("Show invisibles", () => { + test("the toolbar button is absent unless the option is enabled", async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(button(page)).toHaveCount(0) + }) + + test.describe("when enabled", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/show-invisibles.html") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("the toolbar button appears", async ({ page }) => { + await expect(button(page)).toBeVisible() + }) + + test("marks are hidden until the button is toggled on", async ({ page, editor }) => { + await expect(button(page)).toHaveAttribute("aria-pressed", "false") + await expect(editor.content).not.toHaveClass(new RegExp(SHOW_INVISIBLES_CLASS)) + + await button(page).click() + + await expect(button(page)).toHaveAttribute("aria-pressed", "true") + await expect(editor.content).toHaveClass(new RegExp(SHOW_INVISIBLES_CLASS)) + + await button(page).click() + + await expect(button(page)).toHaveAttribute("aria-pressed", "false") + await expect(editor.content).not.toHaveClass(new RegExp(SHOW_INVISIBLES_CLASS)) + }) + + test("a soft return is rendered as a markable element but serialized as a plain
", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "World") + + await assertEditorHtml(editor, "

Hello
World

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(1) + }) + + test("consecutive soft returns each render a markable element", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "Shift+Enter", "World") + + await assertEditorHtml(editor, "

Hello

World

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(2) + }) + + test("an empty paragraph placeholder is not a markable soft return", async ({ page, editor }) => { + await editor.send("Hello", "Enter", "Enter", "World") + + await assertEditorHtml(editor, "

Hello


World

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(0) + }) + + test("shows the return glyph on soft returns when toggled on", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "World") + const softReturn = editor.content.locator("span.lexxy-line-break").first() + + expect(await pseudoContent(softReturn, "::before")).toBe("none") + + await button(page).click() + + expect(await pseudoContent(softReturn, "::before")).toBe('"↵"') + }) + + test("shows the pilcrow glyph at paragraph ends when toggled on", async ({ page, editor }) => { + await editor.send("Hello") + const paragraph = editor.content.locator("p").first() + + expect(await pseudoContent(paragraph, "::after")).toBe("none") + + await button(page).click() + + expect(await pseudoContent(paragraph, "::after")).toBe('"¶"') + }) + + test("marks never leak into the serialized value", async ({ page, editor }) => { + await button(page).click() + await editor.send("Hello", "Shift+Enter", "World") + + await assertEditorHtml(editor, "

Hello
World

") + }) + + test("a soft return loaded from saved HTML round-trips unchanged", async ({ page, editor }) => { + await editor.setValue("

Hello
World

") + + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(1) + await assertEditorHtml(editor, "

Hello
World

") + }) + + test("backspace removes a soft return", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "World") + await editor.send("ArrowLeft", "ArrowLeft", "ArrowLeft", "ArrowLeft", "ArrowLeft", "Backspace") + + await assertEditorHtml(editor, "

HelloWorld

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(0) + }) + + test("forward delete removes a soft return", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "World") + await editor.send("ArrowLeft", "ArrowLeft", "ArrowLeft", "ArrowLeft", "ArrowLeft", "ArrowLeft", "Delete") + + await assertEditorHtml(editor, "

HelloWorld

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(0) + }) + + test("a selection spanning a soft return can be replaced", async ({ page, editor }) => { + await editor.send("Hello", "Shift+Enter", "World") + await editor.selectAll() + await editor.send("Bye") + + await assertEditorHtml(editor, "

Bye

") + await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(0) + }) + + test("toggling and editing raises no console errors", async ({ page, editor }) => { + startMonitoringConsole(page) + + await button(page).click() + await editor.send("Hello", "Shift+Enter", "World", "Enter", "Again") + await button(page).click() + + await assertEditorHtml(editor, "

Hello
World

Again

") + expect(page).toHaveNoErrors() + }) + }) +}) From 417f784fa9e6c4be54e30b150664a3c57fed4d10 Mon Sep 17 00:00:00 2001 From: Roman Sklenar Date: Thu, 18 Jun 2026 00:02:54 +0200 Subject: [PATCH 2/3] Fix(show-invisibles): derive toggle button state from the live class 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 --- src/extensions/show_invisibles_extension.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extensions/show_invisibles_extension.js b/src/extensions/show_invisibles_extension.js index 930d87e5c..f0b0f6edf 100644 --- a/src/extensions/show_invisibles_extension.js +++ b/src/extensions/show_invisibles_extension.js @@ -41,7 +41,7 @@ export class ShowInvisiblesExtension extends LexxyExtension { name: "show-invisibles", class: "lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end", title: "Show formatting marks", - "aria-pressed": "false" + "aria-pressed": this.#marksVisible.toString() }, ToolbarIcons.showInvisibles) } @@ -59,6 +59,10 @@ export class ShowInvisiblesExtension extends LexxyExtension { this.#button.setAttribute("aria-pressed", visible.toString()) } + get #marksVisible() { + return this.#contentElement.classList.contains(SHOW_INVISIBLES_CLASS) + } + get #contentElement() { return this.editorElement.editorContentElement } From fb4a589c0707e3dcb77f972bf4c46309b5eea538 Mon Sep 17 00:00:00 2001 From: Roman Sklenar Date: Thu, 18 Jun 2026 00:11:18 +0200 Subject: [PATCH 3/3] Refactor(show-invisibles): tidy after self-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/configuration.md | 2 +- src/extensions/show_invisibles_extension.js | 13 +++---------- src/nodes/markable_line_break_node.js | 9 ++++----- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 40a9cbb08..ddcc1aafa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,7 +50,7 @@ Editors support the following options, configurable using presets and element at - `multiLine`: Pass `false` to force single line editing. - `permittedAttachmentTypes`: Restrict the editor to a specific allowlist of attachment content types. Unset (the default) permits any content type. Example: ``. - `richText`: Pass `false` to disable rich text editing. -- `showInvisibles`: Pass `true` to enable the "show invisibles" capability — a Word/Google-Docs-style toolbar toggle that reveals formatting marks while composing: `¶` at the end of each paragraph and heading, and `↵` at each soft return (`Shift`+`Enter`). Disabled by default. The marks are painted only inside the editor (never in the rendered content) and never alter the saved HTML. The toggle starts off; the writer clicks the toolbar button to reveal the marks. Example: ``. +- `showInvisibles`: Pass `true` to add a Word/Google-Docs-style toolbar toggle that reveals formatting marks while composing — `¶` at paragraph and heading ends, and `↵` at each soft return (`Shift`+`Enter`). Disabled by default; the marks are editor-only and never change the saved HTML. Example: ``. The toolbar is considered part of the editor for `lexxy:focus` and `lexxy:blur` events. If the toolbar registers event or lexical handlers, it should expose a `dispose()` function which will be called on editor disconnect. diff --git a/src/extensions/show_invisibles_extension.js b/src/extensions/show_invisibles_extension.js index f0b0f6edf..450853a0a 100644 --- a/src/extensions/show_invisibles_extension.js +++ b/src/extensions/show_invisibles_extension.js @@ -41,7 +41,7 @@ export class ShowInvisiblesExtension extends LexxyExtension { name: "show-invisibles", class: "lexxy-editor__toolbar-button lexxy-editor__toolbar-group-end", title: "Show formatting marks", - "aria-pressed": this.#marksVisible.toString() + "aria-pressed": "false" }, ToolbarIcons.showInvisibles) } @@ -55,15 +55,8 @@ export class ShowInvisiblesExtension extends LexxyExtension { } #toggle = () => { - const visible = this.#contentElement.classList.toggle(SHOW_INVISIBLES_CLASS) + const content = this.editorElement.editorContentElement + const visible = content.classList.toggle(SHOW_INVISIBLES_CLASS) this.#button.setAttribute("aria-pressed", visible.toString()) } - - get #marksVisible() { - return this.#contentElement.classList.contains(SHOW_INVISIBLES_CLASS) - } - - get #contentElement() { - return this.editorElement.editorContentElement - } } diff --git a/src/nodes/markable_line_break_node.js b/src/nodes/markable_line_break_node.js index 301e2471b..5d6fbc7ad 100644 --- a/src/nodes/markable_line_break_node.js +++ b/src/nodes/markable_line_break_node.js @@ -1,10 +1,9 @@ import { LineBreakNode } from "lexical" -// Renders a soft return as a markable element in the editor DOM so a formatting -// mark can be painted on it via CSS. Browsers don't draw ::before/::after on a -// bare
, and a run of

offers no element to hang a marker on, so the -//
is wrapped in a span the stylesheet can target. exportDOM still emits a -// plain
, leaving serialized content untouched. +// Wraps a soft return's
in a markable span so CSS can paint a formatting +// mark on it: browsers don't draw ::before/::after on a bare
, and a run of +//

offers no element to hang a marker on. exportDOM still emits a plain +//
, so serialized content is unchanged. export class MarkableLineBreakNode extends LineBreakNode { $config() { return this.config("markable_line_break", { extends: LineBreakNode })