vexy-stax-js 3.1.2 → 3.1.7
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/CHANGELOG.md +90 -0
- package/README.md +23 -3
- package/package.json +1 -1
- package/src/controls.js +138 -0
- package/src/element.js +61 -10
- package/src/geometry.js +11 -2
- package/src/index.js +76 -12
- package/src/stage.js +18 -7
- package/src/transition.js +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project are documented here.
|
|
6
6
|
|
|
7
|
+
## [3.1.4] — issue 701: inline scene + aspect
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Inline scene via a `<script type="application/json">` child** (701): drop the entire scene JSON
|
|
12
|
+
*inside* the `<vexy-stax>` element — no external `scene` URL and no giant `config` attribute to
|
|
13
|
+
escape. Precedence: `config` object → inline `<script>` scene → `scene` URL → `slides`. Inline
|
|
14
|
+
slide `src` paths resolve against the host page, so use absolute URLs for off-page images. This is
|
|
15
|
+
the clean way to "specify the full scene right where you load the component" (also keeps working
|
|
16
|
+
with the existing `el.scene = {…}` / `config` object/JSON-string paths).
|
|
17
|
+
- **`aspect` attribute / `createStax({ aspect })` option** (701): shapes the embed box via CSS
|
|
18
|
+
`aspect-ratio` so a deck is easy to make short and wide. `aspect="3"` (or `"3/1"`, `"3:1"`,
|
|
19
|
+
`"16:9"`) → a 3:1 box; `:` and `x` are normalized to `/`. The camera reframes the deck to the
|
|
20
|
+
resolved box (the existing ResizeObserver path). `width`/`height` still set explicit CSS sizes;
|
|
21
|
+
the scene's own `size` controls the internal plate aspect.
|
|
22
|
+
|
|
23
|
+
## [3.0.17] — control-button + docs-scene follow-ups
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Control-button label was stale during scrollspy** (343/344): the single toggle button only
|
|
28
|
+
refreshed on a click-toggle's `transitionend`, so while the deck morphed by SCROLL (`seek`) the
|
|
29
|
+
label lagged (said "Explain" while already expanded). `VexyStax` now emits a **`viewchange`**
|
|
30
|
+
CustomEvent whenever `_currentView` flips — from `seek` (scroll), `setView`, or a transition — and
|
|
31
|
+
the buttons listen to it, so the label always reflects the current view (and thus the action a
|
|
32
|
+
click will perform). Verified: `seek(0.7)` → "Preview", `seek(0.2)` → "Explain".
|
|
33
|
+
- **Demo scene edits were silently overwritten** (build): `docs/airbl-demo.scene.json` and
|
|
34
|
+
`docs/airbl-scrollable.scene.json` were REGENERATED from the shared py testdata on every
|
|
35
|
+
`build:docs`, clobbering hand edits. The demos now have **editable source files** under
|
|
36
|
+
`vexy-stax-js/demo-scenes/` that the build merely COPIES into `docs/` (seeding them once from the
|
|
37
|
+
testdata + default floor if absent). Edit `demo-scenes/airbl-scrollable.scene.json` to customize
|
|
38
|
+
the scrollable demo — it persists. (The slide PNGs are still copied from the py testdata.)
|
|
39
|
+
|
|
40
|
+
## [3.0.16] — issue 344
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **Back slides' caption plates painted over front slides** (344): in the expanded view a caption
|
|
45
|
+
attached to a slide behind the front (e.g. "Halftone fill: Dots") drew ON TOP OF the frontmost
|
|
46
|
+
slide. The three.js plates use `depthWrite:false` and the captions `depthTest:false`, so DRAW
|
|
47
|
+
ORDER — not depth — decides compositing, but every plate shared `renderOrder 0` and every caption
|
|
48
|
+
`renderOrder 2`, so all captions painted over all plates. Each slide now owns a **per-slide
|
|
49
|
+
render-order block** `i*4` (index 0 = backmost): plate `i*4`, border `i*4+1`, caption `i*4+2`. A
|
|
50
|
+
front slide's plate (`(i+1)*4`) therefore paints AFTER — and its opaque pixels cover — a back
|
|
51
|
+
slide's caption (`i*4+2`), while transparent areas still let it show through. Mirrors the pygfx
|
|
52
|
+
engine's issue-327 ordering. (The pygfx + Blender engines occlude correctly via the depth buffer /
|
|
53
|
+
real 3D depth and were unaffected.) Verified: the frontmost slide is no longer overdrawn by the
|
|
54
|
+
caption of the slide behind it.
|
|
55
|
+
|
|
56
|
+
## [3.0.15] — issue 343
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Built-in control buttons** (343): an opt-in overlay over the deck that switches views via the
|
|
61
|
+
smooth click-toggle. Two layouts: `buttons="toggle"` — ONE relabeling button ("Explain" while
|
|
62
|
+
compact → expand, then "Preview" while expanded → collapse); `buttons="pair"` — two side-by-side
|
|
63
|
+
buttons ("Explain" / "Preview"). Default placement is just above the bottom, horizontally
|
|
64
|
+
centered; default styling is black text on a barely-there (5 %) blurred black pill. Fully
|
|
65
|
+
customizable: labels via `explain-label`/`preview-label`, placement via `buttons-position`
|
|
66
|
+
(`bottom`|`top`|`bottom-left`|…|`center`), and every visual via `--vexy-btn-*` CSS custom
|
|
67
|
+
properties (`--vexy-btn-color`/`-bg`/`-blur`/`-radius`/`-pad`/…). Exposed three ways:
|
|
68
|
+
the `<vexy-stax buttons="…">` attributes, `createStax(el, { buttons, explainLabel, previewLabel,
|
|
69
|
+
buttonsPosition, buttonStyle })`, and the `VexyStax.controls(opts)` method. New `src/controls.js`
|
|
70
|
+
(`attachControls`). Showcased on the scrollable demo + demo-component. Verified headless: the
|
|
71
|
+
toggle relabels Explain⇄Preview across clicks, the pair renders with custom labels, no errors.
|
|
72
|
+
|
|
73
|
+
## [3.0.14] — issue 342 (scrollspy polish)
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
|
|
77
|
+
- **Click-toggle on the scrollspy demo snapped back to expanded** (342): after a click collapsed
|
|
78
|
+
the deck, the next scroll event re-seeked it to the scroll-position-derived value (expanded). The
|
|
79
|
+
`scrollable.html` scroll handler now uses a **manual-hold** model — a click-toggled state is held
|
|
80
|
+
until the scroll position naturally reaches the matching endpoint, then control hands back to
|
|
81
|
+
scroll seamlessly. The deck no longer snaps back.
|
|
82
|
+
- **Post-toggle compact view was framed "too close" (plates cropped)** (342): `playTransition` ran
|
|
83
|
+
`frameStateAt(scene, t)` **without** the live container aspect, so animated transition frames fell
|
|
84
|
+
back to the scene aspect and the compact endpoint was framed closer than `setView`/`seek` (which
|
|
85
|
+
pass the live aspect). `transition()` now threads `this.stage.camera.aspect` through to
|
|
86
|
+
`playTransition` → `frameStateAt`. Verified: the post-toggle compact camera matches the initial
|
|
87
|
+
compact camera exactly on a wide container.
|
|
88
|
+
- **Click-toggle transition was too slow** (342): a toggle reused the scene's `transition.duration`
|
|
89
|
+
(up to 3 s for scroll-story scenes). `toggleView()` now plays a snappy fixed **0.7 s** leg via a
|
|
90
|
+
new `opts.duration` override on `transition()`/`playTransition()`, independent of the scene timing.
|
|
91
|
+
|
|
92
|
+
### Changed
|
|
93
|
+
|
|
94
|
+
- **Scrollable demo stage is `100vw × 60vh`** (user request): shorter and wider than the previous
|
|
95
|
+
2:1 box. The camera fits the deck to this live aspect (see the aspect fix above).
|
|
96
|
+
|
|
7
97
|
## [3.0.13] — issues 341, 342
|
|
8
98
|
|
|
9
99
|
### Added
|
package/README.md
CHANGED
|
@@ -44,13 +44,33 @@ Three ways to drop a deck on a page, easiest first (issues 341 / 342).
|
|
|
44
44
|
|
|
45
45
|
<!-- …or point at a full scene JSON (with captions, camera, transition, …): -->
|
|
46
46
|
<vexy-stax scene="scene.json" view="expanded"></vexy-stax>
|
|
47
|
+
|
|
48
|
+
<!-- …or drop the WHOLE scene inline, right where you load the component (issue 701) —
|
|
49
|
+
a `<script type="application/json">` child, no external URL: -->
|
|
50
|
+
<vexy-stax view="compact" mode="playable" aspect="3">
|
|
51
|
+
<script type="application/json">
|
|
52
|
+
{ "version": 1, "transition": { "kind": "expand_collapse" },
|
|
53
|
+
"slides": [ { "src": "https://example.com/layer-0.png" },
|
|
54
|
+
{ "src": "https://example.com/layer-1.png" } ] }
|
|
55
|
+
</script>
|
|
56
|
+
</vexy-stax>
|
|
47
57
|
```
|
|
48
58
|
|
|
49
59
|
**Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
|
|
50
60
|
toggles compact↔expanded. Opt out with `click-toggle="false"`.
|
|
51
61
|
|
|
52
|
-
**Scene-in-init** (issue 342):
|
|
53
|
-
`el.scene = { version: 1, slides: [{ src: "a.png" }, …] }`
|
|
62
|
+
**Scene-in-init** (issue 342/701): supply an inline scene **without a URL** three ways — assign a
|
|
63
|
+
JS object (`el.scene = { version: 1, slides: [{ src: "a.png" }, …] }` or the `config` property), a
|
|
64
|
+
JSON-string `config` attribute, or a child **`<script type="application/json">`** holding the whole
|
|
65
|
+
scene (shown above; the no-escaping way to "specify the full scene right where you load the
|
|
66
|
+
component"). Inline slide `src` paths resolve against the host page, so use absolute URLs when the
|
|
67
|
+
images live elsewhere.
|
|
68
|
+
|
|
69
|
+
**Aspect / size** (issue 701): the `aspect` attribute shapes the box via CSS `aspect-ratio` so a
|
|
70
|
+
deck is easy to make short and wide — `aspect="3"` (or `"3/1"`, `"3:1"`, `"16:9"`) gives a 3:1 box;
|
|
71
|
+
the camera reframes the deck to fit. `width`/`height` still set an explicit CSS size, and the
|
|
72
|
+
scene's own `size` controls the internal plate aspect. `createStax(el, { aspect: "3" })` is the ESM
|
|
73
|
+
equivalent.
|
|
54
74
|
|
|
55
75
|
### ES Module — `createStax`
|
|
56
76
|
|
|
@@ -75,7 +95,7 @@ const low = new VexyStax(container, scene); // the low-level path is still avai
|
|
|
75
95
|
```
|
|
76
96
|
|
|
77
97
|
`createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
|
|
78
|
-
height, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
|
|
98
|
+
height, aspect, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
|
|
79
99
|
`transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
|
|
80
100
|
|
|
81
101
|
### Global script — `window.VexyStax`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexy-stax-js",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Browser renderer for the vexy-stax shared scene format: 3D glass plates in two views, with morphable opacity. Ships as ESM, Web Component, and a classic-script global.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/controls.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// this_file: vexy-stax-js/src/controls.js
|
|
3
|
+
//
|
|
4
|
+
// Built-in control buttons (issue 343): a small overlay over the deck that switches between the
|
|
5
|
+
// compact ("Preview") and expanded ("Explain") views via the smooth click-toggle morph. Two layouts:
|
|
6
|
+
// - "toggle" (default): ONE relabeling button — "Explain" while compact (click → expand), then
|
|
7
|
+
// "Preview" while expanded (click → collapse back to compact).
|
|
8
|
+
// - "pair": TWO buttons side-by-side — "Explain" (→ expand) and "Preview" (→ compact).
|
|
9
|
+
// Default placement is just above the bottom, horizontally centered. Default styling is black text
|
|
10
|
+
// on a barely-there (5%) blurred black pill. Everything is themeable via CSS custom properties
|
|
11
|
+
// (`--vexy-btn-*` on the element) and the labels/position/type are options.
|
|
12
|
+
|
|
13
|
+
// Simple position presets (the `position` option). All are relative to the component box.
|
|
14
|
+
const POSITIONS = {
|
|
15
|
+
bottom: "left:50%;bottom:6%;transform:translateX(-50%);",
|
|
16
|
+
top: "left:50%;top:6%;transform:translateX(-50%);",
|
|
17
|
+
"bottom-left": "left:5%;bottom:6%;",
|
|
18
|
+
"bottom-right": "right:5%;bottom:6%;",
|
|
19
|
+
"top-left": "left:5%;top:6%;",
|
|
20
|
+
"top-right": "right:5%;top:6%;",
|
|
21
|
+
center: "left:50%;top:50%;transform:translate(-50%,-50%);",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let _styleInjected = false;
|
|
25
|
+
|
|
26
|
+
/** Safari does not apply backdrop-filter over a WebGL canvas; use a solid pill there instead. */
|
|
27
|
+
function isSafari(doc) {
|
|
28
|
+
const ua = doc.defaultView?.navigator?.userAgent ?? "";
|
|
29
|
+
return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|EdgiOS|Edg\//i.test(ua);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function injectStyle(doc) {
|
|
33
|
+
if (_styleInjected || !doc?.head) return;
|
|
34
|
+
_styleInjected = true;
|
|
35
|
+
const s = doc.createElement("style");
|
|
36
|
+
// All visual knobs are CSS custom properties so a host overrides them with one rule, e.g.
|
|
37
|
+
// vexy-stax { --vexy-btn-color:#fff; --vexy-btn-bg:rgba(0,0,0,.4); --vexy-btn-blur:14px; }
|
|
38
|
+
// Safari: backdrop-filter is ignored over canvas — use --vexy-btn-bg-solid instead (issue 343).
|
|
39
|
+
const safariBtn = isSafari(doc)
|
|
40
|
+
? `
|
|
41
|
+
.vexy-stax-controls button{
|
|
42
|
+
-webkit-backdrop-filter:none;backdrop-filter:none;
|
|
43
|
+
background:var(--vexy-btn-bg-solid,rgba(255,255,255,0.92));
|
|
44
|
+
}
|
|
45
|
+
.vexy-stax-controls button:hover{background:var(--vexy-btn-bg-solid-hover,rgba(255,255,255,0.97))}`
|
|
46
|
+
: "";
|
|
47
|
+
s.textContent = `
|
|
48
|
+
.vexy-stax-controls{position:absolute;z-index:5;display:flex;gap:8px;pointer-events:none}
|
|
49
|
+
.vexy-stax-controls button{
|
|
50
|
+
pointer-events:auto;cursor:pointer;font:inherit;
|
|
51
|
+
font-size:var(--vexy-btn-font,14px);font-weight:var(--vexy-btn-weight,600);letter-spacing:.01em;line-height:1;
|
|
52
|
+
color:var(--vexy-btn-color,#000);
|
|
53
|
+
background:var(--vexy-btn-bg,rgba(0,0,0,0.05));
|
|
54
|
+
-webkit-backdrop-filter:blur(var(--vexy-btn-blur,10px));backdrop-filter:blur(var(--vexy-btn-blur,10px));
|
|
55
|
+
border:var(--vexy-btn-border,1px solid rgba(0,0,0,0.08));
|
|
56
|
+
border-radius:var(--vexy-btn-radius,999px);
|
|
57
|
+
padding:var(--vexy-btn-pad,9px 18px);
|
|
58
|
+
box-shadow:var(--vexy-btn-shadow,0 2px 10px rgba(0,0,0,0.06));
|
|
59
|
+
transition:background .15s,transform .12s;
|
|
60
|
+
}
|
|
61
|
+
.vexy-stax-controls button:hover{background:var(--vexy-btn-bg-hover,rgba(0,0,0,0.10))}
|
|
62
|
+
.vexy-stax-controls button:active{transform:translateY(1px)}${safariBtn}
|
|
63
|
+
`;
|
|
64
|
+
doc.head.appendChild(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Attach control buttons over the deck (issue 343).
|
|
69
|
+
* @param {object} stax the VexyStax instance (uses `_currentView` + `toggleView()`/`setView`)
|
|
70
|
+
* @param {HTMLElement} container the element the canvas lives in (the overlay is appended here)
|
|
71
|
+
* @param {object} [opts]
|
|
72
|
+
* @param {"toggle"|"pair"} [opts.type="toggle"] single relabeling button, or a side-by-side pair
|
|
73
|
+
* @param {string} [opts.explainLabel="Explain"] label for the compact→expand action
|
|
74
|
+
* @param {string} [opts.previewLabel="Preview"] label for the expand→compact action
|
|
75
|
+
* @param {string} [opts.position="bottom"] one of POSITIONS (bottom|top|bottom-left|…|center)
|
|
76
|
+
* @param {string} [opts.style] extra inline CSS appended to the overlay wrapper (full custom position)
|
|
77
|
+
* @returns {{el:HTMLElement, update:()=>void, destroy:()=>void}|null}
|
|
78
|
+
*/
|
|
79
|
+
export function attachControls(stax, container, opts = {}) {
|
|
80
|
+
if (!container?.ownerDocument || !container.appendChild) return null;
|
|
81
|
+
const doc = container.ownerDocument;
|
|
82
|
+
injectStyle(doc);
|
|
83
|
+
|
|
84
|
+
const type = opts.type === "pair" ? "pair" : "toggle";
|
|
85
|
+
const explainLabel = opts.explainLabel ?? "Explain"; // compact → expand
|
|
86
|
+
const previewLabel = opts.previewLabel ?? "Preview"; // expanded → compact
|
|
87
|
+
const posKey = opts.position && POSITIONS[opts.position] ? opts.position : "bottom";
|
|
88
|
+
|
|
89
|
+
// The overlay is absolutely positioned, so the container must establish a positioning context.
|
|
90
|
+
if (container.style && typeof getComputedStyle === "function" && getComputedStyle(container).position === "static") {
|
|
91
|
+
container.style.position = "relative";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const wrap = doc.createElement("div");
|
|
95
|
+
wrap.className = "vexy-stax-controls";
|
|
96
|
+
wrap.setAttribute("style", POSITIONS[posKey] + (opts.style ?? ""));
|
|
97
|
+
|
|
98
|
+
const isCompact = () => stax._currentView === "compact";
|
|
99
|
+
let update;
|
|
100
|
+
|
|
101
|
+
if (type === "pair") {
|
|
102
|
+
const bExplain = doc.createElement("button");
|
|
103
|
+
bExplain.type = "button";
|
|
104
|
+
bExplain.textContent = explainLabel;
|
|
105
|
+
bExplain.addEventListener("click", () => { if (isCompact()) stax.toggleView(); });
|
|
106
|
+
const bPreview = doc.createElement("button");
|
|
107
|
+
bPreview.type = "button";
|
|
108
|
+
bPreview.textContent = previewLabel;
|
|
109
|
+
bPreview.addEventListener("click", () => { if (!isCompact()) stax.toggleView(); });
|
|
110
|
+
wrap.append(bExplain, bPreview);
|
|
111
|
+
update = () => {}; // both labels are always shown
|
|
112
|
+
} else {
|
|
113
|
+
const b = doc.createElement("button");
|
|
114
|
+
b.type = "button";
|
|
115
|
+
b.addEventListener("click", () => stax.toggleView());
|
|
116
|
+
wrap.append(b);
|
|
117
|
+
update = () => { b.textContent = isCompact() ? explainLabel : previewLabel; };
|
|
118
|
+
update();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
container.appendChild(wrap);
|
|
122
|
+
|
|
123
|
+
// Keep the single-button label in sync with the CURRENT view however it changes — a click-toggle,
|
|
124
|
+
// a setView, OR a scroll-driven seek (issue 344 follow-up). VexyStax emits a "viewchange"
|
|
125
|
+
// CustomEvent on the container whenever `_currentView` flips, including mid-scrollspy, so the
|
|
126
|
+
// label is never stale (a click during a scroll always shows the action it will perform).
|
|
127
|
+
const onChange = () => update();
|
|
128
|
+
container.addEventListener("viewchange", onChange);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
el: wrap,
|
|
132
|
+
update,
|
|
133
|
+
destroy() {
|
|
134
|
+
container.removeEventListener("viewchange", onChange);
|
|
135
|
+
wrap.remove();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/element.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
// <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
|
|
5
5
|
// (space/newline-separated image URLs — issue 341), captions (bool), view,
|
|
6
|
-
// mode (static|playable|scrollspy), width, height
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// mode (static|playable|scrollspy), width, height, aspect (CSS aspect-ratio,
|
|
7
|
+
// e.g. "3", "3/1", "3:1" — issue 701). Property `config` accepts an inline scene
|
|
8
|
+
// object (overrides `scene`/`slides`); an inline `<script type="application/json">`
|
|
9
|
+
// child also supplies the scene (issue 701 — "specify the full scene right where
|
|
10
|
+
// you load the component"). Events: ready, transitionstart, transitionend. Mounts
|
|
11
|
+
// a VexyStax in light DOM. Auto-registers on import.
|
|
9
12
|
|
|
10
13
|
import { VexyStax, loadScene, makeScene } from "./index.js";
|
|
11
14
|
|
|
@@ -19,7 +22,11 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
19
22
|
static get observedAttributes() {
|
|
20
23
|
// `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
|
|
21
24
|
// `click-toggle` is the issue-342 opt-out for the default click-to-toggle behavior.
|
|
22
|
-
|
|
25
|
+
// `buttons` + `explain-label`/`preview-label`/`buttons-position` are the issue-343 control buttons.
|
|
26
|
+
return [
|
|
27
|
+
"scene", "slides", "captions", "view", "mode", "trigger", "width", "height", "aspect",
|
|
28
|
+
"click-toggle", "buttons", "explain-label", "preview-label", "buttons-position",
|
|
29
|
+
];
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
constructor() {
|
|
@@ -80,7 +87,7 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
80
87
|
|
|
81
88
|
attributeChangedCallback(name) {
|
|
82
89
|
if (!this.isConnected) return;
|
|
83
|
-
if (name === "width" || name === "height") {
|
|
90
|
+
if (name === "width" || name === "height" || name === "aspect") {
|
|
84
91
|
this._applySize();
|
|
85
92
|
this._stax?.resize();
|
|
86
93
|
return;
|
|
@@ -104,6 +111,36 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
104
111
|
const h = this.getAttribute("height");
|
|
105
112
|
if (w) this.style.width = /^\d+$/.test(w) ? `${w}px` : w;
|
|
106
113
|
if (h) this.style.height = /^\d+$/.test(h) ? `${h}px` : h;
|
|
114
|
+
// Issue 701: `aspect` sets the CSS aspect-ratio so the embed box is easy to shape
|
|
115
|
+
// (e.g. aspect="3" / "3/1" / "3:1" → a short, wide 3:1 deck). Accepts the CSS
|
|
116
|
+
// `<width>/<height>` or bare-ratio forms; ":" and "x" are normalized to "/". With
|
|
117
|
+
// only `aspect` (no height) the element's height follows from its width, and the
|
|
118
|
+
// ResizeObserver reframes the camera to the resolved box.
|
|
119
|
+
const aspect = this.getAttribute("aspect");
|
|
120
|
+
if (aspect && aspect.trim()) {
|
|
121
|
+
this.style.aspectRatio = aspect.trim().replace(/[:x]/i, " / ");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Issue 701: an inline scene declared as a child `<script type="application/json">` (or
|
|
127
|
+
* `application/vexy-scene+json`). This is the no-escaping way to "specify the full scene
|
|
128
|
+
* right where you load the component" — drop the whole scene JSON inside the element instead
|
|
129
|
+
* of pointing `scene` at a URL. Returns the parsed object (later normalized by loadScene), or
|
|
130
|
+
* null when there is no such child. A `<script>` child is never rendered, so it is invisible.
|
|
131
|
+
*/
|
|
132
|
+
_inlineScene() {
|
|
133
|
+
if (typeof this.querySelector !== "function") return null;
|
|
134
|
+
const tag = this.querySelector(
|
|
135
|
+
'script[type="application/json"], script[type="application/vexy-scene+json"]'
|
|
136
|
+
);
|
|
137
|
+
const text = tag?.textContent?.trim();
|
|
138
|
+
if (!text) return null;
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(text);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new Error(`<vexy-stax>: inline <script> scene is not valid JSON (${err.message})`);
|
|
143
|
+
}
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
async _mount() {
|
|
@@ -114,11 +151,12 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
114
151
|
this._stax = null;
|
|
115
152
|
|
|
116
153
|
const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
|
|
117
|
-
// Source precedence (issue 341): inline `config` object →
|
|
118
|
-
// `slides` is the easy path: a space/newline-separated list
|
|
119
|
-
// or remote http(s)) built into a scene via makeScene.
|
|
120
|
-
// caption plates (default on; here off unless slides carry
|
|
121
|
-
|
|
154
|
+
// Source precedence (issue 341 + 701): inline `config` object → inline <script> scene →
|
|
155
|
+
// `scene` URL → `slides` list. `slides` is the easy path: a space/newline-separated list
|
|
156
|
+
// of image URLs (local, data:, or remote http(s)) built into a scene via makeScene.
|
|
157
|
+
// `captions` (bool attr) toggles caption plates (default on; here off unless slides carry
|
|
158
|
+
// their own — empty by default).
|
|
159
|
+
const sceneSrc = this._config ?? this._inlineScene() ?? this.getAttribute("scene");
|
|
122
160
|
const slidesAttr = this.getAttribute("slides");
|
|
123
161
|
let scene;
|
|
124
162
|
if (sceneSrc) {
|
|
@@ -158,6 +196,19 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
158
196
|
this._stax?.enableClickToggle();
|
|
159
197
|
}
|
|
160
198
|
|
|
199
|
+
// Issue 343: built-in control buttons. `buttons` = "toggle" (single relabeling button) or
|
|
200
|
+
// "pair" (two buttons); a bare `buttons` attribute defaults to "toggle". `explain-label`,
|
|
201
|
+
// `preview-label` and `buttons-position` customize text + placement (style via --vexy-btn-*).
|
|
202
|
+
const buttonsAttr = this.getAttribute("buttons");
|
|
203
|
+
if (buttonsAttr !== null && buttonsAttr !== "false") {
|
|
204
|
+
this._stax?.controls({
|
|
205
|
+
type: buttonsAttr === "pair" ? "pair" : "toggle",
|
|
206
|
+
explainLabel: this.getAttribute("explain-label") ?? undefined,
|
|
207
|
+
previewLabel: this.getAttribute("preview-label") ?? undefined,
|
|
208
|
+
position: this.getAttribute("buttons-position") ?? undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
|
|
162
213
|
} catch (err) {
|
|
163
214
|
this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
|
package/src/geometry.js
CHANGED
|
@@ -122,9 +122,18 @@ export function captionBaselineY(scene) {
|
|
|
122
122
|
return -(scene.size.height / 2.0) + CAPTION_BASELINE_EM * captionSize(scene);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Per-slide gap (points), falling back to camera.gap when unset (null).
|
|
127
|
+
* Resolves a gap of 0 to MIN_GAP.
|
|
128
|
+
*/
|
|
126
129
|
export function plateGaps(scene) {
|
|
127
|
-
return scene.slides.map((s) =>
|
|
130
|
+
return scene.slides.map((s) => {
|
|
131
|
+
let g = (s.gap === null || s.gap === undefined ? scene.camera.gap : s.gap);
|
|
132
|
+
if (g === 0) {
|
|
133
|
+
g = MIN_GAP;
|
|
134
|
+
}
|
|
135
|
+
return g;
|
|
136
|
+
});
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
/**
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { loadScene, parseScene, makeScene, resolvedOpacity } from "./scene.js";
|
|
|
11
11
|
import { frameStateAt, ease } from "./geometry.js";
|
|
12
12
|
import { playTransition, transitionEndpoints, buildTimeline, morphAtProgress } from "./transition.js";
|
|
13
13
|
import { attachScrollspy } from "./scrollspy.js";
|
|
14
|
+
import { attachControls } from "./controls.js";
|
|
14
15
|
import { canvasToPngBlob, recordVideo } from "./export.js";
|
|
15
16
|
|
|
16
17
|
export {
|
|
@@ -57,6 +58,7 @@ export class VexyStax {
|
|
|
57
58
|
this._currentView = scene.view === "compact" ? "compact" : "expanded";
|
|
58
59
|
this._morphT = this._currentView === "compact" ? 0 : 1;
|
|
59
60
|
this._clickToggle = null; // { handler } when wired (enableClickToggle)
|
|
61
|
+
this._controls = null; // { destroy } when wired (controls(), issue 343)
|
|
60
62
|
this._toggling = false; // guard against overlapping toggle transitions
|
|
61
63
|
this._ready = this.stage.init().then(() => {
|
|
62
64
|
this.stage.render();
|
|
@@ -95,8 +97,8 @@ export class VexyStax {
|
|
|
95
97
|
await this._ready;
|
|
96
98
|
this.stage.setView(view);
|
|
97
99
|
this.stage.render();
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
100
|
+
this._morphT = view === "compact" ? 0 : 1;
|
|
101
|
+
this._setView_(view === "compact" ? "compact" : "expanded");
|
|
100
102
|
return this;
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -112,10 +114,19 @@ export class VexyStax {
|
|
|
112
114
|
// Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
|
|
113
115
|
// whether it is currently nearer compact (→ expand) or expanded (→ collapse).
|
|
114
116
|
this._morphT = tt;
|
|
115
|
-
|
|
117
|
+
// Issue 344 follow-up: emit a viewchange when the scroll crosses the compact/expanded midpoint
|
|
118
|
+
// so the control-button label (and any host UI) stays in sync during a scroll-driven morph.
|
|
119
|
+
this._setView_(tt >= 0.5 ? "expanded" : "compact");
|
|
116
120
|
return this;
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/** Set `_currentView` and emit a "viewchange" CustomEvent on the container when it changes (343/344). */
|
|
124
|
+
_setView_(view) {
|
|
125
|
+
if (view === this._currentView) return;
|
|
126
|
+
this._currentView = view;
|
|
127
|
+
this.container?.dispatchEvent?.(new CustomEvent("viewchange", { detail: { view } }));
|
|
128
|
+
}
|
|
129
|
+
|
|
119
130
|
/** Resize the renderer/camera to the container (or explicit size). */
|
|
120
131
|
resize(width, height) {
|
|
121
132
|
const w = width ?? this.container.clientWidth;
|
|
@@ -186,7 +197,10 @@ export class VexyStax {
|
|
|
186
197
|
const t = this._morphFromGaps(state.gaps);
|
|
187
198
|
this.stage.applyFrameState(state, t);
|
|
188
199
|
this.stage.render();
|
|
189
|
-
|
|
200
|
+
// Pass the LIVE container aspect so the animated frames match setView()/seek() framing
|
|
201
|
+
// (issue 342: otherwise the compact endpoint is framed too close on a wide container).
|
|
202
|
+
// `opts.duration` lets a click-toggle play a snappy leg instead of the full scene timing.
|
|
203
|
+
}, { kind: resolvedKind, onProgress: opts.onProgress, aspect: this.stage.camera.aspect, duration: opts.duration });
|
|
190
204
|
|
|
191
205
|
this._cancelTransition = controller.cancel;
|
|
192
206
|
this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
|
|
@@ -195,7 +209,7 @@ export class VexyStax {
|
|
|
195
209
|
// Settle the tracked view to the transition's end endpoint (issue 342).
|
|
196
210
|
const { endMorph } = transitionEndpoints(resolvedKind);
|
|
197
211
|
this._morphT = endMorph;
|
|
198
|
-
this.
|
|
212
|
+
this._setView_(endMorph >= 0.5 ? "expanded" : "compact");
|
|
199
213
|
this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
|
|
200
214
|
} finally {
|
|
201
215
|
this._cancelTransition = null;
|
|
@@ -217,11 +231,13 @@ export class VexyStax {
|
|
|
217
231
|
this._toggling = true;
|
|
218
232
|
try {
|
|
219
233
|
// The scene may have no `transition` section (e.g. a slides-only deck) — supply timing so
|
|
220
|
-
// toggle still animates. transition() reads scene.transition for
|
|
234
|
+
// toggle still animates. transition() reads scene.transition for the easing; ensure one exists.
|
|
221
235
|
if (!this.scene.transition) {
|
|
222
|
-
this.scene.transition = { kind, duration: 0.
|
|
236
|
+
this.scene.transition = { kind, duration: 0.7, wait: 0, fps: 30, easing: "easeInOutCubic" };
|
|
223
237
|
}
|
|
224
|
-
|
|
238
|
+
// Issue 342: a click-toggle plays a SNAPPY leg (0.7 s) regardless of the scene's
|
|
239
|
+
// transition.duration (which may be a long 3 s scroll-story ramp) — overriding it here.
|
|
240
|
+
await this.transition(kind, { duration: 0.7 });
|
|
225
241
|
} finally {
|
|
226
242
|
this._toggling = false;
|
|
227
243
|
}
|
|
@@ -249,6 +265,19 @@ export class VexyStax {
|
|
|
249
265
|
return this;
|
|
250
266
|
}
|
|
251
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Add built-in control buttons over the deck (issue 343): a single relabeling toggle
|
|
270
|
+
* ("Explain" → expand, "Preview" → compact) or a side-by-side pair. Frosted, bottom-centered by
|
|
271
|
+
* default; themeable via `--vexy-btn-*` CSS custom properties on the element. Replaces any prior
|
|
272
|
+
* controls. Pass `false` to remove them. Options: `{type:"toggle"|"pair", explainLabel,
|
|
273
|
+
* previewLabel, position, style}`.
|
|
274
|
+
*/
|
|
275
|
+
controls(opts = {}) {
|
|
276
|
+
this._controls?.destroy();
|
|
277
|
+
this._controls = opts === false ? null : attachControls(this, this.container, opts);
|
|
278
|
+
return this;
|
|
279
|
+
}
|
|
280
|
+
|
|
252
281
|
/** Remove the click-to-toggle handler (issue 342 opt-out). */
|
|
253
282
|
disableClickToggle() {
|
|
254
283
|
if (this._clickToggle) {
|
|
@@ -268,11 +297,26 @@ export class VexyStax {
|
|
|
268
297
|
}
|
|
269
298
|
|
|
270
299
|
/**
|
|
271
|
-
* Record the transition to a video Blob
|
|
272
|
-
*
|
|
300
|
+
* Record the transition to a video Blob.
|
|
301
|
+
*
|
|
302
|
+
* **Encoding path selection** (export.js `recordVideo`):
|
|
303
|
+
* 1. **PRIMARY — WebCodecs + mp4-muxer** (issue 331): uses `VideoEncoder` to
|
|
304
|
+
* encode each rendered frame directly into H.264/mp4 (preferred) or VP9/webm.
|
|
305
|
+
* Produces a fully seekable container with correct duration and per-stream
|
|
306
|
+
* frame-count metadata. Available in Chrome 94+, Edge 94+, and recent Safari.
|
|
307
|
+
* 2. **FALLBACK — MediaRecorder** (`canvas.captureStream`): used when
|
|
308
|
+
* `VideoEncoder` is unavailable (older browsers, some WebViews). Output is a
|
|
309
|
+
* non-seekable webm stream; duration/frame metadata may be absent. The
|
|
310
|
+
* recorded MIME type reflects the first supported codec from
|
|
311
|
+
* `[vp9, vp8, webm, mp4]`.
|
|
312
|
+
*
|
|
313
|
+
* The clip is bookended by held stills: `scene.video.first_hold` copies of the
|
|
314
|
+
* start frame and `scene.video.last_hold` copies of the end frame (default 10
|
|
315
|
+
* each, matching the Python `frame_plan` holds).
|
|
316
|
+
*
|
|
273
317
|
* @param {object} [opts]
|
|
274
318
|
* @param {string} [opts.kind] override scene.transition.kind
|
|
275
|
-
* @returns {Promise<Blob>}
|
|
319
|
+
* @returns {Promise<Blob>} mp4 Blob (WebCodecs path) or webm Blob (MediaRecorder fallback)
|
|
276
320
|
*/
|
|
277
321
|
async toVideo(opts = {}) {
|
|
278
322
|
await this._ready;
|
|
@@ -375,6 +419,8 @@ export class VexyStax {
|
|
|
375
419
|
this._cancelTransition?.();
|
|
376
420
|
this._scrollspy?.disconnect?.();
|
|
377
421
|
this.disableClickToggle();
|
|
422
|
+
this._controls?.destroy();
|
|
423
|
+
this._controls = null;
|
|
378
424
|
this._ro?.disconnect?.();
|
|
379
425
|
this._ro = null;
|
|
380
426
|
this.stage?.dispose();
|
|
@@ -385,7 +431,7 @@ export class VexyStax {
|
|
|
385
431
|
// MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
|
|
386
432
|
// `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
|
|
387
433
|
const MOUNT_KEYS = new Set([
|
|
388
|
-
"slides", "scene", "view", "mode", "trigger", "width", "height", "baseUrl", "clickToggle",
|
|
434
|
+
"slides", "scene", "view", "mode", "trigger", "width", "height", "aspect", "baseUrl", "clickToggle",
|
|
389
435
|
]);
|
|
390
436
|
|
|
391
437
|
/**
|
|
@@ -405,6 +451,7 @@ const MOUNT_KEYS = new Set([
|
|
|
405
451
|
* "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
|
|
406
452
|
* @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
|
|
407
453
|
* @param {string} [opts.width] @param {string} [opts.height] CSS size overrides on the element.
|
|
454
|
+
* @param {string|number} [opts.aspect] CSS aspect-ratio for the box (e.g. 3, "3/1", "3:1").
|
|
408
455
|
* @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
|
|
409
456
|
* ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
|
|
410
457
|
* transition, background, captions, floor, edge, caption_defaults, …).
|
|
@@ -419,6 +466,9 @@ export async function createStax(elOrSelector, opts = {}) {
|
|
|
419
466
|
// Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
|
|
420
467
|
if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
|
|
421
468
|
if (opts.height) el.style.height = /^\d+$/.test(String(opts.height)) ? `${opts.height}px` : opts.height;
|
|
469
|
+
// Issue 701: `aspect` shapes the box via CSS aspect-ratio (e.g. 3, "3/1", "3:1" → a 3:1 deck);
|
|
470
|
+
// ":"/"x" are normalized to "/". Parity with the <vexy-stax aspect="…"> attribute.
|
|
471
|
+
if (opts.aspect) el.style.aspectRatio = String(opts.aspect).trim().replace(/[:x]/i, " / ");
|
|
422
472
|
if (typeof el.style === "object") {
|
|
423
473
|
el.style.position = el.style.position || "relative";
|
|
424
474
|
el.style.display = el.style.display || "block";
|
|
@@ -460,5 +510,19 @@ export async function createStax(elOrSelector, opts = {}) {
|
|
|
460
510
|
// and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
|
|
461
511
|
// with `clickToggle: false`.
|
|
462
512
|
if (opts.clickToggle !== false) stax.enableClickToggle();
|
|
513
|
+
|
|
514
|
+
// Issue 343: built-in control buttons. `buttons: true` (or "toggle"/"pair") shows the overlay;
|
|
515
|
+
// `explainLabel`/`previewLabel`/`buttonsPosition`/`buttonStyle` customize it (or pass a full
|
|
516
|
+
// object as `buttons`).
|
|
517
|
+
if (opts.buttons) {
|
|
518
|
+
const c = typeof opts.buttons === "object" ? opts.buttons : { type: opts.buttons === "pair" ? "pair" : "toggle" };
|
|
519
|
+
stax.controls({
|
|
520
|
+
explainLabel: opts.explainLabel,
|
|
521
|
+
previewLabel: opts.previewLabel,
|
|
522
|
+
position: opts.buttonsPosition,
|
|
523
|
+
style: opts.buttonStyle,
|
|
524
|
+
...c,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
463
527
|
return stax;
|
|
464
528
|
}
|
package/src/stage.js
CHANGED
|
@@ -30,6 +30,11 @@ import {
|
|
|
30
30
|
} from "./geometry.js";
|
|
31
31
|
import { resolvedOpacity } from "./scene.js";
|
|
32
32
|
|
|
33
|
+
// Per-slide render-order block size (issue 344): each slide owns [i*BLOCK, i*BLOCK+BLOCK) for its
|
|
34
|
+
// plate (+0), border (+1) and caption (+2). Blocks grow with the slide index (front slides last),
|
|
35
|
+
// so a front slide's plate occludes the captions of slides behind it. Reflections/floor sit below.
|
|
36
|
+
const RENDER_BLOCK = 4;
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* Build a white OPAQUE bordered caption PLATE (issue 311). The whole caption is one
|
|
35
40
|
* rectangle drawn on a single canvas texture: solid white fill, a stroked border of
|
|
@@ -277,11 +282,14 @@ export class Stage {
|
|
|
277
282
|
opacity: 1,
|
|
278
283
|
});
|
|
279
284
|
const mesh = new THREE.Mesh(geometry, material);
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
+
// PER-SLIDE render-order blocks (issue 344, mirrors pygfx issue 327): the plates and
|
|
286
|
+
// captions are alpha-blended with depthWrite/Test off, so DRAW ORDER — not depth — decides
|
|
287
|
+
// compositing. Give each slide a contiguous block `i*BLOCK` that grows with the slide index
|
|
288
|
+
// (index 0 = backmost, last = frontmost), so a FRONT slide's plate (i*BLOCK) paints AFTER —
|
|
289
|
+
// and its opaque pixels cover — a BACK slide's caption (`(i-1)*BLOCK + 2`). Within a block:
|
|
290
|
+
// plate(+0) < border(+1) < caption(+2). Reflections/floor stay below all of it (-2 / -1).
|
|
291
|
+
// Equal renderOrders would let three.js distance-sort and flip frame-to-frame (issue 320 §9).
|
|
292
|
+
mesh.renderOrder = i * RENDER_BLOCK;
|
|
285
293
|
this.threeScene.add(mesh);
|
|
286
294
|
|
|
287
295
|
// BLURRY mirror reflection (issue 303 §1): a mirror copy below the floor line
|
|
@@ -319,7 +327,7 @@ export class Stage {
|
|
|
319
327
|
let border = null;
|
|
320
328
|
if (this._edgeWidth > 0) {
|
|
321
329
|
border = this._makeBorder(w, h, this._edgeWidth, this._edgeColor);
|
|
322
|
-
border.group.renderOrder = 1; //
|
|
330
|
+
border.group.renderOrder = i * RENDER_BLOCK + 1; // on top of this slide's plate (issue 344)
|
|
323
331
|
this.threeScene.add(border.group);
|
|
324
332
|
}
|
|
325
333
|
|
|
@@ -450,7 +458,10 @@ export class Stage {
|
|
|
450
458
|
borderColor,
|
|
451
459
|
};
|
|
452
460
|
const { mesh, material, worldWidth } = makeCaptionSprite(caption.text, style);
|
|
453
|
-
|
|
461
|
+
// Issue 344: this slide's caption draws on top of its OWN plate + border (i*BLOCK + 2), but
|
|
462
|
+
// BELOW the next (more frontward) slide's plate ((i+1)*BLOCK), so front slides occlude the
|
|
463
|
+
// captions of slides behind them.
|
|
464
|
+
mesh.renderOrder = i * RENDER_BLOCK + 2;
|
|
454
465
|
this.threeScene.add(mesh);
|
|
455
466
|
this.captions.push({ sprite: mesh, material, plateIndex: i, caption, worldWidth });
|
|
456
467
|
});
|
package/src/transition.js
CHANGED
|
@@ -114,8 +114,12 @@ export function playTransition(scene, apply, opts = {}) {
|
|
|
114
114
|
const tr = scene.transition;
|
|
115
115
|
const kind = opts.kind ?? tr?.kind;
|
|
116
116
|
if (!kind) throw new Error("playTransition: no transition kind (scene.transition is null and no kind given)");
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// `opts.duration` overrides the scene timing (issue 342: a click-toggle plays a SNAPPY leg, not
|
|
118
|
+
// the scene's full transition.duration). `opts.aspect` is the LIVE container aspect — without it
|
|
119
|
+
// frameStateAt() falls back to the scene aspect and the compact endpoint is framed too close for
|
|
120
|
+
// a non-scene-aspect container (e.g. the wide scrollable demo).
|
|
121
|
+
const duration = opts.duration ?? tr?.duration ?? 3.0;
|
|
122
|
+
const wait = opts.duration != null ? 0.0 : tr?.wait ?? 0.0;
|
|
119
123
|
const easing = tr?.easing ?? "easeInOutCubic";
|
|
120
124
|
const timeline = buildTimeline(kind, { duration, wait });
|
|
121
125
|
|
|
@@ -147,7 +151,7 @@ export function playTransition(scene, apply, opts = {}) {
|
|
|
147
151
|
const elapsed = now() - start;
|
|
148
152
|
const p = totalMs > 0 ? Math.min(1, elapsed / totalMs) : 1;
|
|
149
153
|
const t = morphAtProgress(timeline, p, (x) => ease(easing, x));
|
|
150
|
-
apply(frameStateAt(scene, t));
|
|
154
|
+
apply(frameStateAt(scene, t, opts.aspect));
|
|
151
155
|
opts.onProgress?.(p);
|
|
152
156
|
if (p >= 1) {
|
|
153
157
|
resolveFn();
|