partforge 0.73.1 → 0.75.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.
@@ -247,6 +247,7 @@ function createCleanupStack() {
247
247
  // exactly once here — submodules take element refs and never query the document.
248
248
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
249
249
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
250
+ fontCatalog,
250
251
  annotateSend = "viewbar",
251
252
  container: legacyContainer, controls: legacyControls } = {}) {
252
253
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
@@ -835,7 +836,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
835
836
  onParamChange();
836
837
  }, onParamsCommit
837
838
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
838
- : undefined);
839
+ : undefined,
840
+ { fontCatalog });
839
841
  cleanup.defer(() => panel.dispose());
840
842
  panelRef = panel;
841
843
  const updateRelevance = () => {
@@ -910,6 +912,21 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
910
912
  },
911
913
  });
912
914
  if (animCtl) cleanup.defer(() => animCtl.detach());
915
+ // Sketch mode takes the transport bar's slot: the host floats its own
916
+ // composer there (partforge-cloud's sketch composer), and ink is stored in
917
+ // screen space against the pose it was drawn over — a playing animation
918
+ // under it is meaningless. STOP first, then hide: hiding first leaves a
919
+ // frame where an animation still drives the model beneath a bar that is
920
+ // already gone. Playback does not resume on the way out; "stop" is the
921
+ // contract, and notifyUserEdit is the same pause a user's own control edit
922
+ // performs.
923
+ if (annotateMode && animCtl) {
924
+ cleanup.defer(annotateMode.onModeChange(() => {
925
+ const on = annotateMode.isEnabled();
926
+ if (on) animCtl.notifyUserEdit();
927
+ animCtl.setHidden(on);
928
+ }));
929
+ }
913
930
 
914
931
  // Re-run the active view under the current caching setting, so toggling the
915
932
  // ?debug switch updates the readout for the same design without a param change.
