Skip to content

Commit 7ca8bdc

Browse files
committed
MD Editor: Added custom textarea undo/redo, updated positioning methods
1 parent 6621d55 commit 7ca8bdc

File tree

2 files changed

+116
-46
lines changed

2 files changed

+116
-46
lines changed

resources/js/markdown/inputs/textarea.ts

Lines changed: 115 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,70 @@
11
import {MarkdownEditorInput, MarkdownEditorInputSelection} from "./interface";
22
import {MarkdownEditorShortcutMap} from "../shortcuts";
33
import {MarkdownEditorEventMap} from "../dom-handlers";
4+
import {debounce} from "../../services/util";
45

6+
type UndoStackEntry = {
7+
content: string;
8+
selection: MarkdownEditorInputSelection;
9+
}
10+
11+
class UndoStack {
12+
protected onChangeDebounced: (callback: () => UndoStackEntry) => void;
13+
14+
protected stack: UndoStackEntry[] = [];
15+
protected pointer: number = -1;
16+
protected lastActionTime: number = 0;
17+
18+
constructor() {
19+
this.onChangeDebounced = debounce(this.onChange, 1000, false);
20+
}
21+
22+
undo(): UndoStackEntry|null {
23+
if (this.pointer < 1) {
24+
return null;
25+
}
26+
27+
this.lastActionTime = Date.now();
28+
this.pointer -= 1;
29+
return this.stack[this.pointer];
30+
}
31+
32+
redo(): UndoStackEntry|null {
33+
const atEnd = this.pointer === this.stack.length - 1;
34+
if (atEnd) {
35+
return null;
36+
}
37+
38+
this.lastActionTime = Date.now();
39+
this.pointer++;
40+
return this.stack[this.pointer];
41+
}
42+
43+
push(getValueCallback: () => UndoStackEntry): void {
44+
// Ignore changes made via undo/redo actions
45+
if (Date.now() - this.lastActionTime < 100) {
46+
return;
47+
}
48+
49+
this.onChangeDebounced(getValueCallback);
50+
}
51+
52+
protected onChange(getValueCallback: () => UndoStackEntry) {
53+
// Trim the end of the stack from the pointer since we're branching away
54+
if (this.pointer !== this.stack.length - 1) {
55+
this.stack = this.stack.slice(0, this.pointer)
56+
}
57+
58+
this.stack.push(getValueCallback());
59+
60+
// Limit stack size
61+
if (this.stack.length > 50) {
62+
this.stack = this.stack.slice(this.stack.length - 50);
63+
}
64+
65+
this.pointer = this.stack.length - 1;
66+
}
67+
}
568

