diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css
index 8b1f88966..a14e87a57 100644
--- a/app/assets/stylesheets/lexxy-editor.css
+++ b/app/assets/stylesheets/lexxy-editor.css
@@ -441,6 +441,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..ddcc1aafa 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 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/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 bc54260eb..52b144508 100644
--- a/src/elements/editor.js
+++ b/src/elements/editor.js
@@ -42,6 +42,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 { CustomAttachmentDragAndDropExtension } from "../extensions/custom_attachment_drag_and_drop_extension.js"
import { nextFrame } from "../helpers/timing_helper.js"
@@ -203,7 +204,8 @@ export class LexicalEditorElement extends HTMLElement {
FormatEscapeExtension,
LinkOpenerExtension,
PreventLexicalTripleClickExtension,
- CustomAttachmentDragAndDropExtension
+ CustomAttachmentDragAndDropExtension,
+ 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..450853a0a
--- /dev/null
+++ b/src/extensions/show_invisibles_extension.js
@@ -0,0 +1,62 @@
+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 content = this.editorElement.editorContentElement
+ const visible = content.classList.toggle(SHOW_INVISIBLES_CLASS)
+ this.#button.setAttribute("aria-pressed", visible.toString())
+ }
+}
diff --git a/src/nodes/markable_line_break_node.js b/src/nodes/markable_line_break_node.js
new file mode 100644
index 000000000..5d6fbc7ad
--- /dev/null
+++ b/src/nodes/markable_line_break_node.js
@@ -0,0 +1,27 @@
+import { LineBreakNode } from "lexical"
+
+// 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 })
+ }
+
+ 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 @@
+
+
+