Support undo and redo on text

This commit is contained in:
Elena Torro
2025-11-18 13:49:08 +01:00
parent 64b892f82d
commit 4baa894ee4

View File

@@ -83,6 +83,27 @@ export class TextEditor extends EventTarget {
*/ */
#canvas = null; #canvas = null;
/**
* Undo history stack for text editor content.
*
* @type {Array<{root: HTMLElement, selection: object}>}
*/
#undoHistory = [];
/**
* Redo history stack for text editor content.
*
* @type {Array<{root: HTMLElement, selection: object}>}
*/
#redoHistory = [];
/**
* Maximum number of undo states to keep.
*
* @type {number}
*/
#maxUndoStates = 50;
/** /**
* Constructor. * Constructor.
* *
@@ -158,9 +179,12 @@ export class TextEditor extends EventTarget {
if (options.shouldUpdatePositionOnScroll) { if (options.shouldUpdatePositionOnScroll) {
window.addEventListener("scroll", this.#onScroll); window.addEventListener("scroll", this.#onScroll);
} }
addEventListeners(this.#element, this.#events, { addEventListeners(this.#element, this.#events, {
capture: true, capture: true,
}); });
this.#element.addEventListener("keydown", this.#onDocumentKeyDown, true);
} }
/** /**
@@ -177,6 +201,11 @@ export class TextEditor extends EventTarget {
options, options,
); );
this.#setupListeners(options); this.#setupListeners(options);
// Save initial state for undo
setTimeout(() => {
this.#saveUndoState();
}, 0);
} }
/** /**
@@ -310,6 +339,7 @@ export class TextEditor extends EventTarget {
*/ */
#onBeforeInput = (e) => { #onBeforeInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
e.preventDefault();
return; return;
} }
@@ -331,6 +361,10 @@ export class TextEditor extends EventTarget {
if (!this.#selectionController.startMutation()) { if (!this.#selectionController.startMutation()) {
return; return;
} }
// Save undo state before making changes
this.#saveUndoState();
command(e, this, this.#selectionController); command(e, this, this.#selectionController);
const mutations = this.#selectionController.endMutation(); const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations); this.#notifyLayout(LayoutType.FULL, mutations);
@@ -344,6 +378,7 @@ export class TextEditor extends EventTarget {
*/ */
#onInput = (e) => { #onInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
e.preventDefault();
return; return;
} }
@@ -364,6 +399,42 @@ export class TextEditor extends EventTarget {
} }
}; };
/**
* Handles keydown events for undo/redo operations
*
* @param {KeyboardEvent} e
*/
#onDocumentKeyDown = (e) => {
// Prevent browser's native undo/redo and use text editor's internal undo/redo
if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z") && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.#performUndo();
return;
}
// Handle Ctrl+Shift+Z (redo) and Ctrl+Y (redo)
if ((e.ctrlKey || e.metaKey) && ((e.key === "z" || e.key === "Z") && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.#performRedo();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || e.key === "Y")) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.#performRedo();
return;
}
};
/** /**
* Handles keydown events * Handles keydown events
* *
@@ -376,6 +447,26 @@ export class TextEditor extends EventTarget {
return; return;
} }
// Prevent browser's native undo/redo and let document handler take care of it
if ((e.ctrlKey || e.metaKey) && (e.key === "z" || e.key === "Z")) {
e.preventDefault();
e.stopPropagation();
return;
}
// Handle Ctrl+Shift+Z (redo) and Ctrl+Y (redo)
if ((e.ctrlKey || e.metaKey) && ((e.key === "z" || e.key === "Z") && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === "y" || e.key === "Y")) {
e.preventDefault();
e.stopPropagation();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") { if ((e.ctrlKey || e.metaKey) && e.key === "Backspace") {
e.preventDefault(); e.preventDefault();
@@ -383,6 +474,9 @@ export class TextEditor extends EventTarget {
return; return;
} }
// Save undo state before making changes
this.#saveUndoState();
if (this.#selectionController.isCollapsed) { if (this.#selectionController.isCollapsed) {
this.#selectionController.removeWordBackward(); this.#selectionController.removeWordBackward();
} else { } else {
@@ -559,6 +653,214 @@ export class TextEditor extends EventTarget {
return this; return this;
} }
/**
* Saves current state to undo history.
*/
#saveUndoState() {
try {
const rootClone = this.#root.cloneNode(true);
// Save selection as simple text content for safer restoration
const selectionInfo = this.#selectionController.hasFocus ? {
textContent: this.#root.textContent,
isCollapsed: this.#selectionController.isCollapsed,
startOffset: this.#getTextOffset(this.#selectionController.anchorNode, this.#selectionController.anchorOffset),
endOffset: this.#getTextOffset(this.#selectionController.focusNode, this.#selectionController.focusOffset)
} : null;
this.#undoHistory.push({
root: rootClone,
selection: selectionInfo
});
// Limit history size
if (this.#undoHistory.length > this.#maxUndoStates) {
this.#undoHistory.shift();
}
// Clear redo history when new action is performed
this.#redoHistory = [];
} catch (error) {
console.warn("Failed to save undo state:", error);
}
}
/**
* Gets the text offset for a given node and offset within the root.
*/
#getTextOffset(node, offset) {
if (!node || !this.#root.contains(node)) return 0;
const walker = this.#root.ownerDocument.createTreeWalker(
this.#root,
NodeFilter.SHOW_TEXT,
null,
false
);
let textOffset = 0;
let currentNode;
while (currentNode = walker.nextNode()) {
if (currentNode === node) {
return textOffset + offset;
}
textOffset += currentNode.textContent.length;
}
return textOffset;
}
/**
* Restores selection to a text offset position.
*/
#restoreTextSelection(startOffset, endOffset) {
try {
const walker = this.#root.ownerDocument.createTreeWalker(
this.#root,
NodeFilter.SHOW_TEXT,
null,
false
);
let currentOffset = 0;
let currentNode;
let startNode = null, startPos = 0;
let endNode = null, endPos = 0;
while (currentNode = walker.nextNode()) {
const nodeLength = currentNode.textContent.length;
if (!startNode && currentOffset + nodeLength >= startOffset) {
startNode = currentNode;
startPos = startOffset - currentOffset;
}
if (!endNode && currentOffset + nodeLength >= endOffset) {
endNode = currentNode;
endPos = endOffset - currentOffset;
break;
}
currentOffset += nodeLength;
}
if (startNode) {
endNode = endNode || startNode;
endPos = endNode === startNode ? startPos : endPos;
this.#selectionController.setSelection(
startNode, Math.min(startPos, startNode.textContent.length),
endNode, Math.min(endPos, endNode.textContent.length)
);
}
} catch (error) {
console.warn("Failed to restore text selection:", error);
// Fallback: just focus the editor
this.#element.focus();
}
}
/**
* Performs undo operation.
*/
#performUndo() {
if (this.#undoHistory.length === 0) {
return;
}
try {
// Save current state to redo history
const currentRootClone = this.#root.cloneNode(true);
const currentSelectionInfo = this.#selectionController.hasFocus ? {
textContent: this.#root.textContent,
isCollapsed: this.#selectionController.isCollapsed,
startOffset: this.#getTextOffset(this.#selectionController.anchorNode, this.#selectionController.anchorOffset),
endOffset: this.#getTextOffset(this.#selectionController.focusNode, this.#selectionController.focusOffset)
} : null;
this.#redoHistory.push({
root: currentRootClone,
selection: currentSelectionInfo
});
// Restore previous state
const undoState = this.#undoHistory.pop();
const restoredRoot = undoState.root.cloneNode(true);
this.#root.replaceWith(restoredRoot);
this.#root = restoredRoot;
// Restore selection using text offset approach
if (undoState.selection) {
setTimeout(() => {
this.#restoreTextSelection(undoState.selection.startOffset, undoState.selection.endOffset);
}, 0);
} else {
// Just focus the editor if no selection info
setTimeout(() => {
this.#element.focus();
}, 0);
}
// Notify that content changed
this.#changeController.notifyImmediately();
this.#notifyLayout(LayoutType.FULL, null);
} catch (error) {
console.error("Failed to perform undo:", error);
}
}
/**
* Performs redo operation.
*/
#performRedo() {
if (this.#redoHistory.length === 0) {
return;
}
try {
// Save current state to undo history
const currentRootClone = this.#root.cloneNode(true);
const currentSelectionInfo = this.#selectionController.hasFocus ? {
textContent: this.#root.textContent,
isCollapsed: this.#selectionController.isCollapsed,
startOffset: this.#getTextOffset(this.#selectionController.anchorNode, this.#selectionController.anchorOffset),
endOffset: this.#getTextOffset(this.#selectionController.focusNode, this.#selectionController.focusOffset)
} : null;
this.#undoHistory.push({
root: currentRootClone,
selection: currentSelectionInfo
});
// Restore redo state
const redoState = this.#redoHistory.pop();
const restoredRoot = redoState.root.cloneNode(true);
this.#root.replaceWith(restoredRoot);
this.#root = restoredRoot;
// Restore selection using text offset approach
if (redoState.selection) {
setTimeout(() => {
this.#restoreTextSelection(redoState.selection.startOffset, redoState.selection.endOffset);
}, 0);
} else {
// Just focus the editor if no selection info
setTimeout(() => {
this.#element.focus();
}, 0);
}
// Notify that content changed
this.#changeController.notifyImmediately();
this.#notifyLayout(LayoutType.FULL, null);
} catch (error) {
console.error("Failed to perform redo:", error);
}
}
/** /**
* Disposes everything. * Disposes everything.
*/ */
@@ -573,6 +875,9 @@ export class TextEditor extends EventTarget {
this.#selectionController.dispose(); this.#selectionController.dispose();
this.#selectionController = null; this.#selectionController = null;
removeEventListeners(this.#element, this.#events); removeEventListeners(this.#element, this.#events);
this.#element.removeEventListener("keydown", this.#onDocumentKeyDown, true);
this.#undoHistory = [];
this.#redoHistory = [];
this.#element = null; this.#element = null;
this.#root = null; this.#root = null;
} }