mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 15:32:23 -04:00
implement custom undo functionality to input handler
This commit is contained in:
parent
c57b7c496d
commit
c31802ae77
2 changed files with 134 additions and 0 deletions
|
@ -7,6 +7,7 @@ import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from "@tslib/keys";
|
||||||
import { singleCallback } from "@tslib/typing";
|
import { singleCallback } from "@tslib/typing";
|
||||||
|
|
||||||
import { HandlerList } from "./handler-list";
|
import { HandlerList } from "./handler-list";
|
||||||
|
import { UndoManager } from "./undo-manager";
|
||||||
|
|
||||||
const nbsp = "\xa0";
|
const nbsp = "\xa0";
|
||||||
|
|
||||||
|
@ -46,6 +47,39 @@ export interface InputHandlerAPI {
|
||||||
readonly specialKey: HandlerList<SpecialKeyParams>;
|
readonly specialKey: HandlerList<SpecialKeyParams>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* An interface that allows Svelte components to attach event listeners via triggers.
|
||||||
* They will be attached to the component(s) that install the manager.
|
* They will be attached to the component(s) that install the manager.
|
||||||
|
@ -56,6 +90,7 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
|
||||||
const beforeInput = new HandlerList<InputEventParams>();
|
const beforeInput = new HandlerList<InputEventParams>();
|
||||||
const insertText = new HandlerList<InsertTextParams>();
|
const insertText = new HandlerList<InsertTextParams>();
|
||||||
const afterInput = new HandlerList<EventParams>();
|
const afterInput = new HandlerList<EventParams>();
|
||||||
|
const undoManager = new UndoManager();
|
||||||
|
|
||||||
async function onBeforeInput(this: Element, event: InputEvent): Promise<void> {
|
async function onBeforeInput(this: Element, event: InputEvent): Promise<void> {
|
||||||
const selection = getSelection(this)!;
|
const selection = getSelection(this)!;
|
||||||
|
@ -63,6 +98,9 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
|
||||||
|
|
||||||
await beforeInput.dispatch({ event });
|
await beforeInput.dispatch({ event });
|
||||||
|
|
||||||
|
const position = getCaretPosition(this);
|
||||||
|
undoManager.register(this, position);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!range
|
!range
|
||||||
|| !event.inputType.startsWith("insert")
|
|| !event.inputType.startsWith("insert")
|
||||||
|
@ -91,6 +129,8 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onInput(this: Element, event: Event): Promise<void> {
|
async function onInput(this: Element, event: Event): Promise<void> {
|
||||||
|
const position = getCaretPosition(this);
|
||||||
|
undoManager.register(this, position);
|
||||||
await afterInput.dispatch({ event });
|
await afterInput.dispatch({ event });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,6 +161,17 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
|
||||||
} else if (event.code === "Tab") {
|
} else if (event.code === "Tab") {
|
||||||
specialKey.dispatch({ event, action: "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<void> {
|
||||||
|
const position = getCaretPosition(this);
|
||||||
|
//Wait for paste event to be done
|
||||||
|
setTimeout(() => {}, 0);
|
||||||
|
undoManager.register(this, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupHandler(element: HTMLElement): { destroy(): void } {
|
function setupHandler(element: HTMLElement): { destroy(): void } {
|
||||||
|
@ -130,6 +181,7 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
|
||||||
on(element, "blur", clearInsertText),
|
on(element, "blur", clearInsertText),
|
||||||
on(element, "pointerdown", onPointerDown),
|
on(element, "pointerdown", onPointerDown),
|
||||||
on(element, "keydown", onKeyDown),
|
on(element, "keydown", onKeyDown),
|
||||||
|
on(element, "paste", onPaste),
|
||||||
on(document, "selectionchange", clearInsertText),
|
on(document, "selectionchange", clearInsertText),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
82
ts/lib/sveltelib/undo-manager.ts
Normal file
82
ts/lib/sveltelib/undo-manager.ts
Normal file
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue