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
7 changes: 7 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,13 @@
display: none;
}

&[data-disabled-marks~="bold"] button[name="bold"],
&[data-disabled-marks~="italic"] button[name="italic"],
&[data-disabled-marks~="strikethrough"] button[name="strikethrough"],
&[data-disabled-marks~="underline"] button[name="underline"] {
display: none;
}

&[data-upload="file"] button[name="image"] {
display: none;
}
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Editors support the following options, configurable using presets and element at
- `toolbar.upload`: Control which upload button(s) appear in the toolbar. Accepts `"file"`, `"image"`, or `"both"` (default). The image button restricts the file picker to images and videos (`accept="image/*,video/*"`), which triggers the native photo/video picker on iOS and Android. The file button opens an unrestricted file picker.
- `attachments`: Pass `false` to disable attachments completely. By default, attachments are supported, including paste and drag & drop support. For finer-grained control — keeping attachments enabled while restricting which content types are accepted — use `permittedAttachmentTypes`.
- `markdown`: Pass `false` to disable Markdown support.
- `marks`: Choose which inline marks the editor allows, as an allowlist. Defaults to `["bold", "italic", "strikethrough", "underline"]` (all). Any mark left out is disabled everywhere — its toolbar button is hidden, its keyboard shortcut and Markdown shortcut are inert, and its markup is reduced to plain text on import (paste, `value`, and initial content). Pass `[]` to disable all inline marks. Example: `<lexxy-editor marks='["bold", "italic"]'></lexxy-editor>`.
- `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.
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,
marks: [ "bold", "italic", "strikethrough", "underline" ],
toolbar: {
upload: "both"
},
Expand Down
19 changes: 19 additions & 0 deletions src/editor/command_dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
$isRangeSelection,
$isTextNode,
$setSelection,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_NORMAL,
FORMAT_TEXT_COMMAND,
INDENT_CONTENT_COMMAND,
Expand Down Expand Up @@ -68,6 +69,7 @@ export class CommandDispatcher {
this.contents = editorElement.contents

this.#registerCommands()
this.#registerDisabledMarkInterceptor()
this.#registerKeyboardCommands()
this.#registerDragAndDropHandlers()
}
Expand Down Expand Up @@ -299,6 +301,23 @@ export class CommandDispatcher {
}
}

// Swallow FORMAT_TEXT_COMMAND for disabled marks at high priority, before
// Lexical's rich-text handler (registered at COMMAND_PRIORITY_EDITOR) can apply
// them. This single hook covers every path that ends in FORMAT_TEXT_COMMAND:
// the toolbar buttons, programmatic dispatch, and the native Cmd+B/I/U shortcuts
// Lexical dispatches internally even when the button is gone.
#registerDisabledMarkInterceptor() {
const disabledMarks = this.editorElement.disabledMarks
if (disabledMarks.length === 0) return

const disabled = new Set(disabledMarks)
this.#registerCommandHandler(
FORMAT_TEXT_COMMAND,
COMMAND_PRIORITY_HIGH,
(format) => disabled.has(format)
)
}

#registerCommandHandler(command, priority, handler) {
this.#listeners.track(this.editor.registerCommand(command, handler, priority))
}
Expand Down
32 changes: 32 additions & 0 deletions src/editor/marks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Inline marks that can be enabled or disabled through the `marks` option.
// Frozen because the editor's enabledMarks getter can return it directly.
export const MARK_TYPES = Object.freeze([ "bold", "italic", "strikethrough", "underline" ])

// Semantic HTML tags each mark is imported from. Deleting these tags from the
// editor's html conversions strips the mark on import: the default TextNode
// conversion and the legacy Trix conversion share the same tag key, so dropping
// the key removes both. (`del` is contributed by the Trix content extension; the
// rest come from Lexical's TextNode.importDOM.)
//
// Note: because the Trix conversion under these keys also carries legacy highlight
// color (e.g. `<em style="color:…">`), disabling a mark drops that co-located color
// too — an accepted consequence of stripping a now-disabled feature on load.
export const MARK_TO_TAGS = {
bold: [ "b", "strong" ],
italic: [ "i", "em" ],
strikethrough: [ "s", "del" ],
underline: [ "u" ]
}

// Drop the markdown transformers that would produce a disabled mark. Text-format
// transformers expose a `format` array (e.g. ["bold"] or ["bold", "italic"]); a
// combined transformer is dropped when either of its formats is disabled.
// Transformers without a `format` (headings, lists, links…) are left untouched.
export function withoutDisabledMarkTransformers(transformers, disabledMarks) {
if (disabledMarks.length === 0) return transformers

const disabled = new Set(disabledMarks)
return transformers.filter((transformer) =>
!Array.isArray(transformer.format) || !transformer.format.some((format) => disabled.has(format))
)
}
71 changes: 66 additions & 5 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/
import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown"
import { HORIZONTAL_DIVIDER } from "../editor/markdown/horizontal_divider_transformer"
import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler"
import { MARK_TO_TAGS, MARK_TYPES, withoutDisabledMarkTransformers } from "../editor/marks"

import theme from "../config/theme"
import { HorizontalDividerNode } from "../nodes/horizontal_divider_node"
Expand Down Expand Up @@ -47,7 +48,6 @@ import { nextFrame } from "../helpers/timing_helper.js"
export class LexicalEditorElement extends HTMLElement {
static formAssociated = true
static debug = false
static commands = [ "bold", "italic", "strikethrough" ]

static observedAttributes = [ "connected", "required" ]

Expand Down Expand Up @@ -260,6 +260,32 @@ export class LexicalEditorElement extends HTMLElement {
return this.config.get("richText")
}

// The inline marks the editor allows, as an allow-list intersected with the known
// mark types. Accepts an array (`marks='["bold"]'`) or a whitespace-separated string
// (`marks="bold italic"`), mirroring `permittedAttachmentTypes`. Any other value —
// a bare/empty attribute, a boolean, a number — is not a valid allow-list and falls
// back to all marks enabled, so a misconfiguration never silently disables everything.
// Use `marks='[]'` to disable every mark.
get enabledMarks() {
const configured = this.config.get("marks")

let list
if (Array.isArray(configured)) {
list = configured
} else if (typeof configured === "string" && configured.trim() !== "") {
list = configured.split(/\s+/)
} else {
return MARK_TYPES
}

return Object.freeze(MARK_TYPES.filter((mark) => list.includes(mark)))
}

get disabledMarks() {
const enabled = this.enabledMarks
return Object.freeze(MARK_TYPES.filter((mark) => !enabled.includes(mark)))
}
Comment on lines +269 to +287

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. MARK_TYPES is now frozen at the module level so the fall-back return MARK_TYPES cannot be mutated by a host, and both enabledMarks and disabledMarks now return Object.freezed arrays, matching permittedAttachmentTypes. disabledMarks also computes enabledMarks once rather than per-iteration. Added a test asserting both getters return frozen arrays.


registerAdapter(adapter) {
this.adapter = adapter

Expand Down Expand Up @@ -407,6 +433,7 @@ export class LexicalEditorElement extends HTMLElement {
export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
},
$initialEditorState: (editor) => {
this.#removeDisabledConversions(editor)
this.#configureSanitizer(editor)
this.#loadInitialValue(editor)
this.#setInternalFormValue(this.#readSanitizedEditorValue(editor))
Expand Down Expand Up @@ -576,10 +603,14 @@ export class LexicalEditorElement extends HTMLElement {
registerRichText(this.editor),
registerList(this.editor)
)
this.#registerDisabledMarkStripper(registered)
this.#registerTableComponents()
this.#registerCodeHiglightingComponents()
if (this.supportsMarkdown) {
const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ]
// Both handlers must receive the disabled-mark-filtered list: the leading-tag handler
// applies formats via selection.formatText() (not FORMAT_TEXT_COMMAND), so the command
// interceptor can't stop it — dropping the transformer is what disables the shortcut there.
const transformers = withoutDisabledMarkTransformers([ ...TRANSFORMERS, HORIZONTAL_DIVIDER ], this.disabledMarks)
registered.push(
registerMarkdownShortcuts(this.editor, transformers),
registerMarkdownLeadingTagHandler(this.editor, transformers)
Expand Down Expand Up @@ -607,6 +638,21 @@ export class LexicalEditorElement extends HTMLElement {
this.#disposables.push(codeLanguagePicker)
}

// The import conversions and the FORMAT_TEXT_COMMAND interceptor cover HTML and command
// paths, but content pasted from another Lexical editor arrives as serialized nodes whose
// format bitfields are restored directly. This transform clears any disabled-mark bit so a
// disabled mark can never render in the editor, whatever path produced it.
#registerDisabledMarkStripper(registered) {
const disabledMarks = this.disabledMarks
if (disabledMarks.length === 0) return

registered.push(this.editor.registerNodeTransform(TextNode, (node) => {
for (const mark of disabledMarks) {
if (node.hasFormat(mark)) node.toggleFormat(mark)
}
}))
}

#handleEnter() {
// We can't prevent these externally using regular keydown because Lexical handles it first.
this.#listeners.track(this.editor.registerCommand(
Expand Down Expand Up @@ -716,6 +762,7 @@ export class LexicalEditorElement extends HTMLElement {
const toolbar = createElement("lexxy-toolbar")
toolbar.innerHTML = LexicalToolbar.defaultTemplate
toolbar.setAttribute("data-attachments", this.supportsAttachments) // Drives toolbar CSS styles
toolbar.setAttribute("data-disabled-marks", this.disabledMarks.join(" ")) // Drives toolbar CSS styles
toolbar.configure(this.config.get("toolbar"))
this.prepend(toolbar)
return toolbar
Expand All @@ -725,6 +772,19 @@ export class LexicalEditorElement extends HTMLElement {
this.classList.toggle("lexxy-editor--empty", this.isEmpty)
}

// Drop the html conversions for disabled marks so their markup is reduced to
// plain text on every import path (initial value, setValue, paste). Each tag key
// holds both Lexical's default TextNode conversion and the legacy Trix conversion,
// so deleting it strips the mark regardless of which produced it. It also removes
// the tag from the sanitizer allow-list, which derives from these keys.
#removeDisabledConversions(editor) {
for (const mark of this.disabledMarks) {
for (const tag of MARK_TO_TAGS[mark]) {
editor._htmlConversions?.delete(tag)
}
}
}

#configureSanitizer(editor) {
setSanitizerConfig(this.#getAllowedElements(editor))
}
Expand Down Expand Up @@ -755,9 +815,10 @@ export class LexicalEditorElement extends HTMLElement {
const linkNode = $getNearestNodeOfType(anchorNode, LinkNode)

attributes = {
bold: { active: format.isBold, enabled: true },
italic: { active: format.isItalic, enabled: true },
strikethrough: { active: format.isStrikethrough, enabled: true },
bold: { active: format.isBold, enabled: this.enabledMarks.includes("bold") },
italic: { active: format.isItalic, enabled: this.enabledMarks.includes("italic") },
strikethrough: { active: format.isStrikethrough, enabled: this.enabledMarks.includes("strikethrough") },
underline: { active: format.isUnderline, enabled: this.enabledMarks.includes("underline") },
code: { active: format.isInCode, enabled: true },
highlight: { active: format.isHighlight, enabled: true },
link: { active: format.isInLink, enabled: true },
Expand Down
24 changes: 24 additions & 0 deletions test/browser/fixtures/marks-empty.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Marks Empty</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks="" required></lexxy-editor>
</div>

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

<script type="module" src="/editor.js"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions test/browser/fixtures/marks-limited.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Marks Limited</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks='["bold", "italic"]' required></lexxy-editor>
</div>

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

<script type="module" src="/editor.js"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions test/browser/fixtures/marks-none.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Marks None</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks='[]' required></lexxy-editor>
</div>

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

<script type="module" src="/editor.js"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions test/browser/fixtures/marks-string.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Marks String</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks="bold italic" required></lexxy-editor>
</div>

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

<script type="module" src="/editor.js"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions test/browser/fixtures/marks-true.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Marks True</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks="true" required></lexxy-editor>
</div>

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

<script type="module" src="/editor.js"></script>
</body>
</html>
Loading