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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Editors support the following options, configurable using presets and element at
- `multiLine`: Pass `false` to force single line editing.
- `richText`: Pass `false` to disable rich text editing.

The toolbar is considered part of the editor for `lexxy:focus` and `lexxy:blur` events.
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.

Lexxy also supports standard HTML attributes:
- `placeholder`: Text displayed when the editor is empty.
Expand Down
16 changes: 12 additions & 4 deletions src/editor/command_dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ const COMMANDS = [

export class CommandDispatcher {
#selectionBeforeDrag = null
#unregister = []

static configureFor(editorElement) {
new CommandDispatcher(editorElement)
return new CommandDispatcher(editorElement)
}

constructor(editorElement) {
Expand Down Expand Up @@ -267,6 +268,13 @@ export class CommandDispatcher {
this.editor.dispatchCommand(REDO_COMMAND, undefined)
}

dispose() {
while (this.#unregister.length) {
const unregister = this.#unregister.pop()
unregister()
}
}

#registerCommands() {
for (const command of COMMANDS) {
const methodName = `dispatch${capitalize(command)}`
Expand All @@ -277,12 +285,12 @@ export class CommandDispatcher {
}

#registerCommandHandler(command, priority, handler) {
this.editor.registerCommand(command, handler, priority)
this.#unregister.push(this.editor.registerCommand(command, handler, priority))
}

#registerKeyboardCommands() {
this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#handleArrowRightKey.bind(this), COMMAND_PRIORITY_NORMAL)
this.editor.registerCommand(KEY_TAB_COMMAND, this.#handleTabKey.bind(this), COMMAND_PRIORITY_NORMAL)
this.#registerCommandHandler(KEY_ARROW_RIGHT_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleArrowRightKey.bind(this))
this.#registerCommandHandler(KEY_TAB_COMMAND, COMMAND_PRIORITY_NORMAL, this.#handleTabKey.bind(this))
}

#handleArrowRightKey(event) {
Expand Down
4 changes: 4 additions & 0 deletions src/editor/contents.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export default class Contents {
constructor(editorElement) {
this.editorElement = editorElement
this.editor = editorElement.editor
}

dispose() {
this.editorElement = null
this.editor = null
}

insertHtml(html, { tag } = {}) {
Expand Down
50 changes: 34 additions & 16 deletions src/editor/selection.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
$createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isDecoratorNode, $isElementNode,
$isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DELETE_CHARACTER_COMMAND,
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode
KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode,
mergeRegister
} from "lexical"
import { $getNearestNodeOfType } from "@lexical/utils"
import { $getListDepth, ListItemNode, ListNode } from "@lexical/list"
Expand All @@ -16,6 +17,8 @@ import { $isHeadingNode, $isQuoteNode } from "@lexical/rich-text"
import { $isActionTextAttachmentNode } from "../nodes/action_text_attachment_node"

export default class Selection {
#unregister = []

constructor(editorElement) {
this.editorElement = editorElement
this.editorContentElement = editorElement.editorContentElement
Expand Down Expand Up @@ -272,6 +275,18 @@ export default class Selection {
return this.#findPreviousSiblingUp(anchorNode)
}

dispose() {
this.editorElement = null
this.editorContentElement = null
this.editor = null
this.previouslySelectedKeys = null

while (this.#unregister.length) {
const unregister = this.#unregister.pop()
unregister()
}
}

// When all inline code text is deleted, Lexical's selection retains the stale
// code format flag. Verify the flag is backed by actual code-formatted content:
// a code block ancestor or a text node that carries the code format.
Expand All @@ -287,7 +302,7 @@ export default class Selection {
// detects that stale state and clears it so newly typed text won't be
// code-formatted.
#clearStaleInlineCodeFormat() {
this.editor.registerUpdateListener(({ editorState, tags }) => {
this.#unregister.push(this.editor.registerUpdateListener(({ editorState, tags }) => {
if (tags.has("history-merge") || tags.has("skip-dom-selection")) return

let isStale = false
Expand Down Expand Up @@ -316,7 +331,7 @@ export default class Selection {
})
}, 0)
}
})
}))
}

get #currentlySelectedKeys() {
Expand All @@ -335,29 +350,32 @@ export default class Selection {
}

#processSelectionChangeCommands() {
this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW)
this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW)
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW)
this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW)
this.#unregister.push(mergeRegister(
this.editor.registerCommand(KEY_ARROW_LEFT_COMMAND, this.#selectPreviousNode.bind(this), COMMAND_PRIORITY_LOW),
this.editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, this.#selectNextNode.bind(this), COMMAND_PRIORITY_LOW),
this.editor.registerCommand(KEY_ARROW_UP_COMMAND, this.#selectPreviousTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),
this.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, this.#selectNextTopLevelNode.bind(this), COMMAND_PRIORITY_LOW),

this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW)
this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW),

this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
this.current = $getSelection()
}, COMMAND_PRIORITY_LOW)
this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
this.current = $getSelection()
}, COMMAND_PRIORITY_LOW)
))
}

#listenForNodeSelections() {
this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
this.#unregister.push(this.editor.registerCommand(CLICK_COMMAND, ({ target }) => {
if (!isDOMNode(target)) return false

