diff --git a/ts/lib/sveltelib/input-handler.ts b/ts/lib/sveltelib/input-handler.ts index 0e31aa9a3..3ed7d7910 100644 --- a/ts/lib/sveltelib/input-handler.ts +++ b/ts/lib/sveltelib/input-handler.ts @@ -7,6 +7,7 @@ import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from "@tslib/keys"; import { singleCallback } from "@tslib/typing"; import { HandlerList } from "./handler-list"; +import { UndoManager } from "./undo-manager"; const nbsp = "\xa0"; @@ -46,6 +47,39 @@ export interface InputHandlerAPI { readonly specialKey: HandlerList; } +function getCaretPosition(element: Element){ + const selection = getSelection(element)!; + const range = getRange(selection); + if(!range) return 0; + + let startNode = range.startContainer; + let startOffset = range.startOffset; + + if(!range.collapsed){ + if(selection.anchorNode) startNode = selection.anchorNode; + startOffset = selection.anchorOffset; + } + + if(startNode.nodeType == Node.TEXT_NODE){ + 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++; + } + counter += startOffset; + return counter; + } else { + let counter = 0; + for(let i = 0; i < startOffset; i++){ + const node = element.childNodes[i]; + if(node.textContent) counter += node.textContent.length; + if(node.nodeType !== Node.TEXT_NODE) counter++; + } + return counter; + } +} + /** * An interface that allows Svelte components to attach event listeners via triggers. * They will be attached to the component(s) that install the manager. @@ -56,6 +90,7 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { const beforeInput = new HandlerList(); const insertText = new HandlerList(); const afterInput = new HandlerList(); + const undoManager = new UndoManager(); async function onBeforeInput(this: Element, event: InputEvent): Promise { const selection = getSelection(this)!; @@ -63,6 +98,9 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { await beforeInput.dispatch({ event }); + const position = getCaretPosition(this); + undoManager.register(this, position); + if ( !range || !event.inputType.startsWith("insert") @@ -91,6 +129,8 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { } async function onInput(this: Element, event: Event): Promise { + const position = getCaretPosition(this); + undoManager.register(this, position); await afterInput.dispatch({ event }); } @@ -121,6 +161,17 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] { } else if (event.code === "Tab") { specialKey.dispatch({ event, action: "tab" }); } + else if((event.ctrlKey || event.metaKey) && event.key == "z"){ + event.preventDefault(); + undoManager.undo(this); + } + } + + async function onPaste(this: Element, event: ClipboardEvent): Promise { + const position = getCaretPosition(this); + //Wait for paste event to be done + setTimeout(() => {}, 0); + undoManager.register(this, position); } function setupHandler(element: HTMLElement): { destroy(): void } { @@ -130,6 +181,7 @@ 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 new file mode 100644 index 000000000..0dd4f9ffd --- /dev/null +++ b/ts/lib/sveltelib/undo-manager.ts @@ -0,0 +1,82 @@ +import { getRange, getSelection } from "@tslib/cross-browser"; + +type State = { + content: string; + position: number; +} + +export class UndoManager { + private stack: State[] = []; + private isUpdating: boolean = false; + private lastPosition: number = 0; + public register = this.debounce(this.push, 700, (position: number) => this.lastPosition = position); + + private push(element: Element): void { + if(this.isUpdating) return; + if (this.stack.length > 0 && this.stack[this.stack.length-1].content === element.innerHTML) return; + + const state = {content: element.innerHTML, position: this.lastPosition} + this.stack.push(state); + } + + public undo(element: Element): void{ + this.isUpdating = true; + this.stack.pop(); + + let last: State; + if(this.stack.length <= 0) last = {content: "", position: this.lastPosition}; + else last = this.stack[this.stack.length-1]; + element.innerHTML = last.content; + + const selection = getSelection(element)!; + let range = getRange(selection); + + let counter = this.lastPosition; + let nodeFound: Node | null = null; + let nodeOffset = 0; + for(const node of element.childNodes){ + let nodeLength = node.textContent?.length || 0; + if (counter <= nodeLength) { + nodeFound = node; + nodeOffset = counter; + break; + } + if(node.nodeType !== Node.TEXT_NODE) counter--; + counter -= nodeLength; + } + if(!range){ + this.isUpdating = false; + return; + } + if(!nodeFound){ + if(element.lastChild) range?.setStart(element.lastChild as Node, (element.lastChild?.textContent?.length || 0)) + range.collapse(true); + selection.removeAllRanges() + selection.addRange(range); + this.isUpdating = false; + return; + } + let finalOffset = Math.min(nodeOffset, nodeFound.textContent?.length || 0); + range.setStart(nodeFound, finalOffset); + range.collapse(true); + selection.removeAllRanges() + selection.addRange(range); + + if(this.stack.length > 0) this.lastPosition = this.stack[this.stack.length-1].position; + this.isUpdating = false; + } + + private debounce(func: Function, delay: number, onTransactionStart: Function): Function { + let timeout; + return (...args) => { + const isNewTransaction = timeout === undefined; + clearTimeout(timeout); + if(isNewTransaction) onTransactionStart.call(this, args[1]); + + timeout = setTimeout(() => { + func.call(this, args[0]); + timeout = undefined; + }, delay); + }; + } +} \ No newline at end of file