669
export class TextareaInput implements MarkdownEditorInput {
770

@@ -10,6 +73,7 @@ export class TextareaInput implements MarkdownEditorInput {
1073
protected events: MarkdownEditorEventMap;
1174
protected onChange: () => void;
1275
protected eventController = new AbortController();
76+
protected undoStack = new UndoStack();
1377

1478
protected textSizeCache: {x: number; y: number}|null = null;
1579

@@ -25,17 +89,34 @@ export class TextareaInput implements MarkdownEditorInput {
2589
this.onChange = onChange;
2690

2791
this.onKeyDown = this.onKeyDown.bind(this);
92+
this.configureLocalShortcuts();
2893
this.configureListeners();
2994

30-
// TODO - Undo/Redo
31-
3295
this.input.style.removeProperty("display");
96+
this.undoStack.push(() => ({content: this.getText(), selection: this.getSelection()}));
3397
}
3498

3599
teardown() {
36100
this.eventController.abort('teardown');
37101
}
38102

103+
configureLocalShortcuts(): void {
104+
this.shortcuts['Mod-z'] = () => {
105+
const undoEntry = this.undoStack.undo();
106+
if (undoEntry) {
107+
this.setText(undoEntry.content);
108+
this.setSelection(undoEntry.selection, false);
109+
}
110+
};
111+
this.shortcuts['Mod-y'] = () => {
112+
const redoContent = this.undoStack.redo();
113+
if (redoContent) {
114+
this.setText(redoContent.content);
115+
this.setSelection(redoContent.selection, false);
116+
}
117+
}
118+
}
119+
39120
configureListeners(): void {
40121
// Keyboard shortcuts
41122
this.input.addEventListener('keydown', this.onKeyDown, {signal: this.eventController.signal});
@@ -48,15 +129,8 @@ export class TextareaInput implements MarkdownEditorInput {
48129
// Input change handling
49130
this.input.addEventListener('input', () => {
50131
this.onChange();
132+
this.undoStack.push(() => ({content: this.input.value, selection: this.getSelection()}));
51133
}, {signal: this.eventController.signal});
52-
53-
this.input.addEventListener('click', (event: MouseEvent) => {
54-
const x = event.clientX;
55-
const y = event.clientY;
56-
const range = this.eventToPosition(event);
57-
const text = this.getText().split('');
58-
console.log(range, text.slice(0, 20));
59-
});
60134
}
61135

62136
onKeyDown(e: KeyboardEvent) {
@@ -83,33 +157,7 @@ export class TextareaInput implements MarkdownEditorInput {
83157

84158
eventToPosition(event: MouseEvent): MarkdownEditorInputSelection {
85159
const eventCoords = this.mouseEventToTextRelativeCoords(event);
86-
const textSize = this.measureTextSize();
87-
const lineWidth = this.measureLineCharCount(textSize.x);
88-
89-
const lines = this.getText().split('\n');
90-
91-
// TODO - Check this
92-
93-
let currY = 0;
94-
let currPos = 0;
95-
for (const line of lines) {
96-
let linePos = 0;
97-
const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
98-
for (let i = 0; i < wrapCount; i++) {
99-
currY += textSize.y;
100-
if (currY > eventCoords.y) {
101-
const targetX = Math.floor(eventCoords.x / textSize.x);
102-
const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
103-
return {from: maxPos, to: maxPos};
104-
}
105-
106-
linePos += lineWidth;
107-
}
108-
109-
currPos += line.length + 1;
110-
}
111-
112-
return this.getSelection();
160+
return this.inputPositionToSelection(eventCoords.x, eventCoords.y);
113161
}
114162

115163
focus(): void {
@@ -153,15 +201,8 @@ export class TextareaInput implements MarkdownEditorInput {
153201

154202
getTextAboveView(): string {
155203
const scrollTop = this.input.scrollTop;
156-
const computedStyles = window.getComputedStyle(this.input);
157-
const lines = this.getText().split('\n');
158-
const paddingTop = Number(computedStyles.paddingTop.replace('px', ''));
159-
const paddingBottom = Number(computedStyles.paddingBottom.replace('px', ''));
160-
161-
const avgLineHeight = (this.input.scrollHeight - paddingBottom - paddingTop) / lines.length;
162-
const roughLinePos = Math.max(Math.floor((scrollTop - paddingTop) / avgLineHeight), 0);
163-
const linesAbove = this.getText().split('\n').slice(0, roughLinePos);
164-
return linesAbove.join('\n');
204+
const selection = this.inputPositionToSelection(0, scrollTop);
205+
return this.getSelectionText({from: 0, to: selection.to});
165206
}
166207

167208
searchForLineContaining(text: string): MarkdownEditorInputSelection | null {
@@ -243,4 +284,32 @@ export class TextareaInput implements MarkdownEditorInput {
243284

244285
return {x: xPos, y: yPos};
245286
}
287+
288+
protected inputPositionToSelection(x: number, y: number): MarkdownEditorInputSelection {
289+
const textSize = this.measureTextSize();
290+
const lineWidth = this.measureLineCharCount(textSize.x);
291+
292+
const lines = this.getText().split('\n');
293+
294+
let currY = 0;
295+
let currPos = 0;
296+
for (const line of lines) {
297+
let linePos = 0;
298+
const wrapCount = Math.max(Math.ceil(line.length / lineWidth), 1);
299+
for (let i = 0; i < wrapCount; i++) {
300+
currY += textSize.y;
301+
if (currY > y) {
302+
const targetX = Math.floor(x / textSize.x);
303+
const maxPos = Math.min(currPos + linePos + targetX, currPos + line.length);
304+
return {from: maxPos, to: maxPos};
305+
}
306+
307+
linePos += lineWidth;
308+
}
309+
310+
currPos += line.length + 1;
311+
}
312+
313+
return this.getSelection();
314+
}
246315
}

resources/sass/_forms.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
flex: 1;
6565
border: 0;
6666
width: 100%;
67+
margin: 0;
6768
&:focus {
6869
outline: 0;
6970
}

0 commit comments

Comments
 (0)