@glissade/scene 0.20.0-pre.3 → 0.20.0-pre.5
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/constructionProps.js +2 -1
- package/dist/describe.d.ts +2 -1
- package/dist/describe.js +27 -2
- package/dist/diagnostics.d.ts +3 -56
- package/dist/diagnostics.js +2 -192
- package/dist/tokens.d.ts +58 -0
- package/dist/tokens.js +192 -0
- package/package.json +6 -2
package/dist/describe.d.ts
CHANGED
|
@@ -95,7 +95,8 @@ interface ApiManifest {
|
|
|
95
95
|
/**
|
|
96
96
|
* The curated helper/factory surface (0.20) — `createPlayer`/`mount`,
|
|
97
97
|
* `motionPath`/`followPath`, `clip`/`clipList`, `renderToDataURL`/`snapshotCanvas`,
|
|
98
|
-
* `splitText
|
|
98
|
+
* `splitText`, `Grid`, and the `Stack`/`Row`/`Column` layout factories. Every
|
|
99
|
+
* `name` is also a `window.glissade.<name>` global on the IIFE.
|
|
99
100
|
*/
|
|
100
101
|
helpers: DescribedHelper[];
|
|
101
102
|
createScene: string;
|
package/dist/describe.js
CHANGED
|
@@ -23,7 +23,7 @@ import { easings, listValueTypes } from "@glissade/core";
|
|
|
23
23
|
* never pulled onto the base embed path — a scene that never calls `describe()`
|
|
24
24
|
* pays zero bytes for it.
|
|
25
25
|
*/
|
|
26
|
-
const RAW_VERSION = "0.20.0-pre.
|
|
26
|
+
const RAW_VERSION = "0.20.0-pre.5";
|
|
27
27
|
const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
|
|
28
28
|
/** Arity of a value type's numeric repr: vec2/vec2-arc → 2, number → 1; others (color/paint/path/string/boolean) carry no scalar arity. */
|
|
29
29
|
function arityOf(type) {
|
|
@@ -74,7 +74,8 @@ const CONSTRUCTION_PROP_META = {
|
|
|
74
74
|
fontWeight: { type: "number" },
|
|
75
75
|
fontStyle: { type: "'normal'|'italic'" },
|
|
76
76
|
align: { type: "'left'|'center'|'right'" },
|
|
77
|
-
lineHeight: { type: "number" }
|
|
77
|
+
lineHeight: { type: "number" },
|
|
78
|
+
fontVariationSettings: { type: "string" }
|
|
78
79
|
},
|
|
79
80
|
Image: { assetId: {
|
|
80
81
|
type: "string",
|
|
@@ -330,6 +331,30 @@ const HELPERS = [
|
|
|
330
331
|
summary: "Split a Text node into per-word / per-char parts you can animate individually (kinetic typography). Tree-shaken off the base scene index.",
|
|
331
332
|
import: "@glissade/scene/type",
|
|
332
333
|
usage: "splitText(text: Text, opts?: { by?: 'word'|'char' }): Node[]"
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: "Grid",
|
|
337
|
+
summary: "Build-time CSS-grid-style track resolver: position plain children into a column grid (fr/px tracks + gaps), returning a Group. Pure fan-out (no Yoga, no new target) — the goldens hold by construction. Tree-shaken off the base scene index.",
|
|
338
|
+
import: "@glissade/scene/grid",
|
|
339
|
+
usage: "Grid({ columns: number | (number | { fr })[], children: Node[], gap?, columnGap?, rowGap?, cellHeight?, width? }): Group — child[i] → row floor(i/cols), col i%cols"
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
name: "Stack",
|
|
343
|
+
summary: "Yoga-flexbox layout factory (column by default) — a Layout subclass that stacks children with gap/padding/justify/align. Needs loadYogaLayoutEngine() before mount/render. On the @glissade/scene/layout subpath.",
|
|
344
|
+
import: "@glissade/scene/layout",
|
|
345
|
+
usage: "Stack({ children, direction?: 'row'|'column', gap?, padding?, justify?, align? }): Layout"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "Row",
|
|
349
|
+
summary: "Yoga-flexbox layout factory pinned to direction:\"row\" — children laid out horizontally. Needs loadYogaLayoutEngine() before mount/render. On the @glissade/scene/layout subpath.",
|
|
350
|
+
import: "@glissade/scene/layout",
|
|
351
|
+
usage: "Row({ children, gap?, padding?, justify?, align? }): Layout"
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "Column",
|
|
355
|
+
summary: "Yoga-flexbox layout factory pinned to direction:\"column\" — children laid out vertically. Needs loadYogaLayoutEngine() before mount/render. On the @glissade/scene/layout subpath.",
|
|
356
|
+
import: "@glissade/scene/layout",
|
|
357
|
+
usage: "Column({ children, gap?, padding?, justify?, align? }): Layout"
|
|
333
358
|
}
|
|
334
359
|
];
|
|
335
360
|
/** One-line "what's there" for each documented tree-shakeable subpath entry. */
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { i as DrawCommand, m as Resource, n as DisplayList
|
|
1
|
+
import { i as DrawCommand, m as Resource, n as DisplayList } from "./displayList.js";
|
|
2
2
|
import { t as collapseReplacer } from "./collapseReplacer.js";
|
|
3
|
-
import { B as NodeProps, L as EvalContext, V as PropInit, _ as WordBox, p as Text, z as Node } from "./nodes.js";
|
|
4
3
|
import { r as Scene } from "./scene.js";
|
|
5
|
-
import { Timeline
|
|
4
|
+
import { Timeline } from "@glissade/core";
|
|
6
5
|
|
|
7
6
|
//#region src/displayDiff.d.ts
|
|
8
7
|
|
|
@@ -104,56 +103,4 @@ interface CacheColdResult {
|
|
|
104
103
|
*/
|
|
105
104
|
declare function auditCacheCold(createScene: () => Scene, doc: Timeline, t: number): CacheColdResult;
|
|
106
105
|
//#endregion
|
|
107
|
-
|
|
108
|
-
interface TokenRange {
|
|
109
|
-
/** token text (whitespace-insensitive run match) or inclusive [from, to] wordBoxes indices */
|
|
110
|
-
match: string | readonly [number, number];
|
|
111
|
-
/** which occurrence of a string match; default 1 (the first) */
|
|
112
|
-
occurrence?: number;
|
|
113
|
-
/** range id for track targets ('<nodeId>/<rangeId>/fill' …); default 'r<index>' */
|
|
114
|
-
id?: string;
|
|
115
|
-
fill?: PropInit<string>;
|
|
116
|
-
opacity?: PropInit<number>;
|
|
117
|
-
/** 0→1 left-to-right reveal across the range; default 1 */
|
|
118
|
-
progress?: PropInit<number>;
|
|
119
|
-
/** scale about the range rect's center; default 1 */
|
|
120
|
-
scale?: PropInit<number>;
|
|
121
|
-
/** translation of the range's rects, px — shakes and nudges; default [0, 0] */
|
|
122
|
-
offset?: PropInit<Vec2>;
|
|
123
|
-
}
|
|
124
|
-
interface TokenHighlightProps extends NodeProps {
|
|
125
|
-
/** the Text whose tokens get highlighted; place this node as an EARLIER sibling */
|
|
126
|
-
text: Text;
|
|
127
|
-
ranges: TokenRange[];
|
|
128
|
-
/** marker overhang beyond the ink box, [x, y] px; default [4, 2] */
|
|
129
|
-
padding?: [number, number];
|
|
130
|
-
cornerRadius?: number;
|
|
131
|
-
/** re-resolve string matches every frame (animated text); default false — drift throws */
|
|
132
|
-
rematch?: boolean;
|
|
133
|
-
}
|
|
134
|
-
declare class TokenMatchError extends Error {
|
|
135
|
-
constructor(message: string);
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Find the Nth whitespace-stripped consecutive run of boxes equal to token.
|
|
139
|
-
* Boundary-exact: a run that diverges mid-segment is not a match; ending
|
|
140
|
-
* mid-segment throws (with the real segment list) rather than half-boxing.
|
|
141
|
-
*/
|
|
142
|
-
declare function matchTokenRun(boxes: WordBox[], token: string, occurrence?: number): [number, number];
|
|
143
|
-
declare class TokenHighlight extends Node {
|
|
144
|
-
readonly target: Text;
|
|
145
|
-
readonly padding: [number, number];
|
|
146
|
-
readonly cornerRadius: number;
|
|
147
|
-
readonly rematch: boolean;
|
|
148
|
-
private readonly ranges;
|
|
149
|
-
constructor(props: TokenHighlightProps);
|
|
150
|
-
private resolveRun;
|
|
151
|
-
protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
|
|
155
|
-
* — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
|
|
156
|
-
*/
|
|
157
|
-
declare function tokenHighlight(text: Text, props: Omit<TokenHighlightProps, 'text'>): TokenHighlight;
|
|
158
|
-
//#endregion
|
|
159
|
-
export { type CacheColdResult, type CommandDelta, DL_SNAPSHOT_VERSION, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, TokenHighlight, type TokenHighlightProps, TokenMatchError, type TokenRange, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, matchTokenRun, parseDisplaySnapshot, serializeDisplayList, tokenHighlight };
|
|
106
|
+
export { type CacheColdResult, type CommandDelta, DL_SNAPSHOT_VERSION, type DisplayDiff, type DlSnapshot, DlSnapshotError, type FieldChange, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList };
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { L as createDisplayListBuilder, V as collapseReplacer, r as Group } from "./nodes.js";
|
|
2
2
|
import { a as evaluate } from "./scene.js";
|
|
3
|
-
import { signal, vec2Signal } from "@glissade/core";
|
|
4
3
|
//#region src/displayDiff.ts
|
|
5
4
|
/** A flat, stable JSON value for one command with its resource ids INLINED to content. */
|
|
6
5
|
function commandView(cmd, resources) {
|
|
@@ -192,193 +191,4 @@ function auditCacheCold(createScene, doc, t) {
|
|
|
192
191
|
};
|
|
193
192
|
}
|
|
194
193
|
//#endregion
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Multi-range token highlight: sub-line ranges over a Text node's wordBoxes,
|
|
198
|
-
* each with its OWN animatable fill/opacity/progress/scale — four-color
|
|
199
|
-
* category passes, per-token flips, karaoke-with-color. Design answers from
|
|
200
|
-
* downstream production (the NNDL verification ritual):
|
|
201
|
-
* - ranges VALIDATE at construction and THROW on copy drift at draw — the
|
|
202
|
-
* throw is load-bearing (it catches edited copy that no longer matches);
|
|
203
|
-
* `rematch: true` opts animated text into per-frame re-resolution.
|
|
204
|
-
* - a range spanning a wrap produces one rect per line segment.
|
|
205
|
-
* - string matches are whitespace-stripped consecutive box runs and must end
|
|
206
|
-
* boundary-exact (mid-segment end = error listing the actual segments);
|
|
207
|
-
* `[wordIndex, wordIndex]` ranges sidestep matching entirely.
|
|
208
|
-
*/
|
|
209
|
-
var TokenMatchError = class extends Error {
|
|
210
|
-
constructor(message) {
|
|
211
|
-
super(message);
|
|
212
|
-
this.name = "TokenMatchError";
|
|
213
|
-
}
|
|
214
|
-
};
|
|
215
|
-
const stripWs = (s) => s.replace(/\s+/g, "");
|
|
216
|
-
/**
|
|
217
|
-
* Find the Nth whitespace-stripped consecutive run of boxes equal to token.
|
|
218
|
-
* Boundary-exact: a run that diverges mid-segment is not a match; ending
|
|
219
|
-
* mid-segment throws (with the real segment list) rather than half-boxing.
|
|
220
|
-
*/
|
|
221
|
-
function matchTokenRun(boxes, token, occurrence = 1) {
|
|
222
|
-
const want = stripWs(token);
|
|
223
|
-
if (want.length === 0) throw new TokenMatchError("empty token");
|
|
224
|
-
let seen = 0;
|
|
225
|
-
for (let i = 0; i < boxes.length; i++) {
|
|
226
|
-
let acc = "";
|
|
227
|
-
for (let j = i; j < boxes.length; j++) {
|
|
228
|
-
acc += stripWs(boxes[j].text);
|
|
229
|
-
if (acc === want) {
|
|
230
|
-
seen++;
|
|
231
|
-
if (seen === occurrence) return [i, j];
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
if (!want.startsWith(acc)) break;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
throw new TokenMatchError(`no match for token '${token}'${occurrence > 1 ? ` (occurrence ${occurrence})` : ""} — segments are [${boxes.map((b) => `'${b.text}'`).join(", ")}]`);
|
|
238
|
-
}
|
|
239
|
-
var TokenHighlight = class extends Node {
|
|
240
|
-
target;
|
|
241
|
-
padding;
|
|
242
|
-
cornerRadius;
|
|
243
|
-
rematch;
|
|
244
|
-
ranges;
|
|
245
|
-
constructor(props) {
|
|
246
|
-
super(props);
|
|
247
|
-
this.target = props.text;
|
|
248
|
-
this.padding = props.padding ?? [4, 2];
|
|
249
|
-
this.cornerRadius = props.cornerRadius ?? 4;
|
|
250
|
-
this.rematch = props.rematch ?? false;
|
|
251
|
-
const boxes = this.target.wordBoxes();
|
|
252
|
-
this.ranges = props.ranges.map((spec, index) => {
|
|
253
|
-
const run = this.resolveRun(boxes, spec);
|
|
254
|
-
const id = spec.id ?? `r${index}`;
|
|
255
|
-
const r = {
|
|
256
|
-
spec,
|
|
257
|
-
fill: init(signal("#ffe066"), spec.fill),
|
|
258
|
-
opacity: init(signal(1), spec.opacity),
|
|
259
|
-
progress: init(signal(1), spec.progress),
|
|
260
|
-
scale: init(signal(1), spec.scale),
|
|
261
|
-
offset: initVec(vec2Signal([0, 0]), spec.offset),
|
|
262
|
-
run,
|
|
263
|
-
bound: runText(boxes, run)
|
|
264
|
-
};
|
|
265
|
-
this.registerTarget(`${id}/fill`, r.fill, "color");
|
|
266
|
-
this.registerTarget(`${id}/opacity`, r.opacity, "number");
|
|
267
|
-
this.registerTarget(`${id}/progress`, r.progress, "number");
|
|
268
|
-
this.registerTarget(`${id}/scale`, r.scale, "number");
|
|
269
|
-
this.registerTarget(`${id}/offset`, r.offset, "vec2");
|
|
270
|
-
this.registerTarget(`${id}/offset.x`, r.offset.x, "number");
|
|
271
|
-
this.registerTarget(`${id}/offset.y`, r.offset.y, "number");
|
|
272
|
-
return r;
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
resolveRun(boxes, spec) {
|
|
276
|
-
if (typeof spec.match !== "string") {
|
|
277
|
-
const [from, to] = spec.match;
|
|
278
|
-
if (from < 0 || to >= boxes.length || from > to) throw new TokenMatchError(`word index range [${from}, ${to}] out of bounds (${boxes.length} boxes)`);
|
|
279
|
-
return [from, to];
|
|
280
|
-
}
|
|
281
|
-
return matchTokenRun(boxes, spec.match, spec.occurrence ?? 1);
|
|
282
|
-
}
|
|
283
|
-
draw(out, ctx) {
|
|
284
|
-
const boxes = this.target.wordBoxes(ctx.measurer);
|
|
285
|
-
if (boxes.length === 0) return;
|
|
286
|
-
const tm = this.target.localMatrix();
|
|
287
|
-
const [px, py] = this.padding;
|
|
288
|
-
let pushedTransform = false;
|
|
289
|
-
for (const r of this.ranges) {
|
|
290
|
-
const opacity = r.opacity();
|
|
291
|
-
if (opacity <= 0) continue;
|
|
292
|
-
const progress = Math.min(1, Math.max(0, r.progress()));
|
|
293
|
-
if (progress <= 0) continue;
|
|
294
|
-
let run = r.run;
|
|
295
|
-
if (this.rematch && typeof r.spec.match === "string") run = matchTokenRun(boxes, r.spec.match, r.spec.occurrence ?? 1);
|
|
296
|
-
else if (runText(boxes, run) !== r.bound) throw new TokenMatchError(`bound token '${r.bound}' no longer matches boxes [${run[0]}, ${run[1]}] — the text changed (segments now [${boxes.slice(run[0], run[1] + 1).map((b) => `'${b.text}'`).join(", ")}]); pass rematch: true for animated text`);
|
|
297
|
-
const lineRects = [];
|
|
298
|
-
for (let i = run[0]; i <= run[1]; i++) {
|
|
299
|
-
const b = boxes[i];
|
|
300
|
-
const last = lineRects[lineRects.length - 1];
|
|
301
|
-
const cur = i > run[0] && boxes[i - 1].line === b.line ? last : void 0;
|
|
302
|
-
if (cur) {
|
|
303
|
-
cur.w = b.x + b.w + px - cur.x;
|
|
304
|
-
cur.y = Math.min(cur.y, b.y - py);
|
|
305
|
-
cur.h = Math.max(cur.h, b.y + b.h + py - cur.y);
|
|
306
|
-
} else lineRects.push({
|
|
307
|
-
x: b.x - px,
|
|
308
|
-
y: b.y - py,
|
|
309
|
-
w: b.w + 2 * px,
|
|
310
|
-
h: b.h + 2 * py
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
if (!pushedTransform && !matEquals(tm, IDENTITY)) {
|
|
314
|
-
out.push({
|
|
315
|
-
op: "transform",
|
|
316
|
-
m: tm
|
|
317
|
-
});
|
|
318
|
-
pushedTransform = true;
|
|
319
|
-
}
|
|
320
|
-
const grouped = opacity < 1;
|
|
321
|
-
if (grouped) out.push({
|
|
322
|
-
op: "pushGroup",
|
|
323
|
-
opacity,
|
|
324
|
-
blend: "source-over",
|
|
325
|
-
filters: []
|
|
326
|
-
});
|
|
327
|
-
const fill = r.fill();
|
|
328
|
-
const scale = r.scale();
|
|
329
|
-
const [ox, oy] = r.offset();
|
|
330
|
-
let remaining = progress * lineRects.reduce((sum, q) => sum + q.w, 0);
|
|
331
|
-
for (const q of lineRects) {
|
|
332
|
-
const fillW = Math.min(q.w, remaining);
|
|
333
|
-
remaining -= fillW;
|
|
334
|
-
if (fillW <= 0) break;
|
|
335
|
-
const cx = q.x + q.w / 2 + ox;
|
|
336
|
-
const cy = q.y + q.h / 2 + oy;
|
|
337
|
-
const w = fillW * scale;
|
|
338
|
-
const h = q.h * scale;
|
|
339
|
-
const x = cx - q.w / 2 * scale;
|
|
340
|
-
const y = cy - h / 2;
|
|
341
|
-
const rad = Math.min(this.cornerRadius, w / 2, h / 2);
|
|
342
|
-
const path = out.resource({
|
|
343
|
-
kind: "path",
|
|
344
|
-
segs: roundedRectSegs(x, y, w, h, rad)
|
|
345
|
-
});
|
|
346
|
-
out.push({
|
|
347
|
-
op: "fillPath",
|
|
348
|
-
path,
|
|
349
|
-
paint: {
|
|
350
|
-
kind: "color",
|
|
351
|
-
color: fill
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
|
-
if (remaining <= 0) break;
|
|
355
|
-
}
|
|
356
|
-
if (grouped) out.push({ op: "popGroup" });
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
function runText(boxes, run) {
|
|
361
|
-
return boxes.slice(run[0], run[1] + 1).map((b) => stripWs(b.text)).join("");
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
|
|
365
|
-
* — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
|
|
366
|
-
*/
|
|
367
|
-
function tokenHighlight(text, props) {
|
|
368
|
-
return new TokenHighlight({
|
|
369
|
-
...props,
|
|
370
|
-
text
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
function init(sig, v) {
|
|
374
|
-
if (typeof v === "function") sig.bindSource(v);
|
|
375
|
-
else if (v !== void 0) sig.set(v);
|
|
376
|
-
return sig;
|
|
377
|
-
}
|
|
378
|
-
function initVec(sig, v) {
|
|
379
|
-
if (typeof v === "function") sig.bindSource(v);
|
|
380
|
-
else if (v !== void 0) sig.set(v);
|
|
381
|
-
return sig;
|
|
382
|
-
}
|
|
383
|
-
//#endregion
|
|
384
|
-
export { DL_SNAPSHOT_VERSION, DlSnapshotError, TokenHighlight, TokenMatchError, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, matchTokenRun, parseDisplaySnapshot, serializeDisplayList, tokenHighlight };
|
|
194
|
+
export { DL_SNAPSHOT_VERSION, DlSnapshotError, auditCacheCold, collapseReplacer, diffDisplayLists, formatDisplayDiff, parseDisplaySnapshot, serializeDisplayList };
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { r as DisplayListBuilder } from "./displayList.js";
|
|
2
|
+
import { B as NodeProps, L as EvalContext, V as PropInit, _ as WordBox, p as Text, z as Node } from "./nodes.js";
|
|
3
|
+
import { Vec2 } from "@glissade/core";
|
|
4
|
+
|
|
5
|
+
//#region src/tokenHighlight.d.ts
|
|
6
|
+
|
|
7
|
+
interface TokenRange {
|
|
8
|
+
/** token text (whitespace-insensitive run match) or inclusive [from, to] wordBoxes indices */
|
|
9
|
+
match: string | readonly [number, number];
|
|
10
|
+
/** which occurrence of a string match; default 1 (the first) */
|
|
11
|
+
occurrence?: number;
|
|
12
|
+
/** range id for track targets ('<nodeId>/<rangeId>/fill' …); default 'r<index>' */
|
|
13
|
+
id?: string;
|
|
14
|
+
fill?: PropInit<string>;
|
|
15
|
+
opacity?: PropInit<number>;
|
|
16
|
+
/** 0→1 left-to-right reveal across the range; default 1 */
|
|
17
|
+
progress?: PropInit<number>;
|
|
18
|
+
/** scale about the range rect's center; default 1 */
|
|
19
|
+
scale?: PropInit<number>;
|
|
20
|
+
/** translation of the range's rects, px — shakes and nudges; default [0, 0] */
|
|
21
|
+
offset?: PropInit<Vec2>;
|
|
22
|
+
}
|
|
23
|
+
interface TokenHighlightProps extends NodeProps {
|
|
24
|
+
/** the Text whose tokens get highlighted; place this node as an EARLIER sibling */
|
|
25
|
+
text: Text;
|
|
26
|
+
ranges: TokenRange[];
|
|
27
|
+
/** marker overhang beyond the ink box, [x, y] px; default [4, 2] */
|
|
28
|
+
padding?: [number, number];
|
|
29
|
+
cornerRadius?: number;
|
|
30
|
+
/** re-resolve string matches every frame (animated text); default false — drift throws */
|
|
31
|
+
rematch?: boolean;
|
|
32
|
+
}
|
|
33
|
+
declare class TokenMatchError extends Error {
|
|
34
|
+
constructor(message: string);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Find the Nth whitespace-stripped consecutive run of boxes equal to token.
|
|
38
|
+
* Boundary-exact: a run that diverges mid-segment is not a match; ending
|
|
39
|
+
* mid-segment throws (with the real segment list) rather than half-boxing.
|
|
40
|
+
*/
|
|
41
|
+
declare function matchTokenRun(boxes: WordBox[], token: string, occurrence?: number): [number, number];
|
|
42
|
+
declare class TokenHighlight extends Node {
|
|
43
|
+
readonly target: Text;
|
|
44
|
+
readonly padding: [number, number];
|
|
45
|
+
readonly cornerRadius: number;
|
|
46
|
+
readonly rematch: boolean;
|
|
47
|
+
private readonly ranges;
|
|
48
|
+
constructor(props: TokenHighlightProps);
|
|
49
|
+
private resolveRun;
|
|
50
|
+
protected draw(out: DisplayListBuilder, ctx: EvalContext): void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
|
|
54
|
+
* — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
|
|
55
|
+
*/
|
|
56
|
+
declare function tokenHighlight(text: Text, props: Omit<TokenHighlightProps, 'text'>): TokenHighlight;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { TokenHighlight, type TokenHighlightProps, TokenMatchError, type TokenRange, matchTokenRun, tokenHighlight };
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { H as IDENTITY, K as matEquals, f as roundedRectSegs, p as Node } from "./nodes.js";
|
|
2
|
+
import { signal, vec2Signal } from "@glissade/core";
|
|
3
|
+
//#region src/tokenHighlight.ts
|
|
4
|
+
/**
|
|
5
|
+
* Multi-range token highlight: sub-line ranges over a Text node's wordBoxes,
|
|
6
|
+
* each with its OWN animatable fill/opacity/progress/scale — four-color
|
|
7
|
+
* category passes, per-token flips, karaoke-with-color. Design answers from
|
|
8
|
+
* downstream production (the NNDL verification ritual):
|
|
9
|
+
* - ranges VALIDATE at construction and THROW on copy drift at draw — the
|
|
10
|
+
* throw is load-bearing (it catches edited copy that no longer matches);
|
|
11
|
+
* `rematch: true` opts animated text into per-frame re-resolution.
|
|
12
|
+
* - a range spanning a wrap produces one rect per line segment.
|
|
13
|
+
* - string matches are whitespace-stripped consecutive box runs and must end
|
|
14
|
+
* boundary-exact (mid-segment end = error listing the actual segments);
|
|
15
|
+
* `[wordIndex, wordIndex]` ranges sidestep matching entirely.
|
|
16
|
+
*/
|
|
17
|
+
var TokenMatchError = class extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "TokenMatchError";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const stripWs = (s) => s.replace(/\s+/g, "");
|
|
24
|
+
/**
|
|
25
|
+
* Find the Nth whitespace-stripped consecutive run of boxes equal to token.
|
|
26
|
+
* Boundary-exact: a run that diverges mid-segment is not a match; ending
|
|
27
|
+
* mid-segment throws (with the real segment list) rather than half-boxing.
|
|
28
|
+
*/
|
|
29
|
+
function matchTokenRun(boxes, token, occurrence = 1) {
|
|
30
|
+
const want = stripWs(token);
|
|
31
|
+
if (want.length === 0) throw new TokenMatchError("empty token");
|
|
32
|
+
let seen = 0;
|
|
33
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
34
|
+
let acc = "";
|
|
35
|
+
for (let j = i; j < boxes.length; j++) {
|
|
36
|
+
acc += stripWs(boxes[j].text);
|
|
37
|
+
if (acc === want) {
|
|
38
|
+
seen++;
|
|
39
|
+
if (seen === occurrence) return [i, j];
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
if (!want.startsWith(acc)) break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new TokenMatchError(`no match for token '${token}'${occurrence > 1 ? ` (occurrence ${occurrence})` : ""} — segments are [${boxes.map((b) => `'${b.text}'`).join(", ")}]`);
|
|
46
|
+
}
|
|
47
|
+
var TokenHighlight = class extends Node {
|
|
48
|
+
target;
|
|
49
|
+
padding;
|
|
50
|
+
cornerRadius;
|
|
51
|
+
rematch;
|
|
52
|
+
ranges;
|
|
53
|
+
constructor(props) {
|
|
54
|
+
super(props);
|
|
55
|
+
this.target = props.text;
|
|
56
|
+
this.padding = props.padding ?? [4, 2];
|
|
57
|
+
this.cornerRadius = props.cornerRadius ?? 4;
|
|
58
|
+
this.rematch = props.rematch ?? false;
|
|
59
|
+
const boxes = this.target.wordBoxes();
|
|
60
|
+
this.ranges = props.ranges.map((spec, index) => {
|
|
61
|
+
const run = this.resolveRun(boxes, spec);
|
|
62
|
+
const id = spec.id ?? `r${index}`;
|
|
63
|
+
const r = {
|
|
64
|
+
spec,
|
|
65
|
+
fill: init(signal("#ffe066"), spec.fill),
|
|
66
|
+
opacity: init(signal(1), spec.opacity),
|
|
67
|
+
progress: init(signal(1), spec.progress),
|
|
68
|
+
scale: init(signal(1), spec.scale),
|
|
69
|
+
offset: initVec(vec2Signal([0, 0]), spec.offset),
|
|
70
|
+
run,
|
|
71
|
+
bound: runText(boxes, run)
|
|
72
|
+
};
|
|
73
|
+
this.registerTarget(`${id}/fill`, r.fill, "color");
|
|
74
|
+
this.registerTarget(`${id}/opacity`, r.opacity, "number");
|
|
75
|
+
this.registerTarget(`${id}/progress`, r.progress, "number");
|
|
76
|
+
this.registerTarget(`${id}/scale`, r.scale, "number");
|
|
77
|
+
this.registerTarget(`${id}/offset`, r.offset, "vec2");
|
|
78
|
+
this.registerTarget(`${id}/offset.x`, r.offset.x, "number");
|
|
79
|
+
this.registerTarget(`${id}/offset.y`, r.offset.y, "number");
|
|
80
|
+
return r;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
resolveRun(boxes, spec) {
|
|
84
|
+
if (typeof spec.match !== "string") {
|
|
85
|
+
const [from, to] = spec.match;
|
|
86
|
+
if (from < 0 || to >= boxes.length || from > to) throw new TokenMatchError(`word index range [${from}, ${to}] out of bounds (${boxes.length} boxes)`);
|
|
87
|
+
return [from, to];
|
|
88
|
+
}
|
|
89
|
+
return matchTokenRun(boxes, spec.match, spec.occurrence ?? 1);
|
|
90
|
+
}
|
|
91
|
+
draw(out, ctx) {
|
|
92
|
+
const boxes = this.target.wordBoxes(ctx.measurer);
|
|
93
|
+
if (boxes.length === 0) return;
|
|
94
|
+
const tm = this.target.localMatrix();
|
|
95
|
+
const [px, py] = this.padding;
|
|
96
|
+
let pushedTransform = false;
|
|
97
|
+
for (const r of this.ranges) {
|
|
98
|
+
const opacity = r.opacity();
|
|
99
|
+
if (opacity <= 0) continue;
|
|
100
|
+
const progress = Math.min(1, Math.max(0, r.progress()));
|
|
101
|
+
if (progress <= 0) continue;
|
|
102
|
+
let run = r.run;
|
|
103
|
+
if (this.rematch && typeof r.spec.match === "string") run = matchTokenRun(boxes, r.spec.match, r.spec.occurrence ?? 1);
|
|
104
|
+
else if (runText(boxes, run) !== r.bound) throw new TokenMatchError(`bound token '${r.bound}' no longer matches boxes [${run[0]}, ${run[1]}] — the text changed (segments now [${boxes.slice(run[0], run[1] + 1).map((b) => `'${b.text}'`).join(", ")}]); pass rematch: true for animated text`);
|
|
105
|
+
const lineRects = [];
|
|
106
|
+
for (let i = run[0]; i <= run[1]; i++) {
|
|
107
|
+
const b = boxes[i];
|
|
108
|
+
const last = lineRects[lineRects.length - 1];
|
|
109
|
+
const cur = i > run[0] && boxes[i - 1].line === b.line ? last : void 0;
|
|
110
|
+
if (cur) {
|
|
111
|
+
cur.w = b.x + b.w + px - cur.x;
|
|
112
|
+
cur.y = Math.min(cur.y, b.y - py);
|
|
113
|
+
cur.h = Math.max(cur.h, b.y + b.h + py - cur.y);
|
|
114
|
+
} else lineRects.push({
|
|
115
|
+
x: b.x - px,
|
|
116
|
+
y: b.y - py,
|
|
117
|
+
w: b.w + 2 * px,
|
|
118
|
+
h: b.h + 2 * py
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (!pushedTransform && !matEquals(tm, IDENTITY)) {
|
|
122
|
+
out.push({
|
|
123
|
+
op: "transform",
|
|
124
|
+
m: tm
|
|
125
|
+
});
|
|
126
|
+
pushedTransform = true;
|
|
127
|
+
}
|
|
128
|
+
const grouped = opacity < 1;
|
|
129
|
+
if (grouped) out.push({
|
|
130
|
+
op: "pushGroup",
|
|
131
|
+
opacity,
|
|
132
|
+
blend: "source-over",
|
|
133
|
+
filters: []
|
|
134
|
+
});
|
|
135
|
+
const fill = r.fill();
|
|
136
|
+
const scale = r.scale();
|
|
137
|
+
const [ox, oy] = r.offset();
|
|
138
|
+
let remaining = progress * lineRects.reduce((sum, q) => sum + q.w, 0);
|
|
139
|
+
for (const q of lineRects) {
|
|
140
|
+
const fillW = Math.min(q.w, remaining);
|
|
141
|
+
remaining -= fillW;
|
|
142
|
+
if (fillW <= 0) break;
|
|
143
|
+
const cx = q.x + q.w / 2 + ox;
|
|
144
|
+
const cy = q.y + q.h / 2 + oy;
|
|
145
|
+
const w = fillW * scale;
|
|
146
|
+
const h = q.h * scale;
|
|
147
|
+
const x = cx - q.w / 2 * scale;
|
|
148
|
+
const y = cy - h / 2;
|
|
149
|
+
const rad = Math.min(this.cornerRadius, w / 2, h / 2);
|
|
150
|
+
const path = out.resource({
|
|
151
|
+
kind: "path",
|
|
152
|
+
segs: roundedRectSegs(x, y, w, h, rad)
|
|
153
|
+
});
|
|
154
|
+
out.push({
|
|
155
|
+
op: "fillPath",
|
|
156
|
+
path,
|
|
157
|
+
paint: {
|
|
158
|
+
kind: "color",
|
|
159
|
+
color: fill
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
if (remaining <= 0) break;
|
|
163
|
+
}
|
|
164
|
+
if (grouped) out.push({ op: "popGroup" });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
function runText(boxes, run) {
|
|
169
|
+
return boxes.slice(run[0], run[1] + 1).map((b) => stripWs(b.text)).join("");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* `children: [tokenHighlight(para, { ranges: [{ match: '$48,200', fill: cat.money }] }), para]`
|
|
173
|
+
* — each range animates independently via '<id>/<rangeId>/fill|opacity|progress|scale'.
|
|
174
|
+
*/
|
|
175
|
+
function tokenHighlight(text, props) {
|
|
176
|
+
return new TokenHighlight({
|
|
177
|
+
...props,
|
|
178
|
+
text
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function init(sig, v) {
|
|
182
|
+
if (typeof v === "function") sig.bindSource(v);
|
|
183
|
+
else if (v !== void 0) sig.set(v);
|
|
184
|
+
return sig;
|
|
185
|
+
}
|
|
186
|
+
function initVec(sig, v) {
|
|
187
|
+
if (typeof v === "function") sig.bindSource(v);
|
|
188
|
+
else if (v !== void 0) sig.set(v);
|
|
189
|
+
return sig;
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
export { TokenHighlight, TokenMatchError, matchTokenRun, tokenHighlight };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/scene",
|
|
3
|
-
"version": "0.20.0-pre.
|
|
3
|
+
"version": "0.20.0-pre.5",
|
|
4
4
|
"description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"types": "./dist/diagnostics.d.ts",
|
|
42
42
|
"default": "./dist/diagnostics.js"
|
|
43
43
|
},
|
|
44
|
+
"./tokens": {
|
|
45
|
+
"types": "./dist/tokens.d.ts",
|
|
46
|
+
"default": "./dist/tokens.js"
|
|
47
|
+
},
|
|
44
48
|
"./motion": {
|
|
45
49
|
"types": "./dist/motion.d.ts",
|
|
46
50
|
"default": "./dist/motion.js"
|
|
@@ -55,7 +59,7 @@
|
|
|
55
59
|
],
|
|
56
60
|
"dependencies": {
|
|
57
61
|
"yoga-layout": "^3.2.1",
|
|
58
|
-
"@glissade/core": "0.20.0-pre.
|
|
62
|
+
"@glissade/core": "0.20.0-pre.5"
|
|
59
63
|
},
|
|
60
64
|
"repository": {
|
|
61
65
|
"type": "git",
|