Skip to content

Commit c2e49b4

Browse files
committed
Add configurable marks support
Make the inline marks (bold, italic, strikethrough, underline) configurable via Lexxy.configure or a `marks` element attribute, as an allow-list. The default enables all four, preserving current behavior. Accepts a JSON array (marks='["bold","italic"]') or a whitespace-separated string (marks="bold italic"); pass [] to disable every mark. A disabled mark is inert everywhere, not just hidden from the toolbar: its FORMAT_TEXT_COMMAND is swallowed by a high-priority interceptor (covering the toolbar button, programmatic dispatch, and the native Cmd+B/I/U shortcuts), its Markdown shortcut transformers are filtered out, its toolbar button is hidden via a data-disabled-marks attribute, and its semantic tags are dropped from the HTML import conversions so saved markup is reduced to plain text on load and paste. A TextNode transform additionally clears any disabled-mark format bit restored from pasted Lexical clipboard data.
1 parent d1c6a80 commit c2e49b4

15 files changed

Lines changed: 522 additions & 6 deletions

File tree

app/assets/stylesheets/lexxy-editor.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,13 @@
463463
display: none;
464464
}
465465

466+
&[data-disabled-marks~="bold"] button[name="bold"],
467+
&[data-disabled-marks~="italic"] button[name="italic"],
468+
&[data-disabled-marks~="strikethrough"] button[name="strikethrough"],
469+
&[data-disabled-marks~="underline"] button[name="underline"] {
470+
display: none;
471+
}
472+
466473
&[data-upload="file"] button[name="image"] {
467474
display: none;
468475
}

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Editors support the following options, configurable using presets and element at
4747
- `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.
4848
- `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`.
4949
- `markdown`: Pass `false` to disable Markdown support.
50+
- `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>`.
5051
- `multiLine`: Pass `false` to force single line editing.
5152
- `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>`.
5253
- `richText`: Pass `false` to disable rich text editing.

src/config/lexxy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const presets = new Configuration({
1515
multiLine: true,
1616
permittedAttachmentTypes: null,
1717
richText: true,
18+
marks: [ "bold", "italic", "strikethrough", "underline" ],
1819
toolbar: {
1920
upload: "both"
2021
},

src/editor/command_dispatcher.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
$isRangeSelection,
55
$isTextNode,
66
$setSelection,
7+
COMMAND_PRIORITY_HIGH,
78
COMMAND_PRIORITY_NORMAL,
89
FORMAT_TEXT_COMMAND,
910
INDENT_CONTENT_COMMAND,
@@ -68,6 +69,7 @@ export class CommandDispatcher {
6869
this.contents = editorElement.contents
6970

7071
this.#registerCommands()
72+
this.#registerDisabledMarkInterceptor()
7173
this.#registerKeyboardCommands()
7274
this.#registerDragAndDropHandlers()
7375
}
@@ -299,6 +301,23 @@ export class CommandDispatcher {
299301
}
300302
}
301303

304+
// Swallow FORMAT_TEXT_COMMAND for disabled marks at high priority, before
305+
// Lexical's rich-text handler (registered at COMMAND_PRIORITY_EDITOR) can apply
306+
// them. This single hook covers every path that ends in FORMAT_TEXT_COMMAND:
307+
// the toolbar buttons, programmatic dispatch, and the native Cmd+B/I/U shortcuts
308+
// Lexical dispatches internally even when the button is gone.
309+
#registerDisabledMarkInterceptor() {
310+
const disabledMarks = this.editorElement.disabledMarks
311+
if (disabledMarks.length === 0) return
312+
313+
const disabled = new Set(disabledMarks)
314+
this.#registerCommandHandler(
315+
FORMAT_TEXT_COMMAND,
316+
COMMAND_PRIORITY_HIGH,
317+
(format) => disabled.has(format)
318+
)
319+
}
320+
302321
#registerCommandHandler(command, priority, handler) {
303322
this.#listeners.track(this.editor.registerCommand(command, handler, priority))
304323
}

src/editor/marks.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Inline marks that can be enabled or disabled through the `marks` option.
2+
export const MARK_TYPES = [ "bold", "italic", "strikethrough", "underline" ]
3+
4+
// Semantic HTML tags each mark is imported from. Deleting these tags from the
5+
// editor's html conversions strips the mark on import: the default TextNode
6+
// conversion and the legacy Trix conversion share the same tag key, so dropping
7+
// the key removes both. (`del` is contributed by the Trix content extension; the
8+
// rest come from Lexical's TextNode.importDOM.)
9+
//
10+
// Note: because the Trix conversion under these keys also carries legacy highlight
11+
// color (e.g. `<em style="color:…">`), disabling a mark drops that co-located color
12+
// too — an accepted consequence of stripping a now-disabled feature on load.
13+
export const MARK_TO_TAGS = {
14+
bold: [ "b", "strong" ],
15+
italic: [ "i", "em" ],
16+
strikethrough: [ "s", "del" ],
17+
underline: [ "u" ]
18+
}
19+
20+
// Drop the markdown transformers that would produce a disabled mark. Text-format
21+
// transformers expose a `format` array (e.g. ["bold"] or ["bold", "italic"]); a
22+
// combined transformer is dropped when either of its formats is disabled.
23+
// Transformers without a `format` (headings, lists, links…) are left untouched.
24+
export function withoutDisabledMarkTransformers(transformers, disabledMarks) {
25+
if (disabledMarks.length === 0) return transformers
26+
27+
const disabled = new Set(disabledMarks)
28+
return transformers.filter((transformer) =>
29+
!Array.isArray(transformer.format) || !transformer.format.some((format) => disabled.has(format))
30+
)
31+
}

src/elements/editor.js

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/
1212
import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown"
1313
import { HORIZONTAL_DIVIDER } from "../editor/markdown/horizontal_divider_transformer"
1414
import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler"
15+
import { MARK_TO_TAGS, MARK_TYPES, withoutDisabledMarkTransformers } from "../editor/marks"
1516

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

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

@@ -260,6 +260,31 @@ export class LexicalEditorElement extends HTMLElement {
260260
return this.config.get("richText")
261261
}
262262

263+
// The inline marks the editor allows, as an allow-list intersected with the known
264+
// mark types. Accepts an array (`marks='["bold"]'`) or a whitespace-separated string
265+
// (`marks="bold italic"`), mirroring `permittedAttachmentTypes`. Any other value —
266+
// a bare/empty attribute, a boolean, a number — is not a valid allow-list and falls
267+
// back to all marks enabled, so a misconfiguration never silently disables everything.
268+
// Use `marks='[]'` to disable every mark.
269+
get enabledMarks() {
270+
const configured = this.config.get("marks")
271+
272+
let list
273+
if (Array.isArray(configured)) {
274+
list = configured
275+
} else if (typeof configured === "string" && configured.trim() !== "") {
276+
list = configured.split(/\s+/)
277+
} else {
278+
return MARK_TYPES
279+
}
280+
281+
return MARK_TYPES.filter((mark) => list.includes(mark))
282+
}
283+
284+
get disabledMarks() {
285+
return MARK_TYPES.filter((mark) => !this.enabledMarks.includes(mark))
286+
}
287+
263288
registerAdapter(adapter) {
264289
this.adapter = adapter
265290

@@ -407,6 +432,7 @@ export class LexicalEditorElement extends HTMLElement {
407432
export: new Map([ [ TextNode, exportTextNodeDOM ], [ CodeHighlightNode, exportTextNodeDOM ] ])
408433
},
409434
$initialEditorState: (editor) => {
435+
this.#removeDisabledConversions(editor)
410436
this.#configureSanitizer(editor)
411437
this.#loadInitialValue(editor)
412438
this.#setInternalFormValue(this.#readSanitizedEditorValue(editor))
@@ -576,10 +602,14 @@ export class LexicalEditorElement extends HTMLElement {
576602
registerRichText(this.editor),
577603
registerList(this.editor)
578604
)
605+
this.#registerDisabledMarkStripper(registered)
579606
this.#registerTableComponents()
580607
this.#registerCodeHiglightingComponents()
581608
if (this.supportsMarkdown) {
582-
const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ]
609+
// Both handlers must receive the disabled-mark-filtered list: the leading-tag handler
610+
// applies formats via selection.formatText() (not FORMAT_TEXT_COMMAND), so the command
611+
// interceptor can't stop it — dropping the transformer is what disables the shortcut there.
612+
const transformers = withoutDisabledMarkTransformers([ ...TRANSFORMERS, HORIZONTAL_DIVIDER ], this.disabledMarks)
583613
registered.push(
584614
registerMarkdownShortcuts(this.editor, transformers),
585615
registerMarkdownLeadingTagHandler(this.editor, transformers)
@@ -607,6 +637,21 @@ export class LexicalEditorElement extends HTMLElement {
607637
this.#disposables.push(codeLanguagePicker)
608638
}
609639

640+
// The import conversions and the FORMAT_TEXT_COMMAND interceptor cover HTML and command
641+
// paths, but content pasted from another Lexical editor arrives as serialized nodes whose
642+
// format bitfields are restored directly. This transform clears any disabled-mark bit so a
643+
// disabled mark can never render in the editor, whatever path produced it.
644+
#registerDisabledMarkStripper(registered) {
645+
const disabledMarks = this.disabledMarks
646+
if (disabledMarks.length === 0) return
647+
648+
registered.push(this.editor.registerNodeTransform(TextNode, (node) => {
649+
for (const mark of disabledMarks) {
650+
if (node.hasFormat(mark)) node.toggleFormat(mark)
651+
}
652+
}))
653+
}
654+
610655
#handleEnter() {
611656
// We can't prevent these externally using regular keydown because Lexical handles it first.
612657
this.#listeners.track(this.editor.registerCommand(
@@ -716,6 +761,7 @@ export class LexicalEditorElement extends HTMLElement {
716761
const toolbar = createElement("lexxy-toolbar")
717762
toolbar.innerHTML = LexicalToolbar.defaultTemplate
718763
toolbar.setAttribute("data-attachments", this.supportsAttachments) // Drives toolbar CSS styles
764+
toolbar.setAttribute("data-disabled-marks", this.disabledMarks.join(" ")) // Drives toolbar CSS styles
719765
toolbar.configure(this.config.get("toolbar"))
720766
this.prepend(toolbar)
721767
return toolbar
@@ -725,6 +771,19 @@ export class LexicalEditorElement extends HTMLElement {
725771
this.classList.toggle("lexxy-editor--empty", this.isEmpty)
726772
}
727773

774+
// Drop the html conversions for disabled marks so their markup is reduced to
775+
// plain text on every import path (initial value, setValue, paste). Each tag key
776+
// holds both Lexical's default TextNode conversion and the legacy Trix conversion,
777+
// so deleting it strips the mark regardless of which produced it. It also removes
778+
// the tag from the sanitizer allow-list, which derives from these keys.
779+
#removeDisabledConversions(editor) {
780+
for (const mark of this.disabledMarks) {
781+
for (const tag of MARK_TO_TAGS[mark]) {
782+
editor._htmlConversions?.delete(tag)
783+
}
784+
}
785+
}
786+
728787
#configureSanitizer(editor) {
729788
setSanitizerConfig(this.#getAllowedElements(editor))
730789
}
@@ -755,9 +814,10 @@ export class LexicalEditorElement extends HTMLElement {
755814
const linkNode = $getNearestNodeOfType(anchorNode, LinkNode)
756815

757816
attributes = {
758-
bold: { active: format.isBold, enabled: true },
759-
italic: { active: format.isItalic, enabled: true },
760-
strikethrough: { active: format.isStrikethrough, enabled: true },
817+
bold: { active: format.isBold, enabled: this.enabledMarks.includes("bold") },
818+
italic: { active: format.isItalic, enabled: this.enabledMarks.includes("italic") },
819+
strikethrough: { active: format.isStrikethrough, enabled: this.enabledMarks.includes("strikethrough") },
820+
underline: { active: format.isUnderline, enabled: this.enabledMarks.includes("underline") },
761821
code: { active: format.isInCode, enabled: true },
762822
highlight: { active: format.isHighlight, enabled: true },
763823
link: { active: format.isInLink, enabled: true },
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>Lexxy Test — Marks Empty</title>
7+
<link rel="stylesheet" href="/styles.css">
8+
</head>
9+
<body>
10+
<form>
11+
<div class="title">
12+
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
13+
</div>
14+
15+
<div class="body">
16+
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks="" required></lexxy-editor>
17+
</div>
18+
19+
<div class="events"></div>
20+
</form>
21+
22+
<script type="module" src="/editor.js"></script>
23+
</body>
24+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>Lexxy Test — Marks Limited</title>
7+
<link rel="stylesheet" href="/styles.css">
8+
</head>
9+
<body>
10+
<form>
11+
<div class="title">
12+
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
13+
</div>
14+
15+
<div class="body">
16+
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks='["bold", "italic"]' required></lexxy-editor>
17+
</div>
18+
19+
<div class="events"></div>
20+
</form>
21+
22+
<script type="module" src="/editor.js"></script>
23+
</body>
24+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>Lexxy Test — Marks None</title>
7+
<link rel="stylesheet" href="/styles.css">
8+
</head>
9+
<body>
10+
<form>
11+
<div class="title">
12+
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
13+
</div>
14+
15+
<div class="body">
16+
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks='[]' required></lexxy-editor>
17+
</div>
18+
19+
<div class="events"></div>
20+
</form>
21+
22+
<script type="module" src="/editor.js"></script>
23+
</body>
24+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width,initial-scale=1">
6+
<title>Lexxy Test — Marks String</title>
7+
<link rel="stylesheet" href="/styles.css">
8+
</head>
9+
<body>
10+
<form>
11+
<div class="title">
12+
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
13+
</div>
14+
15+
<div class="body">
16+
<lexxy-editor class="lexxy-content" placeholder="Write something..." marks="bold italic" required></lexxy-editor>
17+
</div>
18+
19+
<div class="events"></div>
20+
</form>
21+
22+
<script type="module" src="/editor.js"></script>
23+
</body>
24+
</html>

0 commit comments

Comments
 (0)