diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index a80e99d21..ed558da03 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -463,6 +463,21 @@ display: none; } + &[data-tables="false"] button[name="table"] { + display: none; + } + + &[data-highlight="false"] .lexxy-editor__toolbar-dropdown--highlight { + 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; } diff --git a/docs/configuration.md b/docs/configuration.md index 07522f3ab..8872a74d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,9 +47,13 @@ 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: ``. - `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. +- `tables`: Pass `false` to disable tables entirely. Table insertion is removed, and any existing `` markup is reduced to plain text (cell text preserved) when loaded. By default, tables are enabled. +- `highlight`: Color highlighting configuration. Pass `{ enabled: false }` (or simply `false`) to disable highlighting entirely (it is enabled by default). See [Highlighting](highlighting.md) for configuring the available colors. +- `headings`: Choose which heading levels the toolbar offers, as an array of heading tags. Defaults to `["h2", "h3", "h4"]`. Any of `h1`–`h4` listed gets a button in the format dropdown; levels left out are hidden. Pass `[]` to remove every heading button. Markdown shortcuts (`#`…`######`) still produce headings regardless of this setting. 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/docs/highlighting.md b/docs/highlighting.md index db50f4af8..4b183d4f5 100644 --- a/docs/highlighting.md +++ b/docs/highlighting.md @@ -32,3 +32,19 @@ Lexxy.configure({ } }) ``` + +## Disabling highlighting + +Pass `highlight.enabled: false` to disable color highlighting entirely. The toolbar control is hidden, the commands become inert, and existing highlight markup is reduced to plain text on load. + +```javascript +Lexxy.configure({ + default: { + highlight: { enabled: false } + } +}) +``` + +Or per editor: ``. + +A bare boolean is also accepted as a shorthand — `highlight: false` in a preset, or ``. diff --git a/src/config/lexxy.js b/src/config/lexxy.js index 4899b5c2c..976e50ad2 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -15,10 +15,15 @@ const presets = new Configuration({ multiLine: true, permittedAttachmentTypes: null, richText: true, + tables: true, + code: true, + marks: [ "bold", "italic", "strikethrough", "underline" ], + headings: [ "h2", "h3", "h4" ], toolbar: { upload: "both" }, highlight: { + enabled: true, buttons: { color: range(1, 9).map(n => `var(--highlight-${n})`), "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`), diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index cd6c68e0d..201b184f5 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -4,6 +4,7 @@ import { $isRangeSelection, $isTextNode, $setSelection, + COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_NORMAL, FORMAT_TEXT_COMMAND, INDENT_CONTENT_COMMAND, @@ -33,6 +34,7 @@ const COMMANDS = [ "unlink", "toggleHighlight", "removeHighlight", + "setFormatHeadingHuge", "setFormatHeadingLarge", "setFormatHeadingMedium", "setFormatHeadingSmall", @@ -68,6 +70,7 @@ export class CommandDispatcher { this.contents = editorElement.contents this.#registerCommands() + this.#registerDisabledMarkInterceptor() this.#registerKeyboardCommands() this.#registerDragAndDropHandlers() } @@ -156,6 +159,8 @@ export class CommandDispatcher { } dispatchInsertCodeBlock() { + if (!this.editorElement.supportsCode) return + if (this.selection.hasSelectedWordsInSingleLine) { this.#toggleInlineCode() } else { @@ -228,6 +233,10 @@ export class CommandDispatcher { $insertNodeToNearestRoot(new HorizontalDividerNode) } + dispatchSetFormatHeadingHuge() { + this.contents.applyHeadingFormat("h1") + } + dispatchSetFormatHeadingLarge() { this.contents.applyHeadingFormat("h2") } @@ -277,6 +286,8 @@ export class CommandDispatcher { } dispatchInsertTable() { + if (!this.editorElement.supportsTables) return + this.editor.dispatchCommand(INSERT_TABLE_COMMAND, { "rows": 3, "columns": 3, "includeHeaders": true }) } @@ -299,6 +310,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)) } diff --git a/src/editor/headings.js b/src/editor/headings.js new file mode 100644 index 000000000..a5c812a3b --- /dev/null +++ b/src/editor/headings.js @@ -0,0 +1,11 @@ +// Canonical heading levels the toolbar can offer, ordered largest to smallest. +// The `name`/`command` for h2-h4 are the original public contract — referenced by +// host adapters, the toolbar template, and the native `editor-initialized` event — +// so they must not change. h1 adds a new name/command. Which of these levels are +// actually offered is driven by the `headings` configuration option. +export const HEADING_LEVELS = [ + { tag: "h1", name: "heading-huge", command: "setFormatHeadingHuge", label: "Huge heading" }, + { tag: "h2", name: "heading-large", command: "setFormatHeadingLarge", label: "Large heading" }, + { tag: "h3", name: "heading-medium", command: "setFormatHeadingMedium", label: "Medium heading" }, + { tag: "h4", name: "heading-small", command: "setFormatHeadingSmall", label: "Small heading" }, +] diff --git a/src/editor/marks.js b/src/editor/marks.js new file mode 100644 index 000000000..6485e0d22 --- /dev/null +++ b/src/editor/marks.js @@ -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. ``), 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)) + ) +} diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 15e1026a1..ed55e2b19 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -14,6 +14,8 @@ const NO_STYLE = Symbol("no_style") export class HighlightDropdown extends ToolbarDropdown { editorReady() { + if (!this.editorElement.supportsHighlight) return + this.#setUpButtons() this.#registerButtonHandlers() } @@ -34,6 +36,7 @@ export class HighlightDropdown extends ToolbarDropdown { this.#buttonContainer.innerHTML = "" const colorGroups = this.editorElement.config.get("highlight.buttons") + if (!colorGroups) return this.#populateButtonGroup("color", colorGroups.color) this.#populateButtonGroup("background-color", colorGroups["background-color"]) diff --git a/src/elements/editor.js b/src/elements/editor.js index ea2161621..c0445c18d 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -3,15 +3,18 @@ import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" import { $getNearestNodeOfType } from "@lexical/utils" +import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection" import { registerPlainText } from "@lexical/plain-text" import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text" import { $generateHtmlFromNodes, $generateNodesFromDOM as $generateLexicalNodesFromDOM } from "@lexical/html" import { filterDisallowedAttachmentNodes } from "../helpers/attachment_filter_helper" import { $convertInlineImageDataURIs } from "../helpers/inline_image_uri_helper" import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code" -import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown" +import { CODE, INLINE_CODE, 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 { HEADING_LEVELS } from "../editor/headings" import theme from "../config/theme" import { HorizontalDividerNode } from "../nodes/horizontal_divider_node" @@ -47,7 +50,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" ] @@ -260,6 +262,47 @@ export class LexicalEditorElement extends HTMLElement { return this.config.get("richText") } + get supportsTables() { + return this.supportsRichText && this.config.get("tables") + } + + get supportsHighlight() { + // Accept both the object form ({ enabled: false }) and a bare boolean (highlight: false), + // mirroring the scalar-disable convention used by the sibling options. + const highlight = this.config.get("highlight") + return this.supportsRichText && highlight !== false && highlight?.enabled !== false + } + + get supportsCode() { + return this.supportsRichText && this.config.get("code") + } + + // 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))) + } + registerAdapter(adapter) { this.adapter = adapter @@ -407,6 +450,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)) @@ -435,12 +479,14 @@ export class LexicalEditorElement extends HTMLElement { HeadingNode, ListNode, ListItemNode, - CodeNode, - CodeHighlightNode, LinkNode, AutoLinkNode, HorizontalDividerNode ) + + if (this.supportsCode) { + nodes.push(CodeNode, CodeHighlightNode) + } } return nodes @@ -576,10 +622,15 @@ export class LexicalEditorElement extends HTMLElement { registerRichText(this.editor), registerList(this.editor) ) - this.#registerTableComponents() - this.#registerCodeHiglightingComponents() + this.#registerDisabledMarkStripper(registered) + this.#registerDisabledHighlightStripper(registered) + if (this.supportsTables) this.#registerTableComponents() + if (this.supportsCode) 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(this.#enabledMarkdownTransformers(), this.disabledMarks) registered.push( registerMarkdownShortcuts(this.editor, transformers), registerMarkdownLeadingTagHandler(this.editor, transformers) @@ -592,6 +643,13 @@ export class LexicalEditorElement extends HTMLElement { this.#listeners.track(...registered) } + #enabledMarkdownTransformers() { + const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ] + if (this.supportsCode) return transformers + + return transformers.filter((transformer) => transformer !== CODE && transformer !== INLINE_CODE) + } + #registerTableComponents() { let tableTools = this.querySelector("lexxy-table-tools") tableTools ??= createElement("lexxy-table-tools") @@ -607,6 +665,41 @@ 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) + } + })) + } + + // Highlight is stored as color/background-color styles (plus the highlight format bit) + // on text nodes. The import conversions strip and legacy Trix color, but content + // pasted from another Lexxy editor arrives as serialized nodes whose styles are restored + // directly — bypassing those conversions. This transform clears the highlight styling so + // a disabled highlight can never render in the editor, whatever path produced it. + #registerDisabledHighlightStripper(registered) { + if (this.supportsHighlight) return + + registered.push(this.editor.registerNodeTransform(TextNode, (node) => { + if (node.hasFormat("highlight")) node.toggleFormat("highlight") + + const styles = getStyleObjectFromCSS(node.getStyle()) + if (styles.color || styles["background-color"]) { + delete styles.color + delete styles["background-color"] + node.setStyle(getCSSFromStyleObject(styles)) + } + })) + } + #handleEnter() { // We can't prevent these externally using regular keydown because Lexical handles it first. this.#listeners.track(this.editor.registerCommand( @@ -716,6 +809,13 @@ 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-tables", this.supportsTables) // Drives toolbar CSS styles + toolbar.setAttribute("data-highlight", this.supportsHighlight) // Drives toolbar CSS styles + toolbar.setAttribute("data-disabled-marks", this.disabledMarks.join(" ")) // Drives toolbar CSS styles + if (!this.supportsCode) toolbar.querySelector("[name='code']")?.remove() + for (const level of HEADING_LEVELS) { + if (!this.#enabledHeadings.includes(level.tag)) toolbar.querySelector(`[name='${level.name}']`)?.remove() + } toolbar.configure(this.config.get("toolbar")) this.prepend(toolbar) return toolbar @@ -725,6 +825,33 @@ export class LexicalEditorElement extends HTMLElement { this.classList.toggle("lexxy-editor--empty", this.isEmpty) } + // Drop HTML import conversions for disabled features so their markup is reduced to plain + // text on every import path (initial value, setValue, paste) and excluded from the + // sanitizer allow-list, which #getImportableTags derives from these same conversion keys. + #removeDisabledConversions(editor) { + if (!this.supportsHighlight) { + // The highlight extension owns the import conversion, but Lexical's TextNode + // also imports as its built-in highlight format. When highlight is disabled the + // extension isn't registered, so drop the conversion entirely. + editor._htmlConversions?.delete("mark") + } + + if (!this.supportsCode) { + // CodeNode is not registered when code is disabled, but the `code` (inline) and `pre` + // HTML conversions remain — TextNode.importDOM registers `code` independently of CodeNode. + editor._htmlConversions?.delete("code") + editor._htmlConversions?.delete("pre") + } + + // Each disabled mark's tag keys hold both Lexical's default TextNode conversion and the + // legacy Trix conversion, so deleting them strips the mark regardless of which produced it. + for (const mark of this.disabledMarks) { + for (const tag of MARK_TO_TAGS[mark]) { + editor._htmlConversions?.delete(tag) + } + } + } + #configureSanitizer(editor) { setSanitizerConfig(this.#getAllowedElements(editor)) } @@ -755,11 +882,12 @@ 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 }, + highlight: { active: this.supportsHighlight && format.isHighlight, enabled: this.supportsHighlight }, link: { active: format.isInLink, enabled: true }, quote: { active: format.isInQuote, enabled: true }, heading: { active: format.isInHeading, enabled: true }, @@ -770,7 +898,7 @@ export class LexicalEditorElement extends HTMLElement { } linkHref = linkNode ? linkNode.getURL() : null - highlight = format.isHighlight ? getHighlightStyles(selection) : null + highlight = this.supportsHighlight && format.isHighlight ? getHighlightStyles(selection) : null headingTag = format.headingTag ?? null }) @@ -804,6 +932,8 @@ export class LexicalEditorElement extends HTMLElement { } get #resolvedHighlightColors() { + if (!this.supportsHighlight) return null + const buttons = this.config.get("highlight.buttons") if (!buttons) return null @@ -812,14 +942,22 @@ export class LexicalEditorElement extends HTMLElement { return { colors, backgroundColors } } + // The heading levels the toolbar/format menu offers, driven by the `headings` config + // option. Markdown shortcuts (#…######) still produce headings regardless of this. + get #enabledHeadings() { + const configured = this.config.get("headings") + return Array.isArray(configured) ? configured : HEADING_LEVELS.map((level) => level.tag) + } + get #supportedHeadingFormats() { if (!this.supportsRichText) return [] + const enabled = this.#enabledHeadings return [ { label: "Normal", command: "setFormatParagraph", tag: null }, - { label: "Large heading", command: "setFormatHeadingLarge", tag: "h2" }, - { label: "Medium heading", command: "setFormatHeadingMedium", tag: "h3" }, - { label: "Small heading", command: "setFormatHeadingSmall", tag: "h4" }, + ...HEADING_LEVELS + .filter((level) => enabled.includes(level.tag)) + .map(({ label, command, tag }) => ({ label, command, tag })), ] } diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 366e3c360..bb95c9d2b 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -11,6 +11,7 @@ import { ListenerBin, registerEventListener } from "../helpers/listener_helper" import { handleRollingTabIndex } from "../helpers/accessibility_helper" import ToolbarIcons from "./toolbar_icons" import { generateDomId, isActiveAndVisible } from "../helpers/html_helper" +import { HEADING_LEVELS } from "../editor/headings" export class LexicalToolbarElement extends HTMLElement { static observedAttributes = [ "connected" ] @@ -232,9 +233,9 @@ export class LexicalToolbarElement extends HTMLElement { this.#setButtonPressed("format", isInHeading) this.#setButtonPressed("paragraph", !isInHeading) - this.#setButtonPressed("heading-large", headingTag === "h2") - this.#setButtonPressed("heading-medium", headingTag === "h3") - this.#setButtonPressed("heading-small", headingTag === "h4") + for (const level of HEADING_LEVELS) { + this.#setButtonPressed(level.name, headingTag === level.tag) + } this.#setButtonPressed("lists", isInList) this.#setButtonPressed("unordered-list", isInList && listType === "bullet") @@ -413,6 +414,9 @@ export class LexicalToolbarElement extends HTMLElement { + diff --git a/src/elements/toolbar_icons.js b/src/elements/toolbar_icons.js index 5867b13b9..fcd86362d 100644 --- a/src/elements/toolbar_icons.js +++ b/src/elements/toolbar_icons.js @@ -26,6 +26,11 @@ export default { `, + "h1": + ` + + `, + "h2": ` diff --git a/src/extensions/format_escape_extension.js b/src/extensions/format_escape_extension.js index f42afed28..efe108793 100644 --- a/src/extensions/format_escape_extension.js +++ b/src/extensions/format_escape_extension.js @@ -21,12 +21,7 @@ export class FormatEscapeExtension extends LexxyExtension { get lexicalExtension() { return defineExtension({ name: "lexxy/format-escape", - nodes: [ - EarlyEscapeCodeNode, - { replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode }, - EarlyEscapeListItemNode, - { replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode }, - ], + nodes: this.#nodes, register(editor) { return mergeRegister( editor.registerCommand( @@ -44,6 +39,25 @@ export class FormatEscapeExtension extends LexxyExtension { } }) } + + // CodeNode is only registered when code support is enabled. Declaring a node + // replacement for an unregistered CodeNode throws at editor build, so the + // code-escape entries are included only when the editor supports code. + get #nodes() { + const nodes = [ + EarlyEscapeListItemNode, + { replace: ListItemNode, with: () => new EarlyEscapeListItemNode(), withKlass: EarlyEscapeListItemNode }, + ] + + if (this.editorElement.supportsCode) { + nodes.unshift( + EarlyEscapeCodeNode, + { replace: CodeNode, with: (node) => new EarlyEscapeCodeNode(node.getLanguage()), withKlass: EarlyEscapeCodeNode }, + ) + } + + return nodes + } } function $escapeFromBlockquote() { diff --git a/src/extensions/highlight_extension.js b/src/extensions/highlight_extension.js index 380f2ec15..6dad19501 100644 --- a/src/extensions/highlight_extension.js +++ b/src/extensions/highlight_extension.js @@ -24,10 +24,12 @@ const pendingCodeHighlights = new WeakMap() export class HighlightExtension extends LexxyExtension { get enabled() { - return this.editorElement.supportsRichText + return this.editorElement.supportsHighlight } get lexicalExtension() { + const supportsCode = this.editorElement.supportsCode + const extension = defineExtension({ dependencies: [ RichTextExtension ], name: "lexxy/highlight", @@ -44,21 +46,32 @@ export class HighlightExtension extends LexxyExtension { // keep the ref to the canonicalizers for optimized css conversion const canonicalizers = buildCanonicalizers(config) - // Register the
 converter directly in the conversion cache so it
