@glissade/scene 0.18.0 → 0.19.0-pre.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/nodes.js CHANGED
@@ -1693,6 +1693,12 @@ var Text = class extends Node {
1693
1693
  width;
1694
1694
  lineHeight;
1695
1695
  reveal;
1696
+ /**
1697
+ * Reveal fraction in [0, 1]; NaN (the default) means "unset" so plain `reveal`
1698
+ * is authoritative and the node stays byte-identical to one without it. When
1699
+ * not-NaN it overrides `reveal` via {@link effectiveReveal}.
1700
+ */
1701
+ revealFraction;
1696
1702
  constructor(props = {}) {
1697
1703
  super(props);
1698
1704
  this.text = initProp(signal(""), props.text);
@@ -1705,11 +1711,27 @@ var Text = class extends Node {
1705
1711
  this.width = initProp(signal(0), props.width);
1706
1712
  this.lineHeight = props.lineHeight ?? 1.25;
1707
1713
  this.reveal = initProp(signal(Number.POSITIVE_INFINITY), props.reveal);
1714
+ this.revealFraction = initProp(signal(NaN), props.revealFraction);
1708
1715
  this.registerTarget("width", this.width, "number");
1709
1716
  this.registerTarget("text", this.text, "string");
1710
1717
  this.registerTarget("fill", this.fill, "color");
1711
1718
  this.registerTarget("fontSize", this.fontSize, "number");
1712
1719
  this.registerTarget("reveal", this.reveal, "number");
1720
+ this.registerTarget("revealFraction", this.revealFraction, "number");
1721
+ }
1722
+ /**
1723
+ * The grapheme COUNT to reveal this frame — the single source the draw mask,
1724
+ * {@link revealHead}, and the masked emit path all read. When `revealFraction`
1725
+ * is set (not NaN) it wins: `round(clamp(fraction, 0, 1) * graphemeCount)`,
1726
+ * resolved against the SAME laid-out grapheme stream `reveal` counts. Unset
1727
+ * (NaN) it falls straight through to `reveal()`, so a node without
1728
+ * `revealFraction` is byte-identical to before this prop existed.
1729
+ */
1730
+ effectiveReveal(measurer) {
1731
+ const frac = this.revealFraction();
1732
+ if (Number.isNaN(frac)) return this.reveal();
1733
+ const total = this.graphemes(measurer).length;
1734
+ return Math.round((frac <= 0 ? 0 : frac >= 1 ? 1 : frac) * total);
1713
1735
  }
1714
1736
  intrinsicSize(measurer) {
1715
1737
  const text = this.text();
@@ -1845,6 +1867,58 @@ var Text = class extends Node {
1845
1867
  return boxes;
1846
1868
  }
1847
1869
  /**
1870
+ * Per-grapheme ink boxes within each laid-out line — the per-grapheme analogue
1871
+ * of {@link wordBoxes}, boxing the SAME grapheme units `reveal`/`graphemes()`
1872
+ * count (`Intl.Segmenter` boundaries via `segmentGraphemes`, so emoji/ZWJ
1873
+ * sequences stay whole). Positioned by cumulative prefix advances so
1874
+ * cross-grapheme kerning is exact and the boxes' advances sum to the line
1875
+ * width — the boundaries MATCH the draw path, so splitText goldens don't
1876
+ * drift. Whitespace graphemes advance but produce no box (dropped), exactly
1877
+ * as `wordBoxes()` trims whitespace advance. The substrate `splitText({ by:
1878
+ * 'grapheme' })` snapshots.
1879
+ */
1880
+ graphemeBoxes(measurer) {
1881
+ const m = measurer ?? this.measurerSource?.() ?? fallbackMeasurer();
1882
+ const text = this.text();
1883
+ if (!text) return [];
1884
+ const font = {
1885
+ family: this.fontFamily,
1886
+ size: this.fontSize(),
1887
+ weight: this.fontWeight,
1888
+ ...this.fontStyle === "italic" ? { style: "italic" } : {}
1889
+ };
1890
+ const maxWidth = this.width();
1891
+ const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
1892
+ const step = quantize(font.size * this.lineHeight);
1893
+ const boxes = [];
1894
+ for (let i = 0; i < lines.length; i++) {
1895
+ const line = lines[i];
1896
+ if (!line) continue;
1897
+ const met = m.measureText(line, font);
1898
+ const lineW = quantize(met.width);
1899
+ const lineX = this.align === "left" ? 0 : this.align === "center" ? -lineW / 2 : -lineW;
1900
+ const y = i * step - met.ascent;
1901
+ const h = met.ascent + met.descent;
1902
+ let prefix = "";
1903
+ for (const g of segmentGraphemes(line)) {
1904
+ const start = prefix;
1905
+ prefix += g;
1906
+ if (g.trim() === "") continue;
1907
+ const before = m.measureText(start, font).width;
1908
+ const after = m.measureText(start + g, font).width;
1909
+ boxes.push({
1910
+ text: g,
1911
+ line: i,
1912
+ x: lineX + before,
1913
+ y,
1914
+ w: after - before,
1915
+ h
1916
+ });
1917
+ }
1918
+ }
1919
+ return boxes;
1920
+ }
1921
+ /**
1848
1922
  * The laid-out grapheme stream the typewriter reveal advances over — every
1849
1923
  * grapheme of every wrapped line, in reading order (soft-wrap whitespace is
1850
1924
  * dropped by the breaker, exactly as drawn, so draw/revealHead/revealSchedule
@@ -1889,7 +1963,7 @@ var Text = class extends Node {
1889
1963
  const lines = breakLines(this.text(), font, maxWidth > 0 ? maxWidth : void 0, m);
1890
1964
  const step = quantize(font.size * this.lineHeight);
1891
1965
  const total = lines.reduce((n, l) => n + segmentGraphemes(l).length, 0);
1892
- const revealRaw = this.reveal();
1966
+ const revealRaw = this.effectiveReveal(m);
1893
1967
  const shown = Math.max(0, Math.min(Number.isFinite(revealRaw) ? Math.floor(revealRaw) : total, total));
1894
1968
  let remaining = shown;
1895
1969
  let last = {
@@ -1937,7 +2011,7 @@ var Text = class extends Node {
1937
2011
  const maxWidth = this.width();
1938
2012
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
1939
2013
  const step = quantize(font.size * this.lineHeight);
1940
- const revealRaw = this.reveal();
2014
+ const revealRaw = this.effectiveReveal(ctx.measurer);
1941
2015
  const masked = Number.isFinite(revealRaw);
1942
2016
  let remaining = masked ? Math.max(0, Math.floor(revealRaw)) : 0;
1943
2017
  for (let i = 0; i < lines.length; i++) {
package/dist/type.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { W as TextMeasurer, _ as WordBox, i as Group, m as TextProps, p as Text, r as GraphemeBox, s as LineBox } from "./nodes.js";
2
+
3
+ //#region src/type.d.ts
4
+
5
+ type SplitBy = 'word' | 'line' | 'grapheme';
6
+ interface SplitTextOpts {
7
+ /** What unit to split into. Default 'word'. */
8
+ by?: SplitBy;
9
+ /**
10
+ * Stable id prefix for the wrapping group and its parts (`${id}/${i}`). When
11
+ * omitted, falls back to the source Text's own `id` (and throws if neither is
12
+ * set — a split needs a stable id namespace to bind tracks against).
13
+ */
14
+ id?: string;
15
+ /**
16
+ * The measurer to snapshot part geometry with. Defaults to the source's
17
+ * injected measurer, then the process fallback — exactly the chain the other
18
+ * Text geometry getters use.
19
+ */
20
+ measurer?: TextMeasurer;
21
+ }
22
+ /** One part of a split, in the source Text's draw space (group-local coords). */
23
+ interface SplitPart {
24
+ /** The part's text (a word, a full line, or a single grapheme). */
25
+ text: string;
26
+ /** The generated child node (a left-aligned Text positioned at the part). */
27
+ node: Text;
28
+ /** Laid-out line index the part came from. */
29
+ line: number;
30
+ /** Part ink box, in the source's draw space (== the child's group-local box). */
31
+ box: {
32
+ x: number;
33
+ y: number;
34
+ w: number;
35
+ h: number;
36
+ };
37
+ }
38
+ interface SplitTextResult {
39
+ /** The wrapping group (`id`), positioned where the source sat — draw THIS. */
40
+ node: Group;
41
+ /** The generated part children, in reading order. */
42
+ children: Text[];
43
+ /** Per-part geometry + node, in reading order. */
44
+ parts: SplitPart[];
45
+ }
46
+ declare class SplitTextError extends Error {
47
+ constructor(message: string);
48
+ }
49
+ /**
50
+ * Expand a Text (instance or props) into a `Group` of positioned per-part child
51
+ * Texts — one per word / line / grapheme. PURE build-time expansion to ordinary
52
+ * nodes; the part geometry is a STATIC snapshot of the source's current layout.
53
+ *
54
+ * const split = splitText(title, { by: 'word', id: 'title' });
55
+ * // scene children: [split.node] (REPLACES the original title)
56
+ * // animate each word: split.children[i] / track('title/0/opacity', …)
57
+ *
58
+ * Stagger a word-by-word reveal by fanning a clip across `split.children`, or
59
+ * compose with `tl.stagger` over the `${id}/${i}` ids.
60
+ */
61
+ declare function splitText(source: Text | TextProps, opts?: SplitTextOpts): SplitTextResult;
62
+ //#endregion
63
+ export { type GraphemeBox, type LineBox, SplitBy, SplitPart, SplitTextError, SplitTextOpts, SplitTextResult, type WordBox, splitText };
package/dist/type.js ADDED
@@ -0,0 +1,120 @@
1
+ import { r as Group, s as Text, v as fallbackMeasurer, y as quantize } from "./nodes.js";
2
+ //#region src/type.ts
3
+ /**
4
+ * `@glissade/scene/type` — `splitText()`: build-time split-text sub-targets
5
+ * (0.19 kinetic-typography tier). A PURE PRE-EVALUATE EXPANSION: it expands a
6
+ * Text into a `Group` of independently addressable child Texts — one per word,
7
+ * line, or grapheme, id `${id}/${i}` — so each part animates on its own (stagger
8
+ * a word-by-word reveal, scatter graphemes, …). Like `each()`, nothing executes
9
+ * at play time: it snapshots the source's laid-out part geometry ONCE and emits
10
+ * ordinary positioned nodes, so `evaluate()` stays a pure function of time and
11
+ * the goldens hold by construction.
12
+ *
13
+ * SEPARATE entry point with its own budget (mirrors `each()`/`scene/layout`/
14
+ * `scene/path`) — the base embed never pays for it.
15
+ *
16
+ * Authoring semantics (the blessed defaults):
17
+ * - STATIC SNAPSHOT: part boxes are captured at build time from the source's
18
+ * measurer. Animating the source's width/fontSize after the split will NOT
19
+ * reflow the parts — the documented `each()` tradeoff. Re-`splitText()` on a
20
+ * changed source if you need a different layout.
21
+ * - REPLACE the source: this returns the Group of parts to draw INSTEAD of the
22
+ * original Text. Don't also add the source to the scene, or it double-draws.
23
+ */
24
+ var SplitTextError = class extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "SplitTextError";
28
+ }
29
+ };
30
+ /**
31
+ * Expand a Text (instance or props) into a `Group` of positioned per-part child
32
+ * Texts — one per word / line / grapheme. PURE build-time expansion to ordinary
33
+ * nodes; the part geometry is a STATIC snapshot of the source's current layout.
34
+ *
35
+ * const split = splitText(title, { by: 'word', id: 'title' });
36
+ * // scene children: [split.node] (REPLACES the original title)
37
+ * // animate each word: split.children[i] / track('title/0/opacity', …)
38
+ *
39
+ * Stagger a word-by-word reveal by fanning a clip across `split.children`, or
40
+ * compose with `tl.stagger` over the `${id}/${i}` ids.
41
+ */
42
+ function splitText(source, opts = {}) {
43
+ const text = source instanceof Text ? source : new Text(source);
44
+ const id = opts.id ?? text.id;
45
+ if (id === void 0) throw new SplitTextError("splitText() needs a stable id — pass { id } or give the source Text an id (the parts bind tracks against ${id}/${i})");
46
+ const by = opts.by ?? "word";
47
+ const m = opts.measurer ?? text.measurerSource?.() ?? fallbackMeasurer();
48
+ const font = {
49
+ fontFamily: text.fontFamily,
50
+ fontSize: text.fontSize(),
51
+ fontWeight: text.fontWeight,
52
+ fontStyle: text.fontStyle,
53
+ fill: text.fill(),
54
+ lineHeight: text.lineHeight
55
+ };
56
+ const step = quantize(font.fontSize * font.lineHeight);
57
+ const boxes = by === "line" ? text.lineBoxes(m).map((b, i) => ({
58
+ text: b.text,
59
+ line: i,
60
+ x: b.x,
61
+ y: b.y,
62
+ w: b.w,
63
+ h: b.h
64
+ })) : by === "word" ? text.wordBoxes(m).map((b) => ({
65
+ text: b.text,
66
+ line: b.line,
67
+ x: b.x,
68
+ y: b.y,
69
+ w: b.w,
70
+ h: b.h
71
+ })) : text.graphemeBoxes(m).map((b) => ({
72
+ text: b.text,
73
+ line: b.line,
74
+ x: b.x,
75
+ y: b.y,
76
+ w: b.w,
77
+ h: b.h
78
+ }));
79
+ const children = [];
80
+ const parts = [];
81
+ for (let i = 0; i < boxes.length; i++) {
82
+ const b = boxes[i];
83
+ const node = new Text({
84
+ id: `${id}/${i}`,
85
+ text: b.text,
86
+ fill: font.fill,
87
+ fontFamily: font.fontFamily,
88
+ fontSize: font.fontSize,
89
+ fontWeight: font.fontWeight,
90
+ ...font.fontStyle === "italic" ? { fontStyle: "italic" } : {},
91
+ lineHeight: font.lineHeight,
92
+ align: "left",
93
+ position: [b.x, b.line * step]
94
+ });
95
+ children.push(node);
96
+ parts.push({
97
+ text: b.text,
98
+ node,
99
+ line: b.line,
100
+ box: {
101
+ x: b.x,
102
+ y: b.y,
103
+ w: b.w,
104
+ h: b.h
105
+ }
106
+ });
107
+ }
108
+ const [px, py] = text.position();
109
+ return {
110
+ node: new Group({
111
+ id,
112
+ children,
113
+ position: [px, py]
114
+ }),
115
+ children,
116
+ parts
117
+ };
118
+ }
119
+ //#endregion
120
+ export { SplitTextError, splitText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.18.0",
3
+ "version": "0.19.0-pre.1",
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": {
@@ -24,6 +24,10 @@
24
24
  "./describe": {
25
25
  "types": "./dist/describe.d.ts",
26
26
  "default": "./dist/describe.js"
27
+ },
28
+ "./type": {
29
+ "types": "./dist/type.d.ts",
30
+ "default": "./dist/type.js"
27
31
  }
28
32
  },
29
33
  "files": [
@@ -31,7 +35,7 @@
31
35
  ],
32
36
  "dependencies": {
33
37
  "yoga-layout": "^3.2.1",
34
- "@glissade/core": "0.18.0"
38
+ "@glissade/core": "0.19.0-pre.1"
35
39
  },
36
40
  "repository": {
37
41
  "type": "git",