Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6978c12
Image upload history merging
zoltanhosszu Apr 1, 2026
ace7cf3
Node selection doesn't update history
zoltanhosszu Apr 1, 2026
d15cc11
PR feedback
zoltanhosszu Apr 2, 2026
a8cdca8
Test updates
zoltanhosszu Apr 2, 2026
60ead38
No history tracking for ProvisionalParagraph
zoltanhosszu Apr 2, 2026
e88ca05
Update action_text_attachment_upload_node.js
zoltanhosszu Apr 2, 2026
6e89099
Redo stack clearing
zoltanhosszu Apr 2, 2026
20a5d7f
Fix flaky gallery test
zoltanhosszu Apr 2, 2026
2af14d1
Simplify code
zoltanhosszu Apr 7, 2026
082bc32
Update attachments.test.js
zoltanhosszu Apr 7, 2026
abba8ef
Use CAN_REDO/UNDO commands to update state
samuelpecher Apr 22, 2026
ae2abd4
Abort upload when node is no longer attached
samuelpecher Apr 22, 2026
39901bc
RewriteableHistoryExtension for history-safe background updates
samuelpecher Apr 21, 2026
dfdc51b
On `value=`, insert nodes within the selection rather than append at …
samuelpecher Apr 22, 2026
d6042ed
Force immediate update commit with discrete on value set
samuelpecher Apr 22, 2026
d08f95d
Merge initial value load with first history entry
samuelpecher Apr 22, 2026
bf7ab5b
Simplify editor flush to just force a read cycle
samuelpecher Apr 22, 2026
631fdc2
Fix test: undo should pop the last change
samuelpecher Apr 22, 2026
e4092fe
Remove test: Upload completion should not clear redo stack
samuelpecher Apr 22, 2026
ca3e657
Fix test: Make typing-after text consistent
samuelpecher Apr 23, 2026
ef10fde
Select any ProvisionalParagraph created at a root selection position
samuelpecher Apr 23, 2026
eca10bd
Fix tests: wait for attachment url substitution
samuelpecher Apr 23, 2026
db5fa97
Drop disused `Selection#current=`
samuelpecher Apr 23, 2026
29d0a8e
Extract HistoryExtension helpers
samuelpecher Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/editor/contents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 4 additions & 9 deletions src/editor/selection.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
)
}
Expand Down Expand Up @@ -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
Expand Down
54 changes: 28 additions & 26 deletions src/elements/editor.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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"
Comment thread
samuelpecher marked this conversation as resolved.
import { FormatEscapeExtension } from "../extensions/format_escape_extension.js"
import { LinkOpenerExtension } from "../extensions/link_opener_extension.js"
Expand All @@ -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()
Expand Down Expand Up @@ -144,6 +144,7 @@ export class LexicalEditorElement extends HTMLElement {
HighlightExtension,
TrixContentExtension,
TablesExtension,
RewritableHistoryExtension,
AttachmentsExtension,
FormatEscapeExtension,
LinkOpenerExtension
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -307,6 +302,7 @@ export class LexicalEditorElement extends HTMLElement {
this.#registerComponents()
this.#handleEnter()
this.#registerFocusEvents()
this.#registerHistoryEvents()
this.#attachDebugHooks()
this.#attachToolbar()
this.#configureSanitizer()
Expand Down Expand Up @@ -403,8 +399,10 @@ export class LexicalEditorElement extends HTMLElement {
}

#loadInitialValue() {
const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p></p>"
this.value = this.#initialValue = initialHtml
const initialHtml = this.valueBeforeDisconnect || this.getAttribute("value") || "<p><br></p>"
this.editor.update(() => {
this.value = this.#initialValue = initialHtml
}, { tag: HISTORY_MERGE_TAG })
Comment thread
samuelpecher marked this conversation as resolved.
}

#resetBeforeTurboCaches() {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 9 additions & 17 deletions src/elements/toolbar.js
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -233,8 +227,6 @@ export class LexicalToolbarElement extends HTMLElement {
this.#setButtonPressed("code", isInCode)

this.#setButtonPressed("table", isInTable)

this.#updateUndoRedoButtonStates()
}

#setButtonPressed(name, isPressed) {
Expand Down Expand Up @@ -445,11 +437,11 @@ export class LexicalToolbarElement extends HTMLElement {

<div class="lexxy-editor__toolbar-spacer" role="separator"></div>

<button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo">
<button class="lexxy-editor__toolbar-button" type="button" name="undo" data-command="undo" title="Undo" disabled aria-disabled="true">
${ToolbarIcons.undo}
</button>

<button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo">
<button class="lexxy-editor__toolbar-button" type="button" name="redo" data-command="redo" title="Redo" disabled aria-disabled="true">
${ToolbarIcons.redo}
</button>

Expand Down
15 changes: 14 additions & 1 deletion src/extensions/provisional_paragraph_extension.js
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand All @@ -34,17 +36,28 @@ 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()
}
}

function $markAllProvisionalParagraphsDirty() {
// Selection-driven visibility updates must not become standalone undo steps.
$addUpdateTag(HISTORY_MERGE_TAG)

for (const provisionalParagraph of $getAllProvisionalParagraphs()) {
provisionalParagraph.markDirty()
}
Expand Down
Loading
Loading