partforge 0.97.0 → 0.98.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.
@@ -0,0 +1,321 @@
1
+ // The shared drop target behind `type: "image"`, `type: "vector"` and
2
+ // `type: "font"` (Tasks 6, 8, 9) — NOT itself a control. It owns no param key
3
+ // and draws no label/row of its own; it turns a dropped, picked or pasted
4
+ // file into either a host-stored source string or the converted artifact, and
5
+ // hands that to `onSource`. Kind-agnostic for classification and upload: the
6
+ // registry (`../../ingest/registry.js`) already knows what each kind accepts
7
+ // and how to convert it, and `classify`/`convertFor`/the `onAssetUpload`
8
+ // branch never look past `kind` as an opaque string.
9
+ //
10
+ // ONE named exception, in exactly two places, both explained where they
11
+ // occur: `convertedArtifact` and the "no host hook" branch of `handle()`
12
+ // both check `kind === "vector"` by name, because a vector's "no host hook"
13
+ // delivery is the PARSED document object, not bytes like every other kind
14
+ // (task-9 addendum, Ruling D) — that asymmetry has to live somewhere, and
15
+ // singling it out here is more honest than pretending every kind still ends
16
+ // up looking the same.
17
+ //
18
+ // `mountDrop` (bottom of this file) is the widget-facing half: wiring
19
+ // `makeFileDrop`'s output to a control's own `params[node.key]` and error
20
+ // surface is the same ~25 lines in widgets/image.js, widgets/font.js and
21
+ // widgets/vector.js — two copies were defensible as deliberate mirroring
22
+ // (Tasks 6, 8); a third (Task 9) is where a shared helper wins (task-9
23
+ // addendum, Ruling L). All three widgets call it instead of keeping their own
24
+ // `mountXDrop`/`makeDropError` pair.
25
+ //
26
+ // Before this file there was no drag-and-drop, file picker, or paste
27
+ // affordance anywhere in the panel (design doc, "Evidence" §1) — this is new
28
+ // code, not an extraction.
29
+ //
30
+ // MAIN-THREAD ONLY, like font.js/image.js: it reaches the real converters
31
+ // (canvas decode for images, paper.js for vectors) only through the
32
+ // registry's `convert` thunks, so a part with no such control never pays for
33
+ // either, and neither is reachable from the geometry worker's import closure
34
+ // (test/worker-layering.test.js walks the registry itself; this file adds
35
+ // nothing new to that surface).
36
+ import { classify, convertFor, rowFor } from "../../ingest/registry.js";
37
+
38
+ // A generous but bounded cap, checked against `file.size` — BEFORE even
39
+ // `file.arrayBuffer()` is called, let alone conversion — so an oversize file
40
+ // is never read into memory just to be rejected. 25 MB comfortably covers a
41
+ // photo, a font family file or a hand-drawn SVG; nothing this framework
42
+ // ingests is legitimately bigger, and letting a bigger one through would
43
+ // decode on the main thread with no progress UI. The design doc left the
44
+ // exact number to this task (open item 2) — this is that pick.
45
+ const MAX_BYTES = 25 * 1024 * 1024;
46
+
47
+ const bytesLabel = (n) =>
48
+ n >= 1024 * 1024 ? `${(n / (1024 * 1024)).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`;
49
+
50
+ // media type -> a short human noun, for a refusal message naming what a file
51
+ // actually IS (spec §5: "names what works"). Kept local and small: the
52
+ // registry's own `label`s already read as sentences ("an image (PNG, JPG or
53
+ // WebP)"), not nouns, so they are the wrong shape for "X is a ␣ file".
54
+ const MEDIA_NOUN = {
55
+ "image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WebP", "image/svg+xml": "SVG",
56
+ "font/otf": "OTF", "font/ttf": "TTF", "font/woff2": "WOFF2",
57
+ };
58
+
59
+ // Compose the refusal message from a failed `classify()`. Two shapes, both
60
+ // naming the actual file:
61
+ // - the registry knows another slot that WOULD take it (`suggestKind`) ->
62
+ // name where it belongs, e.g. "logo.svg is artwork (SVG) ... try the
63
+ // Artwork control instead" — the exact case the registry's `suggestKind`
64
+ // exists for. Uses the row's own `name` (a display name, e.g. "Artwork"),
65
+ // never the internal `kind` key (e.g. "vector") — the key is an
66
+ // implementation detail the registry happens to use for lookups, not
67
+ // something a control is labelled.
68
+ // - it knows only what THIS slot accepts — either the bytes are unrecognised,
69
+ // or they are a real, named format that just has no home anywhere (WOFF2)
70
+ // -> say what works, point nowhere.
71
+ function refusalMessage(filename, kind, { mediaType, suggestKind }) {
72
+ const needLabel = rowFor(kind).label;
73
+ if (suggestKind) {
74
+ const haveRow = rowFor(suggestKind);
75
+ return `"${filename}" is ${haveRow.label} — this control accepts ${needLabel}. Try the ${haveRow.name} control instead.`;
76
+ }
77
+ const noun = mediaType && MEDIA_NOUN[mediaType];
78
+ const what = noun ? `a ${noun} file` : "not a file this control recognises";
79
+ return `"${filename}" is ${what} — this control accepts ${needLabel}.`;
80
+ }
81
+
82
+ function el(tag, className, text) {
83
+ const node = document.createElement(tag);
84
+ if (className) node.className = className;
85
+ if (text != null) node.textContent = text;
86
+ return node;
87
+ }
88
+
89
+ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
90
+ const row = rowFor(kind);
91
+ const wrap = el("div", "file-drop");
92
+ wrap.tabIndex = 0;
93
+ wrap.setAttribute("role", "button");
94
+ const hint = el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`);
95
+ wrap.append(hint);
96
+
97
+ // The click/keyboard path to the same handler a drop uses. Hidden rather
98
+ // than absent: a real `<input type="file">` is what gives this a native
99
+ // "Choose File" affordance and OS-level type filtering (`accept`), neither
100
+ // of which is worth hand-rolling.
101
+ const input = document.createElement("input");
102
+ input.type = "file";
103
+ input.className = "file-drop-input";
104
+ input.hidden = true;
105
+ if (row?.accepts?.length) input.accept = row.accepts.join(",");
106
+ wrap.append(input);
107
+
108
+ // The converted artifact from the most recently accepted drop — a Blob (or,
109
+ // for a `convert: null` kind like font, the original File, which already IS
110
+ // the artifact since nothing transforms it). Kept so a failed
111
+ // `onAssetUpload` can be retried without re-reading and re-converting the
112
+ // file the user already dropped: that reconvert — a canvas decode, a
113
+ // paper.js import — is the whole reason this exists rather than just
114
+ // re-deriving it from the DOM on retry.
115
+ let converted = null;
116
+
117
+ // One AbortController for every listener this widget adds (created here,
118
+ // ahead of `handle`, so `handle` can close over `signal` — see `stale()`
119
+ // below). `dispose()` is a single `abort()` rather than a hand-maintained
120
+ // list of pairs to get wrong. font-picker.js:49 explains why removal
121
+ // matters: a stray listener keeps this whole closure (and everything it
122
+ // closes over — `onSource`, `onAssetUpload`, eventually a live `params`)
123
+ // alive long after the panel that created it is gone.
124
+ const ac = new AbortController();
125
+ const { signal } = ac;
126
+
127
+ // `handle()` is a multi-await chain (read -> classify -> convert -> upload),
128
+ // and two things can happen while it's suspended: the widget can be
129
+ // disposed (a panel rebuild tore this control down), or a SECOND drop can
130
+ // land before the first finishes (a slow first file, a fast second one).
131
+ // Neither must be allowed to write into `converted` or fire `onSource`/
132
+ // `onError` after the fact — a disposed widget has no live closure to write
133
+ // into safely, and a superseded drop must not clobber the newer one's
134
+ // result (the user's most recent drop has to win). `generation` is bumped
135
+ // on every `handle()` call; `stale()` is true once either reason applies,
136
+ // and every callback site below is gated on it.
137
+ let generation = 0;
138
+
139
+ // The argument shape a converter wants differs by kind — imageToPng wants
140
+ // the Blob itself, ingestSvg wants the decoded SVG text — so this is the one
141
+ // place that looks past "kind" as an opaque string. A `convert: null` row
142
+ // (font) needs no call at all: the dropped file is already the artifact.
143
+ //
144
+ // Returns `{ blob, doc }`: `blob` is what an `onAssetUpload` host hook gets
145
+ // (a host uploads bytes, not a live object) and what `lastBlob()` retains
146
+ // for a retry; `doc` is set only for "vector", where it is the PARSED
147
+ // partforge-vector document `ingestSvg` already produced. `blob` still
148
+ // carries the JSON-serialized form for the upload path, but `handle()`
149
+ // below delivers `doc` — never the serialized bytes — to `onSource` when
150
+ // there is no host hook to upload to (task-9 addendum, Ruling D: the
151
+ // resolver already accepts an in-tree parsed object directly, so
152
+ // serializing it only for the resolver to re-parse would be pure waste).
153
+ async function convertedArtifact(file, mediaType) {
154
+ const convert = await convertFor(kind, mediaType);
155
+ if (!convert) return { blob: file, doc: undefined };
156
+ if (kind === "vector") {
157
+ const text = new TextDecoder("utf-8").decode(await file.arrayBuffer());
158
+ const doc = convert(text, { source: file.name });
159
+ return { blob: new Blob([JSON.stringify(doc)], { type: "application/json" }), doc };
160
+ }
161
+ return { blob: await convert(file), doc: undefined };
162
+ }
163
+
164
+ async function handle(file) {
165
+ const myGen = ++generation;
166
+ const stale = () => signal.aborted || myGen !== generation;
167
+
168
+ // Checked against `file.size` alone — no read yet — so an oversize file
169
+ // never gets fully buffered into memory just to be rejected.
170
+ if (file.size > MAX_BYTES) {
171
+ onError?.(`"${file.name}" is ${bytesLabel(file.size)} — over the ${bytesLabel(MAX_BYTES)} limit.`);
172
+ return;
173
+ }
174
+
175
+ const bytes = new Uint8Array(await file.arrayBuffer());
176
+ if (stale()) return;
177
+
178
+ const result = classify(bytes, kind);
179
+ if (!result.ok) {
180
+ onError?.(refusalMessage(file.name, kind, result));
181
+ return;
182
+ }
183
+
184
+ let artifact;
185
+ try {
186
+ artifact = await convertedArtifact(file, result.mediaType);
187
+ } catch (err) {
188
+ if (stale()) return;
189
+ // Names the file and the stage (spec §5) — a malformed SVG must not
190
+ // read as a partforge bug.
191
+ onError?.(`"${file.name}" could not be converted: ${err.message}`);
192
+ return;
193
+ }
194
+ if (stale()) return; // a newer drop already won — don't clobber its `converted`
195
+ converted = artifact.blob; // retained from here on, success or not — see the field comment above
196
+
197
+ if (onAssetUpload) {
198
+ try {
199
+ const source = await onAssetUpload(artifact.blob, { kind, filename: file.name });
200
+ if (stale()) return;
201
+ // A host hook that resolves to anything but a non-empty string —
202
+ // `undefined`, an object, `""` — is a contract violation, not a
203
+ // source: writing it into the param would corrupt the part rather
204
+ // than fail loudly.
205
+ if (typeof source !== "string" || !source) {
206
+ onError?.(`"${file.name}" was converted, but the upload hook returned ${JSON.stringify(source)} instead of a source string.`);
207
+ return;
208
+ }
209
+ onSource?.(source);
210
+ } catch (err) {
211
+ if (stale()) return;
212
+ // The artifact stays in `converted` — a retry (the host re-driving
213
+ // onAssetUpload, e.g. from a "try again" button) costs a network
214
+ // call, not a reconvert.
215
+ onError?.(`"${file.name}" converted, but the upload failed: ${err.message}. Retry — it doesn't need reconverting.`);
216
+ }
217
+ return;
218
+ }
219
+
220
+ // No host hook. For "vector" the artifact IS the parsed document object —
221
+ // deliver that directly, never its serialized bytes (task-9 addendum,
222
+ // Ruling D; see `convertedArtifact` above).
223
+ if (kind === "vector") {
224
+ onSource?.(artifact.doc);
225
+ return;
226
+ }
227
+
228
+ // Every other kind: the bytes themselves are the param value. This is the
229
+ // path the partforge-cloud sandbox needs because it cannot fetch URLs —
230
+ // correct and expected, not a degraded fallback (see font.js/image.js's
231
+ // own byte-valued param handling for the downstream half of this).
232
+ const ab = await artifact.blob.arrayBuffer();
233
+ if (stale()) return;
234
+ onSource?.(ab);
235
+ }
236
+
237
+ async function handleFiles(files) {
238
+ const list = Array.from(files ?? []);
239
+ if (list.length === 0) return;
240
+ if (list.length > 1) {
241
+ // Silently dropping the rest would be worse than a one-line note
242
+ // (spec §5) — reported, but the first file still proceeds below.
243
+ onError?.(`${list.length} files were dropped — using only the first, "${list[0].name}".`);
244
+ }
245
+ await handle(list[0]);
246
+ }
247
+
248
+ const onDrop = (e) => {
249
+ e.preventDefault();
250
+ wrap.classList.remove("file-drop-over");
251
+ handleFiles(e.dataTransfer?.files);
252
+ };
253
+ const onDragOver = (e) => { e.preventDefault(); wrap.classList.add("file-drop-over"); };
254
+ const onDragLeave = () => wrap.classList.remove("file-drop-over");
255
+ const onClick = () => input.click();
256
+ const onKeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } };
257
+ const onChange = () => { handleFiles(input.files); input.value = ""; }; // same file twice must still fire `change`
258
+ // Paste is the third affordance the feature exists to add (design doc,
259
+ // "Evidence" §1: "no drag-and-drop, file picker, or paste affordance
260
+ // anywhere in the panel") — a clipboard image/font/SVG lands the same way a
261
+ // drop does.
262
+ const onPaste = (e) => { if (e.clipboardData?.files?.length) handleFiles(e.clipboardData.files); };
263
+
264
+ // `ac`/`signal` were created above, ahead of `handle()` — every listener
265
+ // below shares the same one.
266
+ wrap.addEventListener("drop", onDrop, { signal });
267
+ wrap.addEventListener("dragover", onDragOver, { signal });
268
+ wrap.addEventListener("dragleave", onDragLeave, { signal });
269
+ wrap.addEventListener("click", onClick, { signal });
270
+ wrap.addEventListener("keydown", onKeydown, { signal });
271
+ wrap.addEventListener("paste", onPaste, { signal });
272
+ input.addEventListener("change", onChange, { signal });
273
+
274
+ return {
275
+ el: wrap,
276
+ dispose: () => ac.abort(),
277
+ lastBlob: () => converted,
278
+ };
279
+ }
280
+
281
+ // The widget-facing wiring: mounts a `makeFileDrop` for one control and binds
282
+ // it to `params[node.key]`, including the control's own error surface (a
283
+ // hidden-by-default line under the drop target, filled verbatim with
284
+ // whatever `onError` composed — spec: "do not rewrite or wrap it; render
285
+ // it"). Marks the drop element `data-pf-drop` (the tests select on it).
286
+ //
287
+ // `onRender` is the widget's own repaint (`paintField`/`paint`) — called
288
+ // after a successful drop so the field/button reflects the new value
289
+ // immediately, the same way a catalog picker's `onPicked` already does.
290
+ // `onChange`/`onCommit` are then fired in that order, mirroring every other
291
+ // widget's own edit path (render.js wires them: `onChange` marks the section
292
+ // Custom and re-applies condition state, `onCommit` is the "the user finished
293
+ // an interaction" signal that triggers a rebuild).
294
+ //
295
+ // Returns `{ el, errorEl, dispose }` rather than appending anything itself —
296
+ // the caller still owns layout (where the drop target and error line sit
297
+ // relative to the field/button), only the wiring is shared.
298
+ export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender }) {
299
+ const errorEl = el("div", "file-drop-error");
300
+ errorEl.hidden = true;
301
+
302
+ const drop = makeFileDrop({
303
+ kind,
304
+ onAssetUpload,
305
+ onSource: (source) => {
306
+ errorEl.hidden = true;
307
+ errorEl.textContent = "";
308
+ params[node.key] = source;
309
+ onRender?.();
310
+ onChange?.();
311
+ onCommit?.();
312
+ },
313
+ onError: (message) => {
314
+ errorEl.hidden = false;
315
+ errorEl.textContent = message;
316
+ },
317
+ });
318
+ drop.el.setAttribute("data-pf-drop", "");
319
+
320
+ return { el: drop.el, errorEl, dispose: () => drop.dispose() };
321
+ }
@@ -1,13 +1,22 @@
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.
1
+ // The `type: "font"` control. Its VALUE is a font source — either a source
2
+ // string (the same grammar `PartDefinition.fonts` already accepts) or raw
3
+ // font bytes (an ArrayBuffer/typed array): the partforge-cloud sandbox cannot
4
+ // fetch URLs, so it puts the bytes straight in the param (font-source.js now
5
+ // accepts that shape). Everything downstream (presets, undo, the params hash,
6
+ // `when`) works with either shape, no special case.
4
7
  //
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.
8
+ // Two renderings, mirroring widgets/image.js. With a host-supplied
9
+ // `fontCatalog` it is a button showing the current face IN that face, opening
10
+ // the picker. Without one it degrades to a URL text field, so a standalone
11
+ // partforge app (which ships no catalog) still exposes the parameter.
12
+ //
13
+ // Fonts are the "used as-is" kind: the ingest registry's `font` row has
14
+ // `convert: null` (a TTF/OTF is validated, never converted), so unlike
15
+ // image.js/svg there is no converter to warm up here — see makeFont's own
16
+ // note below.
9
17
  import { attachInfo } from "../info.js";
