@phin-tech/dottie 0.6.3 → 0.7.0

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/PARITY.md CHANGED
@@ -47,6 +47,9 @@ fixture, so the gap is visible rather than silent.
47
47
  | Symbol parameters (`{{name}}` in a symbol's text, filled per copy) | Supported | [edge/params](../fixtures/edge/params.dottie) | Defaults, undeclared names left as written, values passed into nested copies, filled before text types out |
48
48
  | Copies that play when clicked | Supported | [edge/buttons](../fixtures/edge/buttons.dottie) | `<dottie-player>` only: the HTML export is drawn ahead of time and shows them at rest |
49
49
  | Clicks: play a scene, open a link, hover looks, keyboard | Supported | [edge/clicks](../fixtures/edge/clicks.dottie) | As the HTML export does; only http, https and mailto links are followed |
50
+ | Page variables and conditions (`variables`, `when`) | Supported | [edge/state](../fixtures/edge/state.dottie) | Starting values in every still and export; connectors go with a shape the state hides |
51
+ | Clicks that set and toggle variables | Supported | [edge/state](../fixtures/edge/state.dottie) | Change what shows without touching the playhead. The HTML export of a page with state is this player |
52
+ | Looping scenes (`loop` on a marker) | Supported | [edge/state](../fixtures/edge/state.dottie) | As the HTML export plays them: no stop on entering, round from the scene's end to its start |
50
53
  | Links out of the drawing (`url`) | Supported | [edge/links](../fixtures/edge/links.dottie) | A web or mail `url` opens when clicked if there is no `click`, and SVG export wraps it in `<a>`; a link to another drawing does nothing on the web |
51
54
  | Links between shapes and pages | Partial | [edge/clicks](../fixtures/edge/clicks.dottie) | A link to a shape on another page shows that page; one on the same page does nothing. The HTML export holds one page, so it has neither |
52
55
  | Editing | Missing | | |
package/README.md CHANGED
@@ -33,11 +33,16 @@ Elements the drawing makes clickable work with the mouse and the keyboard (Tab,
33
33
  a named scene on to the next one, opens a link in a new tab (only `http`, `https` and `mailto`), or shows the page a
34
34
  shape's link points at. Each shows its hover look under the pointer; button symbols show their over and down looks
35
35
  and take clicks on their hit area, and a symbol's copy set to play when clicked plays its animation through. Every
36
- click first fires a cancelable `action` event whose `detail` is `{element, scene | url | page, replay?}`; call
37
- `preventDefault()` to handle it yourself.
36
+ click first fires a cancelable `action` event whose `detail` is `{element, scene | url | page | state, set?, toggle?,
37
+ replay?}`; call `preventDefault()` to handle it yourself.
38
+
39
+ A click can also set or toggle the page's variables, which show and hide the elements with a `when` while the
40
+ animation keeps playing: a diagram you leave running that people poke. A looping scene plays round until a click
41
+ or next leaves it, instead of stopping.
38
42
 
39
43
  The element has `play()`, `pause()`, `nextScene()`, `prevScene()`, `activate(elementId)`, `currentTime`
40
- (settable), `duration`, `paused`, and fires `load`, `frame`, `action` and `error` events. Its page color comes from the drawing's theme; set
44
+ (settable), `duration`, `paused`, `variables` and `setState({name: value})`, and fires `load`, `frame`, `action`,
45
+ `state` (with `{variables}`, after a change) and `error` events. Its page color comes from the drawing's theme; set
41
46
  `--dottie-radius` to round it, and style its parts with `::part(stage)` and `::part(controls)`.
42
47
 
43
48
  Without modules, `dist/dottie-player.min.js` is a classic script that registers the element and sets
package/dist/compile.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! dottie.js 0.6.3 | Apache-2.0 | https://github.com/phin-tech/dottie */
1
+ /*! dottie.js 0.7.0 | Apache-2.0 | https://github.com/phin-tech/dottie */
2
2
  var __defProp = Object.defineProperty;
3
3
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
4
4
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
@@ -44,13 +44,19 @@ function defaultBase() {
44
44
  }
45
45
  var base = defaultBase();
46
46
 
47
+ // src/state.ts
48
+ var MAX_NAME = 64;
49
+ function isName(name) {
50
+ return name.length <= MAX_NAME && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
51
+ }
52
+
47
53
  // src/model.ts
48
54
  var SUPPORTED_VERSION = 2;
49
55
  var KINDS = ["rectangle", "ellipse", "diamond", "text", "connector", "freehand", "path", "note", "image", "symbol"];
50
56
  var EASES = ["linear", "easeIn", "easeOut", "easeInOut", "hold", "spring", "bounce", "elastic"];
51
57
  var KNOWN_FIELDS = {
52
58
  file: ["version", "pages", "images"],
53
- page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols"],
59
+ page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols", "variables"],
54
60
  symbol: ["id", "name", "width", "height", "layers", "elements", "timeline", "button", "params"],
55
61
  layer: ["id", "name", "visible", "locked"],
56
62
  rgba: ["r", "g", "b", "a"],
@@ -78,6 +84,7 @@ var KNOWN_FIELDS = {
78
84
  "anchor",
79
85
  "click",
80
86
  "hover",
87
+ "when",
81
88
  "symbol",
82
89
  "playback",
83
90
  "offset",
@@ -103,8 +110,8 @@ var KNOWN_FIELDS = {
103
110
  follow: ["path", "start", "end", "ease", "reverse", "orient", "laps"],
104
111
  repeat: ["start", "end", "count"],
105
112
  cameraKey: ["t", "x", "y", "width", "height", "ease"],
106
- marker: ["t", "name"],
107
- click: ["scene", "url"],
113
+ marker: ["t", "name", "loop"],
114
+ click: ["scene", "url", "set", "toggle"],
108
115
  hover: ["stroke", "fill", "opacity"]
109
116
  };
110
117
  var MAX_DURATION = 3600;
@@ -258,6 +265,7 @@ var Reader = class {
258
265
  anchor: str(v.anchor) ?? null,
259
266
  click: this.click(v.click),
260
267
  hover: this.hover(v.hover),
268
+ when: str(v.when) ?? null,
261
269
  symbol: uuid(v.symbol) ?? null,
262
270
  playback: this.playback(v.playback),
263
271
  offset: num(v.offset) ?? null,
@@ -293,13 +301,27 @@ var Reader = class {
293
301
  }
294
302
  return { id, shape, version, params };
295
303
  }
304
+ variables(v) {
305
+ if (!isObj(v)) return null;
306
+ const out = {};
307
+ for (const [k, b] of Object.entries(v)) if (typeof b === "boolean") out[k] = b;
308
+ return Object.keys(out).length > 0 ? out : null;
309
+ }
296
310
  click(v) {
297
311
  if (!isObj(v)) return null;
298
312
  this.fields(v, "click");
299
313
  const url = str(v.url);
300
314
  const safe = url === void 0 ? null : allowedURL(url);
301
315
  if (url !== void 0 && safe === null) this.warn("Skipped a click link that isn't http, https or mailto");
302
- return { scene: str(v.scene) ?? null, url: safe };
316
+ let set = null;
317
+ if (isObj(v.set)) {
318
+ set = {};
319
+ for (const [k, b] of Object.entries(v.set)) if (typeof b === "boolean" && isName(k)) set[k] = b;
320
+ if (Object.keys(set).length === 0) set = null;
321
+ }
322
+ const raw = typeof v.toggle === "string" ? [v.toggle] : Array.isArray(v.toggle) && v.toggle.every((x) => typeof x === "string") ? v.toggle : null;
323
+ const toggle = raw ? raw.filter(isName) : null;
324
+ return { scene: str(v.scene) ?? null, url: safe, set, toggle: toggle && toggle.length > 0 ? toggle : null };
303
325
  }
304
326
  playback(v) {
305
327
  const s = str(v);
@@ -400,7 +422,7 @@ var Reader = class {
400
422
  if (camera) stableSortBy(camera, (c) => c.t);
401
423
  const markers = Array.isArray(v.markers) ? v.markers.filter(isObj).flatMap((m) => {
402
424
  const t = num(m.t);
403
- return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "" }] : [];
425
+ return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "", loop: bool(m.loop) ?? null }] : [];
404
426
  }) : null;
405
427
  if (markers) stableSortBy(markers, (m) => m.t);
406
428
  const poster = num(v.poster);
@@ -433,7 +455,8 @@ var Reader = class {
433
455
  elements: (Array.isArray(v.elements) ? v.elements : []).map((e) => this.element(e, images)).filter((e) => e !== null),
434
456
  theme: str(v.theme) ?? null,
435
457
  timeline: this.timeline(v.timeline),
436
- symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null
458
+ symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null,
459
+ variables: this.variables(v.variables)
437
460
  };
438
461
  }
439
462
  };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- /*! dottie.js 0.6.3 | Apache-2.0 | https://github.com/phin-tech/dottie */
