diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index 112c46005..82242adf9 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -441,14 +441,6 @@ display: none; } - &[data-upload="file"] button[name="image"] { - display: none; - } - - &[data-upload="image"] button[name="file"] { - display: none; - } - .lexxy-editor__toolbar-button { aspect-ratio: 1; block-size: var(--lexxy-toolbar-button-size); @@ -572,7 +564,7 @@ .lexxy-editor__toolbar-dropdown-list { border-start-start-radius: 0; flex-direction: column; - gap: 0.1ch; + gap: var(--lexxy-toolbar-gap); padding: 0.1ch; button { @@ -581,6 +573,7 @@ flex-direction: row; gap: 1ch; padding: 1ch; + position: relative; &[aria-pressed="true"] { background-color: var(--lexxy-color-selected); @@ -600,10 +593,48 @@ } } - .separator { - background: var(--lexxy-color-ink-lighter); - block-size: 1px; - inline-size: 100%; + .lexxy-editor__toolbar-group-end { + margin-block-end: var(--lexxy-toolbar-gap); + + &:after { + background: var(--lexxy-color-ink-lighter); + block-size: 1px; + content: ""; + display: block; + inset-inline: 0.5ch; + inset-block-end: calc(var(--lexxy-toolbar-gap) * -2); + pointer-events: none; + position: absolute; + } + + & + * { + margin: 0; + margin-block-start: calc(var(--lexxy-toolbar-gap) + 1px); + } + } + + [overflowing] & { + flex-direction: row; + + button span { display: none; } + + .lexxy-editor__toolbar-group-end { + margin: 0; + margin-inline-end: var(--lexxy-toolbar-gap); + + &:after { + block-size: auto; + inline-size: 1px; + inset-block: 0.5ch; + inset-inline-start: auto; + inset-inline-end: calc(var(--lexxy-toolbar-gap) * -2); + } + + & + * { + margin: 0; + margin-inline-start: calc(var(--lexxy-toolbar-gap) + 1px); + } + } } } diff --git a/docs/configuration.md b/docs/configuration.md index b6d510c78..3ed6b6840 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,8 +43,8 @@ Lexxy.configure({ Editors support the following options, configurable using presets and element attributes: -- `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. +- `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 the toolbar layout. By default, the toolbar is bootstrapped and displayed above the editor. See [Toolbar Customization](toolbar.md) for full details. + - `toolbar.items`: An array that controls which items appear in the toolbar and in what order. See [Toolbar Customization](toolbar.md). - `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. diff --git a/docs/toolbar.md b/docs/toolbar.md new file mode 100644 index 000000000..086d358da --- /dev/null +++ b/docs/toolbar.md @@ -0,0 +1,173 @@ +--- +title: Toolbar +layout: default +parent: Configuration +nav_order: 2 +--- + +# Toolbar Customization + +The toolbar layout is fully configurable through the `toolbar.items` array. Each entry defines a button, dropdown group, separator, or spacer. + +## Item types + +| Entry | Description | +|-------|-------------| +| `"bold"` | A button name from the built-in registry (see below) | +| `{ name, icon, label, items }` | A dropdown group containing child items | +| `"|"` | A visual group separator — adds a border after the preceding item | +| `"~"` | A flexible spacer — pushes subsequent items to the right edge | + +## Default items + +This is the default toolbar configuration: + +```js +toolbar: { + items: [ + "image", + "file", + "|", + "bold", + "italic", + { name: "format", icon: "heading", label: "Text formatting", items: [ + "paragraph", + "heading-large", + "heading-medium", + "heading-small", + "|", + "strikethrough", + "underline", + ] + }, + { name: "lists", icon: "ul", label: "Lists", items: [ + "unordered-list", + "ordered-list", + ] + }, + "highlight", + "link", + "quote", + "code", + "|", + "table", + "divider", + "~", + "undo", + "redo", + ] +} +``` + +## Available items + +| Name | Icon | Description | +|------|------|-------------| +| `image` | image | Upload images | +| `file` | attachment | Upload files | +| `bold` | bold | Bold formatting | +| `italic` | italic | Italic formatting | +| `paragraph` | paragraph | Normal text (typically inside a format dropdown) | +| `heading-large` | h2 | Large heading | +| `heading-medium` | h3 | Medium heading | +| `heading-small` | h4 | Small heading | +| `strikethrough` | strikethrough | Strikethrough text | +| `underline` | underline | Underline text | +| `unordered-list` | ul | Bullet list | +| `ordered-list` | ol | Numbered list | +| `highlight` | highlight | Color highlight picker | +| `link` | link | Insert/edit link | +| `quote` | quote | Block quote | +| `code` | code | Code block | +| `table` | table | Insert table | +| `divider` | hr | Horizontal divider | +| `undo` | undo | Undo | +| `redo` | redo | Redo | + +## Examples + +### Minimal toolbar + +```js +Lexxy.configure({ + compact: { + toolbar: { + items: ["bold", "italic", "link", "|", "undo", "redo"] + } + } +}) +``` + +```html + +``` + +### Without attachments + +Omit `image` and `file` to remove upload buttons: + +```js +toolbar: { + items: [ + "bold", + "italic", + { name: "format", icon: "heading", label: "Text formatting", items: [ + "paragraph", "heading-large", "heading-medium", "heading-small", + "|", "strikethrough", "underline", + ] + }, + "link", + "quote", + "code", + "~", + "undo", + "redo", + ] +} +``` + +### Custom dropdown groups + +Create your own dropdown groupings: + +```js +toolbar: { + items: [ + "bold", + "italic", + { name: "insert", icon: "table", label: "Insert", items: [ + "table", + "divider", + "code", + "quote", + ] + }, + "link", + "~", + "undo", + "redo", + ] +} +``` + +### Per-editor override + +Override the toolbar for a specific editor using the HTML attribute: + +```html + +``` + +### Disabling the toolbar + +Pass `false` to hide the toolbar entirely: + +```html + +``` + +## Separators and spacers + +Use `"|"` between items to create visual groups. The separator renders as a thin vertical line (top-level) or a horizontal line (inside dropdowns). + +Use `"~"` to insert a flexible spacer that pushes all following items to the right edge of the toolbar. This is typically used before undo/redo. diff --git a/src/config/lexxy.js b/src/config/lexxy.js index e26c591c1..5813dcd09 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -15,7 +15,38 @@ const presets = new Configuration({ multiLine: true, richText: true, toolbar: { - upload: "both" + items: [ + "image", + "file", + "|", + "bold", + "italic", + { name: "format", icon: "heading", label: "Text formatting", items: [ + "paragraph", + "heading-large", + "heading-medium", + "heading-small", + "|", + "strikethrough", + "underline", + ] + }, + { name: "lists", icon: "ul", label: "Lists", items: [ + "unordered-list", + "ordered-list", + ] + }, + "highlight", + "link", + "quote", + "code", + "|", + "table", + "divider", + "~", + "undo", + "redo", + ] }, highlight: { buttons: { diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 595044a1b..7973addd9 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -1,5 +1,7 @@ import { $getSelection, $isRangeSelection } from "lexical" import { $getSelectionStyleValueForProperty } from "@lexical/selection" +import { createElement } from "../../helpers/html_helper" +import ToolbarIcons from "../toolbar_icons" import { ToolbarDropdown } from "../toolbar_dropdown" const APPLY_HIGHLIGHT_SELECTOR = "button.lexxy-highlight-button" @@ -11,6 +13,31 @@ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']" const NO_STYLE = Symbol("no_style") export class HighlightDropdown extends ToolbarDropdown { + static buildToolbarElement(name, entry) { + const details = createElement("details", { + className: "lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron", + name: "lexxy-dropdown" + }) + + details.appendChild(createElement("summary", { + className: "lexxy-editor__toolbar-button", + name, + title: entry.title + }, ToolbarIcons[entry.icon])) + + const dropdown = createElement("lexxy-highlight-dropdown", { + className: "lexxy-editor__toolbar-dropdown-content" + }) + dropdown.appendChild(createElement("div", { className: "lexxy-highlight-colors" })) + dropdown.appendChild(createElement("button", { + className: "lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset", + "data-command": "removeHighlight" + }, "Remove all coloring")) + + details.appendChild(dropdown) + return details + } + connectedCallback() { super.connectedCallback() this.#registerToggleHandler() diff --git a/src/elements/dropdown/link.js b/src/elements/dropdown/link.js index d881b6096..da3bd74b4 100644 --- a/src/elements/dropdown/link.js +++ b/src/elements/dropdown/link.js @@ -1,8 +1,53 @@ import { $getSelection, $isRangeSelection } from "lexical" import { $isLinkNode } from "@lexical/link" +import { createElement } from "../../helpers/html_helper" +import ToolbarIcons from "../toolbar_icons" import { ToolbarDropdown } from "../toolbar_dropdown" export class LinkDropdown extends ToolbarDropdown { + static buildToolbarElement(name, entry) { + const details = createElement("details", { + className: "lexxy-editor__toolbar-dropdown", + name: "lexxy-dropdown" + }) + + const summaryProps = { + className: "lexxy-editor__toolbar-button", + name, + title: entry.title + } + if (entry.hotkey) summaryProps["data-hotkey"] = entry.hotkey + details.appendChild(createElement("summary", summaryProps, ToolbarIcons[entry.icon])) + + const dropdown = createElement("lexxy-link-dropdown", { + className: "lexxy-editor__toolbar-dropdown-content" + }) + + const form = createElement("form", { method: "dialog" }) + form.appendChild(createElement("input", { + type: "url", + placeholder: "Enter a URL\u2026", + className: "input" + })) + + const actions = createElement("div", { className: "lexxy-editor__toolbar-dropdown-actions" }) + actions.appendChild(createElement("button", { + type: "submit", + className: "lexxy-editor__toolbar-button", + value: "link" + }, "Link")) + actions.appendChild(createElement("button", { + type: "button", + className: "lexxy-editor__toolbar-button", + value: "unlink" + }, "Unlink")) + + form.appendChild(actions) + dropdown.appendChild(form) + details.appendChild(dropdown) + return details + } + connectedCallback() { super.connectedCallback() this.input = this.querySelector("input") diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..275f175b3 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -494,10 +494,10 @@ export class LexicalEditorElement extends HTMLElement { } #createDefaultToolbar() { + const items = this.config.get("toolbar.items") const toolbar = createElement("lexxy-toolbar") - toolbar.innerHTML = LexicalToolbar.defaultTemplate + toolbar.appendChild(LexicalToolbar.buildTemplate(items)) toolbar.setAttribute("data-attachments", this.supportsAttachments) // Drives toolbar CSS styles - toolbar.configure(this.config.get("toolbar")) this.prepend(toolbar) return toolbar }