diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 8b1f88966..7bfdb5086 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -479,6 +479,10 @@ display: none; } + &[data-code-blocks="false"][data-inline-code="false"] button[name="code"] { + display: none; + } + &[data-upload="file"] button[name="image"] { display: none; } diff --git a/home/docs/configuration.md b/home/docs/configuration.md index 54ec50985..2b2d37909 100644 --- a/home/docs/configuration.md +++ b/home/docs/configuration.md @@ -46,6 +46,8 @@ Editors support the following options, configurable using presets and element at - `toolbar`: Pass `false` to disable the toolbar entirely, pass the ID of a `` element to use as an external toolbar, or pass an object to configure individual toolbar buttons. By default, the toolbar is bootstrapped and displayed above the editor. - `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`. +- `codeBlocks`: Pass `false` to disable code blocks. Markdown insertion is removed, and any existing `
` code is reduced to plain text when loaded. The shared toolbar code button remains if `inlineCode` is enabled. By default, code blocks are enabled.
+- `inlineCode`: Pass `false` to disable inline `code`. Markdown insertion is removed, and any existing inline `` is reduced to plain text when loaded. The shared toolbar code button remains if `codeBlocks` is enabled. By default, inline code is enabled.
 - `markdown`: Pass `false` to disable Markdown support.
 - `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: ``.
diff --git a/src/config/lexxy.js b/src/config/lexxy.js
index 1056f3669..06eb3c9b2 100644
--- a/src/config/lexxy.js
+++ b/src/config/lexxy.js
@@ -11,6 +11,8 @@ const global = new Configuration({
 const presets = new Configuration({
   default: {
     attachments: true,
+    codeBlocks: true,
+    inlineCode: true,
     markdown: true,
     multiLine: true,
     permittedAttachmentTypes: null,
diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js
index 032fd13ea..dcca576a6 100644
--- a/src/editor/command_dispatcher.js
+++ b/src/editor/command_dispatcher.js
@@ -157,13 +157,23 @@ export class CommandDispatcher {
   }
 
   dispatchInsertCodeBlock() {
-    if (this.selection.hasSelectedWordsInSingleLine) {
+    if (this.#shouldToggleInlineCode()) {
       this.#toggleInlineCode()
-    } else {
+    } else if (this.editorElement.supportsCodeBlocks) {
       this.contents.toggleCodeBlock()
     }
   }
 
+  #shouldToggleInlineCode() {
+    if (!this.editorElement.supportsInlineCode) {
+      return false
+    } else if (!this.editorElement.supportsCodeBlocks) {
+      return true
+    } else {
+      return this.selection.hasSelectedWordsInSingleLine
+    }
+  }
+
   #toggleInlineCode() {
     const selection = $getSelection()
     if (!$isRangeSelection(selection)) return
diff --git a/src/elements/editor.js b/src/elements/editor.js
index bd23c32b4..9f03e9128 100644
--- a/src/elements/editor.js
+++ b/src/elements/editor.js
@@ -1,4 +1,4 @@
-import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $hasUpdateTag, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $onUpdate, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, PASTE_TAG, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical"
+import { $addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $hasUpdateTag, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $onUpdate, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, PASTE_TAG, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical"
 import { buildEditorFromExtensions } from "@lexical/extension"
 import { ListItemNode, ListNode, registerList } from "@lexical/list"
 import { AutoLinkNode, LinkNode } from "@lexical/link"
@@ -9,7 +9,7 @@ import { $generateHtmlFromNodes, $generateNodesFromDOM as $generateLexicalNodesF
 import { filterDisallowedAttachmentNodes } from "../helpers/attachment_filter_helper"
 import { $convertInlineImageDataURIs } from "../helpers/inline_image_uri_helper"
 import { CodeHighlightNode, CodeNode } 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"
 
@@ -267,6 +267,14 @@ export class LexicalEditorElement extends HTMLElement {
     return this.config.get("attachments")
   }
 
+  get supportsCodeBlocks() {
+    return this.supportsRichText && this.config.get("codeBlocks")
+  }
+
+  get supportsInlineCode() {
+    return this.supportsRichText && this.config.get("inlineCode")
+  }
+
   get supportsMarkdown() {
     return this.supportsRichText && this.config.get("markdown")
   }
@@ -523,7 +531,7 @@ export class LexicalEditorElement extends HTMLElement {
     this.#setEditorHtml(initialHtml, { editor })
   }
 
-  #setEditorHtml(html, { editor = this.editor } = { }) {
+  #setEditorHtml(html, { editor = this.editor } = {}) {
     $getRoot()
       .clear()
       .selectEnd()
@@ -606,8 +614,10 @@ export class LexicalEditorElement extends HTMLElement {
       )
       this.#registerTableComponents()
       this.#registerCodeLanguagePicker()
+      if (!this.supportsCodeBlocks) registered.push(this.#registerCodeBlockStripper())
+      if (!this.supportsInlineCode) registered.push(this.#registerInlineCodeStripper())
       if (this.supportsMarkdown) {
-        const transformers = [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ]
+        const transformers = this.#markdownTransformers()
         registered.push(
           registerMarkdownShortcuts(this.editor, transformers),
           registerMarkdownLeadingTagHandler(this.editor, transformers)
@@ -620,6 +630,37 @@ export class LexicalEditorElement extends HTMLElement {
     this.#listeners.track(...registered)
   }
 
+  #markdownTransformers() {
+    const excluded = new Set()
+    if (!this.supportsCodeBlocks) excluded.add(CODE)
+    if (!this.supportsInlineCode) excluded.add(INLINE_CODE)
+
+    return [ ...TRANSFORMERS, HORIZONTAL_DIVIDER ].filter(transformer => !excluded.has(transformer))
+  }
+
+  // Code nodes stay registered even when code blocks are disabled
+  #registerCodeBlockStripper() {
+    return this.editor.registerNodeTransform(CodeNode, (node) => {
+      const paragraphs = node.getTextContent().split("\n").map((line) => {
+        const paragraph = $createParagraphNode()
+        if (line.length > 0) paragraph.append($createTextNode(line))
+        return paragraph
+      })
+
+      paragraphs.forEach((paragraph) => node.insertBefore(paragraph))
+      node.remove()
+    })
+  }
+
+  // Inline code is a text format rather than a node, so strip the format from
+  // any imported or pasted content. Runs on load and paste; a no-op for
+  // unformatted text.
+  #registerInlineCodeStripper() {
+    return this.editor.registerNodeTransform(TextNode, (node) => {
+      if (node.hasFormat("code")) node.toggleFormat("code")
+    })
+  }
+
   #registerTableComponents() {
     let tableTools = this.querySelector("lexxy-table-tools")
     tableTools ??= createElement("lexxy-table-tools")
@@ -743,6 +784,8 @@ 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-code-blocks", this.supportsCodeBlocks)
+    toolbar.setAttribute("data-inline-code", this.supportsInlineCode)
     toolbar.configure(this.config.get("toolbar"))
     this.prepend(toolbar)
     return toolbar
diff --git a/test/browser/fixtures/code-all-false.html b/test/browser/fixtures/code-all-false.html
new file mode 100644
index 000000000..559191984
--- /dev/null
+++ b/test/browser/fixtures/code-all-false.html
@@ -0,0 +1,24 @@
+
+
+
+  
+  
+  Lexxy Test — Code Fully Disabled
+  
+
+
+  
+
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/test/browser/fixtures/code-blocks-false.html b/test/browser/fixtures/code-blocks-false.html new file mode 100644 index 000000000..a9c9342da --- /dev/null +++ b/test/browser/fixtures/code-blocks-false.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Code Blocks Disabled + + + +
+
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/test/browser/fixtures/inline-code-false.html b/test/browser/fixtures/inline-code-false.html new file mode 100644 index 000000000..4fb2e6824 --- /dev/null +++ b/test/browser/fixtures/inline-code-false.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test — Inline Code 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..792784740 --- /dev/null +++ b/test/browser/tests/formatting/code_disabled.test.js @@ -0,0 +1,109 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" +import { assertEditorHtml, startMonitoringConsole } from "../../helpers/assertions.js" +import { clickToolbarButton } from "../../helpers/toolbar.js" + +const CODE_BLOCK_HTML = '
alpha beta

After

' +const INLINE_CODE_HTML = "

Hello world again

" + +test.describe("Code blocks disabled, inline code enabled", () => { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/code-blocks-false.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("the shared code toolbar button is still visible", async ({ page }) => { + await expect(page.locator("lexxy-toolbar button[name='code']")).toBeVisible() + }) + + test("the toolbar button applies inline code instead of a code block", async ({ page, editor }) => { + await editor.setValue("

hello

") + await editor.select("hello") + + await clickToolbarButton(page, "insertCodeBlock") + + await assertEditorHtml(editor, "

hello

") + await expect(editor.content.locator("pre")).toHaveCount(0) + }) + + test("typing a Markdown fence does not create a code block", async ({ editor }) => { + await editor.click() + await editor.send("```") + await editor.send("Enter") + await editor.flush() + + await expect(editor.content.locator("pre")).toHaveCount(0) + }) + + test("a loaded code block is reduced to plain text", async ({ editor }) => { + await editor.setValue(CODE_BLOCK_HTML) + + await expect(editor.content.locator("code")).toHaveCount(0) + const value = await editor.value() + expect(value).not.toContain(" { + test.beforeEach(async ({ page, editor }) => { + await page.goto("/inline-code-false.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + }) + + test("the shared code toolbar button is still visible", async ({ page }) => { + await expect(page.locator("lexxy-toolbar button[name='code']")).toBeVisible() + }) + + test("the toolbar button inserts a code block instead of inline code", async ({ page, editor }) => { + await editor.setValue("

hello

") + await editor.select("hello") + + await clickToolbarButton(page, "insertCodeBlock") + + await expect.poll(() => editor.value()).toContain(" { + await editor.click() + await editor.send("`hello`") + await editor.flush() + + await expect(editor.content.locator("code")).toHaveCount(0) + expect(await editor.plainTextValue()).toContain("hello") + }) + + test("loaded inline code is reduced to plain text", async ({ editor }) => { + await editor.setValue(INLINE_CODE_HTML) + + await expect(editor.content.locator("code")).toHaveCount(0) + const value = await editor.value() + expect(value).not.toContain(" { + test("the shared code toolbar button is hidden", async ({ page, editor }) => { + await page.goto("/code-all-false.html") + await editor.waitForConnected() + await page.waitForSelector("lexxy-toolbar[connected]") + + await expect(page.locator("lexxy-toolbar button[name='code']")).toBeHidden() + }) + + test("the editor connects without console errors", async ({ page, editor }) => { + startMonitoringConsole(page) + + await page.goto("/code-all-false.html") + await editor.waitForConnected() + + expect(page).toHaveNoErrors() + }) +}) diff --git a/test/dummy/app/views/posts/_form.html.erb b/test/dummy/app/views/posts/_form.html.erb index 4a50c0e0a..f5ad4b4f2 100644 --- a/test/dummy/app/views/posts/_form.html.erb +++ b/test/dummy/app/views/posts/_form.html.erb @@ -48,6 +48,8 @@ <%= form.rich_text_area :body, placeholder: "Write something...", autofocus: params[:autofocus] ? "true" : nil, attachments: (params[:attachments_disabled] != "true"), + "code-blocks": params[:code_blocks_disabled] ? "false" : nil, + "inline-code": params[:inline_code_disabled] ? "false" : nil, markdown: params[:markdown_disabled] ? "false" : nil, "single-line": params[:multi_line_disabled] ? "true" : nil, "rich-text": params[:rich_text_disabled] ? "false" : nil, diff --git a/test/javascript/unit/editor/code_configuration.test.js b/test/javascript/unit/editor/code_configuration.test.js new file mode 100644 index 000000000..1de4fc25d --- /dev/null +++ b/test/javascript/unit/editor/code_configuration.test.js @@ -0,0 +1,52 @@ +import { expect, test } from "vitest" +import { createElement } from "../helpers/dom_helper" +import EditorConfiguration from "src/editor/configuration" +import { configure } from "src/index" + +configure({ + default: { + codeBlocks: true, + inlineCode: true + }, + noCode: { + codeBlocks: false, + inlineCode: false + }, + fallbackToDefault: { + } +}) + +test("uses default code options", () => { + const element = createElement("") + const config = new EditorConfiguration(element) + expect(config.get("codeBlocks")).toBe(true) + expect(config.get("inlineCode")).toBe(true) +}) + +test("uses preset code options", () => { + const element = createElement("") + const config = new EditorConfiguration(element) + expect(config.get("codeBlocks")).toBe(false) + expect(config.get("inlineCode")).toBe(false) +}) + +test("overrides code blocks via element attribute", () => { + const element = createElement(``) + const config = new EditorConfiguration(element) + expect(config.get("codeBlocks")).toBe(false) + expect(config.get("inlineCode")).toBe(true) +}) + +test("overrides inline code via element attribute", () => { + const element = createElement(``) + const config = new EditorConfiguration(element) + expect(config.get("inlineCode")).toBe(false) + expect(config.get("codeBlocks")).toBe(true) +}) + +test("preset falls back to default code options", () => { + const element = createElement("") + const config = new EditorConfiguration(element) + expect(config.get("codeBlocks")).toBe(true) + expect(config.get("inlineCode")).toBe(true) +}) diff --git a/test/system/code_disabled_test.rb b/test/system/code_disabled_test.rb new file mode 100644 index 000000000..bfcf5f5a9 --- /dev/null +++ b/test/system/code_disabled_test.rb @@ -0,0 +1,50 @@ +require "application_system_test_case" + +class CodeDisabledTest < ApplicationSystemTestCase + test "a saved code block is reduced to plain text on load and round-trips without one" do + visit edit_post_path(posts(:empty), code_blocks_disabled: true) + wait_for_editor + + find_editor.value = '
alpha beta

After

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