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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions home/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<lexxy-toolbar>` 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 `<pre>` 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 `<code>` 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: `<lexxy-editor permitted-attachment-types="application/vnd.basecamp.mention application/vnd.basecamp.opengraph-embed"></lexxy-editor>`.
Expand Down
2 changes: 2 additions & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/editor/command_dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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()
})
Comment on lines +650 to +652

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think editing another tool would be out of scope of this PR

}

// 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.
Comment on lines +655 to +657
#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")
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/browser/fixtures/code-all-false.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test — Code Fully Disabled</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." code-blocks="false" inline-code="false" required></lexxy-editor>
</div>

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

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

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

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

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

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

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

<script type="module" src="/editor.js"></script>
</body>
</html>
109 changes: 109 additions & 0 deletions test/browser/tests/formatting/code_disabled.test.js
Original file line number Diff line number Diff line change
@@ -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 = '<pre data-language="plain"><code>alpha beta</code></pre><p>After</p>'
const INLINE_CODE_HTML = "<p>Hello <code>world</code> again</p>"

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("<p>hello</p>")
await editor.select("hello")

await clickToolbarButton(page, "insertCodeBlock")

await assertEditorHtml(editor, "<p><code>hello</code></p>")
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("<pre")
expect(value).toContain("alpha beta")
expect(value).toContain("After")
})
})

test.describe("Inline code disabled, code blocks enabled", () => {
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("<p>hello</p>")
await editor.select("hello")

await clickToolbarButton(page, "insertCodeBlock")

await expect.poll(() => editor.value()).toContain("<pre")
expect(await editor.value()).not.toContain("<code")
})

test("typing a Markdown backtick does not apply inline code", async ({ editor }) => {
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("<code")
expect(value).toContain("Hello")
expect(value).toContain("world")
expect(value).toContain("again")
})
})

test.describe("Code blocks and inline code both disabled", () => {
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()
})
})
2 changes: 2 additions & 0 deletions test/dummy/app/views/posts/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions test/javascript/unit/editor/code_configuration.test.js
Original file line number Diff line number Diff line change
@@ -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("<lexxy-editor></lexxy-editor>")
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("<lexxy-editor preset='noCode'></lexxy-editor>")
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(`<lexxy-editor code-blocks="false"></lexxy-editor>`)
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(`<lexxy-editor inline-code="false"></lexxy-editor>`)
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("<lexxy-editor preset='fallbackToDefault'></lexxy-editor>")
const config = new EditorConfiguration(element)
expect(config.get("codeBlocks")).toBe(true)
expect(config.get("inlineCode")).toBe(true)
})
Loading
Loading