10
18
  import { FONT_ALLOW_DEFAULT, fontSourceAllowed } from "../../font-source.js";
19
+ import { mountDrop } from "./file-drop.js";
11
20
 
12
21
  function el(tag, className, text) {
13
22
  const node = document.createElement(tag);
@@ -16,6 +25,8 @@ function el(tag, className, text) {
16
25
  return node;
17
26
  }
18
27
 
28
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
29
+
19
30
  const WEIGHTS = { 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular", 500: "Medium",
20
31
  600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black" };
21
32
  export const variantLabel = (v) => {
@@ -41,7 +52,7 @@ export function fontLabel(source) {
41
52
  return { family, variant: m ? m[2] : null };
42
53
  }
43
54
 
44
- export function makeFont(node, params, { onChange, onCommit, info, fontCatalog } = {}) {
55
+ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog, onAssetUpload } = {}) {
45
56
  const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : FONT_ALLOW_DEFAULT;
46
57
  const wrap = el("div", "slider");
47
58
  const row = el("div", "row");
@@ -57,7 +68,16 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
57
68
  const field = document.createElement("input");
58
69
  field.type = "text";
59
70
  field.className = "text-input";
60
- field.value = String(params[node.key] ?? "");
71
+ const paintField = () => {
72
+ const v = params[node.key];
73
+ // Bytes cannot round-trip through a text field — `String(arrayBuffer)`
74
+ // is "[object ArrayBuffer]", not a value anyone typed. Show an honest
75
+ // placeholder instead of that, and leave the field free to type a
76
+ // replacement URL over it. Mirrors widgets/image.js's paintField.
77
+ field.value = isBytes(v) ? "" : String(v ?? "");
78
+ field.placeholder = isBytes(v) ? "Uploaded font" : "";
79
+ field.classList.remove("warn");
80
+ };
61
81
  field.addEventListener("change", () => {
62
82
  if (!fontSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
63
83
  field.classList.remove("warn");
@@ -65,8 +85,15 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
65
85
  onChange?.();
66
86
  onCommit?.();
67
87
  });
88
+ paintField();
68
89
  wrap.append(field);
69
- return { el: wrap, sync: () => { field.value = String(params[node.key] ?? ""); field.classList.remove("warn"); } };
90
+
91
+ const drop = mountDrop("font", {
92
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
93
+ });
94
+ wrap.append(drop.el, drop.errorEl);
95
+
96
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
70
97
  }
71
98
 
72
99
  const btn = el("button", "font-btn");
@@ -83,6 +110,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
83
110
  // back to the filename — which is right for a vendored `<family>-<variant>.ttf`
84
111
  // and merely ugly for a hash. `describe` is optional and may be async, so the
85
112
  // label is painted twice: filename immediately, catalog answer when it lands.
113
+ //
114
+ // A byte-valued param (the cloud sandbox path, dropped or uploaded) has no
115
+ // filename to derive a label from — `fontLabel` would print "—" for it, so
116
+ // it is special-cased to an honest "Uploaded font" instead, the same rule
117
+ // widgets/image.js applies for a byte-valued image.
86
118
  let paintSeq = 0;
87
119
  const paint = () => {
88
120
  const src = params[node.key];
@@ -93,7 +125,7 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
93
125
  fvar.textContent = variantLabel(variant);
94
126
  fname.style.fontFamily = `"${family}", var(--pf-sans)`;
95
127
  };
96
- show(fontLabel(src));
128
+ show(isBytes(src) ? { family: "Uploaded font", variant: null } : fontLabel(src));
97
129
  if (typeof fontCatalog.describe !== "function") return;
98
130
  Promise.resolve()
99
131
  .then(() => fontCatalog.describe(src))
@@ -114,7 +146,14 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog }
114
146
  picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
115
147
  });
