From e3e46764d7b057fb2f17ee17f3f2f918536c8299 Mon Sep 17 00:00:00 2001 From: fcolona Date: Fri, 18 Jul 2025 18:07:42 -0300 Subject: [PATCH] make undo/redo work with formatting --- ts/lib/sveltelib/input-handler.ts | 52 +++++++++++++++++++++---------- ts/lib/sveltelib/undo-manager.ts | 17 +++++++--- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/ts/lib/sveltelib/input-handler.ts b/ts/lib/sveltelib/input-handler.ts index 867c75ee9..a24a9338e 100644 --- a/ts/lib/sveltelib/input-handler.ts +++ b/ts/lib/sveltelib/input-handler.ts @@ -47,6 +47,17 @@ export interface InputHandlerAPI { readonly specialKey: HandlerList; } +export function getMaxOffset(node: Node) { + if (node.nodeType === Node.TEXT_NODE) { + if(!node.textContent) return 0; + return node.textContent.length; + } else if (node.nodeType === Node.ELEMENT_NODE) { + return node.childNodes.length; + } + return 0; +} + + function getCaretPosition(element: Element){ const selection = getSelection(element)!; const range = getRange(selection); @@ -64,17 +75,17 @@ function getCaretPosition(element: Element){ let counter = 0; for(const node of element.childNodes){ if(node === startNode) break; - if(node.textContent) counter += node.textContent.length; - if(node.nodeType !== Node.TEXT_NODE) counter++; + if(node.textContent && node.nodeType == Node.TEXT_NODE) counter += node.textContent.length; + if(node.nodeName === "BR") counter++; } counter += startOffset; return counter; } else { let counter = 0; - for(let i = 0; i < startOffset; i++){ + for(let i = 0; (i < startOffset) && (i < element.childNodes.length); i++){ const node = element.childNodes[i]; - if(node.textContent) counter += node.textContent.length; - if(node.nodeType !== Node.TEXT_NODE) counter++; + if(node.textContent && node.nodeType == Node.TEXT_NODE) counter += node.textContent.length; + if(node.nodeName === "BR") counter++; } return counter; } @@ -90,7 +101,20 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { const beforeInput = new HandlerList(); const insertText = new HandlerList(); const afterInput = new HandlerList(); + const undoManager = new UndoManager(); + let hasSetupObserver = false; + const config = { + attributes: true, + childList: true, + subtree: true + }; + const observer = new MutationObserver(onMutation); + + function onMutation(mutationsList: MutationRecord[], observer){ + const element = mutationsList[0].target; + undoManager.register(element.innerHTML, getMaxOffset(element)); + } async function onBeforeInput(this: Element, event: InputEvent): Promise { const selection = getSelection(this)!; @@ -98,9 +122,6 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { await beforeInput.dispatch({ event }); - const position = getCaretPosition(this); - undoManager.register(this.innerHTML, position); - if ( !range || !event.inputType.startsWith("insert") @@ -129,8 +150,13 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { } async function onInput(this: Element, event: Event): Promise { + if(!hasSetupObserver) { + observer.observe(this, config); + hasSetupObserver = true; + } const position = getCaretPosition(this); - undoManager.register(this.innerHTML, position); + undoManager.register(this.innerHTML, position-1); + undoManager.clearRedoStack(); await afterInput.dispatch({ event }); } @@ -171,13 +197,6 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { } } - async function onPaste(this: Element, event: ClipboardEvent): Promise { - const position = getCaretPosition(this); - //Wait for paste event to be done - setTimeout(() => {}, 0); - undoManager.register(this.innerHTML, position); - } - function setupHandler(element: HTMLElement): { destroy(): void } { const destroy = singleCallback( on(element, "beforeinput", onBeforeInput), @@ -185,7 +204,6 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { on(element, "blur", clearInsertText), on(element, "pointerdown", onPointerDown), on(element, "keydown", onKeyDown), - on(element, "paste", onPaste), on(document, "selectionchange", clearInsertText), ); diff --git a/ts/lib/sveltelib/undo-manager.ts b/ts/lib/sveltelib/undo-manager.ts index 7dff4f38a..c8ebdb59d 100644 --- a/ts/lib/sveltelib/undo-manager.ts +++ b/ts/lib/sveltelib/undo-manager.ts @@ -1,4 +1,5 @@ import { getRange, getSelection } from "@tslib/cross-browser"; +import { getMaxOffset } from "./input-handler"; type State = { content: string; @@ -10,9 +11,14 @@ export class UndoManager { private redoStack: State[] = []; private isUpdating: boolean = false; private transactionStart: number = 0; - public register = this.debounce(this.pushToUndo, 700, (position: number) => this.transactionStart = position); + public register = this.debounce(this.pushToUndo, 500, (position: number) => this.transactionStart = position); - private pushToUndo(content: string): void { + public clearRedoStack(){ + if(this.isUpdating) return; + this.redoStack = []; + } + + public pushToUndo(content: string): void { if(this.isUpdating) return; if (this.undoStack.length > 0 && this.undoStack[this.undoStack.length-1].content === content) return; @@ -66,6 +72,7 @@ export class UndoManager { return; } let finalOffset = Math.min(nodeOffset, nodeFound.textContent?.length || 0); + if(finalOffset > getMaxOffset(nodeFound)) finalOffset = getMaxOffset(nodeFound); range.setStart(nodeFound, finalOffset); range.collapse(true); selection.removeAllRanges() @@ -97,7 +104,7 @@ export class UndoManager { nodeOffset = counter; break; } - if(node.nodeType !== Node.TEXT_NODE) counter--; + if(node.nodeName === "BR") counter--; counter -= nodeLength; } if(!range){ @@ -113,6 +120,7 @@ export class UndoManager { return; } let finalOffset = Math.min(nodeOffset, nodeFound.textContent?.length || 0); + if(finalOffset > getMaxOffset(nodeFound)) finalOffset = getMaxOffset(nodeFound); range.setStart(nodeFound, finalOffset); range.collapse(true); selection.removeAllRanges() @@ -127,10 +135,9 @@ export class UndoManager { return (...args) => { const isNewTransaction = timeout === undefined; clearTimeout(timeout); - if(isNewTransaction) onTransactionStart.call(this, args[1]); + if(isNewTransaction) onTransactionStart.call(this, args[1]) timeout = setTimeout(() => { - this.redoStack = []; func.call(this, args[0]); timeout = undefined; }, delay);