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/src/framework/chrome.css
CHANGED
|
@@ -250,6 +250,26 @@
|
|
|
250
250
|
.pf-anim-bar { bottom: 64px; }
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/* Placement half of app.css's touch layout — same query, so the two must be
|
|
254
|
+
edited together. That layout stacks the bar into rows, and a shrink-to-fit
|
|
255
|
+
absolutely-positioned box sizes itself to its widest single item (195px,
|
|
256
|
+
measured at 390px) rather than to the room available, wrapping every row far
|
|
257
|
+
earlier than it needs to. State the width outright.
|
|
258
|
+
|
|
259
|
+
The lift off the viewbar comes along for a landscape phone, which is wider
|
|
260
|
+
than 719px and so misses the block above. It also keeps this clear of the
|
|
261
|
+
viewbar CLAMP in animation-controls.js: with the bands no longer
|
|
262
|
+
intersecting, applyPlacement() returns before touching left/transform, so
|
|
263
|
+
the stacked bar stays centred instead of being slid sideways by a rule that
|
|
264
|
+
was reasoning about a single-row bar.
|
|
265
|
+
|
|
266
|
+
The cap is for a tablet — a coarse pointer on a wide stage — where an
|
|
267
|
+
unbounded stacked bar would span 700px+ and strand the reset button across a
|
|
268
|
+
mostly empty row. A phone is narrower than the cap and still fills its width. */
|
|
269
|
+
@media (max-width: 719px), (pointer: coarse) {
|
|
270
|
+
.pf-anim-bar { width: calc(100% - 24px); max-width: 520px; bottom: 64px; }
|
|
271
|
+
}
|
|
272
|
+
|
|
253
273
|
|
|
254
274
|
|
|
255
275
|
/* ---- reduced motion -----------------------------------------------------
|
|
@@ -1,364 +1,15 @@
|
|
|
1
|
-
//
|
|
1
|
+
// The control panel's public entry point. The implementation lives in panel/:
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function applyRelevance(relevant, controls, sections) {
|
|
17
|
-
const showAll = !(relevant instanceof Set);
|
|
18
|
-
for (const { key, el: node } of controls) {
|
|
19
|
-
const irrelevant = !showAll && !relevant.has(key);
|
|
20
|
-
node.classList.toggle("irrelevant", irrelevant);
|
|
21
|
-
if (irrelevant) node.title = "Doesn't affect the parts in the current view";
|
|
22
|
-
else node.removeAttribute("title");
|
|
23
|
-
}
|
|
24
|
-
for (const { el: node, keys } of sections) {
|
|
25
|
-
const anyRelevant = showAll || [...keys].some((k) => relevant.has(k));
|
|
26
|
-
node.classList.toggle("section-hidden", !anyRelevant);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// --- visibility (hidden controls/sections) --------------------------------
|
|
31
|
-
export const visibleAdvanced = (sec) => (sec.advanced ?? []).filter((d) => !d.hidden);
|
|
32
|
-
export const visibleFeatures = (sec) => (sec.features ?? []).filter((f) => !f.hidden);
|
|
33
|
-
// Standalone toggle checkboxes a preset section can show (outside the Advanced fold),
|
|
34
|
-
// e.g. preview switches. Each: { key, label, on?, description?, hidden? }.
|
|
35
|
-
export const visibleToggles = (sec) => (sec.toggles ?? []).filter((t) => !t.hidden);
|
|
36
|
-
export function sectionRenders(sec) {
|
|
37
|
-
if (sec.hidden) return false;
|
|
38
|
-
if (sec.features) return visibleFeatures(sec).length > 0;
|
|
39
|
-
const hasPresets = sec.presets && Object.keys(sec.presets).length > 0;
|
|
40
|
-
return !!hasPresets || visibleAdvanced(sec).length > 0 || visibleToggles(sec).length > 0;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Parse a typed value → clamped to [min, max], or null if not a finite number.
|
|
44
|
-
export function clampToRange(raw, min, max) {
|
|
45
|
-
const v = parseFloat(raw);
|
|
46
|
-
if (!Number.isFinite(v)) return null;
|
|
47
|
-
return Math.min(max, Math.max(min, v));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// --- info glyph + per-panel popover -----------------------------------------
|
|
51
|
-
// Popover top edge: below the glyph when it fits, flipped above when the
|
|
52
|
-
// viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
|
|
53
|
-
// sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
|
|
54
|
-
// reports zero layout metrics, so the flip can't be exercised via the DOM.
|
|
55
|
-
export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
|
|
56
|
-
const below = glyphBottom + 6;
|
|
57
|
-
if (below + popHeight <= viewportHeight - 8) return below;
|
|
58
|
-
return Math.max(8, glyphTop - 6 - popHeight);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// One popover element per panel, shared by all its glyphs (only one open at a
|
|
62
|
-
// time). Document-level dismiss listeners are registered per panel and removed
|
|
63
|
-
// by panel.dispose().
|
|
64
|
-
export function createInfoPopover() {
|
|
65
|
-
const pop = el("div", "popover");
|
|
66
|
-
pop.hidden = true;
|
|
67
|
-
document.body.append(pop);
|
|
68
|
-
let owner = null; // the glyph whose description is showing
|
|
69
|
-
|
|
70
|
-
function close() {
|
|
71
|
-
if (pop.hidden) return;
|
|
72
|
-
pop.hidden = true;
|
|
73
|
-
if (owner) { owner.setAttribute("aria-expanded", "false"); owner = null; }
|
|
74
|
-
}
|
|
75
|
-
const onDocClick = (e) => {
|
|
76
|
-
if (!pop.hidden && !pop.contains(e.target) && !e.target.closest?.(".info")) close();
|
|
77
|
-
};
|
|
78
|
-
const onDocKeydown = (e) => { if (e.key === "Escape") close(); };
|
|
79
|
-
document.addEventListener("click", onDocClick);
|
|
80
|
-
document.addEventListener("keydown", onDocKeydown);
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
toggle(glyph, description) {
|
|
84
|
-
if (owner === glyph) { close(); return; } // toggle off
|
|
85
|
-
close();
|
|
86
|
-
pop.innerHTML = renderMarkdown(description);
|
|
87
|
-
pop.hidden = false;
|
|
88
|
-
owner = glyph;
|
|
89
|
-
glyph.setAttribute("aria-expanded", "true");
|
|
90
|
-
const r = glyph.getBoundingClientRect();
|
|
91
|
-
pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
|
|
92
|
-
pop.style.left = `${Math.max(8, r.left - 8)}px`;
|
|
93
|
-
},
|
|
94
|
-
dispose() {
|
|
95
|
-
document.removeEventListener("click", onDocClick);
|
|
96
|
-
document.removeEventListener("keydown", onDocKeydown);
|
|
97
|
-
pop.remove();
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Append a focusable ⓘ glyph to `container` that toggles the panel's shared
|
|
103
|
-
// popover with `description` (Markdown). No-op when description is empty.
|
|
104
|
-
export function attachInfo(container, description, info) {
|
|
105
|
-
if (typeof description !== "string" || !description.trim()) return;
|
|
106
|
-
const glyph = document.createElement("button");
|
|
107
|
-
glyph.type = "button";
|
|
108
|
-
glyph.className = "info";
|
|
109
|
-
glyph.textContent = "ⓘ";
|
|
110
|
-
glyph.setAttribute("aria-label", "More info");
|
|
111
|
-
glyph.setAttribute("aria-expanded", "false");
|
|
112
|
-
glyph.addEventListener("click", (e) => { e.stopPropagation(); info.toggle(glyph, description); });
|
|
113
|
-
container.append(glyph);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function el(tag, className, text) {
|
|
117
|
-
const node = document.createElement(tag);
|
|
118
|
-
if (className) node.className = className;
|
|
119
|
-
if (text != null) node.textContent = text;
|
|
120
|
-
return node;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// One parameter control bound to params[def.key]. `def.control`:
|
|
124
|
-
// "slider" (default) — range slider + an editable number box (drag OR type)
|
|
125
|
-
// "number" — number box only (no slider)
|
|
126
|
-
// "text" — single-line text field
|
|
127
|
-
// "textarea" — multiline text field
|
|
128
|
-
// The box accepts exact values (finer than `step`); typed values clamp to
|
|
129
|
-
// [min, max] on commit (blur/Enter). Returns { wrap, sync }.
|
|
130
|
-
function makeSlider(def, params, onChange, info) {
|
|
131
|
-
const numeric = def.control === "number";
|
|
132
|
-
const wrap = el("div", "slider");
|
|
133
|
-
const row = el("div", "row");
|
|
134
|
-
const label = el("label", "", def.label);
|
|
135
|
-
attachInfo(label, def.description, info);
|
|
136
|
-
row.append(label);
|
|
137
|
-
|
|
138
|
-
// editable value box (+ optional unit suffix)
|
|
139
|
-
const val = el("div", "val");
|
|
140
|
-
const box = document.createElement("input");
|
|
141
|
-
box.type = "number";
|
|
142
|
-
box.className = "num";
|
|
143
|
-
box.min = def.min; box.max = def.max; box.step = def.step;
|
|
144
|
-
box.value = numStr(params[def.key]);
|
|
145
|
-
val.append(box);
|
|
146
|
-
if (def.unit) val.append(el("span", "unit", def.unit));
|
|
147
|
-
row.append(val);
|
|
148
|
-
wrap.append(row);
|
|
149
|
-
|
|
150
|
-
let slider = null;
|
|
151
|
-
if (!numeric) {
|
|
152
|
-
slider = document.createElement("input");
|
|
153
|
-
slider.type = "range";
|
|
154
|
-
slider.min = def.min; slider.max = def.max; slider.step = def.step;
|
|
155
|
-
slider.value = params[def.key];
|
|
156
|
-
slider.addEventListener("input", () => {
|
|
157
|
-
params[def.key] = +slider.value;
|
|
158
|
-
box.value = numStr(+slider.value);
|
|
159
|
-
onChange?.();
|
|
160
|
-
});
|
|
161
|
-
wrap.append(slider);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// live preview while typing (unclamped); clamp + reformat on commit (blur/Enter)
|
|
165
|
-
box.addEventListener("input", () => {
|
|
166
|
-
const v = parseFloat(box.value);
|
|
167
|
-
if (!Number.isFinite(v)) return;
|
|
168
|
-
params[def.key] = v;
|
|
169
|
-
if (slider) slider.value = v;
|
|
170
|
-
onChange?.();
|
|
171
|
-
});
|
|
172
|
-
box.addEventListener("change", () => {
|
|
173
|
-
const v = clampToRange(box.value, def.min, def.max);
|
|
174
|
-
if (v == null) { box.value = numStr(params[def.key]); return; } // revert invalid input
|
|
175
|
-
params[def.key] = v;
|
|
176
|
-
box.value = numStr(v);
|
|
177
|
-
if (slider) slider.value = v;
|
|
178
|
-
onChange?.();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const sync = () => {
|
|
182
|
-
box.value = numStr(params[def.key]);
|
|
183
|
-
if (slider) slider.value = params[def.key];
|
|
184
|
-
};
|
|
185
|
-
return { wrap, sync };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function makeTextControl(def, params, onChange, info) {
|
|
189
|
-
const multiline = def.control === "textarea";
|
|
190
|
-
const wrap = el("div", "slider");
|
|
191
|
-
const row = el("div", "row");
|
|
192
|
-
const label = el("label", "", def.label);
|
|
193
|
-
attachInfo(label, def.description, info);
|
|
194
|
-
row.append(label);
|
|
195
|
-
wrap.append(row);
|
|
196
|
-
|
|
197
|
-
const field = document.createElement(multiline ? "textarea" : "input");
|
|
198
|
-
if (!multiline) field.type = "text";
|
|
199
|
-
field.className = "text-input";
|
|
200
|
-
field.value = String(params[def.key] ?? "");
|
|
201
|
-
field.addEventListener("input", () => {
|
|
202
|
-
params[def.key] = field.value;
|
|
203
|
-
onChange?.();
|
|
204
|
-
});
|
|
205
|
-
wrap.append(field);
|
|
206
|
-
|
|
207
|
-
const sync = () => { field.value = String(params[def.key] ?? ""); };
|
|
208
|
-
return { wrap, sync };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const makeParameterControl = (def, params, onChange, info) =>
|
|
212
|
-
def.control === "text" || def.control === "textarea"
|
|
213
|
-
? makeTextControl(def, params, onChange, info)
|
|
214
|
-
: makeSlider(def, params, onChange, info);
|
|
215
|
-
|
|
216
|
-
// A collapsible "Advanced ▾" block. Returns { adv, toggle }.
|
|
217
|
-
function advancedBlock() {
|
|
218
|
-
const adv = el("div", "adv hidden");
|
|
219
|
-
const toggle = el("button", "adv-toggle", "Advanced ▾");
|
|
220
|
-
toggle.addEventListener("click", () => {
|
|
221
|
-
const hidden = adv.classList.toggle("hidden");
|
|
222
|
-
toggle.textContent = hidden ? "Advanced ▾" : "Advanced ▴";
|
|
223
|
-
});
|
|
224
|
-
return { adv, toggle };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
export function buildControls(root, parameters, params, onDirty) {
|
|
228
|
-
const info = createInfoPopover();
|
|
229
|
-
const controls = []; // { key, el } per control element
|
|
230
|
-
const sections = []; // { el, keys:Set } per rendered section
|
|
231
|
-
const syncFns = []; // { key, sync } for every widget that can re-read params
|
|
232
|
-
for (const sec of parameters) {
|
|
233
|
-
if (!sectionRenders(sec)) continue;
|
|
234
|
-
const section = el("div", "section");
|
|
235
|
-
const title = el("div", "sec-title", sec.title);
|
|
236
|
-
attachInfo(title, sec.description, info);
|
|
237
|
-
section.append(title);
|
|
238
|
-
const keys = new Set();
|
|
239
|
-
const register = (key, node, sync) => {
|
|
240
|
-
controls.push({ key, el: node });
|
|
241
|
-
keys.add(key);
|
|
242
|
-
if (sync) syncFns.push({ key, sync });
|
|
243
|
-
};
|
|
244
|
-
if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
|
|
245
|
-
else buildPresetSection(section, sec, params, onDirty, register, info);
|
|
246
|
-
root.append(section);
|
|
247
|
-
sections.push({ el: section, keys });
|
|
248
|
-
}
|
|
249
|
-
return {
|
|
250
|
-
applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
|
|
251
|
-
// Re-read params into the widgets — all of them, or just `keys`. The
|
|
252
|
-
// programmatic twin of a user edit (setParams); never fires onDirty.
|
|
253
|
-
syncValues: (keys) => {
|
|
254
|
-
const only = keys && new Set(keys);
|
|
255
|
-
for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
|
|
256
|
-
},
|
|
257
|
-
dispose: () => { info.dispose(); root.replaceChildren(); },
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function buildPresetSection(section, sec, params, onDirty, register, info) {
|
|
262
|
-
// preset picker, below the title, full width (omitted when the section has no presets)
|
|
263
|
-
let preset = null;
|
|
264
|
-
const presetNames = sec.presets ? Object.keys(sec.presets) : [];
|
|
265
|
-
if (presetNames.length) {
|
|
266
|
-
preset = document.createElement("select");
|
|
267
|
-
preset.className = "preset";
|
|
268
|
-
for (const name of [...presetNames, "Custom"]) {
|
|
269
|
-
const o = document.createElement("option");
|
|
270
|
-
o.value = name; o.textContent = name; preset.append(o);
|
|
271
|
-
}
|
|
272
|
-
preset.value = presetNames[0];
|
|
273
|
-
section.append(preset);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// standalone toggle checkboxes (e.g. preview switches), shown below the preset and
|
|
277
|
-
// outside the Advanced fold so they stay visible. Independent of the preset selector.
|
|
278
|
-
for (const t of visibleToggles(sec)) {
|
|
279
|
-
const row = el("label", "feat");
|
|
280
|
-
const box = document.createElement("input");
|
|
281
|
-
box.type = "checkbox";
|
|
282
|
-
box.checked = params[t.key] > 0;
|
|
283
|
-
const lbl = el("span", "", t.label);
|
|
284
|
-
attachInfo(lbl, t.description, info);
|
|
285
|
-
row.append(box, lbl);
|
|
286
|
-
box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
|
|
287
|
-
register(t.key, row, () => { box.checked = params[t.key] > 0; });
|
|
288
|
-
section.append(row);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const advanced = visibleAdvanced(sec);
|
|
292
|
-
const syncs = {};
|
|
293
|
-
if (advanced.length) {
|
|
294
|
-
const { adv, toggle } = advancedBlock();
|
|
295
|
-
for (const def of advanced) {
|
|
296
|
-
const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
|
|
297
|
-
adv.append(s.wrap);
|
|
298
|
-
syncs[def.key] = s.sync; // raw: preset APPLICATION must not mark itself Custom
|
|
299
|
-
// A programmatic edit diverges from the preset exactly as a user edit does,
|
|
300
|
-
// so the picker falls back to Custom — leaving a stale preset name selected
|
|
301
|
-
// would also make it unre-appliable (no change event for the current option).
|
|
302
|
-
register(def.key, s.wrap, () => { s.sync(); if (preset) preset.value = "Custom"; });
|
|
303
|
-
}
|
|
304
|
-
section.append(toggle, adv);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// applying a preset overwrites its keys and refreshes this section's sliders
|
|
308
|
-
if (preset) {
|
|
309
|
-
preset.addEventListener("change", () => {
|
|
310
|
-
const bundle = sec.presets[preset.value];
|
|
311
|
-
if (!bundle) return; // "Custom"
|
|
312
|
-
Object.assign(params, bundle);
|
|
313
|
-
for (const key in syncs) if (key in params) syncs[key]();
|
|
314
|
-
onDirty?.();
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function buildFeatureSection(section, sec, params, onDirty, register, info) {
|
|
320
|
-
// Everything lives under Advanced: each feature is a checkbox followed by its
|
|
321
|
-
// own controls, which appear directly below it when the box is checked.
|
|
322
|
-
const { adv, toggle } = advancedBlock();
|
|
323
|
-
section.append(toggle, adv);
|
|
324
|
-
|
|
325
|
-
for (const feat of visibleFeatures(sec)) {
|
|
326
|
-
const checkRow = el("label", "feat");
|
|
327
|
-
const box = document.createElement("input");
|
|
328
|
-
box.type = "checkbox";
|
|
329
|
-
box.checked = params[feat.key] > 0;
|
|
330
|
-
const featLabel = el("span", "", feat.label);
|
|
331
|
-
attachInfo(featLabel, feat.description, info);
|
|
332
|
-
checkRow.append(box, featLabel);
|
|
333
|
-
// `group` is created just below — the sync only ever runs after this
|
|
334
|
-
// function returns, so the closure is safely bound by then.
|
|
335
|
-
register(feat.key, checkRow, () => {
|
|
336
|
-
box.checked = params[feat.key] > 0;
|
|
337
|
-
group.classList.toggle("hidden", !box.checked);
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
const group = el("div", "feat-group");
|
|
341
|
-
const syncs = [];
|
|
342
|
-
for (const def of feat.sliders.filter((d) => !d.hidden)) {
|
|
343
|
-
const s = makeParameterControl(def, params, onDirty, info);
|
|
344
|
-
group.append(s.wrap);
|
|
345
|
-
syncs.push(s.sync);
|
|
346
|
-
register(def.key, s.wrap, s.sync);
|
|
347
|
-
}
|
|
348
|
-
group.classList.toggle("hidden", !box.checked);
|
|
349
|
-
|
|
350
|
-
box.addEventListener("change", () => {
|
|
351
|
-
if (box.checked) {
|
|
352
|
-
if (!(params[feat.key] > 0)) params[feat.key] = feat.on; // enable
|
|
353
|
-
syncs.forEach((s) => s());
|
|
354
|
-
group.classList.remove("hidden");
|
|
355
|
-
} else {
|
|
356
|
-
params[feat.key] = 0; // disable
|
|
357
|
-
group.classList.add("hidden");
|
|
358
|
-
}
|
|
359
|
-
onDirty?.();
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
adv.append(checkRow, group); // checkbox, then its controls right below
|
|
363
|
-
}
|
|
364
|
-
}
|
|
3
|
+
// panel/legacy.js the original advanced/toggles/features shapes
|
|
4
|
+
// panel/model.js canonical nodes -> render tree, plus conditions
|
|
5
|
+
// panel/widget-specs.js the control-type registry (shared with partforge/lint)
|
|
6
|
+
// panel/panel-state.js one pure pass for visibility/disabling/dimming
|
|
7
|
+
// panel/widgets/ one DOM factory per type
|
|
8
|
+
// panel/render.js the DOM binder
|
|
9
|
+
//
|
|
10
|
+
// This file stays at its path because animation-controls.js imports the popover
|
|
11
|
+
// helpers from it, and because it is the documented import site.
|
|
12
|
+
export { buildControls } from "./panel/render.js";
|
|
13
|
+
export { popoverTop, createInfoPopover, attachInfo } from "./panel/info.js";
|
|
14
|
+
export { clampToRange } from "./panel/widgets/numeric.js";
|
|
15
|
+
export { visibleAdvanced, visibleFeatures, visibleToggles, sectionRenders } from "./panel/legacy.js";
|
|
@@ -9,6 +9,8 @@ import { EASINGS } from "../animation.js";
|
|
|
9
9
|
import { CANONICAL_VIEWS } from "../view-angles.js";
|
|
10
10
|
import { probeSubPartPose } from "../pose-probe-core.js";
|
|
11
11
|
import { resolveDerived } from "../derive.js";
|
|
12
|
+
import { desugar } from "../panel/legacy.js";
|
|
13
|
+
import { controlNodes } from "../panel/model.js";
|
|
12
14
|
|
|
13
15
|
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
14
16
|
|
|
@@ -22,20 +24,19 @@ const animEntries = (part) =>
|
|
|
22
24
|
// rule checks its own slice). A bare-tracks animation is one anonymous step.
|
|
23
25
|
const rawSteps = (a) => (Array.isArray(a.steps) ? a.steps.filter(isPlainObject) : [{ ...a, label: null }]);
|
|
24
26
|
|
|
25
|
-
// The control descriptor ranges, for value-in-range checks.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
27
|
+
// The control descriptor ranges, for value-in-range checks. Walks the shared
|
|
28
|
+
// panel model rather than re-implementing the schema walk — before that model
|
|
29
|
+
// existed this duplicated rules-schema.js's collectDescriptors by design, and
|
|
30
|
+
// the two could drift.
|
|
31
|
+
//
|
|
32
|
+
// desugar() is used directly, WITHOUT buildTree(): hidden controls must stay in,
|
|
33
|
+
// because an animation may legitimately drive a parameter with no visible UI.
|
|
28
34
|
function paramRanges(part) {
|
|
29
35
|
const ranges = new Map();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
for (const sec of secs) {
|
|
35
|
-
for (const d of Array.isArray(sec?.advanced) ? sec.advanced : []) add(d);
|
|
36
|
-
for (const f of Array.isArray(sec?.features) ? sec.features : []) {
|
|
37
|
-
for (const s of Array.isArray(f?.sliders) ? f.sliders : []) add(s);
|
|
38
|
-
}
|
|
36
|
+
for (const c of controlNodes(desugar(part?.parameters))) {
|
|
37
|
+
// A checkbox has no range; only numeric controls constrain a keyframe.
|
|
38
|
+
if (c.type === "checkbox") continue;
|
|
39
|
+
if (typeof c.key === "string" && !ranges.has(c.key)) ranges.set(c.key, { min: c.min, max: c.max });
|
|
39
40
|
}
|
|
40
41
|
return ranges;
|
|
41
42
|
}
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// resolve against `defaults`, which produce a control that silently does nothing.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
6
|
import { suggest } from "../geometry/op-options.js";
|
|
7
|
+
import { fieldsFor } from "../panel/widget-specs.js";
|
|
8
|
+
import { sectionRenders } from "../panel/legacy.js";
|
|
7
9
|
|
|
8
|
-
//
|
|
9
|
-
const CONTROL_FIELDS = ["key", "label", "unit", "min", "max", "step", "control", "hidden", "description"];
|
|
10
|
+
// Legacy container descriptors aren't widget types, so they keep explicit lists.
|
|
10
11
|
const FEATURE_FIELDS = ["key", "label", "on", "sliders", "hidden", "description"];
|
|
11
|
-
const TOGGLE_FIELDS = ["key", "label", "on", "hidden", "description"];
|
|
12
12
|
|
|
13
13
|
const sections = (part) => (Array.isArray(part?.parameters) ? part.parameters : []);
|
|
14
14
|
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
@@ -21,7 +21,7 @@ function collectDescriptors(part) {
|
|
|
21
21
|
const out = [];
|
|
22
22
|
sections(part).forEach((sec, si) => {
|
|
23
23
|
arr(sec?.advanced).forEach((d, i) => {
|
|
24
|
-
if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields:
|
|
24
|
+
if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider") });
|
|
25
25
|
});
|
|
26
26
|
arr(sec?.features).forEach((f, i) => {
|
|
27
27
|
if (!f) return;
|
|
@@ -31,11 +31,11 @@ function collectDescriptors(part) {
|
|
|
31
31
|
// recognise the demo.js flange_d pattern below: a slider sharing its key
|
|
32
32
|
// with the feature is not an independent parameter, it's the feature's own
|
|
33
33
|
// magnitude, and `defaults[key] === 0` there means "off", not "out of range".
|
|
34
|
-
if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields:
|
|
34
|
+
if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: fieldsFor("slider"), featureKey: f.key });
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
arr(sec?.toggles).forEach((t, i) => {
|
|
38
|
-
if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields:
|
|
38
|
+
if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: fieldsFor("checkbox") });
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
return out;
|
|
@@ -43,21 +43,10 @@ function collectDescriptors(part) {
|
|
|
43
43
|
|
|
44
44
|
const defaultKeys = (part) => new Set(Object.keys(part?.defaults ?? {}));
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// `features-requires-sliders` must not flag a `feat.sliders.filter(...)` the panel
|
|
51
|
-
// will never reach: controls.js only iterates `visibleFeatures(sec)` (skipping any
|
|
52
|
-
// feature marked `hidden: true`), and only builds a section at all when
|
|
53
|
-
// `sectionRenders(sec)` is true.
|
|
54
|
-
const visibleFeatures = (sec) => arr(sec?.features).filter((f) => f && !f.hidden);
|
|
55
|
-
function sectionRenders(sec) {
|
|
56
|
-
if (sec?.hidden) return false;
|
|
57
|
-
if (sec?.features) return visibleFeatures(sec).length > 0;
|
|
58
|
-
const hasPresets = sec?.presets && Object.keys(sec.presets).length > 0;
|
|
59
|
-
return !!hasPresets || arr(sec?.advanced).some((d) => d && !d.hidden) || arr(sec?.toggles).some((t) => t && !t.hidden);
|
|
60
|
-
}
|
|
46
|
+
// These used to be hand-copied from controls.js, because importing it would have
|
|
47
|
+
// dragged `marked`/`dompurify` into partforge/lint and broken its zero-dependency
|
|
48
|
+
// guarantee. panel/legacy.js imports nothing, so lint can share the real
|
|
49
|
+
// implementation and the two can no longer drift.
|
|
61
50
|
|
|
62
51
|
export const SCHEMA_RULES = [
|
|
63
52
|
{
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The ⓘ glyph and its per-panel popover. Shared by the control panel and the
|
|
2
|
+
// animation transport bar (animation-controls.js), which is why it is its own
|
|
3
|
+
// module rather than living inside the panel renderer.
|
|
4
|
+
import { renderMarkdown } from "../markdown.js";
|
|
5
|
+
|
|
6
|
+
function el(tag, className, text) {
|
|
7
|
+
const node = document.createElement(tag);
|
|
8
|
+
if (className) node.className = className;
|
|
9
|
+
if (text != null) node.textContent = text;
|
|
10
|
+
return node;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Popover top edge: below the glyph when it fits, flipped above when the
|
|
14
|
+
// viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
|
|
15
|
+
// sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
|
|
16
|
+
// reports zero layout metrics, so the flip can't be exercised via the DOM.
|
|
17
|
+
export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
|
|
18
|
+
const below = glyphBottom + 6;
|
|
19
|
+
if (below + popHeight <= viewportHeight - 8) return below;
|
|
20
|
+
return Math.max(8, glyphTop - 6 - popHeight);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One popover element per panel, shared by all its glyphs (only one open at a
|
|
24
|
+
// time). Document-level dismiss listeners are registered per panel and removed
|
|
25
|
+
// by panel.dispose().
|
|
26
|
+
export function createInfoPopover() {
|
|
27
|
+
const pop = el("div", "popover");
|
|
28
|
+
pop.hidden = true;
|
|
29
|
+
document.body.append(pop);
|
|
30
|
+
let owner = null; // the glyph whose description is showing
|
|
31
|
+
|
|
32
|
+
function close() {
|
|
33
|
+
if (pop.hidden) return;
|
|
34
|
+
pop.hidden = true;
|
|
35
|
+
if (owner) { owner.setAttribute("aria-expanded", "false"); owner = null; }
|
|
36
|
+
}
|
|
37
|
+
const onDocClick = (e) => {
|
|
38
|
+
if (!pop.hidden && !pop.contains(e.target) && !e.target.closest?.(".info")) close();
|
|
39
|
+
};
|
|
40
|
+
const onDocKeydown = (e) => { if (e.key === "Escape") close(); };
|
|
41
|
+
document.addEventListener("click", onDocClick);
|
|
42
|
+
document.addEventListener("keydown", onDocKeydown);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
toggle(glyph, description) {
|
|
46
|
+
if (owner === glyph) { close(); return; } // toggle off
|
|
47
|
+
close();
|
|
48
|
+
pop.innerHTML = renderMarkdown(description);
|
|
49
|
+
pop.hidden = false;
|
|
50
|
+
owner = glyph;
|
|
51
|
+
glyph.setAttribute("aria-expanded", "true");
|
|
52
|
+
const r = glyph.getBoundingClientRect();
|
|
53
|
+
pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
|
|
54
|
+
pop.style.left = `${Math.max(8, r.left - 8)}px`;
|
|
55
|
+
},
|
|
56
|
+
dispose() {
|
|
57
|
+
document.removeEventListener("click", onDocClick);
|
|
58
|
+
document.removeEventListener("keydown", onDocKeydown);
|
|
59
|
+
pop.remove();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Append a focusable ⓘ glyph to `container` that toggles the panel's shared
|
|
65
|
+
// popover with `description` (Markdown). No-op when description is empty.
|
|
66
|
+
export function attachInfo(container, description, info) {
|
|
67
|
+
if (typeof description !== "string" || !description.trim()) return;
|
|
68
|
+
const glyph = document.createElement("button");
|
|
69
|
+
glyph.type = "button";
|
|
70
|
+
glyph.className = "info";
|
|
71
|
+
glyph.textContent = "ⓘ";
|
|
72
|
+
glyph.setAttribute("aria-label", "More info");
|
|
73
|
+
glyph.setAttribute("aria-expanded", "false");
|
|
74
|
+
glyph.addEventListener("click", (e) => { e.stopPropagation(); info.toggle(glyph, description); });
|
|
75
|
+
container.append(glyph);
|
|
76
|
+
}
|