partforge 0.113.0 → 0.114.0
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 +138 -1
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/lint/rules-schema.js +56 -0
- package/src/framework/lint/source-scan.js +116 -2
- package/src/framework/mount.js +21 -2
- package/src/framework/panel/author.js +5 -0
- package/src/framework/panel/json-value.js +55 -0
- package/src/framework/panel/render.js +119 -36
- package/src/framework/panel/scoped-params.js +52 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/custom.js +253 -0
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/panel-values.js +6 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
|
@@ -7,6 +7,10 @@ import { computeState } from "./panel-state.js";
|
|
|
7
7
|
import { WIDGET_FACTORIES } from "./widgets/index.js";
|
|
8
8
|
import { makeReadout } from "./widgets/readout.js";
|
|
9
9
|
import { createInfoPopover, attachInfo } from "./info.js";
|
|
10
|
+
import { isJsonValue } from "./json-value.js";
|
|
11
|
+
|
|
12
|
+
// The budget every custom control's transient state shares (getState below).
|
|
13
|
+
export const PANEL_STATE_MAX_BYTES = 65536;
|
|
10
14
|
|
|
11
15
|
function el(tag, className, text) {
|
|
12
16
|
const node = document.createElement(tag);
|
|
@@ -48,6 +52,22 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
48
52
|
indexNodes(tree, nodeById);
|
|
49
53
|
let relevant = null;
|
|
50
54
|
|
|
55
|
+
const panelErrors = []; // {key, label, phase, message} from custom controls
|
|
56
|
+
const customWidgets = new Map(); // param key -> { label, getState } for custom controls
|
|
57
|
+
const reportedStateDrops = new Set(); // keys already recorded as a dropped getState() — getState may be called many times
|
|
58
|
+
// What a custom control's host reaches back into the panel for: the part's
|
|
59
|
+
// own files (host.file), the state a previous mount left (host.state), the
|
|
60
|
+
// error sink, and the sub-panel builder — a re-entry into buildControls with
|
|
61
|
+
// a scoped params view and no section header (opts.bare, Task 5).
|
|
62
|
+
const customCtx = {
|
|
63
|
+
files: opts.files ?? null,
|
|
64
|
+
panelState: opts.panelState ?? null,
|
|
65
|
+
onError: (e) => { panelErrors.push(e); opts.onPanelError?.(e); },
|
|
66
|
+
buildSubPanel: (container, controls, scoped, onSubDirty, onSubCommit) =>
|
|
67
|
+
buildControls(container, [{ controls }], scoped, onSubDirty, onSubCommit,
|
|
68
|
+
{ ...opts, bare: true, panelState: null, onPanelError: customCtx.onError }),
|
|
69
|
+
};
|
|
70
|
+
|
|
51
71
|
// Re-apply state after any change that could flip a condition. This is what
|
|
52
72
|
// reproduces the legacy feature behavior generically: ticking a feature's
|
|
53
73
|
// checkbox now simply makes its group's condition true. It also re-syncs any
|
|
@@ -224,67 +244,87 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
224
244
|
};
|
|
225
245
|
const widget = factory(node, params, {
|
|
226
246
|
onChange: () => { markCustom(); onEdit(); },
|
|
227
|
-
|
|
247
|
+
// A factory may name the keys it committed (custom controls own several);
|
|
248
|
+
// the built-ins call onCommit() bare and get their own key, as before.
|
|
249
|
+
onCommit: (keys) => commit(Array.isArray(keys) && keys.length ? keys : [node.key]),
|
|
228
250
|
info,
|
|
229
251
|
fontCatalog: opts.fontCatalog,
|
|
230
252
|
imageCatalog: opts.imageCatalog,
|
|
231
253
|
onAssetUpload: opts.onAssetUpload,
|
|
232
254
|
declaredSource: opts.declaredSource,
|
|
255
|
+
custom: customCtx,
|
|
233
256
|
});
|
|
234
257
|
nodeEls.set(node.id, widget.el);
|
|
235
258
|
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|
|
236
259
|
widgetSyncs.set(node.id, widget.sync);
|
|
237
260
|
if (widget.dispose) disposers.push(widget.dispose);
|
|
261
|
+
// A custom control reads derive() output like a readout does, and keeps
|
|
262
|
+
// transient state the host may carry across a remount.
|
|
263
|
+
if (typeof widget.onDerived === "function") displayUpdates.set(node.id, widget.onDerived);
|
|
264
|
+
if (typeof widget.getState === "function" && node.key) {
|
|
265
|
+
customWidgets.set(node.key, { label: node.label ?? node.key, getState: widget.getState });
|
|
266
|
+
}
|
|
238
267
|
container.append(widget.el);
|
|
239
268
|
|
|
240
269
|
// The raw sync is what a PRESET application uses — it must not mark itself
|
|
241
270
|
// Custom (controls.test.js:366). The registered sync is what an external
|
|
242
271
|
// syncValues() uses, and for a preset-section control it does drop the
|
|
243
272
|
// picker to Custom (controls.test.js:350), because a programmatic edit
|
|
244
|
-
// diverges from the preset exactly as a user edit does.
|
|
273
|
+
// diverges from the preset exactly as a user edit does. A widget that owns
|
|
274
|
+
// several keys (custom's `keys`) registers under each of them.
|
|
245
275
|
if (sectionCtx) rawSyncs.get(sectionCtx.id).push({ key: node.key, sync: widget.sync });
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
sync: () => { widget.sync(); markCustom(); }
|
|
249
|
-
}
|
|
276
|
+
const ownedKeys = Array.isArray(widget.keys) && widget.keys.length ? widget.keys : [node.key];
|
|
277
|
+
for (const key of ownedKeys) {
|
|
278
|
+
syncFns.push({ key, sync: () => { widget.sync(); markCustom(); } });
|
|
279
|
+
}
|
|
250
280
|
}
|
|
251
281
|
|
|
252
282
|
for (const section of tree) {
|
|
253
283
|
groupIds.add(section.id);
|
|
254
284
|
const secEl = el("div", "section");
|
|
255
285
|
nodeEls.set(section.id, secEl);
|
|
256
|
-
|
|
257
|
-
const header = el("div", "sec-header");
|
|
258
|
-
const title = el("button", "sec-title");
|
|
259
|
-
title.type = "button";
|
|
260
|
-
// The chev span carries NO text — its glyph comes from CSS (::before) —
|
|
261
|
-
// because sectionByTitle-style lookups match `.sec-title` by exact
|
|
262
|
-
// textContent === title (controls.test.js:210), and a text chevron here
|
|
263
|
-
// would break that match.
|
|
264
|
-
title.append(el("span", "sec-name", section.title ?? ""));
|
|
265
|
-
header.append(title);
|
|
266
|
-
// Row order: title (flex:1), then ⓘ, then the chevron on the far right.
|
|
267
|
-
// The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
|
|
268
|
-
// <button>, and a button nested in a button is invalid HTML that never
|
|
269
|
-
// receives clicks.
|
|
270
|
-
attachInfo(header, section.description, info);
|
|
271
|
-
header.append(el("span", "chev"));
|
|
272
|
-
secEl.append(header);
|
|
273
|
-
|
|
274
286
|
const body = el("div", "sec-body");
|
|
275
|
-
body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
|
|
276
|
-
title.setAttribute("aria-controls", body.id);
|
|
277
|
-
secEl.append(body);
|
|
278
287
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
+
if (opts.bare) {
|
|
289
|
+
// A sub-panel inside a custom control (host.controls): controls only,
|
|
290
|
+
// no header row and no disclosure — the widget owns the framing, so
|
|
291
|
+
// there is no aria-controls to point at the body and it gets no id
|
|
292
|
+
// (a mounted sub-panel would otherwise collide with the outer panel's
|
|
293
|
+
// own pf-sec-<id>, or with another sub-panel's, since ids are not
|
|
294
|
+
// scoped to a mount).
|
|
295
|
+
secEl.classList.add("bare");
|
|
296
|
+
secEl.append(body);
|
|
297
|
+
} else {
|
|
298
|
+
body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
|
|
299
|
+
const header = el("div", "sec-header");
|
|
300
|
+
const title = el("button", "sec-title");
|
|
301
|
+
title.type = "button";
|
|
302
|
+
// The chev span carries NO text — its glyph comes from CSS (::before) —
|
|
303
|
+
// because sectionByTitle-style lookups match `.sec-title` by exact
|
|
304
|
+
// textContent === title (controls.test.js:210), and a text chevron here
|
|
305
|
+
// would break that match.
|
|
306
|
+
title.append(el("span", "sec-name", section.title ?? ""));
|
|
307
|
+
header.append(title);
|
|
308
|
+
// Row order: title (flex:1), then ⓘ, then the chevron on the far right.
|
|
309
|
+
// The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
|
|
310
|
+
// <button>, and a button nested in a button is invalid HTML that never
|
|
311
|
+
// receives clicks.
|
|
312
|
+
attachInfo(header, section.description, info);
|
|
313
|
+
header.append(el("span", "chev"));
|
|
314
|
+
secEl.append(header);
|
|
315
|
+
title.setAttribute("aria-controls", body.id);
|
|
316
|
+
secEl.append(body);
|
|
317
|
+
|
|
318
|
+
// The whole header row toggles: the title button's own click bubbles up
|
|
319
|
+
// here, the chevron and the empty row space hit it directly, and the ⓘ
|
|
320
|
+
// stops propagation in attachInfo. aria state stays on the title button.
|
|
321
|
+
header.addEventListener("click", () => {
|
|
322
|
+
const nowHidden = body.classList.toggle("hidden");
|
|
323
|
+
title.setAttribute("aria-expanded", String(!nowHidden));
|
|
324
|
+
secEl.classList.toggle("collapsed", nowHidden);
|
|
325
|
+
});
|
|
326
|
+
disclosures.set(section.id, { body, button: title, el: secEl });
|
|
327
|
+
}
|
|
288
328
|
|
|
289
329
|
// `preset` is filled in when a preset node renders. Controls read it late, so
|
|
290
330
|
// one appearing after them in the children array still works.
|
|
@@ -357,6 +397,49 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
357
397
|
}
|
|
358
398
|
return true;
|
|
359
399
|
},
|
|
400
|
+
// What custom controls reported failing this mount, in order. A host that
|
|
401
|
+
// remounts per edit (partforge-cloud) reads this after mount and hands it
|
|
402
|
+
// to the agent beside the build's own warnings.
|
|
403
|
+
errors: () => panelErrors.map((e) => ({ ...e })),
|
|
404
|
+
// Every custom control's non-empty transient state, keyed by param, as
|
|
405
|
+
// plain JSON — hand it back as mount({ panelState }) so a selection survives
|
|
406
|
+
// the remount an edit performs. One shared budget: a state that would push
|
|
407
|
+
// the total past it is dropped and recorded (phase "state") rather than
|
|
408
|
+
// silently lost.
|
|
409
|
+
getState: () => {
|
|
410
|
+
const out = {};
|
|
411
|
+
let budget = PANEL_STATE_MAX_BYTES;
|
|
412
|
+
for (const [key, w] of customWidgets) {
|
|
413
|
+
try {
|
|
414
|
+
const state = w.getState();
|
|
415
|
+
if (!state || typeof state !== "object" || Object.keys(state).length === 0) continue;
|
|
416
|
+
if (!isJsonValue(state, { maxBytes: Infinity })) {
|
|
417
|
+
if (!reportedStateDrops.has(key)) {
|
|
418
|
+
reportedStateDrops.add(key);
|
|
419
|
+
customCtx.onError({ key, label: w.label, phase: "state", message: "panel state is not a JSON value; dropped" });
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const size = new TextEncoder().encode(JSON.stringify(state)).length;
|
|
424
|
+
if (size > budget) {
|
|
425
|
+
if (!reportedStateDrops.has(key)) {
|
|
426
|
+
reportedStateDrops.add(key);
|
|
427
|
+
customCtx.onError({ key, label: w.label, phase: "state", message: `panel state (${size} bytes) exceeds the ${PANEL_STATE_MAX_BYTES}-byte budget; dropped` });
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
budget -= size;
|
|
432
|
+
out[key] = structuredClone(state);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
if (!reportedStateDrops.has(key)) {
|
|
435
|
+
reportedStateDrops.add(key);
|
|
436
|
+
const message = (e && typeof e.message === "string" && e.message) || String(e);
|
|
437
|
+
customCtx.onError({ key, label: w.label, phase: "state", message: `panel state could not be read: ${message}` });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
},
|
|
360
443
|
// replaceChildren() only reaches what is INSIDE root; a widget that parked
|
|
361
444
|
// DOM (or a document-level listener) elsewhere has to be told to let go.
|
|
362
445
|
dispose: () => { info.dispose(); for (const d of disposers) d(); root.replaceChildren(); },
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// A params-shaped view INTO a custom control's owned value, so the built-in
|
|
2
|
+
// control factories — which read and write `params[node.key]` and nothing
|
|
3
|
+
// else — can edit `tiles[3].height` without knowing they are inside a JSON
|
|
4
|
+
// value. Every read clones the owned value (read()) and every write hands a
|
|
5
|
+
// whole new value back (write(next)): the stored value is never mutated in
|
|
6
|
+
// place, which is what keeps the worker's cache keys and the relevance
|
|
7
|
+
// recorder honest.
|
|
8
|
+
//
|
|
9
|
+
// `path` is dotted from the owned value's root: "" (the root itself), "3"
|
|
10
|
+
// (the fourth array element), "walls.north". A write whose parent does not
|
|
11
|
+
// exist is refused and reported through onError, never thrown — a throwing
|
|
12
|
+
// params write inside a slider's input handler would take the panel down.
|
|
13
|
+
export function scopedParams({ read, write, path = "", onError }) {
|
|
14
|
+
const segs = path === "" ? [] : String(path).split(".");
|
|
15
|
+
const parentOf = (root) => {
|
|
16
|
+
let cur = root;
|
|
17
|
+
for (const s of segs) {
|
|
18
|
+
if (cur === null || typeof cur !== "object") return null;
|
|
19
|
+
cur = cur[s];
|
|
20
|
+
}
|
|
21
|
+
return cur !== null && typeof cur === "object" ? cur : null;
|
|
22
|
+
};
|
|
23
|
+
return new Proxy({}, {
|
|
24
|
+
get(_, key) {
|
|
25
|
+
if (typeof key !== "string") return undefined;
|
|
26
|
+
const p = parentOf(read());
|
|
27
|
+
return p ? p[key] : undefined;
|
|
28
|
+
},
|
|
29
|
+
has(_, key) {
|
|
30
|
+
const p = parentOf(read());
|
|
31
|
+
return typeof key === "string" && !!p && Object.hasOwn(p, key);
|
|
32
|
+
},
|
|
33
|
+
ownKeys() {
|
|
34
|
+
const p = parentOf(read());
|
|
35
|
+
return p ? Object.keys(p) : [];
|
|
36
|
+
},
|
|
37
|
+
getOwnPropertyDescriptor(_, key) {
|
|
38
|
+
const p = parentOf(read());
|
|
39
|
+
if (!p || typeof key !== "string" || !Object.hasOwn(p, key)) return undefined;
|
|
40
|
+
return { value: p[key], enumerable: true, configurable: true, writable: true };
|
|
41
|
+
},
|
|
42
|
+
set(_, key, value) {
|
|
43
|
+
if (typeof key !== "string") return false;
|
|
44
|
+
const root = read();
|
|
45
|
+
const p = parentOf(root);
|
|
46
|
+
if (!p) { onError?.(`no value at path "${path}" to write "${key}" into`); return true; }
|
|
47
|
+
p[key] = value;
|
|
48
|
+
write(root);
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -39,6 +39,7 @@ export const WIDGET_SPECS = [
|
|
|
39
39
|
{ type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview", "sourceField"] },
|
|
40
40
|
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
41
41
|
{ type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
42
|
+
{ type: "custom", kind: "control", fields: [...AUTHOR_COMMON, "widget", "keys"] },
|
|
42
43
|
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
43
44
|
];
|
|
44
45
|
|
|
@@ -60,6 +61,7 @@ const AUTHOR_EXTRAS = {
|
|
|
60
61
|
font: ["allow", "preview", "sourceField"],
|
|
61
62
|
image: ["allow", "sourceField"],
|
|
62
63
|
vector: ["allow", "sourceField"],
|
|
64
|
+
custom: ["widget", "keys"],
|
|
63
65
|
};
|
|
64
66
|
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
65
67
|
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// A part-authored widget in the rail (type: "custom"). The author's `widget`
|
|
2
|
+
// function draws whatever it wants into the slot — DOM, SVG, canvas — through
|
|
3
|
+
// a small host object; the framework's job is to make that function safe to
|
|
4
|
+
// run (a throw lands on a card, never on the panel), to route its writes
|
|
5
|
+
// through the ordinary dirty/commit chain, and to keep its transient state
|
|
6
|
+
// across the remount every edit performs (getState/panelState).
|
|
7
|
+
//
|
|
8
|
+
// The widget runs in the panel's own realm: mount() receives the part module
|
|
9
|
+
// directly, so `node.widget` is the author's real function, never a clone.
|
|
10
|
+
// It must never reach the geometry worker — it does not: only `params` cross.
|
|
11
|
+
import { attachInfo } from "../info.js";
|
|
12
|
+
import { vectorThumb } from "./vector-thumb.js";
|
|
13
|
+
import { isJsonValue, jsonValueProblem } from "../json-value.js";
|
|
14
|
+
import { scopedParams } from "../scoped-params.js";
|
|
15
|
+
|
|
16
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
17
|
+
const SVG_TAGS = new Set([
|
|
18
|
+
"svg", "g", "path", "polygon", "polyline", "circle", "ellipse", "rect", "line", "text", "tspan",
|
|
19
|
+
"use", "defs", "clipPath", "mask", "pattern", "marker", "symbol", "image", "title", "desc",
|
|
20
|
+
"foreignObject", "linearGradient", "radialGradient", "stop",
|
|
21
|
+
]);
|
|
22
|
+
// `pfc-tree://<path>?v=<stamp>` — the token a `vector` control writes into a
|
|
23
|
+
// param when the artwork lives as a file in the part (partforge-cloud's
|
|
24
|
+
// treeTokens.js). The stamp is content-addressed and irrelevant here: the
|
|
25
|
+
// files map is the tree as of THIS mount.
|
|
26
|
+
const TREE_TOKEN_RE = /^pfc-tree:\/\/([^?#]+)(?:[?#].*)?$/i;
|
|
27
|
+
|
|
28
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v));
|
|
29
|
+
const errorText = (e) => (e && typeof e.message === "string" && e.message) || String(e);
|
|
30
|
+
const cloneValue = (v) => (v !== null && typeof v === "object" ? structuredClone(v) : v);
|
|
31
|
+
|
|
32
|
+
function el(tag, className, text) {
|
|
33
|
+
const node = document.createElement(tag);
|
|
34
|
+
if (className) node.className = className;
|
|
35
|
+
if (text != null) node.textContent = text;
|
|
36
|
+
return node;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Drop any `type: "custom"` entry (recursively through groups) from a
|
|
40
|
+
// sub-control list: a widget inside a widget is refused, one level only.
|
|
41
|
+
function stripNested(controls, report) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const c of Array.isArray(controls) ? controls : []) {
|
|
44
|
+
if (!c) continue;
|
|
45
|
+
if (c.type === "custom") { report(typeof c.key === "string" ? c.key : "?"); continue; }
|
|
46
|
+
if (c.type === "group") { out.push({ ...c, controls: stripNested(c.controls, report) }); continue; }
|
|
47
|
+
out.push(c);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function makeCustom(node, params, { onChange, onCommit, info, custom = {} } = {}) {
|
|
53
|
+
const { files = null, panelState = null, onError, buildSubPanel } = custom;
|
|
54
|
+
const label = node.label ?? node.key;
|
|
55
|
+
const wrap = el("div", "pf-custom");
|
|
56
|
+
if (node.label) {
|
|
57
|
+
const row = el("div", "row");
|
|
58
|
+
const lbl = el("label", "", node.label);
|
|
59
|
+
attachInfo(lbl, node.description, info);
|
|
60
|
+
row.append(lbl);
|
|
61
|
+
wrap.append(row);
|
|
62
|
+
}
|
|
63
|
+
const slot = el("div", "pf-custom-slot");
|
|
64
|
+
wrap.append(slot);
|
|
65
|
+
|
|
66
|
+
const ownedKeys = [node.key, ...(Array.isArray(node.keys) ? node.keys.filter((k) => typeof k === "string") : [])];
|
|
67
|
+
const pending = new Set(); // keys set since the last commit
|
|
68
|
+
const subPanels = new Set(); // disposers of open host.controls panels
|
|
69
|
+
let instance = null;
|
|
70
|
+
let retired = false;
|
|
71
|
+
let lastDerived = "{}";
|
|
72
|
+
|
|
73
|
+
const report = (phase, message) => onError?.({ key: node.key, label, phase, message });
|
|
74
|
+
|
|
75
|
+
// A throw anywhere in widget code: record it, retire the widget for this
|
|
76
|
+
// mount, tear down its sub-panels, and put the card in the slot. Every other
|
|
77
|
+
// control keeps working; the geometry keeps rendering.
|
|
78
|
+
const fail = (phase, e) => {
|
|
79
|
+
if (retired) return;
|
|
80
|
+
retired = true;
|
|
81
|
+
const message = errorText(e);
|
|
82
|
+
report(phase, message);
|
|
83
|
+
for (const d of [...subPanels]) { try { d(); } catch { /* already failing */ } }
|
|
84
|
+
subPanels.clear();
|
|
85
|
+
const card = el("div", "pf-custom-error");
|
|
86
|
+
card.append(el("div", "pf-custom-error-title", `${label}: custom control failed`));
|
|
87
|
+
card.append(el("div", "pf-custom-error-message", message));
|
|
88
|
+
slot.replaceChildren(card);
|
|
89
|
+
};
|
|
90
|
+
const guarded = (phase, fn) => {
|
|
91
|
+
if (retired) return undefined;
|
|
92
|
+
try { return fn(); } catch (e) { fail(phase, e); return undefined; }
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const restoredState = panelState && typeof panelState === "object" && Object.hasOwn(panelState, node.key)
|
|
96
|
+
&& panelState[node.key] && typeof panelState[node.key] === "object" && !Array.isArray(panelState[node.key])
|
|
97
|
+
&& isJsonValue(panelState[node.key], { maxBytes: Infinity })
|
|
98
|
+
? structuredClone(panelState[node.key])
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
const host = {
|
|
102
|
+
el: slot,
|
|
103
|
+
doc: slot.ownerDocument,
|
|
104
|
+
key: node.key,
|
|
105
|
+
state: restoredState ?? {},
|
|
106
|
+
derived: {},
|
|
107
|
+
get disabled() { return wrap.classList.contains("disabled"); },
|
|
108
|
+
|
|
109
|
+
get(key = node.key) { return cloneValue(params[key]); },
|
|
110
|
+
|
|
111
|
+
set(value, { key = node.key, commit = true } = {}) {
|
|
112
|
+
if (retired) return;
|
|
113
|
+
if (!ownedKeys.includes(key)) {
|
|
114
|
+
throw new TypeError(`custom control "${node.key}" may not write "${key}" — list it in \`keys\``);
|
|
115
|
+
}
|
|
116
|
+
if (key === node.key) {
|
|
117
|
+
const problem = jsonValueProblem(value);
|
|
118
|
+
if (problem) throw new TypeError(`value for "${key}" ${problem}`);
|
|
119
|
+
} else if (!isScalar(value)) {
|
|
120
|
+
throw new TypeError(`value for "${key}" must be a finite number, string or boolean`);
|
|
121
|
+
}
|
|
122
|
+
params[key] = cloneValue(value);
|
|
123
|
+
pending.add(key);
|
|
124
|
+
onChange?.();
|
|
125
|
+
if (commit) host.commit([key]);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
commit(keys = [node.key]) {
|
|
129
|
+
if (retired) return;
|
|
130
|
+
const changed = keys.filter((k) => pending.has(k));
|
|
131
|
+
if (!changed.length) return;
|
|
132
|
+
for (const k of changed) pending.delete(k);
|
|
133
|
+
onCommit?.(changed);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
setState(patch) { if (retired) return; Object.assign(host.state, patch); },
|
|
137
|
+
|
|
138
|
+
// Element builder: SVG tags get the SVG namespace, `on<event>` attrs
|
|
139
|
+
// become listeners (wrapped: a throw retires the widget), everything else
|
|
140
|
+
// is set verbatim; `true` → a bare attribute, null/undefined/false → omitted.
|
|
141
|
+
h(tag, attrs = {}, ...children) {
|
|
142
|
+
const doc = slot.ownerDocument;
|
|
143
|
+
const elm = SVG_TAGS.has(tag) ? doc.createElementNS(SVG_NS, tag) : doc.createElement(tag);
|
|
144
|
+
for (const [name, v] of Object.entries(attrs ?? {})) {
|
|
145
|
+
if (v === null || v === undefined || v === false) continue;
|
|
146
|
+
if (name.startsWith("on") && typeof v === "function") {
|
|
147
|
+
elm.addEventListener(name.slice(2).toLowerCase(), (ev) => guarded("event", () => v(ev)));
|
|
148
|
+
} else {
|
|
149
|
+
elm.setAttribute(name, v === true ? "" : String(v));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const append = (c) => {
|
|
153
|
+
if (c === null || c === undefined || c === false) return;
|
|
154
|
+
if (Array.isArray(c)) { c.forEach(append); return; }
|
|
155
|
+
elm.append(typeof c === "object" ? c : doc.createTextNode(String(c)));
|
|
156
|
+
};
|
|
157
|
+
children.forEach(append);
|
|
158
|
+
return elm;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// An SVG string → its root element (HTML parsing puts `<svg>` in the SVG
|
|
162
|
+
// namespace), or null. No sanitizer: this is the same author's code that
|
|
163
|
+
// already runs here.
|
|
164
|
+
svg(text) {
|
|
165
|
+
if (typeof text !== "string") return null;
|
|
166
|
+
const tpl = slot.ownerDocument.createElement("template");
|
|
167
|
+
tpl.innerHTML = text.trim();
|
|
168
|
+
const root = tpl.content.firstElementChild;
|
|
169
|
+
return root && root.tagName.toLowerCase() === "svg" ? root : null;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
svgFromVector: (doc) => vectorThumb(doc),
|
|
173
|
+
|
|
174
|
+
// The text of one of the part's own files, by path or pfc-tree:// token.
|
|
175
|
+
file(pathOrToken) {
|
|
176
|
+
if (!files || typeof pathOrToken !== "string") return null;
|
|
177
|
+
const m = TREE_TOKEN_RE.exec(pathOrToken);
|
|
178
|
+
const path = (m ? m[1] : pathOrToken).replace(/^\.\//, "");
|
|
179
|
+
return Object.hasOwn(files, path) && typeof files[path] === "string" ? files[path] : null;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// Built-in controls bound INSIDE the owned value (scoped-params.js). One
|
|
183
|
+
// level: a custom control in the list is dropped and reported.
|
|
184
|
+
controls(container, controls, { path = "" } = {}) {
|
|
185
|
+
if (!buildSubPanel || retired) return () => {};
|
|
186
|
+
const stripped = stripNested(controls, (key) =>
|
|
187
|
+
report("create", `custom control "${key}" cannot be nested inside "${node.key}"`));
|
|
188
|
+
const scoped = scopedParams({
|
|
189
|
+
read: () => host.get(),
|
|
190
|
+
// A sub-control write can push the owned value past the JSON cap —
|
|
191
|
+
// that is the value contract refusing one edit, not widget code
|
|
192
|
+
// failing, so it is reported and swallowed rather than left to throw
|
|
193
|
+
// out of the built-in factory's own (unguarded) input handler.
|
|
194
|
+
write: (next) => {
|
|
195
|
+
try { host.set(next, { commit: false }); } catch (e) { report("event", errorText(e)); }
|
|
196
|
+
},
|
|
197
|
+
path,
|
|
198
|
+
onError: (message) => report("event", message),
|
|
199
|
+
});
|
|
200
|
+
const sub = buildSubPanel(container, stripped, scoped, () => {}, () => host.commit());
|
|
201
|
+
const dispose = () => { subPanels.delete(dispose); sub.dispose(); };
|
|
202
|
+
subPanels.add(dispose);
|
|
203
|
+
return dispose;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
guarded("create", () => {
|
|
208
|
+
if (typeof node.widget !== "function") throw new TypeError("`widget` is not a function");
|
|
209
|
+
const r = node.widget(host);
|
|
210
|
+
instance = r && typeof r === "object" ? r : null;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const update = (reason) => guarded("update", () => instance?.update?.({ reason, disabled: host.disabled }));
|
|
214
|
+
|
|
215
|
+
// Restoring panel state is not part of creation: a throw here is a bad
|
|
216
|
+
// `update`, not a bad `create` — the widget did construct successfully.
|
|
217
|
+
if (restoredState && !retired) update("restore");
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
el: wrap,
|
|
221
|
+
keys: ownedKeys,
|
|
222
|
+
sync: () => update("sync"),
|
|
223
|
+
onDerived: (derived) => {
|
|
224
|
+
const next = derived ?? {};
|
|
225
|
+
host.derived = next;
|
|
226
|
+
let s;
|
|
227
|
+
try {
|
|
228
|
+
s = JSON.stringify(next);
|
|
229
|
+
} catch {
|
|
230
|
+
// Not everything is stringifiable (a cycle, a BigInt). Treat that as
|
|
231
|
+
// "changed" rather than let it escape into panel.refresh().
|
|
232
|
+
update("derived");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (s === lastDerived) return;
|
|
236
|
+
lastDerived = s;
|
|
237
|
+
update("derived");
|
|
238
|
+
},
|
|
239
|
+
getState: () => host.state,
|
|
240
|
+
dispose: () => {
|
|
241
|
+
for (const d of [...subPanels]) { try { d(); } catch { /* disposing anyway */ } }
|
|
242
|
+
subPanels.clear();
|
|
243
|
+
// Attempted even when retired: a widget that already failed once (a
|
|
244
|
+
// throwing listener, say) still owns resources — timers, observers,
|
|
245
|
+
// an open sub-panel's DOM — that its own dispose() is the only code
|
|
246
|
+
// that knows how to release.
|
|
247
|
+
try { instance?.dispose?.(); } catch (e) { report("dispose", errorText(e)); }
|
|
248
|
+
// A disposed panel's host must stop accepting writes: host.set/commit
|
|
249
|
+
// are silent no-ops once retired, same as after a widget's own throw.
|
|
250
|
+
retired = true;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -7,6 +7,7 @@ import { makeSelect, makeRadio } from "./select.js";
|
|
|
7
7
|
import { makeFont } from "./font.js";
|
|
8
8
|
import { makeImage } from "./image.js";
|
|
9
9
|
import { makeVector } from "./vector.js";
|
|
10
|
+
import { makeCustom } from "./custom.js";
|
|
10
11
|
// Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
|
|
11
12
|
// setImagePicker() at module scope, so each widget's button finds a picker to
|
|
12
13
|
// open. They live HERE and not in font.js/image.js because the dependency has
|
|
@@ -27,4 +28,5 @@ export const WIDGET_FACTORIES = {
|
|
|
27
28
|
font: makeFont,
|
|
28
29
|
image: makeImage,
|
|
29
30
|
vector: makeVector,
|
|
31
|
+
custom: makeCustom,
|
|
30
32
|
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Public entry for `partforge/panel-values`: the value contract of a
|
|
2
|
+
// `type: "custom"` control, for hosts that persist panel settings (partforge-
|
|
3
|
+
// cloud rewrites `defaults` with these). Dependency-free, DOM-free, tiny — a
|
|
4
|
+
// host imports this without pulling partforge/lint's rule set into its bundle.
|
|
5
|
+
export { isJsonValue, jsonValueProblem, CUSTOM_VALUE_MAX_BYTES, CUSTOM_VALUE_MAX_DEPTH } from "./framework/panel/json-value.js";
|
|
6
|
+
export { readJsonLiteral, writeJsonLiteral } from "./framework/lint/source-scan.js";
|
package/types/index.d.ts
CHANGED
|
@@ -199,6 +199,17 @@ export interface MountOptions {
|
|
|
199
199
|
* part cannot support is dropped, never fatal.
|
|
200
200
|
*/
|
|
201
201
|
viewerState?: ViewerState | null;
|
|
202
|
+
/**
|
|
203
|
+
* A previous mount's `runtime.getPanelState()`: the transient state of the
|
|
204
|
+
* part's custom controls (a selected tile), keyed by param. Same remount
|
|
205
|
+
* story as `viewerState`; omit on a first mount. Never persisted by partforge.
|
|
206
|
+
*/
|
|
207
|
+
panelState?: PanelState | null;
|
|
208
|
+
/**
|
|
209
|
+
* The part's own source tree as text, for custom controls' `host.file(path)`.
|
|
210
|
+
* Omit and `host.file` answers null.
|
|
211
|
+
*/
|
|
212
|
+
files?: Record<string, string>;
|
|
202
213
|
/**
|
|
203
214
|
* A provider backing every `type: "font"` control in the part. partforge
|
|
204
215
|
* ships none — without one, a font control renders as a plain URL field.
|
|
@@ -256,6 +267,57 @@ export interface ViewerState {
|
|
|
256
267
|
cutaway: CutawayState | null;
|
|
257
268
|
}
|
|
258
269
|
|
|
270
|
+
/** Custom controls' transient state, keyed by the param each control owns. Plain JSON. */
|
|
271
|
+
export type PanelState = Record<string, Record<string, unknown>>;
|
|
272
|
+
|
|
273
|
+
export interface PanelError {
|
|
274
|
+
/** The param the failing control owns. */
|
|
275
|
+
key: string;
|
|
276
|
+
label: string;
|
|
277
|
+
phase: "create" | "update" | "event" | "dispose" | "state";
|
|
278
|
+
message: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** What a `type: "custom"` control's `widget(host)` receives. */
|
|
282
|
+
export interface CustomControlHost {
|
|
283
|
+
/** The slot to draw into, inside the rail. */
|
|
284
|
+
el: HTMLElement;
|
|
285
|
+
doc: Document;
|
|
286
|
+
/** The param this control owns. */
|
|
287
|
+
key: string;
|
|
288
|
+
/** A structured clone of the current value of `key` (default: the owned key). */
|
|
289
|
+
get(key?: string): unknown;
|
|
290
|
+
/**
|
|
291
|
+
* Replace a value. Refused (throws TypeError) for a key the control does not
|
|
292
|
+
* own or a value outside the contract (owned key: a JSON value; `keys`: a
|
|
293
|
+
* scalar). Stores a clone, schedules a rebuild, and commits unless
|
|
294
|
+
* `commit: false` — then call `commit()` when the gesture ends.
|
|
295
|
+
*/
|
|
296
|
+
set(value: unknown, opts?: { key?: string; commit?: boolean }): void;
|
|
297
|
+
commit(keys?: string[]): void;
|
|
298
|
+
/** The latest `derive()` output. */
|
|
299
|
+
derived: Record<string, unknown>;
|
|
300
|
+
/** Transient JSON state that survives a remount; never a param. */
|
|
301
|
+
state: Record<string, unknown>;
|
|
302
|
+
setState(patch: Record<string, unknown>): void;
|
|
303
|
+
/** Mount built-in controls bound at `path` inside the owned value. Returns a disposer. */
|
|
304
|
+
controls(container: HTMLElement, controls: import("./part.js").PanelEntry[], opts?: { path?: string }): () => void;
|
|
305
|
+
/** Element builder; SVG tags get the SVG namespace, `on*` attrs become listeners. */
|
|
306
|
+
h(tag: string, attrs?: Record<string, unknown>, ...children: unknown[]): Element;
|
|
307
|
+
/** Parse an SVG string to its root element, or null. */
|
|
308
|
+
svg(text: string): SVGSVGElement | null;
|
|
309
|
+
/** Render a partforge-vector document to an inline `<svg>`, or null. */
|
|
310
|
+
svgFromVector(doc: unknown): SVGSVGElement | null;
|
|
311
|
+
/** Text of one of the part's own files, by path or `pfc-tree://` token, or null. */
|
|
312
|
+
file(pathOrToken: string): string | null;
|
|
313
|
+
readonly disabled: boolean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface CustomControlInstance {
|
|
317
|
+
update?(ctx: { reason: "sync" | "derived" | "restore"; disabled: boolean }): void;
|
|
318
|
+
dispose?(): void;
|
|
319
|
+
}
|
|
320
|
+
|
|
259
321
|
export interface ExportPartsOptions {
|
|
260
322
|
/** Sub-part names, as `listExportableParts()` reports them. */
|
|
261
323
|
parts: string[];
|
|
@@ -472,6 +534,10 @@ export interface PartRuntime {
|
|
|
472
534
|
* view-cube click, Reframe, or an animation cue).
|
|
473
535
|
*/
|
|
474
536
|
getViewerState(): ViewerState;
|
|
537
|
+
/** Every custom control's non-empty transient state, keyed by param. Hand back as `panelState`. */
|
|
538
|
+
getPanelState(): PanelState;
|
|
539
|
+
/** What custom controls reported failing this mount, in order. */
|
|
540
|
+
getPanelErrors(): PanelError[];
|
|
475
541
|
/**
|
|
476
542
|
* Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
|
|
477
543
|
* can say so rather than showing a dead canvas. The listener takes no
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// partforge/panel-values — the value contract of a `type: "custom"` control.
|
|
2
|
+
|
|
3
|
+
export const CUSTOM_VALUE_MAX_BYTES: 16384;
|
|
4
|
+
export const CUSTOM_VALUE_MAX_DEPTH: 8;
|
|
5
|
+
|
|
6
|
+
export interface JsonValueLimits { maxBytes?: number; maxDepth?: number }
|
|
7
|
+
|
|
8
|
+
/** A string, boolean, finite number, or an array / plain object of those. */
|
|
9
|
+
export type JsonValue = string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
|
|
10
|
+
|
|
11
|
+
/** Why `v` is not a JSON value within the caps (a fragment such as `is null at tiles[2].h`), or null. */
|
|
12
|
+
export function jsonValueProblem(v: unknown, limits?: JsonValueLimits): string | null;
|
|
13
|
+
export function isJsonValue(v: unknown, limits?: JsonValueLimits): v is JsonValue;
|
|
14
|
+
|
|
15
|
+
/** Parse an array/object literal's source text (bare or quoted keys, trailing commas, JS string escapes). Null on anything else. */
|
|
16
|
+
export function readJsonLiteral(text: string): { value: JsonValue } | null;
|
|
17
|
+
/** Source text for a value: compact under 80 chars, else indented JSON relative to `indent`. */
|
|
18
|
+
export function writeJsonLiteral(value: JsonValue, opts?: { indent?: string }): string;
|