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/editor/selection.js b/src/editor/selection.js index 10102e658..eee32723b 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" @@ -31,12 +31,6 @@ export default class Selection { this.#clearStaleInlineCodeFormat() } - set current(selection) { - this.editor.update(() => { - 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) ) } @@ -563,6 +557,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/elements/editor.js b/src/elements/editor.js index 9961fa9d8..fa58a38ee 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 { $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" @@ -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,6 +34,7 @@ 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" @@ -48,11 +48,11 @@ export class LexicalEditorElement extends HTMLElement { static observedAttributes = [ "connected", "required" ] #initialValue = "" - #initialValueLoaded = false #validationTextArea = document.createElement("textarea") #editorInitializedRafId = null #listeners = new ListenerBin() #disposables = [] + #historyState = { undo: false, redo: false } constructor() { super() @@ -144,6 +144,7 @@ export class LexicalEditorElement extends HTMLElement { HighlightExtension, TrixContentExtension, TablesExtension, + RewritableHistoryExtension, AttachmentsExtension, FormatEscapeExtension, LinkOpenerExtension @@ -250,28 +251,22 @@ export class LexicalEditorElement extends HTMLElement { } set value(html) { - const wasEmpty = !this.#initialValueLoaded - 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() + }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG }) + } - // 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(() => { })) - } - }) + get canUndo() { + return this.#historyState.undo + } - this.#initialValueLoaded = true + get canRedo() { + return this.#historyState.redo } #parseHtmlIntoLexicalNodes(html) { @@ -307,6 +302,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerComponents() this.#handleEnter() this.#registerFocusEvents() + this.#registerHistoryEvents() this.#attachDebugHooks() this.#attachToolbar() this.#configureSanitizer() @@ -403,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() { @@ -454,8 +452,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) } @@ -538,6 +534,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 +637,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 { - - diff --git a/src/extensions/provisional_paragraph_extension.js b/src/extensions/provisional_paragraph_extension.js index f9110330c..91adb2e5a 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, $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() @@ -45,6 +55,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() } diff --git a/src/extensions/rewritable_history_extension.js b/src/extensions/rewritable_history_extension.js new file mode 100644 index 000000000..2aa9f9a44 --- /dev/null +++ b/src/extensions/rewritable_history_extension.js @@ -0,0 +1,123 @@ +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) { + 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) + if (!node) continue + + if (patch) Object.assign(node.getWritable(), patch) + 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 = editorState._nodeMap.get(nodeKey) + if (!node) continue + + if (patch) { + this.#patchNodeInEditorState(editorState, node, patch) + } else if (replace) { + this.#replaceNodeInEditorState(editorState, node, replace) + } + } + } + } + + #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 + } + + #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) { + const clone = $cloneWithProperties(node) + Object.assign(clone, patch) + return clone +} + +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 +} + +// 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 90252cc2f..77d770a57 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -1,8 +1,5 @@ -import { $getSelection, $isRangeSelection, $isRootOrShadowRoot, 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" -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" @@ -26,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 @@ -86,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, @@ -110,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) @@ -131,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.#backgroundUpdateTags }) + this.patchAndRewriteHistory({ width, height }) } get #hasDimensions() { @@ -162,8 +157,8 @@ export class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode { } else { this.#dispatchEvent("lexxy:upload-end", { file: this.file, error: null }) this.editor.update(() => { - this.showUploadedAttachment(blob) - }, { tag: this.#backgroundUpdateTags }) + this.$showUploadedAttachment(blob) + }) } }) } @@ -178,7 +173,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) } } @@ -188,70 +183,35 @@ 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) { - this.editor.update(() => { - this.getWritable().progress = progress - }, { tag: this.#backgroundUpdateTags }) + this.patchAndRewriteHistory({ progress }) } #handleUploadError(error) { console.warn(`Upload error for ${this.file?.name ?? "file"}: ${error}`) - this.editor.update(() => { - this.getWritable().uploadError = true - }, { tag: this.#backgroundUpdateTags }) + + 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() } - // 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. - get #backgroundUpdateTags() { - if (this.#editorHasFocus) { - return SILENT_UPDATE_TAGS - } else { - return [ ...SILENT_UPDATE_TAGS, 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() 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/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(() => {}) }) } 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..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("") 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("") 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("") 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) { @@ -462,6 +465,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() diff --git a/test/browser/tests/attachments/attachments.test.js b/test/browser/tests/attachments/attachments.test.js index 5c82cb3ea..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,90 @@ 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 }) => { + 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 — 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, { uploadDelayMs: 1_000 }) + + 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.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 + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve))) + + // 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).toBeVisible() + await expect(editor.content).not.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 }) => {