@@ -0,0 +1,400 @@
1
+ // The `type: "font"` picker: a takeover panel over the rail with two sliding
2
+ // panes (families, then that family's weights) and a shared footer.
3
+ //
4
+ // Main-thread only — it is DOM-heavy and is NOT part of the worker graph.
5
+ // It draws list rows in each family's own face by loading Google's name-only
6
+ // `menuUrl` subset through a FontFace, which is why a row costs a few KB and
7
+ // not the whole family.
8
+ //
9
+ // Ported from spike/font-picker.html, whose layout and interaction were settled
10
+ // against a running build over the real 1,942-family catalog (spec §6). The
11
+ // spike's own data path — a bundled catalog.json plus the Google CSS API — does
12
+ // NOT come along: here the families arrive from the host's `fontCatalog` and
13
+ // every face is a `FontFace` over a URL that catalog handed us.
14
+ import { fontLabel, variantLabel, setFontPicker } from "./widgets/font.js";
15
+ import { fontSourceAllowed } from "../font-source.js";
16
+
17
+ const ROW_H = 44; // comfortable density (spec §6)
18
+ const OVERSCAN = 4; // rows rendered above/below the viewport
19
+ const SEARCH_LIMIT = 200;
20
+ const SEARCH_DEBOUNCE_MS = 120;
21
+ const SAMPLE = "Hamburgefonstiv 0123"; // the default variant-pane sample; `preview` overrides it
22
+ // A variants pane can hold 18 weights; auto-loading every real face for a CJK
23
+ // family would be tens of megabytes on one click. Past this, the sample line
24
+ // falls back to the panel font with the weight synthesized.
25
+ const VARIANT_FACE_MAX_BYTES = 1_500_000;
26
+
27
+ function el(tag, className, text) {
28
+ const node = document.createElement(tag);
29
+ if (className) node.className = className;
30
+ if (text != null) node.textContent = text;
31
+ return node;
32
+ }
33
+
34
+ // Family names come from the host and land in a `font-family` declaration, so
35
+ // strip the two characters that could end the quoted string early.
36
+ const faceStack = (...names) =>
37
+ [...names.map((n) => `"${String(n).replace(/["\\]/g, "")}"`), "var(--pf-sans)"].join(", ");
38
+
39
+ const kbLabel = (bytes) => (Number.isFinite(bytes) ? `${Math.round(bytes / 1024)}K` : "");
40
+
41
+ // The face a row advertises, and the one a click lands on when the user has no
42
+ // standing weight preference. Kept separate from `pickVariant` so the row's
43
+ // size caption does not change under the user as they audition weights.
44
+ const listVariant = (f) =>
45
+ f.variants.find((v) => v.variant === "400" || v.variant === "regular") ?? f.variants[0];
46
+
47
+ // At most one picker is open at a time, and the previous one has to be CLOSED
48
+ // rather than merely detached: its `keydown` listener lives on `document`, so
49
+ // dropping the element off the DOM leaves the handler — and the whole closure,
50
+ // up to 200 admitted families — alive forever, one more on every re-open.
51
+ // Only close() unregisters it, so every path that supersedes a picker goes
52
+ // through here.
53
+ let openPicker = null;
54
+
55
+ export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked }) {
56
+ // Takeover: the picker covers the rail on desktop and the single visible pane
57
+ // below the narrow breakpoint. One layout for both widths (spec §6).
58
+ const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
59
+ openPicker?.close(); // never two at once
60
+
61
+ // ── state ───────────────────────────────────────────────────────────────
62
+ // The author's `preview` string, when they set one. A part lettered in digits,
63
+ // or in a script "Hamburgefonstiv" cannot even render, is auditioned against
64
+ // the wrong glyphs by the generic sample — which is the whole point of the
65
+ // field (spec §1). Blank or non-string falls back to the default.
66
+ const sampleText = typeof node.preview === "string" && node.preview.trim() ? node.preview : SAMPLE;
67
+ let results = []; // what the catalog last returned…
68
+ let resultsQuery = ""; // …for this query
69
+ let query = ""; // what is in the box right now
70
+ let rows = []; // what the list is showing
71
+ let closed = false;
72
+ let searchSeq = 0;
73
+ let debounce = null;
74
+ let failed = false;
75
+ // The value alone cannot name a live-picked face (a gstatic filename is a
76
+ // content hash), so start from `fontLabel` and sharpen it the moment the
77
+ // catalog hands us a family whose variant URL is this exact value.
78
+ const initial = fontLabel(params[node.key]);
79
+ let selFamily = initial.family;
80
+ let selVariant = initial.variant ?? "400";
81
+ let selBytes = null;
82
+ let openFamily = null; // the family the variants pane is showing
83
+
84
+ const faceRequested = new Set(); // families whose menu face we have asked for
85
+ // …and the ones we are no longer waiting on: arrived, or definitively failed.
86
+ // A row is dimmed while its face is PENDING; a 404 is settled, not pending,
87
+ // so it goes back to full strength in the panel font rather than staying grey.
88
+ const faceSettled = new Set();
89
+ const variantFaces = new Set(); // variant URLs already loaded
90
+
91
+ // ── DOM ─────────────────────────────────────────────────────────────────
92
+ const picker = el("div", "picker");
93
+ const panes = el("div", "pk-panes");
94
+
95
+ const browse = el("div", "pk-pane");
96
+ browse.dataset.pane = "browse";
97
+ const head = el("div", "pk-head");
98
+ const titlebar = el("div", "pk-titlebar");
99
+ const closeBtn = el("button", "pk-x", "\u00d7");
100
+ closeBtn.type = "button";
101
+ closeBtn.title = "Close";
102
+ titlebar.append(el("b", "", node.label ?? node.key), closeBtn);
103
+ const search = document.createElement("input");
104
+ search.className = "pk-search";
105
+ search.type = "text";
106
+ search.placeholder = "Search fonts";
107
+ search.autocomplete = "off";
108
+ search.spellcheck = false;
109
+ head.append(titlebar, search);
110
+ const hint = el("div", "pk-hint");
111
+ hint.hidden = true;
112
+ const list = el("div", "pk-list");
113
+ const spacer = el("div", "pk-spacer");
114
+ const empty = el("p", "pk-empty");
115
+ empty.hidden = true;
116
+ list.append(spacer, empty);
117
+ browse.append(head, hint, list);
118
+
119
+ const variants = el("div", "pk-pane");
120
+ variants.dataset.pane = "variants";
121
+ const vhead = el("div", "pk-head");
122
+ const back = el("button", "pk-back", "\u2190 all families");
123
+ back.type = "button";
124
+ const vtitlebar = el("div", "pk-titlebar");
125
+ const vtitle = el("b");
126
+ vtitlebar.append(vtitle);
127
+ vhead.append(back, vtitlebar);
128
+ const vlist = el("div", "pk-vlist");
129
+ variants.append(vhead, vlist);
130
+
131
+ panes.append(browse, variants);
132
+
133
+ // The footer sits BELOW the sliding pane box, not inside it, so Done stays
134
+ // reachable from either pane — picking a weight never has to exit to commit.
135
+ const foot = el("div", "pk-foot");
136
+ const sel = el("span", "pk-sel");
137
+ const done = el("button", "pk-done", "Done");
138
+ done.type = "button";
139
+ foot.append(sel, done);
140
+
141
+ picker.append(panes, foot);
142
+ host.append(picker);
143
+ paintSel();
144
+ search.focus?.();
145
+
146
+ // ── faces ───────────────────────────────────────────────────────────────
147
+ // happy-dom (and any non-browser host) may not implement FontFace at all; a
148
+ // missing one must degrade to un-styled rows, never throw.
149
+ const canLoadFaces = () => typeof FontFace === "function" && typeof document.fonts?.add === "function";
150
+
151
+ function settle(family) {
152
+ faceSettled.add(family);
153
+ if (closed) return;
154
+ for (const row of spacer.children) {
155
+ if (row.dataset.family === family) row.classList.remove("loading");
156
+ }
157
+ }
158
+
159
+ function requestFaces(families) {
160
+ if (!canLoadFaces()) return;
161
+ for (const f of families) {
162
+ if (faceRequested.has(f.family)) continue;
163
+ faceRequested.add(f.family);
164
+ // The menu file is fetched, so it goes through the same allowlist as the
165
+ // value itself — a catalog is host-supplied, not trusted.
166
+ if (!f.menuUrl || !fontSourceAllowed(f.menuUrl, allow)) { settle(f.family); continue; }
167
+ let face;
168
+ try { face = new FontFace(f.family, `url(${f.menuUrl})`); } catch { settle(f.family); continue; }
169
+ face.load()
170
+ .then((loaded) => { document.fonts.add(loaded); })
171
+ .catch(() => { /* a family that will not load stays in the panel font */ })
172
+ .then(() => settle(f.family));
173
+ }
174
+ }
175
+
176
+ // The variants pane needs the REAL weights — the menu subset carries only the
177
+ // family name's glyphs at one weight, so it cannot show what 700 looks like.
178
+ // Each face is registered under `<family> <variant>` so the weights do not
179
+ // collide with each other or with the menu face.
180
+ function requestVariantFace(family, v) {
181
+ if (!canLoadFaces()) return;
182
+ if (variantFaces.has(v.url) || !fontSourceAllowed(v.url, allow)) return;
183
+ if (Number.isFinite(v.bytes) && v.bytes > VARIANT_FACE_MAX_BYTES) return;
184
+ variantFaces.add(v.url);
185
+ let face;
186
+ try { face = new FontFace(`${family} ${v.variant}`, `url(${v.url})`); } catch { return; }
187
+ face.load().then((loaded) => document.fonts.add(loaded)).catch(() => {});
188
+ }
189
+
190
+ // ── the list ────────────────────────────────────────────────────────────
191
+ // Reconcile by (index, family, selected) — index ALONE is wrong: after a
192
+ // search the same index holds a different family, and an index-keyed row
193
+ // keeps rendering the old one. The spike paid a screenshot to find this.
194
+ const rowKey = (i, f) => `${i}|${f.family}|${f.family === selFamily ? 1 : 0}`;
195
+
196
+ function rowEl(i, f) {
197
+ const row = el("div", "pk-row" + (f.family === selFamily ? " sel" : ""));
198
+ row.dataset.i = String(i);
199
+ row.dataset.key = rowKey(i, f);
200
+ row.dataset.family = f.family;
201
+ if (canLoadFaces() && !faceSettled.has(f.family)) row.classList.add("loading");
202
+ const main = el("div", "pk-main");
203
+ const face = el("div", "pk-face", f.family);
204
+ face.style.fontFamily = faceStack(f.family);
205
+ const n = f.variants.length;
206
+ main.append(face, el("div", "pk-sub", `${n} style${n === 1 ? "" : "s"} · ${f.category ?? "—"}`));
207
+ row.append(main, el("div", "pk-meta", kbLabel(listVariant(f)?.bytes)));
208
+ row.addEventListener("click", () => choose(f));
209
+ return row;
210
+ }
211
+
212
+ function render() {
213
+ if (closed) return;
214
+ spacer.style.height = `${rows.length * ROW_H}px`;
215
+ const top = list.scrollTop || 0;
216
+ const vh = list.clientHeight || 360;
217
+ const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
218
+ const last = Math.min(rows.length, Math.ceil((top + vh) / ROW_H) + OVERSCAN);
219
+
220
+ const wanted = new Map();
221
+ for (let i = first; i < last; i++) wanted.set(i, rows[i]);
222
+ requestFaces([...wanted.values()]);
223
+
224
+ for (const node_ of [...spacer.children]) {
225
+ const i = Number(node_.dataset.i);
226
+ if (!wanted.has(i) || node_.dataset.key !== rowKey(i, wanted.get(i))) node_.remove();
227
+ else wanted.delete(i);
228
+ }
229
+ for (const [i, f] of wanted) spacer.append(rowEl(i, f));
230
+ for (const node_ of spacer.children) {
231
+ node_.style.height = `${ROW_H}px`;
232
+ node_.style.transform = `translateY(${Number(node_.dataset.i) * ROW_H}px)`;
233
+ }
234
+
235
+ empty.hidden = rows.length > 0;
236
+ if (!rows.length) {
237
+ empty.textContent = failed ? "The font catalog is unavailable."
238
+ : query.trim() ? `No families match "${query.trim()}".`
239
+ : "No families available.";
240
+ }
241
+ hint.hidden = !query.trim() || !rows.length;
242
+ if (!hint.hidden) hint.textContent = `${rows.length.toLocaleString()} match${rows.length === 1 ? "" : "es"}`;
243
+ }
244
+
245
+ // Drop variants the allowlist refuses, and drop a family left with none — the
246
+ // UI half of the font-source check. A family we cannot legally write must not
247
+ // be offered, not merely fail on click.
248
+ function admissible(entries) {
249
+ const out = [];
250
+ for (const f of entries ?? []) {
251
+ if (!f || typeof f.family !== "string" || !Array.isArray(f.variants)) continue;
252
+ const ok = f.variants.filter((v) => v && fontSourceAllowed(v.url, allow));
253
+ if (!ok.length) continue;
254
+ out.push({ ...f, variants: ok });
255
+ if (!selBytes) {
256
+ const hit = ok.find((v) => v.url === params[node.key]);
257
+ if (hit) { selFamily = f.family; selVariant = hit.variant; selBytes = hit.bytes; paintSel(); }
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ // While the user is typing ahead of the catalog, narrow what is already in
264
+ // hand rather than blanking the list; once the catalog has answered for this
265
+ // exact query, show precisely what it returned (its matching may be fuzzier
266
+ // than a substring test, and second-guessing it would drop real hits).
267
+ function recompute() {
268
+ // Compare TRIMMED against trimmed: runSearch stores the trimmed query, so a
269
+ // trailing space would otherwise never match and the list would stay stuck
270
+ // on the client-side narrowing instead of showing the catalog's answer.
271
+ const q = query.trim();
272
+ rows = resultsQuery === q || !q
273
+ ? results
274
+ : results.filter((f) => f.family.toLowerCase().includes(q.toLowerCase()));
275
+ render();
276
+ }
277
+
278
+ function runSearch(q) {
279
+ const seq = ++searchSeq;
280
+ Promise.resolve()
281
+ .then(() => fontCatalog.search(q, { limit: SEARCH_LIMIT }))
282
+ .then((entries) => {
283
+ if (closed || seq !== searchSeq) return; // a newer search already won
284
+ failed = false;
285
+ results = admissible(entries);
286
+ resultsQuery = q;
287
+ recompute();
288
+ })
289
+ .catch(() => {
290
+ if (closed || seq !== searchSeq) return;
291
+ failed = true;
292
+ results = [];
293
+ resultsQuery = q;
294
+ recompute();
295
+ });
296
+ }
297
+
298
+ search.addEventListener("input", () => {
299
+ query = search.value;
300
+ list.scrollTop = 0; // a new query starts at the top
301
+ recompute(); // instant, from what we hold
302
+ clearTimeout(debounce);
303
+ debounce = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
304
+ });
305
+ list.addEventListener("scroll", render);
306
+
307
+ // ── choosing ────────────────────────────────────────────────────────────
308
+ const pickVariant = (f) =>
309
+ f.variants.find((v) => v.variant === selVariant) ?? listVariant(f);
310
+
311
+ function choose(f) {
312
+ commit(f, pickVariant(f));
313
+ // 1,036 of the 1,942 catalog families ship a single face. Stepping into a
314
+ // one-row weight list you immediately back out of is pure friction, so for
315
+ // those the row click IS the selection and the list stays put (spec §6).
316
+ if (f.variants.length > 1) openVariants(f);
317
+ }
318
+
319
+ function commit(f, v) {
320
+ if (!v) return;
321
+ params[node.key] = v.url;
322
+ selFamily = f.family;
323
+ selVariant = v.variant;
324
+ selBytes = v.bytes;
325
+ onPicked?.();
326
+ paintSel();
327
+ paintVariantRows();
328
+ render();
329
+ }
330
+
331
+ function paintSel() {
332
+ sel.textContent = "";
333
+ const strong = el("b", "", selFamily);
334
+ const rest = ` · ${variantLabel(selVariant)}` + (selBytes ? ` · ${kbLabel(selBytes)}` : "");
335
+ sel.append(strong, document.createTextNode(rest));
336
+ }
337
+
338
+ function openVariants(f) {
339
+ openFamily = f;
340
+ vtitle.textContent = f.family;
341
+ vlist.textContent = "";
342
+ for (const v of f.variants) {
343
+ const b = el("button", "vrow" + (v.variant === selVariant ? " on" : ""));
344
+ b.type = "button";
345
+ b.dataset.v = v.variant;
346
+ const sample = el("span", "vsample", sampleText);
347
+ sample.style.fontFamily = faceStack(`${f.family} ${v.variant}`, f.family);
348
+ sample.style.fontWeight = String(v.variant).replace(/i$/, "") || "400";
349
+ sample.style.fontStyle = /i$/.test(String(v.variant)) ? "italic" : "normal";
350
+ b.append(sample, el("span", "vlabel", v.label ?? variantLabel(v.variant)));
351
+ // Commit WITHOUT leaving — you audition weights against the live
352
+ // geometry, so committing and navigating are separate actions (spec §6).
353
+ b.addEventListener("click", () => commit(f, v));
354
+ requestVariantFace(f.family, v);
355
+ vlist.append(b);
356
+ }
357
+ vlist.scrollTop = 0;
358
+ picker.classList.add("at-variants");
359
+ }
360
+
361
+ // Repaint which weight is current without rebuilding the list — the pane
362
+ // stays put while you audition, so the rows must not be torn down under you.
363
+ function paintVariantRows() {
364
+ if (openFamily?.family !== selFamily) return;
365
+ for (const b of vlist.children) b.classList.toggle("on", b.dataset.v === selVariant);
366
+ }
367
+
368
+ const leaveVariants = () => { openFamily = null; picker.classList.remove("at-variants"); render(); };
369
+ back.addEventListener("click", leaveVariants);
370
+
371
+ // ── closing ─────────────────────────────────────────────────────────────
372
+ // Named here so close() can clear the module-level handle; `close` is a
373
+ // hoisted function declaration, so this captures it.
374
+ const handle = { close };
375
+
376
+ function close() {
377
+ if (closed) return; // idempotent
378
+ closed = true;
379
+ clearTimeout(debounce);
380
+ document.removeEventListener("keydown", onKey);
381
+ picker.remove();
382
+ if (openPicker === handle) openPicker = null;
383
+ }
384
+ function onKey(ev) {
385
+ if (ev.key !== "Escape") return;
386
+ ev.stopPropagation();
387
+ if (picker.classList.contains("at-variants")) leaveVariants();
388
+ else close();
389
+ }
390
+ document.addEventListener("keydown", onKey);
391
+ closeBtn.addEventListener("click", close);
392
+ done.addEventListener("click", close);
393
+
394
+ runSearch("");
395
+ render();
396
+ openPicker = handle;
397
+ return handle;
398
+ }
399
+
400
+ setFontPicker(openFontPicker);
@@ -24,7 +24,7 @@ function indexNodes(nodes, map) {
24
24
  }
25
25
  }
26
26
 
27
- export function buildControls(root, parameters, params, onDirty, onCommit) {
27
+ export function buildControls(root, parameters, params, onDirty, onCommit, opts = {}) {
28
28
  const info = createInfoPopover();
29
29
  const tree = buildTree(desugar(parameters));
30
30
 
@@ -34,6 +34,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
34
34
  const syncFns = []; // { key, sync } for every widget
35
35
  const rawSyncs = new Map(); // sectionId -> [{ key, sync }] for preset application
36
36
  const widgetSyncs = new Map(); // id -> the RAW widget sync (no markCustom)
37
+ const disposers = []; // widget teardown — a font picker lives OUTSIDE root
37
38
  const nodeById = new Map(); // id -> node, for the reveal re-sync
38
39
  const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
39
40
  const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
@@ -225,10 +226,12 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
225
226
  onChange: () => { markCustom(); onEdit(); },
226
227
  onCommit: () => commit([node.key]),
227
228
  info,
229
+ fontCatalog: opts.fontCatalog,
228
230
  });
229
231
  nodeEls.set(node.id, widget.el);
230
232
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
231
233
  widgetSyncs.set(node.id, widget.sync);
234
+ if (widget.dispose) disposers.push(widget.dispose);
232
235
  container.append(widget.el);
233
236
 
234
237
  // The raw sync is what a PRESET application uses — it must not mark itself
@@ -351,6 +354,8 @@ export function buildControls(root, parameters, params, onDirty, onCommit) {
351
354
  }
352
355
  return true;
353
356
  },
354
- dispose: () => { info.dispose(); root.replaceChildren(); },
357
+ // replaceChildren() only reaches what is INSIDE root; a widget that parked
358
+ // DOM (or a document-level listener) elsewhere has to be told to let go.
359
+ dispose: () => { info.dispose(); for (const d of disposers) d(); root.replaceChildren(); },
355
360
  };
