Skip to content
Merged
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
14 changes: 13 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,19 @@ jobs:
ruby-version: ruby-3.3.7
bundler-cache: true

- name: Run tests
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'yarn'

- name: Install JavaScript dependencies
run: yarn install --frozen-lockfile

- name: Run JavaScript tests
run: yarn test

- name: Run Rails tests
env:
RAILS_ENV: test
# REDIS_URL: redis://localhost:6379/0
Expand Down
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,56 @@ Under the hood, this will insert a `<lexxy-editor>` tag, that will be a first-cl
<lexxy-editor name="post[body]"...>...</lexxy-editor>
```

You can configure editors in two ways: using `Lexxy.configure` and element attributes.

```js
import * as Lexxy from "lexxy"

// overriding default options will affect all editors
Lexxy.configure({
default: {
toolbar: false
}
})
<lexxy-editor></lexxy-editor>

// you can also create new presets, which will extend the default preset
Lexxy.configure({
simple: {
richText: false
}
})
<lexxy-editor preset="simple"></lexxy-editor>

// you can override specific options with attributes on editor elements
<lexxy-editor preset="simple" rich-text="true"></lexxy-editor>

// finally, some options can only be configured globally
Lexxy.configure({
global: {
attachmentTagName: "bc-attachment"
}
})
```

Copy link
Member

Choose a reason for hiding this comment

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

This should include a reference to Lexxy.global and the option to configure tag names, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right! ✅

What do you think of the public interface? Right now I only expose Lexxy.configure which re-routes it's input to global/presets.

configure({ global: newGlobal, ...newPresets }) {
  if (newGlobal) {
    global.merge(newGlobal)
  }
  presets.merge(newPresets)
}

Copy link
Member

Choose a reason for hiding this comment

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

I think it is good 👍

## Options

The `<lexxy-editor>` element supports these options:
Editors support the following options, configurable using presets and element attributes:

- `toolbar`: Pass `false` to disable the toolbar entirely, or pass the ID of a `<lexxy-toolbar>` element to use as an external toolbar. By default, the toolbar is bootstrapped and displayed above the editor.
- `attachments`: Pass `false` to disable attachments completely. By default, attachments are supported, including paste and Drag & Drop support.
- `markdown`: Pass `false` to disable Markdown support.
- `multiLine`: Pass `false` to force single line editing.
- `richText`: Pass `false` to disable rich text editing.

In addition, the `<lexxy-editor>` element supports these attributes:

- `placeholder`: Text displayed when the editor is empty.
- `toolbar`: Pass `"false"` to disable the toolbar entirely, or pass an element ID to render the toolbar in an external element. By default, the toolbar is bootstrapped and displayed above the editor.
- `attachments`: Pass `"false"` to disable attachments completely. By default, attachments are supported, including paste and Drag & Drop support.
- Lexxy uses the `ElementInternals` API to participate in HTML forms as any standard control. This means that you can use standard HTML attributes like `name`, `value`, `required`, `disabled`, etc.

Finally, the following can only be configured using `Lexxy.configure({ global: ... })`:

Lexxy uses the `ElementInternals` API to participate in HTML forms as any standard control. This means that you can use standard HTML attributes like `name`, `value`, `required`, `disabled`, etc.
- `attachmentTagName`: The tag name used for [Action Text custom attachments](https://guides.rubyonrails.org/action_text_overview.html#signed-globalid). By default, they will be rendered as `action-text-attachment` tags.

## Prompts

Expand Down
238 changes: 187 additions & 51 deletions app/assets/javascript/lexxy.js

Large diffs are not rendered by default.

Binary file modified app/assets/javascript/lexxy.js.br
Binary file not shown.
Binary file modified app/assets/javascript/lexxy.js.gz
Binary file not shown.
6 changes: 3 additions & 3 deletions app/assets/javascript/lexxy.min.js

Large diffs are not rendered by default.

Binary file modified app/assets/javascript/lexxy.min.js.br
Binary file not shown.
Binary file modified app/assets/javascript/lexxy.min.js.gz
Binary file not shown.
5 changes: 3 additions & 2 deletions config/ci.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
step "Style: Ruby", "bin/rubocop -f github"
step "Style: JavaScript", "yarn lint"

step "Tests: prepare", "env RAILS_ENV=test bin/rails db:test:prepare"
step "Tests: run", "env RAILS_ENV=test bin/rails test:all -d"
step "Tests: JavaScript", "yarn test"
step "Tests: Rails (prepare)", "env RAILS_ENV=test bin/rails db:test:prepare"
step "Tests: Rails (run)", "env RAILS_ENV=test bin/rails test:all -d"

unless success?
failure "Signoff: CI failed.", "Fix the issues and try again."
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"eslint": "^9.15.0",
"jsdom": "^27.3.0",
"rollup": "^4.44.1",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-gzip": "^4.1.1"
"rollup-plugin-gzip": "^4.1.1",
"vitest": "^4.0.16"
},
"scripts": {
"build": "rollup -c",
"build:npm": "rollup -c rollup.config.npm.mjs",
"watch": "rollup -wc --watch.onEnd=\"rails restart\"",
"lint": "eslint",
"test": "vitest --environment=jsdom",
"prerelease": "yarn build:npm",
"release": "yarn build:npm && yarn publish"
},
Expand Down
18 changes: 18 additions & 0 deletions src/config/configuration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { deepMerge } from "../helpers/hash_helper"

export default class Configuration {
#tree = {}

constructor(...configs) {
this.merge(...configs)
}

merge(...configs) {
return this.#tree = configs.reduce(deepMerge, this.#tree)
}

get(path) {
const keys = path.split(".")
return keys.reduce((node, key) => node[key], this.#tree)
}
}
15 changes: 9 additions & 6 deletions src/config/dom_purify.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import DOMPurify from "dompurify"
import { getCSSFromStyleObject, getStyleObjectFromCSS } from "@lexical/selection"
import Lexxy from "./lexxy"

const ALLOWED_HTML_TAGS = [ "a", "action-text-attachment", "b", "blockquote", "br", "code", "em",
const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "em",
"figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "ul", "table", "tbody", "tr", "th", "td" ]

const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
Expand Down Expand Up @@ -37,8 +38,10 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
}
})

DOMPurify.setConfig({
ALLOWED_TAGS: ALLOWED_HTML_TAGS,
ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
})
export function buildConfig() {
return {
ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")),
ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
}
}
26 changes: 26 additions & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Configuration from "./configuration"

const global = new Configuration({
attachmentTagName: "action-text-attachment"
})

const presets = new Configuration({
default: {
attachments: true,
markdown: true,
multiLine: true,
richText: true,
toolbar: true,
}
})

export default {
global,
presets,
configure({ global: newGlobal, ...newPresets }) {
if (newGlobal) {
global.merge(newGlobal)
}
presets.merge(newPresets)
}
}
12 changes: 11 additions & 1 deletion src/editor/clipboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { nextFrame } from "../helpers/timing_helpers"
import { dispatch } from "../helpers/html_helper"
import { $getSelection, $isRangeSelection } from "lexical"
import { $isCodeNode } from "@lexical/code"
import { $insertDataTransferForRichText } from "@lexical/clipboard"

export default class Clipboard {
constructor(editorElement) {
Expand Down Expand Up @@ -70,8 +71,10 @@ export default class Clipboard {
} else if (isUrl(text)) {
const nodeKey = this.contents.createLink(text)
this.#dispatchLinkInsertEvent(nodeKey, { url: text })
} else {
} else if (this.editorElement.supportsMarkdown) {
this.#pasteMarkdown(text)
} else {
this.#pasteRichText(clipboardData)
Copy link
Member

Choose a reason for hiding this comment

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

nice 👏

}
})
}
Expand All @@ -93,6 +96,13 @@ export default class Clipboard {
this.contents.insertHtml(html)
}

#pasteRichText(clipboardData) {
this.editor.update(() => {
const selection = $getSelection()
$insertDataTransferForRichText(clipboardData, selection, this.editor)
})
}

#handlePastedFiles(clipboardData) {
if (!this.editorElement.supportsAttachments) return

Expand Down
45 changes: 45 additions & 0 deletions src/editor/configuration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Configuration from "../config/configuration"
import Lexxy from "../config/lexxy"
import { dasherize } from "../helpers/string_helper"

export default class EditorConfiguration {
#editorElement
#config

constructor(editorElement) {
this.#editorElement = editorElement
this.#config = new Configuration(
Lexxy.presets.get("default"),
Lexxy.presets.get(editorElement.preset),
this.#overrides
)
}

get(path) {
return this.#config.get(path)
}

get #overrides() {
const overrides = {}
for (const option of this.#defaultOptions) {
const attribute = dasherize(option)
if (this.#editorElement.hasAttribute(attribute)) {
overrides[option] = this.#parseAttribute(attribute)
}
}
return overrides
}

get #defaultOptions() {
return Object.keys(Lexxy.presets.get("default"))
}

#parseAttribute(attribute) {
const value = this.#editorElement.getAttribute(attribute)
try {
return JSON.parse(value)
} catch {
return value
}
}
}
2 changes: 1 addition & 1 deletion src/editor/contents.js
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ export default class Contents {
}

#appendLineBreakIfNeeded(paragraph) {
if ($isParagraphNode(paragraph) && !this.editorElement.isSingleLineMode) {
if ($isParagraphNode(paragraph) && this.editorElement.supportsMultiLine) {
const children = paragraph.getChildren()
const last = children[children.length - 1]
const beforeLast = children[children.length - 2]
Expand Down
49 changes: 35 additions & 14 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Selection from "../editor/selection"
import { createElement, dispatch, generateDomId, parseHtml, sanitize } from "../helpers/html_helper"
import { registerHeaderBackgroundTransform } from "../helpers/table_helper"
import LexicalToolbar from "./toolbar"
import Configuration from "../editor/configuration"
import Contents from "../editor/contents"
import Clipboard from "../editor/clipboard"
import Highlighter from "../editor/highlighter"
Expand All @@ -43,6 +44,7 @@ export default class LexicalEditorElement extends HTMLElement {

connectedCallback() {
this.id ??= generateDomId("lexxy-editor")
this.config = new Configuration(this)
this.editor = this.#createEditor()
this.contents = new Contents(this)
this.selection = new Selection(this)
Expand Down Expand Up @@ -114,16 +116,29 @@ export default class LexicalEditorElement extends HTMLElement {
return this.querySelector(".lexxy-prompt-menu.lexxy-prompt-menu--visible") !== null
}

get isRichTextMode() {
return this.getAttribute("rich-text") !== "false"
get preset() {
return this.getAttribute("preset") || "default"
}

get isSingleLineMode() {
return this.hasAttribute("single-line")
get supportsAttachments() {
return this.config.get("attachments")
}

get supportsAttachments() {
return this.getAttribute("attachments") !== "false"
get supportsMarkdown() {
return this.supportsRichText && this.config.get("markdown")
}

get supportsMultiLine() {
return this.config.get("multiLine") && !this.isSingleLineMode
}

get supportsRichText() {
return this.config.get("richText")
}

// TODO: Deprecate `single-line` attribute
get isSingleLineMode() {
return this.hasAttribute("single-line")
}

get contentTabIndex() {
Expand Down Expand Up @@ -220,7 +235,7 @@ export default class LexicalEditorElement extends HTMLElement {
get #lexicalNodes() {
const nodes = [ CustomActionTextAttachmentNode ]

if (this.isRichTextMode) {
if (this.supportsRichText) {
nodes.push(
TrixTextNode,
HighlightNode,
Expand Down Expand Up @@ -333,12 +348,14 @@ export default class LexicalEditorElement extends HTMLElement {
}

#registerComponents() {
if (this.isRichTextMode) {
if (this.supportsRichText) {
registerRichText(this.editor)
registerList(this.editor)
this.#registerTableComponents()
this.#registerCodeHiglightingComponents()
registerMarkdownShortcuts(this.editor, TRANSFORMERS)
if (this.supportsMarkdown) {
registerMarkdownShortcuts(this.editor, TRANSFORMERS)
}
} else {
registerPlainText(this.editor)
}
Expand Down Expand Up @@ -387,7 +404,7 @@ export default class LexicalEditorElement extends HTMLElement {
}

// In single line mode, prevent ENTER
if (this.isSingleLineMode) {
if (!this.supportsMultiLine) {
event.preventDefault()
return true
}
Expand All @@ -407,7 +424,7 @@ export default class LexicalEditorElement extends HTMLElement {
}

#handleTables() {
if (this.isRichTextMode) {
if (this.supportsRichText) {
this.removeTableSelectionObserver = registerTableSelectionObserver(this.editor, true)
setScrollableTablesActive(this.editor, true)
}
Expand All @@ -431,12 +448,16 @@ export default class LexicalEditorElement extends HTMLElement {
}

#findOrCreateDefaultToolbar() {
const toolbarId = this.getAttribute("toolbar")
return toolbarId ? document.getElementById(toolbarId) : this.#createDefaultToolbar()
const toolbarId = this.config.get("toolbar")
if (toolbarId && toolbarId !== true) {
return document.getElementById(toolbarId)
} else {
return this.#createDefaultToolbar()
}
}

get #hasToolbar() {
return this.isRichTextMode && this.getAttribute("toolbar") !== "false"
return this.supportsRichText && this.config.get("toolbar")
}

#createDefaultToolbar() {
Expand Down
Loading