domotion-svg 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -56,7 +56,18 @@ export interface TypingOverlay {
|
|
|
56
56
|
speed?: number;
|
|
57
57
|
/** Background color to mask placeholder text */
|
|
58
58
|
bgColor?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Field width in px. When set, the typed text WRAPS to this width like a
|
|
61
|
+
* browser textarea — breaking on spaces (char-breaking over-long words),
|
|
62
|
+
* advancing one line-height per wrapped line — instead of running off the
|
|
63
|
+
* right edge on a single line (DM-840). Omit for unbounded single-line text.
|
|
64
|
+
*/
|
|
59
65
|
bgWidth?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Field height in px (used to size the placeholder mask). The mask grows
|
|
68
|
+
* beyond this if the wrapped text needs more lines, so the typed text always
|
|
69
|
+
* sits on a clean background.
|
|
70
|
+
*/
|
|
60
71
|
bgHeight?: number;
|
|
61
72
|
}
|
|
62
73
|
export interface TapOverlay {
|
|
@@ -245,45 +245,119 @@ function transitionDuration(f) {
|
|
|
245
245
|
return 0;
|
|
246
246
|
return f.transition.duration;
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
|
|
250
|
+
* browser textarea does: break on spaces, char-break a word longer than the
|
|
251
|
+
* field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
|
|
252
|
+
* line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
|
|
253
|
+
* overlays with no `bgWidth`. DM-840.
|
|
254
|
+
*/
|
|
255
|
+
function wrapTypingText(text, maxChars) {
|
|
256
|
+
const lines = [];
|
|
257
|
+
for (const paragraph of text.split("\n")) {
|
|
258
|
+
if (maxChars === Infinity) {
|
|
259
|
+
lines.push(paragraph);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (paragraph === "") {
|
|
263
|
+
lines.push("");
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
let cur = "";
|
|
267
|
+
for (let word of paragraph.split(" ")) {
|
|
268
|
+
// A single word wider than the line char-breaks across lines.
|
|
269
|
+
while (word.length > maxChars) {
|
|
270
|
+
if (cur !== "") {
|
|
271
|
+
lines.push(cur);
|
|
272
|
+
cur = "";
|
|
273
|
+
}
|
|
274
|
+
lines.push(word.slice(0, maxChars));
|
|
275
|
+
word = word.slice(maxChars);
|
|
276
|
+
}
|
|
277
|
+
if (cur === "")
|
|
278
|
+
cur = word;
|
|
279
|
+
else if ((cur + " " + word).length <= maxChars)
|
|
280
|
+
cur += " " + word;
|
|
281
|
+
else {
|
|
282
|
+
lines.push(cur);
|
|
283
|
+
cur = word;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
lines.push(cur);
|
|
287
|
+
}
|
|
288
|
+
return lines;
|
|
289
|
+
}
|
|
248
290
|
function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDuration, totalSec) {
|
|
249
291
|
const delay = overlay.delay ?? 300;
|
|
250
292
|
const speed = overlay.speed ?? 60;
|
|
251
293
|
const fontSize = overlay.fontSize ?? 14;
|
|
252
|
-
const charWidth = fontSize * 0.6;
|
|
294
|
+
const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
|
|
295
|
+
const lineHeight = Math.round(fontSize * 1.35);
|
|
253
296
|
const color = overlay.color ?? "#e6edf3";
|
|
254
297
|
const typeStartMs = frameStart + delay;
|
|
298
|
+
const id = `t${frameIdx}`;
|
|
299
|
+
// DM-840: wrap to bgWidth so typed text behaves like a browser field
|
|
300
|
+
// (textarea) — wrapping to the next line instead of running off the right
|
|
301
|
+
// edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
|
|
302
|
+
// width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
|
|
303
|
+
// keep the original single-line behavior (maxChars = Infinity).
|
|
304
|
+
const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
|
|
305
|
+
const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
|
|
306
|
+
const lines = wrapTypingText(overlay.text, maxChars);
|
|
307
|
+
const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
|
|
308
|
+
const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
255
309
|
const parts = [];
|
|
256
310
|
const cssRules = [];
|
|
257
|
-
|
|
258
|
-
//
|
|
311
|
+
// ── Timeline — all stops clamped to the frame so the overlay can't leak
|
|
312
|
+
// across the cut into the next frame. `naturalEnd` is when typing finishes
|
|
313
|
+
// at the requested speed; if that runs past the frame we compress the reveal
|
|
314
|
+
// to fit. The fully-typed text then HOLDS until just before the frame ends
|
|
315
|
+
// (the old hard 3 s cap cut long text off mid-type), then fades out.
|
|
316
|
+
const disappearGap = 150;
|
|
317
|
+
const naturalEndMs = typeStartMs + visibleChars * speed;
|
|
318
|
+
const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
|
|
319
|
+
const effTypeDur = Math.max(1, textEndMs - typeStartMs);
|
|
320
|
+
const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
|
|
321
|
+
const disappearMs = Math.min(frameEnd, holdEndMs + 100);
|
|
322
|
+
const textHeight = fontSize + 4;
|
|
323
|
+
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
324
|
+
const disappearPct = pct(disappearMs, totalDuration);
|
|
325
|
+
// Background mask — grown to cover every wrapped line so the typed text
|
|
326
|
+
// always lands on a clean field instead of the captured placeholder.
|
|
259
327
|
if (overlay.bgColor != null) {
|
|
260
|
-
const bgW = overlay.bgWidth ??
|
|
261
|
-
const bgH = overlay.bgHeight ?? fontSize + 6;
|
|
328
|
+
const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
|
|
329
|
+
const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
|
|
262
330
|
const bgStartPct = pct(typeStartMs, totalDuration);
|
|
263
|
-
const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
|
|
264
331
|
parts.push(` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`);
|
|
265
332
|
cssRules.push(`
|
|
266
|
-
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${
|
|
333
|
+
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
267
334
|
.${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
|
|
268
335
|
}
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
336
|
+
// Typewriter reveal: one <text> per wrapped line, each unveiled by a
|
|
337
|
+
// width-growing clip during the slice of the type timeline when that line's
|
|
338
|
+
// characters are typed (line N starts after line N-1 finishes), so the caret
|
|
339
|
+
// advances down the field exactly as it would in the browser.
|
|
340
|
+
let cumChars = 0;
|
|
341
|
+
lines.forEach((line, li) => {
|
|
342
|
+
const lineY = overlay.y + li * lineHeight;
|
|
343
|
+
// +1 cell of slack so the last glyph never clips: the real monospace
|
|
344
|
+
// advance is slightly wider than the 0.6em estimate, and the trailing cell
|
|
345
|
+
// is where the caret would sit just after the typed character anyway.
|
|
346
|
+
const lineWidth = line.length * charWidth + charWidth;
|
|
347
|
+
const clipId = `${id}-clip${li}`;
|
|
348
|
+
const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
|
|
349
|
+
const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
|
|
350
|
+
cumChars += line.length;
|
|
351
|
+
parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
|
|
352
|
+
parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`);
|
|
353
|
+
cssRules.push(`
|
|
354
|
+
@keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
|
|
355
|
+
.${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
|
|
356
|
+
});
|
|
357
|
+
// Whole-overlay visibility — shared by every line's <text>.
|
|
280
358
|
const typeStartPct = pct(typeStartMs, totalDuration);
|
|
281
|
-
const typeEndPct = pct(textEndMs, totalDuration);
|
|
282
|
-
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
283
359
|
cssRules.push(`
|
|
284
|
-
@keyframes ${id}-
|
|
285
|
-
.${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
|
|
286
|
-
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
|
|
360
|
+
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
287
361
|
.${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
|
|
288
362
|
return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
|
|
289
363
|
}
|
|
@@ -287,4 +287,60 @@ describe("animator", () => {
|
|
|
287
287
|
// 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
|
|
288
288
|
expect(svg).toMatch(/50\.000%/);
|
|
289
289
|
});
|
|
290
|
+
// DM-840: a typing overlay over a bgWidth-constrained field must wrap like a
|
|
291
|
+
// browser textarea instead of running the text off the right edge.
|
|
292
|
+
describe("typing overlay text wrapping (DM-840)", () => {
|
|
293
|
+
const longText = "The quick brown fox jumps over the lazy dog repeatedly";
|
|
294
|
+
function typedSvg(opts) {
|
|
295
|
+
return generateAnimatedSvg({
|
|
296
|
+
width: 400, height: 200,
|
|
297
|
+
frames: [{
|
|
298
|
+
svgContent: `<rect width="400" height="200" fill="#fff"/>`,
|
|
299
|
+
duration: 4000,
|
|
300
|
+
overlays: [{
|
|
301
|
+
kind: "typing", text: longText, x: 20, y: 40,
|
|
302
|
+
fontSize: 14, bgColor: "#fff", bgWidth: opts.bgWidth, bgHeight: 24,
|
|
303
|
+
}],
|
|
304
|
+
}],
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
// Pull the text content out of every `<text class="t0-text" …>…</text>`.
|
|
308
|
+
function typedLines(svg) {
|
|
309
|
+
return [...svg.matchAll(/<text class="t0-text"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]);
|
|
310
|
+
}
|
|
311
|
+
it("wraps the typed text into multiple lines bounded by bgWidth", () => {
|
|
312
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
313
|
+
const lines = typedLines(svg);
|
|
314
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
315
|
+
// bgWidth 120, charWidth = 14*0.6 = 8.4 → usable (120-4)/8.4 ≈ 13 chars/line.
|
|
316
|
+
const maxChars = Math.floor((120 - 4) / (14 * 0.6));
|
|
317
|
+
for (const line of lines)
|
|
318
|
+
expect(line.length).toBeLessThanOrEqual(maxChars);
|
|
319
|
+
// No glyph escapes the field: each line's right edge stays within the bg.
|
|
320
|
+
expect(lines.join("")).not.toContain(" "); // wrap consumed the break spaces
|
|
321
|
+
});
|
|
322
|
+
it("advances y by a line-height per wrapped line", () => {
|
|
323
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
324
|
+
const ys = [...svg.matchAll(/<text class="t0-text" x="20" y="([\d.]+)"/g)].map((m) => Number(m[1]));
|
|
325
|
+
expect(ys.length).toBeGreaterThan(1);
|
|
326
|
+
// Strictly increasing baselines, ~lineHeight (round(14*1.35)=19) apart.
|
|
327
|
+
for (let i = 1; i < ys.length; i++) {
|
|
328
|
+
expect(ys[i]).toBeGreaterThan(ys[i - 1]);
|
|
329
|
+
expect(ys[i] - ys[i - 1]).toBe(19);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
it("keeps single-line behavior when no bgWidth is given (backward compatible)", () => {
|
|
333
|
+
const svg = typedSvg({});
|
|
334
|
+
const lines = typedLines(svg);
|
|
335
|
+
expect(lines.length).toBe(1);
|
|
336
|
+
expect(lines[0]).toBe(longText);
|
|
337
|
+
});
|
|
338
|
+
it("grows the bg mask to cover all wrapped lines", () => {
|
|
339
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
340
|
+
const bg = /<rect class="t0-bg"[^>]*height="([\d.]+)"/.exec(svg);
|
|
341
|
+
expect(bg).not.toBeNull();
|
|
342
|
+
// More than the single-line bgHeight (24) since the text wraps to several lines.
|
|
343
|
+
expect(Number(bg[1])).toBeGreaterThan(24);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
290
346
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domotion-svg",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "DOM-to-animated-SVG renderer. Captures HTML/CSS via Playwright Chromium and converts it to self-contained SVG with CSS animations — pixel-faithful demos that scale crisply and load lazily.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Brian Westphal",
|
|
@@ -303,4 +303,66 @@ describe("animator", () => {
|
|
|
303
303
|
// 50.000% boundary — frame 0 fades out and frame 1 fades in at the same instant.
|
|
304
304
|
expect(svg).toMatch(/50\.000%/);
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
// DM-840: a typing overlay over a bgWidth-constrained field must wrap like a
|
|
308
|
+
// browser textarea instead of running the text off the right edge.
|
|
309
|
+
describe("typing overlay text wrapping (DM-840)", () => {
|
|
310
|
+
const longText = "The quick brown fox jumps over the lazy dog repeatedly";
|
|
311
|
+
|
|
312
|
+
function typedSvg(opts: { bgWidth?: number }): string {
|
|
313
|
+
return generateAnimatedSvg({
|
|
314
|
+
width: 400, height: 200,
|
|
315
|
+
frames: [{
|
|
316
|
+
svgContent: `<rect width="400" height="200" fill="#fff"/>`,
|
|
317
|
+
duration: 4000,
|
|
318
|
+
overlays: [{
|
|
319
|
+
kind: "typing", text: longText, x: 20, y: 40,
|
|
320
|
+
fontSize: 14, bgColor: "#fff", bgWidth: opts.bgWidth, bgHeight: 24,
|
|
321
|
+
}],
|
|
322
|
+
}],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Pull the text content out of every `<text class="t0-text" …>…</text>`.
|
|
327
|
+
function typedLines(svg: string): string[] {
|
|
328
|
+
return [...svg.matchAll(/<text class="t0-text"[^>]*>([^<]*)<\/text>/g)].map((m) => m[1]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
it("wraps the typed text into multiple lines bounded by bgWidth", () => {
|
|
332
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
333
|
+
const lines = typedLines(svg);
|
|
334
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
335
|
+
// bgWidth 120, charWidth = 14*0.6 = 8.4 → usable (120-4)/8.4 ≈ 13 chars/line.
|
|
336
|
+
const maxChars = Math.floor((120 - 4) / (14 * 0.6));
|
|
337
|
+
for (const line of lines) expect(line.length).toBeLessThanOrEqual(maxChars);
|
|
338
|
+
// No glyph escapes the field: each line's right edge stays within the bg.
|
|
339
|
+
expect(lines.join("")).not.toContain(" "); // wrap consumed the break spaces
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("advances y by a line-height per wrapped line", () => {
|
|
343
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
344
|
+
const ys = [...svg.matchAll(/<text class="t0-text" x="20" y="([\d.]+)"/g)].map((m) => Number(m[1]));
|
|
345
|
+
expect(ys.length).toBeGreaterThan(1);
|
|
346
|
+
// Strictly increasing baselines, ~lineHeight (round(14*1.35)=19) apart.
|
|
347
|
+
for (let i = 1; i < ys.length; i++) {
|
|
348
|
+
expect(ys[i]).toBeGreaterThan(ys[i - 1]);
|
|
349
|
+
expect(ys[i] - ys[i - 1]).toBe(19);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("keeps single-line behavior when no bgWidth is given (backward compatible)", () => {
|
|
354
|
+
const svg = typedSvg({});
|
|
355
|
+
const lines = typedLines(svg);
|
|
356
|
+
expect(lines.length).toBe(1);
|
|
357
|
+
expect(lines[0]).toBe(longText);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("grows the bg mask to cover all wrapped lines", () => {
|
|
361
|
+
const svg = typedSvg({ bgWidth: 120 });
|
|
362
|
+
const bg = /<rect class="t0-bg"[^>]*height="([\d.]+)"/.exec(svg);
|
|
363
|
+
expect(bg).not.toBeNull();
|
|
364
|
+
// More than the single-line bgHeight (24) since the text wraps to several lines.
|
|
365
|
+
expect(Number(bg![1])).toBeGreaterThan(24);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
306
368
|
});
|
|
@@ -60,7 +60,18 @@ export interface TypingOverlay {
|
|
|
60
60
|
speed?: number;
|
|
61
61
|
/** Background color to mask placeholder text */
|
|
62
62
|
bgColor?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Field width in px. When set, the typed text WRAPS to this width like a
|
|
65
|
+
* browser textarea — breaking on spaces (char-breaking over-long words),
|
|
66
|
+
* advancing one line-height per wrapped line — instead of running off the
|
|
67
|
+
* right edge on a single line (DM-840). Omit for unbounded single-line text.
|
|
68
|
+
*/
|
|
63
69
|
bgWidth?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Field height in px (used to size the placeholder mask). The mask grows
|
|
72
|
+
* beyond this if the wrapped text needs more lines, so the typed text always
|
|
73
|
+
* sits on a clean background.
|
|
74
|
+
*/
|
|
64
75
|
bgHeight?: number;
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -445,6 +456,35 @@ function transitionDuration(f: AnimationFrame): number {
|
|
|
445
456
|
return f.transition.duration;
|
|
446
457
|
}
|
|
447
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Wrap `text` into lines no wider than `maxChars` monospace cells, the way a
|
|
461
|
+
* browser textarea does: break on spaces, char-break a word longer than the
|
|
462
|
+
* field, and honor explicit newlines. `maxChars === Infinity` → no wrap (one
|
|
463
|
+
* line per explicit-newline paragraph), preserving the pre-DM-840 behavior for
|
|
464
|
+
* overlays with no `bgWidth`. DM-840.
|
|
465
|
+
*/
|
|
466
|
+
function wrapTypingText(text: string, maxChars: number): string[] {
|
|
467
|
+
const lines: string[] = [];
|
|
468
|
+
for (const paragraph of text.split("\n")) {
|
|
469
|
+
if (maxChars === Infinity) { lines.push(paragraph); continue; }
|
|
470
|
+
if (paragraph === "") { lines.push(""); continue; }
|
|
471
|
+
let cur = "";
|
|
472
|
+
for (let word of paragraph.split(" ")) {
|
|
473
|
+
// A single word wider than the line char-breaks across lines.
|
|
474
|
+
while (word.length > maxChars) {
|
|
475
|
+
if (cur !== "") { lines.push(cur); cur = ""; }
|
|
476
|
+
lines.push(word.slice(0, maxChars));
|
|
477
|
+
word = word.slice(maxChars);
|
|
478
|
+
}
|
|
479
|
+
if (cur === "") cur = word;
|
|
480
|
+
else if ((cur + " " + word).length <= maxChars) cur += " " + word;
|
|
481
|
+
else { lines.push(cur); cur = word; }
|
|
482
|
+
}
|
|
483
|
+
lines.push(cur);
|
|
484
|
+
}
|
|
485
|
+
return lines;
|
|
486
|
+
}
|
|
487
|
+
|
|
448
488
|
function renderTypingOverlay(
|
|
449
489
|
overlay: TypingOverlay,
|
|
450
490
|
frameIdx: number,
|
|
@@ -456,52 +496,85 @@ function renderTypingOverlay(
|
|
|
456
496
|
const delay = overlay.delay ?? 300;
|
|
457
497
|
const speed = overlay.speed ?? 60;
|
|
458
498
|
const fontSize = overlay.fontSize ?? 14;
|
|
459
|
-
const charWidth = fontSize * 0.6;
|
|
499
|
+
const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
|
|
500
|
+
const lineHeight = Math.round(fontSize * 1.35);
|
|
460
501
|
const color = overlay.color ?? "#e6edf3";
|
|
461
502
|
const typeStartMs = frameStart + delay;
|
|
503
|
+
const id = `t${frameIdx}`;
|
|
504
|
+
|
|
505
|
+
// DM-840: wrap to bgWidth so typed text behaves like a browser field
|
|
506
|
+
// (textarea) — wrapping to the next line instead of running off the right
|
|
507
|
+
// edge. Text starts at overlay.x and the bg rect starts at overlay.x-2 with
|
|
508
|
+
// width bgWidth, so the usable text width is bgWidth-4. With no bgWidth we
|
|
509
|
+
// keep the original single-line behavior (maxChars = Infinity).
|
|
510
|
+
const maxLineWidth = overlay.bgWidth != null ? overlay.bgWidth - 4 : Infinity;
|
|
511
|
+
const maxChars = maxLineWidth === Infinity ? Infinity : Math.max(1, Math.floor(maxLineWidth / charWidth));
|
|
512
|
+
const lines = wrapTypingText(overlay.text, maxChars);
|
|
513
|
+
const visibleChars = Math.max(1, lines.reduce((n, l) => n + l.length, 0));
|
|
514
|
+
const longestLineChars = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
462
515
|
|
|
463
516
|
const parts: string[] = [];
|
|
464
517
|
const cssRules: string[] = [];
|
|
465
|
-
const id = `t${frameIdx}`;
|
|
466
518
|
|
|
467
|
-
//
|
|
519
|
+
// ── Timeline — all stops clamped to the frame so the overlay can't leak
|
|
520
|
+
// across the cut into the next frame. `naturalEnd` is when typing finishes
|
|
521
|
+
// at the requested speed; if that runs past the frame we compress the reveal
|
|
522
|
+
// to fit. The fully-typed text then HOLDS until just before the frame ends
|
|
523
|
+
// (the old hard 3 s cap cut long text off mid-type), then fades out.
|
|
524
|
+
const disappearGap = 150;
|
|
525
|
+
const naturalEndMs = typeStartMs + visibleChars * speed;
|
|
526
|
+
const textEndMs = Math.min(naturalEndMs, Math.max(typeStartMs + 1, frameEnd - disappearGap));
|
|
527
|
+
const effTypeDur = Math.max(1, textEndMs - typeStartMs);
|
|
528
|
+
const holdEndMs = Math.max(textEndMs, frameEnd - disappearGap);
|
|
529
|
+
const disappearMs = Math.min(frameEnd, holdEndMs + 100);
|
|
530
|
+
const textHeight = fontSize + 4;
|
|
531
|
+
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
532
|
+
const disappearPct = pct(disappearMs, totalDuration);
|
|
533
|
+
|
|
534
|
+
// Background mask — grown to cover every wrapped line so the typed text
|
|
535
|
+
// always lands on a clean field instead of the captured placeholder.
|
|
468
536
|
if (overlay.bgColor != null) {
|
|
469
|
-
const bgW = overlay.bgWidth ??
|
|
470
|
-
const bgH = overlay.bgHeight ?? fontSize + 6;
|
|
537
|
+
const bgW = overlay.bgWidth ?? longestLineChars * charWidth + 8;
|
|
538
|
+
const bgH = Math.max(overlay.bgHeight ?? fontSize + 6, lines.length * lineHeight + 6);
|
|
471
539
|
const bgStartPct = pct(typeStartMs, totalDuration);
|
|
472
|
-
const bgEndPct = pct(frameStart + overlay.text.length * speed + delay + 500, totalDuration);
|
|
473
|
-
|
|
474
540
|
parts.push(
|
|
475
541
|
` <rect class="${id}-bg" x="${overlay.x - 2}" y="${overlay.y - fontSize + 2}" width="${bgW}" height="${bgH}" fill="${overlay.bgColor}" rx="2" />`,
|
|
476
542
|
);
|
|
477
543
|
cssRules.push(`
|
|
478
|
-
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${
|
|
544
|
+
@keyframes ${id}-bg { 0%, ${bgStartPct} { opacity: 0; } ${pct(typeStartMs + 50, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
479
545
|
.${id}-bg { animation: ${id}-bg ${totalSec.toFixed(2)}s infinite; }`);
|
|
480
546
|
}
|
|
481
547
|
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
548
|
+
// Typewriter reveal: one <text> per wrapped line, each unveiled by a
|
|
549
|
+
// width-growing clip during the slice of the type timeline when that line's
|
|
550
|
+
// characters are typed (line N starts after line N-1 finishes), so the caret
|
|
551
|
+
// advances down the field exactly as it would in the browser.
|
|
552
|
+
|
|
553
|
+
let cumChars = 0;
|
|
554
|
+
lines.forEach((line, li) => {
|
|
555
|
+
const lineY = overlay.y + li * lineHeight;
|
|
556
|
+
// +1 cell of slack so the last glyph never clips: the real monospace
|
|
557
|
+
// advance is slightly wider than the 0.6em estimate, and the trailing cell
|
|
558
|
+
// is where the caret would sit just after the typed character anyway.
|
|
559
|
+
const lineWidth = line.length * charWidth + charWidth;
|
|
560
|
+
const clipId = `${id}-clip${li}`;
|
|
561
|
+
const lineStartPct = pct(typeStartMs + (cumChars / visibleChars) * effTypeDur, totalDuration);
|
|
562
|
+
const lineEndPct = pct(typeStartMs + ((cumChars + line.length) / visibleChars) * effTypeDur, totalDuration);
|
|
563
|
+
cumChars += line.length;
|
|
564
|
+
|
|
565
|
+
parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
|
|
566
|
+
parts.push(
|
|
567
|
+
` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeXml(line)}</text>`,
|
|
568
|
+
);
|
|
569
|
+
cssRules.push(`
|
|
570
|
+
@keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
|
|
571
|
+
.${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s infinite; }`);
|
|
572
|
+
});
|
|
496
573
|
|
|
574
|
+
// Whole-overlay visibility — shared by every line's <text>.
|
|
497
575
|
const typeStartPct = pct(typeStartMs, totalDuration);
|
|
498
|
-
const typeEndPct = pct(textEndMs, totalDuration);
|
|
499
|
-
const holdEndPct = pct(holdEndMs, totalDuration);
|
|
500
|
-
|
|
501
576
|
cssRules.push(`
|
|
502
|
-
@keyframes ${id}-
|
|
503
|
-
.${id}-reveal { animation: ${id}-reveal ${totalSec.toFixed(2)}s infinite; }
|
|
504
|
-
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${pct(holdEndMs + 100, totalDuration)}, 100% { opacity: 0; } }
|
|
577
|
+
@keyframes ${id}-vis { 0%, ${typeStartPct} { opacity: 0; } ${pct(typeStartMs + 30, totalDuration)} { opacity: 1; } ${holdEndPct} { opacity: 1; } ${disappearPct}, 100% { opacity: 0; } }
|
|
505
578
|
.${id}-text { animation: ${id}-vis ${totalSec.toFixed(2)}s infinite; }`);
|
|
506
579
|
|
|
507
580
|
return { svgMarkup: parts.join("\n"), css: cssRules.join("") };
|