X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/6b4b500a3313f30c92a8a6ffa1d8427fdf4d2aaa..HEAD:/resources/js/markdown/inputs/textarea.ts diff --git a/resources/js/markdown/inputs/textarea.ts b/resources/js/markdown/inputs/textarea.ts index d1eabd270..e0c3ac37c 100644 --- a/resources/js/markdown/inputs/textarea.ts +++ b/resources/js/markdown/inputs/textarea.ts @@ -1,39 +1,146 @@ 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('-'); @@ -45,11 +152,12 @@ export class TextareaInput implements MarkdownEditorInput { 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 { @@ -61,11 +169,11 @@ export class TextareaInput implements MarkdownEditorInput { 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}; @@ -93,15 +201,8 @@ export class TextareaInput implements MarkdownEditorInput { 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 { @@ -120,6 +221,7 @@ export class TextareaInput implements MarkdownEditorInput { setText(text: string, selection?: MarkdownEditorInputSelection): void { this.input.value = text; + this.input.dispatchEvent(new Event('input')); if (selection) { this.setSelection(selection, false); } @@ -134,4 +236,80 @@ export class TextareaInput implements MarkdownEditorInput { 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