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 @@ + + + + + + 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() + }) + }) +})