make undo/redo work with formatting

This commit is contained in:
fcolona 2025-07-18 18:07:42 -03:00
parent 9cef20df22
commit e3e46764d7
2 changed files with 47 additions and 22 deletions

View file

@ -47,6 +47,17 @@ export interface InputHandlerAPI {
readonly specialKey: HandlerList<SpecialKeyParams>;
}
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<InputEventParams>();
const insertText = new HandlerList<InsertTextParams>();
const afterInput = new HandlerList<EventParams>();
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 = <Element>mutationsList[0].target;
undoManager.register(element.innerHTML, getMaxOffset(element));
}
async function onBeforeInput(this: Element, event: InputEvent): Promise<void> {
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<void> {
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<void> {
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),
);

View file

@ -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);