-        // coexists with other extensions' "pre" converters (the extension-level
-        // html.import uses Object.assign, which means only one "pre" per key).
-        $registerPreConversion(editor)
-
-        return mergeRegister(
+        const teardowns = [
           editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, (styles) => $toggleSelectionStyles(editor, styles), COMMAND_PRIORITY_NORMAL),
           editor.registerCommand(REMOVE_HIGHLIGHT_COMMAND, () => $toggleSelectionStyles(editor, BLANK_STYLES), COMMAND_PRIORITY_NORMAL),
           editor.registerNodeTransform(TextNode, $syncHighlightWithStyle),
-          editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
-          editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers)),
-          editor.registerMutationListener(CodeNode, (mutations) => {
-            $applyPendingCodeHighlights(editor, mutations)
-          }, { skipInitialization: true })
-        )
+          editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
+        ]
+
+        // Code-block highlighting depends on CodeNode/CodeHighlightNode being
+        // registered. When code is disabled those classes are absent, so the
+        // 
 converter (which creates a CodeNode) and the node transform /
+        // mutation listener keyed on them would throw at editor build.
+        if (supportsCode) {
+          // Register the 
 converter directly in the conversion cache so it
+          // coexists with other extensions' "pre" converters (the extension-level
+          // html.import uses Object.assign, which means only one "pre" per key).
+          $registerPreConversion(editor)
+
+          teardowns.push(
+            editor.registerNodeTransform(CodeHighlightNode, $syncHighlightWithCodeHighlightNode),
+            editor.registerMutationListener(CodeNode, (mutations) => {
+              $applyPendingCodeHighlights(editor, mutations)
+            }, { skipInitialization: true })
+          )
+        }
+
+        return mergeRegister(...teardowns)
       }
     })
 