2
+ /*! dottie.js 0.7.0 | Apache-2.0 | https://github.com/phin-tech/dottie */
3
3
  var __defProp = Object.defineProperty;
4
4
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
5
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
@@ -52,13 +52,19 @@ function defaultBase() {
52
52
  }
53
53
  var base = defaultBase();
54
54
 
55
+ // src/state.ts
56
+ var MAX_NAME = 64;
57
+ function isName(name) {
58
+ return name.length <= MAX_NAME && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
59
+ }
60
+
55
61
  // src/model.ts
56
62
  var SUPPORTED_VERSION = 2;
57
63
  var KINDS = ["rectangle", "ellipse", "diamond", "text", "connector", "freehand", "path", "note", "image", "symbol"];
58
64
  var EASES = ["linear", "easeIn", "easeOut", "easeInOut", "hold", "spring", "bounce", "elastic"];
59
65
  var KNOWN_FIELDS = {
60
66
  file: ["version", "pages", "images"],
61
- page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols"],
67
+ page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols", "variables"],
62
68
  symbol: ["id", "name", "width", "height", "layers", "elements", "timeline", "button", "params"],
63
69
  layer: ["id", "name", "visible", "locked"],
64
70
  rgba: ["r", "g", "b", "a"],
@@ -86,6 +92,7 @@ var KNOWN_FIELDS = {
86
92
  "anchor",
87
93
  "click",
88
94
  "hover",
95
+ "when",
89
96
  "symbol",
90
97
  "playback",
91
98
  "offset",
@@ -111,8 +118,8 @@ var KNOWN_FIELDS = {
111
118
  follow: ["path", "start", "end", "ease", "reverse", "orient", "laps"],
112
119
  repeat: ["start", "end", "count"],
113
120
  cameraKey: ["t", "x", "y", "width", "height", "ease"],
114
- marker: ["t", "name"],
115
- click: ["scene", "url"],
121
+ marker: ["t", "name", "loop"],
122
+ click: ["scene", "url", "set", "toggle"],
116
123
  hover: ["stroke", "fill", "opacity"]
117
124
  };
118
125
  var MAX_DURATION = 3600;
@@ -266,6 +273,7 @@ var Reader = class {
266
273
  anchor: str(v.anchor) ?? null,
267
274
  click: this.click(v.click),
268
275
  hover: this.hover(v.hover),
276
+ when: str(v.when) ?? null,
269
277
  symbol: uuid(v.symbol) ?? null,
270
278
  playback: this.playback(v.playback),
271
279
  offset: num(v.offset) ?? null,
@@ -301,13 +309,27 @@ var Reader = class {
301
309
  }
302
310
  return { id, shape, version, params };
303
311
  }
312
+ variables(v) {
313
+ if (!isObj(v)) return null;
314
+ const out = {};
315
+ for (const [k, b] of Object.entries(v)) if (typeof b === "boolean") out[k] = b;
316
+ return Object.keys(out).length > 0 ? out : null;
317
+ }
304
318
  click(v) {
305
319
  if (!isObj(v)) return null;
306
320
  this.fields(v, "click");
307
321
  const url = str(v.url);
308
322
  const safe = url === void 0 ? null : allowedURL(url);
309
323
  if (url !== void 0 && safe === null) this.warn("Skipped a click link that isn't http, https or mailto");
310
- return { scene: str(v.scene) ?? null, url: safe };
324
+ let set = null;
325
+ if (isObj(v.set)) {
326
+ set = {};
327
+ for (const [k, b] of Object.entries(v.set)) if (typeof b === "boolean" && isName(k)) set[k] = b;
328
+ if (Object.keys(set).length === 0) set = null;
329
+ }
330
+ const raw = typeof v.toggle === "string" ? [v.toggle] : Array.isArray(v.toggle) && v.toggle.every((x) => typeof x === "string") ? v.toggle : null;
331
+ const toggle = raw ? raw.filter(isName) : null;
332
+ return { scene: str(v.scene) ?? null, url: safe, set, toggle: toggle && toggle.length > 0 ? toggle : null };
311
333
  }
312
334
  playback(v) {
313
335
  const s = str(v);
@@ -408,7 +430,7 @@ var Reader = class {
408
430
  if (camera) stableSortBy(camera, (c) => c.t);
409
431
  const markers = Array.isArray(v.markers) ? v.markers.filter(isObj).flatMap((m) => {
410
432
  const t = num(m.t);
411
- return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "" }] : [];
433
+ return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "", loop: bool(m.loop) ?? null }] : [];
412
434
  }) : null;
