Skip to content
Draft
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
11 changes: 11 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ Editors support the following options, configurable using presets and element at
- `markdown`: Pass `false` to disable Markdown support.
- `multiLine`: Pass `false` to force single line editing.
- `richText`: Pass `false` to disable rich text editing.
- `headings`: Pass an array of heading tags to configure which heading levels are available. The toolbar displays a dropdown for selecting among the configured levels. Defaults to `["h2", "h3", "h4"]`. Pass an empty array to disable headings entirely.

```js
// Via preset
Lexxy.configure({
default: { headings: ["h1", "h2", "h3"] }
})

// Via element attribute
<lexxy-editor headings='["h2", "h3"]'></lexxy-editor>
```

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.

Expand Down
1 change: 1 addition & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const presets = new Configuration({
toolbar: {
upload: "both"
},
headings: [ "h2", "h3", "h4" ],
highlight: {
buttons: {
color: range(1, 9).map(n => `var(--highlight-${n})`),
Expand Down
9 changes: 9 additions & 0 deletions src/editor/command_dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const COMMANDS = [
"setFormatHeadingSmall",
"setFormatParagraph",
"clearFormatting",
"applyHeadingFormat",
"insertUnorderedList",
"insertOrderedList",
"insertQuoteBlock",
Expand Down Expand Up @@ -245,6 +246,14 @@ export class CommandDispatcher {
this.contents.applyHeadingFormat("h4")
}

dispatchApplyHeadingFormat(tag) {
if (tag) {
this.contents.applyHeadingFormat(tag)
} else {
this.contents.applyParagraphFormat()
}
}

dispatchSetFormatParagraph() {
this.contents.applyParagraphFormat()
}
Expand Down
107 changes: 107 additions & 0 deletions src/elements/dropdown/heading.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { $getSelection, $isRangeSelection } from "lexical"
import { $isHeadingNode } from "@lexical/rich-text"
import { ToolbarDropdown } from "../toolbar_dropdown"

export class HeadingDropdown extends ToolbarDropdown {
connectedCallback() {
super.connectedCallback()
this.#registerToggleHandler()
}

initialize() {
this.#setUpButtons()
this.#registerButtonHandlers()
}

#registerToggleHandler() {
this.container.addEventListener("toggle", this.#handleToggle.bind(this))
}

#registerButtonHandlers() {
this.#headingButtons.forEach(button => button.addEventListener("click", this.#handleHeadingClick.bind(this)))
this.querySelector(".lexxy-heading-remove")?.addEventListener("click", this.#handleRemoveHeadingClick.bind(this))
}

#setUpButtons() {
const headings = this.#configuredHeadings

headings.forEach((tag) => {
this.#buttonContainer.appendChild(this.#createButton(tag))
})
}

#createButton(tag) {
const button = document.createElement("button")
button.dataset.heading = tag
button.classList.add("lexxy-editor__toolbar-button", "lexxy-heading-button")
button.name = tag
button.textContent = tag.toUpperCase()
button.type = "button"
return button
}

#handleToggle({ newState }) {
if (newState === "open") {
this.editor.getEditorState().read(() => {
this.#updateHeadingButtonStates($getSelection())
})
}
}

#handleHeadingClick(event) {
event.preventDefault()

const button = event.target.closest(".lexxy-heading-button")
if (!button) return

const tag = button.dataset.heading
this.editor.dispatchCommand("applyHeadingFormat", tag)
this.close()
}

#handleRemoveHeadingClick(event) {
event.preventDefault()

this.editor.dispatchCommand("applyHeadingFormat", null)
this.close()
}

#updateHeadingButtonStates(selection) {
if (!$isRangeSelection(selection)) return

const anchorNode = selection.anchor.getNode()
let currentTag = null

if (anchorNode.getParent() !== null) {
const topLevelElement = anchorNode.getTopLevelElementOrThrow()
if ($isHeadingNode(topLevelElement)) {
currentTag = topLevelElement.getTag()
}
}

this.#headingButtons.forEach(button => {
button.setAttribute("aria-pressed", button.dataset.heading === currentTag)
})

const removeButton = this.querySelector(".lexxy-heading-remove")
if (removeButton) {
removeButton.disabled = currentTag === null
}
}

get #configuredHeadings() {
const configured = this.editorElement.config.get("headings")
const headings = Array.isArray(configured) ? configured : [ "h2", "h3", "h4" ]
return headings.filter((heading) => /^h[1-6]$/.test(heading))
}

get #buttonContainer() {
return this.querySelector(".lexxy-heading-options")
}

