@cube-drone/marquee-html-renderer 0.9.0 → 0.9.1
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.
- package/dist/effects.d.ts +47 -0
- package/dist/effects.js +252 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/render.js +127 -175
- package/package.json +5 -5
- package/src/effects.ts +316 -0
- package/src/index.ts +1 -0
- package/src/render.ts +138 -207
package/src/effects.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// The effect vocabulary's look, as data: which classes, custom properties
|
|
2
|
+
// and data attributes an effect span's container wears, and - for by=letter
|
|
3
|
+
// / by=word - exactly which runs of which text nodes are animated units and
|
|
4
|
+
// with what phase offset. The HTML renderer builds its markup from this, and
|
|
5
|
+
// any other surface that draws effects (the live editor's in-place marks)
|
|
6
|
+
// takes the same answer, so the two can't disagree about segmentation,
|
|
7
|
+
// phase order, offsets, or caps.
|
|
8
|
+
|
|
9
|
+
import type { Attrs, Node } from "@cube-drone/marquee-parser";
|
|
10
|
+
|
|
11
|
+
const TOKEN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
12
|
+
const COUNT = /^[0-9]{1,4}$/;
|
|
13
|
+
|
|
14
|
+
/** The language-defined animated effects (SPEC.md, "Text effects"). */
|
|
15
|
+
export const EFFECT_NAMES: ReadonlySet<string> = new Set([
|
|
16
|
+
"marquee", "blink", "rainbow", "bounce", "jitter", "wave", "rubber", "typewriter", "fadein",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** One animated unit: `[start, end)` in its text node's `value` (UTF-16
|
|
20
|
+
* offsets), and the `--mq-o` phase offset it wears. */
|
|
21
|
+
export interface EffectUnit {
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
offset: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EffectLook {
|
|
28
|
+
/** The container's classes: `mq-<name>`, plus `mq-split` when split. */
|
|
29
|
+
className: string;
|
|
30
|
+
/** Custom properties on the container (`--mq-speed`, `--mq-rate`, a
|
|
31
|
+
* reveal's step), in emission order. */
|
|
32
|
+
vars: [string, string][];
|
|
33
|
+
/** Data attributes on the container (a marquee's direction). */
|
|
34
|
+
data: [string, string][];
|
|
35
|
+
/** Per-unit animation, or null when the run animates as one piece (no
|
|
36
|
+
* `by=`, no units, or past the unit cap). Keyed by text node identity. */
|
|
37
|
+
units: Map<Node, EffectUnit[]> | null;
|
|
38
|
+
/** With units: the word groups - `[start, end)` value ranges, each a run
|
|
39
|
+
* of text between spaces that holds units - which must wrap as one. Units
|
|
40
|
+
* are inline-blocks (transforms need them to be), and a line may break on
|
|
41
|
+
* either side of every inline-block; a `white-space: nowrap` group puts
|
|
42
|
+
* the break opportunities back where the spaces are. */
|
|
43
|
+
groups: Map<Node, [number, number][]> | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How an effect span looks, or null if `name` isn't an effect. */
|
|
47
|
+
export function effectLook(name: string, attrs: Attrs, children: Node[]): EffectLook | null {
|
|
48
|
+
if (!EFFECT_NAMES.has(name)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const look: EffectLook = { className: `mq-${name}`, vars: [], data: [], units: null, groups: null };
|
|
52
|
+
const by = attrs["by"] === "letter" || attrs["by"] === "word" ? attrs["by"] : null;
|
|
53
|
+
let splitBy: SplitBy | null = null;
|
|
54
|
+
switch (name) {
|
|
55
|
+
case "marquee":
|
|
56
|
+
if (TOKEN.test(attrs["direction"] ?? "")) {
|
|
57
|
+
look.data.push(["direction", attrs["direction"]!]);
|
|
58
|
+
}
|
|
59
|
+
if (COUNT.test(attrs["speed"] ?? "")) {
|
|
60
|
+
look.vars.push(["--mq-speed", attrs["speed"]!]);
|
|
61
|
+
}
|
|
62
|
+
return look;
|
|
63
|
+
case "blink":
|
|
64
|
+
// Split blink: ramp is theater-marquee chase lights, scatter is
|
|
65
|
+
// twinkle. The rate rides the container; units inherit it.
|
|
66
|
+
if (COUNT.test(attrs["rate"] ?? "")) {
|
|
67
|
+
look.vars.push(["--mq-rate", attrs["rate"]!]);
|
|
68
|
+
}
|
|
69
|
+
splitBy = by;
|
|
70
|
+
break;
|
|
71
|
+
case "typewriter":
|
|
72
|
+
// Inherently per-unit: the reveal IS a by=letter effect (by=word for
|
|
73
|
+
// word-at-a-time). speed= is units per second; the container carries
|
|
74
|
+
// the per-unit delay step, each unit its ordinal in --mq-o.
|
|
75
|
+
splitBy = by === "word" ? "word" : "letter";
|
|
76
|
+
look.vars.push(["--mq-tw-step", `${revealStep(attrs["speed"], 14)}s`]);
|
|
77
|
+
break;
|
|
78
|
+
case "fadein":
|
|
79
|
+
// Bare [fadein] fades the whole run in once; by= drifts units in on
|
|
80
|
+
// staggered starts (the same one-shot family as typewriter).
|
|
81
|
+
if (by !== null) {
|
|
82
|
+
splitBy = by;
|
|
83
|
+
look.vars.push(["--mq-fi-step", `${revealStep(attrs["speed"], 16)}s`]);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
default: // rainbow, bounce, jitter, wave, rubber
|
|
87
|
+
splitBy = by;
|
|
88
|
+
}
|
|
89
|
+
if (splitBy === null) {
|
|
90
|
+
return look;
|
|
91
|
+
}
|
|
92
|
+
const units = splitUnits(name, splitBy, attrs["phase"], children);
|
|
93
|
+
if (units === null) {
|
|
94
|
+
// Past the cap (or nothing to split): the run animates whole, and the
|
|
95
|
+
// split's container knobs go with the split.
|
|
96
|
+
look.vars = [];
|
|
97
|
+
return look;
|
|
98
|
+
}
|
|
99
|
+
look.className += " mq-split";
|
|
100
|
+
look.units = units;
|
|
101
|
+
look.groups = wordGroups(units);
|
|
102
|
+
return look;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A text node, laid out for a split render: plain text that rides along,
|
|
106
|
+
* animated units, and word groups holding units (and the punctuation that
|
|
107
|
+
* clings to them) that must not wrap apart. */
|
|
108
|
+
export type SplitPiece =
|
|
109
|
+
| { kind: "text"; text: string }
|
|
110
|
+
| { kind: "unit"; text: string; offset: string }
|
|
111
|
+
| { kind: "group"; pieces: SplitPiece[] };
|
|
112
|
+
|
|
113
|
+
export function splitPieces(node: Node & { type: "text" }, look: EffectLook): SplitPiece[] {
|
|
114
|
+
const units = look.units?.get(node) ?? [];
|
|
115
|
+
const groups = look.groups?.get(node) ?? [];
|
|
116
|
+
const lay = (from: number, to: number): SplitPiece[] => {
|
|
117
|
+
const out: SplitPiece[] = [];
|
|
118
|
+
let at = from;
|
|
119
|
+
for (const u of units) {
|
|
120
|
+
if (u.start < from || u.end > to) continue;
|
|
121
|
+
if (u.start > at) out.push({ kind: "text", text: node.value.slice(at, u.start) });
|
|
122
|
+
out.push({ kind: "unit", text: node.value.slice(u.start, u.end), offset: u.offset });
|
|
123
|
+
at = u.end;
|
|
124
|
+
}
|
|
125
|
+
if (to > at) out.push({ kind: "text", text: node.value.slice(at, to) });
|
|
126
|
+
return out;
|
|
127
|
+
};
|
|
128
|
+
const out: SplitPiece[] = [];
|
|
129
|
+
let at = 0;
|
|
130
|
+
for (const [start, end] of groups) {
|
|
131
|
+
out.push(...lay(at, start), { kind: "group", pieces: lay(start, end) });
|
|
132
|
+
at = end;
|
|
133
|
+
}
|
|
134
|
+
out.push(...lay(at, node.value.length));
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Scripts written without spaces between words wrap between characters,
|
|
139
|
+
* so their runs are never grouped (a whole sentence would become one
|
|
140
|
+
* unbreakable word): Han, kana, fullwidth forms, Thai, Lao, Khmer, Myanmar. */
|
|
141
|
+
const SPACELESS = /[\u0E00-\u0EFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u{20000}-\u{3FFFF}]/u;
|
|
142
|
+
|
|
143
|
+
function wordGroups(units: Map<Node, EffectUnit[]>): Map<Node, [number, number][]> {
|
|
144
|
+
const out = new Map<Node, [number, number][]>();
|
|
145
|
+
for (const [node, list] of units) {
|
|
146
|
+
if (node.type !== "text") continue;
|
|
147
|
+
const groups: [number, number][] = [];
|
|
148
|
+
for (const m of node.value.matchAll(/\S+/g)) {
|
|
149
|
+
const start = m.index;
|
|
150
|
+
const end = start + m[0].length;
|
|
151
|
+
const inside = list.filter((u) => u.start >= start && u.end <= end);
|
|
152
|
+
// Nothing to hold together: no units, a spaceless script, or a run
|
|
153
|
+
// that is exactly one unit (already a single inline-block).
|
|
154
|
+
if (inside.length === 0 || SPACELESS.test(m[0])) continue;
|
|
155
|
+
if (inside.length === 1 && inside[0]!.start === start && inside[0]!.end === end) continue;
|
|
156
|
+
groups.push([start, end]);
|
|
157
|
+
}
|
|
158
|
+
out.set(node, groups);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -- per-unit effects (by=letter / by=word): each unit carries a phase
|
|
164
|
+
// offset in --mq-o; the stylesheet replays the effect's keyframes through a
|
|
165
|
+
// negative animation-delay. Locale pinned so segmentation (and thus output)
|
|
166
|
+
// is stable.
|
|
167
|
+
|
|
168
|
+
const SEGMENTERS = {
|
|
169
|
+
letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
|
|
170
|
+
word: new Intl.Segmenter("en", { granularity: "word" }),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
type SplitBy = keyof typeof SEGMENTERS;
|
|
174
|
+
|
|
175
|
+
/** DOM-weight discipline: past this many units, the run animates whole.
|
|
176
|
+
* Loopers pay a live animation per element forever, so they cap low;
|
|
177
|
+
* typewriter's units are 1ms one-shots (finished animations cost nothing)
|
|
178
|
+
* and its natural material is long text, so it caps high. */
|
|
179
|
+
const MAX_SPLIT_UNITS = 400;
|
|
180
|
+
const MAX_REVEAL_UNITS = 2000;
|
|
181
|
+
|
|
182
|
+
type Phase = "ramp" | "scatter";
|
|
183
|
+
|
|
184
|
+
interface SplitState {
|
|
185
|
+
effect: string;
|
|
186
|
+
by: SplitBy;
|
|
187
|
+
phase: Phase;
|
|
188
|
+
i: number;
|
|
189
|
+
total: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function splitUnits(
|
|
193
|
+
effect: string,
|
|
194
|
+
by: SplitBy,
|
|
195
|
+
phaseAttr: string | undefined,
|
|
196
|
+
children: Node[],
|
|
197
|
+
): Map<Node, EffectUnit[]> | null {
|
|
198
|
+
// Each effect has a natural phase order (jitter scatters, the rest sweep);
|
|
199
|
+
// the knob overrides it either way. Invalid values degrade to the default.
|
|
200
|
+
const phase: Phase =
|
|
201
|
+
phaseAttr === "scatter" || phaseAttr === "ramp" ? phaseAttr : effect === "jitter" ? "scatter" : "ramp";
|
|
202
|
+
const total = countUnits(children, by);
|
|
203
|
+
const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
|
|
204
|
+
if (total === 0 || total > cap) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const state: SplitState = { effect, by, phase, i: 0, total };
|
|
208
|
+
const units = new Map<Node, EffectUnit[]>();
|
|
209
|
+
collectUnits(children, state, units);
|
|
210
|
+
return units;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The nodes a split descends into: text, through emphasis/strong/strike.
|
|
214
|
+
* Anything else (a link, an emoji, a nested span) renders whole, un-split. */
|
|
215
|
+
function collectUnits(nodes: Node[], state: SplitState, out: Map<Node, EffectUnit[]>): void {
|
|
216
|
+
for (const node of nodes) {
|
|
217
|
+
if (node.type === "text") {
|
|
218
|
+
const list: EffectUnit[] = [];
|
|
219
|
+
for (const seg of SEGMENTERS[state.by].segment(node.value)) {
|
|
220
|
+
if (isUnit(seg, state.by)) {
|
|
221
|
+
list.push({ start: seg.index, end: seg.index + seg.segment.length, offset: unitOffset(state) });
|
|
222
|
+
state.i += 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
out.set(node, list);
|
|
226
|
+
} else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
|
|
227
|
+
collectUnits(node.children, state, out);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function countUnits(nodes: Node[], by: SplitBy): number {
|
|
233
|
+
let n = 0;
|
|
234
|
+
for (const node of nodes) {
|
|
235
|
+
if (node.type === "text") {
|
|
236
|
+
for (const seg of SEGMENTERS[by].segment(node.value)) {
|
|
237
|
+
if (isUnit(seg, by)) {
|
|
238
|
+
n += 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
|
|
242
|
+
n += countUnits(node.children, by);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return n;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A segment gets wrapped if it's animatable: for words, word-like segments
|
|
249
|
+
* (spaces and bare punctuation ride along); for letters, anything that
|
|
250
|
+
* isn't whitespace. */
|
|
251
|
+
function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
|
|
252
|
+
return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Offsets are deterministic (goldens exist; a document renders the same
|
|
256
|
+
* twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
|
|
257
|
+
* integer hash - randomness-shaped, never random. */
|
|
258
|
+
function unitOffset(state: SplitState): string {
|
|
259
|
+
// Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
|
|
260
|
+
// ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
|
|
261
|
+
// looping effects replay. phase=scatter reveals in a
|
|
262
|
+
// scrambled-but-deterministic order: a stride at the run's golden-ratio
|
|
263
|
+
// point, nudged coprime with the total, walks a well-spread permutation
|
|
264
|
+
// at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
|
|
265
|
+
// -1, so forty-unit runs typed in backwards.)
|
|
266
|
+
if (isReveal(state.effect)) {
|
|
267
|
+
const o = state.phase === "scatter" ? (state.i * scatterStride(state.total)) % state.total : state.i;
|
|
268
|
+
return String(o);
|
|
269
|
+
}
|
|
270
|
+
let o: number;
|
|
271
|
+
if (state.phase === "scatter") {
|
|
272
|
+
o = ((state.i * 7919) % 101) / 101;
|
|
273
|
+
} else {
|
|
274
|
+
switch (state.effect) {
|
|
275
|
+
case "rainbow":
|
|
276
|
+
o = state.i / state.total; // gradient across the whole run
|
|
277
|
+
break;
|
|
278
|
+
case "wave":
|
|
279
|
+
o = (state.i % 8) / 8; // fixed ripple wavelength
|
|
280
|
+
break;
|
|
281
|
+
case "bounce":
|
|
282
|
+
o = (state.i % 6) / 6;
|
|
283
|
+
break;
|
|
284
|
+
default:
|
|
285
|
+
o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return String(Math.round(o * 1000) / 1000);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** speed= (units per second, a COUNT) into a per-unit delay step in
|
|
293
|
+
* seconds; invalid or absent falls to the effect's default rate. */
|
|
294
|
+
function revealStep(speedAttr: string | undefined, dflt: number): number {
|
|
295
|
+
const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0 ? Number(speedAttr) : dflt;
|
|
296
|
+
return Math.round(1000 / speed) / 1000;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The one-shot reveals: sequential ordinals, the high unit cap. */
|
|
300
|
+
function isReveal(effect: string): boolean {
|
|
301
|
+
return effect === "typewriter" || effect === "fadein";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function gcd(a: number, b: number): number {
|
|
305
|
+
return b === 0 ? a : gcd(b, a % b);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The smallest stride >= ~61.8% of total that's coprime with it (falls
|
|
309
|
+
* back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
|
|
310
|
+
function scatterStride(total: number): number {
|
|
311
|
+
let stride = Math.max(1, Math.round(total * 0.618));
|
|
312
|
+
while (stride < total && gcd(stride, total) !== 1) {
|
|
313
|
+
stride += 1;
|
|
314
|
+
}
|
|
315
|
+
return gcd(stride, total) === 1 ? stride : 1;
|
|
316
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Profile } from "./profile.ts";
|
|
|
11
11
|
|
|
12
12
|
export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
|
|
13
13
|
export { bareWebProfile } from "./profile.ts";
|
|
14
|
+
export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
|
|
14
15
|
export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
|
|
15
16
|
|
|
16
17
|
/** Parse and render in one step. Throws UnsupportedVersionError for unknown
|