413
435
  if (markers) stableSortBy(markers, (m) => m.t);
414
436
  const poster = num(v.poster);
@@ -441,7 +463,8 @@ var Reader = class {
441
463
  elements: (Array.isArray(v.elements) ? v.elements : []).map((e) => this.element(e, images)).filter((e) => e !== null),
442
464
  theme: str(v.theme) ?? null,
443
465
  timeline: this.timeline(v.timeline),
444
- symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null
466
+ symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null,
467
+ variables: this.variables(v.variables)
445
468
  };
446
469
  }
447
470
  };
@@ -588,7 +611,7 @@ function compile(source2, runtime, options2 = {}) {
588
611
  }
589
612
 
590
613
  // src/index.ts
591
- var VERSION = "0.6.3";
614
+ var VERSION = "0.7.0";
592
615
 
593
616
  // src/cli.ts
594
617
  var usage = `dottie-compile ${VERSION}: a Dottie drawing as one script that plays it on any page.
@@ -1,4 +1,4 @@
1
- /*! dottie.js 0.6.3 | Apache-2.0 | https://github.com/phin-tech/dottie */
1
+ /*! dottie.js 0.7.0 | Apache-2.0 | https://github.com/phin-tech/dottie */
2
2
  var __defProp = Object.defineProperty;
3
3
  var __defNormalProp = (obj, key, value2) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value: value2 }) : obj[key] = value2;
4
4
  var __export = (target, all) => {
@@ -66,13 +66,67 @@ __export(index_exports, {
66
66
  withRoughness: () => withRoughness
67
67
  });
68
68
 
69
+ // src/state.ts
70
+ var MAX_NAME = 64;
71
+ function isName(name) {
72
+ return name.length <= MAX_NAME && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
73
+ }
74
+ function conditionNames(condition) {
75
+ const out = [];
76
+ for (const any of condition.split("||")) {
77
+ for (const all of any.split("&&")) {
78
+ let term = trim(all);
79
+ if (term.startsWith("!")) term = trim(term.slice(1));
80
+ if (!isName(term)) return null;
81
+ out.push(term);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ function holds(condition, state) {
87
+ if (conditionNames(condition) === null) return false;
88
+ return condition.split("||").some((any) => any.split("&&").every((all) => {
89
+ let term = trim(all);
90
+ let negated = false;
91
+ if (term.startsWith("!")) {
92
+ negated = true;
93
+ term = trim(term.slice(1));
94
+ }
95
+ return (state[term] ?? false) !== negated;
96
+ }));
97
+ }
98
+ function stateValues(page, choice) {
99
+ if (choice === "all") return null;
100
+ if (choice === "initial") return { ...page.variables ?? {} };
101
+ return { ...page.variables ?? {}, ...choice };
102
+ }
103
+ function stateHidden(page, choice) {
104
+ const values = stateValues(page, choice);
105
+ const out = /* @__PURE__ */ new Set();
106
+ if (!values) return out;
107
+ for (const e of page.elements) if (e.when !== null && !holds(e.when, values)) out.add(e.id);
108
+ return out;
109
+ }
110
+ function changesState(c) {
111
+ return !!c && (Object.keys(c.set ?? {}).length > 0 || (c.toggle ?? []).length > 0);
112
+ }
113
+ function applyClick(c, state) {
114
+ const out = { ...state };
115
+ for (const [k, v] of Object.entries(c.set ?? {})) out[k] = v;
116
+ for (const k of c.toggle ?? []) out[k] = !(out[k] ?? false);
117
+ return out;
118
+ }
119
+ function trim(s) {
120
+ return s.replace(/^[ \t ]+|[ \t ]+$/g, "");
121
+ }
122
+
69
123
  // src/model.ts
70
124
  var SUPPORTED_VERSION = 2;
71
125
  var KINDS = ["rectangle", "ellipse", "diamond", "text", "connector", "freehand", "path", "note", "image", "symbol"];
72
126
  var EASES = ["linear", "easeIn", "easeOut", "easeInOut", "hold", "spring", "bounce", "elastic"];
73
127
  var KNOWN_FIELDS = {
74
128
  file: ["version", "pages", "images"],
75
- page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols"],
129
+ page: ["id", "name", "layers", "elements", "theme", "guidesX", "guidesY", "guidesLocked", "timeline", "symbols", "variables"],
76
130
  symbol: ["id", "name", "width", "height", "layers", "elements", "timeline", "button", "params"],
77
131
  layer: ["id", "name", "visible", "locked"],
78
132
  rgba: ["r", "g", "b", "a"],
@@ -100,6 +154,7 @@ var KNOWN_FIELDS = {
100
154
  "anchor",
101
155
  "click",
102
156
  "hover",
157
+ "when",
103
158
  "symbol",
104
159
  "playback",
105
160
  "offset",
@@ -125,8 +180,8 @@ var KNOWN_FIELDS = {
125
180
  follow: ["path", "start", "end", "ease", "reverse", "orient", "laps"],
126
181
  repeat: ["start", "end", "count"],
127
182
  cameraKey: ["t", "x", "y", "width", "height", "ease"],
128
- marker: ["t", "name"],
129
- click: ["scene", "url"],
183
+ marker: ["t", "name", "loop"],
184
+ click: ["scene", "url", "set", "toggle"],
130
185
  hover: ["stroke", "fill", "opacity"]
131
186
  };
132
187
  var MAX_DURATION = 3600;
@@ -285,6 +340,7 @@ var Reader = class {
285
340
  anchor: str(v.anchor) ?? null,
286
341
  click: this.click(v.click),
287
342
  hover: this.hover(v.hover),
343
+ when: str(v.when) ?? null,
288
344
  symbol: uuid(v.symbol) ?? null,
289
345
  playback: this.playback(v.playback),
290
346
  offset: num(v.offset) ?? null,
@@ -320,13 +376,27 @@ var Reader = class {
320
376
  }
321
377
  return { id, shape, version, params };
322
378
  }
379
+ variables(v) {
380
+ if (!isObj(v)) return null;
381
+ const out = {};
382
+ for (const [k, b] of Object.entries(v)) if (typeof b === "boolean") out[k] = b;
383
+ return Object.keys(out).length > 0 ? out : null;
384
+ }
323
385
  click(v) {
324
386
  if (!isObj(v)) return null;
325
387
  this.fields(v, "click");
326
388
  const url = str(v.url);
327
389
  const safe = url === void 0 ? null : allowedURL(url);
328
390
  if (url !== void 0 && safe === null) this.warn("Skipped a click link that isn't http, https or mailto");
329
- return { scene: str(v.scene) ?? null, url: safe };
391
+ let set = null;
392
+ if (isObj(v.set)) {
393
+ set = {};
394
+ for (const [k, b] of Object.entries(v.set)) if (typeof b === "boolean" && isName(k)) set[k] = b;
395
+ if (Object.keys(set).length === 0) set = null;
396
+ }
397
+ const raw = typeof v.toggle === "string" ? [v.toggle] : Array.isArray(v.toggle) && v.toggle.every((x) => typeof x === "string") ? v.toggle : null;
398
+ const toggle = raw ? raw.filter(isName) : null;
399
+ return { scene: str(v.scene) ?? null, url: safe, set, toggle: toggle && toggle.length > 0 ? toggle : null };
330
400
  }
331
401
  playback(v) {
332
402
  const s = str(v);
@@ -427,7 +497,7 @@ var Reader = class {
427
497
  if (camera) stableSortBy(camera, (c) => c.t);
428
498
  const markers = Array.isArray(v.markers) ? v.markers.filter(isObj).flatMap((m) => {
429
499
  const t = num(m.t);
430
- return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "" }] : [];
500
+ return t !== void 0 && t >= 0 ? [{ t, name: str(m.name) ?? "", loop: bool(m.loop) ?? null }] : [];
431
501
  }) : null;
432
502
  if (markers) stableSortBy(markers, (m) => m.t);
433
503
  const poster = num(v.poster);
@@ -460,7 +530,8 @@ var Reader = class {
460
530
  elements: (Array.isArray(v.elements) ? v.elements : []).map((e) => this.element(e, images)).filter((e) => e !== null),
461
531
  theme: str(v.theme) ?? null,
462
532
  timeline: this.timeline(v.timeline),
463
- symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null
533
+ symbols: Array.isArray(v.symbols) ? v.symbols.map((x) => this.symbol(x, images)).filter((x) => x !== null) : null,
534
+ variables: this.variables(v.variables)
464
535
  };
465
536
  }
466
537
  };
@@ -2130,17 +2201,20 @@ function trackMap(tl) {
2130
2201
  for (const t of tl.tracks) if (!m.has(t.element)) m.set(t.element, t);
2131
2202
  return m;
2132
2203
  }
2133
- function resolved(page, t) {
2134
- return expandingSymbols(posed(page, t), clampTime(t));
2204
+ function resolved(page, t, state = "initial") {
2205
+ return expandingSymbols(posed(page, t, state), clampTime(t));
2135
2206
  }
2136
- function posed(page, t) {
2137
- const timeline = page.timeline;
2207
+ var EMPTY_TIMELINE = { fps: 12, duration: 0, boil: null, settle: null, poster: null, tracks: [], camera: null, markers: null };
2208
+ function posed(page, t, state = "initial") {
2209
+ const off = stateHidden(page, state);
2210
+ const timeline = page.timeline ?? (off.size === 0 ? null : EMPTY_TIMELINE);
2138
2211
  if (!timeline) return page;
2139
2212
  t = clampTime(t);
2140
2213
  const tracks = trackMap(timeline);
2141
2214
  const reseed = timeline.boil === null ? null : Math.trunc(Math.floor(t * clampFPS(timeline.fps) + 1e-9) / Math.max(1, timeline.boil)) % BOIL_DRAWINGS >>> 0;
2142
2215
  const hidden = /* @__PURE__ */ new Set();
2143
2216
  for (const tr of tracks.values()) if (!isVisible(tr, t)) hidden.add(tr.element);
2217
+ for (const id of off) hidden.add(id);
2144
2218
  let moving = null;
2145
2219
  if (timeline.settle === true) {
2146
2220
  moving = /* @__PURE__ */ new Set();
@@ -2179,7 +2253,7 @@ function posed(page, t) {
2179
2253
  elements = elements.map((e) => {
2180
2254
  const c = e.connector;
2181
2255
  if (!c || ![c.start.elementID, c.end.elementID].some((id) => id !== null && hidden.has(id))) return e;
2182
- if (!(tracks.get(e.id)?.keys.some((k) => k.visible !== void 0) ?? false)) {
2256
+ if (!(tracks.get(e.id)?.keys.some((k) => k.visible !== void 0) ?? false) || [c.start.elementID, c.end.elementID].some((id) => id !== null && off.has(id))) {
2183
2257
  gone.add(e.id);
2184
2258
  return e;
2185
2259
  }
@@ -2195,7 +2269,7 @@ function posterTime(page) {
2195
2269
  return page.timeline ? page.timeline.poster ?? page.timeline.duration : 0;
2196
2270
  }
2197
2271
  function still(page) {
2198
- return page.timeline ? resolved(page, posterTime(page)) : expandingSymbols(page, 0);
2272
+ return page.timeline ? resolved(page, posterTime(page)) : expandingSymbols(posed(page, 0), 0);
2199
2273
  }
2200
2274
  function cameraRect(tl, t) {
2201
2275
  const keys = tl.camera ?? [];
@@ -2304,7 +2378,7 @@ function filled(s, args) {
2304
2378
  }) };
2305
2379
  }
2306
2380
  function symbolPage(s) {
2307
- return { id: s.id, name: s.name, layers: s.layers, elements: s.elements, theme: null, timeline: s.timeline, symbols: null };
2381
+ return { id: s.id, name: s.name, layers: s.layers, elements: s.elements, theme: null, timeline: s.timeline, symbols: null, variables: null };
2308
2382
  }
2309
2383
  function hasInstances(page) {
2310
2384
  return page.elements.some((e) => e.kind === "symbol");
@@ -2433,16 +2507,21 @@ function clickTargets(page, file) {
2433
2507
  let pageOf = null;
2434
2508
  for (const e of page.elements) {
2435
2509
  const c = e.click;
2510
+ const state = c && changesState(c) ? { ...c.set ? { set: c.set } : {}, ...c.toggle ? { toggle: c.toggle } : {} } : null;
2436
2511
  if (c?.scene) {
2437
2512
  const i = sceneNamed(c.scene, markers);
2438
2513
  if (i >= 0) {
2439
- out.set(e.id, { scene: i });
2514
+ out.set(e.id, { scene: i, ...state });
2440
2515
  continue;
2441
2516
  }
2442
2517
  }
2443
2518
  const url = (c?.url ? allowedURL(c.url) : null) ?? webLink(e.url);
2444
2519
  if (url) {
2445
- out.set(e.id, { url });
2520
+ out.set(e.id, { url, ...state });
2521
+ continue;
2522
+ }
2523
+ if (state) {
2524
+ out.set(e.id, { state: true, ...state });
2446
2525
  continue;
2447
2526
  }
2448
2527
  if (e.link && file && file.pages.length > 1) {
@@ -3356,6 +3435,10 @@ var Playback = class {
3356
3435
  __publicField(this, "plan");
3357
3436
  __publicField(this, "scenes");
3358
3437
  __publicField(this, "sceneNames");
3438
+ /** Whether each scene loops (Marker.loop). */
3439
+ __publicField(this, "sceneLoops");
3440
+ /** The page's variables now: their starting values until clicks change them. */
3441
+ __publicField(this, "state");
3359
3442
  __publicField(this, "background");
3360
3443
  __publicField(this, "ink");
3361
3444
  /** What clicking each clickable element does, by element id. */
@@ -3415,6 +3498,8 @@ var Playback = class {
3415
3498
  };
3416
3499
  this.scenes = (page.timeline?.markers ?? []).map((m) => near(m.t));
3417
3500
  this.sceneNames = (page.timeline?.markers ?? []).map((m) => m.name);
3501
+ this.sceneLoops = (page.timeline?.markers ?? []).map((m) => m.loop === true);
3502
+ this.state = { ...page.variables ?? {} };
3418
3503
  const theme = themeNamed(page.theme);
3419
3504
  this.background = theme?.background ?? WHITE;
3420
3505
  this.ink = theme?.ink ?? { r: 0.12, g: 0.12, b: 0.13, a: 1 };
@@ -3465,7 +3550,7 @@ var Playback = class {
3465
3550
  const times = /* @__PURE__ */ new Map();
3466
3551
  for (const [id, start] of this.replayStarts) times.set(id, Math.min((now - start) / 1e3, this.replays.get(id) ?? 0));
3467
3552
  const tc = clampTime(t);
3468
- const pose = posed(this.page, tc);
3553
+ const pose = posed(this.page, tc, this.state);
3469
3554
  const frame2 = expandingSymbols(pose, tc, true, times);
3470
3555
  const { parts } = svgParts(frame2, this.background, r);
3471
3556
  const owned = (m, parts2) => {
@@ -3587,6 +3672,20 @@ ${h}</g>
3587
3672
  });
3588
3673
  return s;
3589
3674
  }
3675
+ /** The frame after scene `s`'s last: the next scene's, or the frame count after the last scene. */
3676
+ sceneEnd(s) {
3677
+ return this.scenes.find((f, j) => j > s && f > this.scenes[s]) ?? this.frameCount;
3678
+ }
3679
+ /** Where playing goes from frame `i`, and whether it stops there: on into a looping scene, round to the start of
3680
+ * the looping scene `i` ends, or a stop at a scene or the end (`loops`: whether the timeline loops at its end). */
3681
+ step(i, loops) {
3682
+ const next = i + 1;
3683
+ const s = this.sceneAt(i);
3684
+ if (s >= 0 && this.sceneLoops[s] && next >= this.sceneEnd(s)) return { frame: this.scenes[s], stop: false };
3685
+ if (next >= this.frameCount) return { frame: 0, stop: !loops || this.scenes.length > 0 };
3686
+ const entered = this.scenes.indexOf(next);
3687
+ return { frame: next, stop: entered >= 0 && !this.sceneLoops[entered] };
3688
+ }
3590
3689
  };
3591
3690
  var ElementBase = typeof HTMLElement === "undefined" ? class {
3592
3691
  } : HTMLElement;
@@ -3740,7 +3839,7 @@ var DottiePlayer = class extends ElementBase {
3740
3839
  this.setPlaying(false);
3741
3840
  return;
3742
3841
  }
3743
- this.setPlaying(!this.hasAttribute("no-autoplay") && !p.scenes.includes(0));
3842
+ this.setPlaying(!this.hasAttribute("no-autoplay") && (!p.scenes.includes(0) || p.sceneLoops[p.scenes.indexOf(0)]));
3744
3843
  }
3745
3844
  // MARK: Playback
3746
3845
  get paused() {
@@ -3766,11 +3865,13 @@ var DottiePlayer = class extends ElementBase {
3766
3865
  pause() {
3767
3866
  this.setPlaying(false);
3768
3867
  }
3769
- /** Plays on to the next scene (from the start after the last). */
3868
+ /** Plays on to the next scene (from the start after the last). Out of a looping scene it jumps to the next. */
3770
3869
  nextScene() {
3771
3870
  const p = this.playback;
3772
3871
  if (!p) return;
3773
- if (p.frame >= p.frameCount - 1) p.show(0);
3872
+ const s = p.sceneAt(p.frame);
3873
+ if (s >= 0 && p.sceneLoops[s]) p.show(s + 1 < p.scenes.length ? p.scenes[s + 1] : 0);
3874
+ else if (p.frame >= p.frameCount - 1) p.show(0);
3774
3875
  this.setPlaying(true);
3775
3876
  }
3776
3877
  /** Back to the start of this scene, or the one before if already there. */
@@ -3799,6 +3900,8 @@ var DottiePlayer = class extends ElementBase {
3799
3900
  this.scheduleReplay();
3800
3901
  }
3801
3902
  if (!click) return;
3903
+ if (click.set || click.toggle) this.setState(applyClick({ scene: null, url: null, set: click.set ?? null, toggle: click.toggle ?? null }, p.state));
3904
+ if ("state" in click) return;
3802
3905
  if ("url" in click) {
3803
3906
  if (/^(https?:|mailto:)/i.test(click.url)) window.open(click.url, "_blank", "noopener");
3804
3907
  } else if ("page" in click) {
@@ -3806,13 +3909,27 @@ var DottiePlayer = class extends ElementBase {
3806
3909
  } else if (click.scene >= 0 && click.scene < p.scenes.length) {
3807
3910
  if (reduced || this.hasAttribute("at") || p.frameCount < 2) {
3808
3911
  this.setPlaying(false);
3809
- p.show(click.scene + 1 < p.scenes.length ? p.scenes[click.scene + 1] : p.frameCount - 1);
3912
+ p.show(p.sceneLoops[click.scene] ? p.scenes[click.scene] : click.scene + 1 < p.scenes.length ? p.scenes[click.scene + 1] : p.frameCount - 1);
3810
3913
  } else {
3811
3914
  p.show(p.scenes[click.scene]);
3812
3915
  this.setPlaying(true);
3813
3916
  }
3814
3917
  }
3815
3918
  }
3919
+ /** The page's variables now. */
3920
+ get variables() {
3921
+ return { ...this.playback?.state ?? {} };
3922
+ }
3923
+ /** Gives the page's variables these values (the others keep theirs), redraws, and fires `state`. */
3924
+ setState(values) {
3925
+ const p = this.playback;
3926
+ if (!p) return;
3927
+ const next = { ...p.state, ...values };
3928
+ if (Object.keys(next).every((k) => next[k] === p.state[k])) return;
3929
+ p.state = next;
3930
+ p.redraw();
3931
+ this.dispatchEvent(new CustomEvent("state", { detail: { variables: { ...next } } }));
3932
+ }
3816
3933
  /** Redraws, while the page itself stands still, as long as a clicked copy is playing through. */
3817
3934
  scheduleReplay() {
3818
3935
  if (this.replayRaf) return;
@@ -3838,7 +3955,8 @@ var DottiePlayer = class extends ElementBase {
3838
3955
  schedule() {
3839
3956
  if (this.playing && this.visible && !this.raf && this.isConnected) this.raf = requestAnimationFrame((now) => this.tick(now));
3840
3957
  }
3841
- /** Steps whole frames at the timeline's rate; with scenes, playing stops at each one and at the end. */
3958
+ /** Steps whole frames at the timeline's rate; with scenes, playing stops at each one and at the end, except that a
3959
+ * looping scene plays round (Playback.step). */
3842
3960
  tick(now) {
3843
3961
  this.raf = 0;
3844
3962
  const p = this.playback;
@@ -3849,14 +3967,14 @@ var DottiePlayer = class extends ElementBase {
3849
3967
  let advanced = false;
3850
3968
  while (this.playing && this.acc >= step) {
3851
3969
  this.acc -= step;
3852
- const next = p.frame + 1;
3853
- if (next >= p.frameCount && (p.scenes.length || this.hasAttribute("no-loop"))) {
3970
+ const { frame: frame2, stop } = p.step(p.frame, !this.hasAttribute("no-loop"));
3971
+ if (stop && frame2 === 0 && p.frame + 1 >= p.frameCount) {
3854
3972
  this.setPlaying(false);
3855
3973
  break;
3856
3974
  }
3857
- p.frame = next % p.frameCount;
3975
+ p.frame = frame2;
3858
3976
  advanced = true;
3859
- if (p.scenes.includes(p.frame)) {
3977
+ if (stop) {
3860
3978
  this.setPlaying(false);
3861
3979
  break;
3862
3980
  }
@@ -4006,7 +4124,7 @@ function render(source, options = {}) {
4006
4124
  function isFile(v) {
4007
4125
  return typeof v === "object" && v !== null && Array.isArray(v.pages) && Array.isArray(v.warnings);
4008
4126
  }
4009
- var VERSION = "0.6.3";
4127
+ var VERSION = "0.7.0";
4010
4128
 
4011
4129
  // src/player-entry.ts
4012
4130
  definePlayer();