116
148
 
117
- return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
149
+ const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
150
+ wrap.append(drop.el, drop.errorEl);
151
+
152
+ return {
153
+ el: wrap,
154
+ sync: paint,
155
+ dispose: () => { picker?.close(); picker = null; drop.dispose(); },
156
+ };
118
157
  }
119
158
 
120
159
  // Assigned by font-picker.js, which widgets/index.js imports for the side
@@ -17,6 +17,7 @@
17
17
  // path, not the panel.
18
18
  import { attachInfo } from "../info.js";
19
19
  import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
20
+ import { mountDrop } from "./file-drop.js";
20
21
 
21
22
  function el(tag, className, text) {
22
23
  const node = document.createElement(tag);
@@ -53,7 +54,7 @@ function paintPreview(img, source) {
53
54
  }
54
55
  }
55
56
 
56
- export function makeImage(node, params, { onChange, onCommit, info, imageCatalog } = {}) {
57
+ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
57
58
  const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
58
59
  const wrap = el("div", "slider");
59
60
  const row = el("div", "row");
@@ -98,7 +99,13 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
98
99
  });
99
100
  paintField();
100
101
  wrap.append(field);
101
- return { el: wrap, sync: paintField };
102
+
103
+ const drop = mountDrop("image", {
104
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
105
+ });
106
+ wrap.append(drop.el, drop.errorEl);
107
+
108
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
102
109
  }