const targetNode = $getNearestNodeFromDOMNode(target)
return $isDecoratorNode(targetNode) && this.#selectInLexical(targetNode)
}, COMMAND_PRIORITY_LOW)
}, COMMAND_PRIORITY_LOW))

this.editor.getRootElement().addEventListener("lexxy:internal:move-to-next-line", (event) => {
this.#selectOrAppendNextLine()
})
const moveNextLineHandler = () => this.#selectOrAppendNextLine()
const rootElement = this.editor.getRootElement()
rootElement.addEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler)
this.#unregister.push(() => rootElement.removeEventListener("lexxy:internal:move-to-next-line", moveNextLineHandler))
}

#containEditorFocus() {
Expand Down
22 changes: 15 additions & 7 deletions src/elements/code_language_picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@ export class CodeLanguagePicker extends HTMLElement {
}

disconnectedCallback() {
this.dispose()
}

dispose() {
this.unregisterUpdateListener?.()
this.unregisterUpdateListener = null
}

#attachLanguagePicker() {
this.languagePickerElement = this.#createLanguagePicker()

this.languagePickerElement.addEventListener("change", () => {
this.#updateCodeBlockLanguage(this.languagePickerElement.value)
})
this.languagePickerElement = this.#findLanguagePicker() ?? this.#createLanguagePicker()
this.append(this.languagePickerElement)
}

this.languagePickerElement.setAttribute("nonce", getNonce())
this.appendChild(this.languagePickerElement)
#findLanguagePicker() {
return this.querySelector("select")
}

#createLanguagePicker() {
Expand All @@ -40,6 +42,12 @@ export class CodeLanguagePicker extends HTMLElement {
selectElement.appendChild(option)
}

selectElement.addEventListener("change", () => {
this.#updateCodeBlockLanguage(this.languagePickerElement.value)
})

selectElement.setAttribute("nonce", getNonce())

return selectElement
}

Expand Down
33 changes: 21 additions & 12 deletions src/elements/dropdown/highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,35 @@ const REMOVE_HIGHLIGHT_SELECTOR = "[data-command='removeHighlight']"
const NO_STYLE = Symbol("no_style")

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

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

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

disconnectedCallback() {
this.container?.removeEventListener("toggle", this.#handleToggle)
this.#removeButtonHandlers()
super.disconnectedCallback()
}
Comment on lines +19 to 28
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

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

connectedCallback() adds a toggle listener using this.#handleToggle.bind(this), but there’s no corresponding removal on disconnect, and bind() creates a new function each time. On reconnects this will accumulate duplicate toggle handlers and keep old instances alive. Store the bound handler on the instance (or use an arrow field) and remove it in disconnectedCallback() (or register it once).

Copilot uses AI. Check for mistakes.

#registerButtonHandlers() {
this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick.bind(this)))
this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick.bind(this))
this.#colorButtons.forEach(button => button.addEventListener("click", this.#handleColorButtonClick))
this.querySelector(REMOVE_HIGHLIGHT_SELECTOR).addEventListener("click", this.#handleRemoveHighlightClick)
}

#removeButtonHandlers() {
this.#colorButtons.forEach(button => button.removeEventListener("click", this.#handleColorButtonClick))
this.querySelector(REMOVE_HIGHLIGHT_SELECTOR)?.removeEventListener("click", this.#handleRemoveHighlightClick)
}

#setUpButtons() {
this.#buttonContainer.innerHTML = ""

const colorGroups = this.editorElement.config.get("highlight.buttons")

this.#populateButtonGroup("color", colorGroups.color)
Expand All @@ -56,15 +65,15 @@ export class HighlightDropdown extends ToolbarDropdown {
return button
}

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

#handleColorButtonClick(event) {
#handleColorButtonClick = (event) => {
event.preventDefault()

const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR)
Expand All @@ -77,7 +86,7 @@ export class HighlightDropdown extends ToolbarDropdown {
this.close()
}

#handleRemoveHighlightClick(event) {
#handleRemoveHighlightClick = (event) => {
event.preventDefault()

this.editor.dispatchCommand("removeHighlight")
Expand Down
19 changes: 11 additions & 8 deletions src/elements/dropdown/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,30 @@ export class LinkDropdown extends ToolbarDropdown {
super.connectedCallback()
this.input = this.querySelector("input")

this.#registerHandlers()
this.container.addEventListener("toggle", this.#handleToggle)
this.addEventListener("submit", this.#handleSubmit)
this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink)
}

#registerHandlers() {
this.container.addEventListener("toggle", this.#handleToggle.bind(this))
this.addEventListener("submit", this.#handleSubmit.bind(this))
this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this))
disconnectedCallback() {
this.container?.removeEventListener("toggle", this.#handleToggle)
this.removeEventListener("submit", this.#handleSubmit)
this.querySelector("[value='unlink']")?.removeEventListener("click", this.#handleUnlink)
super.disconnectedCallback()
}

#handleToggle({ newState }) {
#handleToggle = ({ newState }) => {
this.input.value = this.#selectedLinkUrl
this.input.required = newState === "open"
}

#handleSubmit(event) {
#handleSubmit = (event) => {
const command = event.submitter?.value
this.editor.dispatchCommand(command, this.input.value)
this.close()
}

#handleUnlink() {
#handleUnlink = () => {
this.editor.dispatchCommand("unlink")
this.close()
}
Expand Down
Loading
Loading