partforge 0.146.3 → 0.146.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1248,7 +1248,12 @@ export function tilePicker(host) {
1248
1248
  `update({reason, disabled})` is called when your key changes from outside the widget
1249
1249
  (`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
1250
1250
  (`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
1251
- **not** called for your own `set`. `dispose()` runs on teardown.
1251
+ **not** called for your own `set`, and a `"derived"` update is **held while a gesture is
1252
+ in flight** — a `set` with `commit: false`, or a `host.controls` sub-control being
1253
+ edited — and delivered once `host.commit()` ends it (`host.derived` is current
1254
+ throughout). That is what lets the pattern above rebuild its sub-panel on every
1255
+ `update` without pulling the slider or number box out from under the user: every
1256
+ build the drag itself triggers ends in a derived update. `dispose()` runs on teardown.
1252
1257
 
1253
1258
  **Rules that keep it working.**
1254
1259
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.146.3",
3
+ "version": "0.146.4",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -69,6 +69,10 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
69
69
  let instance = null;
70
70
  let retired = false;
71
71
  let lastDerived = "{}";
72
+ // A derived update that arrived while a deferred gesture was in flight
73
+ // (`pending` non-empty): held, delivered once the gesture commits. See
74
+ // `update` below.
75
+ let heldUpdate = false;
72
76
 
73
77
  const report = (phase, message) => onError?.({ key: node.key, label, phase, message });
74
78
 
@@ -131,6 +135,9 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
131
135
  if (!changed.length) return;
132
136
  for (const k of changed) pending.delete(k);
133
137
  onCommit?.(changed);
138
+ // The gesture is over: deliver the derived update it held back (once,
139
+ // for the latest derived — host.derived already carries it).
140
+ if (heldUpdate && pending.size === 0) { heldUpdate = false; update("derived"); }
134
141
  },
135
142
 
136
143
  setState(patch) { if (retired) return; Object.assign(host.state, patch); },
@@ -210,7 +217,21 @@ export function makeCustom(node, params, { onChange, onCommit, info, custom = {}
210
217
  instance = r && typeof r === "object" ? r : null;
211
218
  });
212
219
 
213
- const update = (reason) => guarded("update", () => instance?.update?.({ reason, disabled: host.disabled }));
220
+ // A derived update is HELD while a deferred gesture is in flight — a
221
+ // `host.set(…, {commit: false})` or a host.controls sub-control edit that
222
+ // has not committed yet. Every build the gesture itself triggers (a slider
223
+ // drag's live preview, each keystroke in a number box) ends in a derived
224
+ // refresh, and the documented widget pattern answers update() by rebuilding
225
+ // its sub-panel — which tore the input being edited out of the DOM before
226
+ // its `change` could fire. Chromium dispatches `change` on detach; WebKit
227
+ // does not, so on Safari the edit reached the viewer and was never
228
+ // committed (partforge-cloud feedback #172). host.derived is still updated
229
+ // at once; only the call is deferred, to the commit that ends the gesture.
230
+ // `sync` is never held: an outside write supersedes the gesture.
231
+ const update = (reason) => {
232
+ if (reason === "derived" && pending.size > 0) { heldUpdate = true; return; }
233
+ guarded("update", () => instance?.update?.({ reason, disabled: host.disabled }));
234
+ };
214
235
 
215
236
  // Restoring panel state is not part of creation: a throw here is a bad
216
237
  // `update`, not a bad `create` — the widget did construct successfully.
@@ -67,6 +67,10 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
67
67
  // the log mapping don't, so be explicit).
68
68
  const thumbFor = (v) => log ? toPosSafe(v) : Math.max(node.min, Math.min(node.max, v));
69
69
 
70
+ // Edited (an `input`) since the last `change`. A widget torn down mid-edit
71
+ // commits what it holds (dispose below): the DOM fires no `change` for a
72
+ // detached input on WebKit, and the value is already in params either way.
73
+ let dirty = false;
70
74
  let slider = null;
71
75
  if (!numeric) {
72
76
  slider = document.createElement("input");
@@ -78,9 +82,10 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
78
82
  params[node.key] = v;
79
83
  box.value = numStr(v);
80
84
  paintWarn();
85
+ dirty = true;
81
86
  onChange?.();
82
87
  });
83
- slider.addEventListener("change", () => onCommit?.());
88
+ slider.addEventListener("change", () => { dirty = false; onCommit?.(); });
84
89
  wrap.append(slider);
85
90
  }
86
91
 
@@ -131,6 +136,7 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
131
136
  params[node.key] = v;
132
137
  if (slider) slider.value = thumbFor(v);
133
138
  paintWarn();
139
+ dirty = true;
134
140
  onChange?.();
135
141
  });
136
142
  box.addEventListener("change", () => {
@@ -140,6 +146,7 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
140
146
  box.value = numStr(v);
141
147
  if (slider) slider.value = thumbFor(v);
142
148
  paintWarn();
149
+ dirty = false;
143
150
  onChange?.();
144
151
  onCommit?.();
145
152
  });
@@ -150,5 +157,8 @@ export function makeNumeric(node, params, { onChange, onCommit, info }) {
150
157
  paintWarn();
151
158
  };
152
159
  paintWarn();
153
- return { el: wrap, sync };
160
+ // Torn down with an edit still uncommitted (a sub-panel rebuilt under the
161
+ // pointer, a remount): commit it, since no `change` is coming.
162
+ const dispose = () => { if (dirty) { dirty = false; onCommit?.(); } };
163
+ return { el: wrap, sync, dispose };
154
164
  }