import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
import {MarkdownEditorShortcutMap} from "../shortcuts";
import {MarkdownEditorEventMap} from "../dom-handlers";
+import {debounce} from "../../services/util";
+type UndoStackEntry = {
+ content: string;
+ selection: MarkdownEditorInputSelection;
+}
+
+class UndoStack {
+ protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
+
+ protected stack: UndoStackEntry[] = [];
+ protected pointer: number = -1;
+ protected lastActionTime: number = 0;
+
+ constructor() {
+ this.onChangeDebounced = debounce(this.onChange, 1000, false);
+ }
+
+ undo(): UndoStackEntry|null {
+ if (this.pointer < 1) {
+ return null;
+ }
+
+ this.lastActionTime = Date.now();
+ this.pointer -= 1;
+ return this.stack[this.pointer];
+ }
+
+ redo(): UndoStackEntry|null {
+ const atEnd = this.pointer === this.stack.length - 1;
+ if (atEnd) {
+ return null;
+ }
+
+ this.lastActionTime = Date.now();
+ this.pointer++;
+ return this.stack[this.pointer];
+ }
+
+ push(getValueCallback: () => UndoStackEntry): void {
+ // Ignore changes made via undo/redo actions
+ if (Date.now() - this.lastActionTime < 100) {
+ return;
+ }
+
+ this.onChangeDebounced(getValueCallback);
+ }
+
+ protected onChange(getValueCallback: () => UndoStackEntry) {
+ // Trim the end of the stack from the pointer since we're branching away
+ if (this.pointer !== this.stack.length - 1) {
+ this.stack = this.stack.slice(0, this.pointer)
+ }
+
+ this.stack.push(getValueCallback());
+
+ // Limit stack size
+ if (this.stack.length > 50) {
+ this.stack = this.stack.slice(this.stack.length - 50);
+ }
+
+ this.pointer = this.stack.length - 1;
+ }
+}
export class TextareaInput implements MarkdownEditorInput {
protected input: HTMLTextAreaElement;
protected shortcuts: MarkdownEditorShortcutMap;
protected events: MarkdownEditorEventMap;
+ protected onChange: () => void;
+ protected eventController = new AbortController();
+ protected undoStack = new UndoStack();
- constructor(input: HTMLTextAreaElement, shortcuts: MarkdownEditorShortcutMap, events: MarkdownEditorEventMap) {
+ protected textSizeCache: {x: number; y: number}|null = null;
+
+ constructor(
+ input: HTMLTextAreaElement,
+ shortcuts: MarkdownEditorShortcutMap,
+ events: MarkdownEditorEventMap,
+ onChange: () => void
+ ) {
this.input = input;
this.shortcuts = shortcuts;
this.events = events;
+ this.onChange = onChange;
this.onKeyDown = this.onKeyDown.bind(this);
+ this.configureLocalShortcuts();
this.configureListeners();
+
+ this.input.style.removeProperty("display");
+ this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
+ }
+
+ teardown() {
+ this.eventController.abort('teardown');
+ }
+
+ configureLocalShortcuts(): void {
+ this.shortcuts['Mod-z'] = () => {
+ const undoEntry = this.undoStack.undo();
+ if (undoEntry) {
+ this.setText(undoEntry.content);
+ this.setSelection(undoEntry.selection, false);
+ }
+ };
+ this.shortcuts['Mod-y'] = () => {
+ const redoContent = this.undoStack.redo();
+ if (redoContent) {
+ this.setText(redoContent.content);
+ this.setSelection(redoContent.selection, false);
+ }
+ }
}
configureListeners(): void {
- // TODO - Teardown handling
- this.input.addEventListener('keydown', this.onKeyDown);
+ // Keyboard shortcuts
+ this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
+ // Shared event listeners
for (const [name, listener] of Object.entries(this.events)) {
- this.input.addEventListener(name, listener);
+ this.input.addEventListener(name, listener, {signal: this.eventController.signal});
}
+
+ // Input change handling
+ this.input.addEventListener('input', () => {
+ this.onChange();
+ this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
+ }, {signal: this.eventController.signal});
}
onKeyDown(e: KeyboardEvent) {
const isApple = navigator.platform.startsWith("Mac") || navigator.platform === "iPhone";
+ const key = e.key.length > 1 ? e.key : e.key.toLowerCase();
const keyParts = [
e.shiftKey ? 'Shift' : null,
isApple && e.metaKey ? 'Mod' : null,
!isApple && e.ctrlKey ? 'Mod' : null,
- e.key,
+ key,
];
const keyString = keyParts.filter(Boolean).join('-');
appendText(text: string): void {
this.input.value += `\n${text}`;
+ this.input.dispatchEvent(new Event('input'));
}
- coordsToSelection(x: number, y: number): MarkdownEditorInputSelection {
- // TODO
- return this.getSelection();
+ eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
+ const eventCoords = this.mouseEventToTextRelativeCoords(event);
+ return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
}
focus(): void {
let lineStart = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
- const newEnd = lineStart + line.length + 1;
- if (position < newEnd) {
- return {from: lineStart, to: newEnd};
+ const lineEnd = lineStart + line.length;
+ if (position <= lineEnd) {
+ return {from: lineStart, to: lineEnd};
}
- lineStart = newEnd;
+ lineStart = lineEnd + 1;
}
return {from: 0, to: 0};
getTextAboveView(): string {
const scrollTop = this.input.scrollTop;
- const computedStyles = window.getComputedStyle(this.input);
- const lines = this.getText().split('\n');
- const paddingTop = Number(computedStyles.paddingTop.replace('px', ''));
- const paddingBottom = Number(computedStyles.paddingBottom.replace('px', ''));
-
- const avgLineHeight = (this.input.scrollHeight - paddingBottom - paddingTop) / lines.length;
- const roughLinePos = Math.max(Math.floor((scrollTop - paddingTop) / avgLineHeight), 0);
- const linesAbove = this.getText().split('\n').slice(0, roughLinePos);
- return linesAbove.join('\n');
+ const selection = this.inputPositionToSelection(0, scrollTop);
+ return this.getSelectionText({from: 0, to: selection.to});
}
searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
setText(text: string, selection?: MarkdownEditorInputSelection): void {
this.input.value = text;
+ this.input.dispatchEvent(new Event('input'));
if (selection) {
this.setSelection(selection, false);
}
this.setSelection(newSelection, false);
}
}
+
+ protected measureTextSize(): {x: number; y: number} {
+ if (this.textSizeCache) {
+ return this.textSizeCache;
+ }
+
+ const el = document.createElement("div");
+ el.textContent = `a\nb`;
+ const inputStyles = window.getComputedStyle(this.input)
+ el.style.font = inputStyles.font;
+ el.style.lineHeight = inputStyles.lineHeight;
+ el.style.padding = '0px';
+ el.style.display = 'inline-block';
+ el.style.visibility = 'hidden';
+ el.style.position = 'absolute';
+ el.style.whiteSpace = 'pre';
+ this.input.after(el);
+
+ const bounds = el.getBoundingClientRect();
+ el.remove();
+ this.textSizeCache = {
+ x: bounds.width,
+ y: bounds.height / 2,
+ };
+ return this.textSizeCache;
+ }
+
+ protected measureLineCharCount(textWidth: number): number {
+ const inputStyles = window.getComputedStyle(this.input);
+ const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+ const paddingRight = Number(inputStyles.paddingRight.replace('px', ''));
+ const width = Number(inputStyles.width.replace('px', ''));
+ const textSpace = width - (paddingLeft + paddingRight);
+
+ return Math.floor(textSpace / textWidth);
+ }
+
+ protected mouseEventToTextRelativeCoords(event: MouseEvent): {x: number; y: number} {
+ const inputBounds = this.input.getBoundingClientRect();
+ const inputStyles = window.getComputedStyle(this.input);
+ const paddingTop = Number(inputStyles.paddingTop.replace('px', ''));
+ const paddingLeft = Number(inputStyles.paddingLeft.replace('px', ''));
+
+ const xPos = Math.max(event.clientX - (inputBounds.left + paddingLeft), 0);
+ const yPos = Math.max((event.clientY - (inputBounds.top + paddingTop)) + this.input.scrollTop, 0);
+
+ return {x: xPos, y: yPos};
+ }
+
+ protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
+ const textSize = this.measureTextSize();
+ const lineWidth = this.measureLineCharCount(textSize.x);
+
+ const lines = this.getText().split('\n');
+
+ let currY = 0;
+ let currPos = 0;
+ for (const line of lines) {
+ let linePos = 0;
+ const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
+ for (let i = 0; i < wrapCount; i++) {
+ currY += textSize.y;
+ if (currY > y) {
+ const targetX = Math.floor(x / textSize.x);
+ const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
+ return {from: maxPos, to: maxPos};
+ }
+
+ linePos += lineWidth;
+ }
+
+ currPos += line.length + 1;
+ }
+
+ return this.getSelection();
+ }
}
\ No newline at end of file