103
110
 
104
111
  const btn = el("button", "image-btn");
@@ -154,7 +161,14 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
154
161
  picker = openImagePicker?.({ node, params, allow, imageCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
155
162
  });
156
163
 
157
- return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
164
+ const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
165
+ wrap.append(drop.el, drop.errorEl);
166
+
167
+ return {
168
+ el: wrap,
169
+ sync: paint,
170
+ dispose: () => { picker?.close(); picker = null; drop.dispose(); },
171
+ };
158
172
  }
159
173
 
160
174
  // Assigned by image-picker.js, which widgets/index.js imports for the side
@@ -6,6 +6,7 @@ import { makeCheckbox } from "./checkbox.js";
6
6
  import { makeSelect, makeRadio } from "./select.js";
7
7
  import { makeFont } from "./font.js";
8
8
  import { makeImage } from "./image.js";
9
+ import { makeVector } from "./vector.js";
9
10
  // Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
10
11
  // setImagePicker() at module scope, so each widget's button finds a picker to
11
12
  // open. They live HERE and not in font.js/image.js because the dependency has
@@ -25,4 +26,5 @@ export const WIDGET_FACTORIES = {
25
26
  radio: makeRadio,
26
27
  font: makeFont,
27
28
  image: makeImage,
29
+ vector: makeVector,
28
30
  };
