folioscope 0.0.2 → 0.0.3
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/README.md +4 -3
- package/dist/index.d.mts +43 -1
- package/dist/index.mjs +12 -980
- package/dist/vite/index.d.mts +6 -1
- package/dist/vite/index.mjs +1 -133
- package/package.json +5 -1
- package/wasm/folioscope_wasm.d.ts +11 -5
- package/wasm/folioscope_wasm.js +142 -5
- package/wasm/folioscope_wasm_bg.wasm +0 -0
- package/wasm/folioscope_wasm_bg.wasm.d.ts +6 -2
package/dist/index.mjs
CHANGED
|
@@ -1,613 +1,4 @@
|
|
|
1
|
-
//#region src/controls.ts
|
|
2
|
-
const CONTROLS_STYLE = `
|
|
3
|
-
.controls { display: flex; gap: 4px; align-items: center; padding: 4px; font: 13px/1.2 system-ui, sans-serif; background: var(--folioscope-controls-background, #1e1e1e); color: var(--folioscope-controls-color, #eee); }
|
|
4
|
-
.controls[hidden] { display: none; }
|
|
5
|
-
.controls button, .controls select { font: inherit; color: inherit; background: transparent; border: 1px solid #555; border-radius: 4px; padding: 2px 8px; min-height: 28px; cursor: pointer; }
|
|
6
|
-
.controls button:disabled { opacity: .4; cursor: default; }
|
|
7
|
-
.controls select[hidden] { display: none; }
|
|
8
|
-
.controls .spacer { flex: 1; }
|
|
9
|
-
`;
|
|
10
|
-
/** The control bar: restart, back, page and flow pickers, fullscreen. */
|
|
11
|
-
var Controls = class {
|
|
12
|
-
element;
|
|
13
|
-
backButton;
|
|
14
|
-
pages;
|
|
15
|
-
flows;
|
|
16
|
-
constructor(actions) {
|
|
17
|
-
const el = document.createElement("div");
|
|
18
|
-
el.className = "controls";
|
|
19
|
-
el.setAttribute("part", "controls");
|
|
20
|
-
el.setAttribute("role", "toolbar");
|
|
21
|
-
el.setAttribute("aria-label", "Prototype controls");
|
|
22
|
-
el.hidden = true;
|
|
23
|
-
const button = (label, text, action) => {
|
|
24
|
-
const b = document.createElement("button");
|
|
25
|
-
b.type = "button";
|
|
26
|
-
b.textContent = text;
|
|
27
|
-
b.title = label;
|
|
28
|
-
b.setAttribute("aria-label", label);
|
|
29
|
-
b.addEventListener("click", action);
|
|
30
|
-
return b;
|
|
31
|
-
};
|
|
32
|
-
const select = (label, onChange) => {
|
|
33
|
-
const s = document.createElement("select");
|
|
34
|
-
s.setAttribute("aria-label", label);
|
|
35
|
-
s.addEventListener("change", () => onChange(s.selectedIndex));
|
|
36
|
-
return s;
|
|
37
|
-
};
|
|
38
|
-
this.backButton = button("Back", "←", () => actions.back());
|
|
39
|
-
this.pages = select("Page", (i) => actions.page(i));
|
|
40
|
-
this.flows = select("Flow", (i) => actions.flow(i));
|
|
41
|
-
const spacer = document.createElement("span");
|
|
42
|
-
spacer.className = "spacer";
|
|
43
|
-
el.append(button("Restart", "⟲", () => actions.restart()), this.backButton, this.pages, this.flows, spacer, button("Fullscreen", "⛶", () => actions.fullscreen()));
|
|
44
|
-
this.element = el;
|
|
45
|
-
}
|
|
46
|
-
/** Fills the pickers for a newly loaded document and its current page. */
|
|
47
|
-
load(index, page, flow) {
|
|
48
|
-
const playable = index.pages.filter((p) => p.frames.length > 0);
|
|
49
|
-
fill(this.pages, playable.map((p) => p.name), playable.indexOf(page));
|
|
50
|
-
fill(this.flows, page.flows.map((f) => f.name || "Flow"), flow);
|
|
51
|
-
}
|
|
52
|
-
update(canGoBack) {
|
|
53
|
-
this.backButton.disabled = !canGoBack;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
/** Options of a picker, hidden when there is nothing to choose. */
|
|
57
|
-
function fill(select, names, selected) {
|
|
58
|
-
select.replaceChildren(...names.map((name) => {
|
|
59
|
-
const option = document.createElement("option");
|
|
60
|
-
option.textContent = name;
|
|
61
|
-
return option;
|
|
62
|
-
}));
|
|
63
|
-
select.selectedIndex = Math.max(0, selected);
|
|
64
|
-
select.hidden = names.length < 2;
|
|
65
|
-
}
|
|
66
|
-
//#endregion
|
|
67
|
-
//#region ../../node_modules/.pnpm/thumbhash@0.1.1/node_modules/thumbhash/thumbhash.js
|
|
68
|
-
/**
|
|
69
|
-
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
|
70
|
-
*
|
|
71
|
-
* @param hash The bytes of the ThumbHash.
|
|
72
|
-
* @returns The width, height, and pixels of the rendered placeholder image.
|
|
73
|
-
*/
|
|
74
|
-
function thumbHashToRGBA(hash) {
|
|
75
|
-
let { PI, min, max, cos, round } = Math;
|
|
76
|
-
let header24 = hash[0] | hash[1] << 8 | hash[2] << 16;
|
|
77
|
-
let header16 = hash[3] | hash[4] << 8;
|
|
78
|
-
let l_dc = (header24 & 63) / 63;
|
|
79
|
-
let p_dc = (header24 >> 6 & 63) / 31.5 - 1;
|
|
80
|
-
let q_dc = (header24 >> 12 & 63) / 31.5 - 1;
|
|
81
|
-
let l_scale = (header24 >> 18 & 31) / 31;
|
|
82
|
-
let hasAlpha = header24 >> 23;
|
|
83
|
-
let p_scale = (header16 >> 3 & 63) / 63;
|
|
84
|
-
let q_scale = (header16 >> 9 & 63) / 63;
|
|
85
|
-
let isLandscape = header16 >> 15;
|
|
86
|
-
let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
|
|
87
|
-
let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
|
|
88
|
-
let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1;
|
|
89
|
-
let a_scale = (hash[5] >> 4) / 15;
|
|
90
|
-
let ac_start = hasAlpha ? 6 : 5;
|
|
91
|
-
let ac_index = 0;
|
|
92
|
-
let decodeChannel = (nx, ny, scale) => {
|
|
93
|
-
let ac = [];
|
|
94
|
-
for (let cy = 0; cy < ny; cy++) for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) ac.push(((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2) & 15) / 7.5 - 1) * scale);
|
|
95
|
-
return ac;
|
|
96
|
-
};
|
|
97
|
-
let l_ac = decodeChannel(lx, ly, l_scale);
|
|
98
|
-
let p_ac = decodeChannel(3, 3, p_scale * 1.25);
|
|
99
|
-
let q_ac = decodeChannel(3, 3, q_scale * 1.25);
|
|
100
|
-
let a_ac = hasAlpha && decodeChannel(5, 5, a_scale);
|
|
101
|
-
let ratio = thumbHashToApproximateAspectRatio(hash);
|
|
102
|
-
let w = round(ratio > 1 ? 32 : 32 * ratio);
|
|
103
|
-
let h = round(ratio > 1 ? 32 / ratio : 32);
|
|
104
|
-
let rgba = new Uint8Array(w * h * 4), fx = [], fy = [];
|
|
105
|
-
for (let y = 0, i = 0; y < h; y++) for (let x = 0; x < w; x++, i += 4) {
|
|
106
|
-
let l = l_dc, p = p_dc, q = q_dc, a = a_dc;
|
|
107
|
-
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) fx[cx] = cos(PI / w * (x + .5) * cx);
|
|
108
|
-
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) fy[cy] = cos(PI / h * (y + .5) * cy);
|
|
109
|
-
for (let cy = 0, j = 0; cy < ly; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) l += l_ac[j] * fx[cx] * fy2;
|
|
110
|
-
for (let cy = 0, j = 0; cy < 3; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
|
111
|
-
let f = fx[cx] * fy2;
|
|
112
|
-
p += p_ac[j] * f;
|
|
113
|
-
q += q_ac[j] * f;
|
|
114
|
-
}
|
|
115
|
-
if (hasAlpha) for (let cy = 0, j = 0; cy < 5; cy++) for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) a += a_ac[j] * fx[cx] * fy2;
|
|
116
|
-
let b = l - 2 / 3 * p;
|
|
117
|
-
let r = (3 * l - b + q) / 2;
|
|
118
|
-
let g = r - q;
|
|
119
|
-
rgba[i] = max(0, 255 * min(1, r));
|
|
120
|
-
rgba[i + 1] = max(0, 255 * min(1, g));
|
|
121
|
-
rgba[i + 2] = max(0, 255 * min(1, b));
|
|
122
|
-
rgba[i + 3] = max(0, 255 * min(1, a));
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
w,
|
|
126
|
-
h,
|
|
127
|
-
rgba
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Extracts the approximate aspect ratio of the original image.
|
|
132
|
-
*
|
|
133
|
-
* @param hash The bytes of the ThumbHash.
|
|
134
|
-
* @returns The approximate aspect ratio (i.e. width / height).
|
|
135
|
-
*/
|
|
136
|
-
function thumbHashToApproximateAspectRatio(hash) {
|
|
137
|
-
let header = hash[3];
|
|
138
|
-
let hasAlpha = hash[2] & 128;
|
|
139
|
-
let isLandscape = hash[4] & 128;
|
|
140
|
-
return (isLandscape ? hasAlpha ? 5 : 7 : header & 7) / (isLandscape ? header & 7 : hasAlpha ? 5 : 7);
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
|
|
144
|
-
* A. This is optimized for speed and simplicity and does not optimize for size
|
|
145
|
-
* at all. This doesn't do any compression (all values are stored uncompressed).
|
|
146
|
-
*
|
|
147
|
-
* @param w The width of the input image. Must be ≤100px.
|
|
148
|
-
* @param h The height of the input image. Must be ≤100px.
|
|
149
|
-
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
|
150
|
-
* @returns A data URL containing a PNG for the input image.
|
|
151
|
-
*/
|
|
152
|
-
function rgbaToDataURL(w, h, rgba) {
|
|
153
|
-
let row = w * 4 + 1;
|
|
154
|
-
let idat = 6 + h * (5 + row);
|
|
155
|
-
let bytes = [
|
|
156
|
-
137,
|
|
157
|
-
80,
|
|
158
|
-
78,
|
|
159
|
-
71,
|
|
160
|
-
13,
|
|
161
|
-
10,
|
|
162
|
-
26,
|
|
163
|
-
10,
|
|
164
|
-
0,
|
|
165
|
-
0,
|
|
166
|
-
0,
|
|
167
|
-
13,
|
|
168
|
-
73,
|
|
169
|
-
72,
|
|
170
|
-
68,
|
|
171
|
-
82,
|
|
172
|
-
0,
|
|
173
|
-
0,
|
|
174
|
-
w >> 8,
|
|
175
|
-
w & 255,
|
|
176
|
-
0,
|
|
177
|
-
0,
|
|
178
|
-
h >> 8,
|
|
179
|
-
h & 255,
|
|
180
|
-
8,
|
|
181
|
-
6,
|
|
182
|
-
0,
|
|
183
|
-
0,
|
|
184
|
-
0,
|
|
185
|
-
0,
|
|
186
|
-
0,
|
|
187
|
-
0,
|
|
188
|
-
0,
|
|
189
|
-
idat >>> 24,
|
|
190
|
-
idat >> 16 & 255,
|
|
191
|
-
idat >> 8 & 255,
|
|
192
|
-
idat & 255,
|
|
193
|
-
73,
|
|
194
|
-
68,
|
|
195
|
-
65,
|
|
196
|
-
84,
|
|
197
|
-
120,
|
|
198
|
-
1
|
|
199
|
-
];
|
|
200
|
-
let table = [
|
|
201
|
-
0,
|
|
202
|
-
498536548,
|
|
203
|
-
997073096,
|
|
204
|
-
651767980,
|
|
205
|
-
1994146192,
|
|
206
|
-
1802195444,
|
|
207
|
-
1303535960,
|
|
208
|
-
1342533948,
|
|
209
|
-
-306674912,
|
|
210
|
-
-267414716,
|
|
211
|
-
-690576408,
|
|
212
|
-
-882789492,
|
|
213
|
-
-1687895376,
|
|
214
|
-
-2032938284,
|
|
215
|
-
-1609899400,
|
|
216
|
-
-1111625188
|
|
217
|
-
];
|
|
218
|
-
let a = 1, b = 0;
|
|
219
|
-
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
|
220
|
-
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, row >> 8 ^ 255, 0);
|
|
221
|
-
for (b = (b + a) % 65521; i < end; i++) {
|
|
222
|
-
let u = rgba[i] & 255;
|
|
223
|
-
bytes.push(u);
|
|
224
|
-
a = (a + u) % 65521;
|
|
225
|
-
b = (b + a) % 65521;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
bytes.push(b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130);
|
|
229
|
-
for (let [start, end] of [[12, 29], [37, 41 + idat]]) {
|
|
230
|
-
let c = -1;
|
|
231
|
-
for (let i = start; i < end; i++) {
|
|
232
|
-
c ^= bytes[i];
|
|
233
|
-
c = c >>> 4 ^ table[c & 15];
|
|
234
|
-
c = c >>> 4 ^ table[c & 15];
|
|
235
|
-
}
|
|
236
|
-
c = ~c;
|
|
237
|
-
bytes[end++] = c >>> 24;
|
|
238
|
-
bytes[end++] = c >> 16 & 255;
|
|
239
|
-
bytes[end++] = c >> 8 & 255;
|
|
240
|
-
bytes[end++] = c & 255;
|
|
241
|
-
}
|
|
242
|
-
return "data:image/png;base64," + btoa(String.fromCharCode(...bytes));
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Decodes a ThumbHash to a PNG data URL. This is a convenience function that
|
|
246
|
-
* just calls "thumbHashToRGBA" followed by "rgbaToDataURL".
|
|
247
|
-
*
|
|
248
|
-
* @param hash The bytes of the ThumbHash.
|
|
249
|
-
* @returns A data URL containing a PNG for the rendered ThumbHash.
|
|
250
|
-
*/
|
|
251
|
-
function thumbHashToDataURL(hash) {
|
|
252
|
-
let image = thumbHashToRGBA(hash);
|
|
253
|
-
return rgbaToDataURL(image.w, image.h, image.rgba);
|
|
254
|
-
}
|
|
255
|
-
//#endregion
|
|
256
|
-
//#region src/lite.ts
|
|
257
|
-
/**
|
|
258
|
-
* Builds a frame: its layers as images, its hotspots as transparent elements. Clickable hotspots
|
|
259
|
-
* are focusable buttons; those that only react to the pointer are plain areas.
|
|
260
|
-
*/
|
|
261
|
-
function renderFrame(frame, layerUrl, on) {
|
|
262
|
-
const el = document.createElement("div");
|
|
263
|
-
el.className = "frame";
|
|
264
|
-
el.setAttribute("role", "group");
|
|
265
|
-
el.setAttribute("aria-label", frame.name);
|
|
266
|
-
el.tabIndex = -1;
|
|
267
|
-
let content;
|
|
268
|
-
for (const layer of frame.layers) {
|
|
269
|
-
const img = document.createElement("img");
|
|
270
|
-
img.src = layerUrl(layer);
|
|
271
|
-
img.alt = "";
|
|
272
|
-
img.draggable = false;
|
|
273
|
-
place(img, layer.bounds);
|
|
274
|
-
if (layer.scrolls && frame.scroll) {
|
|
275
|
-
const scroller = document.createElement("div");
|
|
276
|
-
scroller.className = "scroll";
|
|
277
|
-
const { direction } = frame.scroll;
|
|
278
|
-
scroller.style.overflowX = direction === "vertical" ? "hidden" : "auto";
|
|
279
|
-
scroller.style.overflowY = direction === "horizontal" ? "hidden" : "auto";
|
|
280
|
-
content = document.createElement("div");
|
|
281
|
-
content.className = "content";
|
|
282
|
-
content.style.width = `${frame.scroll.content.width}px`;
|
|
283
|
-
content.style.height = `${frame.scroll.content.height}px`;
|
|
284
|
-
content.append(img);
|
|
285
|
-
scroller.append(content);
|
|
286
|
-
el.append(scroller);
|
|
287
|
-
} else el.append(img);
|
|
288
|
-
}
|
|
289
|
-
for (const hotspot of frame.hotspots) {
|
|
290
|
-
const by = (type) => hotspot.interactions.filter((i) => i.trigger.type === type).map((i) => i.action);
|
|
291
|
-
const clicks = by("click");
|
|
292
|
-
const pointer = POINTER_TRIGGERS.some((type) => by(type).length > 0);
|
|
293
|
-
if (clicks.length === 0 && !pointer) continue;
|
|
294
|
-
const area = document.createElement(clicks.length > 0 ? "button" : "div");
|
|
295
|
-
area.className = "hotspot";
|
|
296
|
-
place(area, hotspot.bounds);
|
|
297
|
-
if (area instanceof HTMLButtonElement) {
|
|
298
|
-
area.type = "button";
|
|
299
|
-
area.setAttribute("aria-label", hotspot.name);
|
|
300
|
-
area.addEventListener("click", (event) => {
|
|
301
|
-
event.stopPropagation();
|
|
302
|
-
for (const action of clicks) on.action(action, hotspot.bounds);
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
if (pointer) {
|
|
306
|
-
const run = (actions) => {
|
|
307
|
-
for (const action of actions) on.action(action, hotspot.bounds);
|
|
308
|
-
};
|
|
309
|
-
area.addEventListener("pointerenter", (event) => {
|
|
310
|
-
run(by("mouseEnter"));
|
|
311
|
-
if (event.pointerType !== "touch") for (const action of by("hover")) on.hold(action, "hover", hotspot.bounds);
|
|
312
|
-
});
|
|
313
|
-
area.addEventListener("pointerleave", () => run(by("mouseLeave")));
|
|
314
|
-
area.addEventListener("pointerdown", () => {
|
|
315
|
-
run(by("mouseDown"));
|
|
316
|
-
for (const action of by("press")) on.hold(action, "press", hotspot.bounds);
|
|
317
|
-
});
|
|
318
|
-
area.addEventListener("pointerup", () => run(by("mouseUp")));
|
|
319
|
-
}
|
|
320
|
-
(hotspot.fixed ? el : content ?? el).append(area);
|
|
321
|
-
}
|
|
322
|
-
for (const area of el.querySelectorAll(":scope > .hotspot")) el.append(area);
|
|
323
|
-
return el;
|
|
324
|
-
}
|
|
325
|
-
const POINTER_TRIGGERS = [
|
|
326
|
-
"hover",
|
|
327
|
-
"press",
|
|
328
|
-
"mouseEnter",
|
|
329
|
-
"mouseLeave",
|
|
330
|
-
"mouseDown",
|
|
331
|
-
"mouseUp"
|
|
332
|
-
];
|
|
333
|
-
/**
|
|
334
|
-
* Opens or closes an overlay layer (its `.backdrop` and `.sheet`) with the overlay's transition:
|
|
335
|
-
* slides and moves bring the sheet in from that side of the screen, anything else fades it.
|
|
336
|
-
*/
|
|
337
|
-
async function playOverlay(layer, transition, closing) {
|
|
338
|
-
if (globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches || transition.durationMs === 0 || transition.kind.type === "instant") return;
|
|
339
|
-
const timing = {
|
|
340
|
-
duration: transition.durationMs,
|
|
341
|
-
easing: easing(transition),
|
|
342
|
-
fill: "both"
|
|
343
|
-
};
|
|
344
|
-
const kind = transition.kind;
|
|
345
|
-
const [hidden, shown] = kind.type === "slideIn" || kind.type === "moveIn" || kind.type === "push" ? [{ transform: offset(kind.from, 1) }, { transform: "none" }] : [{ opacity: 0 }, { opacity: 1 }];
|
|
346
|
-
const run = (el, from, to) => el?.animate(closing ? [to, from] : [from, to], timing).finished.catch(() => void 0);
|
|
347
|
-
await Promise.all([run(layer.querySelector(".sheet"), hidden, shown), run(layer.querySelector(".backdrop"), { opacity: 0 }, { opacity: 1 })]);
|
|
348
|
-
}
|
|
349
|
-
/** Scrolls the frame `el` to a "scroll to" action's target, smoothly unless it is instant. */
|
|
350
|
-
function scrollFrame(el, action) {
|
|
351
|
-
const scroller = el.querySelector(".scroll");
|
|
352
|
-
if (!scroller || !action.target) return;
|
|
353
|
-
const instant = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches || action.transition.kind.type === "instant";
|
|
354
|
-
scroller.scrollTo({
|
|
355
|
-
left: action.target.x - (action.offset?.x ?? 0),
|
|
356
|
-
top: action.target.y - (action.offset?.y ?? 0),
|
|
357
|
-
behavior: instant ? "instant" : "smooth"
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
/** Resolves once every image of `el` is decoded, so a frame never shows half-loaded. */
|
|
361
|
-
async function decoded(el) {
|
|
362
|
-
await Promise.all([...el.querySelectorAll("img")].map((img) => img.decode().catch(() => void 0)));
|
|
363
|
-
}
|
|
364
|
-
/** Plays `transition` from `outgoing` to `incoming`, both already in the stage. */
|
|
365
|
-
async function play(outgoing, incoming, transition, reverse) {
|
|
366
|
-
const reduced = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
367
|
-
if (!outgoing || !transition || reduced || transition.durationMs === 0 || transition.kind.type === "instant") return;
|
|
368
|
-
const timing = {
|
|
369
|
-
duration: transition.durationMs,
|
|
370
|
-
easing: easing(transition),
|
|
371
|
-
fill: "both"
|
|
372
|
-
};
|
|
373
|
-
const kind = transition.kind;
|
|
374
|
-
const animations = [];
|
|
375
|
-
const slide = (side, sign) => offset(side, sign);
|
|
376
|
-
switch (kind.type) {
|
|
377
|
-
case "slideIn":
|
|
378
|
-
case "moveIn":
|
|
379
|
-
if (reverse) {
|
|
380
|
-
outgoing.style.zIndex = "1";
|
|
381
|
-
animations.push(outgoing.animate([{ transform: "none" }, { transform: slide(kind.from, 1) }], timing));
|
|
382
|
-
} else animations.push(incoming.animate([{ transform: slide(kind.from, 1) }, { transform: "none" }], timing));
|
|
383
|
-
break;
|
|
384
|
-
case "push": {
|
|
385
|
-
const sign = reverse ? -1 : 1;
|
|
386
|
-
animations.push(incoming.animate([{ transform: slide(kind.from, sign) }, { transform: "none" }], timing), outgoing.animate([{ transform: "none" }, { transform: slide(kind.from, -sign) }], timing));
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
case "slideOut":
|
|
390
|
-
case "moveOut":
|
|
391
|
-
outgoing.style.zIndex = "1";
|
|
392
|
-
animations.push(outgoing.animate([{ transform: "none" }, { transform: slide(kind.to, 1) }], timing));
|
|
393
|
-
break;
|
|
394
|
-
default: animations.push(incoming.animate([{ opacity: 0 }, { opacity: 1 }], timing));
|
|
395
|
-
}
|
|
396
|
-
await Promise.all(animations.map((a) => a.finished.catch(() => void 0)));
|
|
397
|
-
}
|
|
398
|
-
function place(el, box) {
|
|
399
|
-
Object.assign(el.style, {
|
|
400
|
-
left: `${box.x}px`,
|
|
401
|
-
top: `${box.y}px`,
|
|
402
|
-
width: `${box.width}px`,
|
|
403
|
-
height: `${box.height}px`
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
/** Where a screen sits off-stage on `side`. */
|
|
407
|
-
function offset(side, sign) {
|
|
408
|
-
return `translate${side === "left" || side === "right" ? "X" : "Y"}(${(side === "left" || side === "top" ? -1 : 1) * sign * 100}%)`;
|
|
409
|
-
}
|
|
410
|
-
function easing(transition) {
|
|
411
|
-
const e = transition.easing;
|
|
412
|
-
switch (e.type) {
|
|
413
|
-
case "linear": return "linear";
|
|
414
|
-
case "easeIn": return "ease-in";
|
|
415
|
-
case "easeInOut": return "ease-in-out";
|
|
416
|
-
case "cubicBezier": return `cubic-bezier(${e.points.join(", ")})`;
|
|
417
|
-
default: return "ease-out";
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
//#endregion
|
|
421
|
-
//#region src/runtime.ts
|
|
422
|
-
/**
|
|
423
|
-
* The prototype's state machine, independent of the DOM: the current screen of a page, the
|
|
424
|
-
* overlays over it, and the history `back` returns through.
|
|
425
|
-
*/
|
|
426
|
-
var Runtime = class {
|
|
427
|
-
page;
|
|
428
|
-
frames;
|
|
429
|
-
history = [];
|
|
430
|
-
/** Frames left by a hover or press still under way, innermost last; `back` skips them. */
|
|
431
|
-
held = [];
|
|
432
|
-
state;
|
|
433
|
-
start;
|
|
434
|
-
/** Starts on `flow` (name or index), else the page's first flow, else its first frame. */
|
|
435
|
-
constructor(page, flow) {
|
|
436
|
-
this.page = page;
|
|
437
|
-
this.frames = new Map(page.frames.map((f) => [f.id, f]));
|
|
438
|
-
const start = ((flow !== void 0 && /^\d+$/.test(flow) ? page.flows[Number(flow)] : void 0) ?? page.flows.find((f) => f.name === flow) ?? page.flows[0])?.start ?? page.frames[0]?.id;
|
|
439
|
-
if (start === void 0 || !this.frames.has(start)) throw new Error(`page "${page.name}" has no frame to start on`);
|
|
440
|
-
this.start = start;
|
|
441
|
-
this.state = {
|
|
442
|
-
frame: start,
|
|
443
|
-
overlays: []
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
/** The screen below the overlays. */
|
|
447
|
-
get frame() {
|
|
448
|
-
return this.frames.get(this.state.frame);
|
|
449
|
-
}
|
|
450
|
-
/** The frame on top: the last overlay, else the screen. */
|
|
451
|
-
get top() {
|
|
452
|
-
const overlay = this.state.overlays.at(-1);
|
|
453
|
-
return overlay ? this.frames.get(overlay.id) : this.frame;
|
|
454
|
-
}
|
|
455
|
-
/** How many hovers and presses currently hold a frame. */
|
|
456
|
-
get holding() {
|
|
457
|
-
return this.held.length;
|
|
458
|
-
}
|
|
459
|
-
get canGoBack() {
|
|
460
|
-
return this.history.length > 0;
|
|
461
|
-
}
|
|
462
|
-
/**
|
|
463
|
-
* Applies an action; returns the step to show, or `undefined` when the screen stays. `origin`
|
|
464
|
-
* is the hotspot that ran it, where a `manual` overlay opens.
|
|
465
|
-
*/
|
|
466
|
-
run(action, origin) {
|
|
467
|
-
switch (action.type) {
|
|
468
|
-
case "navigate": return this.goTo(action.to, action.transition);
|
|
469
|
-
case "overlay":
|
|
470
|
-
case "swapOverlay": {
|
|
471
|
-
if (!this.frames.has(action.to)) return void 0;
|
|
472
|
-
const overlays = [...this.state.overlays];
|
|
473
|
-
if (action.type === "swapOverlay") overlays.pop();
|
|
474
|
-
overlays.push({
|
|
475
|
-
id: action.to,
|
|
476
|
-
transition: action.transition,
|
|
477
|
-
origin,
|
|
478
|
-
offset: action.offset
|
|
479
|
-
});
|
|
480
|
-
this.commit({
|
|
481
|
-
frame: this.base,
|
|
482
|
-
overlays
|
|
483
|
-
});
|
|
484
|
-
return this.step({
|
|
485
|
-
transition: action.transition,
|
|
486
|
-
layer: "overlay"
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
case "close": return this.close();
|
|
490
|
-
case "back": return this.back();
|
|
491
|
-
default: return;
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
goTo(frameId, transition) {
|
|
495
|
-
if (!this.frames.has(frameId) || frameId === this.state.frame && !this.state.overlays.length) return void 0;
|
|
496
|
-
this.commit({
|
|
497
|
-
frame: frameId,
|
|
498
|
-
overlays: []
|
|
499
|
-
});
|
|
500
|
-
return this.step({ transition });
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* "While hovering" and "while pressing": shows the action's frame until `release()`, without
|
|
504
|
-
* adding it to the history. Not inside overlays yet.
|
|
505
|
-
*/
|
|
506
|
-
hold(action) {
|
|
507
|
-
if (action.type !== "navigate" || this.state.overlays.length > 0) return void 0;
|
|
508
|
-
if (!this.frames.has(action.to) || action.to === this.state.frame) return void 0;
|
|
509
|
-
this.held.push(this.state.frame);
|
|
510
|
-
this.state = {
|
|
511
|
-
frame: action.to,
|
|
512
|
-
overlays: []
|
|
513
|
-
};
|
|
514
|
-
return this.step({ transition: action.transition });
|
|
515
|
-
}
|
|
516
|
-
/** Ends the innermost hold: back to the frame it started from, at once. */
|
|
517
|
-
release() {
|
|
518
|
-
const from = this.held.pop();
|
|
519
|
-
if (from === void 0) return void 0;
|
|
520
|
-
this.state = {
|
|
521
|
-
frame: from,
|
|
522
|
-
overlays: []
|
|
523
|
-
};
|
|
524
|
-
return this.step();
|
|
525
|
-
}
|
|
526
|
-
/** Closes the top overlay, with its opening transition played backwards. */
|
|
527
|
-
close() {
|
|
528
|
-
const top = this.state.overlays.at(-1);
|
|
529
|
-
if (!top) return void 0;
|
|
530
|
-
const below = {
|
|
531
|
-
frame: this.state.frame,
|
|
532
|
-
overlays: this.state.overlays.slice(0, -1)
|
|
533
|
-
};
|
|
534
|
-
if (same(this.history.at(-1), below)) this.history.pop();
|
|
535
|
-
this.state = below;
|
|
536
|
-
return this.step({
|
|
537
|
-
transition: top.transition,
|
|
538
|
-
reverse: true,
|
|
539
|
-
layer: "overlay"
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
back() {
|
|
543
|
-
const base = this.held[0];
|
|
544
|
-
this.held = [];
|
|
545
|
-
if (base !== void 0) this.state = {
|
|
546
|
-
frame: base,
|
|
547
|
-
overlays: []
|
|
548
|
-
};
|
|
549
|
-
const previous = this.history.pop();
|
|
550
|
-
if (previous === void 0) return base === void 0 ? void 0 : this.step();
|
|
551
|
-
const leaving = this.state;
|
|
552
|
-
this.state = previous;
|
|
553
|
-
if (previous.frame === leaving.frame) {
|
|
554
|
-
const top = leaving.overlays.at(-1) ?? previous.overlays.at(-1);
|
|
555
|
-
return this.step({
|
|
556
|
-
transition: top?.transition,
|
|
557
|
-
reverse: true,
|
|
558
|
-
layer: "overlay"
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
const transition = this.lastTransitionTo(leaving.frame, previous.frame);
|
|
562
|
-
return this.step({
|
|
563
|
-
transition,
|
|
564
|
-
reverse: true
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
restart() {
|
|
568
|
-
this.history = [];
|
|
569
|
-
this.held = [];
|
|
570
|
-
this.state = {
|
|
571
|
-
frame: this.start,
|
|
572
|
-
overlays: []
|
|
573
|
-
};
|
|
574
|
-
return this.step();
|
|
575
|
-
}
|
|
576
|
-
/** The frame below any hold. */
|
|
577
|
-
get base() {
|
|
578
|
-
return this.held[0] ?? this.state.frame;
|
|
579
|
-
}
|
|
580
|
-
/** Moves to `next`, recording where it came from; a hold under way is committed. */
|
|
581
|
-
commit(next) {
|
|
582
|
-
this.history.push(this.held.length ? {
|
|
583
|
-
frame: this.held[0],
|
|
584
|
-
overlays: []
|
|
585
|
-
} : this.state);
|
|
586
|
-
this.held = [];
|
|
587
|
-
this.state = next;
|
|
588
|
-
}
|
|
589
|
-
step(how = {}) {
|
|
590
|
-
return {
|
|
591
|
-
frame: this.frame,
|
|
592
|
-
overlays: this.state.overlays.map(({ id, ...rest }) => ({
|
|
593
|
-
frame: this.frames.get(id),
|
|
594
|
-
...rest
|
|
595
|
-
})),
|
|
596
|
-
...how
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
lastTransitionTo(to, from) {
|
|
600
|
-
for (const hotspot of this.frames.get(from)?.hotspots ?? []) for (const { action } of hotspot.interactions) if (action.type === "navigate" && action.to === to) return action.transition;
|
|
601
|
-
}
|
|
602
|
-
};
|
|
603
|
-
function same(a, b) {
|
|
604
|
-
return a !== void 0 && a.frame === b.frame && a.overlays.length === b.overlays.length && a.overlays.every((o, i) => o.id === b.overlays[i].id);
|
|
605
|
-
}
|
|
606
|
-
//#endregion
|
|
607
|
-
//#region src/element.ts
|
|
608
|
-
const TAG_NAME = "folioscope-player";
|
|
609
|
-
const BaseElement = globalThis.HTMLElement ?? class {};
|
|
610
|
-
const STYLE = `
|
|
1
|
+
var e=class{element;backButton;pages;flows;constructor(e){let t=document.createElement(`div`);t.className=`controls`,t.setAttribute(`part`,`controls`),t.setAttribute(`role`,`toolbar`),t.setAttribute(`aria-label`,`Prototype controls`),t.hidden=!0;let n=(e,t,n)=>{let r=document.createElement(`button`);return r.type=`button`,r.textContent=t,r.title=e,r.setAttribute(`aria-label`,e),r.addEventListener(`click`,n),r},r=(e,t)=>{let n=document.createElement(`select`);return n.setAttribute(`aria-label`,e),n.addEventListener(`change`,()=>t(n.selectedIndex)),n};this.backButton=n(`Back`,`←`,()=>e.back()),this.pages=r(`Page`,t=>e.page(t)),this.flows=r(`Flow`,t=>e.flow(t));let i=document.createElement(`span`);i.className=`spacer`,t.append(n(`Restart`,`⟲`,()=>e.restart()),this.backButton,this.pages,this.flows,i,n(`Fullscreen`,`⛶`,()=>e.fullscreen())),this.element=t}load(e,n,r){let i=e.pages.filter(e=>e.frames.length>0);t(this.pages,i.map(e=>e.name),i.indexOf(n)),t(this.flows,n.flows.map(e=>e.name||`Flow`),r)}update(e){this.backButton.disabled=!e}};function t(e,t,n){e.replaceChildren(...t.map(e=>{let t=document.createElement(`option`);return t.textContent=e,t})),e.selectedIndex=Math.max(0,n),e.hidden=t.length<2}function n(e,t){return`width`in t?t.x>=e.x&&t.y>=e.y&&t.x+t.width<=e.x+e.width&&t.y+t.height<=e.y+e.height:t.x>=e.x&&t.x<e.x+e.width&&t.y>=e.y&&t.y<e.y+e.height}function r(e,t,n={}){let{mode:r=`contain`,align:i={}}=n,{x:a=.5,y:o=.5}=i,s=t.width/e.width,c=t.height/e.height,l,u;switch(r){case`contain`:l=u=Math.min(s,c);break;case`cover`:l=u=Math.max(s,c);break;case`fill`:l=s,u=c;break;case`none`:l=u=1;break;default:l=u=Math.min(1,s,c)}return{a:l,b:0,c:0,d:u,e:(t.width-e.width*l)*a+0,f:(t.height-e.height*u)*o+0}}function i(e,t,n){return r(e,t,{mode:`contain`,align:n})}function a(e){let{PI:t,min:n,max:r,cos:i,round:a}=Math,s=e[0]|e[1]<<8|e[2]<<16,c=e[3]|e[4]<<8,l=(s&63)/63,u=(s>>6&63)/31.5-1,d=(s>>12&63)/31.5-1,f=(s>>18&31)/31,p=s>>23,m=(c>>3&63)/63,h=(c>>9&63)/63,g=c>>15,_=r(3,g?p?5:7:c&7),v=r(3,g?c&7:p?5:7),y=p?(e[5]&15)/15:1,b=(e[5]>>4)/15,x=p?6:5,S=0,C=(t,n,r)=>{let i=[];for(let a=0;a<n;a++)for(let o=+!a;o*n<t*(n-a);o++)i.push(((e[x+(S>>1)]>>((S++&1)<<2)&15)/7.5-1)*r);return i},w=C(_,v,f),T=C(3,3,m*1.25),E=C(3,3,h*1.25),D=p&&C(5,5,b),O=o(e),k=a(O>1?32:32*O),A=a(O>1?32/O:32),j=new Uint8Array(k*A*4),M=[],N=[];for(let e=0,a=0;e<A;e++)for(let o=0;o<k;o++,a+=4){let s=l,c=u,f=d,m=y;for(let e=0,n=r(_,p?5:3);e<n;e++)M[e]=i(t/k*(o+.5)*e);for(let n=0,a=r(v,p?5:3);n<a;n++)N[n]=i(t/A*(e+.5)*n);for(let e=0,t=0;e<v;e++)for(let n=+!e,r=N[e]*2;n*v<_*(v-e);n++,t++)s+=w[t]*M[n]*r;for(let e=0,t=0;e<3;e++)for(let n=+!e,r=N[e]*2;n<3-e;n++,t++){let e=M[n]*r;c+=T[t]*e,f+=E[t]*e}if(p)for(let e=0,t=0;e<5;e++)for(let n=+!e,r=N[e]*2;n<5-e;n++,t++)m+=D[t]*M[n]*r;let h=s-2/3*c,g=(3*s-h+f)/2,b=g-f;j[a]=r(0,255*n(1,g)),j[a+1]=r(0,255*n(1,b)),j[a+2]=r(0,255*n(1,h)),j[a+3]=r(0,255*n(1,m))}return{w:k,h:A,rgba:j}}function o(e){let t=e[3],n=e[2]&128,r=e[4]&128;return(r?n?5:7:t&7)/(r?t&7:n?5:7)}function s(e,t,n){let r=e*4+1,i=6+t*(5+r),a=[137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,e>>8,e&255,0,0,t>>8,t&255,8,6,0,0,0,0,0,0,0,i>>>24,i>>16&255,i>>8&255,i&255,73,68,65,84,120,1],o=[0,498536548,997073096,651767980,1994146192,1802195444,1303535960,1342533948,-306674912,-267414716,-690576408,-882789492,-1687895376,-2032938284,-1609899400,-1111625188],s=1,c=0;for(let e=0,i=0,o=r-1;e<t;e++,o+=r-1)for(a.push(e+1<t?0:1,r&255,r>>8,~r&255,r>>8^255,0),c=(c+s)%65521;i<o;i++){let e=n[i]&255;a.push(e),s=(s+e)%65521,c=(c+s)%65521}a.push(c>>8,c&255,s>>8,s&255,0,0,0,0,0,0,0,0,73,69,78,68,174,66,96,130);for(let[e,t]of[[12,29],[37,41+i]]){let n=-1;for(let r=e;r<t;r++)n^=a[r],n=n>>>4^o[n&15],n=n>>>4^o[n&15];n=~n,a[t++]=n>>>24,a[t++]=n>>16&255,a[t++]=n>>8&255,a[t++]=n&255}return`data:image/png;base64,`+btoa(String.fromCharCode(...a))}function c(e){let t=a(e);return s(t.w,t.h,t.rgba)}var l=.001;function u(e={}){let{mass:t=1,stiffness:n=100,damping:r=10,initialVelocity:i=0}=e;if(!(t>0&&n>0&&r>0&&Number.isFinite(i)&&Number.isFinite(t*n*r)))throw RangeError(`spring: mass, stiffness and damping must be positive, and initialVelocity finite`);let a=Math.sqrt(n/t),o=r/(2*Math.sqrt(n*t)),s,c;if(o<1){let e=a*Math.sqrt(1-o*o),t=(o*a-i)/e;s=n=>Math.exp(-o*a*n)*(Math.cos(e*n)+t*Math.sin(e*n)),c=e=>Math.exp(-o*a*e)*Math.hypot(1,t)}else if(o===1){let e=a-i;s=t=>(1+e*t)*Math.exp(-a*t),c=t=>(1+Math.abs(e)*t)*Math.exp(-a*t)}else{let e=a*Math.sqrt(o*o-1),t=-o*a+e,n=-o*a-e,r=(-i-t)/(n-t),l=1-r;s=e=>l*Math.exp(t*e)+r*Math.exp(n*e),c=e=>(Math.abs(l)+Math.abs(r))*Math.exp(t*e)}let u=1;for(;c(u/1e3)>=l;)u*=2;let d=Math.max(1,Math.ceil(u/1e4)),f=u;for(;f>0&&Math.abs(s(f/1e3))<l;)f-=d;let p=Math.max(f+d,1),m=e=>e<=0?0:e>=p?1:1-s(e/1e3);return{durationMs:p,at:m,easing:e=>m(e*p)}}function d(e,t=32){if(!(Number.isInteger(t)&&t>=2))throw RangeError(`toCssLinear: samples must be an integer >= 2, got ${t}`);return`linear(${Array.from({length:t},(n,r)=>Number(e(r/(t-1)).toFixed(4))+0).join(`, `)})`}function f(e){return typeof globalThis.matchMedia==`function`&&globalThis.matchMedia(e).matches}function p(){return f(`(prefers-reduced-motion: reduce)`)}function m(e,t,n){let r=document.createElement(`div`);r.className=`frame`,r.setAttribute(`role`,`group`),r.setAttribute(`aria-label`,e.name),r.tabIndex=-1;let i;for(let n of e.layers){let a=document.createElement(`img`);if(a.src=t(n),a.alt=``,a.draggable=!1,C(a,n.bounds),n.scrolls&&e.scroll){let t=document.createElement(`div`);t.className=`scroll`;let{direction:n}=e.scroll;t.style.overflowX=n===`vertical`?`hidden`:`auto`,t.style.overflowY=n===`horizontal`?`hidden`:`auto`,i=document.createElement(`div`),i.className=`content`,i.style.width=`${e.scroll.content.width}px`,i.style.height=`${e.scroll.content.height}px`,i.append(a),t.append(i),r.append(t)}else r.append(a)}for(let t of e.hotspots){let e=h(t,n);e&&(t.fixed?r:i??r).append(e)}for(let a of e.components??[]){let e=g(a,t,n);(a.fixed?r:i??r).append(e)}return r}function h(e,t){let n=t=>e.interactions.filter(e=>e.trigger.type===t).map(e=>e.action),r=n(`click`),i=_.some(e=>n(e).length>0);if(r.length===0&&!i)return;let a=document.createElement(r.length>0?`button`:`div`);if(a.className=`hotspot`,C(a,e.bounds),a instanceof HTMLButtonElement&&(a.type=`button`,a.setAttribute(`aria-label`,e.name),a.addEventListener(`click`,n=>{n.stopPropagation();for(let n of r)t.action(n,e.bounds)})),i){let r=n=>{for(let r of n)t.action(r,e.bounds)};a.addEventListener(`pointerenter`,i=>{if(r(n(`mouseEnter`)),i.pointerType!==`touch`)for(let r of n(`hover`))t.hold(r,`hover`,e.bounds)}),a.addEventListener(`pointerleave`,()=>r(n(`mouseLeave`))),a.addEventListener(`pointerdown`,()=>{r(n(`mouseDown`));for(let r of n(`press`))t.hold(r,`press`,e.bounds)}),a.addEventListener(`pointerup`,()=>r(n(`mouseUp`)))}return a}function g(e,t,n){let r=document.createElement(`div`);r.className=`component`,r.dataset.component=e.id,C(r,e.bounds);let i=t=>({...t,x:t.x+e.bounds.x,y:t.y+e.bounds.y}),a=e.initial,o=[],s=(n,i)=>{let o=e.states.find(e=>e.variant===n);if(!o)return;a=n,r.dataset.state=o.name,r.style.width=`${o.bounds.width}px`,r.style.height=`${o.bounds.height}px`;let s=document.createElement(`img`);s.src=t(o.layer),s.alt=``,s.draggable=!1,C(s,o.layer.bounds);let l=r.querySelector(`img`),u=o.hotspots.flatMap(e=>h(e,c)??[]);r.replaceChildren(...l?[l]:[],s,...u);let d=p();!l||!i||d||i.durationMs===0||i.kind.type===`instant`?l?.remove():s.animate([{opacity:0},{opacity:1}],{...T(i)}).finished.catch(()=>void 0).then(()=>l.remove())},c={action:(e,t)=>{e.type===`changeTo`?s(e.to,e.transition):n.action(e,i(t))},hold:(e,t,c)=>{if(e.type!==`changeTo`)return n.hold(e,t,i(c));o.push(a),s(e.to,e.transition);let l=()=>{let e=o.pop();e!==void 0&&s(e)};t===`press`?globalThis.addEventListener(`pointerup`,l,{once:!0}):r.addEventListener(`pointerleave`,l,{once:!0})}};for(let n of e.states)new Image().src=t(n.layer);return s(e.initial),r}const _=[`hover`,`press`,`mouseEnter`,`mouseLeave`,`mouseDown`,`mouseUp`];async function v(e,t,n){if(p()||t.durationMs===0||t.kind.type===`instant`)return;let r={...T(t),fill:`both`},i=t.kind,[a,o]=i.type===`slideIn`||i.type===`moveIn`||i.type===`push`?[{transform:w(i.from,1)},{transform:`none`}]:[{opacity:0},{opacity:1}],s=(e,t,i)=>e?.animate(n?[i,t]:[t,i],r).finished.catch(()=>void 0);await Promise.all([s(e.querySelector(`.sheet`),a,o),s(e.querySelector(`.backdrop`),{opacity:0},{opacity:1})])}async function y(e,t,n,r,i){if(!t.parts?.length||!n.parts?.length||p())return;let a={...T(r),fill:`both`},o=document.createElement(`div`);o.className=`smart`;let s=(e,t)=>{let n=document.createElement(`img`);return n.src=i(t),n.alt=``,C(n,e),o.append(n),n},c=(e,t)=>`translate(${t.x-e.x}px, ${t.y-e.y}px) scale(${t.width/e.width||1}, ${t.height/e.height||1})`,l=/* @__PURE__ */ new Map;for(let[e,r]of t.parts.entries()){let t=n.parts.findIndex((e,t)=>e.name===r.name&&![...l.values()].includes(t));t>=0&&l.set(e,t)}let u=new Set(l.values()),d=[];for(let[e,r]of t.parts.entries()){let t=l.get(e),i=t===void 0?void 0:n.parts[t].layer.bounds,a=s(r.layer.bounds,r.layer);d.push([a,i?[{transform:`none`},{transform:c(r.layer.bounds,i)}]:[{opacity:1},{opacity:0}]])}for(let[e,r]of n.parts.entries()){let n=[...l].find(([,t])=>t===e)?.[0],i=u.has(e)&&n!==void 0?t.parts[n].layer.bounds:void 0,a=s(r.layer.bounds,r.layer);d.push([a,i?[{transform:c(r.layer.bounds,i),opacity:0},{transform:`none`,opacity:1}]:[{opacity:0},{opacity:1}]])}e.append(o),await x(o),await Promise.all(d.map(([e,t])=>e.animate(t,a).finished.catch(()=>void 0))),o.remove()}function b(e,t){let n=e.querySelector(`.scroll`);if(!n||!t.target)return;let r=p()||t.transition.kind.type===`instant`;n.scrollTo({left:t.target.x-(t.offset?.x??0),top:t.target.y-(t.offset?.y??0),behavior:r?`instant`:`smooth`})}async function x(e){await Promise.all([...e.querySelectorAll(`img`)].map(e=>e.decode().catch(()=>void 0)))}async function S(e,t,n,r){let i=p();if(!e||!n||i||n.durationMs===0||n.kind.type===`instant`)return;let a={...T(n),fill:`both`},o=n.kind,s=[],c=(e,t)=>w(e,t);switch(o.type){case`slideIn`:case`moveIn`:r?(e.style.zIndex=`1`,s.push(e.animate([{transform:`none`},{transform:c(o.from,1)}],a))):s.push(t.animate([{transform:c(o.from,1)},{transform:`none`}],a));break;case`push`:{let n=r?-1:1;s.push(t.animate([{transform:c(o.from,n)},{transform:`none`}],a),e.animate([{transform:`none`},{transform:c(o.from,-n)}],a));break}case`slideOut`:case`moveOut`:e.style.zIndex=`1`,s.push(e.animate([{transform:`none`},{transform:c(o.to,1)}],a));break;default:s.push(t.animate([{opacity:0},{opacity:1}],a))}await Promise.all(s.map(e=>e.finished.catch(()=>void 0)))}function C(e,t){Object.assign(e.style,{left:`${t.x}px`,top:`${t.y}px`,width:`${t.width}px`,height:`${t.height}px`})}function w(e,t){return`translate${e===`left`||e===`right`?`X`:`Y`}(${(e===`left`||e===`top`?-1:1)*t*100}%)`}function T(e){let t=e.easing;if(t.type===`spring`){let e=u(t);return{duration:e.durationMs,easing:d(t=>e.at(t*e.durationMs))}}return{duration:e.durationMs,easing:E(e)}}function E(e){let t=e.easing;switch(t.type){case`linear`:return`linear`;case`easeIn`:return`ease-in`;case`easeInOut`:return`ease-in-out`;case`cubicBezier`:return`cubic-bezier(${t.points.join(`, `)})`;default:return`ease-out`}}var D=class{page;frames;history=[];held=[];state;start;constructor(e,t){this.page=e,this.frames=new Map(e.frames.map(e=>[e.id,e]));let n=((t!==void 0&&/^\d+$/.test(t)?e.flows[Number(t)]:void 0)??e.flows.find(e=>e.name===t)??e.flows[0])?.start??e.frames[0]?.id;if(n===void 0||!this.frames.has(n))throw Error(`page "${e.name}" has no frame to start on`);this.start=n,this.state={frame:n,overlays:[]}}get frame(){return this.frames.get(this.state.frame)}get top(){let e=this.state.overlays.at(-1);return e?this.frames.get(e.id):this.frame}get holding(){return this.held.length}get canGoBack(){return this.history.length>0}run(e,t){switch(e.type){case`navigate`:return this.goTo(e.to,e.transition);case`overlay`:case`swapOverlay`:{if(!this.frames.has(e.to))return;let n=[...this.state.overlays];return e.type===`swapOverlay`&&n.pop(),n.push({id:e.to,transition:e.transition,origin:t,offset:e.offset}),this.commit({frame:this.base,overlays:n}),this.step({transition:e.transition,layer:`overlay`})}case`close`:return this.close();case`back`:return this.back();default:return}}goTo(e,t){if(this.frames.has(e)&&(e!==this.state.frame||this.state.overlays.length))return this.commit({frame:e,overlays:[]}),this.step({transition:t})}hold(e){if(!(e.type!==`navigate`||this.state.overlays.length>0)&&this.frames.has(e.to)&&e.to!==this.state.frame)return this.held.push(this.state.frame),this.state={frame:e.to,overlays:[]},this.step({transition:e.transition})}release(){let e=this.held.pop();if(e!==void 0)return this.state={frame:e,overlays:[]},this.step()}close(){let e=this.state.overlays.at(-1);if(!e)return;let t={frame:this.state.frame,overlays:this.state.overlays.slice(0,-1)};return O(this.history.at(-1),t)&&this.history.pop(),this.state=t,this.step({transition:e.transition,reverse:!0,layer:`overlay`})}back(){let e=this.held[0];this.held=[],e!==void 0&&(this.state={frame:e,overlays:[]});let t=this.history.pop();if(t===void 0)return e===void 0?void 0:this.step();let n=this.state;if(this.state=t,t.frame===n.frame){let e=n.overlays.at(-1)??t.overlays.at(-1);return this.step({transition:e?.transition,reverse:!0,layer:`overlay`})}let r=this.lastTransitionTo(n.frame,t.frame);return this.step({transition:r,reverse:!0})}restart(){return this.history=[],this.held=[],this.state={frame:this.start,overlays:[]},this.step()}get base(){return this.held[0]??this.state.frame}commit(e){this.history.push(this.held.length?{frame:this.held[0],overlays:[]}:this.state),this.held=[],this.state=e}step(e={}){return{frame:this.frame,overlays:this.state.overlays.map(({id:e,...t})=>({frame:this.frames.get(e),...t})),...e}}lastTransitionTo(e,t){for(let n of this.frames.get(t)?.hotspots??[])for(let{action:t}of n.interactions)if(t.type===`navigate`&&t.to===e)return t.transition}};function O(e,t){return e!==void 0&&e.frame===t.frame&&e.overlays.length===t.overlays.length&&e.overlays.every((e,n)=>e.id===t.overlays[n].id)}const k=`folioscope-player`,A=globalThis.HTMLElement??class{};var j=class extends A{static observedAttributes=[`src`,`flow`,`page`,`controls`];root;viewport;stage;runtime;index;base=``;density=1;shown;shownFrame;timers=[];loading=0;preloadedPage;showing=0;resize;controls;holds=[];releasePress=()=>this.release(e=>e.kind===`press`);constructor(){super(),this.root=this.attachShadow({mode:`open`}),this.root.innerHTML=`<style>
|
|
611
2
|
:host { display: block; position: relative; contain: content; }
|
|
612
3
|
:host([hidden]) { display: none; }
|
|
613
4
|
.viewport { position: relative; width: 100%; overflow: hidden; aspect-ratio: var(--w) / var(--h); }
|
|
@@ -623,378 +14,19 @@ const STYLE = `
|
|
|
623
14
|
.content { position: relative; }
|
|
624
15
|
.sheet { pointer-events: none; }
|
|
625
16
|
.sheet > .frame { pointer-events: auto; }
|
|
17
|
+
.component { position: absolute; }
|
|
18
|
+
.smart { position: absolute; inset: 0; overflow: hidden; }
|
|
19
|
+
.smart img { position: absolute; display: block; transform-origin: 0 0; pointer-events: none; }
|
|
626
20
|
.hotspot { position: absolute; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; }
|
|
627
21
|
.hotspot:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
628
22
|
:host([hotspots="always"]) .hotspot { background: rgb(37 99 235 / 0.15); box-shadow: inset 0 0 0 2px rgb(37 99 235 / 0.6); }
|
|
629
23
|
.viewport.flash .hotspot { animation: folioscope-flash 0.7s ease-out; }
|
|
630
24
|
@keyframes folioscope-flash { from { background: rgb(37 99 235 / 0.35); box-shadow: inset 0 0 0 2px rgb(37 99 235 / 0.8); } }
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
"flow",
|
|
640
|
-
"page",
|
|
641
|
-
"controls"
|
|
642
|
-
];
|
|
643
|
-
root;
|
|
644
|
-
viewport;
|
|
645
|
-
stage;
|
|
646
|
-
runtime;
|
|
647
|
-
index;
|
|
648
|
-
base = "";
|
|
649
|
-
density = 1;
|
|
650
|
-
shown;
|
|
651
|
-
timers = [];
|
|
652
|
-
loading = 0;
|
|
653
|
-
preloadedPage;
|
|
654
|
-
showing = 0;
|
|
655
|
-
resize;
|
|
656
|
-
controls;
|
|
657
|
-
/** The hovers and presses holding a frame, innermost last, with the hotspot that started each. */
|
|
658
|
-
holds = [];
|
|
659
|
-
releasePress = () => this.release((hold) => hold.kind === "press");
|
|
660
|
-
constructor() {
|
|
661
|
-
super();
|
|
662
|
-
this.root = this.attachShadow({ mode: "open" });
|
|
663
|
-
this.root.innerHTML = `<style>${STYLE}${CONTROLS_STYLE}</style><div class="viewport" part="viewport"><div class="stage"></div></div>`;
|
|
664
|
-
this.viewport = this.root.querySelector(".viewport");
|
|
665
|
-
this.stage = this.root.querySelector(".stage");
|
|
666
|
-
this.controls = new Controls({
|
|
667
|
-
restart: () => this.restart(),
|
|
668
|
-
back: () => this.back(),
|
|
669
|
-
page: (i) => this.setAttribute("page", String(this.index?.pages.indexOf(this.playablePages()[i]) ?? 0)),
|
|
670
|
-
flow: (i) => this.setAttribute("flow", String(i)),
|
|
671
|
-
fullscreen: () => void this.requestFullscreen?.().catch(() => void 0)
|
|
672
|
-
});
|
|
673
|
-
this.controls.element.hidden = !this.hasAttribute("controls");
|
|
674
|
-
this.root.append(this.controls.element);
|
|
675
|
-
this.viewport.addEventListener("click", (event) => {
|
|
676
|
-
if (!event.composedPath().some((el) => el instanceof HTMLElement && el.classList.contains("hotspot")) && (this.getAttribute("hotspots") ?? "on-miss") === "on-miss") {
|
|
677
|
-
this.viewport.classList.remove("flash");
|
|
678
|
-
this.viewport.offsetWidth;
|
|
679
|
-
this.viewport.classList.add("flash");
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
|
-
this.viewport.addEventListener("animationend", () => this.viewport.classList.remove("flash"));
|
|
683
|
-
this.addEventListener("fullscreenchange", () => this.fit());
|
|
684
|
-
this.viewport.addEventListener("pointermove", (event) => this.release((hold) => hold.kind === "hover" && !this.under(event, hold.bounds)));
|
|
685
|
-
this.viewport.addEventListener("pointerleave", () => this.release((hold) => hold.kind === "hover"));
|
|
686
|
-
this.addEventListener("keydown", (event) => {
|
|
687
|
-
if (event.key === "Escape" && this.runtime?.canGoBack) {
|
|
688
|
-
event.preventDefault();
|
|
689
|
-
this.show(this.runtime.back());
|
|
690
|
-
}
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
get src() {
|
|
694
|
-
return this.getAttribute("src") ?? "";
|
|
695
|
-
}
|
|
696
|
-
set src(value) {
|
|
697
|
-
this.setAttribute("src", value);
|
|
698
|
-
}
|
|
699
|
-
/** The frame on screen. */
|
|
700
|
-
get frame() {
|
|
701
|
-
return this.runtime?.frame;
|
|
702
|
-
}
|
|
703
|
-
connectedCallback() {
|
|
704
|
-
globalThis.addEventListener("pointerup", this.releasePress);
|
|
705
|
-
globalThis.addEventListener("pointercancel", this.releasePress);
|
|
706
|
-
this.resize = new ResizeObserver(() => this.fit());
|
|
707
|
-
this.resize.observe(this.viewport);
|
|
708
|
-
if (!this.index) this.load();
|
|
709
|
-
}
|
|
710
|
-
disconnectedCallback() {
|
|
711
|
-
globalThis.removeEventListener("pointerup", this.releasePress);
|
|
712
|
-
globalThis.removeEventListener("pointercancel", this.releasePress);
|
|
713
|
-
this.resize?.disconnect();
|
|
714
|
-
this.clearTimers();
|
|
715
|
-
}
|
|
716
|
-
attributeChangedCallback(name, previous, value) {
|
|
717
|
-
if (previous === value || !this.isConnected) return;
|
|
718
|
-
if (name === "controls") this.controls.element.hidden = value === null;
|
|
719
|
-
else if (name === "src") this.load();
|
|
720
|
-
else if (this.index) this.startPage();
|
|
721
|
-
}
|
|
722
|
-
/** Back to the start of the flow. */
|
|
723
|
-
restart() {
|
|
724
|
-
if (this.runtime) this.show(this.runtime.restart());
|
|
725
|
-
}
|
|
726
|
-
back() {
|
|
727
|
-
if (this.runtime) this.show(this.runtime.back());
|
|
728
|
-
}
|
|
729
|
-
goTo(frameId) {
|
|
730
|
-
if (this.runtime) this.show(this.runtime.goTo(frameId));
|
|
731
|
-
}
|
|
732
|
-
async load() {
|
|
733
|
-
const src = this.src;
|
|
734
|
-
if (!src) return;
|
|
735
|
-
const attempt = ++this.loading;
|
|
736
|
-
this.showPoster();
|
|
737
|
-
try {
|
|
738
|
-
if (/\.(fig|sketch|xd|penpot)$/i.test(src)) throw new Error(`${src}: raw design files need the full player, not available yet; bake it with \`folioscope bake\``);
|
|
739
|
-
this.base = src.endsWith("index.json") ? src.slice(0, -10) : src.endsWith("/") ? src : `${src}/`;
|
|
740
|
-
const response = await fetch(`${this.base}index.json`);
|
|
741
|
-
if (!response.ok) throw new Error(`${this.base}index.json: HTTP ${response.status}`);
|
|
742
|
-
const index = await response.json();
|
|
743
|
-
if (index.folio > 1 || index.irVersion > 1) throw new Error(`${src} was baked by a newer folioscope (folio v${index.folio}, IR v${index.irVersion})`);
|
|
744
|
-
if (attempt !== this.loading) return;
|
|
745
|
-
this.index = index;
|
|
746
|
-
const dpr = globalThis.devicePixelRatio ?? 1;
|
|
747
|
-
this.density = [...index.densities].sort((a, b) => a - b).find((d) => d >= dpr) ?? Math.max(...index.densities);
|
|
748
|
-
this.startPage();
|
|
749
|
-
this.emit("folioscope:load", { pages: index.pages.length });
|
|
750
|
-
} catch (error) {
|
|
751
|
-
this.emit("folioscope:error", { error });
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
startPage() {
|
|
755
|
-
const index = this.index;
|
|
756
|
-
const pageAttr = this.getAttribute("page");
|
|
757
|
-
const page = (pageAttr && /^\d+$/.test(pageAttr) ? index.pages[Number(pageAttr)] : index.pages.find((p) => p.name === pageAttr)) ?? index.pages.find((p) => p.flows.length > 0) ?? index.pages[0];
|
|
758
|
-
if (!page) return;
|
|
759
|
-
const flowAttr = this.getAttribute("flow") ?? void 0;
|
|
760
|
-
this.runtime = new Runtime(page, flowAttr);
|
|
761
|
-
const flowIndex = flowAttr !== void 0 && /^\d+$/.test(flowAttr) ? Number(flowAttr) : Math.max(0, page.flows.findIndex((f) => f.name === flowAttr));
|
|
762
|
-
this.controls.load(index, page, flowIndex);
|
|
763
|
-
this.showPoster(this.runtime.frame.placeholder);
|
|
764
|
-
this.show(this.runtime.restart());
|
|
765
|
-
}
|
|
766
|
-
async show(step) {
|
|
767
|
-
if (!step) return;
|
|
768
|
-
const seq = ++this.showing;
|
|
769
|
-
this.clearTimers();
|
|
770
|
-
for (const animation of this.stage.getAnimations({ subtree: true })) animation.finish();
|
|
771
|
-
const hadFocus = this.root.activeElement !== null;
|
|
772
|
-
const shown = step.layer === "overlay" && this.shown ? await this.showOverlay(step, seq) : await this.showScreen(step, seq);
|
|
773
|
-
if (!shown || seq !== this.showing) return;
|
|
774
|
-
if (hadFocus) shown.focus({ preventScroll: true });
|
|
775
|
-
this.controls.update(this.runtime?.canGoBack ?? false);
|
|
776
|
-
const top = step.overlays.at(-1)?.frame ?? step.frame;
|
|
777
|
-
this.emit("folioscope:navigate", {
|
|
778
|
-
frame: top.id,
|
|
779
|
-
name: top.name
|
|
780
|
-
});
|
|
781
|
-
this.schedule(top);
|
|
782
|
-
this.preload(top);
|
|
783
|
-
}
|
|
784
|
-
/** Replaces the screen and its overlays; returns the frame on top. */
|
|
785
|
-
async showScreen(step, seq) {
|
|
786
|
-
const { frame } = step;
|
|
787
|
-
this.viewport.style.setProperty("--w", String(frame.bounds.width));
|
|
788
|
-
this.viewport.style.setProperty("--h", String(frame.bounds.height));
|
|
789
|
-
this.fit();
|
|
790
|
-
const incoming = this.render(frame);
|
|
791
|
-
const overlays = step.overlays.map((overlay) => this.renderOverlay(overlay, frame));
|
|
792
|
-
await Promise.all([incoming, ...overlays].map(decoded));
|
|
793
|
-
if (seq !== this.showing) return void 0;
|
|
794
|
-
const outgoing = this.shown;
|
|
795
|
-
this.shown = incoming;
|
|
796
|
-
for (const el of this.stage.querySelectorAll(":scope > .overlay")) el.remove();
|
|
797
|
-
this.stage.append(incoming, ...overlays);
|
|
798
|
-
this.root.querySelector(".poster")?.remove();
|
|
799
|
-
await play(outgoing, incoming, step.transition, step.reverse ?? false);
|
|
800
|
-
if (seq !== this.showing) return void 0;
|
|
801
|
-
for (const el of this.stage.querySelectorAll(":scope > .frame")) if (el !== incoming) el.remove();
|
|
802
|
-
return overlays.at(-1)?.querySelector(".frame") ?? incoming;
|
|
803
|
-
}
|
|
804
|
-
/** Opens, closes or swaps the top overlay, the screen below staying; returns the frame on top. */
|
|
805
|
-
async showOverlay(step, seq) {
|
|
806
|
-
const open = [...this.stage.querySelectorAll(":scope > .overlay")];
|
|
807
|
-
const wanted = step.overlays;
|
|
808
|
-
const below = Math.min(open.length, wanted.length) - (open.length === wanted.length ? 1 : 0);
|
|
809
|
-
if (open.slice(0, below).some((el, i) => el.dataset.frame !== wanted[i].frame.id)) return this.showScreen({
|
|
810
|
-
...step,
|
|
811
|
-
transition: void 0
|
|
812
|
-
}, seq);
|
|
813
|
-
const closing = open.length > below ? open.at(-1) : void 0;
|
|
814
|
-
const top = wanted.length > below ? wanted.at(-1) : void 0;
|
|
815
|
-
const opening = top && this.renderOverlay(top, step.frame);
|
|
816
|
-
if (opening) {
|
|
817
|
-
await decoded(opening);
|
|
818
|
-
if (seq !== this.showing) return void 0;
|
|
819
|
-
this.stage.append(opening);
|
|
820
|
-
}
|
|
821
|
-
const transition = top?.transition ?? step.transition;
|
|
822
|
-
if (transition) await Promise.all([opening && playOverlay(opening, transition, false), closing && playOverlay(closing, transition, true)]);
|
|
823
|
-
if (seq !== this.showing) return void 0;
|
|
824
|
-
closing?.remove();
|
|
825
|
-
const shown = this.stage.querySelectorAll(":scope > .overlay > .sheet > .frame");
|
|
826
|
-
return shown[shown.length - 1] ?? this.shown;
|
|
827
|
-
}
|
|
828
|
-
/** A frame with its hotspots wired to the runtime; `at` places it in the screen's coordinates. */
|
|
829
|
-
render(frame, at = {
|
|
830
|
-
x: 0,
|
|
831
|
-
y: 0
|
|
832
|
-
}) {
|
|
833
|
-
const el = renderFrame(frame, (layer) => this.layerUrl(layer), {
|
|
834
|
-
action: (action, bounds) => {
|
|
835
|
-
if (action.type === "scrollTo") return scrollFrame(el, action);
|
|
836
|
-
const origin = {
|
|
837
|
-
...bounds,
|
|
838
|
-
x: bounds.x + at.x,
|
|
839
|
-
y: bounds.y + at.y
|
|
840
|
-
};
|
|
841
|
-
this.show(this.runtime?.run(action, origin));
|
|
842
|
-
if (action.type === "openUrl") window.open(action.url, action.newTab ? "_blank" : "_self", "noopener");
|
|
843
|
-
},
|
|
844
|
-
hold: (action, kind, bounds) => this.hold(action, kind, bounds)
|
|
845
|
-
});
|
|
846
|
-
return el;
|
|
847
|
-
}
|
|
848
|
-
/** An overlay layer: a backdrop over the whole screen, and the overlay frame at its position. */
|
|
849
|
-
renderOverlay(overlay, screen) {
|
|
850
|
-
const settings = overlay.frame.overlay ?? {
|
|
851
|
-
position: "center",
|
|
852
|
-
closeOnClickOutside: false
|
|
853
|
-
};
|
|
854
|
-
const at = overlayPosition(settings.position, screen.bounds, overlay);
|
|
855
|
-
const layer = document.createElement("div");
|
|
856
|
-
layer.className = "overlay";
|
|
857
|
-
layer.dataset.frame = overlay.frame.id;
|
|
858
|
-
const backdrop = document.createElement("div");
|
|
859
|
-
backdrop.className = "backdrop";
|
|
860
|
-
if (settings.backdrop) backdrop.style.background = settings.backdrop;
|
|
861
|
-
backdrop.addEventListener("click", () => {
|
|
862
|
-
if (settings.closeOnClickOutside) this.show(this.runtime?.close());
|
|
863
|
-
});
|
|
864
|
-
const sheet = document.createElement("div");
|
|
865
|
-
sheet.className = "sheet";
|
|
866
|
-
const el = this.render(overlay.frame, at);
|
|
867
|
-
Object.assign(el.style, {
|
|
868
|
-
inset: "auto",
|
|
869
|
-
left: `${at.x}px`,
|
|
870
|
-
top: `${at.y}px`,
|
|
871
|
-
width: `${overlay.frame.bounds.width}px`,
|
|
872
|
-
height: `${overlay.frame.bounds.height}px`
|
|
873
|
-
});
|
|
874
|
-
sheet.append(el);
|
|
875
|
-
layer.append(backdrop, sheet);
|
|
876
|
-
return layer;
|
|
877
|
-
}
|
|
878
|
-
hold(action, kind, bounds) {
|
|
879
|
-
const step = this.runtime?.hold(action);
|
|
880
|
-
if (!step) return;
|
|
881
|
-
this.holds.push({
|
|
882
|
-
kind,
|
|
883
|
-
bounds
|
|
884
|
-
});
|
|
885
|
-
this.show(step);
|
|
886
|
-
}
|
|
887
|
-
/** Ends the innermost holds while `ends` says so, then shows the frame they return to. */
|
|
888
|
-
release(ends) {
|
|
889
|
-
const runtime = this.runtime;
|
|
890
|
-
if (!runtime) return;
|
|
891
|
-
this.holds.length = Math.min(this.holds.length, runtime.holding);
|
|
892
|
-
let step;
|
|
893
|
-
while (this.holds.length > 0 && ends(this.holds.at(-1))) {
|
|
894
|
-
this.holds.pop();
|
|
895
|
-
step = runtime.release() ?? step;
|
|
896
|
-
}
|
|
897
|
-
if (step) this.show(step);
|
|
898
|
-
}
|
|
899
|
-
/** Whether a pointer event falls inside `bounds`, in the frame's coordinates. */
|
|
900
|
-
under(event, bounds) {
|
|
901
|
-
const stage = this.stage.getBoundingClientRect();
|
|
902
|
-
const scale = stage.width / (Number(this.viewport.style.getPropertyValue("--w")) || 1);
|
|
903
|
-
const x = (event.clientX - stage.left) / scale;
|
|
904
|
-
const y = (event.clientY - stage.top) / scale;
|
|
905
|
-
return x >= bounds.x && x < bounds.x + bounds.width && y >= bounds.y && y < bounds.y + bounds.height;
|
|
906
|
-
}
|
|
907
|
-
/** "After delay" interactions start counting when the frame shows. */
|
|
908
|
-
schedule(frame) {
|
|
909
|
-
for (const hotspot of frame.hotspots) for (const { trigger, action } of hotspot.interactions) if (trigger.type === "afterDelay") this.timers.push(setTimeout(() => void this.show(this.runtime?.run(action)), trigger.delayMs));
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* `preload`: `neighbors` (default) fetches the frames this one links to, `all` every frame of
|
|
913
|
-
* the page once, `start` and `none` nothing beyond the frame on screen.
|
|
914
|
-
*/
|
|
915
|
-
preload(frame) {
|
|
916
|
-
const strategy = this.getAttribute("preload") ?? "neighbors";
|
|
917
|
-
const page = this.runtime?.page;
|
|
918
|
-
if (!page || strategy === "none" || strategy === "start") return;
|
|
919
|
-
let targets;
|
|
920
|
-
if (strategy === "all") {
|
|
921
|
-
if (this.preloadedPage === page) return;
|
|
922
|
-
this.preloadedPage = page;
|
|
923
|
-
targets = page.frames;
|
|
924
|
-
} else {
|
|
925
|
-
const frames = new Map(page.frames.map((f) => [f.id, f]));
|
|
926
|
-
targets = frame.hotspots.flatMap((h) => h.interactions).flatMap(({ action }) => "to" in action ? [frames.get(action.to)] : []).filter((f) => f !== void 0);
|
|
927
|
-
}
|
|
928
|
-
for (const target of targets) for (const layer of target.layers) new Image().src = this.layerUrl(layer);
|
|
929
|
-
}
|
|
930
|
-
playablePages() {
|
|
931
|
-
return this.index?.pages.filter((p) => p.frames.length > 0) ?? [];
|
|
932
|
-
}
|
|
933
|
-
/** The `poster` image, else the frame's baked placeholder, until the first frame is ready. */
|
|
934
|
-
showPoster(placeholder) {
|
|
935
|
-
const poster = this.getAttribute("poster") ?? (placeholder ? thumbHashToDataURL(Uint8Array.from(atob(placeholder), (c) => c.charCodeAt(0))) : null);
|
|
936
|
-
if (!poster || this.root.querySelector(".poster")) return;
|
|
937
|
-
const img = document.createElement("img");
|
|
938
|
-
img.className = "poster";
|
|
939
|
-
img.alt = "";
|
|
940
|
-
img.src = poster;
|
|
941
|
-
this.viewport.append(img);
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Scales the prototype to the element's width. In fullscreen, the screen's height bounds it too,
|
|
945
|
-
* and the prototype is centered.
|
|
946
|
-
*/
|
|
947
|
-
fit() {
|
|
948
|
-
const style = this.viewport.style;
|
|
949
|
-
const width = Number(style.getPropertyValue("--w")) || 1;
|
|
950
|
-
const height = Number(style.getPropertyValue("--h")) || 1;
|
|
951
|
-
const { clientWidth, clientHeight } = this.viewport;
|
|
952
|
-
const full = document.fullscreenElement === this;
|
|
953
|
-
let scale = 1;
|
|
954
|
-
if ((this.getAttribute("fit") ?? "contain") !== "none") {
|
|
955
|
-
scale = clientWidth / width;
|
|
956
|
-
if (full) scale = Math.min(scale, clientHeight / height);
|
|
957
|
-
}
|
|
958
|
-
scale ||= 1;
|
|
959
|
-
style.setProperty("--scale", String(scale));
|
|
960
|
-
style.setProperty("--x", full ? `${Math.max(0, (clientWidth - width * scale) / 2)}px` : "0px");
|
|
961
|
-
style.setProperty("--y", full ? `${Math.max(0, (clientHeight - height * scale) / 2)}px` : "0px");
|
|
962
|
-
}
|
|
963
|
-
layerUrl(layer) {
|
|
964
|
-
return `${this.base}layers/${layer.src}@${this.density}x.${layer.format}`;
|
|
965
|
-
}
|
|
966
|
-
clearTimers() {
|
|
967
|
-
this.timers.forEach(clearTimeout);
|
|
968
|
-
this.timers = [];
|
|
969
|
-
}
|
|
970
|
-
emit(type, detail) {
|
|
971
|
-
this.dispatchEvent(new CustomEvent(type, {
|
|
972
|
-
detail,
|
|
973
|
-
bubbles: true,
|
|
974
|
-
composed: true
|
|
975
|
-
}));
|
|
976
|
-
}
|
|
977
|
-
};
|
|
978
|
-
/** Top-left corner of an overlay in the screen's coordinates. */
|
|
979
|
-
function overlayPosition(position, screen, overlay) {
|
|
980
|
-
const { width, height } = overlay.frame.bounds;
|
|
981
|
-
if (position === "manual") {
|
|
982
|
-
const origin = overlay.origin ?? {
|
|
983
|
-
x: 0,
|
|
984
|
-
y: 0
|
|
985
|
-
};
|
|
986
|
-
return {
|
|
987
|
-
x: origin.x + (overlay.offset?.x ?? 0),
|
|
988
|
-
y: origin.y + (overlay.offset?.y ?? 0)
|
|
989
|
-
};
|
|
990
|
-
}
|
|
991
|
-
return {
|
|
992
|
-
x: position.endsWith("Left") ? 0 : position.endsWith("Right") ? screen.width - width : (screen.width - width) / 2,
|
|
993
|
-
y: position.startsWith("top") ? 0 : position.startsWith("bottom") ? screen.height - height : (screen.height - height) / 2
|
|
994
|
-
};
|
|
995
|
-
}
|
|
996
|
-
//#endregion
|
|
997
|
-
//#region src/index.ts
|
|
998
|
-
if (typeof customElements !== "undefined" && !customElements.get("folioscope-player")) customElements.define(TAG_NAME, FolioscopePlayer);
|
|
999
|
-
//#endregion
|
|
1000
|
-
export { FolioscopePlayer, Runtime, TAG_NAME };
|
|
25
|
+
|
|
26
|
+
.controls { display: flex; gap: 4px; align-items: center; padding: 4px; font: 13px/1.2 system-ui, sans-serif; background: var(--folioscope-controls-background, #1e1e1e); color: var(--folioscope-controls-color, #eee); }
|
|
27
|
+
.controls[hidden] { display: none; }
|
|
28
|
+
.controls button, .controls select { font: inherit; color: inherit; background: transparent; border: 1px solid #555; border-radius: 4px; padding: 2px 8px; min-height: 28px; cursor: pointer; }
|
|
29
|
+
.controls button:disabled { opacity: .4; cursor: default; }
|
|
30
|
+
.controls select[hidden] { display: none; }
|
|
31
|
+
.controls .spacer { flex: 1; }
|
|
32
|
+
</style><div class="viewport" part="viewport"><div class="stage"></div></div>`,this.viewport=this.root.querySelector(`.viewport`),this.stage=this.root.querySelector(`.stage`),this.controls=new e({restart:()=>this.restart(),back:()=>this.back(),page:e=>this.setAttribute(`page`,String(this.index?.pages.indexOf(this.playablePages()[e])??0)),flow:e=>this.setAttribute(`flow`,String(e)),fullscreen:()=>void this.requestFullscreen?.().catch(()=>void 0)}),this.controls.element.hidden=!this.hasAttribute(`controls`),this.root.append(this.controls.element),this.viewport.addEventListener(`click`,e=>{!e.composedPath().some(e=>e instanceof HTMLElement&&e.classList.contains(`hotspot`))&&(this.getAttribute(`hotspots`)??`on-miss`)===`on-miss`&&(this.viewport.classList.remove(`flash`),this.viewport.offsetWidth,this.viewport.classList.add(`flash`))}),this.viewport.addEventListener(`animationend`,()=>this.viewport.classList.remove(`flash`)),this.addEventListener(`fullscreenchange`,()=>this.fit()),this.viewport.addEventListener(`pointermove`,e=>this.release(t=>t.kind===`hover`&&!this.under(e,t.bounds))),this.viewport.addEventListener(`pointerleave`,()=>this.release(e=>e.kind===`hover`)),this.addEventListener(`keydown`,e=>{e.key===`Escape`&&this.runtime?.canGoBack&&(e.preventDefault(),this.show(this.runtime.back()))})}get src(){return this.getAttribute(`src`)??``}set src(e){this.setAttribute(`src`,e)}get frame(){return this.runtime?.frame}connectedCallback(){globalThis.addEventListener(`pointerup`,this.releasePress),globalThis.addEventListener(`pointercancel`,this.releasePress),this.resize=new ResizeObserver(()=>this.fit()),this.resize.observe(this.viewport),this.index||this.load()}disconnectedCallback(){globalThis.removeEventListener(`pointerup`,this.releasePress),globalThis.removeEventListener(`pointercancel`,this.releasePress),this.resize?.disconnect(),this.clearTimers()}attributeChangedCallback(e,t,n){t!==n&&this.isConnected&&(e===`controls`?this.controls.element.hidden=n===null:e===`src`?this.load():this.index&&this.startPage())}restart(){this.runtime&&this.show(this.runtime.restart())}back(){this.runtime&&this.show(this.runtime.back())}goTo(e){this.runtime&&this.show(this.runtime.goTo(e))}async load(){let e=this.src;if(!e)return;let t=++this.loading;this.showPoster();try{if(/\.(fig|sketch|xd|penpot)$/i.test(e))throw Error(`${e}: raw design files need the full player, not available yet; bake it with \`folioscope bake\``);this.base=e.endsWith(`index.json`)?e.slice(0,-10):e.endsWith(`/`)?e:`${e}/`;let n=await fetch(`${this.base}index.json`);if(!n.ok)throw Error(`${this.base}index.json: HTTP ${n.status}`);let r=await n.json();if(r.folio>1||r.irVersion>1)throw Error(`${e} was baked by a newer folioscope (folio v${r.folio}, IR v${r.irVersion})`);if(t!==this.loading)return;this.index=r;let i=globalThis.devicePixelRatio??1;this.density=[...r.densities].sort((e,t)=>e-t).find(e=>e>=i)??Math.max(...r.densities),this.startPage(),this.emit(`folioscope:load`,{pages:r.pages.length})}catch(e){this.emit(`folioscope:error`,{error:e})}}startPage(){let e=this.index,t=this.getAttribute(`page`),n=(t&&/^\d+$/.test(t)?e.pages[Number(t)]:e.pages.find(e=>e.name===t))??e.pages.find(e=>e.flows.length>0)??e.pages[0];if(!n)return;let r=this.getAttribute(`flow`)??void 0;this.runtime=new D(n,r);let i=r!==void 0&&/^\d+$/.test(r)?Number(r):Math.max(0,n.flows.findIndex(e=>e.name===r));this.controls.load(e,n,i),this.showPoster(this.runtime.frame.placeholder),this.show(this.runtime.restart())}async show(e){if(!e)return;let t=++this.showing;this.clearTimers();for(let e of this.stage.getAnimations({subtree:!0}))e.finish();let n=this.root.activeElement!==null,r=e.layer===`overlay`&&this.shown?await this.showOverlay(e,t):await this.showScreen(e,t);if(!r||t!==this.showing)return;n&&r.focus({preventScroll:!0}),this.controls.update(this.runtime?.canGoBack??!1);let i=e.overlays.at(-1)?.frame??e.frame;this.emit(`folioscope:navigate`,{frame:i.id,name:i.name}),this.schedule(i),this.preload(i)}async showScreen(e,t){let{frame:n}=e;this.viewport.style.setProperty(`--w`,String(n.bounds.width)),this.viewport.style.setProperty(`--h`,String(n.bounds.height)),this.fit();let r=this.render(n),i=e.overlays.map(e=>this.renderOverlay(e,n));if(await Promise.all([r,...i].map(x)),t!==this.showing)return;let a=this.shown;this.shown=r;for(let e of this.stage.querySelectorAll(`:scope > .overlay`))e.remove();this.stage.append(r,...i),this.root.querySelector(`.poster`)?.remove();let o=this.shownFrame;if(this.shownFrame=n,e.transition?.kind.type===`smartAnimate`&&o?.parts&&n.parts?await y(this.stage,o,n,e.transition,e=>this.layerUrl(e)):await S(a,r,e.transition,e.reverse??!1),t===this.showing){for(let e of this.stage.querySelectorAll(`:scope > .frame`))e!==r&&e.remove();return i.at(-1)?.querySelector(`.frame`)??r}}async showOverlay(e,t){let n=[...this.stage.querySelectorAll(`:scope > .overlay`)],r=e.overlays,i=Math.min(n.length,r.length)-+(n.length===r.length);if(n.slice(0,i).some((e,t)=>e.dataset.frame!==r[t].frame.id))return this.showScreen({...e,transition:void 0},t);let a=n.length>i?n.at(-1):void 0,o=r.length>i?r.at(-1):void 0,s=o&&this.renderOverlay(o,e.frame);if(s){if(await x(s),t!==this.showing)return;this.stage.append(s)}let c=o?.transition??e.transition;if(c&&await Promise.all([s&&v(s,c,!1),a&&v(a,c,!0)]),t!==this.showing)return;a?.remove();let l=this.stage.querySelectorAll(`:scope > .overlay > .sheet > .frame`);return l[l.length-1]??this.shown}render(e,t={x:0,y:0}){let n=m(e,e=>this.layerUrl(e),{action:(e,r)=>{if(e.type===`scrollTo`)return b(n,e);let i={...r,x:r.x+t.x,y:r.y+t.y};this.show(this.runtime?.run(e,i)),e.type===`openUrl`&&window.open(e.url,e.newTab?`_blank`:`_self`,`noopener`)},hold:(e,t,n)=>this.hold(e,t,n)});return n}renderOverlay(e,t){let n=e.frame.overlay??{position:`center`,closeOnClickOutside:!1},r=M(n.position,t.bounds,e),i=document.createElement(`div`);i.className=`overlay`,i.dataset.frame=e.frame.id;let a=document.createElement(`div`);a.className=`backdrop`,n.backdrop&&(a.style.background=n.backdrop),a.addEventListener(`click`,()=>{n.closeOnClickOutside&&this.show(this.runtime?.close())});let o=document.createElement(`div`);o.className=`sheet`;let s=this.render(e.frame,r);return Object.assign(s.style,{inset:`auto`,left:`${r.x}px`,top:`${r.y}px`,width:`${e.frame.bounds.width}px`,height:`${e.frame.bounds.height}px`}),o.append(s),i.append(a,o),i}hold(e,t,n){let r=this.runtime?.hold(e);r&&(this.holds.push({kind:t,bounds:n}),this.show(r))}release(e){let t=this.runtime;if(!t)return;this.holds.length=Math.min(this.holds.length,t.holding);let n;for(;this.holds.length>0&&e(this.holds.at(-1));)this.holds.pop(),n=t.release()??n;n&&this.show(n)}under(e,t){let r=this.stage.getBoundingClientRect(),i=r.width/(Number(this.viewport.style.getPropertyValue(`--w`))||1);return n(t,{x:(e.clientX-r.left)/i,y:(e.clientY-r.top)/i})}schedule(e){for(let t of e.hotspots)for(let{trigger:e,action:n}of t.interactions)e.type===`afterDelay`&&this.timers.push(setTimeout(()=>void this.show(this.runtime?.run(n)),e.delayMs))}preload(e){let t=this.getAttribute(`preload`)??`neighbors`,n=this.runtime?.page;if(!n||t===`none`||t===`start`)return;let r;if(t===`all`){if(this.preloadedPage===n)return;this.preloadedPage=n,r=n.frames}else{let t=new Map(n.frames.map(e=>[e.id,e]));r=e.hotspots.flatMap(e=>e.interactions).flatMap(({action:e})=>`to`in e?[t.get(e.to)]:[]).filter(e=>e!==void 0)}for(let t of[e,...r]){let n=(t.parts??[]).map(e=>e.layer);for(let r of t===e?n:[...t.layers,...n])new Image().src=this.layerUrl(r)}}playablePages(){return this.index?.pages.filter(e=>e.frames.length>0)??[]}showPoster(e){let t=this.getAttribute(`poster`)??(e?c(Uint8Array.from(atob(e),e=>e.charCodeAt(0))):null);if(!t||this.root.querySelector(`.poster`))return;let n=document.createElement(`img`);n.className=`poster`,n.alt=``,n.src=t,this.viewport.append(n)}fit(){let e=this.viewport.style,t=Number(e.getPropertyValue(`--w`))||1,n=Number(e.getPropertyValue(`--h`))||1,{clientWidth:r,clientHeight:a}=this.viewport,[o,s,c]=[1,0,0];if((this.getAttribute(`fit`)??`contain`)!==`none`){if(document.fullscreenElement===this){let e=i({width:t,height:n},{width:r,height:a});[o,s,c]=[e.a,e.e,e.f]}else o=r/t}e.setProperty(`--scale`,String(o||1)),e.setProperty(`--x`,`${s}px`),e.setProperty(`--y`,`${c}px`)}layerUrl(e){return`${this.base}layers/${e.src}@${this.density}x.${e.format}`}clearTimers(){this.timers.forEach(clearTimeout),this.timers=[]}emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}};function M(e,t,n){let{width:r,height:i}=n.frame.bounds;if(e===`manual`){let e=n.origin??{x:0,y:0};return{x:e.x+(n.offset?.x??0),y:e.y+(n.offset?.y??0)}}return{x:e.endsWith(`Left`)?0:e.endsWith(`Right`)?t.width-r:(t.width-r)/2,y:e.startsWith(`top`)?0:e.startsWith(`bottom`)?t.height-i:(t.height-i)/2}}typeof customElements<`u`&&!customElements.get(`folioscope-player`)&&customElements.define(k,j);export{j as FolioscopePlayer,D as Runtime,k as TAG_NAME};
|