partforge 0.112.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.
@@ -63,7 +63,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
63
63
  // carries the worker's own error text. See the correlated "error" case below.
64
64
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
65
65
 
66
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
66
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors }) {
67
67
  return {
68
68
  ready, dispose, setParams,
69
69
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -101,6 +101,14 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
101
101
  projection: viewer.getProjection?.() ?? "perspective",
102
102
  cutaway: viewer.getCutawayState?.() ?? null,
103
103
  }),
104
+ // Every custom control's transient state (selection, a scroll position),
105
+ // keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
106
+ // it back as mount()'s `panelState` and a remount comes up with the same
107
+ // tile selected. {} when the mount resolved no panel.
108
+ getPanelState: getPanelState ?? (() => ({})),
109
+ // What custom controls reported failing this mount ({key, label, phase,
110
+ // message}), for a host to relay to whoever authored the part.
111
+ getPanelErrors: getPanelErrors ?? (() => []),
104
112
  // Park/unpark the viewer: stops the render loop and frees the drawing
105
113
  // buffer and the cached capture target. For an embedder that hides the
106
114
  // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
@@ -305,6 +313,13 @@ function createCleanupStack() {
305
313
  // // first mount — the viewer then restores its own persisted
306
314
  // // camera as before. Restore is best-effort per field: a pose
307
315
  // // this part cannot support is dropped, never fatal.
316
+ // panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
317
+ // // state of the part's custom controls (a selected tile), keyed
318
+ // // by param. Same remount story as viewerState; omit on a first
319
+ // // mount. Never persisted by partforge.
320
+ // files: { [path]: string } // the part's own source tree as text, for custom controls'
321
+ // // host.file(path) — an SVG or a vector document that lives
322
+ // // beside the code. Omit and host.file answers null.
308
323
  // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
309
324
  // // Send in the sketch toolbar alongside the other tools.
310
325
  // // "host" drops it: the host draws its own send control —
@@ -331,6 +346,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
331
346
  imageCatalog,
332
347
  onAssetUpload,
333
348
  viewerState,
349
+ files, panelState,
334
350
  annotateSend = "viewbar",
335
351
  container: legacyContainer, controls: legacyControls } = {}) {
336
352
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
@@ -1017,7 +1033,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1017
1033
  // showing the bundled default instead of an empty tile. Rebuilt per panel
1018
1034
  // build, because the declaration is a function of the current params.
1019
1035
  { fontCatalog, imageCatalog, onAssetUpload,
1020
- declaredSource: declaredSourceLookup(part, params) });
1036
+ declaredSource: declaredSourceLookup(part, params),
1037
+ files, panelState });
1021
1038
  cleanup.defer(() => panel.dispose());
1022
1039
  panelRef = panel;
1023
1040
  const updateRelevance = () => {
@@ -1194,6 +1211,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1194
1211
  getView: view, // () => tabsCtl.current()
1195
1212
  setView: (name) => tabsCtl.select(name),
1196
1213
  captureView,
1214
+ getPanelState: () => panelRef?.getState() ?? {},
1215
+ getPanelErrors: () => panelRef?.errors() ?? [],
1197
1216
  listExportableParts: () =>
1198
1217
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
1199
1218
  exportParts: (opts) => exportCtl.exportParts(opts),
@@ -44,6 +44,11 @@ function authoredControl(c) {
44
44
  allow: c.allow,
45
45
  preview: c.preview,
46
46
  sourceField: c.sourceField,
47
+ // Custom controls (type: "custom"): the author's widget function and the
48
+ // extra scalar keys it may write. Both are on this allow-list for the same
49
+ // reason `allow` is (see above) — a field missing here is silently dropped.
50
+ widget: c.widget,
51
+ keys: c.keys,
47
52
  preserveOn: false,
48
53
  marksCustom: true,
49
54
  };
@@ -0,0 +1,55 @@
1
+ //
2
+ // The ONE predicate for the value a `type: "custom"` control may own. The panel
3
+ // (host.set, panel state), lint (custom-default-not-json) and partforge-cloud's
4
+ // persistence (through the `partforge/panel-values` export) all ask this module,
5
+ // so "the linter accepts it" and "the save can write it" cannot drift apart.
6
+ //
7
+ // Imports nothing: it sits inside partforge/lint's pure closure
8
+ // (test/lint-purity.test.js) and inside the sandbox iframe.
9
+
10
+ export const CUSTOM_VALUE_MAX_BYTES = 16384;
11
+ export const CUSTOM_VALUE_MAX_DEPTH = 8;
12
+
13
+ // Keys a JSON value may never carry: on a plain object each of these reaches
14
+ // Object.prototype, so a value that round-trips through JSON.parse and a
15
+ // plain-object assignment could change what `params.x.constructor` means.
16
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
17
+
18
+ const at = (path) => (path ? ` at ${path}` : "");
19
+
20
+ function shapeProblem(v, depth, maxDepth, path) {
21
+ if (v === null) return `is null${at(path)}`;
22
+ const t = typeof v;
23
+ if (t === "number") return Number.isFinite(v) ? null : `is a non-finite number${at(path)}`;
24
+ if (t === "string" || t === "boolean") return null;
25
+ if (t !== "object") return `is a ${t}${at(path)}`;
26
+ if (depth >= maxDepth) return `nests deeper than ${maxDepth} levels${at(path)}`;
27
+ if (Array.isArray(v)) {
28
+ for (let i = 0; i < v.length; i++) {
29
+ const p = shapeProblem(v[i], depth + 1, maxDepth, `${path}[${i}]`);
30
+ if (p) return p;
31
+ }
32
+ return null;
33
+ }
34
+ const proto = Object.getPrototypeOf(v);
35
+ if (proto !== Object.prototype && proto !== null) return `is not a plain object${at(path)}`;
36
+ for (const k of Object.keys(v)) {
37
+ if (FORBIDDEN_KEYS.has(k)) return `uses the forbidden key "${k}"${at(path)}`;
38
+ const p = shapeProblem(v[k], depth + 1, maxDepth, path ? `${path}.${k}` : k);
39
+ if (p) return p;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // Why `v` is not a JSON value — a sentence fragment that reads after the
45
+ // value's name ("`defaults.tiles` is null at tiles[2].h") — or null when it is.
46
+ export function jsonValueProblem(v, { maxBytes = CUSTOM_VALUE_MAX_BYTES, maxDepth = CUSTOM_VALUE_MAX_DEPTH } = {}) {
47
+ const shape = shapeProblem(v, 0, maxDepth, "");
48
+ if (shape) return shape;
49
+ if (maxBytes === Infinity) return null;
50
+ const bytes = new TextEncoder().encode(JSON.stringify(v)).length;
51
+ if (bytes > maxBytes) return `is ${bytes} bytes serialized; the cap is ${maxBytes}`;
52
+ return null;
53
+ }
54
+
55
+ export const isJsonValue = (v, opts) => jsonValueProblem(v, opts) === null;
@@ -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
- onCommit: () => commit([node.key]),
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
- syncFns.push({
247
- key: node.key,
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
- // The whole header row toggles: the title button's own click bubbles up
280
- // here, the chevron and the empty row space hit it directly, and the ⓘ
281
- // stops propagation in attachInfo. aria state stays on the title button.
282
- header.addEventListener("click", () => {
283
- const nowHidden = body.classList.toggle("hidden");
284
- title.setAttribute("aria-expanded", String(!nowHidden));
285
- secEl.classList.toggle("collapsed", nowHidden);
286
- });
287
- disclosures.set(section.id, { body, button: title, el: secEl });
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
+ }