From 939019c8ddffc4c3c6507444f85ca009d11a8fba Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Tue, 23 Jun 2026 06:46:08 +0200 Subject: [PATCH 1/2] Keep toolbar dropdowns open during non-selection editor updates The toolbar registered an editor update listener that closed every open dropdown on each update. While an upload is in progress the editor fires updates for progress and placeholder mutations, which closed any open toolbar dropdown unexpectedly. Close dropdowns only when the selection actually changed between the previous and current editor state, preserving the legitimate close on selection change while leaving dropdowns open for content-only updates. Gating the close on selection changes meant a dropdown no longer closed after the user picked one of its menuitems when the applied format left the selection unchanged. Close the dropdowns when a command is activated from a button inside a dropdown, so choosing a format item still closes the menu regardless of whether the selection moved. --- src/elements/toolbar.js | 19 +++++++-- test/browser/tests/formatting/toolbar.test.js | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 366e3c360..09e851517 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -134,13 +134,15 @@ export class LexicalToolbarElement extends HTMLElement { } } - #dispatchButtonCommand(event, { dataset: { command, payload } }) { + #dispatchButtonCommand(event, button) { + const { command, payload } = button.dataset const isKeyboard = event instanceof PointerEvent && event.pointerId === -1 this.editor.update(() => { this.editor.dispatchCommand(command, payload) }, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined }) + if (button.closest(".lexxy-editor__toolbar-dropdown")) this.closeDropdowns() if (!isKeyboard) this.editor.focus() } @@ -200,14 +202,25 @@ export class LexicalToolbarElement extends HTMLElement { } #monitorSelectionChanges() { - this.#listeners.track(this.editor.registerUpdateListener(({ editorState }) => { + this.#listeners.track(this.editor.registerUpdateListener(({ editorState, prevEditorState }) => { editorState.read(() => { this.#updateButtonStates() - this.closeDropdowns() + if (this.#selectionChanged(editorState, prevEditorState)) this.closeDropdowns() }) })) } + #selectionChanged(editorState, prevEditorState) { + const selection = editorState._selection + const previousSelection = prevEditorState._selection + + if (selection === null || previousSelection === null) { + return selection !== previousSelection + } else { + return !selection.is(previousSelection) + } + } + #monitorHistoryChanges() { this.#listeners.track( this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#setButtonDisabled("undo", !enabled) }, COMMAND_PRIORITY_LOW), diff --git a/test/browser/tests/formatting/toolbar.test.js b/test/browser/tests/formatting/toolbar.test.js index d0577f44d..ea124db84 100644 --- a/test/browser/tests/formatting/toolbar.test.js +++ b/test/browser/tests/formatting/toolbar.test.js @@ -223,6 +223,45 @@ test.describe("Toolbar", () => { expect(accept).toBeNull() }) + test("dropdown stays open during editor updates that do not change the selection", async ({ page, editor }) => { + await editor.setValue(HELLO_EVERYONE) + + const formatPanel = page.locator("lexxy-toolbar-dropdown.lexxy-editor__toolbar-dropdown [data-dropdown-panel]").first() + + await page.locator("lexxy-toolbar button[name='format']").click() + await expect(formatPanel).toBeVisible() + + // Simulate an upload-driven update: content mutates but the selection is untouched, + // the same way upload progress / placeholder mutations fire while a dropdown is open. + await page.evaluate(async () => { + const element = document.querySelector("lexxy-editor") + await new Promise((resolve) => { + element.editor.update(() => { + const editorState = element.editor._pendingEditorState + const root = editorState._nodeMap.get("root") + const paragraph = editorState._nodeMap.get(root.__first) + const textNode = editorState._nodeMap.get(paragraph.__first) + textNode.setTextContent("Hello everyone uploading…") + }, { onUpdate: resolve }) + }) + }) + + await expect(formatPanel).toBeVisible() + }) + + test("dropdown closes when the selection changes", async ({ page, editor }) => { + await editor.setValue(HELLO_EVERYONE) + + const formatPanel = page.locator("lexxy-toolbar-dropdown.lexxy-editor__toolbar-dropdown [data-dropdown-panel]").first() + + await page.locator("lexxy-toolbar button[name='format']").click() + await expect(formatPanel).toBeVisible() + + await editor.select("everyone") + + await expect(formatPanel).toBeHidden() + }) + test("external toolbar", async ({ page }) => { await page.goto("/toolbar-external.html") await expect( From f1c25c5804f6e1f4257f4247bac7fe0f84c7dcac Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 1 Jul 2026 22:33:36 +0200 Subject: [PATCH 2/2] Compare selections through the public EditorState.read API --- src/elements/toolbar.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index 09e851517..215f83aa2 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -203,16 +203,14 @@ export class LexicalToolbarElement extends HTMLElement { #monitorSelectionChanges() { this.#listeners.track(this.editor.registerUpdateListener(({ editorState, prevEditorState }) => { - editorState.read(() => { - this.#updateButtonStates() - if (this.#selectionChanged(editorState, prevEditorState)) this.closeDropdowns() - }) + editorState.read(() => this.#updateButtonStates()) + if (this.#selectionChanged(editorState, prevEditorState)) this.closeDropdowns() })) } #selectionChanged(editorState, prevEditorState) { - const selection = editorState._selection - const previousSelection = prevEditorState._selection + const selection = editorState.read($getSelection) + const previousSelection = prevEditorState.read($getSelection) if (selection === null || previousSelection === null) { return selection !== previousSelection