vexy-stax-js 3.1.1 → 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 +160 -0
- package/LICENSE +1 -1
- package/README.md +92 -13
- package/package.json +1 -1
- package/schema/vexy-stax-scene.schema.json +3 -3
- package/src/controls.js +138 -0
- package/src/element.js +126 -12
- package/src/geometry.js +11 -2
- package/src/global.js +9 -3
- package/src/index.js +225 -5
- package/src/scene.js +130 -5
- package/src/stage.js +24 -7
- package/src/transition.js +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,166 @@
|
|
|
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
|
+
|
|
97
|
+
## [3.0.13] — issues 341, 342
|
|
98
|
+
|
|
99
|
+
### Added
|
|
100
|
+
|
|
101
|
+
- **`makeScene(slides, opts)`** (341): the "extremely easy to use" entry point — build a valid scene
|
|
102
|
+
from a bare list of slide image URLs (or `{src, caption, opacity, gap}` objects) plus a flat
|
|
103
|
+
options bag (`size`, `camera`, `gap`, `transition`, `view`, `background`, `captions`, `floor`,
|
|
104
|
+
`edge`, …). Defaults are filled so `makeScene(["a.png", "b.png"])` renders. Goes through the same
|
|
105
|
+
strict `parseScene` (illegal scenes still throw) and resolves slide srcs against `opts.baseUrl`.
|
|
106
|
+
Exported from `vexy-stax-js`, the element bundle, and the global build.
|
|
107
|
+
- **`createStax(elOrSelector, opts)`** (341): an ESM factory mirroring lines-nano's `createNano`.
|
|
108
|
+
Resolves the element, builds the scene (from `slides` via `makeScene`, or `scene` via `loadScene`
|
|
109
|
+
— a URL **or** an inline object), mounts a `VexyStax`, waits for `ready`, optionally starts a mode
|
|
110
|
+
(`playable`/`scrollspy`), and returns the ready instance. Re-exported from the element bundle so a
|
|
111
|
+
single CDN `<script>` import gives `createStax`/`makeScene`/`loadScene`/`VexyStax`.
|
|
112
|
+
- **`slides` + `captions` attributes on `<vexy-stax>`** (341): `<vexy-stax slides="a.png b.png c.png"
|
|
113
|
+
view="compact" mode="playable">` builds a scene from a space/newline-separated URL list (no scene
|
|
114
|
+
JSON needed). `captions` toggles caption plates. The existing `scene`/`config` paths are unchanged.
|
|
115
|
+
- **Remote slide images** (341): the three.js `TextureLoader` now sets `crossOrigin="anonymous"`, so
|
|
116
|
+
slide `src` may be a remote `http(s)` URL — the image loads cross-origin **and** the canvas stays
|
|
117
|
+
exportable (`toImage`/`toVideo`) when the server sends CORS headers. `resolveSrc` already preserved
|
|
118
|
+
absolute URLs; this is the last piece. Verified headless (a slide referenced by an absolute http
|
|
119
|
+
URL loads and exports a non-trivial PNG).
|
|
120
|
+
- **Click-to-toggle** (342): clicking anywhere inside an interactive container fluently transitions
|
|
121
|
+
between views — not-compact → collapse to compact, compact → expand. It reuses the morph driver (a
|
|
122
|
+
smooth `expand`/`collapse` leg — never a snap) and is **ON by default** for the `<vexy-stax>`
|
|
123
|
+
element and every `createStax` instance, layered **on top of** scrollspy (scroll drives the morph;
|
|
124
|
+
a click still toggles). New methods: `VexyStax.toggleView()` / `.enableClickToggle()` /
|
|
125
|
+
`.disableClickToggle()` and `el.toggleView()`. Opt out via `click-toggle="false"` (attribute) or
|
|
126
|
+
`createStax(el, { clickToggle: false })`.
|
|
127
|
+
- **Scene-in-init** (342): pass a full inline scene **object** at initialization — `createStax(el,
|
|
128
|
+
{ scene: {…} })` and the `<vexy-stax>` `el.scene = {…}` property (alongside the existing `config`
|
|
129
|
+
property). No URL / fetch required.
|
|
130
|
+
- **Step-by-step how-to demos in `docs/`** (341): `scripts/build-docs.mjs` now also emits
|
|
131
|
+
`demo-component.html` (declarative `<vexy-stax>`), `demo-module.html` (ESM `createStax`/inline scene
|
|
132
|
+
/click-to-toggle), and `demo-library.html` (global `window.VexyStax.create`) — each a side-by-side
|
|
133
|
+
"minimal code + live element" page modeled on i.vexy.art/dev/lines-nano. The landing page gains a
|
|
134
|
+
"Use it — three ways" section linking to them, and every page documents **both** the co-located
|
|
135
|
+
local bundle and the **jsDelivr CDN** URL (`https://cdn.jsdelivr.net/npm/vexy-stax-js@<version>/…`,
|
|
136
|
+
version read from `package.json`). The existing `playable.html` / `scrollable.html` are unchanged
|
|
137
|
+
except the scrollspy demo now lets a click toggle on top of the scroll.
|
|
138
|
+
|
|
139
|
+
### Changed
|
|
140
|
+
|
|
141
|
+
- **Default floor → invisible white pane with faint reflections** (`#ffffff` / opacity `0.0` /
|
|
142
|
+
reflectivity `0.1`): `scene.js` `parseFloor`, the JSON schema, and the docs demos now default to a
|
|
143
|
+
floor with **no visible grey rectangle** (opacity 0) and only a whisper of mirror (reflectivity
|
|
144
|
+
0.1). Kept in exact lockstep with `vexy_stax.scene.Floor` (PY↔JS parity). The previous default was
|
|
145
|
+
a smoked-glass dark tint (`#1a1a1a` / `0.04` / `0.5`).
|
|
146
|
+
|
|
147
|
+
### Notes
|
|
148
|
+
|
|
149
|
+
- The package version is `3.1.2` (the next patch after the published `3.1.1`); this CHANGELOG entry
|
|
150
|
+
follows the repo's `3.0.x` issue-tracking heading convention for issues 341/342. CDN URLs in the
|
|
151
|
+
docs/README pin to `3.1.2`.
|
|
152
|
+
|
|
153
|
+
## [3.0.12] — issue 341
|
|
154
|
+
|
|
155
|
+
### Changed
|
|
156
|
+
|
|
157
|
+
- **`docs/` is now a proper landing page** (341): https://vexy.dev/vexy-stax-js/ used to embed the
|
|
158
|
+
playable animation directly (with the dated grey-reflection floor) and linked to nothing.
|
|
159
|
+
`scripts/build-docs.mjs` now emits `index.html` as a LANDING PAGE — a hero + three cards linking
|
|
160
|
+
to the **Animated demo** (`playable.html`), the **Scrollspy demo** (`scrollable.html`), and the
|
|
161
|
+
**Documentation** at https://vexy.dev/vexy-stax-py/ — plus install/usage snippets, and no embedded
|
|
162
|
+
animation. The two demos are emitted as their own pages using clean-floor scene variants
|
|
163
|
+
(`airbl-demo.scene.json` / `airbl-scrollable.scene.json`, reflectivity 0) so neither shows the grey
|
|
164
|
+
reflection "shadows". All paths are relative so the site works both locally and under
|
|
165
|
+
`/vexy-stax-js/`. Verified headless: both demos mount without errors and the landing cards resolve.
|
|
166
|
+
|
|
7
167
|
## [3.0.11] — issues 335, 336, 337
|
|
8
168
|
|
|
9
169
|
### Added
|
package/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright 2025
|
|
189
|
+
Copyright 2025 Fontlab Ltd.
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -28,30 +28,109 @@ node verify/run.mjs && python3 verify/gate.py
|
|
|
28
28
|
|
|
29
29
|
## Usage
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Three ways to drop a deck on a page, easiest first (issues 341 / 342).
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
import { VexyStax, loadScene } from "vexy-stax-js";
|
|
35
|
-
const scene = await loadScene("scene.json");
|
|
36
|
-
const stax = new VexyStax(container, scene);
|
|
37
|
-
await stax.setView("compact");
|
|
38
|
-
const blob = await stax.toImage({ scale: 2 });
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Web Component:
|
|
33
|
+
### Web Component — just a list of slides
|
|
42
34
|
|
|
43
35
|
```html
|
|
44
36
|
<script type="module" src="./dist/vexy-stax.element.js"></script>
|
|
37
|
+
|
|
38
|
+
<!-- The `slides` attribute: a space/newline-separated list of image URLs (local, data:, or
|
|
39
|
+
remote http(s)). Captions off by default; mode="playable" plays the transition once. -->
|
|
40
|
+
<vexy-stax
|
|
41
|
+
slides="layer-0.png layer-1.png https://example.com/layer-2.png"
|
|
42
|
+
view="compact" mode="playable">
|
|
43
|
+
</vexy-stax>
|
|
44
|
+
|
|
45
|
+
<!-- …or point at a full scene JSON (with captions, camera, transition, …): -->
|
|
45
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>
|
|
46
57
|
```
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
**Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
|
|
60
|
+
toggles compact↔expanded. Opt out with `click-toggle="false"`.
|
|
61
|
+
|
|
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.
|
|
74
|
+
|
|
75
|
+
### ES Module — `createStax`
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import { createStax, makeScene, VexyStax, loadScene } from "vexy-stax-js";
|
|
79
|
+
|
|
80
|
+
// One call: build a scene from a URL list, mount, wait for ready.
|
|
81
|
+
const stax = await createStax("#stage", {
|
|
82
|
+
slides: ["layer-0.png", "https://example.com/layer-1.png"],
|
|
83
|
+
gap: 480, transition: "expand_collapse", mode: "playable",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await stax.toggleView(); // fluent compact↔expanded (the default click behavior)
|
|
87
|
+
const mp4 = await stax.toVideo(); // seekable mp4
|
|
88
|
+
|
|
89
|
+
// createStax also accepts an inline scene object (scene-in-init):
|
|
90
|
+
await createStax("#hero", { scene: { version: 1, slides: [{ src: "a.png" }] }, view: "expanded" });
|
|
91
|
+
|
|
92
|
+
// makeScene builds a valid scene from a bare URL list, filling sensible defaults:
|
|
93
|
+
const scene = makeScene(["a.png", "b.png", "c.png"], { gap: 480, transition: "expand_collapse" });
|
|
94
|
+
const low = new VexyStax(container, scene); // the low-level path is still available
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
|
|
98
|
+
height, aspect, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
|
|
99
|
+
`transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
|
|
100
|
+
|
|
101
|
+
### Global script — `window.VexyStax`
|
|
49
102
|
|
|
50
103
|
```html
|
|
51
104
|
<script src="./dist/vexy-stax.global.js"></script>
|
|
52
|
-
<script>
|
|
105
|
+
<script>
|
|
106
|
+
VexyStax.create("#stage", { slides: ["a.png", "b.png", "c.png"] });
|
|
107
|
+
</script>
|
|
53
108
|
```
|
|
54
109
|
|
|
110
|
+
### CDN (no build step)
|
|
111
|
+
|
|
112
|
+
Every snippet above also works verbatim from the jsDelivr CDN — swap the local bundle path for:
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<!-- Web Component / ESM -->
|
|
116
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.element.js"></script>
|
|
117
|
+
<!-- Global script -->
|
|
118
|
+
<script src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.global.js"></script>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Remote slide images
|
|
122
|
+
|
|
123
|
+
Slide `src` may be a local path, a `data:` URI, **or a remote `http(s)` URL**. The texture loader
|
|
124
|
+
requests cross-origin images with `crossOrigin="anonymous"`, so a server that sends CORS headers
|
|
125
|
+
lets the image load **and** keeps the canvas exportable (`toImage` / `toVideo`).
|
|
126
|
+
|
|
127
|
+
### Live demos & how-to pages
|
|
128
|
+
|
|
129
|
+
`npm run build:docs` emits a [docs site](https://vexy.dev/vexy-stax-js/): a landing page, the
|
|
130
|
+
**Animated** (`playable.html`) and **Scrollspy** (`scrollable.html`) demos, plus three side-by-side
|
|
131
|
+
"how to use" pages — `demo-component.html`, `demo-module.html`, `demo-library.html` — each showing
|
|
132
|
+
the minimal code beside the live result (modeled on i.vexy.art/dev/lines-nano).
|
|
133
|
+
|
|
55
134
|
## Layout
|
|
56
135
|
|
|
57
136
|
```
|
|
@@ -74,4 +153,4 @@ for the same scene; both are tested against the same fixture vectors.
|
|
|
74
153
|
|
|
75
154
|
## License
|
|
76
155
|
|
|
77
|
-
Apache-2.0 — Copyright 2026
|
|
156
|
+
Apache-2.0 — Copyright 2026 Fontlab Ltd.
|
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": {
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"type": "object",
|
|
54
54
|
"additionalProperties": false,
|
|
55
55
|
"properties": {
|
|
56
|
-
"color": { "type": "string", "default": "#
|
|
57
|
-
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.
|
|
58
|
-
"reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.
|
|
56
|
+
"color": { "type": "string", "default": "#ffffff" },
|
|
57
|
+
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.0, "description": "Invisible floor pane by default (no grey rectangle); faint reflections only." },
|
|
58
|
+
"reflectivity": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.1 }
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"edge": {
|
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
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// this_file: src/element.js
|
|
3
3
|
//
|
|
4
|
-
// <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
// <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
|
|
5
|
+
// (space/newline-separated image URLs — issue 341), captions (bool), view,
|
|
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.
|
|
12
|
+
|
|
13
|
+
import { VexyStax, loadScene, makeScene } from "./index.js";
|
|
14
|
+
|
|
15
|
+
// Re-export the public ESM API from the element bundle (issue 341): the built
|
|
16
|
+
// dist/vexy-stax.element.js has src/element.js as its entry, so a user who loads that single
|
|
17
|
+
// file can also `import { createStax, makeScene, loadScene, VexyStax }` from it (the how-to
|
|
18
|
+
// ESM demo + the documented CDN URL both rely on this).
|
|
19
|
+
export { VexyStax, loadScene, parseScene, makeScene, createStax } from "./index.js";
|
|
10
20
|
|
|
11
21
|
export class VexyStaxElement extends HTMLElement {
|
|
12
22
|
static get observedAttributes() {
|
|
13
|
-
|
|
23
|
+
// `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
|
|
24
|
+
// `click-toggle` is the issue-342 opt-out for the default click-to-toggle behavior.
|
|
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
|
+
];
|
|
14
30
|
}
|
|
15
31
|
|
|
16
32
|
constructor() {
|
|
@@ -20,7 +36,7 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
20
36
|
this._mounting = false;
|
|
21
37
|
}
|
|
22
38
|
|
|
23
|
-
/** Inline scene object (or JSON string); overrides the `scene`
|
|
39
|
+
/** Inline scene object (or JSON string); overrides the `scene`/`slides` attributes. */
|
|
24
40
|
set config(value) {
|
|
25
41
|
this._config = typeof value === "string" ? JSON.parse(value) : value;
|
|
26
42
|
if (this.isConnected) this._mount();
|
|
@@ -29,6 +45,26 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
29
45
|
return this._config;
|
|
30
46
|
}
|
|
31
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Scene-in-init (issue 342): assigning an OBJECT (or JSON string) sets the inline scene
|
|
50
|
+
* (same as `config`), so `el.scene = {version:1, slides:[…]}` works at init. Assigning a
|
|
51
|
+
* STRING URL is treated as the `scene` attribute (a URL to fetch). This makes the property
|
|
52
|
+
* mirror the lines-nano-style "pass the data right in" ergonomics for the Web Component.
|
|
53
|
+
*/
|
|
54
|
+
set scene(value) {
|
|
55
|
+
if (value && typeof value === "object") {
|
|
56
|
+
this.config = value; // inline scene object
|
|
57
|
+
} else if (typeof value === "string") {
|
|
58
|
+
// Looks like JSON? treat as an inline scene; otherwise it's a URL attribute.
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (trimmed.startsWith("{")) this.config = JSON.parse(trimmed);
|
|
61
|
+
else this.setAttribute("scene", value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
get scene() {
|
|
65
|
+
return this._config ?? this.getAttribute("scene");
|
|
66
|
+
}
|
|
67
|
+
|
|
32
68
|
/** The underlying VexyStax instance (null until mounted). */
|
|
33
69
|
get instance() {
|
|
34
70
|
return this._stax;
|
|
@@ -51,7 +87,7 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
51
87
|
|
|
52
88
|
attributeChangedCallback(name) {
|
|
53
89
|
if (!this.isConnected) return;
|
|
54
|
-
if (name === "width" || name === "height") {
|
|
90
|
+
if (name === "width" || name === "height" || name === "aspect") {
|
|
55
91
|
this._applySize();
|
|
56
92
|
this._stax?.resize();
|
|
57
93
|
return;
|
|
@@ -60,6 +96,12 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
60
96
|
this._stax?.setView(this.getAttribute("view") || "expanded");
|
|
61
97
|
return;
|
|
62
98
|
}
|
|
99
|
+
if (name === "click-toggle") {
|
|
100
|
+
// Toggle the issue-342 behavior in place (no costly remount).
|
|
101
|
+
if (this.getAttribute("click-toggle") === "false") this._stax?.disableClickToggle();
|
|
102
|
+
else this._stax?.enableClickToggle();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
63
105
|
// scene/mode changes re-mount.
|
|
64
106
|
this._mount();
|
|
65
107
|
}
|
|
@@ -69,6 +111,36 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
69
111
|
const h = this.getAttribute("height");
|
|
70
112
|
if (w) this.style.width = /^\d+$/.test(w) ? `${w}px` : w;
|
|
71
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
|
+
}
|
|
72
144
|
}
|
|
73
145
|
|
|
74
146
|
async _mount() {
|
|
@@ -78,10 +150,26 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
78
150
|
this._stax?.destroy();
|
|
79
151
|
this._stax = null;
|
|
80
152
|
|
|
81
|
-
const sceneSrc = this._config ?? this.getAttribute("scene");
|
|
82
|
-
if (!sceneSrc) return; // nothing to render yet
|
|
83
153
|
const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
|
|
84
|
-
|
|
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");
|
|
160
|
+
const slidesAttr = this.getAttribute("slides");
|
|
161
|
+
let scene;
|
|
162
|
+
if (sceneSrc) {
|
|
163
|
+
scene = await loadScene(sceneSrc, { baseUrl });
|
|
164
|
+
} else if (slidesAttr && slidesAttr.trim()) {
|
|
165
|
+
const urls = slidesAttr.split(/\s+/).filter(Boolean);
|
|
166
|
+
const captionsAttr = this.getAttribute("captions");
|
|
167
|
+
const opts = { baseUrl };
|
|
168
|
+
if (captionsAttr !== null) opts.captions = captionsAttr !== "false";
|
|
169
|
+
scene = makeScene(urls, opts);
|
|
170
|
+
} else {
|
|
171
|
+
return; // nothing to render yet
|
|
172
|
+
}
|
|
85
173
|
|
|
86
174
|
const view = this.getAttribute("view") || scene.view || "expanded";
|
|
87
175
|
scene.view = view;
|
|
@@ -99,6 +187,28 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
99
187
|
// mode="playable" leaves the deck at its initial view; the host calls
|
|
100
188
|
// el.transition(...) (e.g. on a button) to play it.
|
|
101
189
|
|
|
190
|
+
// Issue 342: click-to-toggle is ON by default for the interactive container — a click
|
|
191
|
+
// anywhere inside fluently toggles compact↔expanded, layered on top of scrollspy. Opt
|
|
192
|
+
// out with the `click-toggle="false"` attribute (or mode="static" pages that don't want it
|
|
193
|
+
// can still opt out explicitly). Default ON for every mode so a generic <vexy-stax> just
|
|
194
|
+
// works.
|
|
195
|
+
if (this.getAttribute("click-toggle") !== "false") {
|
|
196
|
+
this._stax?.enableClickToggle();
|
|
197
|
+
}
|
|
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
|
+
|
|
102
212
|
this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
|
|
103
213
|
} catch (err) {
|
|
104
214
|
this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
|
|
@@ -127,6 +237,10 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
127
237
|
seek(t) {
|
|
128
238
|
return this._stax?.seek(t);
|
|
129
239
|
}
|
|
240
|
+
/** Issue 342: fluently toggle compact↔expanded (the default click behavior, exposed). */
|
|
241
|
+
toggleView() {
|
|
242
|
+
return this._stax?.toggleView();
|
|
243
|
+
}
|
|
130
244
|
}
|
|
131
245
|
|
|
132
246
|
if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
|
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/global.js
CHANGED
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
// Classic-script global entry (SPEC.md §6.3). Importing this auto-registers the
|
|
5
5
|
// <vexy-stax> element (via element.js) and exposes window.VexyStax.
|
|
6
6
|
|
|
7
|
-
import { VexyStax, loadScene } from "./index.js";
|
|
7
|
+
import { VexyStax, loadScene, makeScene, createStax } from "./index.js";
|
|
8
8
|
import "./element.js";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Issue 341: expose the easy entry points on the global too. `window.VexyStax.create(el, opts)`
|
|
11
|
+
// mirrors lines-nano's `VexyLinesNano.create`, and makeScene builds a scene from a URL list.
|
|
12
|
+
const api = { VexyStax, loadScene, makeScene, createStax, create: createStax };
|
|
11
13
|
|
|
12
14
|
if (typeof window !== "undefined") {
|
|
13
15
|
window.VexyStax = api;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export
|
|
18
|
+
// Also export `create` as a NAMED binding (alias of createStax): Vite's IIFE lib mode assigns
|
|
19
|
+
// `window.VexyStax = <module exports namespace>` AFTER this file runs, overwriting the `api`
|
|
20
|
+
// object above — so `create` must be a real export to survive on `window.VexyStax.create`
|
|
21
|
+
// (the lines-nano-style entry point used by the global demo).
|
|
22
|
+
export { VexyStax, loadScene, makeScene, createStax, createStax as create };
|
package/src/index.js
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
// mapping, and exporters are in transition.js / scrollspy.js / export.js.
|
|
8
8
|
|
|
9
9
|
import { Stage } from "./stage.js";
|
|
10
|
-
import { loadScene, parseScene, resolvedOpacity } from "./scene.js";
|
|
10
|
+
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 {
|
|
17
18
|
loadScene,
|
|
18
19
|
parseScene,
|
|
20
|
+
makeScene,
|
|
19
21
|
resolvedOpacity,
|
|
20
22
|
};
|
|
21
23
|
|
|
@@ -50,6 +52,14 @@ export class VexyStax {
|
|
|
50
52
|
this.scene = scene;
|
|
51
53
|
this.stage = new Stage(container, scene);
|
|
52
54
|
this._ro = null; // ResizeObserver for post-layout resize
|
|
55
|
+
// Issue 342: track the CURRENT view so click-to-toggle knows which way to go. Starts at the
|
|
56
|
+
// scene's initial view; updated by setView/seek/transition/toggleView. `_morphT` is the last
|
|
57
|
+
// applied morph factor (0=compact, 1=expanded) used to disambiguate mid-morph clicks.
|
|
58
|
+
this._currentView = scene.view === "compact" ? "compact" : "expanded";
|
|
59
|
+
this._morphT = this._currentView === "compact" ? 0 : 1;
|
|
60
|
+
this._clickToggle = null; // { handler } when wired (enableClickToggle)
|
|
61
|
+
this._controls = null; // { destroy } when wired (controls(), issue 343)
|
|
62
|
+
this._toggling = false; // guard against overlapping toggle transitions
|
|
53
63
|
this._ready = this.stage.init().then(() => {
|
|
54
64
|
this.stage.render();
|
|
55
65
|
// After mount, observe the container for its first actual layout dimensions.
|
|
@@ -87,6 +97,8 @@ export class VexyStax {
|
|
|
87
97
|
await this._ready;
|
|
88
98
|
this.stage.setView(view);
|
|
89
99
|
this.stage.render();
|
|
100
|
+
this._morphT = view === "compact" ? 0 : 1;
|
|
101
|
+
this._setView_(view === "compact" ? "compact" : "expanded");
|
|
90
102
|
return this;
|
|
91
103
|
}
|
|
92
104
|
|
|
@@ -99,9 +111,22 @@ export class VexyStax {
|
|
|
99
111
|
const tt = Math.max(0, Math.min(1, Number(t) || 0));
|
|
100
112
|
this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
|
|
101
113
|
this.stage.render();
|
|
114
|
+
// Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
|
|
115
|
+
// whether it is currently nearer compact (→ expand) or expanded (→ collapse).
|
|
116
|
+
this._morphT = tt;
|
|
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");
|
|
102
120
|
return this;
|
|
103
121
|
}
|
|
104
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
|
+
|
|
105
130
|
/** Resize the renderer/camera to the container (or explicit size). */
|
|
106
131
|
resize(width, height) {
|
|
107
132
|
const w = width ?? this.container.clientWidth;
|
|
@@ -172,12 +197,19 @@ export class VexyStax {
|
|
|
172
197
|
const t = this._morphFromGaps(state.gaps);
|
|
173
198
|
this.stage.applyFrameState(state, t);
|
|
174
199
|
this.stage.render();
|
|
175
|
-
|
|
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 });
|
|
176
204
|
|
|
177
205
|
this._cancelTransition = controller.cancel;
|
|
178
206
|
this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
|
|
179
207
|
try {
|
|
180
208
|
await controller.promise;
|
|
209
|
+
// Settle the tracked view to the transition's end endpoint (issue 342).
|
|
210
|
+
const { endMorph } = transitionEndpoints(resolvedKind);
|
|
211
|
+
this._morphT = endMorph;
|
|
212
|
+
this._setView_(endMorph >= 0.5 ? "expanded" : "compact");
|
|
181
213
|
this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
|
|
182
214
|
} finally {
|
|
183
215
|
this._cancelTransition = null;
|
|
@@ -185,6 +217,76 @@ export class VexyStax {
|
|
|
185
217
|
return this;
|
|
186
218
|
}
|
|
187
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Click-to-toggle (issue 342): fluently transition between the two views. If the deck is
|
|
222
|
+
* currently expanded (or mid-morph past halfway), collapse to compact; otherwise expand.
|
|
223
|
+
* Reuses the existing morph driver (a smooth `collapse`/`expand` leg) — never a snap. Safe to
|
|
224
|
+
* call repeatedly: an in-flight toggle is ignored until it settles. Returns the played kind.
|
|
225
|
+
*/
|
|
226
|
+
async toggleView() {
|
|
227
|
+
await this._ready;
|
|
228
|
+
if (this._toggling) return null;
|
|
229
|
+
const goCompact = this._currentView !== "compact"; // not compact ⇒ collapse to compact
|
|
230
|
+
const kind = goCompact ? "collapse" : "expand";
|
|
231
|
+
this._toggling = true;
|
|
232
|
+
try {
|
|
233
|
+
// The scene may have no `transition` section (e.g. a slides-only deck) — supply timing so
|
|
234
|
+
// toggle still animates. transition() reads scene.transition for the easing; ensure one exists.
|
|
235
|
+
if (!this.scene.transition) {
|
|
236
|
+
this.scene.transition = { kind, duration: 0.7, wait: 0, fps: 30, easing: "easeInOutCubic" };
|
|
237
|
+
}
|
|
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 });
|
|
241
|
+
} finally {
|
|
242
|
+
this._toggling = false;
|
|
243
|
+
}
|
|
244
|
+
return kind;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Enable click-to-toggle on the container (issue 342): a pointer click anywhere inside the
|
|
249
|
+
* element fluently toggles compact↔expanded. ON by default for interactive containers (the
|
|
250
|
+
* <vexy-stax> element + createStax) and layered ON TOP of scrollspy (scroll drives the morph;
|
|
251
|
+
* a click still toggles). Idempotent. Pass to disableClickToggle() to opt out.
|
|
252
|
+
*/
|
|
253
|
+
enableClickToggle() {
|
|
254
|
+
if (this._clickToggle || !this.container?.addEventListener) return this;
|
|
255
|
+
const handler = (ev) => {
|
|
256
|
+
// Ignore clicks on interactive controls a host may overlay (buttons/links/inputs).
|
|
257
|
+
const tag = ev.target?.tagName;
|
|
258
|
+
if (tag && /^(BUTTON|A|INPUT|SELECT|TEXTAREA|LABEL)$/.test(tag)) return;
|
|
259
|
+
this.toggleView();
|
|
260
|
+
};
|
|
261
|
+
this.container.addEventListener("click", handler);
|
|
262
|
+
// Pointer affordance so it reads as clickable.
|
|
263
|
+
if (this.container.style && !this.container.style.cursor) this.container.style.cursor = "pointer";
|
|
264
|
+
this._clickToggle = { handler };
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
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
|
+
|
|
281
|
+
/** Remove the click-to-toggle handler (issue 342 opt-out). */
|
|
282
|
+
disableClickToggle() {
|
|
283
|
+
if (this._clickToggle) {
|
|
284
|
+
this.container.removeEventListener?.("click", this._clickToggle.handler);
|
|
285
|
+
this._clickToggle = null;
|
|
286
|
+
}
|
|
287
|
+
return this;
|
|
288
|
+
}
|
|
289
|
+
|
|
188
290
|
/** Re-derive the morph factor t from a frame's gap[1] (for caption fade). */
|
|
189
291
|
_morphFromGaps(gaps) {
|
|
190
292
|
if (gaps.length < 2) return 0;
|
|
@@ -195,11 +297,26 @@ export class VexyStax {
|
|
|
195
297
|
}
|
|
196
298
|
|
|
197
299
|
/**
|
|
198
|
-
* Record the transition to a video Blob
|
|
199
|
-
*
|
|
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
|
+
*
|
|
200
317
|
* @param {object} [opts]
|
|
201
318
|
* @param {string} [opts.kind] override scene.transition.kind
|
|
202
|
-
* @returns {Promise<Blob>}
|
|
319
|
+
* @returns {Promise<Blob>} mp4 Blob (WebCodecs path) or webm Blob (MediaRecorder fallback)
|
|
203
320
|
*/
|
|
204
321
|
async toVideo(opts = {}) {
|
|
205
322
|
await this._ready;
|
|
@@ -301,8 +418,111 @@ export class VexyStax {
|
|
|
301
418
|
destroy() {
|
|
302
419
|
this._cancelTransition?.();
|
|
303
420
|
this._scrollspy?.disconnect?.();
|
|
421
|
+
this.disableClickToggle();
|
|
422
|
+
this._controls?.destroy();
|
|
423
|
+
this._controls = null;
|
|
304
424
|
this._ro?.disconnect?.();
|
|
305
425
|
this._ro = null;
|
|
306
426
|
this.stage?.dispose();
|
|
307
427
|
}
|
|
308
428
|
}
|
|
429
|
+
|
|
430
|
+
// Scene-construction options consumed by createStax/makeScene rather than describing how to
|
|
431
|
+
// MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
|
|
432
|
+
// `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
|
|
433
|
+
const MOUNT_KEYS = new Set([
|
|
434
|
+
"slides", "scene", "view", "mode", "trigger", "width", "height", "aspect", "baseUrl", "clickToggle",
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The "extremely easy to use" ESM factory (issue 341), mirroring lines-nano's `createNano`.
|
|
439
|
+
* Resolve the mount element, build the scene (from a bare `slides` list via makeScene, or
|
|
440
|
+
* from a `scene` URL/object via loadScene), mount a VexyStax, wait until it's ready, optionally
|
|
441
|
+
* start a mode (playable/scrollspy), and return the ready instance.
|
|
442
|
+
*
|
|
443
|
+
* @param {HTMLElement|string} elOrSelector mount element or a CSS selector for it
|
|
444
|
+
* @param {object} [opts]
|
|
445
|
+
* @param {string[]|object[]} [opts.slides] slide image URLs (local/data:/remote) or slide
|
|
446
|
+
* objects — the easy path; built via makeScene with the remaining opts as overrides.
|
|
447
|
+
* @param {string|object} [opts.scene] a scene URL or inline scene object (via loadScene);
|
|
448
|
+
* used when `slides` is not given.
|
|
449
|
+
* @param {"expanded"|"compact"} [opts.view] initial view.
|
|
450
|
+
* @param {"static"|"playable"|"scrollspy"} [opts.mode] mount mode (default "static").
|
|
451
|
+
* "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
|
|
452
|
+
* @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
|
|
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").
|
|
455
|
+
* @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
|
|
456
|
+
* ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
|
|
457
|
+
* transition, background, captions, floor, edge, caption_defaults, …).
|
|
458
|
+
* @returns {Promise<VexyStax>} the ready instance.
|
|
459
|
+
*/
|
|
460
|
+
export async function createStax(elOrSelector, opts = {}) {
|
|
461
|
+
const el =
|
|
462
|
+
typeof elOrSelector === "string" ? document.querySelector(elOrSelector) : elOrSelector;
|
|
463
|
+
if (!el) throw new Error(`createStax: element not found (${String(elOrSelector)})`);
|
|
464
|
+
if (opts === null || typeof opts !== "object") throw new Error("createStax: opts must be an object");
|
|
465
|
+
|
|
466
|
+
// Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
|
|
467
|
+
if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
|
|
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, " / ");
|
|
472
|
+
if (typeof el.style === "object") {
|
|
473
|
+
el.style.position = el.style.position || "relative";
|
|
474
|
+
el.style.display = el.style.display || "block";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const baseUrl = opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
|
|
478
|
+
|
|
479
|
+
let scene;
|
|
480
|
+
if (opts.slides) {
|
|
481
|
+
// Flat scene overrides = every opt that isn't a mount/source key.
|
|
482
|
+
const sceneOpts = { baseUrl };
|
|
483
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
484
|
+
if (!MOUNT_KEYS.has(k)) sceneOpts[k] = v;
|
|
485
|
+
}
|
|
486
|
+
scene = makeScene(opts.slides, sceneOpts);
|
|
487
|
+
} else if (opts.scene !== undefined) {
|
|
488
|
+
scene = await loadScene(opts.scene, { baseUrl });
|
|
489
|
+
} else {
|
|
490
|
+
throw new Error("createStax: provide `slides` (URLs) or `scene` (URL/object)");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// The view override wins over the scene's own initial view.
|
|
494
|
+
if (opts.view) scene.view = opts.view;
|
|
495
|
+
|
|
496
|
+
const stax = new VexyStax(el, scene);
|
|
497
|
+
await stax.ready;
|
|
498
|
+
|
|
499
|
+
const mode = opts.mode ?? "static";
|
|
500
|
+
if (mode === "scrollspy") {
|
|
501
|
+
const trigger =
|
|
502
|
+
typeof opts.trigger === "string" ? document.querySelector(opts.trigger) : opts.trigger ?? el;
|
|
503
|
+
stax.scrollspy({ trigger });
|
|
504
|
+
} else if (mode === "playable" && scene.transition) {
|
|
505
|
+
// Kick off the scene's transition once (best-effort; ignore if it's cancelled by teardown).
|
|
506
|
+
stax.transition().catch(() => {});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Issue 342: click-to-toggle is ON by default for the interactive container (every mode),
|
|
510
|
+
// and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
|
|
511
|
+
// with `clickToggle: false`.
|
|
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
|
+
}
|
|
527
|
+
return stax;
|
|
528
|
+
}
|
package/src/scene.js
CHANGED
|
@@ -136,15 +136,17 @@ function parseVideo(raw) {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function parseFloor(raw) {
|
|
139
|
-
//
|
|
140
|
-
|
|
139
|
+
// Default floor: an INVISIBLE white pane (opacity 0 → no grey floor rectangle) with faint
|
|
140
|
+
// reflections (reflectivity 0.1) so decks read as floating with just a whisper of mirror.
|
|
141
|
+
// Kept in exact lockstep with vexy_stax.scene.Floor (PY↔JS parity).
|
|
142
|
+
if (raw === undefined) return { color: "#ffffff", opacity: 0.0, reflectivity: 0.1 };
|
|
141
143
|
const o = asObject(raw, "floor");
|
|
142
144
|
rejectExtraKeys(o, new Set(["color", "opacity", "reflectivity"]), "floor");
|
|
143
145
|
return {
|
|
144
|
-
color: o.color === undefined ? "#
|
|
145
|
-
opacity: o.opacity === undefined ? 0.
|
|
146
|
+
color: o.color === undefined ? "#ffffff" : str(o.color, "floor.color"),
|
|
147
|
+
opacity: o.opacity === undefined ? 0.0 : num(o.opacity, "floor.opacity", { min: 0, max: 1 }),
|
|
146
148
|
reflectivity:
|
|
147
|
-
o.reflectivity === undefined ? 0.
|
|
149
|
+
o.reflectivity === undefined ? 0.1 : num(o.reflectivity, "floor.reflectivity", { min: 0, max: 1 }),
|
|
148
150
|
};
|
|
149
151
|
}
|
|
150
152
|
|
|
@@ -290,6 +292,129 @@ export function parseScene(raw) {
|
|
|
290
292
|
};
|
|
291
293
|
}
|
|
292
294
|
|
|
295
|
+
// Issue 341: the "extremely easy to use" entry point. Build a valid Scene object from a
|
|
296
|
+
// bare list of slide image URLs (or {src, caption, opacity, gap} objects) + a flat options
|
|
297
|
+
// bag, filling sensible defaults so even `makeScene(["a.png", "b.png"])` renders. The result
|
|
298
|
+
// goes straight through parseScene (so the SAME strict invariants apply — illegal scenes
|
|
299
|
+
// still throw) and is returned normalized, ready for `new VexyStax(el, scene)`. Slide `src`
|
|
300
|
+
// may be a local path, a `data:` URI, OR a remote http(s) URL — resolveSrc/the TextureLoader
|
|
301
|
+
// preserve absolute URLs (and stage.js sets crossOrigin so remote images load + stay
|
|
302
|
+
// canvas-exportable). This keeps "easy scene customization" + "remote URL" in one helper.
|
|
303
|
+
|
|
304
|
+
/** One slide entry → a raw scene-slide object. A bare string is treated as `src`. */
|
|
305
|
+
function slideEntry(entry, index, defaults) {
|
|
306
|
+
if (typeof entry === "string") {
|
|
307
|
+
const slide = { src: entry };
|
|
308
|
+
if (defaults.caption) slide.caption = { text: "", show_in: "expanded" };
|
|
309
|
+
return slide;
|
|
310
|
+
}
|
|
311
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
312
|
+
throw new Error(`makeScene: slides[${index}] must be a string URL or an object`);
|
|
313
|
+
}
|
|
314
|
+
// Accept the friendly shape {src, caption, opacity, gap}. `caption` may be a plain
|
|
315
|
+
// string (→ {text, show_in:"expanded"}) for ergonomics, or the full caption object.
|
|
316
|
+
const slide = {};
|
|
317
|
+
if (entry.src === undefined) throw new Error(`makeScene: slides[${index}].src is required`);
|
|
318
|
+
slide.src = entry.src;
|
|
319
|
+
if (entry.gap !== undefined && entry.gap !== null) slide.gap = entry.gap;
|
|
320
|
+
if (entry.opacity !== undefined) slide.opacity = entry.opacity;
|
|
321
|
+
if (entry.caption !== undefined) {
|
|
322
|
+
slide.caption =
|
|
323
|
+
typeof entry.caption === "string"
|
|
324
|
+
? { text: entry.caption, show_in: "expanded" }
|
|
325
|
+
: entry.caption;
|
|
326
|
+
}
|
|
327
|
+
return slide;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Build a normalized Scene from a list of slide image URLs (or slide objects) plus a flat
|
|
332
|
+
* options bag (issue 341). Returns a parsed scene (via parseScene), so it is ready to hand
|
|
333
|
+
* to `new VexyStax(container, scene)`. Sensible defaults are filled so a bare list of URLs
|
|
334
|
+
* renders. Slide `src` may be local, `data:`, or a remote http(s) URL.
|
|
335
|
+
*
|
|
336
|
+
* @param {Array<string|{src:string, caption?:string|object, opacity?:number|object, gap?:number}>} slides
|
|
337
|
+
* slide image URLs or slide objects (at least one).
|
|
338
|
+
* @param {object} [opts] flat scene options:
|
|
339
|
+
* @param {{width:number,height:number}|number[]} [opts.size] scene size (default 1920×1080)
|
|
340
|
+
* @param {object} [opts.camera] camera overrides (gap/distance/angle/elevation/fov)
|
|
341
|
+
* @param {string} [opts.gap] shortcut for camera.gap (expanded plate spacing)
|
|
342
|
+
* @param {string|object} [opts.transition] a transition KIND string, or a full transition object
|
|
343
|
+
* @param {string} [opts.view] initial view ("expanded" | "compact")
|
|
344
|
+
* @param {string} [opts.background] background CSS color
|
|
345
|
+
* @param {boolean} [opts.captions] global captions toggle (default: true)
|
|
346
|
+
* @param {object} [opts.floor] floor overrides (color/opacity/reflectivity)
|
|
347
|
+
* @param {object} [opts.edge] plate edge overrides (width/color)
|
|
348
|
+
* @param {object} [opts.caption_defaults] caption style defaults (size/color/font/…)
|
|
349
|
+
* @param {object} [opts.caption_fade] caption fade overrides
|
|
350
|
+
* @param {object} [opts.video] video render overrides
|
|
351
|
+
* @returns {object} a normalized scene (same shape as parseScene's output)
|
|
352
|
+
*/
|
|
353
|
+
export function makeScene(slides, opts = {}) {
|
|
354
|
+
if (!Array.isArray(slides) || slides.length < 1) {
|
|
355
|
+
throw new Error("makeScene: pass a non-empty array of slide URLs or slide objects");
|
|
356
|
+
}
|
|
357
|
+
if (opts === null || typeof opts !== "object" || Array.isArray(opts)) {
|
|
358
|
+
throw new Error("makeScene: opts must be an object");
|
|
359
|
+
}
|
|
360
|
+
// Fail loud on unknown options (parse, don't validate): a typo'd override (e.g. `juicey`)
|
|
361
|
+
// shouldn't be silently dropped. `baseUrl`/`gap` are makeScene conveniences; the rest map
|
|
362
|
+
// 1:1 onto scene keys validated by parseScene.
|
|
363
|
+
const ALLOWED_OPTS = new Set([
|
|
364
|
+
"baseUrl", "gap", "size", "camera", "transition", "view", "background",
|
|
365
|
+
"captions", "floor", "edge", "juicy", "caption_defaults", "caption_fade", "video",
|
|
366
|
+
]);
|
|
367
|
+
for (const key of Object.keys(opts)) {
|
|
368
|
+
if (!ALLOWED_OPTS.has(key)) throw new Error(`makeScene: unknown option ${JSON.stringify(key)}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A bare list of objects with no caption text shouldn't auto-add empty caption plates;
|
|
372
|
+
// `captions` only forces empty captions for STRING slides when explicitly requested.
|
|
373
|
+
const wantCaptions = opts.captions === true;
|
|
374
|
+
const raw = { version: 1, slides: slides.map((s, i) => slideEntry(s, i, { caption: wantCaptions })) };
|
|
375
|
+
|
|
376
|
+
// size: accept {width,height} or [w,h].
|
|
377
|
+
if (opts.size !== undefined) {
|
|
378
|
+
if (Array.isArray(opts.size)) raw.size = { width: opts.size[0], height: opts.size[1] };
|
|
379
|
+
else raw.size = opts.size;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// camera: start from explicit overrides, then fold in the `gap` shortcut.
|
|
383
|
+
if (opts.camera !== undefined || opts.gap !== undefined) {
|
|
384
|
+
raw.camera = { ...(opts.camera ?? {}) };
|
|
385
|
+
if (opts.gap !== undefined) raw.camera.gap = opts.gap;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// transition: a bare kind string is expanded to a minimal transition object; an object
|
|
389
|
+
// passes through (parseScene fills its own defaults + validates the kind).
|
|
390
|
+
if (opts.transition !== undefined) {
|
|
391
|
+
raw.transition =
|
|
392
|
+
typeof opts.transition === "string" ? { kind: opts.transition } : opts.transition;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (opts.view !== undefined) raw.view = opts.view;
|
|
396
|
+
if (opts.background !== undefined) raw.background = opts.background;
|
|
397
|
+
// captions: only forward an explicit boolean (string-slide empty captions handled above).
|
|
398
|
+
if (typeof opts.captions === "boolean") raw.captions = opts.captions;
|
|
399
|
+
if (opts.floor !== undefined) raw.floor = opts.floor;
|
|
400
|
+
if (opts.edge !== undefined) raw.edge = opts.edge;
|
|
401
|
+
if (opts.juicy !== undefined) raw.juicy = opts.juicy;
|
|
402
|
+
if (opts.caption_defaults !== undefined) raw.caption_defaults = opts.caption_defaults;
|
|
403
|
+
if (opts.caption_fade !== undefined) raw.caption_fade = opts.caption_fade;
|
|
404
|
+
if (opts.video !== undefined) raw.video = opts.video;
|
|
405
|
+
|
|
406
|
+
const scene = parseScene(raw);
|
|
407
|
+
// Resolve slide srcs against an optional base (the host page / scene URL). Absolute
|
|
408
|
+
// http(s) URLs and `data:` URIs are preserved (resolveSrc uses `new URL(src, base)`), so
|
|
409
|
+
// remote slide images keep working; relative paths resolve against the base (issue 341).
|
|
410
|
+
const base =
|
|
411
|
+
opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
|
|
412
|
+
if (base) {
|
|
413
|
+
for (const slide of scene.slides) slide.src = resolveSrc(slide.src, base);
|
|
414
|
+
}
|
|
415
|
+
return scene;
|
|
416
|
+
}
|
|
417
|
+
|
|
293
418
|
/** Resolve a slide `src` against `base` (a URL string). `data:` URIs pass through. */
|
|
294
419
|
function resolveSrc(src, base) {
|
|
295
420
|
if (src.startsWith("data:")) return src;
|
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
|
|
@@ -239,6 +244,12 @@ export class Stage {
|
|
|
239
244
|
|
|
240
245
|
async _buildPlates() {
|
|
241
246
|
const loader = new THREE.TextureLoader();
|
|
247
|
+
// Issue 341: load slide images from REMOTE http(s) URLs, not just local paths/data: URIs.
|
|
248
|
+
// setCrossOrigin("anonymous") makes the underlying <img> a CORS request, so a server that
|
|
249
|
+
// sends `Access-Control-Allow-Origin` lets the image load AND keeps the WebGL canvas
|
|
250
|
+
// un-tainted (toImage/toVideo stay exportable). Local same-origin and data: URIs are
|
|
251
|
+
// unaffected. resolveSrc already preserves absolute URLs, so this is the last piece.
|
|
252
|
+
loader.setCrossOrigin("anonymous");
|
|
242
253
|
const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
|
|
243
254
|
const reflectivity = this.scene.floor.reflectivity;
|
|
244
255
|
|
|
@@ -271,11 +282,14 @@ export class Stage {
|
|
|
271
282
|
opacity: 1,
|
|
272
283
|
});
|
|
273
284
|
const mesh = new THREE.Mesh(geometry, material);
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
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;
|
|
279
293
|
this.threeScene.add(mesh);
|
|
280
294
|
|
|
281
295
|
// BLURRY mirror reflection (issue 303 §1): a mirror copy below the floor line
|
|
@@ -313,7 +327,7 @@ export class Stage {
|
|
|
313
327
|
let border = null;
|
|
314
328
|
if (this._edgeWidth > 0) {
|
|
315
329
|
border = this._makeBorder(w, h, this._edgeWidth, this._edgeColor);
|
|
316
|
-
border.group.renderOrder = 1; //
|
|
330
|
+
border.group.renderOrder = i * RENDER_BLOCK + 1; // on top of this slide's plate (issue 344)
|
|
317
331
|
this.threeScene.add(border.group);
|
|
318
332
|
}
|
|
319
333
|
|
|
@@ -444,7 +458,10 @@ export class Stage {
|
|
|
444
458
|
borderColor,
|
|
445
459
|
};
|
|
446
460
|
const { mesh, material, worldWidth } = makeCaptionSprite(caption.text, style);
|
|
447
|
-
|
|
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;
|
|
448
465
|
this.threeScene.add(mesh);
|
|
449
466
|
this.captions.push({ sprite: mesh, material, plateIndex: i, caption, worldWidth });
|
|
450
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();
|