@@ -0,0 +1,77 @@
1
+ // The `type: "vector"` control. Its VALUE is vector artwork — either a URL
2
+ // string (the same grammar `PartDefinition.vectors` already accepts) or the
3
+ // PARSED partforge-vector document object a drop/paste conversion produces
4
+ // (see file-drop.js's vector-kind handling and vectors.js's `asParsedFile`,
5
+ // "the in-tree form" — a source that IS the parsed contents of its file
6
+ // rather than a way to reach its bytes). `vectorsFor` (vectors.js) is what
7
+ // lets a `type: "vector"` control drive the artwork: a part declares
8
+ // `vectors: (p) => ({ name: p.art })` and this control writes `p.art`.
9
+ //
10
+ // Unlike font.js/image.js there is NO catalog provider for artwork — no
11
+ // `vectorCatalog` exists (see the design doc's "no vector catalog provider"
12
+ // note) — so this control has exactly ONE rendering: a URL field plus a
13
+ // drop target. There is no picker button and nothing here degrades from a
14
+ // richer form; this IS the whole control.
15
+ //
16
+ // Main-thread only: the real SVG -> partforge-vector conversion (paper.js)
17
+ // runs inside `makeFileDrop` -> the registry's "vector" convert thunk,
18
+ // never here.
19
+ import { attachInfo } from "../info.js";
20
+ import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
21
+ import { mountDrop } from "./file-drop.js";
22
+
23
+ function el(tag, className, text) {
24
+ const node = document.createElement(tag);
25
+ if (className) node.className = className;
26
+ if (text != null) node.textContent = text;
27
+ return node;
28
+ }
29
+
30
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
31
+ // A dropped/pasted SVG with no `onAssetUpload` host hook lands as the PARSED
32
+ // document object (task-9 addendum, Ruling D) — an opaque value with nothing
33
+ // a text field can show, the same rule image.js/font.js apply to a
34
+ // byte-valued param.
35
+ const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
36
+
37
+ export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
38
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
39
+ const wrap = el("div", "slider");
40
+ const row = el("div", "row");
41
+ const label = el("label", "", node.label ?? node.key);
42
+ attachInfo(label, node.description, info);
43
+ row.append(label);
44
+ wrap.append(row);
45
+
46
+ // The URL field. Unlike `text`, it does NOT write on every keystroke — a
47
+ // half-typed URL is a guaranteed failed fetch, and the rebuild loop would
48
+ // chase every one of them. Mirrors widgets/image.js's/font.js's own field.
49
+ const field = document.createElement("input");
50
+ field.type = "text";
51
+ field.className = "text-input";
52
+ const paintField = () => {
53
+ const v = params[node.key];
54
+ // A parsed document (or bytes) cannot round-trip through a text field —
55
+ // show an honest placeholder instead, and leave the field free to type a
56
+ // replacement URL over it.
57
+ field.value = isOpaque(v) ? "" : String(v ?? "");
58
+ field.placeholder = isOpaque(v) ? "Uploaded artwork" : "";
59
+ field.classList.remove("warn");
60
+ };
61
+ field.addEventListener("change", () => {
62
+ if (!vectorSourceAllowed(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
+ paintField();
69
+ wrap.append(field);
70
+
71
+ const drop = mountDrop("vector", {
72
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
73
+ });
74
+ wrap.append(drop.el, drop.errorEl);
75
+
76
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
77
+ }