-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTextWriter.js
More file actions
269 lines (235 loc) · 7.65 KB
/
Copy pathTextWriter.js
File metadata and controls
269 lines (235 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
/**
* TextWriter — simulates human-like text editing on a DOM element.
*
* Algorithm:
* 1. Find the longest common subsequence (LCS) between current and target text.
* 2. Walk from the current cursor position toward the nearest mismatch.
* 3. Delete surplus characters, then type the missing ones.
* 4. Repeat until the element matches the target.
*/
export class TextWriter {
// ── Tuneable timings ────────────────────────────────────────────────────────
writingSpeed = 180; // ms per character typed
deletingSpeed = 120; // ms per character deleted (backspace feels faster)
movingSpeed = 50; // ms per cursor step
thinkTime = 300; // ms pause between moving and typing
// ── Internal state ──────────────────────────────────────────────────────────
#element = null;
#targetText = "";
#cursorPos = 0;
#running = false;
constructor(element) {
this.#element = element;
this.#injectStyles();
this.#element.classList.add("textWriter");
}
// ── Public API ───────────────────────────────────────────────────────────────
/** Clear cursor animation and position. */
clearCursor() {
this.#element.removeAttribute("cursor");
this.#cursorPos = 0;
}
/** Animate the element text toward the new target. Resolves when done. */
async write(targetText) {
this.#targetText = targetText?.toUpperCase();
if (this.#running) return;
this.#running = true;
try {
await this.#applyEdits();
} finally {
this.#running = false;
}
}
// ── Core algorithm ───────────────────────────────────────────────────────────
async #applyEdits() {
this.#element.setAttribute("cursor", "typing");
let timedOut = false;
const timeout = setTimeout(() => {
this.#element.textContent = this.#targetText;
timedOut = true;
}, 8000);
while (this.#currentText !== this.#targetText && !timedOut) {
const edit = this.#findNextEdit();
if (!edit) break;
await this.#moveCursorTo(edit.position);
await this.#pause(this.thinkTime);
if (edit.type === "delete") {
await this.#deleteChars(edit.count);
} else {
await this.#typeChars(edit.chars);
}
}
clearTimeout(timeout);
this.#element.setAttribute("cursor", "waiting");
}
/**
* Computes the next atomic edit (delete | insert) closest to the cursor.
*
* Strategy:
* - Build the LCS of current vs. target to find which characters are "kept".
* - Walk the diff to collect all pending deletions and insertions (with their
* positions in the current string).
* - Return whichever pending edit position is nearest to #cursorPos.
*/
#findNextEdit() {
const cur = this.#currentText;
const tgt = this.#targetText;
if (cur === tgt) return null;
const diff = this.#computeDiff(cur, tgt);
// Gather contiguous runs of deletions/insertions
const edits = [];
let i = 0; // index into `cur` as we walk the diff
let j = 0;
while (j < diff.length) {
const op = diff[j];
if (op.type === "keep") {
i += op.text.length;
j++;
} else if (op.type === "delete") {
edits.push({
type: "delete",
position: i + op.text.length,
count: op.text.length,
});
i += op.text.length;
j++;
} else if (op.type === "insert") {
edits.push({ type: "insert", position: i, chars: op.text });
j++;
}
}
if (edits.length === 0) return null;
// Pick the edit whose relevant cursor position is nearest to #cursorPos
return edits.reduce((best, e) => {
const pos = e.type === "delete" ? e.position : e.position;
const bestPos = best.type === "delete" ? best.position : best.position;
return Math.abs(pos - this.#cursorPos) <
Math.abs(bestPos - this.#cursorPos)
? e
: best;
});
}
/**
* Classic linear-space LCS-based diff.
* Returns an array of { type: "keep"|"delete"|"insert", text: string }.
*/
#computeDiff(a, b) {
// DP table — only store two rows
const m = a.length,
n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] =
a[i] === b[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
// Trace back
const ops = [];
let i = 0,
j = 0;
while (i < m || j < n) {
if (i < m && j < n && a[i] === b[j]) {
this.#appendOp(ops, "keep", a[i]);
i++;
j++;
} else if (j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j])) {
this.#appendOp(ops, "insert", b[j]);
j++;
} else {
this.#appendOp(ops, "delete", a[i]);
i++;
}
}
return ops;
}
/** Append a character to the last op if same type, else push new op. */
#appendOp(ops, type, char) {
if (ops.length && ops[ops.length - 1].type === type) {
ops[ops.length - 1].text += char;
} else {
ops.push({ type, text: char });
}
}
// ── Animated primitives ──────────────────────────────────────────────────────
async #moveCursorTo(target) {
const step = target > this.#cursorPos ? 1 : -1;
while (this.#cursorPos !== target) {
this.#cursorPos += step;
this.#render();
await this.#pause(this.movingSpeed);
}
}
async #deleteChars(count) {
for (let k = 0; k < count; k++) {
if (this.#cursorPos === 0) break;
const t = this.#currentText;
this.#element.textContent =
t.slice(0, this.#cursorPos - 1) + t.slice(this.#cursorPos);
this.#cursorPos--;
this.#render();
await this.#pause(this.deletingSpeed);
}
}
async #typeChars(chars) {
for (const ch of chars) {
const t = this.#currentText;
this.#element.textContent =
t.slice(0, this.#cursorPos) + ch + t.slice(this.#cursorPos);
this.#cursorPos++;
this.#render();
await this.#pause(this.writingSpeed);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
get #currentText() {
return this.#element.textContent.toUpperCase();
}
/** Re-render the text with a visible cursor caret. */
#render() {
this.#element.setAttribute(
"before-cursor",
this.#currentText.slice(0, this.#cursorPos),
);
}
#pause(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// ── Styles ─────────────────────────────────────────────────────────────────
/** Call once to inject CSS for cursor styling. */
#injectStyles() {
const styleSheet = document.createElement("style");
styleSheet.textContent = `
.textWriter::before {
content: attr(before-cursor);
position: absolute;
border-right: solid 0.075rem transparent;
animation: none;
color: transparent;
height: 1em;
align-self: center;
}
.textWriter[cursor="waiting"]::before {
animation: blink 1s infinite;
border-color: white;
}
.textWriter[cursor="typing"]::before {
animation: none;
border-color: white;
}
@keyframes blink {
0%,
50% {
border-color: white;
}
51%,
100% {
border-color: transparent;
}
}
`;
document.head.appendChild(styleSheet);
}
}