mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-30 04:23:11 +03:00
MD Editor: Added custom textarea undo/redo, updated positioning methods
This commit is contained in:
@ -1,7 +1,70 @@
|
|||||||
import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
|
import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
|
||||||
import {MarkdownEditorShortcutMap} from "../shortcuts";
|
import {MarkdownEditorShortcutMap} from "../shortcuts";
|
||||||
import {MarkdownEditorEventMap} from "../dom-handlers";
|
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 {
|
export class TextareaInput implements MarkdownEditorInput {
|
||||||
|
|
||||||
@ -10,6 +73,7 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
protected events: MarkdownEditorEventMap;
|
protected events: MarkdownEditorEventMap;
|
||||||
protected onChange: () => void;
|
protected onChange: () => void;
|
||||||
protected eventController = new AbortController();
|
protected eventController = new AbortController();
|
||||||
|
protected undoStack = new UndoStack();
|
||||||
|
|
||||||
protected textSizeCache: {x: number; y: number}|null = null;
|
protected textSizeCache: {x: number; y: number}|null = null;
|
||||||
|
|
||||||
@ -25,17 +89,34 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
this.onChange = onChange;
|
this.onChange = onChange;
|
||||||
|
|
||||||
this.onKeyDown = this.onKeyDown.bind(this);
|
this.onKeyDown = this.onKeyDown.bind(this);
|
||||||
|
this.configureLocalShortcuts();
|
||||||
this.configureListeners();
|
this.configureListeners();
|
||||||
|
|
||||||
// TODO - Undo/Redo
|
|
||||||
|
|
||||||
this.input.style.removeProperty("display");
|
this.input.style.removeProperty("display");
|
||||||
|
this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
|
||||||
}
|
}
|
||||||
|
|
||||||
teardown() {
|
teardown() {
|
||||||
this.eventController.abort('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 {
|
configureListeners(): void {
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
|
this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
|
||||||
@ -48,15 +129,8 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
// Input change handling
|
// Input change handling
|
||||||
this.input.addEventListener('input', () => {
|
this.input.addEventListener('input', () => {
|
||||||
this.onChange();
|
this.onChange();
|
||||||
|
this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
|
||||||
}, {signal: this.eventController.signal});
|
}, {signal: this.eventController.signal});
|
||||||
|
|
||||||
this.input.addEventListener('click', (event: MouseEvent) => {
|
|
||||||
const x = event.clientX;
|
|
||||||
const y = event.clientY;
|
|
||||||
const range = this.eventToPosition(event);
|
|
||||||
const text = this.getText().split('');
|
|
||||||
console.log(range, text.slice(0, 20));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown(e: KeyboardEvent) {
|
onKeyDown(e: KeyboardEvent) {
|
||||||
@ -83,33 +157,7 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
|
|
||||||
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
|
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
|
||||||
const eventCoords = this.mouseEventToTextRelativeCoords(event);
|
const eventCoords = this.mouseEventToTextRelativeCoords(event);
|
||||||
const textSize = this.measureTextSize();
|
return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
|
||||||
const lineWidth = this.measureLineCharCount(textSize.x);
|
|
||||||
|
|
||||||
const lines = this.getText().split('\n');
|
|
||||||
|
|
||||||
// TODO - Check this
|
|
||||||
|
|
||||||
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 > eventCoords.y) {
|
|
||||||
const targetX = Math.floor(eventCoords.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
focus(): void {
|
||||||
@ -153,15 +201,8 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
|
|
||||||
getTextAboveView(): string {
|
getTextAboveView(): string {
|
||||||
const scrollTop = this.input.scrollTop;
|
const scrollTop = this.input.scrollTop;
|
||||||
const computedStyles = window.getComputedStyle(this.input);
|
const selection = this.inputPositionToSelection(0, scrollTop);
|
||||||
const lines = this.getText().split('\n');
|
return this.getSelectionText({from: 0, to: selection.to});
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
|
searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
|
||||||
@ -243,4 +284,32 @@ export class TextareaInput implements MarkdownEditorInput {
|
|||||||
|
|
||||||
return {x: xPos, y: yPos};
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
@ -64,6 +64,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
border: 0;
|
border: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user