356
361
  }
@@ -36,6 +36,7 @@ export const WIDGET_SPECS = [
36
36
  { type: "checkbox", kind: "control", fields: LEGACY_TOGGLE },
37
37
  { type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
38
38
  { type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
39
+ { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
39
40
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
40
41
  ];
41
42
 
@@ -54,6 +55,7 @@ const AUTHOR_EXTRAS = {
54
55
  checkbox: ["on"],
55
56
  select: ["options"],
56
57
  radio: ["options"],
58
+ font: ["allow", "preview"],
57
59
  };
58
60
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
59
61
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
@@ -0,0 +1,125 @@
1
+ // The `type: "font"` control. Its VALUE is a font source string — the same
2
+ // grammar `PartDefinition.fonts` already accepts — so everything downstream
3
+ // (presets, undo, the params hash, `when`) works with no special case.
4
+ //
5
+ // Two renderings. With a host-supplied `fontCatalog` it is a button showing the
6
+ // current face IN that face, opening the picker. Without one it degrades to a
7
+ // URL text field, so a standalone partforge app (which ships no catalog) still
8
+ // exposes the parameter.
9
+ import { attachInfo } from "../info.js";
10
+ import { FONT_ALLOW_DEFAULT, fontSourceAllowed } from "../../font-source.js";
11
+
12
+ function el(tag, className, text) {
13
+ const node = document.createElement(tag);
14
+ if (className) node.className = className;
15
+ if (text != null) node.textContent = text;
16
+ return node;
17
+ }
18
+
19
+ const WEIGHTS = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
20
+ 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
21
+ export const variantLabel = (v) => {
22
+ if (!v) return "Regular";
23
+ const w = String(v).replace(/i$/, ""), italic = /i$/.test(String(v));
24
+ return `${WEIGHTS[w] ?? w}${italic ? " Italic" : ""}`;
25
+ };
26
+
27
+ // A source string → something human. Cloud's fetch_web_font stores files as
28
+ // `<family-slug>[-<variant>].ttf`, so the filename round-trips the label for
29
+ // free on the vendored path; a bare URL falls back to its filename stem.
30
+ export function fontLabel(source) {
31
+ if (typeof source !== "string" || !source) return { family: "—", variant: null };
32
+ let path = source;
33
+ try { path = new URL(source).pathname; } catch { /* not a URL — use the raw string */ }
34
+ const file = path.split("/").filter(Boolean).pop() ?? source;
35
+ const stem = file.replace(/\.(ttf|otf)$/i, "");
36
+ const m = /^(.*)-(\d{3}i?|italic)$/i.exec(stem);
37
+ const slug = m ? m[1] : stem;
38
+ const family = slug.split("-").filter(Boolean)
39
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
40
+ .join(" ") || "—";
41
+ return { family, variant: m ? m[2] : null };
42
+ }
43
+
44
+ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog } = {}) {
45
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : FONT_ALLOW_DEFAULT;
46
+ const wrap = el("div", "slider");
47
+ const row = el("div", "row");
48
+ const label = el("label", "", node.label ?? node.key);
49
+ attachInfo(label, node.description, info);
50
+ row.append(label);
51
+ wrap.append(row);
52
+
53
+ if (!fontCatalog) {
54
+ // Degraded path: a URL field. Unlike `text`, it does NOT write on every
55
+ // keystroke — a half-typed URL is a guaranteed failed fetch, and the
56
+ // rebuild loop would chase every one of them.
57
+ const field = document.createElement("input");
58
+ field.type = "text";
59
+ field.className = "text-input";
60
+ field.value = String(params[node.key] ?? "");
61
+ field.addEventListener("change", () => {
62
+ if (!fontSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
63
+ field.classList.remove("warn");
64
+ params[node.key] = field.value;
65
+ onChange?.();
66
+ onCommit?.();
67
+ });
68
+ wrap.append(field);
69
+ return { el: wrap, sync: () => { field.value = String(params[node.key] ?? ""); field.classList.remove("warn"); } };
70
+ }
71
+
72
+ const btn = el("button", "font-btn");
73
+ btn.type = "button";
74
+ const fname = el("span", "fname");
75
+ const fvar = el("span", "fvar");
76
+ btn.append(fname, fvar);
77
+ btn.insertAdjacentHTML("beforeend",
78
+ '<svg class="caret" width="8" height="7" viewBox="0 0 8 7" aria-hidden="true"><polygon points="0,0 8,0 4,7" fill="currentColor"/></svg>');
79
+ wrap.append(btn);
80
+
81
+ // The value alone cannot name a live-picked face: a gstatic filename is a
82
+ // content hash. Ask the catalog first (it holds the reverse lookup), and fall
83
+ // back to the filename — which is right for a vendored `<family>-<variant>.ttf`
84
+ // and merely ugly for a hash. `describe` is optional and may be async, so the
85
+ // label is painted twice: filename immediately, catalog answer when it lands.
86
+ let paintSeq = 0;
87
+ const paint = () => {
88
+ const src = params[node.key];
89
+ const seq = ++paintSeq;
90
+ const show = ({ family, variant }) => {
91
+ if (seq !== paintSeq) return; // a newer paint already won
92
+ fname.textContent = family;
93
+ fvar.textContent = variantLabel(variant);
94
+ fname.style.fontFamily = `"${family}", var(--pf-sans)`;
95
+ };
96
+ show(fontLabel(src));
97
+ if (typeof fontCatalog.describe !== "function") return;
98
+ Promise.resolve()
99
+ .then(() => fontCatalog.describe(src))
100
+ .then((d) => { if (d?.family) show(d); })
101
+ .catch(() => { /* a failed lookup keeps the filename label */ });
102
+ };
103
+ paint();
104
+
105
+ // The picker registers itself through setFontPicker (see below); with no
106
+ // picker in the bundle the button is inert rather than broken.
107
+ //
108
+ // The handle is kept because the picker is a TAKEOVER: it appends itself to
109
+ // the rail, outside the panel root, so tearing the panel down does not take it
110
+ // with it. Without dispose() the element — and the `document` keydown listener
111
+ // that only close() unhooks — would outlive the panel holding a stale `params`.
112
+ let picker = null;
113
+ btn.addEventListener("click", () => {
114
+ picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
115
+ });
116
+
117
+ return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
118
+ }
119
+
120
+ // Assigned by font-picker.js, which widgets/index.js imports for the side
121
+ // effect. Kept
122
+ // as a mutable binding rather than a static import so this file stays usable —
123
+ // and testable — without dragging the whole picker in.
124
+ export let openFontPicker = null;
125
+ export const setFontPicker = (fn) => { openFontPicker = fn; };
@@ -4,6 +4,13 @@ import { makeNumeric } from "./numeric.js";
4
4
  import { makeText } from "./text.js";
5
5
  import { makeCheckbox } from "./checkbox.js";
6
6
  import { makeSelect, makeRadio } from "./select.js";
7
+ import { makeFont } from "./font.js";
8
+ // Side-effect import: font-picker.js calls setFontPicker() at module scope, so
9
+ // the font widget's button finds a picker to open. It lives HERE and not in
10
+ // font.js because the dependency has to run picker → widget and never back —
11
+ // font.js must stay importable (and testable) without dragging the whole
12
+ // DOM-heavy picker in. See the note at the bottom of font.js.
13
+ import "../font-picker.js";
7
14
 
8
15
  export const WIDGET_FACTORIES = {
9
16
  slider: makeNumeric,
@@ -13,4 +20,5 @@ export const WIDGET_FACTORIES = {
13
20
  checkbox: makeCheckbox,
14
21
  select: makeSelect,
15
22
  radio: makeRadio,
23
+ font: makeFont,
16
24
  };
@@ -32,8 +32,16 @@ export function exportSubParts(part, view, params) {
32
32
 
33
33
  // Resolve a part's effective params + derived values for a build: the user's params
34
34
  // layered over the part defaults, and derive() run once over the result.
35
- export function resolveParams(part, params) {
35
+ //
36
+ // `sanitize(p)` is an optional hook that may rewrite the layered params IN PLACE —
37
+ // the seam a caller uses to refuse an untrusted value before it means anything.
38
+ // It runs BEFORE resolveDerived deliberately: derive() must see exactly the params
39
+ // build() will see, or a refused value still reaches the geometry through `d`.
40
+ // A hook rather than a second copy of this function in the caller, so "resolve a
41
+ // part's params" keeps one definition.
42
+ export function resolveParams(part, params, sanitize) {
36
43
  const p = { ...part.defaults, ...params };
44
+ sanitize?.(p);
37
45
  return { p, d: resolveDerived(part, p) };
38
46
  }
39
47