From 341d93ecd985eb71b08a2aaa6841c302a28ccb7f Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 19:49:47 +0200 Subject: [PATCH 01/10] Image upload history merging --- .../action_text_attachment_upload_node.js | 52 +++++++++++++++---- .../tests/attachments/attachments.test.js | 23 ++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index a30a583f4..b5df4bbb6 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,4 +1,4 @@ -import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, SKIP_DOM_SELECTION_TAG } from "lexical" +import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" import Lexxy from "../config/lexxy" import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { ActionTextAttachmentNode } from "./action_text_attachment_node" @@ -8,6 +8,8 @@ import { loadFileIntoImage } from "../helpers/upload_helper" import { bytesToHumanSize } from "../helpers/storage_helper" export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { + static #activeUploads = new WeakSet() + static getType() { return "action_text_attachment_upload" } @@ -135,7 +137,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { const writable = this.getWritable() writable.width = width writable.height = height - }, { tag: this.#backgroundUpdateTags }) + }, { tag: this.#transientUpdateTags }) } get #hasDimensions() { @@ -145,7 +147,10 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { async #startUploadIfNeeded() { if (this.#uploadStarted) return if (!this.uploadUrl) return // Bridge-managed upload — skip DirectUpload + if (ActionTextAttachmentUploadNode.#activeUploads.has(this.file)) return + ActionTextAttachmentUploadNode.#activeUploads.add(this.file) + const undoStackSnapshot = this.#historyState?.undoStack.length ?? 0 this.#setUploadStarted() const { DirectUpload } = await import("@rails/activestorage") @@ -163,11 +168,30 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null }) this.editor.update(() => { this.showUploadedAttachment(blob) - }, { tag: this.#backgroundUpdateTags }) + }, { + tag: this.#backgroundUpdateTags, + onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(undoStackSnapshot)) + }) } }) } + // The upload lifecycle creates intermediate history entries (from Lexical's + // internal transforms, selection changes, etc.) that contain transient upload + // node states. Trim those intermediate entries so undo skips straight from the + // completed attachment to the state before the upload began. The entry at + // undoStackSnapshot is the pre-upload state (pushed by the insertion); keep it. + #collapseUploadHistory(undoStackSnapshot) { + const historyState = this.#historyState + if (!historyState) return + + historyState.undoStack.length = undoStackSnapshot + 1 + } + + get #historyState() { + return this.editor.getRootElement()?.closest("lexxy-editor")?.historyState + } + #createUploadDelegate() { const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads") @@ -197,14 +221,14 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { #setProgress(progress) { this.editor.update(() => { this.getWritable().progress = progress - }, { tag: this.#backgroundUpdateTags }) + }, { tag: this.#transientUpdateTags }) } #handleUploadError(error) { console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`) this.editor.update(() => { this.getWritable().uploadError = true - }, { tag: this.#backgroundUpdateTags }) + }, { tag: this.#transientUpdateTags }) } showUploadedAttachment(blob) { @@ -221,10 +245,20 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { return replacementNode.getKey() } - // Upload lifecycle methods (progress, completion, errors) run asynchronously and may - // fire while the user is focused on another element (e.g., a title field). Without - // SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into - // the editor, stealing focus from wherever the user is currently typing. + // Transient updates (progress, dimensions, errors) are completely invisible to + // the undo history via HISTORIC_TAG. Only the final node replacement uses + // HISTORY_MERGE_TAG (via #backgroundUpdateTags) so the entire upload is one undo step. + get #transientUpdateTags() { + if (this.#editorHasFocus) { + return [ HISTORIC_TAG ] + } else { + return [ HISTORIC_TAG, SKIP_DOM_SELECTION_TAG ] + } + } + + // Used for the final node replacement (upload complete) to merge with the + // original insertion as a single undo step. Without SKIP_DOM_SELECTION_TAG, + // Lexical's reconciler would steal focus from wherever the user is typing. get #backgroundUpdateTags() { if (this.#editorHasFocus) { return SILENT_UPDATE_TAGS diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 63dd226a2..c26d0af04 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -251,6 +251,29 @@ test.describe("Attachments", () => { await expect.poll(() => editor.plainTextValue()).toContain("hello world") }) + test("undo after uploading into empty editor restores empty state", async ({ page, editor }) => { + await mockActiveStorageUploads(page) + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment") + await expect(figure).toBeVisible({ timeout: 10_000 }) + await editor.flush() + + // Wait for the history collapse to complete (runs in requestAnimationFrame) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) + + // Undo until the undo button is disabled + const undoButton = page.getByRole("button", { name: "Undo" }) + while (await undoButton.evaluate((el) => !el.disabled)) { + await undoButton.click() + await editor.flush() + } + + // No upload node figures should remain — only the clean empty state + await expect(figure).toHaveCount(0) + await expect(editor.content.locator("progress")).toHaveCount(0) + }) + test("Ctrl+C in caption copies text without losing focus", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") From cbbcfe5d933c9e218619652c464b2fbb6fe1599d Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 20:39:01 +0200 Subject: [PATCH 02/10] Node selection doesn't update history --- src/editor/selection.js | 3 ++- test/browser/tests/attachments/attachments.test.js | 12 ++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index 1f5c1e887..edbf00f62 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -1,6 +1,7 @@ import { $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DELETE_CHARACTER_COMMAND, + HISTORY_MERGE_TAG, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode } from "lexical" import { $getNearestNodeOfType } from "@lexical/utils" @@ -34,7 +35,7 @@ export default class Selection { set current(selection) { this.editor.update(() => { this.#syncSelectedClasses() - }) + }, { tag: HISTORY_MERGE_TAG }) } get hasNodeSelection() { diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index c26d0af04..937e5a62f 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -262,14 +262,10 @@ test.describe("Attachments", () => { // Wait for the history collapse to complete (runs in requestAnimationFrame) await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) - // Undo until the undo button is disabled - const undoButton = page.getByRole("button", { name: "Undo" }) - while (await undoButton.evaluate((el) => !el.disabled)) { - await undoButton.click() - await editor.flush() - } - - // No upload node figures should remain — only the clean empty state + // A single undo should restore the empty editor — no stale upload node figures + await page.getByRole("button", { name: "Undo" }).click() + await editor.flush() + await expect(figure).toHaveCount(0) await expect(editor.content.locator("progress")).toHaveCount(0) }) From cbc9125c3773b9faaaf1546e129aee36c19f6308 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 09:40:49 +0200 Subject: [PATCH 03/10] PR feedback --- src/editor/selection.js | 5 ++- .../action_text_attachment_upload_node.js | 24 +++++++++----- .../tests/attachments/attachments.test.js | 32 +++++++++++++++++-- 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index edbf00f62..4c02e7d38 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -1,7 +1,6 @@ import { $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, DELETE_CHARACTER_COMMAND, - HISTORY_MERGE_TAG, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode } from "lexical" import { $getNearestNodeOfType } from "@lexical/utils" @@ -33,9 +32,9 @@ export default class Selection { } set current(selection) { - this.editor.update(() => { + this.editor.getEditorState().read(() => { this.#syncSelectedClasses() - }, { tag: HISTORY_MERGE_TAG }) + }) } get hasNodeSelection() { diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index b5df4bbb6..586131116 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,4 +1,4 @@ -import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" +import { $getNodeByKey, $getSelection, $isRangeSelection, $isRootOrShadowRoot, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" import Lexxy from "../config/lexxy" import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { ActionTextAttachmentNode } from "./action_text_attachment_node" @@ -150,7 +150,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { if (ActionTextAttachmentUploadNode.#activeUploads.has(this.file)) return ActionTextAttachmentUploadNode.#activeUploads.add(this.file) - const undoStackSnapshot = this.#historyState?.undoStack.length ?? 0 + const uploadNodeKey = this.getKey() this.#setUploadStarted() const { DirectUpload } = await import("@rails/activestorage") @@ -170,7 +170,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { this.showUploadedAttachment(blob) }, { tag: this.#backgroundUpdateTags, - onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(undoStackSnapshot)) + onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey)) }) } }) @@ -178,14 +178,22 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { // The upload lifecycle creates intermediate history entries (from Lexical's // internal transforms, selection changes, etc.) that contain transient upload - // node states. Trim those intermediate entries so undo skips straight from the - // completed attachment to the state before the upload began. The entry at - // undoStackSnapshot is the pre-upload state (pushed by the insertion); keep it. - #collapseUploadHistory(undoStackSnapshot) { + // node states. Remove only those entries — identified by the presence of the + // upload node key — so user edits made during the upload are preserved. + #collapseUploadHistory(uploadNodeKey) { const historyState = this.#historyState if (!historyState) return - historyState.undoStack.length = undoStackSnapshot + 1 + historyState.undoStack = historyState.undoStack.filter(entry => + !this.#entryContainsUploadNode(entry, uploadNodeKey) + ) + } + + #entryContainsUploadNode(entry, uploadNodeKey) { + return entry.editorState.read(() => { + const node = $getNodeByKey(uploadNodeKey) + return node instanceof ActionTextAttachmentUploadNode + }) } get #historyState() { diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 937e5a62f..8b8803ace 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -262,14 +262,40 @@ test.describe("Attachments", () => { // Wait for the history collapse to complete (runs in requestAnimationFrame) await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) - // A single undo should restore the empty editor — no stale upload node figures - await page.getByRole("button", { name: "Undo" }).click() - await editor.flush() + // Undo until the undo button is disabled — no stale upload node should remain + const undoButton = page.getByRole("button", { name: "Undo" }) + while (await undoButton.evaluate((el) => !el.disabled)) { + await undoButton.click() + await editor.flush() + } await expect(figure).toHaveCount(0) await expect(editor.content.locator("progress")).toHaveCount(0) }) + test("undo preserves edits made during upload", async ({ page, editor }) => { + await mockActiveStorageUploads(page) + + // Type text first, then upload an image + await editor.send("hello world") + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment") + await expect(figure).toBeVisible({ timeout: 10_000 }) + await editor.flush() + + // Wait for the history collapse to complete + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) + + // Undo should remove the attachment but preserve the typed text + const undoButton = page.getByRole("button", { name: "Undo" }) + await undoButton.click() + await editor.flush() + + await expect(figure).toHaveCount(0) + await expect(editor.content).toContainText("hello world") + }) + test("Ctrl+C in caption copies text without losing focus", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") From fdf4f12cfd832d5dda0cdcf150fa1a4653d0f294 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:38:39 +0200 Subject: [PATCH 04/10] Test updates --- test/browser/helpers/active_storage_mock.js | 6 ++- .../tests/attachments/attachments.test.js | 39 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/test/browser/helpers/active_storage_mock.js b/test/browser/helpers/active_storage_mock.js index 90683ea7c..8d3b504c7 100644 --- a/test/browser/helpers/active_storage_mock.js +++ b/test/browser/helpers/active_storage_mock.js @@ -7,7 +7,7 @@ const TRANSPARENT_PNG = Buffer.from( "base64" ) -export async function mockActiveStorageUploads(page, { delayBlobResponses = false, delayDirectUploadResponse = false } = {}) { +export async function mockActiveStorageUploads(page, { delayBlobResponses = false, delayDirectUploadResponse = false, uploadDelayMs = 0 } = {}) { let blobCounter = 0 const calls = { blobCreations: [], fileUploads: [] } const pendingBlobRoutes = [] @@ -115,6 +115,10 @@ export async function mockActiveStorageUploads(page, { delayBlobResponses = fals contentType: request.headers()["content-type"], }) + if (uploadDelayMs > 0) { + await page.waitForTimeout(uploadDelayMs) + } + await route.fulfill({ status: 204 }) }) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 8b8803ace..53823637c 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -274,14 +274,23 @@ test.describe("Attachments", () => { }) test("undo preserves edits made during upload", async ({ page, editor }) => { - await mockActiveStorageUploads(page) + await mockActiveStorageUploads(page, { uploadDelayMs: 1_000 }) - // Type text first, then upload an image - await editor.send("hello world") await editor.uploadFile("test/fixtures/files/example.png") + const uploadProgress = page.locator("figure.attachment progress") + await expect(uploadProgress).toBeVisible({ timeout: 10_000 }) + + // Type while the upload node is still in-flight. + await editor.send("hello world") + await expect(uploadProgress).toBeVisible() + const figure = page.locator("figure.attachment") - await expect(figure).toBeVisible({ timeout: 10_000 }) + await expect(figure.locator("img")).toHaveAttribute( + "src", + /\/rails\/active_storage\/blobs\/mock-signed-id-\d+\/example\.png/, + { timeout: 10_000 }, + ) await editor.flush() // Wait for the history collapse to complete @@ -293,9 +302,31 @@ test.describe("Attachments", () => { await editor.flush() await expect(figure).toHaveCount(0) + await expect(editor.content.locator("progress")).toHaveCount(0) await expect(editor.content).toContainText("hello world") }) + test("node selection does not create an extra undo step", async ({ page, editor }) => { + await mockActiveStorageUploads(page) + await editor.send("hello") + await editor.uploadFile("test/fixtures/files/example.png") + + const figure = page.locator("figure.attachment[data-content-type='image/png']") + await expect(figure).toBeVisible({ timeout: 10_000 }) + await editor.flush() + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) + + // Click to create a node selection, then undo should still remove the attachment. + await figure.locator("img").click() + await editor.flush() + + await page.getByRole("button", { name: "Undo" }).click() + await editor.flush() + + await expect(figure).toHaveCount(0) + await expect(editor.content).toContainText("hello") + }) + test("Ctrl+C in caption copies text without losing focus", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") From 879e92aab055927697fd7f219e3aa319fbdf9570 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:39:45 +0200 Subject: [PATCH 05/10] No history tracking for ProvisionalParagraph --- src/extensions/provisional_paragraph_extension.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/extensions/provisional_paragraph_extension.js b/src/extensions/provisional_paragraph_extension.js index f9110330c..dc0927a1d 100644 --- a/src/extensions/provisional_paragraph_extension.js +++ b/src/extensions/provisional_paragraph_extension.js @@ -1,4 +1,4 @@ -import { $getRoot, COMMAND_PRIORITY_HIGH, RootNode, SELECTION_CHANGE_COMMAND, defineExtension } from "lexical" +import { $addUpdateTag, $getRoot, COMMAND_PRIORITY_HIGH, HISTORY_MERGE_TAG, RootNode, SELECTION_CHANGE_COMMAND, defineExtension } from "lexical" import { $descendantsMatching, $firstToLastIterator, $insertFirst, mergeRegister } from "@lexical/utils" import { $isProvisionalParagraphNode, ProvisionalParagraphNode } from "../nodes/provisional_paragraph_node" import LexxyExtension from "./lexxy_extension" @@ -45,6 +45,9 @@ function $removeUnneededProvisionalParagraphs(rootNode) { } function $markAllProvisionalParagraphsDirty() { + // Selection-driven visibility updates must not become standalone undo steps. + $addUpdateTag(HISTORY_MERGE_TAG) + for (const provisionalParagraph of $getAllProvisionalParagraphs()) { provisionalParagraph.markDirty() } From d3f637550336981db7abba3a6df4cb46f5ca6387 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:42:30 +0200 Subject: [PATCH 06/10] Update action_text_attachment_upload_node.js --- .../action_text_attachment_upload_node.js | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 586131116..f7cc39ae9 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,4 +1,4 @@ -import { $getNodeByKey, $getSelection, $isRangeSelection, $isRootOrShadowRoot, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" +import { $createParagraphNode, $getNodeByKey, $getRoot, $getSelection, $isRangeSelection, $isRootOrShadowRoot, $nodesOfType, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" import Lexxy from "../config/lexxy" import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { ActionTextAttachmentNode } from "./action_text_attachment_node" @@ -170,23 +170,42 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { this.showUploadedAttachment(blob) }, { tag: this.#backgroundUpdateTags, - onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey)) + onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey, blob.attachable_sgid)) }) } }) } - // The upload lifecycle creates intermediate history entries (from Lexical's - // internal transforms, selection changes, etc.) that contain transient upload - // node states. Remove only those entries — identified by the presence of the - // upload node key — so user edits made during the upload are preserved. - #collapseUploadHistory(uploadNodeKey) { + // Collapse upload-only entries and keep a deterministic undo target that + // preserves in-flight typing while removing the uploaded attachment. + #collapseUploadHistory(uploadNodeKey, uploadedSgid) { const historyState = this.#historyState if (!historyState) return - historyState.undoStack = historyState.undoStack.filter(entry => + const currentSignature = historyState.current && this.#contentSignatureFor(historyState.current.editorState) + + const collapsedUndoStack = historyState.undoStack.filter(entry => !this.#entryContainsUploadNode(entry, uploadNodeKey) ) + + const undoStateWithoutAttachment = this.#stateWithoutUploadedAttachment(uploadedSgid) + if (undoStateWithoutAttachment) { + const undoSignature = this.#contentSignatureFor(undoStateWithoutAttachment) + const lastUndoEntry = collapsedUndoStack.at(-1) + const lastUndoSignature = lastUndoEntry && this.#contentSignatureFor(lastUndoEntry.editorState) + + if (undoSignature !== currentSignature && undoSignature !== lastUndoSignature) { + collapsedUndoStack.push({ + editor: this.editor, + editorState: undoStateWithoutAttachment + }) + } + } + + historyState.undoStack = collapsedUndoStack + + // Force listeners (toolbar undo/redo button states) to observe the stack rewrite. + this.editor.update(() => {}, { tag: HISTORIC_TAG }) } #entryContainsUploadNode(entry, uploadNodeKey) { @@ -196,6 +215,30 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { }) } + #stateWithoutUploadedAttachment(uploadedSgid) { + if (!uploadedSgid) return null + const currentEntry = this.#historyState?.current + if (!currentEntry) return null + + const serializedEditorState = currentEntry.editorState.toJSON() + return this.editor.parseEditorState(JSON.stringify(serializedEditorState), () => { + const uploadedAttachmentNode = $nodesOfType(ActionTextAttachmentNode).find(node => node.sgid === uploadedSgid) + if (!uploadedAttachmentNode) return + + uploadedAttachmentNode.remove() + + const root = $getRoot() + if (root.getChildrenSize() === 0) { + root.append($createParagraphNode()) + } + root.selectEnd() + }) + } + + #contentSignatureFor(editorState) { + return JSON.stringify(editorState.toJSON().root) + } + get #historyState() { return this.editor.getRootElement()?.closest("lexxy-editor")?.historyState } From 1b3e2a8ce7b71ffc153af0f37e631e8e60590499 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:57:19 +0200 Subject: [PATCH 07/10] Redo stack clearing --- .../action_text_attachment_upload_node.js | 6 ++++- .../tests/attachments/attachments.test.js | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index f7cc39ae9..3e3235b04 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -167,7 +167,10 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } else { this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null }) this.editor.update(() => { - this.showUploadedAttachment(blob) + const uploadNode = $getNodeByKey(uploadNodeKey) + if (!(uploadNode instanceof ActionTextAttachmentUploadNode)) return + + uploadNode.showUploadedAttachment(blob) }, { tag: this.#backgroundUpdateTags, onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey, blob.attachable_sgid)) @@ -203,6 +206,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } historyState.undoStack = collapsedUndoStack + historyState.redoStack = [] // Force listeners (toolbar undo/redo button states) to observe the stack rewrite. this.editor.update(() => {}, { tag: HISTORIC_TAG }) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 53823637c..0776c33da 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -327,6 +327,32 @@ test.describe("Attachments", () => { await expect(editor.content).toContainText("hello") }) + test("upload completion clears redo after undo during upload", async ({ page, editor }) => { + await mockActiveStorageUploads(page, { uploadDelayMs: 500 }) + await editor.send("hello") + await editor.uploadFile("test/fixtures/files/example.png") + + const undoButton = page.getByRole("button", { name: "Undo" }) + const redoButton = page.getByRole("button", { name: "Redo" }) + await expect(page.locator("figure.attachment progress")).toBeVisible({ timeout: 10_000 }) + + await undoButton.click() + await editor.flush() + await expect(redoButton).toBeEnabled() + await expect.poll(() => page.evaluate(() => { + return document.querySelector("lexxy-editor").historyState.redoStack.length + })).toBeGreaterThan(0) + + await expect.poll(() => page.evaluate(() => { + return document.querySelector("lexxy-editor").historyState.redoStack.length + }), { timeout: 5_000 }).toBe(0) + + // Redo should be a no-op once completion collapses and clears redo history. + await redoButton.click() + await expect(page.locator("figure.attachment")).toHaveCount(0) + await expect(editor.content).toContainText("hello") + }) + test("Ctrl+C in caption copies text without losing focus", async ({ page, editor }) => { await mockActiveStorageUploads(page) await editor.uploadFile("test/fixtures/files/example.png") From 230cb301cfe58b7effe573eb54290f22df53c221 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 13:02:35 +0200 Subject: [PATCH 08/10] Fix flaky gallery test --- .../browser/tests/attachments/attachment_drag_and_drop.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/browser/tests/attachments/attachment_drag_and_drop.test.js b/test/browser/tests/attachments/attachment_drag_and_drop.test.js index 6a1dd76e2..111f03f26 100644 --- a/test/browser/tests/attachments/attachment_drag_and_drop.test.js +++ b/test/browser/tests/attachments/attachment_drag_and_drop.test.js @@ -462,6 +462,9 @@ async function simulateDragByIndex(page, sourceIndex, targetIndex, position) { } async function dragLocatorToLocator(page, sourceLocator, targetLocator, position = "onto") { + await sourceLocator.scrollIntoViewIfNeeded() + await targetLocator.scrollIntoViewIfNeeded() + const sourceHandle = await sourceLocator.elementHandle() const targetHandle = await targetLocator.elementHandle() From 7d4c20d10cc0b0d6f1d2c73162b52bd3ae6249ff Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 12:28:38 +0200 Subject: [PATCH 09/10] Simplify code --- src/editor/selection.js | 5 +- .../action_text_attachment_upload_node.js | 90 +++++++++---------- 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index 4c02e7d38..5150773ab 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -1,7 +1,7 @@ import { - $createParagraphNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $isDecoratorNode, $isElementNode, + $addUpdateTag, $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 + HISTORY_MERGE_TAG, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, SELECTION_CHANGE_COMMAND, isDOMNode } from "lexical" import { $getNearestNodeOfType } from "@lexical/utils" import { $getListDepth, ListItemNode, ListNode } from "@lexical/list" @@ -554,6 +554,7 @@ export default class Selection { #selectInLexical(node) { if ($isDecoratorNode(node)) { + $addUpdateTag(HISTORY_MERGE_TAG) const selection = $createNodeSelectionWith(node) $setSelection(selection) return selection diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 3e3235b04..1d9ef1018 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,6 +1,8 @@ -import { $createParagraphNode, $getNodeByKey, $getRoot, $getSelection, $isRangeSelection, $isRootOrShadowRoot, $nodesOfType, HISTORIC_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" +import { + $createParagraphNode, $getNodeByKey, $getRoot, $getSelection, $isRangeSelection, $isRootOrShadowRoot, HISTORIC_TAG, HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG, + SKIP_SCROLL_INTO_VIEW_TAG +} from "lexical" import Lexxy from "../config/lexxy" -import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" import { ActionTextAttachmentNode } from "./action_text_attachment_node" import { $isProvisionalParagraphNode } from "./provisional_paragraph_node" import { createElement, dispatch } from "../helpers/html_helper" @@ -173,69 +175,62 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { uploadNode.showUploadedAttachment(blob) }, { tag: this.#backgroundUpdateTags, - onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey, blob.attachable_sgid)) + onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey)) }) } }) } - // Collapse upload-only entries and keep a deterministic undo target that - // preserves in-flight typing while removing the uploaded attachment. - #collapseUploadHistory(uploadNodeKey, uploadedSgid) { + // Upload completion uses HISTORY_PUSH_TAG so Lexical creates the undo boundary. + // Then we rewrite any upload-node snapshots to their clean (node-stripped) form + // and collapse duplicates to avoid no-op undo steps. + #collapseUploadHistory(uploadNodeKey) { const historyState = this.#historyState if (!historyState) return const currentSignature = historyState.current && this.#contentSignatureFor(historyState.current.editorState) + const rewrittenStack = [] + let prevSignature = null - const collapsedUndoStack = historyState.undoStack.filter(entry => - !this.#entryContainsUploadNode(entry, uploadNodeKey) - ) + for (const entry of historyState.undoStack) { + const hasUploadNode = entry.editorState.read(() => + $getNodeByKey(uploadNodeKey) instanceof ActionTextAttachmentUploadNode + ) - const undoStateWithoutAttachment = this.#stateWithoutUploadedAttachment(uploadedSgid) - if (undoStateWithoutAttachment) { - const undoSignature = this.#contentSignatureFor(undoStateWithoutAttachment) - const lastUndoEntry = collapsedUndoStack.at(-1) - const lastUndoSignature = lastUndoEntry && this.#contentSignatureFor(lastUndoEntry.editorState) + const editorState = hasUploadNode + ? this.#editorStateStrippingUploadNodes(entry.editorState) + : entry.editorState - if (undoSignature !== currentSignature && undoSignature !== lastUndoSignature) { - collapsedUndoStack.push({ - editor: this.editor, - editorState: undoStateWithoutAttachment - }) + const signature = this.#contentSignatureFor(editorState) + if (signature !== prevSignature && signature !== currentSignature) { + rewrittenStack.push(hasUploadNode ? { editor: entry.editor, editorState } : entry) } + prevSignature = signature } - historyState.undoStack = collapsedUndoStack + historyState.undoStack = rewrittenStack historyState.redoStack = [] // Force listeners (toolbar undo/redo button states) to observe the stack rewrite. this.editor.update(() => {}, { tag: HISTORIC_TAG }) } - #entryContainsUploadNode(entry, uploadNodeKey) { - return entry.editorState.read(() => { - const node = $getNodeByKey(uploadNodeKey) - return node instanceof ActionTextAttachmentUploadNode + // Upload nodes can't survive a JSON round-trip (File isn't serializable), + // so strip them from the serialized state before parseEditorState calls importJSON. + #editorStateStrippingUploadNodes(editorState) { + const json = editorState.toJSON() + this.#stripNodesFromJSON(json.root, n => n.type === "action_text_attachment_upload") + return this.editor.parseEditorState(json, () => { + if ($getRoot().getChildrenSize() === 0) $getRoot().append($createParagraphNode()) }) } - #stateWithoutUploadedAttachment(uploadedSgid) { - if (!uploadedSgid) return null - const currentEntry = this.#historyState?.current - if (!currentEntry) return null - - const serializedEditorState = currentEntry.editorState.toJSON() - return this.editor.parseEditorState(JSON.stringify(serializedEditorState), () => { - const uploadedAttachmentNode = $nodesOfType(ActionTextAttachmentNode).find(node => node.sgid === uploadedSgid) - if (!uploadedAttachmentNode) return - - uploadedAttachmentNode.remove() - - const root = $getRoot() - if (root.getChildrenSize() === 0) { - root.append($createParagraphNode()) - } - root.selectEnd() + #stripNodesFromJSON(node, predicate) { + if (!node.children) return + node.children = node.children.filter(child => { + if (predicate(child)) return false + this.#stripNodesFromJSON(child, predicate) + return true }) } @@ -301,8 +296,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } // Transient updates (progress, dimensions, errors) are completely invisible to - // the undo history via HISTORIC_TAG. Only the final node replacement uses - // HISTORY_MERGE_TAG (via #backgroundUpdateTags) so the entire upload is one undo step. + // the undo history via HISTORIC_TAG. get #transientUpdateTags() { if (this.#editorHasFocus) { return [ HISTORIC_TAG ] @@ -311,14 +305,14 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } } - // Used for the final node replacement (upload complete) to merge with the - // original insertion as a single undo step. Without SKIP_DOM_SELECTION_TAG, - // Lexical's reconciler would steal focus from wherever the user is typing. + // Use HISTORY_PUSH_TAG to force a stable undo boundary at upload completion. + // SKIP_SCROLL_INTO_VIEW_TAG avoids scroll jumps, and SKIP_DOM_SELECTION_TAG + // prevents focus theft when the editor is not active. get #backgroundUpdateTags() { if (this.#editorHasFocus) { - return SILENT_UPDATE_TAGS + return [ HISTORY_PUSH_TAG, SKIP_SCROLL_INTO_VIEW_TAG ] } else { - return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ] + return [ HISTORY_PUSH_TAG, SKIP_SCROLL_INTO_VIEW_TAG, SKIP_DOM_SELECTION_TAG ] } } From 8142f4446f653d21f8281b0de1bd6d787d22a464 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 12:36:03 +0200 Subject: [PATCH 10/10] Update attachments.test.js --- test/browser/tests/attachments/attachments.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 0776c33da..635d86573 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -349,6 +349,7 @@ test.describe("Attachments", () => { // Redo should be a no-op once completion collapses and clears redo history. await redoButton.click() + await editor.flush() await expect(page.locator("figure.attachment")).toHaveCount(0) await expect(editor.content).toContainText("hello") })