partforge 0.46.4 → 0.47.1
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/docs/AUTHORING-PARTS.md +18 -4
- package/package.json +1 -1
- package/src/framework/animation-controls.js +269 -48
- package/src/framework/app.css +111 -9
- package/src/framework/chrome.css +20 -0
- package/src/framework/controls.js +14 -363
- package/src/framework/lint/rules-animations.js +13 -12
- package/src/framework/lint/rules-schema.js +10 -21
- package/src/framework/panel/info.js +76 -0
- package/src/framework/panel/legacy.js +138 -0
- package/src/framework/panel/model.js +84 -0
- package/src/framework/panel/panel-state.js +70 -0
- package/src/framework/panel/render.js +249 -0
- package/src/framework/panel/widget-specs.js +36 -0
- package/src/framework/panel/widgets/checkbox.js +37 -0
- package/src/framework/panel/widgets/index.js +13 -0
- package/src/framework/panel/widgets/numeric.js +77 -0
- package/src/framework/panel/widgets/text.js +33 -0
- package/types/part.d.ts +19 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -132,9 +132,17 @@ export default {
|
|
|
132
132
|
## Animations
|
|
133
133
|
|
|
134
134
|
A part may declare named animations — pure keyframe data that drives **existing
|
|
135
|
-
params** over time. The viewer shows a transport bar (play/scrub
|
|
136
|
-
drive the same engine via
|
|
137
|
-
stills at any position. The
|
|
135
|
+
params** over time. The viewer shows a transport bar (play/scrub, with ‹ ›
|
|
136
|
+
pagers between animations); hosts drive the same engine via
|
|
137
|
+
`runtime.animation`; `partforge render` can render stills at any position. The
|
|
138
|
+
reference part is `src/parts/hinged-box.js`.
|
|
139
|
+
|
|
140
|
+
Step labels surface on the scrubber rather than in a readout: hovering or
|
|
141
|
+
dragging along the timeline names the chapter under the pointer, and with the
|
|
142
|
+
scrubber focused **PageUp / PageDown jump whole chapters** (PageUp forward,
|
|
143
|
+
matching the key's native slider direction). Screen readers get the same
|
|
144
|
+
information from the scrubber's `aria-valuetext`, which reads
|
|
145
|
+
`"<step label> — <percent>"`.
|
|
138
146
|
|
|
139
147
|
```js
|
|
140
148
|
animations: {
|
|
@@ -149,7 +157,7 @@ animations: {
|
|
|
149
157
|
},
|
|
150
158
|
assemble: {
|
|
151
159
|
label: "Assemble",
|
|
152
|
-
steps: [ // steps play in order;
|
|
160
|
+
steps: [ // steps play in order; named on the scrubber as you hover/drag
|
|
153
161
|
{ label: "Lower the lid", camera: "left", duration: 1.0,
|
|
154
162
|
tracks: { lidLift: [[0, 40], [1, 0]] } },
|
|
155
163
|
{ label: "Open", camera: "iso", duration: 1.0,
|
|
@@ -543,6 +551,12 @@ advanced: [
|
|
|
543
551
|
],
|
|
544
552
|
```
|
|
545
553
|
|
|
554
|
+
**Collapsing.** Each section is a disclosure. A panel with **three or fewer
|
|
555
|
+
sections opens every section and every Advanced fold on load**; beyond that they
|
|
556
|
+
all start closed, because the rail is a fixed-height column and a long part
|
|
557
|
+
otherwise scrolls forever. Set `collapsed: true` or `collapsed: false` on a
|
|
558
|
+
section to override the rule in either direction.
|
|
559
|
+
|
|
546
560
|
---
|
|
547
561
|
|
|
548
562
|
## Designing the control panel
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// through the mount-supplied applyValues hook — the same path as a slider
|
|
6
6
|
// edit, minus the regen debounce. Returns null when the part declares no
|
|
7
7
|
// (valid) animations, so mount can wire it unconditionally.
|
|
8
|
-
import { normalizeAnimations, createPlayback } from "./animation.js";
|
|
8
|
+
import { normalizeAnimations, createPlayback, stepIndexAt } from "./animation.js";
|
|
9
9
|
import { createInfoPopover, attachInfo } from "./controls.js";
|
|
10
10
|
|
|
11
11
|
function el(tag, className, text) {
|
|
@@ -14,11 +14,14 @@ function el(tag, className, text) {
|
|
|
14
14
|
if (text != null) node.textContent = text;
|
|
15
15
|
return node;
|
|
16
16
|
}
|
|
17
|
+
function setBtnLabel(b, label) {
|
|
18
|
+
b.setAttribute("aria-label", label);
|
|
19
|
+
b.title = label;
|
|
20
|
+
}
|
|
17
21
|
function btn(className, text, label) {
|
|
18
22
|
const b = el("button", className, text);
|
|
19
23
|
b.type = "button";
|
|
20
|
-
b
|
|
21
|
-
b.title = label;
|
|
24
|
+
setBtnLabel(b, label);
|
|
22
25
|
return b;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -36,6 +39,41 @@ export function planAnimBarPlacement({ stageWidth, barWidth, viewbarLeft }, { ga
|
|
|
36
39
|
return barWidth > available ? { left, maxWidth: available } : { left };
|
|
37
40
|
}
|
|
38
41
|
|
|
42
|
+
// The scrubber's resolution: `t` is reported to the user as one of this many
|
|
43
|
+
// steps, and read back the same way.
|
|
44
|
+
export const SCRUB_STEPS = 1000;
|
|
45
|
+
|
|
46
|
+
// Snap a seek target UP onto the scrubber's grid.
|
|
47
|
+
//
|
|
48
|
+
// syncUi rounds `t` onto that grid to position the thumb, but a chapter
|
|
49
|
+
// boundary rarely lands on it: with three equal chapters, 1/3 rounds DOWN to
|
|
50
|
+
// 0.333 — which is in the chapter BEFORE the one the playhead is really in. The
|
|
51
|
+
// bar then reports a chapter it is not at, and the next arrow-key nudge reads
|
|
52
|
+
// the rounded value back and "moves" the user a chapter without the position
|
|
53
|
+
// changing. Rounding up keeps the grid value on the same side of the boundary
|
|
54
|
+
// as `t`, so the playhead and what the scrubber shows always agree.
|
|
55
|
+
//
|
|
56
|
+
// A chapter shorter than one step can't be represented at all; nothing here can
|
|
57
|
+
// fix that, and lint's minimum step duration keeps it out of reach.
|
|
58
|
+
export function snapUpToScrubGrid(t) {
|
|
59
|
+
const stepped = Math.ceil(t * SCRUB_STEPS - 1e-9) / SCRUB_STEPS;
|
|
60
|
+
return Math.min(1, Math.max(0, stepped));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// "Close enough to a chapter's start to count as being ON it." One scrubber
|
|
64
|
+
// step, for the same reason: finer than the grid is finer than anything the
|
|
65
|
+
// user can see or land on.
|
|
66
|
+
const AT_BOUNDARY = 1 / SCRUB_STEPS;
|
|
67
|
+
|
|
68
|
+
// Center-x for the chapter bubble, in px within the scrub wrap: the bubble
|
|
69
|
+
// tracks `fraction` along the timeline but never hangs past either end. A
|
|
70
|
+
// wrap narrower than the bubble has no legal band — park it in the middle.
|
|
71
|
+
export function clampBubbleX(fraction, wrapWidth, bubbleWidth) {
|
|
72
|
+
if (wrapWidth <= bubbleWidth) return wrapWidth / 2;
|
|
73
|
+
const half = bubbleWidth / 2;
|
|
74
|
+
return Math.min(Math.max(fraction * wrapWidth, half), wrapWidth - half);
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
// A setter for an element's text that MUTATES its existing text node instead of
|
|
40
78
|
// replacing it. `el.textContent = x` always replaces the node, and WebKit will
|
|
41
79
|
// not dispatch a `click` on an element whose text node was replaced between
|
|
@@ -90,30 +128,137 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
90
128
|
pick.append(o);
|
|
91
129
|
}
|
|
92
130
|
const title = el("span", "pf-anim-title", "");
|
|
93
|
-
|
|
131
|
+
// Multi-animation parts page with ‹ › at the card's outer edges — whole
|
|
132
|
+
// animations only, never chapters (chapters are the bubble + PageUp/Down).
|
|
133
|
+
const paged = animations.length > 1;
|
|
134
|
+
const prevAnimBtn = paged ? btn("pf-anim-page", "‹", "Previous animation") : null;
|
|
135
|
+
const nextAnimBtn = paged ? btn("pf-anim-page", "›", "Next animation") : null;
|
|
136
|
+
if (prevAnimBtn) bar.append(prevAnimBtn);
|
|
137
|
+
bar.append(paged ? pick : title);
|
|
94
138
|
const infoSlot = el("span", "pf-anim-info");
|
|
95
139
|
const playBtn = btn("pf-anim-play", "▶", "Play animation");
|
|
96
|
-
const prevBtn = btn("pf-anim-step-btn", "‹", "Previous step");
|
|
97
|
-
const stepLabel = el("span", "pf-anim-step", "");
|
|
98
|
-
const nextBtn = btn("pf-anim-step-btn", "›", "Next step");
|
|
99
140
|
const scrubWrap = el("span", "pf-anim-scrub-wrap");
|
|
100
141
|
const scrub = document.createElement("input");
|
|
101
142
|
scrub.type = "range";
|
|
102
|
-
scrub.min = "0"; scrub.max =
|
|
143
|
+
scrub.min = "0"; scrub.max = String(SCRUB_STEPS); scrub.step = "1"; scrub.value = "0";
|
|
103
144
|
scrub.className = "pf-anim-scrub";
|
|
104
145
|
scrub.setAttribute("aria-label", "Animation position");
|
|
105
146
|
scrubWrap.append(scrub);
|
|
106
147
|
const resetBtn = btn("pf-anim-reset", "↺", "Reset animation");
|
|
107
|
-
bar.append(infoSlot, playBtn,
|
|
148
|
+
bar.append(infoSlot, playBtn, scrubWrap, resetBtn);
|
|
149
|
+
if (nextAnimBtn) bar.append(nextAnimBtn);
|
|
108
150
|
container.append(bar);
|
|
151
|
+
// Chapter bubble: floats above the scrubber naming the chapter under the
|
|
152
|
+
// pointer (hover) or playhead (scrub).
|
|
153
|
+
//
|
|
154
|
+
// It is a child of the STAGE, not of the bar, for the same reason the ⓘ
|
|
155
|
+
// popover is a child of document.body: when the bar runs out of room it caps
|
|
156
|
+
// its width and sets overflow:hidden (see applyPlacement), and anything
|
|
157
|
+
// inside it is clipped — the bubble sits above the bar's content box, so it
|
|
158
|
+
// was clipped away entirely in exactly the narrow layouts where a shrunken
|
|
159
|
+
// timeline needs its labels most. Living on the stage puts it out of that
|
|
160
|
+
// clip, and out of the placement ResizeObserver's subtree as well.
|
|
161
|
+
//
|
|
162
|
+
// Non-interactive and aria-hidden: the accessible chapter channel is the
|
|
163
|
+
// scrubber's aria-valuetext, not this flag.
|
|
164
|
+
const chapterBubble = el("span", "pf-anim-chapter");
|
|
165
|
+
chapterBubble.setAttribute("aria-hidden", "true");
|
|
166
|
+
container.append(chapterBubble);
|
|
167
|
+
|
|
168
|
+
// A reveal is either HOVER-owned or TRANSIENT. A hover reveal lives until the
|
|
169
|
+
// pointer leaves; a transient one (keyboard jump, scrub, touch) fades itself.
|
|
170
|
+
//
|
|
171
|
+
// `hoverInside` marks the pointer as the current owner, and while it is set a
|
|
172
|
+
// transient reveal deliberately arms no fade — a drag fires pointermove then
|
|
173
|
+
// an `input` on every step, so the transient timer would otherwise be the last
|
|
174
|
+
// one set and would blank the label under a finger that never left.
|
|
175
|
+
//
|
|
176
|
+
// Every path that hides the bubble clears the latch too, so it can never
|
|
177
|
+
// outlive the reveal it guards: an animation switch hides the bubble while the
|
|
178
|
+
// pointer sits still, and a gesture the browser steals for scrolling delivers
|
|
179
|
+
// pointercancel instead of pointerleave.
|
|
180
|
+
const BUBBLE_FADE_MS = 1000;
|
|
181
|
+
let bubbleFadeTimer = 0;
|
|
182
|
+
let hoverInside = false;
|
|
183
|
+
// The label's rendered width is re-measured only when the label itself
|
|
184
|
+
// changes: showChapterBubble runs on every pointermove, and offsetWidth
|
|
185
|
+
// forces a synchronous layout. Writing through textSetter keeps the text node
|
|
186
|
+
// stable per the WebKit rule above, rather than replacing it per move.
|
|
187
|
+
const setBubbleText = textSetter(chapterBubble);
|
|
188
|
+
let bubbleLabel = null;
|
|
189
|
+
let bubbleWidth = 0;
|
|
190
|
+
function showChapterBubble(fraction, { transient = false } = {}) {
|
|
191
|
+
if (current.steps.length <= 1) return;
|
|
192
|
+
const f = Math.min(1, Math.max(0, fraction));
|
|
193
|
+
const label = current.steps[stepIndexAt(current, f)].label;
|
|
194
|
+
if (label !== bubbleLabel) {
|
|
195
|
+
bubbleLabel = label;
|
|
196
|
+
setBubbleText(label);
|
|
197
|
+
bubbleWidth = chapterBubble.offsetWidth;
|
|
198
|
+
}
|
|
199
|
+
// Stage-relative, because the bubble lives on the stage rather than in the
|
|
200
|
+
// wrap: track the point along the timeline, then lift clear of the bar —
|
|
201
|
+
// the BAR's top, not the wrap's. The touch layout wraps the bar into rows
|
|
202
|
+
// with the timeline on the lower one, so "above the wrap" would sit the
|
|
203
|
+
// bubble on the chooser row. checkTransportTargets pins this.
|
|
204
|
+
const wrapRect = scrubWrap.getBoundingClientRect();
|
|
205
|
+
const barRect = bar.getBoundingClientRect();
|
|
206
|
+
const stageRect = container.getBoundingClientRect();
|
|
207
|
+
chapterBubble.style.left =
|
|
208
|
+
`${wrapRect.left - stageRect.left + clampBubbleX(f, wrapRect.width, bubbleWidth)}px`;
|
|
209
|
+
chapterBubble.style.bottom = `${stageRect.bottom - barRect.top + 8}px`;
|
|
210
|
+
chapterBubble.classList.add("pf-show");
|
|
211
|
+
clearTimeout(bubbleFadeTimer);
|
|
212
|
+
bubbleFadeTimer = 0;
|
|
213
|
+
if (transient && !hoverInside) bubbleFadeTimer = setTimeout(hideChapterBubble, BUBBLE_FADE_MS);
|
|
214
|
+
}
|
|
215
|
+
function fadeChapterBubble() {
|
|
216
|
+
if (!chapterBubble.classList.contains("pf-show")) return;
|
|
217
|
+
clearTimeout(bubbleFadeTimer);
|
|
218
|
+
bubbleFadeTimer = setTimeout(hideChapterBubble, BUBBLE_FADE_MS);
|
|
219
|
+
}
|
|
220
|
+
function hideChapterBubble() {
|
|
221
|
+
clearTimeout(bubbleFadeTimer);
|
|
222
|
+
bubbleFadeTimer = 0;
|
|
223
|
+
hoverInside = false;
|
|
224
|
+
chapterBubble.classList.remove("pf-show");
|
|
225
|
+
}
|
|
226
|
+
const onWrapPointerMove = (e) => {
|
|
227
|
+
if (current.steps.length <= 1) return; // no chapters: no bubble, and no layout read
|
|
228
|
+
const rect = scrubWrap.getBoundingClientRect();
|
|
229
|
+
if (!rect.width) return;
|
|
230
|
+
hoverInside = true;
|
|
231
|
+
showChapterBubble((e.clientX - rect.left) / rect.width);
|
|
232
|
+
};
|
|
233
|
+
// A touch pointer's leave arrives with the finger lift, so hiding outright
|
|
234
|
+
// would blank the label the tap just asked for — and touch has no hover to
|
|
235
|
+
// read it with afterwards. Let it fade like a keyboard reveal instead. A
|
|
236
|
+
// mouse leaving still hides at once: the pointer moving away IS the dismissal.
|
|
237
|
+
const onWrapPointerLeave = (e) => {
|
|
238
|
+
hoverInside = false;
|
|
239
|
+
if (e?.pointerType === "touch") fadeChapterBubble();
|
|
240
|
+
else hideChapterBubble();
|
|
241
|
+
};
|
|
242
|
+
scrubWrap.addEventListener("pointermove", onWrapPointerMove);
|
|
243
|
+
scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
|
|
244
|
+
scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
|
|
109
245
|
|
|
110
|
-
// Per-animation chrome: title, ⓘ description,
|
|
246
|
+
// Per-animation chrome: title, ⓘ description, pager labels, scrubber ticks.
|
|
111
247
|
function syncStructure() {
|
|
112
248
|
title.textContent = current.label;
|
|
249
|
+
hideChapterBubble();
|
|
250
|
+
if (paged) {
|
|
251
|
+
// Name the destination. Activating a pager keeps focus on it and leaves
|
|
252
|
+
// its glyph unchanged, so without this a screen reader re-announces the
|
|
253
|
+
// same generic "Next animation" and never says what is now selected.
|
|
254
|
+
const i = animations.indexOf(current);
|
|
255
|
+
const at = (d) => animations[(i + d + animations.length) % animations.length].label;
|
|
256
|
+
setBtnLabel(prevAnimBtn, `Previous animation: ${at(-1)}`);
|
|
257
|
+
setBtnLabel(nextAnimBtn, `Next animation: ${at(1)}`);
|
|
258
|
+
}
|
|
113
259
|
infoSlot.replaceChildren();
|
|
114
260
|
attachInfo(infoSlot, current.description ?? "", info);
|
|
115
261
|
const stepped = current.steps.length > 1;
|
|
116
|
-
prevBtn.hidden = nextBtn.hidden = stepLabel.hidden = !stepped;
|
|
117
262
|
for (const n of scrubWrap.querySelectorAll(".pf-anim-tick")) n.remove();
|
|
118
263
|
if (stepped) {
|
|
119
264
|
for (const t of current.stepStarts.slice(1)) {
|
|
@@ -130,12 +275,13 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
130
275
|
// 1. Text goes through textSetter (see above), so the button's text node is
|
|
131
276
|
// mutated and never replaced. This is the one that matters, because it
|
|
132
277
|
// holds even when the glyph legitimately changes under a held finger —
|
|
133
|
-
// playback ending
|
|
278
|
+
// playback ending mid-press.
|
|
134
279
|
// 2. Each element has its own renderer that leaves early when its own value
|
|
135
|
-
// is unchanged, so a playing transport
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
280
|
+
// is unchanged, so a playing transport touches only what actually moved:
|
|
281
|
+
// the scrubber's value, and aria-valuetext when it crosses a reporting
|
|
282
|
+
// step. Per ELEMENT, not per write: driving several elements off one
|
|
283
|
+
// shared state key means a step change also redraws the button, which was
|
|
284
|
+
// still enough to lose the click before rule 1 was in place.
|
|
139
285
|
//
|
|
140
286
|
// Without rule 1 this cost the pause click outright: press, release, no click
|
|
141
287
|
// event at all, and only ever while playing — the one moment the button is
|
|
@@ -143,9 +289,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
143
289
|
// ~100ms; a synthetic 0ms one survives, which is why automated clicking never
|
|
144
290
|
// saw it). Reset was never affected: nothing rewrites that button per frame.
|
|
145
291
|
const setPlayGlyph = textSetter(playBtn);
|
|
146
|
-
const setStepText = textSetter(stepLabel);
|
|
147
292
|
let shownActive = null;
|
|
148
|
-
let shownStep = null;
|
|
149
293
|
|
|
150
294
|
function renderPlayButton(active) {
|
|
151
295
|
if (active === shownActive) return;
|
|
@@ -156,26 +300,42 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
156
300
|
playBtn.title = label;
|
|
157
301
|
}
|
|
158
302
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
303
|
+
// aria-valuetext is the accessible chapter channel — the bubble is aria-hidden,
|
|
304
|
+
// so this is the only place the chapter name reaches assistive tech. An
|
|
305
|
+
// attribute write never eats clicks (see the WebKit note above), but a screen
|
|
306
|
+
// reader announces every CHANGE, and during playback the position moves on its
|
|
307
|
+
// own: an exact percentage would chatter ~100 times a run with the scrubber
|
|
308
|
+
// focused. So while playing the percentage is reported in 10% steps. A
|
|
309
|
+
// user-driven seek reports the exact position, which is when precision is the
|
|
310
|
+
// feedback the user asked for.
|
|
311
|
+
let shownValuetext = null;
|
|
312
|
+
function renderValuetext(t, stepIndex, playing) {
|
|
313
|
+
const pct = Math.round(t * 100);
|
|
314
|
+
const shown = playing ? Math.round(pct / 10) * 10 : pct;
|
|
315
|
+
const text = current.steps.length > 1
|
|
316
|
+
? `${current.steps[stepIndex].label} — ${shown}%`
|
|
317
|
+
: `${shown}%`;
|
|
318
|
+
if (text === shownValuetext) return;
|
|
319
|
+
shownValuetext = text;
|
|
320
|
+
scrub.setAttribute("aria-valuetext", text);
|
|
164
321
|
}
|
|
165
322
|
|
|
166
323
|
function syncUi() {
|
|
167
324
|
const { status, t, stepIndex } = playback.state();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
renderPlayButton(status === "playing" || status === "intro");
|
|
173
|
-
renderStepLabel(stepIndex);
|
|
325
|
+
const playing = status === "playing" || status === "intro";
|
|
326
|
+
scrub.value = String(Math.round(t * SCRUB_STEPS));
|
|
327
|
+
renderPlayButton(playing);
|
|
328
|
+
renderValuetext(t, stepIndex, playing);
|
|
174
329
|
}
|
|
175
330
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
331
|
+
// Belt-and-braces for the animation swap. Both caches above key on the exact
|
|
332
|
+
// value written to the DOM, so today a stale one can only ever agree with what
|
|
333
|
+
// is already on screen — this reset is currently redundant, and no test can
|
|
334
|
+
// pin it. It is kept because the cache it replaced (the old step readout) keyed
|
|
335
|
+
// on a step INDEX, which genuinely collided across animations at index 0: any
|
|
336
|
+
// future renderer keyed on a proxy rather than on its rendered value needs
|
|
337
|
+
// this hook to already exist.
|
|
338
|
+
function invalidateUi() { shownActive = null; shownValuetext = null; }
|
|
179
339
|
|
|
180
340
|
// --- driver -----------------------------------------------------------------
|
|
181
341
|
// A frame that throws — a malformed cue or track that slipped past lint, a
|
|
@@ -234,8 +394,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
234
394
|
playback = createPlayback(current);
|
|
235
395
|
if (animations.length > 1) pick.value = name;
|
|
236
396
|
syncStructure();
|
|
237
|
-
invalidateUi();
|
|
238
|
-
// outgoing one may have been showing, but for another animation
|
|
397
|
+
invalidateUi();
|
|
239
398
|
syncUi();
|
|
240
399
|
}
|
|
241
400
|
|
|
@@ -259,15 +418,61 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
259
418
|
guarded(() => playback.play());
|
|
260
419
|
}
|
|
261
420
|
};
|
|
262
|
-
const onScrub = () => {
|
|
263
|
-
|
|
264
|
-
|
|
421
|
+
const onScrub = () => {
|
|
422
|
+
disarmAutoplay();
|
|
423
|
+
const f = Number(scrub.value) / SCRUB_STEPS;
|
|
424
|
+
showChapterBubble(f, { transient: true });
|
|
425
|
+
guarded(() => playback.seek(f));
|
|
426
|
+
};
|
|
427
|
+
// PageUp/PageDown jump whole chapters — the keyboard replacement for the
|
|
428
|
+
// removed step buttons. PageUp goes FORWARD, matching the key's native
|
|
429
|
+
// slider direction (it increases the value). Single-step animations keep
|
|
430
|
+
// the browser's native coarse seek instead.
|
|
431
|
+
//
|
|
432
|
+
// Targets are derived from the chapter the playhead is IN rather than by
|
|
433
|
+
// scanning stepStarts for the nearest boundary — same answers (verified
|
|
434
|
+
// equivalent across the whole 0..1 range), but it reads as the rule it
|
|
435
|
+
// implements and allocates nothing per keypress.
|
|
436
|
+
//
|
|
437
|
+
// AT_BOUNDARY decides "am I at this chapter's start or inside it", which is
|
|
438
|
+
// what makes PageDown step back rather than restart. It is one scrubber step
|
|
439
|
+
// because that is the finest position the user can see or reach: a tolerance
|
|
440
|
+
// finer than the grid would call a playhead that just landed on a boundary
|
|
441
|
+
// "inside" the chapter, and PageDown would restart it forever instead of
|
|
442
|
+
// walking back.
|
|
443
|
+
const onScrubKeydown = (e) => {
|
|
444
|
+
if (current.steps.length <= 1) return;
|
|
445
|
+
if (e.key !== "PageUp" && e.key !== "PageDown") return;
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
disarmAutoplay();
|
|
448
|
+
const { t } = playback.state();
|
|
449
|
+
const starts = current.stepStarts;
|
|
450
|
+
const i = stepIndexAt(current, t);
|
|
451
|
+
const target = snapUpToScrubGrid(e.key === "PageUp"
|
|
452
|
+
? (starts[i + 1] ?? 1)
|
|
453
|
+
// Inside a chapter, back up to its own start (restart it); already at the
|
|
454
|
+
// start, step to the chapter before — the video-player convention.
|
|
455
|
+
: (t > starts[i] + AT_BOUNDARY ? starts[i] : (starts[i - 1] ?? 0)));
|
|
456
|
+
// seek() abandons a pending cue but cannot touch the viewer's camera, so an
|
|
457
|
+
// in-flight tween would keep travelling to the position we just left.
|
|
458
|
+
viewer.cancelCameraTween();
|
|
459
|
+
showChapterBubble(target, { transient: true });
|
|
460
|
+
guarded(() => playback.seek(target));
|
|
461
|
+
};
|
|
462
|
+
const cycleAnimation = (dir) => {
|
|
463
|
+
disarmAutoplay();
|
|
464
|
+
const i = animations.indexOf(current);
|
|
465
|
+
selectAnimation(animations[(i + dir + animations.length) % animations.length].name);
|
|
466
|
+
};
|
|
467
|
+
const onPrevAnim = () => cycleAnimation(-1);
|
|
468
|
+
const onNextAnim = () => cycleAnimation(1);
|
|
265
469
|
const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
|
|
266
470
|
const onResetClick = () => { disarmAutoplay(); doReset(); };
|
|
471
|
+
prevAnimBtn?.addEventListener("click", onPrevAnim);
|
|
472
|
+
nextAnimBtn?.addEventListener("click", onNextAnim);
|
|
267
473
|
playBtn.addEventListener("click", onPlayClick);
|
|
268
474
|
scrub.addEventListener("input", onScrub);
|
|
269
|
-
|
|
270
|
-
nextBtn.addEventListener("click", onNext);
|
|
475
|
+
scrub.addEventListener("keydown", onScrubKeydown);
|
|
271
476
|
pick.addEventListener("change", onPick);
|
|
272
477
|
resetBtn.addEventListener("click", onResetClick);
|
|
273
478
|
|
|
@@ -295,6 +500,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
295
500
|
bar.style.transform = "";
|
|
296
501
|
bar.style.maxWidth = "";
|
|
297
502
|
bar.style.overflow = "";
|
|
503
|
+
bar.classList.remove("pf-squeezed");
|
|
298
504
|
const vb = viewbarEl?.getBoundingClientRect();
|
|
299
505
|
const barRect = bar.getBoundingClientRect();
|
|
300
506
|
if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
|
|
@@ -308,22 +514,31 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
308
514
|
bar.style.left = `${plan.left}px`;
|
|
309
515
|
bar.style.transform = "none";
|
|
310
516
|
// maxWidth is the last resort, only reached when even the margin can't
|
|
311
|
-
// hold the gap — and the bar's flex children have hard minimums
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
517
|
+
// hold the gap — and the bar's flex children have hard minimums that don't
|
|
518
|
+
// shrink to fit a tighter cap. overflow:hidden is applied here, inline,
|
|
519
|
+
// rather than as a static rule so it is scoped to the capped state and the
|
|
520
|
+
// (far more common) uncapped bar never clips anything.
|
|
521
|
+
//
|
|
522
|
+
// Nothing that floats above the bar may live inside it, or this clips it:
|
|
523
|
+
// the ⓘ popover is on document.body and the chapter bubble is on the stage,
|
|
524
|
+
// both for that reason.
|
|
315
525
|
if (plan.maxWidth != null) {
|
|
316
526
|
bar.style.maxWidth = `${plan.maxWidth}px`;
|
|
317
527
|
bar.style.overflow = "hidden";
|
|
528
|
+
// Under the cap the bar sheds the pagers (~40px with their gaps). They are
|
|
529
|
+
// pure convenience — the picker beside them reaches every animation — and
|
|
530
|
+
// spending that width on the timeline is what keeps the scrubber
|
|
531
|
+
// targetable instead of letting it collapse toward nothing.
|
|
532
|
+
bar.classList.add("pf-squeezed");
|
|
318
533
|
}
|
|
319
534
|
}
|
|
320
535
|
function schedulePlacement() {
|
|
321
536
|
if (typeof requestAnimationFrame !== "function") return applyPlacement();
|
|
322
537
|
if (!placementRaf) placementRaf = requestAnimationFrame(applyPlacement);
|
|
323
538
|
}
|
|
324
|
-
// Observing the bar itself catches content-driven width changes
|
|
325
|
-
//
|
|
326
|
-
// drags and window resizes.
|
|
539
|
+
// Observing the bar itself catches content-driven width changes — the ⓘ glyph
|
|
540
|
+
// appearing or disappearing when only some animations declare a description;
|
|
541
|
+
// the viewbar, cutaway's actions; the stage, rail drags and window resizes.
|
|
327
542
|
const placementObserver = typeof ResizeObserver === "function"
|
|
328
543
|
? new ResizeObserver(schedulePlacement) : null;
|
|
329
544
|
if (placementObserver) {
|
|
@@ -372,15 +587,21 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
372
587
|
detach() {
|
|
373
588
|
offFrame();
|
|
374
589
|
offOrbit();
|
|
590
|
+
prevAnimBtn?.removeEventListener("click", onPrevAnim);
|
|
591
|
+
nextAnimBtn?.removeEventListener("click", onNextAnim);
|
|
375
592
|
playBtn.removeEventListener("click", onPlayClick);
|
|
376
593
|
scrub.removeEventListener("input", onScrub);
|
|
377
|
-
|
|
378
|
-
nextBtn.removeEventListener("click", onNext);
|
|
594
|
+
scrub.removeEventListener("keydown", onScrubKeydown);
|
|
379
595
|
pick.removeEventListener("change", onPick);
|
|
380
596
|
resetBtn.removeEventListener("click", onResetClick);
|
|
381
597
|
info.dispose();
|
|
382
598
|
placementObserver?.disconnect();
|
|
383
599
|
if (placementRaf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(placementRaf);
|
|
600
|
+
scrubWrap.removeEventListener("pointermove", onWrapPointerMove);
|
|
601
|
+
scrubWrap.removeEventListener("pointerleave", onWrapPointerLeave);
|
|
602
|
+
scrubWrap.removeEventListener("pointercancel", onWrapPointerLeave);
|
|
603
|
+
hideChapterBubble(); // also clears hoverInside
|
|
604
|
+
chapterBubble.remove(); // a stage child, so the bar taking itself out misses it
|
|
384
605
|
bar.remove();
|
|
385
606
|
},
|
|
386
607
|
__viewer: viewer, // test hook only
|
package/src/framework/app.css
CHANGED
|
@@ -64,10 +64,19 @@ canvas { display: block; }
|
|
|
64
64
|
.section:not(.section-hidden) ~ .section:not(.section-hidden) {
|
|
65
65
|
border-top: 1px solid var(--pf-border);
|
|
66
66
|
}
|
|
67
|
+
.sec-header { display: flex; align-items: center; gap: 4px; }
|
|
67
68
|
.sec-title {
|
|
69
|
+
flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
70
|
+
width: 100%; margin: 0 0 9px; padding: 0; border: 0; background: transparent; cursor: pointer;
|
|
71
|
+
text-align: left;
|
|
68
72
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
69
|
-
letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2);
|
|
73
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2);
|
|
70
74
|
}
|
|
75
|
+
.sec-title:hover { color: var(--pf-text-2); }
|
|
76
|
+
.sec-title .chev { display: inline-block; transition: transform 0.15s ease; }
|
|
77
|
+
.sec-title .chev::before { content: "▾"; }
|
|
78
|
+
.sec-title[aria-expanded="false"] .chev { transform: rotate(-90deg); }
|
|
79
|
+
.sec-body.hidden { display: none; }
|
|
71
80
|
select.preset {
|
|
72
81
|
width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
|
|
73
82
|
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px;
|
|
@@ -159,11 +168,20 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
159
168
|
|
|
160
169
|
/* keyboard focus ring shared across the panel's interactive controls */
|
|
161
170
|
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
162
|
-
button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
|
|
171
|
+
button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible {
|
|
163
172
|
outline: none;
|
|
164
173
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
/* `when` disabling. Distinct from .irrelevant (relevance dimming) on purpose:
|
|
177
|
+
two mechanisms that look identical make the panel impossible to reason about. */
|
|
178
|
+
.disabled { opacity: 0.5; pointer-events: none; }
|
|
179
|
+
|
|
180
|
+
/* The wrapper a titled inner group renders into. Conditions hide the wrapper;
|
|
181
|
+
the disclosure hides `.adv` inside it. Unused until `when` becomes authorable
|
|
182
|
+
in phase 5, but the class exists from Task 7 and must have a rule. */
|
|
183
|
+
.adv-wrap.hidden { display: none; }
|
|
184
|
+
|
|
167
185
|
/* part tabs (placement: .pf-float-tabs; legacy markup keeps the old float) */
|
|
168
186
|
#topbar:not(.pf-float-tabs) { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 15; }
|
|
169
187
|
#topbar .seg {
|
|
@@ -239,24 +257,108 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
239
257
|
.pf-anim-pick {
|
|
240
258
|
background: var(--pf-input-bg); border: 1px solid var(--pf-border);
|
|
241
259
|
border-radius: var(--pf-radius-control); padding: 3px 6px;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
260
|
+
/* The widest thing in the bar after the timeline, and the cheapest to give
|
|
261
|
+
up: a select's default `min-width: auto` floors it at its longest option,
|
|
262
|
+
so under pressure it would hold full width while the scrubber collapsed.
|
|
263
|
+
Let it yield first, and faster than everything else — but not to a bare
|
|
264
|
+
chevron: below this the control stops naming the animation it selects. */
|
|
265
|
+
min-width: 44px; flex-shrink: 4;
|
|
266
|
+
}
|
|
267
|
+
/* A child of the stage, not of the bar (the capped bar clips its contents, and
|
|
268
|
+
this sits above them) — animation-controls.js sets left/bottom per reveal,
|
|
269
|
+
stage-relative. z-index clears .pf-anim-bar's 15. */
|
|
270
|
+
.pf-anim-chapter {
|
|
271
|
+
position: absolute; transform: translateX(-50%); z-index: 16;
|
|
272
|
+
font-family: var(--pf-mono); font-size: 10px; color: var(--pf-text-2);
|
|
273
|
+
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
274
|
+
border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
|
|
275
|
+
padding: 3px 8px; white-space: nowrap;
|
|
276
|
+
pointer-events: none;
|
|
277
|
+
/* Out of flow, so nothing else constrains it: a long chapter label would
|
|
278
|
+
otherwise run past both ends of the scrubber and off a narrow stage.
|
|
279
|
+
Capped at the wrap's nominal width and ellipsised instead. */
|
|
280
|
+
max-width: 220px; overflow: hidden; text-overflow: ellipsis;
|
|
281
|
+
opacity: 0; transition: opacity .12s ease;
|
|
282
|
+
}
|
|
283
|
+
.pf-anim-chapter.pf-show { opacity: 1; }
|
|
284
|
+
/* 220px when there is room. The floor is what matters under pressure: a
|
|
285
|
+
timeline is a pointing target, and below ~96px it stops being one — the
|
|
286
|
+
chapter ticks collapse onto each other and a drag can't resolve a chapter.
|
|
287
|
+
Everything else in the bar yields before this does. */
|
|
288
|
+
.pf-anim-scrub-wrap {
|
|
289
|
+
position: relative; display: inline-flex; align-items: center;
|
|
290
|
+
width: 220px; min-width: 80px;
|
|
291
|
+
}
|
|
292
|
+
/* Capped bar: the pagers go, and their width goes to the timeline. */
|
|
293
|
+
.pf-anim-bar.pf-squeezed .pf-anim-page { display: none; }
|
|
249
294
|
.pf-anim-scrub { width: 100%; accent-color: var(--pf-accent); }
|
|
250
295
|
.pf-anim-tick {
|
|
251
296
|
position: absolute; top: 50%; width: 2px; height: 8px; margin-top: -4px;
|
|
252
297
|
background: var(--pf-muted); pointer-events: none;
|
|
253
298
|
}
|
|
254
299
|
|
|
300
|
+
@media (prefers-reduced-motion: reduce) {
|
|
301
|
+
.pf-anim-chapter { transition: none; }
|
|
302
|
+
}
|
|
303
|
+
|
|
255
304
|
@media (max-width: 360px) {
|
|
256
305
|
/* #viewbar's card is 40px here (30px buttons) — keep the floor in step. */
|
|
257
306
|
.pf-anim-bar { min-height: 40px; }
|
|
258
307
|
}
|
|
259
308
|
|
|
309
|
+
/* ---- transport bar, touch layout ----------------------------------------
|
|
310
|
+
The rules above size the BAR; these size the things you press inside it. At
|
|
311
|
+
a 13px glyph in 2px/4px of padding, play, reset and the ‹ › pagers measure
|
|
312
|
+
~20x20, with 8px between neighbours — mouse targets. Measured on a 390px
|
|
313
|
+
stage: a tap 12px off the pause button's centre, ordinary finger error,
|
|
314
|
+
landed on the bar's background and did nothing; a little further landed on
|
|
315
|
+
a pager, which SWITCHES ANIMATION. The scrubber fared worse, flex-shrinking
|
|
316
|
+
toward nothing.
|
|
317
|
+
|
|
318
|
+
So give every control the 44px minimum, and accept the rows that costs: at
|
|
319
|
+
44px per target they do not fit a 320-390px stage on one line.
|
|
320
|
+
|
|
321
|
+
Both query conditions matter. The width half is the framework's own narrow
|
|
322
|
+
layout (chrome.css's 720px breakpoint, measured on the STAGE — partforge in
|
|
323
|
+
an iframe sized to a phone-width card gets it too); the pointer half catches
|
|
324
|
+
a phone in landscape, wider than 720px and still all thumbs.
|
|
325
|
+
|
|
326
|
+
The bar stays a single flat flex line that WRAPS, in source order — no
|
|
327
|
+
`order` overrides, deliberately: a reordered flex row splits visual order
|
|
328
|
+
from DOM order, and with it from focus order, so a keyboard on a narrow
|
|
329
|
+
window would tab in spatial zigzags. Wrapping in source order keeps
|
|
330
|
+
reading, tab and touch order one sequence, and keeps the pagers bracketing
|
|
331
|
+
the bar's ends like the wide layout:
|
|
332
|
+
|
|
333
|
+
‹ [ animation v ] (i) ▶ <- wraps where the width runs out
|
|
334
|
+
[===·===·==o===] ↺ ›
|
|
335
|
+
|
|
336
|
+
The timeline takes whatever width its row has left and drops to a wider
|
|
337
|
+
row of its own below ~360px. scripts/check-app.mjs's checkTransportTargets
|
|
338
|
+
pins the result. */
|
|
339
|
+
@media (max-width: 719px), (pointer: coarse) {
|
|
340
|
+
.pf-anim-bar {
|
|
341
|
+
flex-wrap: wrap; justify-content: center;
|
|
342
|
+
column-gap: 4px; row-gap: 2px; padding: 4px 8px;
|
|
343
|
+
}
|
|
344
|
+
.pf-anim-bar button {
|
|
345
|
+
min-width: 44px; min-height: 44px; padding: 0;
|
|
346
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
347
|
+
}
|
|
348
|
+
.pf-anim-title, .pf-anim-pick { min-width: 0; }
|
|
349
|
+
/* The picker is a target too, not a caption — it opens the animation list. */
|
|
350
|
+
.pf-anim-pick { max-width: 100%; min-height: 44px; padding: 3px 10px; }
|
|
351
|
+
/* The 200px basis makes the timeline BID for real room: at a phone width it
|
|
352
|
+
loses the bid, wraps, and inherits most of a row; 120px is the floor it
|
|
353
|
+
can be squeezed to when sharing one. */
|
|
354
|
+
.pf-anim-scrub-wrap { flex: 1 1 200px; min-width: 120px; width: auto; min-height: 44px; }
|
|
355
|
+
/* The INPUT is the thing a finger has to land on — height on the wrap alone
|
|
356
|
+
leaves an ~18px native strip as the real target, with dead slack around
|
|
357
|
+
it. The track paints centered, so this is invisible; only the hit area
|
|
358
|
+
grows. */
|
|
359
|
+
.pf-anim-scrub { min-height: 44px; }
|
|
360
|
+
}
|
|
361
|
+
|
|
260
362
|
/* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
|
|
261
363
|
(bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
|
|
262
364
|
top-left floating #panel card. Legacy markup still floats #viewbar top-right
|