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.
- package/docs/AUTHORING-PARTS.md +141 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/geometry/polygon.js +67 -5
- 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/geometry.d.ts +2 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|