diff --git a/src/extensions/tables_extension.js b/src/extensions/tables_extension.js
index df14a2b26..cbebe5c5d 100644
--- a/src/extensions/tables_extension.js
+++ b/src/extensions/tables_extension.js
@@ -21,7 +21,7 @@ import { mergeRegister } from "@lexical/utils"
 export class TablesExtension extends LexxyExtension {
 
   get enabled() {
-    return this.editorElement.supportsRichText
+    return this.editorElement.supportsTables
   }
 
   get allowedElements() {
diff --git a/src/extensions/trix_content_extension.js b/src/extensions/trix_content_extension.js
index 67909d1d2..f7e73abab 100644
--- a/src/extensions/trix_content_extension.js
+++ b/src/extensions/trix_content_extension.js
@@ -13,24 +13,32 @@ export class TrixContentExtension extends LexxyExtension {
   }
 
   get lexicalExtension() {
+    // The em/span/strong/del converters below exist to import legacy Trix highlight
+    // colors. When highlight is disabled we drop the color application so those
+    // elements fall back to plain formatting (the em/span/strong handlers return null,
+    // deferring to Lexical's default bold/italic conversion; del keeps strikethrough).
+    const supportsHighlight = this.editorElement.supportsHighlight
+
     return defineExtension({
       name: "lexxy/trix-content",
       html: {
         import: {
-          em: (element) => onlyStyledElements(element, {
+          em: (element) => onlyStyledElements(element, supportsHighlight, {
             conversion: extendTextNodeConversion("i", $applyHighlightStyle),
             priority: 1
           }),
-          span: (element) => onlyStyledElements(element, {
+          span: (element) => onlyStyledElements(element, supportsHighlight, {
             conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
             priority: 1
           }),
-          strong: (element) => onlyStyledElements(element, {
+          strong: (element) => onlyStyledElements(element, supportsHighlight, {
             conversion: extendTextNodeConversion("b", $applyHighlightStyle),
             priority: 1
           }),
           del: () => ({
-            conversion: extendTextNodeConversion("s", $applyStrikethrough, $applyHighlightStyle),
+            conversion: supportsHighlight
+              ? extendTextNodeConversion("s", $applyStrikethrough, $applyHighlightStyle)
+              : extendTextNodeConversion("s", $applyStrikethrough),
             priority: 1
           }),
           pre: (element) => onlyPreLanguageElements(element, {
@@ -43,7 +51,9 @@ export class TrixContentExtension extends LexxyExtension {
   }
 }
 
-function onlyStyledElements(element, conversion) {
+function onlyStyledElements(element, supportsHighlight, conversion) {
+  if (!supportsHighlight) return null
+
   const elementHighlighted = element.style.color !== "" || element.style.backgroundColor !== ""
   return elementHighlighted ? conversion : null
 }
diff --git a/test/browser/fixtures/code-disabled.html b/test/browser/fixtures/code-disabled.html
new file mode 100644
index 000000000..6bc8dc381
--- /dev/null
+++ b/test/browser/fixtures/code-disabled.html
@@ -0,0 +1,24 @@
+
+
+
+  
+  
+  Lexxy Test — Code Disabled
+  
+
+
+  
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/highlight-false-bare.html b/test/browser/fixtures/highlight-false-bare.html new file mode 100644 index 000000000..114069826 --- /dev/null +++ b/test/browser/fixtures/highlight-false-bare.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Highlight Disabled (bare boolean) + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/highlight-false.html b/test/browser/fixtures/highlight-false.html new file mode 100644 index 000000000..3639d6b27 --- /dev/null +++ b/test/browser/fixtures/highlight-false.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Highlight Disabled + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/marks-empty.html b/test/browser/fixtures/marks-empty.html new file mode 100644 index 000000000..8efa1e2aa --- /dev/null +++ b/test/browser/fixtures/marks-empty.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Marks Empty + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/marks-limited.html b/test/browser/fixtures/marks-limited.html new file mode 100644 index 000000000..5e14c6497 --- /dev/null +++ b/test/browser/fixtures/marks-limited.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Marks Limited + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/marks-none.html b/test/browser/fixtures/marks-none.html new file mode 100644 index 000000000..ec55618d6 --- /dev/null +++ b/test/browser/fixtures/marks-none.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Marks None + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/marks-string.html b/test/browser/fixtures/marks-string.html new file mode 100644 index 000000000..3998b8641 --- /dev/null +++ b/test/browser/fixtures/marks-string.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Marks String + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/marks-true.html b/test/browser/fixtures/marks-true.html new file mode 100644 index 000000000..7c853157e --- /dev/null +++ b/test/browser/fixtures/marks-true.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Marks True + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/fixtures/tables-false.html b/test/browser/fixtures/tables-false.html new file mode 100644 index 000000000..b1e973fb3 --- /dev/null +++ b/test/browser/fixtures/tables-false.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Tables Disabled + + + +
+
+ +
+ +
+ +
+ +
+ + + + + diff --git a/test/browser/tests/formatting/code_disabled.test.js b/test/browser/tests/formatting/code_disabled.test.js new file mode 100644 index 000000000..39ef139fd --- /dev/null +++ b/test/browser/tests/formatting/code_disabled.test.js @@ -0,0 +1,70 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { startMonitoringConsole } from "../../helpers/assertions.js" + +// Code blocks render as in the editor's live DOM; the canonical contract +// is the exported value() (blocks ->
, inline code -> ).
+const valueOf = async (editor) => {
+  await editor.flush()
+  return editor.value()
+}
+
+test.describe("Code disabled", () => {
+  test("Code toolbar button is present by default (regression baseline)", async ({ page }) => {
+    await page.goto("/")
+    await page.waitForSelector("lexxy-toolbar[connected]")
+
+    await expect(page.locator("lexxy-toolbar button[name='code']")).toBeVisible()
+  })
+
+  test("Code toolbar button is absent when code is disabled", async ({ page }) => {
+    await page.goto("/code-disabled.html")
+    await page.waitForSelector("lexxy-editor[connected]")
+    await page.waitForSelector("lexxy-toolbar[connected]")
+
+    await expect(page.locator("lexxy-toolbar button[name='code']")).toHaveCount(0)
+  })
+
+  test("markdown code fence does not create a code block when code is disabled", async ({ page, editor }) => {
+    await page.goto("/code-disabled.html")
+    await editor.waitForConnected()
+
+    await editor.click()
+    await editor.send("```")
+    await editor.send("Enter")
+    await editor.send("x")
+
+    await expect.poll(() => valueOf(editor)).not.toContain(" {
+    await page.goto("/code-disabled.html")
+    await editor.waitForConnected()
+
+    await editor.click()
+    await editor.send("`code`")
+
+    await expect.poll(() => valueOf(editor)).not.toContain(" {
+    await page.goto("/code-disabled.html")
+    await editor.waitForConnected()
+
+    await editor.setValue('
def hi
') + + // Neither the block (
) nor the inner inline  should survive.
+    await expect.poll(() => valueOf(editor)).not.toContain(" valueOf(editor)).not.toContain(" valueOf(editor)).toContain("def hi")
+  })
+
+  test("editor connects without crashing when code is disabled", async ({ page }) => {
+    startMonitoringConsole(page)
+
+    await page.goto("/code-disabled.html")
+    await page.waitForSelector("lexxy-editor[connected]")
+
+    expect(page).toHaveNoErrors()
+  })
+})
diff --git a/test/browser/tests/formatting/highlight_disabled.test.js b/test/browser/tests/formatting/highlight_disabled.test.js
new file mode 100644
index 000000000..497d5b321
--- /dev/null
+++ b/test/browser/tests/formatting/highlight_disabled.test.js
@@ -0,0 +1,132 @@
+import { test } from "../../test_helper.js"
+import { expect } from "@playwright/test"
+import { startMonitoringConsole, assertEditorContent } from "../../helpers/assertions.js"
+
+const COLORED_HTML =
+  '

red and bg plain

' + +const valueOf = async (editor) => { + await editor.flush() + return editor.value() +} + +// Simulate pasting content copied from another Lexxy editor: the clipboard carries +// `application/x-lexical-editor` (serialized nodes with their inline styles + format +// bitfields) which Lexical deserializes directly, bypassing HTML import conversions. +async function pasteLexicalNodes(editor, nodes) { + const payload = JSON.stringify({ namespace: "Lexxy", nodes }) + await editor.content.evaluate((el, data) => { + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: new DataTransfer() }) + event.clipboardData.setData("application/x-lexical-editor", data) + event.clipboardData.setData("text/html", "pasted") + event.clipboardData.setData("text/plain", "pasted") + el.dispatchEvent(event) + }, payload) + await editor.flush() +} + +// A serialized text node carrying a highlight color (and the highlight format bit, 1 << 7), +// exactly as a copy from a highlight-enabled editor would produce. +function highlightedTextNode(text, style) { + return { type: "text", version: 1, text, format: 1 << 7, style, mode: "normal", detail: 0 } +} + +test.describe("Highlight disabled", () => { + test("the highlight dropdown is visible by default (regression baseline)", async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(page.locator("lexxy-toolbar [name='highlight']")).toBeVisible() + }) + + test("the highlight dropdown is hidden when highlight is disabled", async ({ page }) => { + await page.goto("/highlight-false.html") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(page.locator("lexxy-toolbar [name='highlight']")).toBeHidden() + }) + + test("dispatching toggleHighlight does nothing when highlight is disabled", async ({ page, editor }) => { + await page.goto("/highlight-false.html") + await editor.waitForConnected() + + await editor.setValue("

Hello everyone

") + await editor.select("everyone") + await editor.locator.evaluate((el) => el.editor.dispatchCommand("toggleHighlight", { color: "var(--highlight-1)" })) + + await expect.poll(() => valueOf(editor)).not.toContain(" valueOf(editor)).not.toContain("var(--highlight") + }) + + test("loading highlighted content strips the highlight when disabled", async ({ page, editor }) => { + await page.goto("/highlight-false.html") + await editor.waitForConnected() + + await editor.setValue(COLORED_HTML) + + await expect.poll(() => valueOf(editor)).not.toContain(" valueOf(editor)).not.toContain("var(--highlight") + // text content must be preserved + const value = await valueOf(editor) + for (const text of [ "red", "bg", "plain" ]) { + expect(value).toContain(text) + } + }) + + test("pasting Lexical clipboard data does not re-introduce highlight colors when disabled", async ({ page, editor }) => { + await page.goto("/highlight-false.html") + await editor.waitForConnected() + + await editor.send("before ") + await pasteLexicalNodes(editor, [ highlightedTextNode("colored", "color: var(--highlight-1);") ]) + + await expect.poll(() => valueOf(editor)).not.toContain("var(--highlight") + await expect.poll(() => valueOf(editor)).not.toContain("color:") + expect(await valueOf(editor)).toContain("colored") + + // the color must not survive in the live editor DOM either + await assertEditorContent(editor, async (content) => { + await expect(content.locator('[style*="color"]')).toHaveCount(0) + }) + }) + + test("editor connects without crashing when highlight is disabled", async ({ page }) => { + startMonitoringConsole(page) + + await page.goto("/highlight-false.html") + await page.waitForSelector("lexxy-editor[connected]") + + expect(page).toHaveNoErrors() + }) + + test("legacy Trix styled-element highlights are stripped (formats kept) when disabled", async ({ page, editor }) => { + await page.goto("/highlight-false.html") + await editor.waitForConnected() + + await editor.setValue('

italic bold struck

') + + const value = await valueOf(editor) + expect(value).not.toContain("var(--highlight") + expect(value).not.toContain("color:") + for (const text of [ "italic", "bold", "struck" ]) expect(value).toContain(text) + + // the inline marks survive; only the highlight color is stripped + await assertEditorContent(editor, async (content) => { + await expect(content.locator('[style*="color"]')).toHaveCount(0) + await expect(content.locator("em, i")).toHaveText("italic") + await expect(content.locator("strong, b")).toHaveText("bold") + }) + }) + + test("a bare highlight=\"false\" attribute disables without crashing", async ({ page }) => { + startMonitoringConsole(page) + + await page.goto("/highlight-false-bare.html") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + + expect(page).toHaveNoErrors() + await expect(page.locator("lexxy-toolbar [name='highlight']")).toBeHidden() + }) +}) diff --git a/test/browser/tests/formatting/marks_disabled.test.js b/test/browser/tests/formatting/marks_disabled.test.js new file mode 100644 index 000000000..6fe40957a --- /dev/null +++ b/test/browser/tests/formatting/marks_disabled.test.js @@ -0,0 +1,248 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { assertEditorHtml } from "../../helpers/assertions.js" +import { startMonitoringConsole } from "../../helpers/assertions.js" + +const HELLO_EVERYONE = "

Hello everyone

" + +// Dispatch an inline-format command the same way the toolbar does, so the test +// exercises the FORMAT_TEXT_COMMAND path (toolbar + native Cmd+B/I/U funnel here). +async function dispatchFormat(editor, command) { + await editor.locator.evaluate((el, cmd) => { + el.editor.update(() => el.editor.dispatchCommand(cmd)) + }, command) + await editor.flush() +} + +// Simulate pasting content copied from another Lexical/Lexxy editor: the clipboard +// carries `application/x-lexical-editor` (serialized nodes with format bitfields) +// which Lexical deserializes directly, bypassing HTML import conversions. +async function pasteLexicalNodes(editor, nodes) { + const payload = JSON.stringify({ namespace: "Lexxy", nodes }) + await editor.content.evaluate((el, data) => { + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: new DataTransfer() }) + event.clipboardData.setData("application/x-lexical-editor", data) + event.clipboardData.setData("text/html", "pasted") + event.clipboardData.setData("text/plain", "pasted") + el.dispatchEvent(event) + }, payload) + await editor.flush() +} + +function textNode(text, format) { + return { type: "text", version: 1, text, format, style: "", mode: "normal", detail: 0 } +} + +test.describe("Configurable marks", () => { + test.describe("default (all marks enabled)", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("every mark button is visible", async ({ page }) => { + for (const name of [ "bold", "italic", "strikethrough", "underline" ]) { + await expect(page.locator(`lexxy-toolbar button[name='${name}']`)).toBeVisible() + } + }) + + test("all four marks still apply", async ({ editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "bold") + await assertEditorHtml(editor, "

Hello everyone

") + + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "underline") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("disabled marks list is empty", async ({ page }) => { + await expect(page.locator("lexxy-toolbar")).toHaveAttribute("data-disabled-marks", "") + }) + + test("enabledMarks and disabledMarks return frozen arrays", async ({ editor }) => { + // The default editor's enabledMarks returns the shared MARK_TYPES constant; it (and the + // filtered disabledMarks) must be frozen so a host can't mutate shared state. + const frozen = await editor.locator.evaluate((el) => ({ + enabled: Object.isFrozen(el.enabledMarks), + disabled: Object.isFrozen(el.disabledMarks), + })) + expect(frozen).toEqual({ enabled: true, disabled: true }) + }) + }) + + test.describe("marks=['bold','italic'] (strikethrough + underline disabled)", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/marks-limited.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("enabled mark buttons stay visible, disabled ones are hidden", async ({ page }) => { + await expect(page.locator("lexxy-toolbar button[name='bold']")).toBeVisible() + await expect(page.locator("lexxy-toolbar button[name='italic']")).toBeVisible() + await expect(page.locator("lexxy-toolbar button[name='strikethrough']")).toBeHidden() + await expect(page.locator("lexxy-toolbar button[name='underline']")).toBeHidden() + }) + + test("enabled marks still apply", async ({ editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "bold") + await assertEditorHtml(editor, "

Hello everyone

") + + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "italic") + await assertEditorHtml(editor, "

Hello everyone

") + }) + + test("dispatching a disabled mark does nothing", async ({ editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "strikethrough") + await assertEditorHtml(editor, HELLO_EVERYONE) + }) + + test("the underline command is inert even though it has no button", async ({ editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "underline") + await assertEditorHtml(editor, HELLO_EVERYONE) + }) + + test("the markdown shortcut for a disabled mark stays literal", async ({ editor }) => { + await editor.send("hello") + await editor.send("~~") + await editor.send("Home") + await editor.send("~~") + await assertEditorHtml(editor, "

~~hello~~

") + }) + + test("disabled marks are stripped on import while enabled marks survive", async ({ editor }) => { + await editor.setValue("

keep strike under

") + await assertEditorHtml(editor, "

keep strike under

") + }) + + test("pasting Lexical clipboard data does not re-introduce a disabled mark", async ({ editor }) => { + await editor.send("before ") + // format 4 = strikethrough bit; pasted as a serialized Lexical node, not HTML, so it + // bypasses the import conversions and the FORMAT_TEXT_COMMAND interceptor. + await pasteLexicalNodes(editor, [ textNode("struck", 4) ]) + + // The mark must not render in the live editor (the sanitizer already strips it from value()). + await expect + .poll(async () => { + await editor.flush() + return await editor.innerHTML() + }, { timeout: 5_000 }) + .not.toContain("lexxy-content__strikethrough") + expect(await editor.value()).toContain("struck") + }) + }) + + test.describe("marks as a whitespace-separated string", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/marks-string.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("marks=\"bold italic\" enables exactly bold + italic", async ({ page, editor }) => { + await expect(page.locator("lexxy-toolbar button[name='bold']")).toBeVisible() + await expect(page.locator("lexxy-toolbar button[name='italic']")).toBeVisible() + await expect(page.locator("lexxy-toolbar button[name='strikethrough']")).toBeHidden() + await expect(page.locator("lexxy-toolbar button[name='underline']")).toBeHidden() + + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "italic") + await assertEditorHtml(editor, "

Hello everyone

") + }) + }) + + // A non-list value (boolean, empty/bare attribute) is not a valid allow-list and must + // fall back to all marks enabled — never silently disable everything. + for (const fixture of [ "marks-true.html", "marks-empty.html" ]) { + test.describe(`${fixture} (non-list value falls back to all enabled)`, () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto(`/${fixture}`) + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("every mark button is visible", async ({ page }) => { + for (const name of [ "bold", "italic", "strikethrough", "underline" ]) { + await expect(page.locator(`lexxy-toolbar button[name='${name}']`)).toBeVisible() + } + await expect(page.locator("lexxy-toolbar")).toHaveAttribute("data-disabled-marks", "") + }) + + test("a mark still applies", async ({ editor }) => { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, "strikethrough") + await assertEditorHtml(editor, "

Hello everyone

") + }) + }) + } + + test.describe("marks=[] (all marks disabled)", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/marks-none.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("no mark buttons are visible but the toolbar still renders", async ({ page }) => { + for (const name of [ "bold", "italic", "strikethrough", "underline" ]) { + await expect(page.locator(`lexxy-toolbar button[name='${name}']`)).toBeHidden() + } + await expect(page.locator("lexxy-toolbar button[name='quote']")).toBeVisible() + }) + + test("every mark command is inert", async ({ editor }) => { + for (const command of [ "bold", "italic", "strikethrough", "underline" ]) { + await editor.setValue(HELLO_EVERYONE) + await editor.select("everyone") + await dispatchFormat(editor, command) + await assertEditorHtml(editor, HELLO_EVERYONE) + } + }) + + test("every mark markdown shortcut stays literal", async ({ editor }) => { + await editor.send("hello") + await editor.send("**") + await editor.send("Home") + await editor.send("**") + await assertEditorHtml(editor, "

**hello**

") + }) + + test("the combined bold-italic markdown shortcut stays literal", async ({ editor }) => { + await editor.send("hello") + await editor.send("***") + await editor.send("Home") + await editor.send("***") + await assertEditorHtml(editor, "

***hello***

") + }) + + test("all mark tags are stripped on import", async ({ editor }) => { + await editor.setValue("

abcd

") + await assertEditorHtml(editor, "

abcd

") + }) + + test("the editor connects without console errors", async ({ page, editor }) => { + // Monitor before navigating so connect-time errors (e.g. from the disabled-mark + // setup) are captured — the beforeEach navigation already happened before this body. + startMonitoringConsole(page) + await page.goto("/marks-none.html") + await editor.waitForConnected() + await editor.setValue("

Hello everyone

") + await editor.flush() + expect(page).toHaveNoErrors() + }) + }) +}) diff --git a/test/browser/tests/tables/disabled.test.js b/test/browser/tests/tables/disabled.test.js new file mode 100644 index 000000000..bc8368378 --- /dev/null +++ b/test/browser/tests/tables/disabled.test.js @@ -0,0 +1,73 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { startMonitoringConsole } from "../../helpers/assertions.js" + +const TABLE_HTML = + '
alphabeta
gammadelta

After table

' + +const valueOf = async (editor) => { + await editor.flush() + return editor.value() +} + +test.describe("Tables disabled", () => { + test("the table toolbar button is present by default (regression baseline)", async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(page.locator("lexxy-toolbar button[name='table']")).toBeVisible() + }) + + test("the table toolbar button is hidden when tables are disabled", async ({ page }) => { + await page.goto("/tables-false.html") + await page.waitForSelector("lexxy-editor[connected]") + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(page.locator("lexxy-toolbar button[name='table']")).toBeHidden() + }) + + test("dispatching insertTable does nothing when tables are disabled", async ({ page, editor }) => { + await page.goto("/tables-false.html") + await editor.waitForConnected() + + await editor.click() + await editor.locator.evaluate((el) => el.editor.update(() => el.editor.dispatchCommand("insertTable"))) + + await expect(editor.content.locator("table")).toHaveCount(0) + await expect.poll(() => valueOf(editor)).not.toContain(" { + await page.goto("/tables-false.html") + await editor.waitForConnected() + + await expect(editor.locator.locator("lexxy-table-tools")).toHaveCount(0) + }) + + test("loading a table strips it to plain text when tables are disabled", async ({ page, editor }) => { + await page.goto("/tables-false.html") + await editor.waitForConnected() + + await editor.setValue(TABLE_HTML) + + await expect(editor.content.locator("table")).toHaveCount(0) + await expect.poll(() => valueOf(editor)).not.toContain(" valueOf(editor)).not.toContain("lexxy-content__table-wrapper") + await expect.poll(() => valueOf(editor)).toContain("After table") + + // strip-to-plain-text must preserve the cell content, not drop it + const value = await valueOf(editor) + for (const cell of [ "alpha", "beta", "gamma", "delta" ]) { + expect(value).toContain(cell) + } + }) + + test("editor connects without crashing when tables are disabled", async ({ page }) => { + startMonitoringConsole(page) + + await page.goto("/tables-false.html") + await page.waitForSelector("lexxy-editor[connected]") + + expect(page).toHaveNoErrors() + }) +}) diff --git a/test/dummy/app/controllers/sandbox_controller.rb b/test/dummy/app/controllers/sandbox_controller.rb index c99acb591..65f5b31d4 100644 --- a/test/dummy/app/controllers/sandbox_controller.rb +++ b/test/dummy/app/controllers/sandbox_controller.rb @@ -1,5 +1,18 @@ class SandboxController < ApplicationController - ALLOWED_TEMPLATES = %w[default tables code lists highlights images empty] + ALLOWED_TEMPLATES = %w[default tables code lists highlights images empty fetlife] + + # Editor configuration overrides per template. Keys map to dasherized + # attributes; values are JSON-encoded so the editor's + # attribute parser (JSON.parse with raw-string fallback) reads them correctly. + EDITOR_OPTIONS = { + "fetlife" => { + code: "false", + highlight: '{"enabled":false}', + marks: '["bold","italic","strikethrough"]', + tables: "false", + headings: '["h1","h2","h3","h4"]' + } + }.freeze def show @template = if params[:template].presence_in ALLOWED_TEMPLATES @@ -7,5 +20,7 @@ def show else "default" end + + @editor_options = EDITOR_OPTIONS.fetch(@template, {}) end end diff --git a/test/dummy/app/views/posts/_form.html.erb b/test/dummy/app/views/posts/_form.html.erb index fc7e59dbc..1453705e1 100644 --- a/test/dummy/app/views/posts/_form.html.erb +++ b/test/dummy/app/views/posts/_form.html.erb @@ -41,8 +41,12 @@ autofocus: params[:autofocus] ? "true" : nil, attachments: (params[:attachments_disabled] != "true"), markdown: params[:markdown_disabled] ? "false" : nil, + marks: params[:marks].presence, "single-line": params[:multi_line_disabled] ? "true" : nil, "rich-text": params[:rich_text_disabled] ? "false" : nil, + tables: params[:tables_disabled] ? "false" : nil, + highlight: params[:highlight_disabled] ? '{"enabled":false}' : nil, + code: params[:code_disabled] ? "false" : nil, toolbar: params[:toolbar_disabled] ? "false" : (params[:toolbar_external] ? "external_toolbar" : nil), data: (params[:authenticated_storage] == "true" ? { direct_upload_url: authenticated_direct_uploads_url } : {}), required: true do %> diff --git a/test/dummy/app/views/sandbox/_fetlife.html.erb b/test/dummy/app/views/sandbox/_fetlife.html.erb new file mode 100644 index 000000000..e69de29bb diff --git a/test/dummy/app/views/sandbox/show.html.erb b/test/dummy/app/views/sandbox/show.html.erb index b4b35022e..26de23548 100644 --- a/test/dummy/app/views/sandbox/show.html.erb +++ b/test/dummy/app/views/sandbox/show.html.erb @@ -15,11 +15,12 @@ Text Highlights Images Empty + FetLife
- <%= rich_text_area_tag :sandbox, render(@template), placeholder: "Write something...", data: { lexxy_output_target: "editor" } do %> + <%= rich_text_area_tag :sandbox, render(@template), **@editor_options, placeholder: "Write something...", data: { lexxy_output_target: "editor" } do %> <%= render "emoji_prompt", trigger: ":" %> <% end %> diff --git a/test/javascript/native/attributes_change.test.js b/test/javascript/native/attributes_change.test.js index f8a2060e8..4c683cc58 100644 --- a/test/javascript/native/attributes_change.test.js +++ b/test/javascript/native/attributes_change.test.js @@ -19,7 +19,7 @@ describe("attributes change event", () => { }) const expectedKeys = [ - "bold", "italic", "strikethrough", "code", "highlight", + "bold", "italic", "strikethrough", "underline", "code", "highlight", "link", "quote", "heading", "unordered-list", "ordered-list", "undo", "redo" ] @@ -56,6 +56,7 @@ describe("attributes change event", () => { expect(event.detail.attributes.bold).toEqual({ active: false, enabled: true }) expect(event.detail.attributes.italic).toEqual({ active: false, enabled: true }) expect(event.detail.attributes.strikethrough).toEqual({ active: false, enabled: true }) + expect(event.detail.attributes.underline).toEqual({ active: false, enabled: true }) expect(event.detail.attributes.code).toEqual({ active: false, enabled: true }) expect(event.detail.attributes.link).toEqual({ active: false, enabled: true }) expect(event.detail.attributes.quote).toEqual({ active: false, enabled: true }) diff --git a/test/javascript/unit/editor/headings_configuration.test.js b/test/javascript/unit/editor/headings_configuration.test.js new file mode 100644 index 000000000..a88443efb --- /dev/null +++ b/test/javascript/unit/editor/headings_configuration.test.js @@ -0,0 +1,54 @@ +import { expect, test } from "vitest" +import { createElement } from "../helpers/dom_helper" +import EditorConfiguration from "src/editor/configuration" +import { configure } from "src/index" + +configure({ + default: { + headings: ["h2", "h3", "h4"] + }, + minimal: { + headings: ["h2"], + }, + noHeadings: { + headings: [], + }, +}) + +test("uses default headings", () => { + const element = createElement("") + const config = new EditorConfiguration(element) + expect(config.get("headings")).toEqual(["h2", "h3", "h4"]) +}) + +test("overrides headings with attribute", () => { + const element = createElement( + '' + ) + const config = new EditorConfiguration(element) + expect(config.get("headings")).toEqual(["h1", "h2", "h3", "h4", "h5", "h6"]) +}) + +test("overrides headings with attribute to include h1 and h5", () => { + const element = createElement( + '' + ) + const config = new EditorConfiguration(element) + expect(config.get("headings")).toEqual(["h1", "h2", "h5"]) +}) + +test("restricts headings to a subset", () => { + const element = createElement( + "" + ) + const config = new EditorConfiguration(element) + expect(config.get("headings")).toEqual(["h2"]) +}) + +test("handles empty headings array", () => { + const element = createElement( + "" + ) + const config = new EditorConfiguration(element) + expect(config.get("headings")).toEqual([]) +}) diff --git a/test/system/code_disabled_test.rb b/test/system/code_disabled_test.rb new file mode 100644 index 000000000..6bce31633 --- /dev/null +++ b/test/system/code_disabled_test.rb @@ -0,0 +1,46 @@ +require "application_system_test_case" + +class CodeDisabledTest < ApplicationSystemTestCase + setup do + visit edit_post_path(posts(:empty), code_disabled: true) + wait_for_editor + end + + test "the Code toolbar button is not rendered" do + assert_no_selector "lexxy-toolbar button[name='code']" + end + + test "a markdown code fence does not create a code block and is not persisted" do + find_editor.send "```" + find_editor.send :enter + find_editor.send "puts 1" + + # Code blocks render as in the editor's live DOM. + assert_no_selector "lexxy-editor code" + + click_on "Update Post" + + within "article.post" do + assert_no_selector "pre" + assert_text "puts 1" + end + end + + test "a saved code block is stripped to plain text on load and round-trips without one" do + find_editor.value = '
def hi
' + + assert_no_selector "lexxy-editor code" + assert_text "def hi" + + click_on "Update Post" + + within "article.post" do + assert_no_selector "pre" + assert_text "def hi" + end + + click_on "Edit this post" + wait_for_editor + assert_no_match(/
 and legacy Trix styled elements (em/strong with color).
+    find_editor.value = '

red ital bold plain

' + + assert_text "red" + assert_text "plain" + assert_no_match(/keep strike under

", find_editor.value + click_on "Update Post" + + # Re-open the saved post with strikethrough + underline disabled: stripped on load. + visit edit_post_path(posts(:empty), marks: '["bold", "italic"]') + assert_equal_html "

keep strike under

", find_editor.value + + # Saving from the limited editor persists the stripped content (rendered + re-edited). + click_on "Update Post" + assert_selector "strong", text: "keep" + assert_no_selector "s" + assert_no_selector "u" + + visit edit_post_path(posts(:empty), marks: '["bold", "italic"]') + assert_equal_html "

keep strike under

", find_editor.value + end + + test "all marks survive the round-trip by default" do + visit edit_post_path(posts(:empty)) + + find_editor.value = "

b i s u

" + assert_equal_html "

b i s u

", find_editor.value + + click_on "Update Post" + click_on "Edit this post" + + assert_equal_html "

b i s u

", find_editor.value + end +end diff --git a/test/system/tables_disabled_test.rb b/test/system/tables_disabled_test.rb new file mode 100644 index 000000000..24c3e4ec6 --- /dev/null +++ b/test/system/tables_disabled_test.rb @@ -0,0 +1,39 @@ +require "application_system_test_case" + +class TablesDisabledTest < ApplicationSystemTestCase + setup do + visit edit_post_path(posts(:empty), tables_disabled: true) + wait_for_editor + end + + test "the table toolbar button is not visible" do + assert_no_selector "lexxy-toolbar button[name='table']" + end + + test "the lexxy-table-tools element is not created" do + assert_no_selector "lexxy-editor lexxy-table-tools" + end + + test "a saved table is stripped to plain text on load and round-trips without one" do + find_editor.value = '
alphabeta
gammadelta

After table

' + + assert_no_selector "lexxy-editor table" + assert_text "After table" + assert_text "alpha" + assert_text "delta" + + click_on "Update Post" + + within "article.post" do + assert_no_selector "table" + assert_text "After table" + assert_text "alpha" + assert_text "delta" + end + + click_on "Edit this post" + wait_for_editor + assert_no_match(/