Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<lexxy-editor permitted-attachment-types="application/vnd.basecamp.mention application/vnd.basecamp.opengraph-embed"></lexxy-editor>`.
- `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: `<lexxy-editor show-invisibles="true"></lexxy-editor>`.

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.

Expand Down
1 change: 1 addition & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const presets = new Configuration({
multiLine: true,
permittedAttachmentTypes: null,
richText: true,
showInvisibles: false,
toolbar: {
upload: "both"
},
Expand Down
4 changes: 3 additions & 1 deletion src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -203,7 +204,8 @@ export class LexicalEditorElement extends HTMLElement {
FormatEscapeExtension,
LinkOpenerExtension,
PreventLexicalTripleClickExtension,
CustomAttachmentDragAndDropExtension
CustomAttachmentDragAndDropExtension,
ShowInvisiblesExtension
]
}

Expand Down
5 changes: 5 additions & 0 deletions src/elements/toolbar_icons.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions src/extensions/show_invisibles_extension.js
Original file line number Diff line number Diff line change
@@ -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())
}
}
27 changes: 27 additions & 0 deletions src/nodes/markable_line_break_node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { LineBreakNode } from "lexical"

// Wraps a soft return's <br> in a markable span so CSS can paint a formatting
// mark on it: browsers don't draw ::before/::after on a bare <br>, and a run of
// <br><br> offers no element to hang a marker on. exportDOM still emits a plain
// <br>, 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 <br>. 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") }
}
}
20 changes: 20 additions & 0 deletions test/browser/fixtures/show-invisibles.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Show Invisibles</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." show-invisibles="true"></lexxy-editor>
</div>

<div class="events"></div>
</form>

<script type="module" src="/editor.js"></script>
</body>
</html>
144 changes: 144 additions & 0 deletions test/browser/tests/formatting/show_invisibles.test.js
Original file line number Diff line number Diff line change
@@ -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 <br>", async ({ page, editor }) => {
await editor.send("Hello", "Shift+Enter", "World")

await assertEditorHtml(editor, "<p>Hello<br>World</p>")
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, "<p>Hello<br><br>World</p>")
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, "<p>Hello</p><p><br></p><p>World</p>")
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, "<p>Hello<br>World</p>")
})

test("a soft return loaded from saved HTML round-trips unchanged", async ({ page, editor }) => {
await editor.setValue("<p>Hello<br>World</p>")

await expect(editor.content.locator("span.lexxy-line-break")).toHaveCount(1)
await assertEditorHtml(editor, "<p>Hello<br>World</p>")
})

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, "<p>HelloWorld</p>")
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, "<p>HelloWorld</p>")
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, "<p>Bye</p>")
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, "<p>Hello<br>World</p><p>Again</p>")
expect(page).toHaveNoErrors()
})
})
})