vexy-stax-js 3.1.1 → 3.1.2
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 +70 -0
- package/LICENSE +1 -1
- package/README.md +72 -13
- package/package.json +1 -1
- package/schema/vexy-stax-scene.schema.json +3 -3
- package/src/element.js +71 -8
- package/src/global.js +9 -3
- package/src/index.js +157 -1
- package/src/scene.js +130 -5
- package/src/stage.js +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,76 @@
|
|
|
4
4
|
|
|
5
5
|
All notable changes to this project are documented here.
|
|
6
6
|
|
|
7
|
+
## [3.0.13] — issues 341, 342
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`makeScene(slides, opts)`** (341): the "extremely easy to use" entry point — build a valid scene
|
|
12
|
+
from a bare list of slide image URLs (or `{src, caption, opacity, gap}` objects) plus a flat
|
|
13
|
+
options bag (`size`, `camera`, `gap`, `transition`, `view`, `background`, `captions`, `floor`,
|
|
14
|
+
`edge`, …). Defaults are filled so `makeScene(["a.png", "b.png"])` renders. Goes through the same
|
|
15
|
+
strict `parseScene` (illegal scenes still throw) and resolves slide srcs against `opts.baseUrl`.
|
|
16
|
+
Exported from `vexy-stax-js`, the element bundle, and the global build.
|
|
17
|
+
- **`createStax(elOrSelector, opts)`** (341): an ESM factory mirroring lines-nano's `createNano`.
|
|
18
|
+
Resolves the element, builds the scene (from `slides` via `makeScene`, or `scene` via `loadScene`
|
|
19
|
+
— a URL **or** an inline object), mounts a `VexyStax`, waits for `ready`, optionally starts a mode
|
|
20
|
+
(`playable`/`scrollspy`), and returns the ready instance. Re-exported from the element bundle so a
|
|
21
|
+
single CDN `<script>` import gives `createStax`/`makeScene`/`loadScene`/`VexyStax`.
|
|
22
|
+
- **`slides` + `captions` attributes on `<vexy-stax>`** (341): `<vexy-stax slides="a.png b.png c.png"
|
|
23
|
+
view="compact" mode="playable">` builds a scene from a space/newline-separated URL list (no scene
|
|
24
|
+
JSON needed). `captions` toggles caption plates. The existing `scene`/`config` paths are unchanged.
|
|
25
|
+
- **Remote slide images** (341): the three.js `TextureLoader` now sets `crossOrigin="anonymous"`, so
|
|
26
|
+
slide `src` may be a remote `http(s)` URL — the image loads cross-origin **and** the canvas stays
|
|
27
|
+
exportable (`toImage`/`toVideo`) when the server sends CORS headers. `resolveSrc` already preserved
|
|
28
|
+
absolute URLs; this is the last piece. Verified headless (a slide referenced by an absolute http
|
|
29
|
+
URL loads and exports a non-trivial PNG).
|
|
30
|
+
- **Click-to-toggle** (342): clicking anywhere inside an interactive container fluently transitions
|
|
31
|
+
between views — not-compact → collapse to compact, compact → expand. It reuses the morph driver (a
|
|
32
|
+
smooth `expand`/`collapse` leg — never a snap) and is **ON by default** for the `<vexy-stax>`
|
|
33
|
+
element and every `createStax` instance, layered **on top of** scrollspy (scroll drives the morph;
|
|
34
|
+
a click still toggles). New methods: `VexyStax.toggleView()` / `.enableClickToggle()` /
|
|
35
|
+
`.disableClickToggle()` and `el.toggleView()`. Opt out via `click-toggle="false"` (attribute) or
|
|
36
|
+
`createStax(el, { clickToggle: false })`.
|
|
37
|
+
- **Scene-in-init** (342): pass a full inline scene **object** at initialization — `createStax(el,
|
|
38
|
+
{ scene: {…} })` and the `<vexy-stax>` `el.scene = {…}` property (alongside the existing `config`
|
|
39
|
+
property). No URL / fetch required.
|
|
40
|
+
- **Step-by-step how-to demos in `docs/`** (341): `scripts/build-docs.mjs` now also emits
|
|
41
|
+
`demo-component.html` (declarative `<vexy-stax>`), `demo-module.html` (ESM `createStax`/inline scene
|
|
42
|
+
/click-to-toggle), and `demo-library.html` (global `window.VexyStax.create`) — each a side-by-side
|
|
43
|
+
"minimal code + live element" page modeled on i.vexy.art/dev/lines-nano. The landing page gains a
|
|
44
|
+
"Use it — three ways" section linking to them, and every page documents **both** the co-located
|
|
45
|
+
local bundle and the **jsDelivr CDN** URL (`https://cdn.jsdelivr.net/npm/vexy-stax-js@<version>/…`,
|
|
46
|
+
version read from `package.json`). The existing `playable.html` / `scrollable.html` are unchanged
|
|
47
|
+
except the scrollspy demo now lets a click toggle on top of the scroll.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- **Default floor → invisible white pane with faint reflections** (`#ffffff` / opacity `0.0` /
|
|
52
|
+
reflectivity `0.1`): `scene.js` `parseFloor`, the JSON schema, and the docs demos now default to a
|
|
53
|
+
floor with **no visible grey rectangle** (opacity 0) and only a whisper of mirror (reflectivity
|
|
54
|
+
0.1). Kept in exact lockstep with `vexy_stax.scene.Floor` (PY↔JS parity). The previous default was
|
|
55
|
+
a smoked-glass dark tint (`#1a1a1a` / `0.04` / `0.5`).
|
|
56
|
+
|
|
57
|
+
### Notes
|
|
58
|
+
|
|
59
|
+
- The package version is `3.1.2` (the next patch after the published `3.1.1`); this CHANGELOG entry
|
|
60
|
+
follows the repo's `3.0.x` issue-tracking heading convention for issues 341/342. CDN URLs in the
|
|
61
|
+
docs/README pin to `3.1.2`.
|
|
62
|
+
|
|
63
|
+
## [3.0.12] — issue 341
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- **`docs/` is now a proper landing page** (341): https://vexy.dev/vexy-stax-js/ used to embed the
|
|
68
|
+
playable animation directly (with the dated grey-reflection floor) and linked to nothing.
|
|
69
|
+
`scripts/build-docs.mjs` now emits `index.html` as a LANDING PAGE — a hero + three cards linking
|
|
70
|
+
to the **Animated demo** (`playable.html`), the **Scrollspy demo** (`scrollable.html`), and the
|
|
71
|
+
**Documentation** at https://vexy.dev/vexy-stax-py/ — plus install/usage snippets, and no embedded
|
|
72
|
+
animation. The two demos are emitted as their own pages using clean-floor scene variants
|
|
73
|
+
(`airbl-demo.scene.json` / `airbl-scrollable.scene.json`, reflectivity 0) so neither shows the grey
|
|
74
|
+
reflection "shadows". All paths are relative so the site works both locally and under
|
|
75
|
+
`/vexy-stax-js/`. Verified headless: both demos mount without errors and the landing cards resolve.
|
|
76
|
+
|
|
7
77
|
## [3.0.11] — issues 335, 336, 337
|
|
8
78
|
|
|
9
79
|
### 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,89 @@ 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>
|
|
46
47
|
```
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
**Click-to-toggle is on by default** (issue 342): clicking anywhere inside a `<vexy-stax>` fluently
|
|
50
|
+
toggles compact↔expanded. Opt out with `click-toggle="false"`.
|
|
51
|
+
|
|
52
|
+
**Scene-in-init** (issue 342): assign an inline scene **object** without a URL —
|
|
53
|
+
`el.scene = { version: 1, slides: [{ src: "a.png" }, …] }` (or the `config` property).
|
|
54
|
+
|
|
55
|
+
### ES Module — `createStax`
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { createStax, makeScene, VexyStax, loadScene } from "vexy-stax-js";
|
|
59
|
+
|
|
60
|
+
// One call: build a scene from a URL list, mount, wait for ready.
|
|
61
|
+
const stax = await createStax("#stage", {
|
|
62
|
+
slides: ["layer-0.png", "https://example.com/layer-1.png"],
|
|
63
|
+
gap: 480, transition: "expand_collapse", mode: "playable",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await stax.toggleView(); // fluent compact↔expanded (the default click behavior)
|
|
67
|
+
const mp4 = await stax.toVideo(); // seekable mp4
|
|
68
|
+
|
|
69
|
+
// createStax also accepts an inline scene object (scene-in-init):
|
|
70
|
+
await createStax("#hero", { scene: { version: 1, slides: [{ src: "a.png" }] }, view: "expanded" });
|
|
71
|
+
|
|
72
|
+
// makeScene builds a valid scene from a bare URL list, filling sensible defaults:
|
|
73
|
+
const scene = makeScene(["a.png", "b.png", "c.png"], { gap: 480, transition: "expand_collapse" });
|
|
74
|
+
const low = new VexyStax(container, scene); // the low-level path is still available
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`createStax(elOrSelector, opts)` — `opts` accepts `{ slides | scene, view, mode, trigger, width,
|
|
78
|
+
height, clickToggle, baseUrl, …sceneOverrides }`. Any remaining key (`size`, `camera`, `gap`,
|
|
79
|
+
`transition`, `background`, `captions`, `floor`, `edge`, …) is forwarded to `makeScene`.
|
|
80
|
+
|
|
81
|
+
### Global script — `window.VexyStax`
|
|
49
82
|
|
|
50
83
|
```html
|
|
51
84
|
<script src="./dist/vexy-stax.global.js"></script>
|
|
52
|
-
<script>
|
|
85
|
+
<script>
|
|
86
|
+
VexyStax.create("#stage", { slides: ["a.png", "b.png", "c.png"] });
|
|
87
|
+
</script>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### CDN (no build step)
|
|
91
|
+
|
|
92
|
+
Every snippet above also works verbatim from the jsDelivr CDN — swap the local bundle path for:
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<!-- Web Component / ESM -->
|
|
96
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.element.js"></script>
|
|
97
|
+
<!-- Global script -->
|
|
98
|
+
<script src="https://cdn.jsdelivr.net/npm/vexy-stax-js@3.1.2/dist/vexy-stax.global.js"></script>
|
|
53
99
|
```
|
|
54
100
|
|
|
101
|
+
### Remote slide images
|
|
102
|
+
|
|
103
|
+
Slide `src` may be a local path, a `data:` URI, **or a remote `http(s)` URL**. The texture loader
|
|
104
|
+
requests cross-origin images with `crossOrigin="anonymous"`, so a server that sends CORS headers
|
|
105
|
+
lets the image load **and** keeps the canvas exportable (`toImage` / `toVideo`).
|
|
106
|
+
|
|
107
|
+
### Live demos & how-to pages
|
|
108
|
+
|
|
109
|
+
`npm run build:docs` emits a [docs site](https://vexy.dev/vexy-stax-js/): a landing page, the
|
|
110
|
+
**Animated** (`playable.html`) and **Scrollspy** (`scrollable.html`) demos, plus three side-by-side
|
|
111
|
+
"how to use" pages — `demo-component.html`, `demo-module.html`, `demo-library.html` — each showing
|
|
112
|
+
the minimal code beside the live result (modeled on i.vexy.art/dev/lines-nano).
|
|
113
|
+
|
|
55
114
|
## Layout
|
|
56
115
|
|
|
57
116
|
```
|
|
@@ -74,4 +133,4 @@ for the same scene; both are tested against the same fixture vectors.
|
|
|
74
133
|
|
|
75
134
|
## License
|
|
76
135
|
|
|
77
|
-
Apache-2.0 — Copyright 2026
|
|
136
|
+
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.2",
|
|
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/element.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
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),
|
|
4
|
+
// <vexy-stax> custom element (SPEC.md §6.2). Attributes: scene (URL), slides
|
|
5
|
+
// (space/newline-separated image URLs — issue 341), captions (bool), view,
|
|
5
6
|
// mode (static|playable|scrollspy), width, height. Property `config` accepts an
|
|
6
|
-
// inline scene object (overrides `scene`). Events: ready, transitionstart,
|
|
7
|
+
// inline scene object (overrides `scene`/`slides`). Events: ready, transitionstart,
|
|
7
8
|
// transitionend. Mounts a VexyStax in light DOM. Auto-registers on import.
|
|
8
9
|
|
|
9
|
-
import { VexyStax, loadScene } from "./index.js";
|
|
10
|
+
import { VexyStax, loadScene, makeScene } from "./index.js";
|
|
11
|
+
|
|
12
|
+
// Re-export the public ESM API from the element bundle (issue 341): the built
|
|
13
|
+
// dist/vexy-stax.element.js has src/element.js as its entry, so a user who loads that single
|
|
14
|
+
// file can also `import { createStax, makeScene, loadScene, VexyStax }` from it (the how-to
|
|
15
|
+
// ESM demo + the documented CDN URL both rely on this).
|
|
16
|
+
export { VexyStax, loadScene, parseScene, makeScene, createStax } from "./index.js";
|
|
10
17
|
|
|
11
18
|
export class VexyStaxElement extends HTMLElement {
|
|
12
19
|
static get observedAttributes() {
|
|
13
|
-
|
|
20
|
+
// `slides` + `captions` are the issue-341 easy path (a scene from a bare URL list);
|
|
21
|
+
// `click-toggle` is the issue-342 opt-out for the default click-to-toggle behavior.
|
|
22
|
+
return ["scene", "slides", "captions", "view", "mode", "trigger", "width", "height", "click-toggle"];
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
constructor() {
|
|
@@ -20,7 +29,7 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
20
29
|
this._mounting = false;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
/** Inline scene object (or JSON string); overrides the `scene`
|
|
32
|
+
/** Inline scene object (or JSON string); overrides the `scene`/`slides` attributes. */
|
|
24
33
|
set config(value) {
|
|
25
34
|
this._config = typeof value === "string" ? JSON.parse(value) : value;
|
|
26
35
|
if (this.isConnected) this._mount();
|
|
@@ -29,6 +38,26 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
29
38
|
return this._config;
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Scene-in-init (issue 342): assigning an OBJECT (or JSON string) sets the inline scene
|
|
43
|
+
* (same as `config`), so `el.scene = {version:1, slides:[…]}` works at init. Assigning a
|
|
44
|
+
* STRING URL is treated as the `scene` attribute (a URL to fetch). This makes the property
|
|
45
|
+
* mirror the lines-nano-style "pass the data right in" ergonomics for the Web Component.
|
|
46
|
+
*/
|
|
47
|
+
set scene(value) {
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
this.config = value; // inline scene object
|
|
50
|
+
} else if (typeof value === "string") {
|
|
51
|
+
// Looks like JSON? treat as an inline scene; otherwise it's a URL attribute.
|
|
52
|
+
const trimmed = value.trim();
|
|
53
|
+
if (trimmed.startsWith("{")) this.config = JSON.parse(trimmed);
|
|
54
|
+
else this.setAttribute("scene", value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
get scene() {
|
|
58
|
+
return this._config ?? this.getAttribute("scene");
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
/** The underlying VexyStax instance (null until mounted). */
|
|
33
62
|
get instance() {
|
|
34
63
|
return this._stax;
|
|
@@ -60,6 +89,12 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
60
89
|
this._stax?.setView(this.getAttribute("view") || "expanded");
|
|
61
90
|
return;
|
|
62
91
|
}
|
|
92
|
+
if (name === "click-toggle") {
|
|
93
|
+
// Toggle the issue-342 behavior in place (no costly remount).
|
|
94
|
+
if (this.getAttribute("click-toggle") === "false") this._stax?.disableClickToggle();
|
|
95
|
+
else this._stax?.enableClickToggle();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
63
98
|
// scene/mode changes re-mount.
|
|
64
99
|
this._mount();
|
|
65
100
|
}
|
|
@@ -78,10 +113,25 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
78
113
|
this._stax?.destroy();
|
|
79
114
|
this._stax = null;
|
|
80
115
|
|
|
81
|
-
const sceneSrc = this._config ?? this.getAttribute("scene");
|
|
82
|
-
if (!sceneSrc) return; // nothing to render yet
|
|
83
116
|
const baseUrl = typeof document !== "undefined" ? document.baseURI : undefined;
|
|
84
|
-
|
|
117
|
+
// Source precedence (issue 341): inline `config` object → `scene` URL → `slides` list.
|
|
118
|
+
// `slides` is the easy path: a space/newline-separated list of image URLs (local, data:,
|
|
119
|
+
// or remote http(s)) built into a scene via makeScene. `captions` (bool attr) toggles
|
|
120
|
+
// caption plates (default on; here off unless slides carry their own — empty by default).
|
|
121
|
+
const sceneSrc = this._config ?? this.getAttribute("scene");
|
|
122
|
+
const slidesAttr = this.getAttribute("slides");
|
|
123
|
+
let scene;
|
|
124
|
+
if (sceneSrc) {
|
|
125
|
+
scene = await loadScene(sceneSrc, { baseUrl });
|
|
126
|
+
} else if (slidesAttr && slidesAttr.trim()) {
|
|
127
|
+
const urls = slidesAttr.split(/\s+/).filter(Boolean);
|
|
128
|
+
const captionsAttr = this.getAttribute("captions");
|
|
129
|
+
const opts = { baseUrl };
|
|
130
|
+
if (captionsAttr !== null) opts.captions = captionsAttr !== "false";
|
|
131
|
+
scene = makeScene(urls, opts);
|
|
132
|
+
} else {
|
|
133
|
+
return; // nothing to render yet
|
|
134
|
+
}
|
|
85
135
|
|
|
86
136
|
const view = this.getAttribute("view") || scene.view || "expanded";
|
|
87
137
|
scene.view = view;
|
|
@@ -99,6 +149,15 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
99
149
|
// mode="playable" leaves the deck at its initial view; the host calls
|
|
100
150
|
// el.transition(...) (e.g. on a button) to play it.
|
|
101
151
|
|
|
152
|
+
// Issue 342: click-to-toggle is ON by default for the interactive container — a click
|
|
153
|
+
// anywhere inside fluently toggles compact↔expanded, layered on top of scrollspy. Opt
|
|
154
|
+
// out with the `click-toggle="false"` attribute (or mode="static" pages that don't want it
|
|
155
|
+
// can still opt out explicitly). Default ON for every mode so a generic <vexy-stax> just
|
|
156
|
+
// works.
|
|
157
|
+
if (this.getAttribute("click-toggle") !== "false") {
|
|
158
|
+
this._stax?.enableClickToggle();
|
|
159
|
+
}
|
|
160
|
+
|
|
102
161
|
this.dispatchEvent(new CustomEvent("ready", { detail: { instance: this._stax } }));
|
|
103
162
|
} catch (err) {
|
|
104
163
|
this.dispatchEvent(new CustomEvent("error", { detail: { error: err } }));
|
|
@@ -127,6 +186,10 @@ export class VexyStaxElement extends HTMLElement {
|
|
|
127
186
|
seek(t) {
|
|
128
187
|
return this._stax?.seek(t);
|
|
129
188
|
}
|
|
189
|
+
/** Issue 342: fluently toggle compact↔expanded (the default click behavior, exposed). */
|
|
190
|
+
toggleView() {
|
|
191
|
+
return this._stax?.toggleView();
|
|
192
|
+
}
|
|
130
193
|
}
|
|
131
194
|
|
|
132
195
|
if (typeof customElements !== "undefined" && !customElements.get("vexy-stax")) {
|
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,7 +7,7 @@
|
|
|
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";
|
|
@@ -16,6 +16,7 @@ import { canvasToPngBlob, recordVideo } from "./export.js";
|
|
|
16
16
|
export {
|
|
17
17
|
loadScene,
|
|
18
18
|
parseScene,
|
|
19
|
+
makeScene,
|
|
19
20
|
resolvedOpacity,
|
|
20
21
|
};
|
|
21
22
|
|
|
@@ -50,6 +51,13 @@ export class VexyStax {
|
|
|
50
51
|
this.scene = scene;
|
|
51
52
|
this.stage = new Stage(container, scene);
|
|
52
53
|
this._ro = null; // ResizeObserver for post-layout resize
|
|
54
|
+
// Issue 342: track the CURRENT view so click-to-toggle knows which way to go. Starts at the
|
|
55
|
+
// scene's initial view; updated by setView/seek/transition/toggleView. `_morphT` is the last
|
|
56
|
+
// applied morph factor (0=compact, 1=expanded) used to disambiguate mid-morph clicks.
|
|
57
|
+
this._currentView = scene.view === "compact" ? "compact" : "expanded";
|
|
58
|
+
this._morphT = this._currentView === "compact" ? 0 : 1;
|
|
59
|
+
this._clickToggle = null; // { handler } when wired (enableClickToggle)
|
|
60
|
+
this._toggling = false; // guard against overlapping toggle transitions
|
|
53
61
|
this._ready = this.stage.init().then(() => {
|
|
54
62
|
this.stage.render();
|
|
55
63
|
// After mount, observe the container for its first actual layout dimensions.
|
|
@@ -87,6 +95,8 @@ export class VexyStax {
|
|
|
87
95
|
await this._ready;
|
|
88
96
|
this.stage.setView(view);
|
|
89
97
|
this.stage.render();
|
|
98
|
+
this._currentView = view === "compact" ? "compact" : "expanded";
|
|
99
|
+
this._morphT = this._currentView === "compact" ? 0 : 1;
|
|
90
100
|
return this;
|
|
91
101
|
}
|
|
92
102
|
|
|
@@ -99,6 +109,10 @@ export class VexyStax {
|
|
|
99
109
|
const tt = Math.max(0, Math.min(1, Number(t) || 0));
|
|
100
110
|
this.stage.applyFrameState(frameStateAt(this.scene, tt, this.stage.camera.aspect), tt);
|
|
101
111
|
this.stage.render();
|
|
112
|
+
// Issue 342: remember the morph position so a click-toggle on a scroll-driven deck knows
|
|
113
|
+
// whether it is currently nearer compact (→ expand) or expanded (→ collapse).
|
|
114
|
+
this._morphT = tt;
|
|
115
|
+
this._currentView = tt >= 0.5 ? "expanded" : "compact";
|
|
102
116
|
return this;
|
|
103
117
|
}
|
|
104
118
|
|
|
@@ -178,6 +192,10 @@ export class VexyStax {
|
|
|
178
192
|
this.container.dispatchEvent?.(new CustomEvent("transitionstart", { detail: { kind: resolvedKind } }));
|
|
179
193
|
try {
|
|
180
194
|
await controller.promise;
|
|
195
|
+
// Settle the tracked view to the transition's end endpoint (issue 342).
|
|
196
|
+
const { endMorph } = transitionEndpoints(resolvedKind);
|
|
197
|
+
this._morphT = endMorph;
|
|
198
|
+
this._currentView = endMorph >= 0.5 ? "expanded" : "compact";
|
|
181
199
|
this.container.dispatchEvent?.(new CustomEvent("transitionend", { detail: { kind: resolvedKind } }));
|
|
182
200
|
} finally {
|
|
183
201
|
this._cancelTransition = null;
|
|
@@ -185,6 +203,61 @@ export class VexyStax {
|
|
|
185
203
|
return this;
|
|
186
204
|
}
|
|
187
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Click-to-toggle (issue 342): fluently transition between the two views. If the deck is
|
|
208
|
+
* currently expanded (or mid-morph past halfway), collapse to compact; otherwise expand.
|
|
209
|
+
* Reuses the existing morph driver (a smooth `collapse`/`expand` leg) — never a snap. Safe to
|
|
210
|
+
* call repeatedly: an in-flight toggle is ignored until it settles. Returns the played kind.
|
|
211
|
+
*/
|
|
212
|
+
async toggleView() {
|
|
213
|
+
await this._ready;
|
|
214
|
+
if (this._toggling) return null;
|
|
215
|
+
const goCompact = this._currentView !== "compact"; // not compact ⇒ collapse to compact
|
|
216
|
+
const kind = goCompact ? "collapse" : "expand";
|
|
217
|
+
this._toggling = true;
|
|
218
|
+
try {
|
|
219
|
+
// 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 timing; ensure one exists.
|
|
221
|
+
if (!this.scene.transition) {
|
|
222
|
+
this.scene.transition = { kind, duration: 0.9, wait: 0, fps: 30, easing: "easeInOutCubic" };
|
|
223
|
+
}
|
|
224
|
+
await this.transition(kind);
|
|
225
|
+
} finally {
|
|
226
|
+
this._toggling = false;
|
|
227
|
+
}
|
|
228
|
+
return kind;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Enable click-to-toggle on the container (issue 342): a pointer click anywhere inside the
|
|
233
|
+
* element fluently toggles compact↔expanded. ON by default for interactive containers (the
|
|
234
|
+
* <vexy-stax> element + createStax) and layered ON TOP of scrollspy (scroll drives the morph;
|
|
235
|
+
* a click still toggles). Idempotent. Pass to disableClickToggle() to opt out.
|
|
236
|
+
*/
|
|
237
|
+
enableClickToggle() {
|
|
238
|
+
if (this._clickToggle || !this.container?.addEventListener) return this;
|
|
239
|
+
const handler = (ev) => {
|
|
240
|
+
// Ignore clicks on interactive controls a host may overlay (buttons/links/inputs).
|
|
241
|
+
const tag = ev.target?.tagName;
|
|
242
|
+
if (tag && /^(BUTTON|A|INPUT|SELECT|TEXTAREA|LABEL)$/.test(tag)) return;
|
|
243
|
+
this.toggleView();
|
|
244
|
+
};
|
|
245
|
+
this.container.addEventListener("click", handler);
|
|
246
|
+
// Pointer affordance so it reads as clickable.
|
|
247
|
+
if (this.container.style && !this.container.style.cursor) this.container.style.cursor = "pointer";
|
|
248
|
+
this._clickToggle = { handler };
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Remove the click-to-toggle handler (issue 342 opt-out). */
|
|
253
|
+
disableClickToggle() {
|
|
254
|
+
if (this._clickToggle) {
|
|
255
|
+
this.container.removeEventListener?.("click", this._clickToggle.handler);
|
|
256
|
+
this._clickToggle = null;
|
|
257
|
+
}
|
|
258
|
+
return this;
|
|
259
|
+
}
|
|
260
|
+
|
|
188
261
|
/** Re-derive the morph factor t from a frame's gap[1] (for caption fade). */
|
|
189
262
|
_morphFromGaps(gaps) {
|
|
190
263
|
if (gaps.length < 2) return 0;
|
|
@@ -301,8 +374,91 @@ export class VexyStax {
|
|
|
301
374
|
destroy() {
|
|
302
375
|
this._cancelTransition?.();
|
|
303
376
|
this._scrollspy?.disconnect?.();
|
|
377
|
+
this.disableClickToggle();
|
|
304
378
|
this._ro?.disconnect?.();
|
|
305
379
|
this._ro = null;
|
|
306
380
|
this.stage?.dispose();
|
|
307
381
|
}
|
|
308
382
|
}
|
|
383
|
+
|
|
384
|
+
// Scene-construction options consumed by createStax/makeScene rather than describing how to
|
|
385
|
+
// MOUNT (view/mode/trigger/width/height) or WHICH source (slides/scene). Everything else in
|
|
386
|
+
// `opts` is treated as a flat scene override and forwarded to makeScene (issue 341).
|
|
387
|
+
const MOUNT_KEYS = new Set([
|
|
388
|
+
"slides", "scene", "view", "mode", "trigger", "width", "height", "baseUrl", "clickToggle",
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The "extremely easy to use" ESM factory (issue 341), mirroring lines-nano's `createNano`.
|
|
393
|
+
* Resolve the mount element, build the scene (from a bare `slides` list via makeScene, or
|
|
394
|
+
* from a `scene` URL/object via loadScene), mount a VexyStax, wait until it's ready, optionally
|
|
395
|
+
* start a mode (playable/scrollspy), and return the ready instance.
|
|
396
|
+
*
|
|
397
|
+
* @param {HTMLElement|string} elOrSelector mount element or a CSS selector for it
|
|
398
|
+
* @param {object} [opts]
|
|
399
|
+
* @param {string[]|object[]} [opts.slides] slide image URLs (local/data:/remote) or slide
|
|
400
|
+
* objects — the easy path; built via makeScene with the remaining opts as overrides.
|
|
401
|
+
* @param {string|object} [opts.scene] a scene URL or inline scene object (via loadScene);
|
|
402
|
+
* used when `slides` is not given.
|
|
403
|
+
* @param {"expanded"|"compact"} [opts.view] initial view.
|
|
404
|
+
* @param {"static"|"playable"|"scrollspy"} [opts.mode] mount mode (default "static").
|
|
405
|
+
* "playable" plays scene.transition once when ready; "scrollspy" attaches a scroll story.
|
|
406
|
+
* @param {Element|string} [opts.trigger] scrollspy trigger (default: the element).
|
|
407
|
+
* @param {string} [opts.width] @param {string} [opts.height] CSS size overrides on the element.
|
|
408
|
+
* @param {string} [opts.baseUrl] base for resolving relative slide/scene URLs.
|
|
409
|
+
* ...any other key is a flat scene override forwarded to makeScene (size, camera, gap,
|
|
410
|
+
* transition, background, captions, floor, edge, caption_defaults, …).
|
|
411
|
+
* @returns {Promise<VexyStax>} the ready instance.
|
|
412
|
+
*/
|
|
413
|
+
export async function createStax(elOrSelector, opts = {}) {
|
|
414
|
+
const el =
|
|
415
|
+
typeof elOrSelector === "string" ? document.querySelector(elOrSelector) : elOrSelector;
|
|
416
|
+
if (!el) throw new Error(`createStax: element not found (${String(elOrSelector)})`);
|
|
417
|
+
if (opts === null || typeof opts !== "object") throw new Error("createStax: opts must be an object");
|
|
418
|
+
|
|
419
|
+
// Optional CSS size on the host element (parity with the <vexy-stax> width/height attrs).
|
|
420
|
+
if (opts.width) el.style.width = /^\d+$/.test(String(opts.width)) ? `${opts.width}px` : opts.width;
|
|
421
|
+
if (opts.height) el.style.height = /^\d+$/.test(String(opts.height)) ? `${opts.height}px` : opts.height;
|
|
422
|
+
if (typeof el.style === "object") {
|
|
423
|
+
el.style.position = el.style.position || "relative";
|
|
424
|
+
el.style.display = el.style.display || "block";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const baseUrl = opts.baseUrl ?? (typeof document !== "undefined" ? document.baseURI : undefined);
|
|
428
|
+
|
|
429
|
+
let scene;
|
|
430
|
+
if (opts.slides) {
|
|
431
|
+
// Flat scene overrides = every opt that isn't a mount/source key.
|
|
432
|
+
const sceneOpts = { baseUrl };
|
|
433
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
434
|
+
if (!MOUNT_KEYS.has(k)) sceneOpts[k] = v;
|
|
435
|
+
}
|
|
436
|
+
scene = makeScene(opts.slides, sceneOpts);
|
|
437
|
+
} else if (opts.scene !== undefined) {
|
|
438
|
+
scene = await loadScene(opts.scene, { baseUrl });
|
|
439
|
+
} else {
|
|
440
|
+
throw new Error("createStax: provide `slides` (URLs) or `scene` (URL/object)");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The view override wins over the scene's own initial view.
|
|
444
|
+
if (opts.view) scene.view = opts.view;
|
|
445
|
+
|
|
446
|
+
const stax = new VexyStax(el, scene);
|
|
447
|
+
await stax.ready;
|
|
448
|
+
|
|
449
|
+
const mode = opts.mode ?? "static";
|
|
450
|
+
if (mode === "scrollspy") {
|
|
451
|
+
const trigger =
|
|
452
|
+
typeof opts.trigger === "string" ? document.querySelector(opts.trigger) : opts.trigger ?? el;
|
|
453
|
+
stax.scrollspy({ trigger });
|
|
454
|
+
} else if (mode === "playable" && scene.transition) {
|
|
455
|
+
// Kick off the scene's transition once (best-effort; ignore if it's cancelled by teardown).
|
|
456
|
+
stax.transition().catch(() => {});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Issue 342: click-to-toggle is ON by default for the interactive container (every mode),
|
|
460
|
+
// and layers on top of scrollspy (scroll drives the morph; a click still toggles). Opt out
|
|
461
|
+
// with `clickToggle: false`.
|
|
462
|
+
if (opts.clickToggle !== false) stax.enableClickToggle();
|
|
463
|
+
return stax;
|
|
464
|
+
}
|
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
|
@@ -239,6 +239,12 @@ export class Stage {
|
|
|
239
239
|
|
|
240
240
|
async _buildPlates() {
|
|
241
241
|
const loader = new THREE.TextureLoader();
|
|
242
|
+
// Issue 341: load slide images from REMOTE http(s) URLs, not just local paths/data: URIs.
|
|
243
|
+
// setCrossOrigin("anonymous") makes the underlying <img> a CORS request, so a server that
|
|
244
|
+
// sends `Access-Control-Allow-Origin` lets the image load AND keeps the WebGL canvas
|
|
245
|
+
// un-tainted (toImage/toVideo stay exportable). Local same-origin and data: URIs are
|
|
246
|
+
// unaffected. resolveSrc already preserves absolute URLs, so this is the last piece.
|
|
247
|
+
loader.setCrossOrigin("anonymous");
|
|
242
248
|
const textures = await Promise.all(this.scene.slides.map((s) => loadTexture(loader, s.src)));
|
|
243
249
|
const reflectivity = this.scene.floor.reflectivity;
|
|
244
250
|
|