partforge 0.46.2 → 0.46.4
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/package.json
CHANGED
|
@@ -22,6 +22,38 @@ function btn(className, text, label) {
|
|
|
22
22
|
return b;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Where the transport bar may sit, given the stage width, the bar's natural
|
|
26
|
+
// width, and the viewbar's left edge (all px, viewbarLeft stage-relative).
|
|
27
|
+
// null → the CSS default (centered) already clears the viewbar. Otherwise
|
|
28
|
+
// inline overrides: `left` slides the bar toward the stage's `margin`, and
|
|
29
|
+
// when even that isn't enough, `maxWidth` caps the bar so the `gap` holds.
|
|
30
|
+
export function planAnimBarPlacement({ stageWidth, barWidth, viewbarLeft }, { gap = 10, margin = 12 } = {}) {
|
|
31
|
+
const centeredLeft = (stageWidth - barWidth) / 2;
|
|
32
|
+
const limit = viewbarLeft - gap - barWidth;
|
|
33
|
+
if (centeredLeft <= limit) return null;
|
|
34
|
+
const left = Math.max(margin, limit);
|
|
35
|
+
const available = Math.max(0, viewbarLeft - gap - margin);
|
|
36
|
+
return barWidth > available ? { left, maxWidth: available } : { left };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A setter for an element's text that MUTATES its existing text node instead of
|
|
40
|
+
// replacing it. `el.textContent = x` always replaces the node, and WebKit will
|
|
41
|
+
// not dispatch a `click` on an element whose text node was replaced between
|
|
42
|
+
// mousedown and mouseup — so a label that can change while the user is pressing
|
|
43
|
+
// it must be written this way or the press is silently swallowed. Isolated in
|
|
44
|
+
// WebKit on a 120ms press: `textContent = <identical string>` loses the click,
|
|
45
|
+
// `node.data = <same>` never does, and neither does an attribute write.
|
|
46
|
+
//
|
|
47
|
+
// This is what makes the transport's glyph safe even when it legitimately flips
|
|
48
|
+
// mid-press (playback ending under the user's finger), which no amount of
|
|
49
|
+
// skipping redundant writes can cover.
|
|
50
|
+
function textSetter(element) {
|
|
51
|
+
const node = element.firstChild?.nodeType === 3
|
|
52
|
+
? element.firstChild
|
|
53
|
+
: element.appendChild(document.createTextNode(""));
|
|
54
|
+
return (value) => { if (node.data !== value) node.data = value; };
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues }) {
|
|
26
58
|
// A malformed animations block must degrade to "no transport bar", never a
|
|
27
59
|
// crashed mount — lint reports the specifics; the viewer just goes without.
|
|
@@ -92,19 +124,59 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
92
124
|
}
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
// syncUi() runs on every playback frame. Two rules keep that from eating the
|
|
128
|
+
// user's clicks, and they are independent:
|
|
129
|
+
//
|
|
130
|
+
// 1. Text goes through textSetter (see above), so the button's text node is
|
|
131
|
+
// mutated and never replaced. This is the one that matters, because it
|
|
132
|
+
// holds even when the glyph legitimately changes under a held finger —
|
|
133
|
+
// playback ending, or a step boundary crossing, mid-press.
|
|
134
|
+
// 2. Each element has its own renderer that leaves early when its own value
|
|
135
|
+
// is unchanged, so a playing transport does no DOM work per frame at all.
|
|
136
|
+
// Per ELEMENT, not per write: driving several elements off one shared
|
|
137
|
+
// state key means a step change also redraws the button, which was still
|
|
138
|
+
// enough to lose the click before rule 1 was in place.
|
|
139
|
+
//
|
|
140
|
+
// Without rule 1 this cost the pause click outright: press, release, no click
|
|
141
|
+
// event at all, and only ever while playing — the one moment the button is
|
|
142
|
+
// for. Measured in WebKit, any press held >= 40ms lost it (a real click is
|
|
143
|
+
// ~100ms; a synthetic 0ms one survives, which is why automated clicking never
|
|
144
|
+
// saw it). Reset was never affected: nothing rewrites that button per frame.
|
|
145
|
+
const setPlayGlyph = textSetter(playBtn);
|
|
146
|
+
const setStepText = textSetter(stepLabel);
|
|
147
|
+
let shownActive = null;
|
|
148
|
+
let shownStep = null;
|
|
149
|
+
|
|
150
|
+
function renderPlayButton(active) {
|
|
151
|
+
if (active === shownActive) return;
|
|
152
|
+
shownActive = active;
|
|
153
|
+
setPlayGlyph(active ? "⏸" : "▶");
|
|
154
|
+
const label = active ? "Pause animation" : "Play animation";
|
|
155
|
+
playBtn.setAttribute("aria-label", label);
|
|
156
|
+
playBtn.title = label;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderStepLabel(stepIndex) {
|
|
160
|
+
if (current.steps.length <= 1 || stepIndex === shownStep) return;
|
|
161
|
+
shownStep = stepIndex;
|
|
162
|
+
const step = current.steps[stepIndex];
|
|
163
|
+
setStepText(`${stepIndex + 1}/${current.steps.length} · ${step.label}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
95
166
|
function syncUi() {
|
|
96
167
|
const { status, t, stepIndex } = playback.state();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
playBtn.title = playBtn.getAttribute("aria-label");
|
|
168
|
+
// The scrubber is the one thing that genuinely moves every frame. Assigning
|
|
169
|
+
// `.value` updates a property rather than replacing a child node, so it
|
|
170
|
+
// costs no clicks — and it is not what anyone reaches for mid-playback.
|
|
101
171
|
scrub.value = String(Math.round(t * 1000));
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
stepLabel.textContent = `${stepIndex + 1}/${current.steps.length} · ${step.label}`;
|
|
105
|
-
}
|
|
172
|
+
renderPlayButton(status === "playing" || status === "intro");
|
|
173
|
+
renderStepLabel(stepIndex);
|
|
106
174
|
}
|
|
107
175
|
|
|
176
|
+
// selectAnimation swaps in a fresh playback whose state can coincide with the
|
|
177
|
+
// outgoing one; the chrome still has to re-render for the new animation.
|
|
178
|
+
function invalidateUi() { shownActive = null; shownStep = null; }
|
|
179
|
+
|
|
108
180
|
// --- driver -----------------------------------------------------------------
|
|
109
181
|
// A frame that throws — a malformed cue or track that slipped past lint, a
|
|
110
182
|
// viewer that rejects a view name — must cost that frame, not the render
|
|
@@ -162,6 +234,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
162
234
|
playback = createPlayback(current);
|
|
163
235
|
if (animations.length > 1) pick.value = name;
|
|
164
236
|
syncStructure();
|
|
237
|
+
invalidateUi(); // a fresh playback starts idle at step 0 — the same key the
|
|
238
|
+
// outgoing one may have been showing, but for another animation
|
|
165
239
|
syncUi();
|
|
166
240
|
}
|
|
167
241
|
|
|
@@ -200,6 +274,65 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
200
274
|
syncStructure();
|
|
201
275
|
syncUi();
|
|
202
276
|
|
|
277
|
+
// --- placement: keep clear of the viewbar ---------------------------------
|
|
278
|
+
// chrome.css centers the bar (left: 50% / translateX(-50%)), and nothing in
|
|
279
|
+
// CSS can stop that centered position sliding under #viewbar when the stage
|
|
280
|
+
// narrows — the viewbar's width is dynamic (cutaway's Flip/Reset appear and
|
|
281
|
+
// disappear), so a static reservation would either overlap or waste centre
|
|
282
|
+
// space. Measure instead: when the two bars' vertical bands intersect, clamp
|
|
283
|
+
// the bar's left so a 10px gap to the viewbar holds, capping its width if
|
|
284
|
+
// even the stage's 12px margin isn't enough. Overrides are inline and
|
|
285
|
+
// cleared at the top of every pass, so chrome.css (or a host that
|
|
286
|
+
// re-anchors either bar out of the shared band) stays authoritative the
|
|
287
|
+
// moment the constraint stops binding. The clear-measure-apply sequence is
|
|
288
|
+
// loop-safe: it settles within one frame, so ResizeObserver — which reports
|
|
289
|
+
// rendered sizes at frame boundaries — never sees the intermediate state.
|
|
290
|
+
const viewbarEl = container.querySelector("#viewbar");
|
|
291
|
+
let placementRaf = 0;
|
|
292
|
+
function applyPlacement() {
|
|
293
|
+
placementRaf = 0;
|
|
294
|
+
bar.style.left = "";
|
|
295
|
+
bar.style.transform = "";
|
|
296
|
+
bar.style.maxWidth = "";
|
|
297
|
+
bar.style.overflow = "";
|
|
298
|
+
const vb = viewbarEl?.getBoundingClientRect();
|
|
299
|
+
const barRect = bar.getBoundingClientRect();
|
|
300
|
+
if (!vb || barRect.top >= vb.bottom || barRect.bottom <= vb.top) return;
|
|
301
|
+
const stageRect = container.getBoundingClientRect();
|
|
302
|
+
const plan = planAnimBarPlacement({
|
|
303
|
+
stageWidth: stageRect.width,
|
|
304
|
+
barWidth: barRect.width,
|
|
305
|
+
viewbarLeft: vb.left - stageRect.left,
|
|
306
|
+
});
|
|
307
|
+
if (!plan) return;
|
|
308
|
+
bar.style.left = `${plan.left}px`;
|
|
309
|
+
bar.style.transform = "none";
|
|
310
|
+
// 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 (~320px)
|
|
312
|
+
// that don't shrink to fit a tighter cap. overflow:hidden only applies
|
|
313
|
+
// here, inline, because a static rule in app.css would also clip the ⓘ
|
|
314
|
+
// info popover in the (far more common) uncapped state.
|
|
315
|
+
if (plan.maxWidth != null) {
|
|
316
|
+
bar.style.maxWidth = `${plan.maxWidth}px`;
|
|
317
|
+
bar.style.overflow = "hidden";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function schedulePlacement() {
|
|
321
|
+
if (typeof requestAnimationFrame !== "function") return applyPlacement();
|
|
322
|
+
if (!placementRaf) placementRaf = requestAnimationFrame(applyPlacement);
|
|
323
|
+
}
|
|
324
|
+
// Observing the bar itself catches content-driven width changes (step label
|
|
325
|
+
// text, animation switch); the viewbar, cutaway's actions; the stage, rail
|
|
326
|
+
// drags and window resizes.
|
|
327
|
+
const placementObserver = typeof ResizeObserver === "function"
|
|
328
|
+
? new ResizeObserver(schedulePlacement) : null;
|
|
329
|
+
if (placementObserver) {
|
|
330
|
+
placementObserver.observe(container);
|
|
331
|
+
placementObserver.observe(bar);
|
|
332
|
+
if (viewbarEl) placementObserver.observe(viewbarEl);
|
|
333
|
+
}
|
|
334
|
+
schedulePlacement();
|
|
335
|
+
|
|
203
336
|
const runtime = {
|
|
204
337
|
// An unknown name is a host bug, not a request to play whatever happens to
|
|
205
338
|
// be selected — say so and do nothing rather than silently animating
|
|
@@ -246,6 +379,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
246
379
|
pick.removeEventListener("change", onPick);
|
|
247
380
|
resetBtn.removeEventListener("click", onResetClick);
|
|
248
381
|
info.dispose();
|
|
382
|
+
placementObserver?.disconnect();
|
|
383
|
+
if (placementRaf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(placementRaf);
|
|
249
384
|
bar.remove();
|
|
250
385
|
},
|
|
251
386
|
__viewer: viewer, // test hook only
|
package/src/framework/app.css
CHANGED
|
@@ -222,6 +222,9 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
222
222
|
.pf-anim-bar {
|
|
223
223
|
display: flex; align-items: center; gap: 8px;
|
|
224
224
|
padding: 6px 10px;
|
|
225
|
+
/* Never shorter than #viewbar's card sharing the same bottom edge:
|
|
226
|
+
34px button + 2×4px padding + 2×1px border (border-box). */
|
|
227
|
+
min-height: 44px;
|
|
225
228
|
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
226
229
|
border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
|
|
227
230
|
}
|
|
@@ -249,6 +252,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
249
252
|
background: var(--pf-muted); pointer-events: none;
|
|
250
253
|
}
|
|
251
254
|
|
|
255
|
+
@media (max-width: 360px) {
|
|
256
|
+
/* #viewbar's card is 40px here (30px buttons) — keep the floor in step. */
|
|
257
|
+
.pf-anim-bar { min-height: 40px; }
|
|
258
|
+
}
|
|
259
|
+
|
|
252
260
|
/* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
|
|
253
261
|
(bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
|
|
254
262
|
top-left floating #panel card. Legacy markup still floats #viewbar top-right
|
package/src/framework/chrome.css
CHANGED
|
@@ -163,7 +163,10 @@
|
|
|
163
163
|
|
|
164
164
|
/* --- animation transport bar (generated by animation-controls.js) ----------
|
|
165
165
|
PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
|
|
166
|
-
#viewbar's, so a host that re-anchors this bar still inherits its chrome.
|
|
166
|
+
#viewbar's, so a host that re-anchors this bar still inherits its chrome.
|
|
167
|
+
animation-controls.js may inline-override left/transform/max-width (and
|
|
168
|
+
overflow while width-capped) to hold a 10px gap to #viewbar, and clears
|
|
169
|
+
the overrides whenever centered placement fits. */
|
|
167
170
|
.pf-anim-bar {
|
|
168
171
|
position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
|
|
169
172
|
z-index: 15; max-width: calc(100% - 24px);
|