From 6978c129f5fb096145440e85c9c2205ece0790ee Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 19:49:47 +0200 Subject: [PATCH 01/24] 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 90252cc2f..f7682953a 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 5c82cb3ea..498a79f0d 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -265,6 +265,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 ace7cf3cc58177d786a393b01cf9dc1f378a9319 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Wed, 1 Apr 2026 20:39:01 +0200 Subject: [PATCH 02/24] 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 10102e658..3d113c09d 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 498a79f0d..88953c3ad 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -276,14 +276,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 d15cc11257ba78bbddcda0d582cc9cbe4f1c1984 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 09:40:49 +0200 Subject: [PATCH 03/24] 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 3d113c09d..0feb8a993 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 f7682953a..25790cff5 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 88953c3ad..ee526487c 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -276,14 +276,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 a8cdca8d6961a6092e0b19f22905907c2c13b2f7 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:38:39 +0200 Subject: [PATCH 04/24] 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 d529cdb69..066abec6e 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 = [] @@ -122,6 +122,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 ee526487c..d60cada07 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -288,14 +288,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 @@ -307,9 +316,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 60ead38dab0db8fbb5dacfe1c196f9fe22926dbd Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:39:45 +0200 Subject: [PATCH 05/24] 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 e88ca059c0661a76c7581247ad8a956c3e3a8182 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:42:30 +0200 Subject: [PATCH 06/24] 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 25790cff5..fc1400bad 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 6e890990be72885c055ed4064558fc7481c8d903 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 11:57:19 +0200 Subject: [PATCH 07/24] 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 fc1400bad..f1c9e8f1c 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 d60cada07..150f1cd3a 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -341,6 +341,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 20a5d7fe7bdd655d4cdf501cef5aecebcb2bc391 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 2 Apr 2026 13:02:35 +0200 Subject: [PATCH 08/24] 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 2af14d15f37c365fb5bf80261947d3b91903e407 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 12:28:38 +0200 Subject: [PATCH 09/24] 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 0feb8a993..1a4becde0 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" @@ -563,6 +563,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 f1c9e8f1c..b78a55b1f 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 082bc323de0fd3c0128e65b30d4d7e7eea532bd9 Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Tue, 7 Apr 2026 12:36:03 +0200 Subject: [PATCH 10/24] 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 150f1cd3a..c407eb4cd 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -363,6 +363,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") }) From abba8ef6978fd7c2916dd79ead666127a84bdd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 12:08:02 +0100 Subject: [PATCH 11/24] Use CAN_REDO/UNDO commands to update state --- src/elements/editor.js | 22 +++++++++++++++++++--- src/elements/toolbar.js | 26 +++++++++----------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 9961fa9d8..a37ecf2c2 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,4 +1,4 @@ -import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" +import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" @@ -53,6 +53,7 @@ export class LexicalEditorElement extends HTMLElement { #editorInitializedRafId = null #listeners = new ListenerBin() #disposables = [] + #historyState = { undo: false, redo: false } constructor() { super() @@ -274,6 +275,14 @@ export class LexicalEditorElement extends HTMLElement { this.#initialValueLoaded = true } + get canUndo() { + return this.#historyState.undo + } + + get canRedo() { + return this.#historyState.redo + } + #parseHtmlIntoLexicalNodes(html) { if (!html) html = "

" const nodes = $generateNodesFromDOM(this.editor, parseHtml(`${html}`)) @@ -307,6 +316,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerComponents() this.#handleEnter() this.#registerFocusEvents() + this.#registerHistoryEvents() this.#attachDebugHooks() this.#attachToolbar() this.#configureSanitizer() @@ -538,6 +548,12 @@ export class LexicalEditorElement extends HTMLElement { } } + #registerHistoryEvents() { + this.#listeners.track( + this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#historyState.undo = enabled }, COMMAND_PRIORITY_NORMAL), + this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#historyState.redo = enabled }, COMMAND_PRIORITY_NORMAL) + ) + } #attachDebugHooks() { if (!LexicalEditorElement.debug) return @@ -635,8 +651,8 @@ export class LexicalEditorElement extends HTMLElement { heading: { active: format.isInHeading, enabled: true }, "unordered-list": { active: format.isInList && format.listType === "bullet", enabled: true }, "ordered-list": { active: format.isInList && format.listType === "number", enabled: true }, - undo: { active: false, enabled: this.historyState?.undoStack.length > 0 }, - redo: { active: false, enabled: this.historyState?.redoStack.length > 0 } + undo: { active: false, enabled: this.canUndo }, + redo: { active: false, enabled: this.canRedo } } linkHref = linkNode ? linkNode.getURL() : null diff --git a/src/elements/toolbar.js b/src/elements/toolbar.js index be76bd6a9..82f4310e0 100644 --- a/src/elements/toolbar.js +++ b/src/elements/toolbar.js @@ -1,6 +1,9 @@ import { $getSelection, $isRangeSelection, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_LOW, SKIP_DOM_SELECTION_TAG } from "lexical" import { getNonce } from "../helpers/csp_helper" @@ -187,19 +190,10 @@ export class LexicalToolbarElement extends HTMLElement { } #monitorHistoryChanges() { - this.#listeners.track(this.editor.registerUpdateListener(() => { - this.#updateUndoRedoButtonStates() - })) - } - - #updateUndoRedoButtonStates() { - this.editor.getEditorState().read(() => { - const historyState = this.editorElement.historyState - if (historyState) { - this.#setButtonDisabled("undo", historyState.undoStack.length === 0) - this.#setButtonDisabled("redo", historyState.redoStack.length === 0) - } - }) + this.#listeners.track( + this.editor.registerCommand(CAN_UNDO_COMMAND, (enabled) => { this.#setButtonDisabled("undo", !enabled) }, COMMAND_PRIORITY_LOW), + this.editor.registerCommand(CAN_REDO_COMMAND, (enabled) => { this.#setButtonDisabled("redo", !enabled) }, COMMAND_PRIORITY_LOW), + ) } #updateButtonStates() { @@ -233,8 +227,6 @@ export class LexicalToolbarElement extends HTMLElement { this.#setButtonPressed("code", isInCode) this.#setButtonPressed("table", isInTable) - - this.#updateUndoRedoButtonStates() } #setButtonPressed(name, isPressed) { @@ -445,11 +437,11 @@ export class LexicalToolbarElement extends HTMLElement { - - From ae2abd4ec22f67cb33cb8355acf4cc2297646ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 12:40:34 +0100 Subject: [PATCH 12/24] Abort upload when node is no longer attached --- src/nodes/action_text_attachment_upload_node.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index b78a55b1f..5fb52c789 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -252,7 +252,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { directUploadWillStoreFileWithXHR: (request) => { if (shouldAuthenticateUploads) request.withCredentials = true - const uploadProgressHandler = (event) => this.#handleUploadProgress(event) + const uploadProgressHandler = (event) => this.#handleUploadProgress(event, request) request.upload.addEventListener("progress", uploadProgressHandler) } } @@ -262,10 +262,14 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { this.#setProgress(1) } - #handleUploadProgress(event) { + #handleUploadProgress(event, request) { const progress = Math.round(event.loaded / event.total * 100) - this.#setProgress(progress) - this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress }) + try { + this.#setProgress(progress) + this.#dispatchEvent("lexxy:upload-progress", { file: this.file, progress }) + } catch { + request.abort() + } } #setProgress(progress) { From 39901bc1d5ddeae936d5240f181d6b6922b724ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Tue, 21 Apr 2026 21:31:12 +0100 Subject: [PATCH 13/24] RewriteableHistoryExtension for history-safe background updates For uploads and othe non-user updates, any editor change will cause an entry in the history stack if applied to a node with `editor.update`. This extension provides the REWRITE_HISTORY_COMMAND which takes Record. The updates are applied to the current state and then applied to the redo/undo stacks, allowing seamless history movement while uploading in the background. A command was used over a REWRITE_HISTORY_TAG as it gives more definite control over patch/replace and detecting replacement would have been complex --- src/editor/contents.js | 2 +- src/elements/editor.js | 6 +- .../rewritable_history_extension.js | 108 ++++++++++++ src/helpers/lexical_helper.js | 8 +- src/nodes/action_text_attachment_node.js | 45 +++-- .../action_text_attachment_upload_node.js | 159 ++---------------- 6 files changed, 153 insertions(+), 175 deletions(-) create mode 100644 src/extensions/rewritable_history_extension.js diff --git a/src/editor/contents.js b/src/editor/contents.js index ac56e5a69..8f8f229c6 100644 --- a/src/editor/contents.js +++ b/src/editor/contents.js @@ -289,7 +289,7 @@ export default class Contents { const node = $getNodeByKey(nodeKey) if (!(node instanceof ActionTextAttachmentUploadNode)) return - const replacementNodeKey = node.showUploadedAttachment(blob) + const replacementNodeKey = node.$showUploadedAttachment(blob) if (replacementNodeKey) { nodeKey = replacementNodeKey } diff --git a/src/elements/editor.js b/src/elements/editor.js index a37ecf2c2..52a768642 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -10,7 +10,6 @@ import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/ import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown" import { HORIZONTAL_DIVIDER } from "../editor/markdown/horizontal_divider_transformer" import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler" -import { createEmptyHistoryState, registerHistory } from "@lexical/history" import theme from "../config/theme" import { HorizontalDividerNode } from "../nodes/horizontal_divider_node" @@ -35,9 +34,11 @@ import { ProvisionalParagraphExtension } from "../extensions/provisional_paragra import { HighlightExtension } from "../extensions/highlight_extension" import { TrixContentExtension } from "../extensions/trix_content_extension" import { TablesExtension } from "../extensions/tables_extension" +import { RewritableHistoryExtension } from "../extensions/rewritable_history_extension.js" import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" import { LinkOpenerExtension } from "../extensions/link_opener_extension.js" +import { HistoryExtension } from "@lexical/history" export class LexicalEditorElement extends HTMLElement { @@ -145,6 +146,7 @@ export class LexicalEditorElement extends HTMLElement { HighlightExtension, TrixContentExtension, TablesExtension, + RewritableHistoryExtension, AttachmentsExtension, FormatEscapeExtension, LinkOpenerExtension @@ -464,8 +466,6 @@ export class LexicalEditorElement extends HTMLElement { } else { registered.push(registerPlainText(this.editor)) } - this.historyState = createEmptyHistoryState() - registered.push(registerHistory(this.editor, this.historyState, 20)) this.#listeners.track(...registered) } diff --git a/src/extensions/rewritable_history_extension.js b/src/extensions/rewritable_history_extension.js new file mode 100644 index 000000000..93cd1727b --- /dev/null +++ b/src/extensions/rewritable_history_extension.js @@ -0,0 +1,108 @@ +import { $cloneWithProperties, $getEditor, $getNodeByKey, COMMAND_PRIORITY_EDITOR, HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG, SKIP_SCROLL_INTO_VIEW_TAG, createCommand, defineExtension } from "lexical" +import { HistoryExtension } from "@lexical/history" + +import LexxyExtension from "./lexxy_extension" +import { isEditorFocused } from "../helpers/lexical_helper" + +// Payload: Record +// - patch: plain object, shallow-merged into the existing node's properties +// - replace: a LexicalNode instance that replaces the node +export const REWRITE_HISTORY_COMMAND = createCommand("REWRITE_HISTORY_COMMAND") + +export class RewritableHistoryExtension extends LexxyExtension { + #historyState = null + + get lexicalExtension() { + return defineExtension({ + name: "lexxy/rewritable-history", + dependencies: [ HistoryExtension ], + register: (editor, _config, state) => { + const historyOutput = state.getDependency(HistoryExtension).output + this.#historyState = historyOutput.historyState.value + + return editor.registerCommand( + REWRITE_HISTORY_COMMAND, + (rewrites) => this.#rewriteHistory(rewrites), + COMMAND_PRIORITY_EDITOR + ) + } + }) + } + + get historyState() { + return this.#historyState + } + + get #allHistoryEntries() { + const entries = Array.from(this.#historyState.undoStack) + if (this.#historyState.current) entries.push(this.#historyState.current) + return entries.concat(this.#historyState.redoStack) + } + + #rewriteHistory(rewrites) { + $getEditor().update(() => { + for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) { + const node = $getNodeByKey(nodeKey) + if (!node) continue + + if (patch) Object.assign(node.getWritable(), patch) + if (replace) node.replace(replace) + } + }, { discrete: true, tag: this.#getBackgroundUpdateTags() }) + + const nodeKeys = Object.keys(rewrites) + + for (const entry of this.#allHistoryEntries) { + if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue + + for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) { + const node = entry.editorState._nodeMap.get(nodeKey) + if (!node) continue + + entry.editorState = safeCloneEditorState(entry.editorState) + + if (patch) { + entry.editorState._nodeMap.set(nodeKey, $cloneNodeWithPatch(node, patch)) + } else if (replace) { + entry.editorState._nodeMap.set(nodeKey, $cloneNodeAdoptingKey(replace, node)) + } + } + } + + return true + } + + #entryHasSomeKeys(entry, nodeKeys) { + return nodeKeys.some(key => entry.editorState._nodeMap.has(key)) + } + + #getBackgroundUpdateTags() { + const tags = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ] + if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG) } + return tags + } +} + +function $cloneNodeWithPatch(node, patch) { + const clone = $cloneWithProperties(node) + Object.assign(clone, patch) + return clone +} + +function $cloneNodeAdoptingKey(source, keyNode) { + const clone = $cloneWithProperties(source) + clone.__key = keyNode.__key + clone.__parent = keyNode.__parent + clone.__prev = keyNode.__prev + clone.__next = keyNode.__next + return clone +} + +// EditorState#clone() keeps the same map reference. +// A new Map is needed to prevent editing Lexical's internal map +// Warning: this bypasses DEV's safety map freezing +function safeCloneEditorState(editorState) { + const clone = editorState.clone() + clone._nodeMap = new Map(editorState._nodeMap) + return clone +} diff --git a/src/helpers/lexical_helper.js b/src/helpers/lexical_helper.js index 5aba366d7..eed45a593 100644 --- a/src/helpers/lexical_helper.js +++ b/src/helpers/lexical_helper.js @@ -1,5 +1,4 @@ import { $createNodeSelection, $createParagraphNode, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isRootNode, $isRootOrShadowRoot, $isTextNode, TextNode } from "lexical" -import { HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG } from "lexical" import { ListNode } from "@lexical/list" import { $getNearestNodeOfType, $lastToFirstIterator } from "@lexical/utils" import { $wrapNodeInElement } from "@lexical/utils" @@ -7,8 +6,6 @@ import { $isAtNodeEnd } from "@lexical/selection" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" -export const SILENT_UPDATE_TAGS = [ HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG ] - export function $createNodeSelectionWith(...nodes) { const selection = $createNodeSelection() nodes.forEach(node => selection.add(node.getKey())) @@ -35,6 +32,11 @@ export function getListType(node) { return list?.getListType() ?? null } +export function isEditorFocused(editor) { + const rootElement = editor.getRootElement() + return rootElement !== null && rootElement.contains(document.activeElement) +} + export function $isAtNodeEdge(point, atStart = null) { if (atStart === null) { return $isAtNodeEdge(point, true) || $isAtNodeEdge(point, false) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index dca6f58f2..408466f33 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -1,9 +1,9 @@ import Lexxy from "../config/lexxy" -import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG } from "lexical" -import { SILENT_UPDATE_TAGS } from "../helpers/lexical_helper" +import { $getEditor, $getNearestRootOrShadowRoot, DecoratorNode, HISTORY_MERGE_TAG } from "lexical" import { createAttachmentFigure, createElement, isPreviewableImage } from "../helpers/html_helper" import { bytesToHumanSize, extractFileName } from "../helpers/storage_helper" import { parseBoolean } from "../helpers/string_helper" +import { REWRITE_HISTORY_COMMAND } from "../extensions/rewritable_history_extension" export class ActionTextAttachmentNode extends DecoratorNode { @@ -218,6 +218,18 @@ export class ActionTextAttachmentNode extends DecoratorNode { return figure } + patchAndRewriteHistory(patch) { + this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, { + [this.getKey()]: { patch } + }) + } + + replaceAndRewriteHistory(node) { + this.editor.dispatchCommand(REWRITE_HISTORY_COMMAND, { + [this.getKey()]: { replace: node } + }) + } + #createDOMForImage(options = {}) { const initialSrc = this.previewSrc || this.src const img = createElement("img", { src: initialSrc, draggable: false, alt: this.altText, ...this.#imageDimensions, ...options }) @@ -246,33 +258,18 @@ export class ActionTextAttachmentNode extends DecoratorNode { #handleImageLoaded(img, previewSrc) { img.src = this.src - this.editor.update(() => { - if (this.isAttached()) this.getWritable().previewSrc = null - }, { tag: this.#backgroundUpdateTags }) + this.patchAndRewriteHistory({ previewSrc: null }) this.#revokePreviewSrc(previewSrc) } #handleImageLoadError(previewSrc) { - this.editor.update(() => { - if (this.isAttached()) { - this.getWritable().previewSrc = null - this.getWritable().uploadError = true - } - }, { tag: this.#backgroundUpdateTags }) + this.patchAndRewriteHistory({ + previewSrc: null, + uploadError: true + }) this.#revokePreviewSrc(previewSrc) } - get #backgroundUpdateTags() { - const rootElement = this.editor.getRootElement() - const editorHasFocus = rootElement !== null && rootElement.contains(document.activeElement) - - if (editorHasFocus) { - return SILENT_UPDATE_TAGS - } else { - return [ ...SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG ] - } - } - #revokePreviewSrc(previewSrc) { if (previewSrc?.startsWith("blob:")) URL.revokeObjectURL(previewSrc) } @@ -334,9 +331,7 @@ export class ActionTextAttachmentNode extends DecoratorNode { figure.appendChild(this.#createEditableCaption()) }) - this.editor.update(() => { - if (this.isAttached()) this.getWritable().pendingPreview = false - }, { tag: this.#backgroundUpdateTags }) + this.patchAndRewriteHistory({ pendingPreview: false }) } #swapFigureContent(figure, fromClass, toClass, renderContent) { diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 5fb52c789..77d770a57 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,17 +1,10 @@ -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 { ActionTextAttachmentNode } from "./action_text_attachment_node" -import { $isProvisionalParagraphNode } from "./provisional_paragraph_node" import { createElement, dispatch } from "../helpers/html_helper" 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" } @@ -30,10 +23,10 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } constructor(node, key) { - const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError } = node - super({ ...node, contentType: file.type }, key) - this.file = file - this.fileName = file.name + const { file, uploadUrl, blobUrlTemplate, progress, width, height, uploadError, fileName, contentType } = node + super({ ...node, contentType: file?.type ?? contentType }, key) + this.file = file ?? null + this.fileName = file?.name ?? fileName this.uploadUrl = uploadUrl this.blobUrlTemplate = blobUrlTemplate this.progress = progress ?? null @@ -90,6 +83,8 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { ...super.exportJSON(), type: "action_text_attachment_upload", version: 1, + fileName: this.fileName, + contentType: this.contentType, uploadUrl: this.uploadUrl, blobUrlTemplate: this.blobUrlTemplate, progress: this.progress, @@ -114,14 +109,14 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } #getFileExtension() { - return this.file.name.split(".").pop().toLowerCase() + return (this.fileName || "").split(".").pop().toLowerCase() } #createCaption() { const figcaption = createElement("figcaption", { className: "attachment__caption" }) - const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.file.name || "" }) - const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file.size) }) + const nameSpan = createElement("span", { className: "attachment__name", textContent: this.caption || this.fileName || "" }) + const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.file?.size) }) figcaption.appendChild(nameSpan) figcaption.appendChild(sizeSpan) @@ -135,11 +130,7 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { #setDimensionsFromImage({ width, height }) { if (this.#hasDimensions) return - this.editor.update(() => { - const writable = this.getWritable() - writable.width = width - writable.height = height - }, { tag: this.#transientUpdateTags }) + this.patchAndRewriteHistory({ width, height }) } get #hasDimensions() { @@ -149,10 +140,7 @@ 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 uploadNodeKey = this.getKey() this.#setUploadStarted() const { DirectUpload } = await import("@rails/activestorage") @@ -169,79 +157,12 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } else { this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null }) this.editor.update(() => { - const uploadNode = $getNodeByKey(uploadNodeKey) - if (!(uploadNode instanceof ActionTextAttachmentUploadNode)) return - - uploadNode.showUploadedAttachment(blob) - }, { - tag: this.#backgroundUpdateTags, - onUpdate: () => requestAnimationFrame(() => this.#collapseUploadHistory(uploadNodeKey)) + this.$showUploadedAttachment(blob) }) } }) } - // 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 - - for (const entry of historyState.undoStack) { - const hasUploadNode = entry.editorState.read(() => - $getNodeByKey(uploadNodeKey) instanceof ActionTextAttachmentUploadNode - ) - - const editorState = hasUploadNode - ? this.#editorStateStrippingUploadNodes(entry.editorState) - : entry.editorState - - const signature = this.#contentSignatureFor(editorState) - if (signature !== prevSignature && signature !== currentSignature) { - rewrittenStack.push(hasUploadNode ? { editor: entry.editor, editorState } : entry) - } - prevSignature = signature - } - - historyState.undoStack = rewrittenStack - historyState.redoStack = [] - - // Force listeners (toolbar undo/redo button states) to observe the stack rewrite. - this.editor.update(() => {}, { tag: HISTORIC_TAG }) - } - - // 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()) - }) - } - - #stripNodesFromJSON(node, predicate) { - if (!node.children) return - node.children = node.children.filter(child => { - if (predicate(child)) return false - this.#stripNodesFromJSON(child, predicate) - return true - }) - } - - #contentSignatureFor(editorState) { - return JSON.stringify(editorState.toJSON().root) - } - - get #historyState() { - return this.editor.getRootElement()?.closest("lexxy-editor")?.historyState - } - #createUploadDelegate() { const shouldAuthenticateUploads = Lexxy.global.get("authenticatedUploads") @@ -273,72 +194,24 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } #setProgress(progress) { - this.editor.update(() => { - this.getWritable().progress = progress - }, { tag: this.#transientUpdateTags }) + this.patchAndRewriteHistory({ progress }) } #handleUploadError(error) { console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`) - this.editor.update(() => { - this.getWritable().uploadError = true - }, { tag: this.#transientUpdateTags }) + + this.patchAndRewriteHistory({ uploadError: true }) } - showUploadedAttachment(blob) { + $showUploadedAttachment(blob) { const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc) - const shouldSelectAfterReplacement = this.#selectionIncludesUploadNode - this.replace(replacementNode) - - if (shouldSelectAfterReplacement && $isRootOrShadowRoot(replacementNode.getParent())) { - replacementNode.selectNext() - } + this.replaceAndRewriteHistory(replacementNode) return replacementNode.getKey() } - // Transient updates (progress, dimensions, errors) are completely invisible to - // the undo history via HISTORIC_TAG. - get #transientUpdateTags() { - if (this.#editorHasFocus) { - return [ HISTORIC_TAG ] - } else { - return [ HISTORIC_TAG, SKIP_DOM_SELECTION_TAG ] - } - } - - // 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 [ HISTORY_PUSH_TAG, SKIP_SCROLL_INTO_VIEW_TAG ] - } else { - return [ HISTORY_PUSH_TAG, SKIP_SCROLL_INTO_VIEW_TAG, SKIP_DOM_SELECTION_TAG ] - } - } - - get #editorHasFocus() { - const rootElement = this.editor.getRootElement() - return rootElement !== null && rootElement.contains(document.activeElement) - } - - get #selectionIncludesUploadNode() { - const selection = $getSelection() - if (selection === null) return false - - if (selection.getNodes().some((node) => node.is(this))) return true - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false - - const anchorNode = selection.anchor.getNode() - if (!$isProvisionalParagraphNode(anchorNode) || !anchorNode.isEmpty()) return false - - const previousSibling = anchorNode.getPreviousSibling() - return previousSibling !== null && previousSibling.is(this) - } - #toActionTextAttachmentNodeWith(blob, previewSrc) { const conversion = new AttachmentNodeConversion(this, blob, previewSrc) return conversion.toAttachmentNode() From dfdc51be255fc2b084e41d43ddcedb0e4d166dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 13:38:29 +0100 Subject: [PATCH 14/24] On `value=`, insert nodes within the selection rather than append at the root --- src/elements/editor.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 52a768642..72b30727f 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,4 +1,4 @@ -import { $addUpdateTag, $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" +import { $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" @@ -38,7 +38,6 @@ import { RewritableHistoryExtension } from "../extensions/rewritable_history_ext import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" import { LinkOpenerExtension } from "../extensions/link_opener_extension.js" -import { HistoryExtension } from "@lexical/history" export class LexicalEditorElement extends HTMLElement { @@ -49,7 +48,6 @@ export class LexicalEditorElement extends HTMLElement { static observedAttributes = [ "connected", "required" ] #initialValue = "" - #initialValueLoaded = false #validationTextArea = document.createElement("textarea") #editorInitializedRafId = null #listeners = new ListenerBin() @@ -257,10 +255,10 @@ export class LexicalEditorElement extends HTMLElement { this.editor.update(() => { $addUpdateTag(SKIP_DOM_SELECTION_TAG) - const root = $getRoot() - root.clear() - root.append(...this.#parseHtmlIntoLexicalNodes(html)) - root.selectEnd() + $getRoot() + .clear() + .selectEnd() + .insertNodes(this.#parseHtmlIntoLexicalNodes(html)) this.#toggleEmptyStatus() From d6042ed18ac8af730dce8e01fc21b6ec1df18997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 13:39:41 +0100 Subject: [PATCH 15/24] Force immediate update commit with discrete on value set Avoids inconsistent initial editor state where editor cannot be interacted with --- src/elements/editor.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 72b30727f..81e9fb30d 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -251,28 +251,14 @@ export class LexicalEditorElement extends HTMLElement { } set value(html) { - const wasEmpty = !this.#initialValueLoaded - this.editor.update(() => { - $addUpdateTag(SKIP_DOM_SELECTION_TAG) $getRoot() .clear() .selectEnd() .insertNodes(this.#parseHtmlIntoLexicalNodes(html)) this.#toggleEmptyStatus() - - // The first time you set the value on an empty editor, Lexical can be - // left in an inconsistent state until the next update (adding attachments - // fails because no root node is detected). A no-op update works around - // it. Only fire on the first load — subsequent set value calls don't hit - // the inconsistent state and the extra reconciler cycle is pure overhead. - if (wasEmpty) { - requestAnimationFrame(() => this.editor?.update(() => { })) - } - }) - - this.#initialValueLoaded = true + }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG }) } get canUndo() { From d08f95d0a43e3672d0bfd30830e483cff6a021a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 13:40:28 +0100 Subject: [PATCH 16/24] Merge initial value load with first history entry --- src/elements/editor.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/elements/editor.js b/src/elements/editor.js index 81e9fb30d..fa58a38ee 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -1,4 +1,4 @@ -import { $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" +import { $createParagraphNode, $getRoot, $getSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, CAN_REDO_COMMAND, CAN_UNDO_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_NORMAL, HISTORY_MERGE_TAG, KEY_ENTER_COMMAND, SKIP_DOM_SELECTION_TAG, TextNode } from "lexical" import { buildEditorFromExtensions } from "@lexical/extension" import { ListItemNode, ListNode, registerList } from "@lexical/list" import { AutoLinkNode, LinkNode } from "@lexical/link" @@ -399,8 +399,10 @@ export class LexicalEditorElement extends HTMLElement { } #loadInitialValue() { - const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "

" - this.value = this.#initialValue = initialHtml + const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "


" + this.editor.update(() => { + this.value = this.#initialValue = initialHtml + }, { tag: HISTORY_MERGE_TAG }) } #resetBeforeTurboCaches() { From bf7ab5b8f3856c8d31962e3aa68e81b3705060bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 15:46:26 +0100 Subject: [PATCH 17/24] Simplify editor flush to just force a read cycle --- test/browser/helpers/editor_handle.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/browser/helpers/editor_handle.js b/test/browser/helpers/editor_handle.js index 83f0e425f..290eb41d6 100644 --- a/test/browser/helpers/editor_handle.js +++ b/test/browser/helpers/editor_handle.js @@ -168,12 +168,7 @@ export class EditorHandle { async flush() { await this.locator.evaluate((el) => { - return new Promise((resolve) => { - el.editor.update( - () => {}, - { onUpdate: () => requestAnimationFrame(resolve) }, - ) - }) + return el.editor.getEditorState().read(() => {}) }) } From 631fdc20a683289fb99814c33b311acde3ef8cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 15:46:46 +0100 Subject: [PATCH 18/24] Fix test: undo should pop the last change Not shift the first --- test/browser/tests/attachments/attachments.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index c407eb4cd..2e9793879 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -310,14 +310,13 @@ test.describe("Attachments", () => { // 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 + // Undo should remove the typed text but preserve the attachment const undoButton = page.getByRole("button", { name: "Undo" }) await undoButton.click() await editor.flush() - await expect(figure).toHaveCount(0) - await expect(editor.content.locator("progress")).toHaveCount(0) - await expect(editor.content).toContainText("hello world") + await expect(figure).toBeVisible() + await expect(editor.content).not.toContainText("hello world") }) test("node selection does not create an extra undo step", async ({ page, editor }) => { From e4092fe6081e550c9bf62d1d5bddd666d55b4672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Wed, 22 Apr 2026 15:50:06 +0100 Subject: [PATCH 19/24] Remove test: Upload completion should not clear redo stack --- .../tests/attachments/attachments.test.js | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 2e9793879..1963563f1 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -340,33 +340,6 @@ 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 editor.flush() - 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 ca3e65716dd0f578e1f4f6e3d96902f151d0952d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 23 Apr 2026 11:24:11 +0100 Subject: [PATCH 20/24] Fix test: Make typing-after text consistent Previous version wasn't testing that the cursor was after the image --- test/browser/tests/attachments/attachments.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 1963563f1..133b0e94a 100644 --- a/test/browser/tests/attachments/attachments.test.js +++ b/test/browser/tests/attachments/attachments.test.js @@ -230,8 +230,8 @@ test.describe("Attachments", () => { // attachment so typing inserts text there. The trailing provisional paragraph // must be visible (not collapsed as hidden) so the caret renders correctly. const paragraphAfterAttachment = figure.locator("xpath=following-sibling::p[1]") - await expect(paragraphAfterAttachment).toHaveClass(/provisional-paragraph/) - await expect(paragraphAfterAttachment).not.toHaveClass(/hidden/) + await expect(paragraphAfterAttachment).toContainClass("provisional-paragraph") + await expect(paragraphAfterAttachment).not.toContainClass("hidden") }) test("typing after uploading image into empty editor inserts text below the attachment", async ({ page, editor }) => { @@ -256,13 +256,15 @@ test.describe("Attachments", () => { await expect(figure).toBeVisible({ timeout: 10_000 }) await editor.send("hello") - await expect.poll(() => editor.plainTextValue()).toContain("hello") + const paragraph = figure.locator("xpath=following-sibling::p[1]") + await expect(paragraph).toHaveText("hello") + await calls.releaseDirectUploadResponses() await editor.flush() await editor.send(" world") - await expect.poll(() => editor.plainTextValue()).toContain("hello world") + await expect(paragraph).toHaveText("hello world") }) test("undo after uploading into empty editor restores empty state", async ({ page, editor }) => { From ef10fded0644d3b1a77c8fb9e455f66fe18346aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 23 Apr 2026 12:59:45 +0100 Subject: [PATCH 21/24] Select any ProvisionalParagraph created at a root selection position Prevents any edge cases where selection is kicked to the root and doesn't settle back into a PovisionalParagraph. This is mostly observed in fast environments like testing as user-clients will put selection back into the created paragraph --- src/extensions/provisional_paragraph_extension.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/extensions/provisional_paragraph_extension.js b/src/extensions/provisional_paragraph_extension.js index dc0927a1d..91adb2e5a 100644 --- a/src/extensions/provisional_paragraph_extension.js +++ b/src/extensions/provisional_paragraph_extension.js @@ -1,4 +1,4 @@ -import { $addUpdateTag, $getRoot, COMMAND_PRIORITY_HIGH, HISTORY_MERGE_TAG, RootNode, SELECTION_CHANGE_COMMAND, defineExtension } from "lexical" +import { $addUpdateTag, $getRoot, $getSelection, $isRootOrShadowRoot, 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" @@ -25,6 +25,8 @@ export class ProvisionalParagraphExtension extends LexxyExtension { } function $insertRequiredProvisionalParagraphs(rootNode) { + const nodeBeforeRootSelection = $nodeBeforeRootSelection(rootNode) + const firstNode = rootNode.getFirstChild() if (ProvisionalParagraphNode.neededBetween(null, firstNode)) { $insertFirst(rootNode, new ProvisionalParagraphNode) @@ -34,10 +36,18 @@ function $insertRequiredProvisionalParagraphs(rootNode) { const nextNode = node.getNextSibling() if (ProvisionalParagraphNode.neededBetween(node, nextNode)) { node.insertAfter(new ProvisionalParagraphNode) + if (node.is(nodeBeforeRootSelection)) node.selectNext() } } } +function $nodeBeforeRootSelection(rootNode) { + const selection = $getSelection() + if (!$isRootOrShadowRoot(selection?.anchor?.getNode())) return null + + return rootNode.getChildAtIndex(selection.anchor.offset - 1) +} + function $removeUnneededProvisionalParagraphs(rootNode) { for (const provisionalParagraph of $getAllProvisionalParagraphs(rootNode)) { provisionalParagraph.removeUnlessRequired() From eca10bdd697fd618c720d230f57ebf14a259ec55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 23 Apr 2026 14:57:28 +0100 Subject: [PATCH 22/24] Fix tests: wait for attachment url substitution --- .../attachment_drag_and_drop.test.js | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) 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 111f03f26..0f074b3c8 100644 --- a/test/browser/tests/attachments/attachment_drag_and_drop.test.js +++ b/test/browser/tests/attachments/attachment_drag_and_drop.test.js @@ -57,7 +57,7 @@ test.describe("Attachment Drag and Drop", () => { test("drag a standalone image above a text paragraph", async ({ page, editor }) => { await editor.send("Hello world", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await simulateDrag(page, "figure.attachment--preview", "p:not(.provisional-paragraph)", "before") @@ -68,7 +68,7 @@ test.describe("Attachment Drag and Drop", () => { test("drag a file attachment above a text paragraph", async ({ page, editor }) => { await editor.send("Hello world", "Enter") await editor.uploadFile("test/fixtures/files/dummy.pdf", { via: "file" }) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await simulateDrag(page, "figure.attachment--file", "p:not(.provisional-paragraph)", "before") @@ -79,7 +79,7 @@ test.describe("Attachment Drag and Drop", () => { test("drag an image and drop in same position causes no change", async ({ page, editor }) => { await editor.send("Before", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const valueBefore = await editor.value() @@ -97,7 +97,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.uploadFile("test/fixtures/files/example.png") await editor.send("Enter") await editor.uploadFile("test/fixtures/files/example2.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await simulateDragByIndex(page, 1, 0, "onto") @@ -110,11 +110,11 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example2.png", ]) await assertGalleryWithImages(editor, 2) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await editor.send("Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const standalone = page.locator(".lexxy-editor__content > figure.attachment") const galleryImg = page.locator(".attachment-gallery figure.attachment").first() @@ -130,11 +130,12 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example2.png", ]) await assertGalleryWithImages(editor, 2) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await editor.send("Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) + await editor.flush() const standalone = page.locator(".lexxy-editor__content > figure.attachment") const galleryImg = page.locator(".attachment-gallery figure.attachment").first() @@ -153,7 +154,7 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example.png", ]) await assertGalleryWithImages(editor, 3) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const images = page.locator(".attachment-gallery figure.attachment") const firstKey = await images.nth(0).getAttribute("data-lexical-node-key") @@ -171,7 +172,7 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example.png", ]) await assertGalleryWithImages(editor, 3) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const images = page.locator(".attachment-gallery figure.attachment") const lastKey = await images.nth(2).getAttribute("data-lexical-node-key") @@ -192,7 +193,7 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example.png", ]) await assertGalleryWithImages(editor, 3) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const galleryImage = page.locator(".attachment-gallery figure.attachment").first() const paragraph = page.locator(".lexxy-editor__content p:not(.provisional-paragraph)") @@ -210,7 +211,7 @@ test.describe("Attachment Drag and Drop", () => { "test/fixtures/files/example2.png", ]) await assertGalleryWithImages(editor, 2) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const galleryImage = page.locator(".attachment-gallery figure.attachment").first() const paragraph = page.locator(".lexxy-editor__content p:not(.provisional-paragraph)") @@ -237,7 +238,7 @@ test.describe("Attachment Drag and Drop", () => { ]) await expect(page.locator(".attachment-gallery")).toHaveCount(2) - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const gallery1Image = page.locator(".attachment-gallery").nth(0).locator("figure.attachment").first() const gallery2Image = page.locator(".attachment-gallery").nth(1).locator("figure.attachment").first() @@ -255,7 +256,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.uploadFile("test/fixtures/files/example.png") await editor.send("Enter") await editor.uploadFile("test/fixtures/files/example2.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await simulateDragByIndex(page, 1, 0, "onto") await assertGalleryWithImages(editor, 2) @@ -269,7 +270,7 @@ test.describe("Attachment Drag and Drop", () => { test("undo reverses repositioning", async ({ page, editor }) => { await editor.send("Hello world", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) await simulateDrag(page, "figure.attachment--preview", "p:not(.provisional-paragraph)", "before") @@ -286,7 +287,7 @@ test.describe("Attachment Drag and Drop", () => { test("source has lexxy-dragging class during drag", async ({ page, editor }) => { await editor.send("Target", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const hasDraggingClass = await page.evaluate(() => { const figure = document.querySelector("figure.attachment") @@ -317,7 +318,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.uploadFile("test/fixtures/files/example.png") await editor.send("Enter") await editor.uploadFile("test/fixtures/files/example2.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const hasHighlight = await page.evaluate(() => { const figures = document.querySelectorAll("figure.attachment") @@ -362,7 +363,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.setValue("
  • First
  • Second
  • Third
") await editor.send("ArrowDown", "ArrowDown", "ArrowDown", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const figure = page.locator("figure.attachment") const secondLi = page.locator(".lexxy-editor__content li").nth(1) @@ -381,7 +382,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.setValue("
  • First
  • Second
") await editor.send("ArrowDown", "ArrowDown", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const figure = page.locator("figure.attachment") const firstLi = page.locator(".lexxy-editor__content li").first() @@ -397,7 +398,7 @@ test.describe("Attachment Drag and Drop", () => { await editor.setValue("
  • First
  • Second
") await editor.send("ArrowDown", "ArrowDown", "Enter") await editor.uploadFile("test/fixtures/files/example.png") - await waitForUploadsComplete(page) + await waitForUploadsComplete(page, editor) const figure = page.locator("figure.attachment") const lastLi = page.locator(".lexxy-editor__content li").last() @@ -424,8 +425,10 @@ test.describe("Attachment Drag and Drop", () => { // --- Helpers --- -async function waitForUploadsComplete(page) { +async function waitForUploadsComplete(page, editor) { await expect(page.locator("figure.attachment > progress")).toHaveCount(0, { timeout: 10_000 }) + await expect(page.locator("figure.attachment img[src^=data]")).toHaveCount(0, { timeout: 10_000 }) + await editor.flush() } async function assertAttachmentVisible(page, contentType) { From db5fa97c81310e33321a804d21bf8a15d7632340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Thu, 23 Apr 2026 15:18:30 +0100 Subject: [PATCH 23/24] Drop disused `Selection#current=` --- src/editor/selection.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/editor/selection.js b/src/editor/selection.js index 1a4becde0..eee32723b 100644 --- a/src/editor/selection.js +++ b/src/editor/selection.js @@ -31,12 +31,6 @@ export default class Selection { this.#clearStaleInlineCodeFormat() } - set current(selection) { - this.editor.getEditorState().read(() => { - this.#syncSelectedClasses() - }) - } - get hasNodeSelection() { return this.editor.getEditorState().read(() => { const selection = $getSelection() @@ -365,7 +359,7 @@ export default class Selection { this.editor.registerCommand(DELETE_CHARACTER_COMMAND, this.#selectDecoratorNodeBeforeDeletion.bind(this), COMMAND_PRIORITY_LOW), this.editor.registerCommand(SELECTION_CHANGE_COMMAND, () => { - this.current = $getSelection() + this.#syncSelectedClasses() }, COMMAND_PRIORITY_LOW) ) } From 29d0a8e5f9e22f9f63630c178d8f168fabc6f02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20P=C3=A9ch=C3=A8r?= Date: Fri, 24 Apr 2026 00:07:27 +0100 Subject: [PATCH 24/24] Extract HistoryExtension helpers --- .../rewritable_history_extension.js | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/extensions/rewritable_history_extension.js b/src/extensions/rewritable_history_extension.js index 93cd1727b..2aa9f9a44 100644 --- a/src/extensions/rewritable_history_extension.js +++ b/src/extensions/rewritable_history_extension.js @@ -40,6 +40,13 @@ export class RewritableHistoryExtension extends LexxyExtension { } #rewriteHistory(rewrites) { + this.#applyRewritesImmediatelyToCurrentState(rewrites) + this.#applyRewritesToHistory(rewrites) + + return true + } + + #applyRewritesImmediatelyToCurrentState(rewrites) { $getEditor().update(() => { for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) { const node = $getNodeByKey(nodeKey) @@ -49,27 +56,27 @@ export class RewritableHistoryExtension extends LexxyExtension { if (replace) node.replace(replace) } }, { discrete: true, tag: this.#getBackgroundUpdateTags() }) + } + #applyRewritesToHistory(rewrites) { const nodeKeys = Object.keys(rewrites) for (const entry of this.#allHistoryEntries) { if (!this.#entryHasSomeKeys(entry, nodeKeys)) continue + const editorState = entry.editorState = safeCloneEditorState(entry.editorState) + for (const [ nodeKey, { patch, replace } ] of Object.entries(rewrites)) { - const node = entry.editorState._nodeMap.get(nodeKey) + const node = editorState._nodeMap.get(nodeKey) if (!node) continue - entry.editorState = safeCloneEditorState(entry.editorState) - if (patch) { - entry.editorState._nodeMap.set(nodeKey, $cloneNodeWithPatch(node, patch)) + this.#patchNodeInEditorState(editorState, node, patch) } else if (replace) { - entry.editorState._nodeMap.set(nodeKey, $cloneNodeAdoptingKey(replace, node)) + this.#replaceNodeInEditorState(editorState, node, replace) } } } - - return true } #entryHasSomeKeys(entry, nodeKeys) { @@ -81,6 +88,14 @@ export class RewritableHistoryExtension extends LexxyExtension { if (!isEditorFocused(this.editorElement.editor)) { tags.push(SKIP_DOM_SELECTION_TAG) } return tags } + + #patchNodeInEditorState(editorState, node, patch) { + editorState._nodeMap.set(node.__key, $cloneNodeWithPatch(node, patch)) + } + + #replaceNodeInEditorState(editorState, node, replaceWith) { + editorState._nodeMap.set(node.__key, $cloneNodeAdoptingKeys(replaceWith, node)) + } } function $cloneNodeWithPatch(node, patch) { @@ -89,12 +104,12 @@ function $cloneNodeWithPatch(node, patch) { return clone } -function $cloneNodeAdoptingKey(source, keyNode) { - const clone = $cloneWithProperties(source) - clone.__key = keyNode.__key - clone.__parent = keyNode.__parent - clone.__prev = keyNode.__prev - clone.__next = keyNode.__next +function $cloneNodeAdoptingKeys(node, previousNode) { + const clone = $cloneWithProperties(node) + clone.__key = previousNode.__key + clone.__parent = previousNode.__parent + clone.__prev = previousNode.__prev + clone.__next = previousNode.__next return clone }