diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 92800d21f0..0cb63daea8 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -83,6 +83,27 @@ export class TextEditor extends EventTarget { */ #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. * @@ -158,9 +179,12 @@ export class TextEditor extends EventTarget { if (options.shouldUpdatePositionOnScroll) { window.addEventListener("scroll", this.#onScroll); } + addEventListeners(this.#element, this.#events, { capture: true, }); + + this.#element.addEventListener("keydown", this.#onDocumentKeyDown, true); } /** @@ -177,6 +201,11 @@ export class TextEditor extends EventTarget { options, ); this.#setupListeners(options); + + // Save initial state for undo + setTimeout(() => { + this.#saveUndoState(); + }, 0); } /** @@ -310,6 +339,7 @@ export class TextEditor extends EventTarget { */ #onBeforeInput = (e) => { if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + e.preventDefault(); return; } @@ -331,6 +361,10 @@ export class TextEditor extends EventTarget { if (!this.#selectionController.startMutation()) { return; } + + // Save undo state before making changes + this.#saveUndoState(); + command(e, this, this.#selectionController); const mutations = this.#selectionController.endMutation(); this.#notifyLayout(LayoutType.FULL, mutations); @@ -344,6 +378,7 @@ export class TextEditor extends EventTarget { */ #onInput = (e) => { if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + e.preventDefault(); 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 * @@ -376,6 +447,26 @@ export class TextEditor extends EventTarget { 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") { e.preventDefault(); @@ -383,6 +474,9 @@ export class TextEditor extends EventTarget { return; } + // Save undo state before making changes + this.#saveUndoState(); + if (this.#selectionController.isCollapsed) { this.#selectionController.removeWordBackward(); } else { @@ -559,6 +653,214 @@ export class TextEditor extends EventTarget { 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. */ @@ -573,6 +875,9 @@ export class TextEditor extends EventTarget { this.#selectionController.dispose(); this.#selectionController = null; removeEventListeners(this.#element, this.#events); + this.#element.removeEventListener("keydown", this.#onDocumentKeyDown, true); + this.#undoHistory = []; + this.#redoHistory = []; this.#element = null; this.#root = null; }