diff --git a/docs/configuration.md b/docs/configuration.md index b6d510c78..6c50d41f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/src/editor/command_dispatcher.js b/src/editor/command_dispatcher.js index 11d9ef9ff..7e2720675 100644 --- a/src/editor/command_dispatcher.js +++ b/src/editor/command_dispatcher.js @@ -53,9 +53,10 @@ const COMMANDS = [ export class CommandDispatcher { #selectionBeforeDrag = null + #unregister = [] static configureFor(editorElement) { - new CommandDispatcher(editorElement) + return new CommandDispatcher(editorElement) } constructor(editorElement) { @@ -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)}` @@ -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) { diff --git a/src/editor/contents.js b/src/editor/contents.js index 80e84419a..85fcdf10d 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -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 } = {}) { diff --git a/src/editor/selection.js b/src/editor/selection.js index 416cae7a8..7d9e11884 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -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" @@ -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 @@ -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. @@ -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 @@ -316,7 +331,7 @@ export default class Selection { }) }, 0) } - }) + })) } get #currentlySelectedKeys() { @@ -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() { diff --git a/src/elements/code_language_picker.js b/src/elements/code_language_picker.js index de9d3a977..a28ecec59 100644 --- a/src/elements/code_language_picker.js +++ b/src/elements/code_language_picker.js @@ -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() { @@ -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 } diff --git a/src/elements/dropdown/highlight.js b/src/elements/dropdown/highlight.js index 595044a1b..0d688f5a9 100644 --- a/src/elements/dropdown/highlight.js +++ b/src/elements/dropdown/highlight.js @@ -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() } #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) @@ -56,7 +65,7 @@ export class HighlightDropdown extends ToolbarDropdown { return button } - #handleToggle({ newState }) { + #handleToggle = ({ newState }) => { if (newState === "open") { this.editor.getEditorState().read(() => { this.#updateColorButtonStates($getSelection()) @@ -64,7 +73,7 @@ export class HighlightDropdown extends ToolbarDropdown { } } - #handleColorButtonClick(event) { + #handleColorButtonClick = (event) => { event.preventDefault() const button = event.target.closest(APPLY_HIGHLIGHT_SELECTOR) @@ -77,7 +86,7 @@ export class HighlightDropdown extends ToolbarDropdown { this.close() } - #handleRemoveHighlightClick(event) { + #handleRemoveHighlightClick = (event) => { event.preventDefault() this.editor.dispatchCommand("removeHighlight") diff --git a/src/elements/dropdown/link.js b/src/elements/dropdown/link.js index d881b6096..4b701bf54 100644 --- a/src/elements/dropdown/link.js +++ b/src/elements/dropdown/link.js @@ -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() } diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..bb23dbe95 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,4 +1,4 @@ -import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" +import { $addUpdateTag, $createParagraphNode, $getRoot, $isElementNode, $isLineBreakNode, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode, mergeRegister } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" @@ -42,6 +42,7 @@ export class LexicalEditorElement extends HTMLElement { #initialValue = "" #validationTextArea = document.createElement("textarea") + #disposables = [] constructor() { super() @@ -55,12 +56,19 @@ export class LexicalEditorElement extends HTMLElement { this.extensions = new Extensions(this) this.editor = this.#createEditor() + this.#disposables.push(this.editor) this.contents = new Contents(this) + this.#disposables.push(this.contents) + this.selection = new Selection(this) + this.#disposables.push(this.selection) + this.clipboard = new Clipboard(this) - CommandDispatcher.configureFor(this) + const commandDispatcher = CommandDispatcher.configureFor(this) + this.#disposables.push(commandDispatcher) + this.#initialize() requestAnimationFrame(() => dispatch(this, "lexxy:initialize")) @@ -113,7 +121,7 @@ export class LexicalEditorElement extends HTMLElement { get toolbarElement() { if (!this.#hasToolbar) return null - this.toolbar = this.toolbar || this.#findOrCreateDefaultToolbar() + this.toolbar ??= this.#findOrCreateDefaultToolbar() return this.toolbar } @@ -249,6 +257,7 @@ export class LexicalEditorElement extends HTMLElement { #createEditor() { this.editorContentElement ||= this.#createEditorContentElement() + this.appendChild(this.editorContentElement) const editor = buildEditorFromExtensions({ name: "lexxy/core", @@ -298,7 +307,6 @@ export class LexicalEditorElement extends HTMLElement { }) editorContentElement.id = `${this.id}-content` this.#ariaAttributes.forEach(attribute => editorContentElement.setAttribute(attribute.name, attribute.value)) - this.appendChild(editorContentElement) if (this.getAttribute("tabindex")) { editorContentElement.setAttribute("tabindex", this.getAttribute("tabindex")) @@ -374,36 +382,48 @@ export class LexicalEditorElement extends HTMLElement { } #registerComponents() { + const registered = [] + if (this.supportsRichText) { - registerRichText(this.editor) - registerList(this.editor) + registered.push( + registerRichText(this.editor), + registerList(this.editor) + ) this.#registerTableComponents() this.#registerCodeHiglightingComponents() if (this.supportsMarkdown) { - registerMarkdownShortcuts(this.editor, TRANSFORMERS) - registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS) + registered.push( + registerMarkdownShortcuts(this.editor, TRANSFORMERS), + registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS) + ) } } else { - registerPlainText(this.editor) + registered.push(registerPlainText(this.editor)) } this.historyState = createEmptyHistoryState() - registerHistory(this.editor, this.historyState, 20) + registered.push(registerHistory(this.editor, this.historyState, 20)) + + this.#addUnregisterHandler(mergeRegister(...registered)) } #registerTableComponents() { - this.tableTools = createElement("lexxy-table-tools") - this.append(this.tableTools) + let tableTools = this.querySelector("lexxy-table-tools") + tableTools ??= createElement("lexxy-table-tools") + this.append(tableTools) + this.#disposables.push(tableTools) } #registerCodeHiglightingComponents() { registerCodeHighlighting(this.editor) - this.codeLanguagePicker = createElement("lexxy-code-language-picker") - this.append(this.codeLanguagePicker) + let codeLanguagePicker = this.querySelector("lexxy-code-language-picker") + codeLanguagePicker ??= createElement("lexxy-code-language-picker") + this.append(codeLanguagePicker) + this.#disposables.push(codeLanguagePicker) } #handleEnter() { // We can't prevent these externally using regular keydown because Lexical handles it first. - this.editor.registerCommand( + this.#addUnregisterHandler(this.editor.registerCommand( KEY_ENTER_COMMAND, (event) => { // Prevent CTRL+ENTER @@ -421,12 +441,17 @@ export class LexicalEditorElement extends HTMLElement { return false }, COMMAND_PRIORITY_NORMAL - ) + )) } #registerFocusEvents() { this.addEventListener("focusin", this.#handleFocusIn) this.addEventListener("focusout", this.#handleFocusOut) + + this.#addUnregisterHandler(() => { + this.removeEventListener("focusin", this.#handleFocusIn) + this.removeEventListener("focusout", this.#handleFocusOut) + }) } #handleFocusIn(event) { @@ -476,6 +501,10 @@ export class LexicalEditorElement extends HTMLElement { #attachToolbar() { if (this.#hasToolbar) { this.toolbarElement.setEditor(this) + if (typeof this.toolbarElement.dispose === "function") { + this.#disposables.push(this.toolbarElement) + } + this.extensions.initializeToolbars() } } @@ -485,7 +514,7 @@ export class LexicalEditorElement extends HTMLElement { if (typeof toolbarConfig === "string") { return document.getElementById(toolbarConfig) } else { - return this.#createDefaultToolbar() + return this.querySelector("lexxy-toolbar") ?? this.#createDefaultToolbar() } } @@ -515,34 +544,22 @@ export class LexicalEditorElement extends HTMLElement { } #reset() { - this.#unregisterHandlers() + this.#dispose() + this.editorContentElement?.remove() + this.editorContentElement = null - if (this.editorContentElement) { - this.editorContentElement.remove() - this.editorContentElement = null - } - - this.contents = null - this.editor = null - - if (this.toolbar) { - if (!this.getAttribute("toolbar")) { this.toolbar.remove() } - this.toolbar = null - } + // Prevents issues with turbo morphing receiving an empty which wipes + // out the DOM for the tools, and the old toolbar reference will cause issues + this.toolbar = null + } - if (this.codeLanguagePicker) { - this.codeLanguagePicker.remove() - this.codeLanguagePicker = null - } + #dispose() { + this.#unregisterHandlers() + document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache) - if (this.tableHandler) { - this.tableHandler.remove() - this.tableHandler = null + while (this.#disposables.length) { + this.#disposables.pop().dispose() } - - this.selection = null - - document.removeEventListener("turbo:before-cache", this.#handleTurboBeforeCache) } #reconnect() { diff --git a/src/elements/table/table_tools.js b/src/elements/table/table_tools.js index 245085b20..199fcb7ae 100644 --- a/src/elements/table/table_tools.js +++ b/src/elements/table/table_tools.js @@ -20,6 +20,10 @@ export class TableTools extends HTMLElement { } disconnectedCallback() { + this.dispose() + } + + dispose() { this.#unregisterKeyboardShortcuts() this.unregisterUpdateListener?.() @@ -44,6 +48,8 @@ export class TableTools extends HTMLElement { } #setUpButtons() { + this.innerHTML = "" + this.appendChild(this.#createRowButtonsContainer()) this.appendChild(this.#createColumnButtonsContainer()) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 0319776b1..815f24aad 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -25,9 +25,22 @@ export class LexicalToolbarElement extends HTMLElement { } disconnectedCallback() { + this.dispose() + } + + dispose() { this.#uninstallResizeObserver() + this.#unbindButtons() this.#unbindHotkeys() this.#unbindFocusListeners() + this.unregisterSelectionListener?.() + this.unregisterHistoryListener?.() + + this.editorElement = null + this.editor = null + this.selection = null + + this.#createEditorPromise() } attributeChangedCallback(name, oldValue, newValue) { @@ -71,10 +84,12 @@ export class LexicalToolbarElement extends HTMLElement { this.connectedCallback() } - #createEditorPromise() { + async #createEditorPromise() { this.editorPromise = new Promise((resolve) => { this.resolveEditorPromise = resolve }) + + this.editorElement = await this.editorPromise } #installResizeObserver() { @@ -90,10 +105,14 @@ export class LexicalToolbarElement extends HTMLElement { } #bindButtons() { - this.addEventListener("click", this.#handleButtonClicked.bind(this)) + this.addEventListener("click", this.#handleButtonClicked) + } + + #unbindButtons() { + this.removeEventListener("click", this.#handleButtonClicked) } - #handleButtonClicked(event) { + #handleButtonClicked = (event) => { this.#handleTargetClicked(event, "[data-command]", this.#dispatchButtonCommand.bind(this)) } @@ -153,8 +172,8 @@ export class LexicalToolbarElement extends HTMLElement { } #unbindFocusListeners() { - this.editorElement.removeEventListener("lexxy:focus", this.#handleEditorFocus) - this.editorElement.removeEventListener("lexxy:blur", this.#handleEditorBlur) + this.editorElement?.removeEventListener("lexxy:focus", this.#handleEditorFocus) + this.editorElement?.removeEventListener("lexxy:blur", this.#handleEditorBlur) this.removeEventListener("keydown", this.#handleKeydown) } @@ -178,7 +197,7 @@ export class LexicalToolbarElement extends HTMLElement { } #monitorSelectionChanges() { - this.editor.registerUpdateListener(() => { + this.unregisterSelectionListener = this.editor.registerUpdateListener(() => { this.editor.getEditorState().read(() => { this.#updateButtonStates() this.#closeDropdowns() @@ -187,7 +206,7 @@ export class LexicalToolbarElement extends HTMLElement { } #monitorHistoryChanges() { - this.editor.registerUpdateListener(() => { + this.unregisterHistoryListener = this.editor.registerUpdateListener(() => { this.#updateUndoRedoButtonStates() }) } diff --git a/src/elements/toolbar_dropdown.js b/src/elements/toolbar_dropdown.js index f91395b7a..c44b51c8a 100644 --- a/src/elements/toolbar_dropdown.js +++ b/src/elements/toolbar_dropdown.js @@ -4,14 +4,15 @@ export class ToolbarDropdown extends HTMLElement { connectedCallback() { this.container = this.closest("details") - this.container.addEventListener("toggle", this.#handleToggle.bind(this)) - this.container.addEventListener("keydown", this.#handleKeyDown.bind(this)) + this.container.addEventListener("toggle", this.#handleToggle) + this.container.addEventListener("keydown", this.#handleKeyDown) this.#onToolbarEditor(this.initialize.bind(this)) } disconnectedCallback() { - this.container.removeEventListener("keydown", this.#handleKeyDown.bind(this)) + this.container?.removeEventListener("toggle", this.#handleToggle) + this.container?.removeEventListener("keydown", this.#handleKeyDown) } get toolbar() { @@ -36,11 +37,11 @@ export class ToolbarDropdown extends HTMLElement { } async #onToolbarEditor(callback) { - await this.toolbar.editorConnected + await this.toolbar.editorElement callback() } - #handleToggle() { + #handleToggle = () => { if (this.container.open) { this.#handleOpen() } @@ -51,7 +52,7 @@ export class ToolbarDropdown extends HTMLElement { this.#resetTabIndexValues() } - #handleKeyDown(event) { + #handleKeyDown = (event) => { if (event.key === "Escape") { event.stopPropagation() this.close() diff --git a/src/extensions/tables_extension.js b/src/extensions/tables_extension.js index 7b656c871..04e84fe0b 100644 --- a/src/extensions/tables_extension.js +++ b/src/extensions/tables_extension.js @@ -38,11 +38,12 @@ export class TablesExtension extends LexxyExtension { TableRowNode ], register(editor) { + setScrollableTablesActive(editor, true) + return mergeRegister( // Register Lexical table plugins registerTablePlugin(editor), registerTableSelectionObserver(editor, true), - setScrollableTablesActive(editor, true), // Bug fix: Prevent hardcoded background color (Lexical #8089) editor.registerNodeTransform(TableCellNode, (node) => { diff --git a/test/browser/tests/editor/leak.test.js b/test/browser/tests/editor/leak.test.js new file mode 100644 index 000000000..cb0609947 --- /dev/null +++ b/test/browser/tests/editor/leak.test.js @@ -0,0 +1,74 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +const CYCLES = 10 +const CONTENT = "

Heading

Some rich text with a link.

End.

" + +test.describe("Leak test", () => { + test.skip(({ browserName }) => browserName !== "chromium", "CDP requires Chromium") + + test.beforeEach(async ({ page, editor }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + + await editor.focus() + await editor.setValue(CONTENT) + }) + + test(`no listener leaks across ${CYCLES} reconnect cycles`, async ({ page, editor }) => { + const cdp = await page.context().newCDPSession(page) + await cdp.send("Performance.enable") + + const getListenerCount = async () => { + await cdp.send("HeapProfiler.collectGarbage") + const { metrics } = await cdp.send("Performance.getMetrics") + return metrics.find((m) => m.name === "JSEventListeners")?.value ?? 0 + } + + // Run one warmup cycle so one-time lazy initialization is excluded + await reconnect(page, editor) + const baseline = await getListenerCount() + + for (let i = 0; i < CYCLES; i++) { + await reconnect(page, editor) + } + + const final = await getListenerCount() + expect(final - baseline).toBe(0) + + await cdp.detach() + }) + + test(`no node leaks across ${CYCLES} reconnect cycles`, async ({ page, editor }) => { + const cdp = await page.context().newCDPSession(page) + await cdp.send("Performance.enable") + + const getNodeCount = async () => { + await cdp.send("HeapProfiler.collectGarbage") + const { metrics } = await cdp.send("Performance.getMetrics") + return metrics.find((m) => m.name === "Nodes")?.value ?? 0 + } + + await reconnect(page, editor) + const baseline = await getNodeCount() + + for (let i = 0; i < CYCLES; i++) { + await reconnect(page, editor) + } + + const final = await getNodeCount() + expect(final - baseline).toBe(0) + + await cdp.detach() + }) +}) + +async function reconnect(page, editor) { + await page.evaluate(() => { + const el = document.querySelector("lexxy-editor") + const parent = el.parentElement + parent.removeChild(el) + parent.appendChild(el) + }) + await editor.waitForConnected() +} diff --git a/test/browser/tests/editor/reconnect.test.js b/test/browser/tests/editor/reconnect.test.js new file mode 100644 index 000000000..d68ee6fc7 --- /dev/null +++ b/test/browser/tests/editor/reconnect.test.js @@ -0,0 +1,46 @@ +import { test } from "../../test_helper.js" +import { expect } from "@playwright/test" + +test.describe("Reconnect", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/") + await page.waitForSelector("lexxy-editor[connected]") + }) + + test("editor disposes cleanly on disconnect", async ({ page, editor }) => { + const errors = [] + page.on("pageerror", (error) => errors.push(error.message)) + + await editor.focus() + await editor.send("Hello") + + await page.evaluate(() => { + const el = document.querySelector("lexxy-editor") + const parent = el.parentElement + parent.removeChild(el) + parent.appendChild(el) + }) + + await editor.waitForConnected() + expect(errors).toEqual([]) + }) + + test("reconnect does not duplicate child elements", async ({ page, editor }) => { + await editor.focus() + await editor.send("Hello") + + await page.evaluate(() => { + const el = document.querySelector("lexxy-editor") + const parent = el.parentElement + parent.removeChild(el) + parent.appendChild(el) + }) + + await editor.waitForConnected() + + const editorEl = page.locator("lexxy-editor") + await expect(editorEl.locator("lexxy-toolbar")).toHaveCount(1) + await expect(editorEl.locator("lexxy-table-tools")).toHaveCount(1) + await expect(editorEl.locator("lexxy-code-language-picker")).toHaveCount(1) + }) +})