get #headingButtons() {
return Array.from(this.querySelectorAll(".lexxy-heading-button"))
}
}

export default HeadingDropdown
2 changes: 2 additions & 0 deletions src/elements/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Toolbar from "./toolbar"

import Editor from "./editor"
import DropdownLink from "./dropdown/link"
import DropdownHeading from "./dropdown/heading"
import DropdownHighlight from "./dropdown/highlight"
import Prompt from "./prompt"
import CodeLanguagePicker from "./code_language_picker"
Expand All @@ -13,6 +14,7 @@ export function defineElements() {
"lexxy-toolbar": Toolbar,
"lexxy-editor": Editor,
"lexxy-link-dropdown": DropdownLink,
"lexxy-heading-dropdown": DropdownHeading,
"lexxy-highlight-dropdown": DropdownHighlight,
"lexxy-prompt": Prompt,
"lexxy-code-language-picker": CodeLanguagePicker,
Expand Down
30 changes: 26 additions & 4 deletions src/elements/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ export class LexicalToolbarElement extends HTMLElement {
this.#setButtonPressed("highlight", isHighlight)
this.#setButtonPressed("link", isInLink)
this.#setButtonPressed("quote", isInQuote)
this.#setButtonPressed("heading", isInHeading)
this.#updateHeadingLabel(headingTag)
this.#setButtonPressed("code", isInCode)

this.#setButtonPressed("table", isInTable)
Expand All @@ -250,6 +252,16 @@ export class LexicalToolbarElement extends HTMLElement {
}
}

#updateHeadingLabel(headingTag) {
const summary = this.querySelector("[name='heading']")
if (!summary) return

const label = summary.querySelector(".lexxy-heading-label")
if (label) {
label.textContent = headingTag ? headingTag.toUpperCase() : ""
}
}

#toolbarIsOverflowing() {
// Safari can report inconsistent clientWidth values on more than 100% window zoom level,
// that was affecting the toolbar overflow calculation. We're adding +1 to get around this issue.
Expand Down Expand Up @@ -306,10 +318,10 @@ export class LexicalToolbarElement extends HTMLElement {
}

#closeDropdowns() {
this.#dropdowns.forEach((details) => {
details.open = false
})
}
this.#dropdowns.forEach((details) => {
details.open = false
})
}

get #dropdowns() {
return this.querySelectorAll("details")
Expand Down Expand Up @@ -384,6 +396,16 @@ export class LexicalToolbarElement extends HTMLElement {
</div>
</details>

<details class="lexxy-editor__toolbar-dropdown" name="lexxy-dropdown">
<summary class="lexxy-editor__toolbar-button" name="heading" title="Heading">
${ToolbarIcons.heading}<span class="lexxy-heading-label"></span>
</summary>
<lexxy-heading-dropdown class="lexxy-editor__toolbar-dropdown-content">
<div class="lexxy-heading-options"></div>
<button type="button" class="lexxy-editor__toolbar-button lexxy-editor__toolbar-dropdown-reset lexxy-heading-remove">Remove heading</button>
</lexxy-heading-dropdown>
</details>

<details class="lexxy-editor__toolbar-dropdown lexxy-editor__toolbar-dropdown--chevron" name="lexxy-dropdown">
<summary class="lexxy-editor__toolbar-button" name="highlight" title="Color highlight">
${ToolbarIcons.highlight}
Expand Down
54 changes: 54 additions & 0 deletions test/javascript/unit/editor/headings_configuration.test.js
Original file line number Diff line number Diff line change
@@ -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("<lexxy-editor></lexxy-editor>")
const config = new EditorConfiguration(element)
expect(config.get("headings")).toEqual(["h2", "h3", "h4"])
})

test("overrides headings with attribute", () => {
const element = createElement(
'<lexxy-editor headings=\'["h1", "h2", "h3", "h4", "h5", "h6"]\'></lexxy-editor>'
)
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(
'<lexxy-editor headings=\'["h1", "h2", "h5"]\'></lexxy-editor>'
)
const config = new EditorConfiguration(element)
expect(config.get("headings")).toEqual(["h1", "h2", "h5"])
})

test("restricts headings to a subset", () => {
const element = createElement(
"<lexxy-editor preset='minimal'></lexxy-editor>"
)
const config = new EditorConfiguration(element)
expect(config.get("headings")).toEqual(["h2"])
})

test("handles empty headings array", () => {
const element = createElement(
"<lexxy-editor preset='noHeadings'></lexxy-editor>"
)
const config = new EditorConfiguration(element)
expect(config.get("headings")).toEqual([])
})