partforge 0.73.0 → 0.74.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.
@@ -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
 
@@ -23,6 +23,15 @@ export default {
23
23
  description: "Grow (>0, bolder) or shrink (<0, thinner) the letters with a **Shape2D offset** — the same operation used for print clearance. Large negative values collapse thin strokes, so the letters hold at their thinnest valid size rather than breaking." },
24
24
  ],
25
25
  },
26
+ {
27
+ id: "typeface",
28
+ title: "Typeface",
29
+ description: "The face the lettering is cut in. Falls back to the bundled Roboto when left as the default.",
30
+ controls: [
31
+ { key: "face", type: "font", label: "Typeface",
32
+ description: "Any face the host's font catalog offers. Without a catalog this is a URL field — a direct link to a `.ttf` or `.otf` that allows cross-origin requests." },
33
+ ],
34
+ },
26
35
  {
27
36
  id: "plate",
28
37
  title: "Plate",
@@ -45,14 +54,19 @@ export default {
45
54
  ],
46
55
  },
47
56
  ],
48
- defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0 },
57
+ defaults: { label: "PARTFORGE\nv0.20", size: 8, depth: 1.2, stroke: 0, margin: 4, corner: 3, thickness: 3, engrave: 0, face: "" },
58
+ // A function of params, not a static map — that is what makes `face` a
59
+ // parameter rather than a constant. An empty value declares nothing, and
60
+ // text2d falls back to the bundled Roboto.
61
+ fonts: (p) => (p.face ? { face: p.face } : {}),
49
62
  parts: {
50
63
  plate: {
51
64
  label: "Nameplate",
52
65
  views: ["plate"],
53
66
  export: { name: "nameplate" },
54
67
  build: (k, p) => {
55
- let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle", lineHeight: p.size * 1.7 });
68
+ let text = k.text2d(p.label, { size: p.size, align: "center", valign: "middle",
69
+ lineHeight: p.size * 1.7, ...(p.face ? { font: "face" } : {}) });
56
70
  // Shape2D offset on the lettering: grow (>0, bolder) or shrink (<0, thinner). Guard
57
71
  // against a shrink that collapses thin strokes — keep the un-offset letters if so.
58
72
  if (p.stroke !== 0) {