partforge 0.101.0 → 0.103.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/README.md +9 -0
- package/docs/AUTHORING-PARTS.md +15 -3
- package/package.json +1 -1
- package/src/framework/app.css +95 -0
- package/src/framework/chrome.css +75 -1
- package/src/framework/mobile-tabs.js +88 -8
- package/src/framework/mount.js +29 -2
- package/src/framework/panel/author.js +11 -0
- package/src/framework/panel/declared-source.js +128 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +6 -6
- package/src/framework/panel/widgets/file-drop.js +45 -16
- package/src/framework/panel/widgets/font.js +10 -2
- package/src/framework/panel/widgets/image.js +93 -15
- package/src/framework/panel/widgets/vector-thumb.js +136 -0
- package/src/framework/panel/widgets/vector.js +48 -5
- package/src/framework/rail.js +78 -16
- package/src/parts/emblem.js +22 -9
- package/types/index.d.ts +20 -0
|
@@ -36,9 +36,9 @@ 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"] },
|
|
40
|
-
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
|
|
41
|
-
{ type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
|
|
39
|
+
{ type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview", "sourceField"] },
|
|
40
|
+
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
41
|
+
{ type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow", "sourceField"] },
|
|
42
42
|
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
43
43
|
];
|
|
44
44
|
|
|
@@ -57,9 +57,9 @@ const AUTHOR_EXTRAS = {
|
|
|
57
57
|
checkbox: ["on"],
|
|
58
58
|
select: ["options"],
|
|
59
59
|
radio: ["options"],
|
|
60
|
-
font: ["allow", "preview"],
|
|
61
|
-
image: ["allow"],
|
|
62
|
-
vector: ["allow"],
|
|
60
|
+
font: ["allow", "preview", "sourceField"],
|
|
61
|
+
image: ["allow", "sourceField"],
|
|
62
|
+
vector: ["allow", "sourceField"],
|
|
63
63
|
};
|
|
64
64
|
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
65
65
|
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|
|
@@ -86,24 +86,47 @@ function el(tag, className, text) {
|
|
|
86
86
|
return node;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// `ambient` is for a control that already has a visible way in — the font
|
|
90
|
+
// control's catalog button, say. A labelled drop zone under it would spend rail
|
|
91
|
+
// height repeating the same offer, so the ambient form carries no hint, no click
|
|
92
|
+
// target and no place in the tab order: it is an overlay that shows itself only
|
|
93
|
+
// while a file is over it (see `.file-drop-ambient` in app.css). Dropping still
|
|
94
|
+
// works, it is simply not advertised.
|
|
95
|
+
//
|
|
96
|
+
// The click path is dropped rather than hidden, deliberately: an invisible
|
|
97
|
+
// overlay that still swallowed clicks would eat the button underneath it, which
|
|
98
|
+
// is the one affordance ambient mode exists to protect.
|
|
99
|
+
export function makeFileDrop({ kind, onSource, onError, onAssetUpload, ambient = false }) {
|
|
90
100
|
const row = rowFor(kind);
|
|
91
|
-
const wrap = el("div", "file-drop");
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
const wrap = el("div", ambient ? "file-drop file-drop-ambient" : "file-drop");
|
|
102
|
+
if (!ambient) {
|
|
103
|
+
wrap.tabIndex = 0;
|
|
104
|
+
wrap.setAttribute("role", "button");
|
|
105
|
+
// Two hints, swapped by CSS on `.has-thumb`. The empty-state one is the only
|
|
106
|
+
// thing in an empty tile; the replace one takes over once a preview fills it.
|
|
107
|
+
// Without the second, a tile showing a part's declared artwork — now the
|
|
108
|
+
// state a control OPENS in — carried no instruction at all, because the
|
|
109
|
+
// first is hidden the moment a thumbnail appears.
|
|
110
|
+
wrap.append(el("span", "file-drop-hint", `Drop ${row?.label ?? "a file"} here, or click to choose`));
|
|
111
|
+
wrap.append(el("span", "file-drop-hint file-drop-hint-replace", "Drop to replace, or click to choose"));
|
|
112
|
+
}
|
|
96
113
|
|
|
97
114
|
// The click/keyboard path to the same handler a drop uses. Hidden rather
|
|
98
115
|
// than absent: a real `<input type="file">` is what gives this a native
|
|
99
116
|
// "Choose File" affordance and OS-level type filtering (`accept`), neither
|
|
100
117
|
// of which is worth hand-rolling.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
input
|
|
105
|
-
if (
|
|
106
|
-
|
|
118
|
+
// Ambient mode has no click path, so it gets no input at all — an unused one
|
|
119
|
+
// would still be focusable in some browsers and would show up to a screen
|
|
120
|
+
// reader as a second, unlabelled file control.
|
|
121
|
+
let input = null;
|
|
122
|
+
if (!ambient) {
|
|
123
|
+
input = document.createElement("input");
|
|
124
|
+
input.type = "file";
|
|
125
|
+
input.className = "file-drop-input";
|
|
126
|
+
input.hidden = true;
|
|
127
|
+
if (row?.accepts?.length) input.accept = row.accepts.join(",");
|
|
128
|
+
wrap.append(input);
|
|
129
|
+
}
|
|
107
130
|
|
|
108
131
|
// The converted artifact from the most recently accepted drop — a Blob (or,
|
|
109
132
|
// for a `convert: null` kind like font, the original File, which already IS
|
|
@@ -266,10 +289,15 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
|
|
|
266
289
|
wrap.addEventListener("drop", onDrop, { signal });
|
|
267
290
|
wrap.addEventListener("dragover", onDragOver, { signal });
|
|
268
291
|
wrap.addEventListener("dragleave", onDragLeave, { signal });
|
|
269
|
-
wrap.addEventListener("click", onClick, { signal });
|
|
270
|
-
wrap.addEventListener("keydown", onKeydown, { signal });
|
|
271
292
|
wrap.addEventListener("paste", onPaste, { signal });
|
|
272
|
-
input
|
|
293
|
+
// Click, keyboard and the input's own change only exist when there is an input
|
|
294
|
+
// to open — ambient mode is drop-and-paste only, so that the control's real
|
|
295
|
+
// button keeps every click.
|
|
296
|
+
if (input) {
|
|
297
|
+
wrap.addEventListener("click", onClick, { signal });
|
|
298
|
+
wrap.addEventListener("keydown", onKeydown, { signal });
|
|
299
|
+
input.addEventListener("change", onChange, { signal });
|
|
300
|
+
}
|
|
273
301
|
|
|
274
302
|
return {
|
|
275
303
|
el: wrap,
|
|
@@ -295,12 +323,13 @@ export function makeFileDrop({ kind, onSource, onError, onAssetUpload }) {
|
|
|
295
323
|
// Returns `{ el, errorEl, dispose }` rather than appending anything itself —
|
|
296
324
|
// the caller still owns layout (where the drop target and error line sit
|
|
297
325
|
// relative to the field/button), only the wiring is shared.
|
|
298
|
-
export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender }) {
|
|
326
|
+
export function mountDrop(kind, { params, node, onAssetUpload, onChange, onCommit, onRender, ambient = false }) {
|
|
299
327
|
const errorEl = el("div", "file-drop-error");
|
|
300
328
|
errorEl.hidden = true;
|
|
301
329
|
|
|
302
330
|
const drop = makeFileDrop({
|
|
303
331
|
kind,
|
|
332
|
+
ambient,
|
|
304
333
|
onAssetUpload,
|
|
305
334
|
onSource: (source) => {
|
|
306
335
|
errorEl.hidden = true;
|
|
@@ -86,7 +86,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
|
|
|
86
86
|
onCommit?.();
|
|
87
87
|
});
|
|
88
88
|
paintField();
|
|
89
|
-
|
|
89
|
+
// Same opt-in rule as image/vector: the URL box appears only for
|
|
90
|
+
// `sourceField: true`. This branch has no catalog button, so hiding it
|
|
91
|
+
// leaves the drop zone as the way in — which is why THIS branch keeps its
|
|
92
|
+
// labelled drop zone rather than going ambient like the catalog one below.
|
|
93
|
+
if (node.sourceField === true) wrap.append(field);
|
|
90
94
|
|
|
91
95
|
const drop = mountDrop("font", {
|
|
92
96
|
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
@@ -146,7 +150,11 @@ export function makeFont(node, params, { onChange, onCommit, info, fontCatalog,
|
|
|
146
150
|
picker = openFontPicker?.({ node, params, allow, fontCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
|
|
147
151
|
});
|
|
148
152
|
|
|
149
|
-
|
|
153
|
+
// Ambient: this branch already has the catalog button as its visible way in, so
|
|
154
|
+
// the drop covers the control invisibly and reveals itself only while a file is
|
|
155
|
+
// over it. The no-catalog branch above stays labelled — there, the drop zone is
|
|
156
|
+
// the only affordance and hiding it would strand the user.
|
|
157
|
+
const drop = mountDrop("font", { params, node, onAssetUpload, onChange, onCommit, onRender: paint, ambient: true });
|
|
150
158
|
wrap.append(drop.el, drop.errorEl);
|
|
151
159
|
|
|
152
160
|
return {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { attachInfo } from "../info.js";
|
|
19
19
|
import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
|
|
20
20
|
import { mountDrop } from "./file-drop.js";
|
|
21
|
+
import { declaredImageUrl } from "../declared-source.js";
|
|
21
22
|
|
|
22
23
|
function el(tag, className, text) {
|
|
23
24
|
const node = document.createElement(tag);
|
|
@@ -40,21 +41,70 @@ export function imageLabel(source) {
|
|
|
40
41
|
return file || source;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// An object URL is a real resource, not a string: the browser pins the blob
|
|
45
|
+
// behind it until it is revoked, and a panel rebuild constructs a fresh widget
|
|
46
|
+
// every time. This owns the whole lifetime — one live URL at a time, the old one
|
|
47
|
+
// revoked before a new one replaces it, and everything released on dispose — so
|
|
48
|
+
// no caller has to remember. Returns `null` for a value that needs no URL.
|
|
49
|
+
function makeObjectUrlSlot() {
|
|
50
|
+
let current = null;
|
|
51
|
+
const release = () => {
|
|
52
|
+
if (current) URL.revokeObjectURL(current);
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
forBytes(source) {
|
|
57
|
+
release();
|
|
58
|
+
if (!isBytes(source)) return null;
|
|
59
|
+
// Always image/png: `imageToPng` is what produced these bytes, whatever the
|
|
60
|
+
// user dropped. The type matters — a Blob with none renders nothing.
|
|
61
|
+
current = URL.createObjectURL(new Blob([source], { type: "image/png" }));
|
|
62
|
+
return current;
|
|
63
|
+
},
|
|
64
|
+
dispose: release,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Point (or unpoint) the live preview. A string source is used directly. Bytes —
|
|
69
|
+
// the partforge-cloud sandbox path, where the converted PNG travels in the param
|
|
70
|
+
// because that sandbox cannot fetch URLs — become an object URL, so the cloud
|
|
71
|
+
// gets the same thumbnail as everyone else rather than a blank tile. `onerror`
|
|
72
|
+
// still covers the remaining broken-image case: a URL that 404s or CORS refuses.
|
|
73
|
+
// Resolved ONCE per paint, never per image: the catalog rendering shows the same
|
|
74
|
+
// source in two <img>s, and asking the slot twice would revoke the URL it had
|
|
75
|
+
// just handed the first one, leaving it pointing at a dead blob.
|
|
76
|
+
function previewSrc(source, urls) {
|
|
77
|
+
return typeof source === "string" && source ? source : urls.forBytes(source);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// When the control's own param is empty, show what the PART is using: its
|
|
81
|
+
// bundled default lives in the `images` declaration, which is the only place it
|
|
82
|
+
// can live (the allow list passes only https, so a file:/dev URL cannot sit in
|
|
83
|
+
// `defaults`). Resolving it is async — a Vite thunk has to be called — so the
|
|
84
|
+
// tile paints empty first and fills in, and a source that never resolves simply
|
|
85
|
+
// leaves it empty. `seq` guards against a slow resolve landing after a newer one.
|
|
86
|
+
function paintDeclared(img, declaredSource, node, apply) {
|
|
87
|
+
if (!declaredSource) return;
|
|
88
|
+
const source = declaredSource("image", node.key);
|
|
89
|
+
if (source === undefined) return;
|
|
90
|
+
const seq = ++img._pfDeclaredSeq;
|
|
91
|
+
declaredImageUrl(source).then((url) => {
|
|
92
|
+
if (url && seq === img._pfDeclaredSeq) apply(url);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function applyPreview(img, src) {
|
|
97
|
+
if (src) {
|
|
49
98
|
img.hidden = false;
|
|
50
|
-
img.src =
|
|
99
|
+
img.src = src;
|
|
51
100
|
} else {
|
|
52
101
|
img.hidden = true;
|
|
53
102
|
img.removeAttribute("src");
|
|
54
103
|
}
|
|
55
104
|
}
|
|
56
105
|
|
|
57
|
-
export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
|
|
106
|
+
export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload, declaredSource } = {}) {
|
|
107
|
+
const urls = makeObjectUrlSlot(); // one live preview URL per widget; see makeObjectUrlSlot
|
|
58
108
|
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
|
|
59
109
|
const wrap = el("div", "slider");
|
|
60
110
|
const row = el("div", "row");
|
|
@@ -65,12 +115,12 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
65
115
|
|
|
66
116
|
const preview = document.createElement("img");
|
|
67
117
|
preview.className = "image-preview";
|
|
118
|
+
preview._pfDeclaredSeq = 0;
|
|
68
119
|
preview.alt = "";
|
|
69
120
|
preview.hidden = true;
|
|
70
121
|
// A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
|
|
71
122
|
// not the browser's broken-image glyph.
|
|
72
123
|
preview.addEventListener("error", () => { preview.hidden = true; });
|
|
73
|
-
wrap.append(preview);
|
|
74
124
|
|
|
75
125
|
if (!imageCatalog) {
|
|
76
126
|
// Degraded path: a URL field. Unlike `text`, it does NOT write on every
|
|
@@ -88,7 +138,13 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
88
138
|
field.value = isBytes(v) ? "" : String(v ?? "");
|
|
89
139
|
field.placeholder = isBytes(v) ? "Uploaded image" : "";
|
|
90
140
|
field.classList.remove("warn");
|
|
91
|
-
|
|
141
|
+
const own = previewSrc(v, urls);
|
|
142
|
+
applyPreview(preview, own);
|
|
143
|
+
preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
|
|
144
|
+
if (!own) paintDeclared(preview, declaredSource, node, (url) => {
|
|
145
|
+
applyPreview(preview, url);
|
|
146
|
+
preview.parentElement?.classList.toggle("has-thumb", true);
|
|
147
|
+
});
|
|
92
148
|
};
|
|
93
149
|
field.addEventListener("change", () => {
|
|
94
150
|
if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
|
|
@@ -98,14 +154,25 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
98
154
|
onCommit?.();
|
|
99
155
|
});
|
|
100
156
|
paintField();
|
|
101
|
-
|
|
157
|
+
// The URL box is OFF unless `sourceField: true`. The tile is already preview,
|
|
158
|
+
// drop target and click-to-choose in one, so on a 288 px rail a fourth
|
|
159
|
+
// affordance for the same job is the one earning its space least. Typing a
|
|
160
|
+
// source by hand is the rarer intent — a host token or an https URL someone
|
|
161
|
+
// already has — so it is the part that becomes opt-in, rather than the one
|
|
162
|
+
// every part pays rail height for.
|
|
163
|
+
if (node.sourceField === true) wrap.append(field);
|
|
102
164
|
|
|
103
165
|
const drop = mountDrop("image", {
|
|
104
166
|
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
105
167
|
});
|
|
168
|
+
// The tile IS the preview: dropping, clicking to choose, and showing what is
|
|
169
|
+
// currently selected become one box rather than three stacked ones.
|
|
170
|
+
// `has-thumb` swaps the dashed empty-state border for a solid frame.
|
|
171
|
+
drop.el.setAttribute("data-pf-thumb", "");
|
|
172
|
+
drop.el.prepend(preview);
|
|
106
173
|
wrap.append(drop.el, drop.errorEl);
|
|
107
174
|
|
|
108
|
-
return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
|
|
175
|
+
return { el: wrap, sync: paintField, dispose: () => { drop.dispose(); urls.dispose(); } };
|
|
109
176
|
}
|
|
110
177
|
|
|
111
178
|
const btn = el("button", "image-btn");
|
|
@@ -131,8 +198,15 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
131
198
|
const paint = () => {
|
|
132
199
|
const src = params[node.key];
|
|
133
200
|
const seq = ++paintSeq;
|
|
134
|
-
|
|
135
|
-
|
|
201
|
+
const url = previewSrc(src, urls);
|
|
202
|
+
applyPreview(preview, url);
|
|
203
|
+
applyPreview(thumb, url);
|
|
204
|
+
preview.parentElement?.classList.toggle("has-thumb", !preview.hidden);
|
|
205
|
+
if (!url) paintDeclared(preview, declaredSource, node, (u) => {
|
|
206
|
+
applyPreview(preview, u);
|
|
207
|
+
applyPreview(thumb, u);
|
|
208
|
+
preview.parentElement?.classList.toggle("has-thumb", true);
|
|
209
|
+
});
|
|
136
210
|
const show = ({ label: text, width, height }) => {
|
|
137
211
|
if (seq !== paintSeq) return; // a newer paint already won
|
|
138
212
|
iname.textContent = width && height ? `${text} (${width}×${height})` : text;
|
|
@@ -162,12 +236,16 @@ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog
|
|
|
162
236
|
});
|
|
163
237
|
|
|
164
238
|
const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
|
|
239
|
+
// Same merge as the degraded branch — the large preview lives in the drop tile;
|
|
240
|
+
// the catalog button keeps its own small thumb.
|
|
241
|
+
drop.el.setAttribute("data-pf-thumb", "");
|
|
242
|
+
drop.el.prepend(preview);
|
|
165
243
|
wrap.append(drop.el, drop.errorEl);
|
|
166
244
|
|
|
167
245
|
return {
|
|
168
246
|
el: wrap,
|
|
169
247
|
sync: paint,
|
|
170
|
-
dispose: () => { picker?.close(); picker = null; drop.dispose(); },
|
|
248
|
+
dispose: () => { picker?.close(); picker = null; drop.dispose(); urls.dispose(); },
|
|
171
249
|
};
|
|
172
250
|
}
|
|
173
251
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// A vector document -> a small inline <svg> preview, for the vector control's
|
|
2
|
+
// thumbnail. MAIN-THREAD ONLY (it builds DOM), but deliberately free of any
|
|
3
|
+
// heavy geometry import: `profile.js` is the one dependency and has no imports
|
|
4
|
+
// of its own. Reaching for `vector-format.js`'s expander instead would pull in
|
|
5
|
+
// contour-ops -> paper-bridge -> paper.js, ~1 MB of curve engine loaded on every
|
|
6
|
+
// page that merely SHOWS a vector control, whether or not anyone drops a file.
|
|
7
|
+
//
|
|
8
|
+
// `tessellateContour` is the geometry's OWN tessellator, which matters more than
|
|
9
|
+
// the saved bytes: a thumbnail that flattened curves its own way could show a
|
|
10
|
+
// shape the kernel would not build. Arcs are the specific trap — the format
|
|
11
|
+
// writes them as a point ON the arc, while SVG's `A` command wants radii and
|
|
12
|
+
// sweep flags, so "just map it to A" is a second interpretation waiting to
|
|
13
|
+
// diverge. At thumbnail size a tessellated arc is pixel-identical anyway.
|
|
14
|
+
import { tessellateContour } from "../../geometry/profile.js";
|
|
15
|
+
|
|
16
|
+
// Enough segments that a full circle reads as round at ~44 px, cheap enough that
|
|
17
|
+
// a document with hundreds of contours still renders in one frame.
|
|
18
|
+
const ARC_SEGS = 24;
|
|
19
|
+
|
|
20
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
|
+
|
|
22
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
23
|
+
const finitePoint = (p) => Array.isArray(p) && p.length >= 2 && finite(p[0]) && finite(p[1]);
|
|
24
|
+
|
|
25
|
+
// The four contour kinds, reduced to a ring of points. The three primitives are
|
|
26
|
+
// sugar the format defines by expansion; `toInternalDocument` normally does this,
|
|
27
|
+
// but it lives behind the paper.js import described above — and these expansions
|
|
28
|
+
// are four lines each, so the thumbnail does them directly rather than paying
|
|
29
|
+
// that cost. `path` delegates to the canonical tessellator.
|
|
30
|
+
function ring(contour) {
|
|
31
|
+
if (!contour || typeof contour !== "object") return null;
|
|
32
|
+
switch (contour.kind) {
|
|
33
|
+
case "circle": {
|
|
34
|
+
const { center: c, r } = contour;
|
|
35
|
+
if (!finitePoint(c) || !finite(r) || r <= 0) return null;
|
|
36
|
+
return Array.from({ length: ARC_SEGS }, (_, i) => {
|
|
37
|
+
const t = (i / ARC_SEGS) * Math.PI * 2;
|
|
38
|
+
return [c[0] + Math.cos(t) * r, c[1] + Math.sin(t) * r];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
case "rect": {
|
|
42
|
+
const { center: c, width: w, height: h } = contour;
|
|
43
|
+
if (!finitePoint(c) || !finite(w) || !finite(h) || w <= 0 || h <= 0) return null;
|
|
44
|
+
const hw = w / 2, hh = h / 2;
|
|
45
|
+
// Corner radius is ignored: at thumbnail scale the rounding is sub-pixel,
|
|
46
|
+
// and squaring it off never changes what the shape reads as.
|
|
47
|
+
return [[c[0] - hw, c[1] - hh], [c[0] + hw, c[1] - hh], [c[0] + hw, c[1] + hh], [c[0] - hw, c[1] + hh]];
|
|
48
|
+
}
|
|
49
|
+
case "polygon":
|
|
50
|
+
return Array.isArray(contour.points) && contour.points.length >= 3
|
|
51
|
+
&& contour.points.every(finitePoint) ? contour.points.map((p) => [p[0], p[1]]) : null;
|
|
52
|
+
case "path":
|
|
53
|
+
default: {
|
|
54
|
+
if (!finitePoint(contour.start) || !Array.isArray(contour.segments)) return null;
|
|
55
|
+
// The FILE format names an arc's midpoint `through`; the internal contour
|
|
56
|
+
// IR names it `via`, and that is what `tessellateContour` reads. A document
|
|
57
|
+
// read off disk or returned by `ingestSvg` therefore speaks `through`, and
|
|
58
|
+
// a segment with neither key is treated as a straight line — so skipping
|
|
59
|
+
// this rename does not fail loudly, it silently replaces every curve with
|
|
60
|
+
// its chord. A circle becomes a triangle, which looks like a rendering bug
|
|
61
|
+
// rather than a parsing one.
|
|
62
|
+
const segments = contour.segments.map((seg) =>
|
|
63
|
+
seg && seg.through && !seg.via ? { ...seg, via: seg.through } : seg);
|
|
64
|
+
const pts = tessellateContour({ ...contour, segments }, ARC_SEGS);
|
|
65
|
+
return Array.isArray(pts) && pts.length >= 3 && pts.every(finitePoint) ? pts : null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One subpath. Y is negated because the model frame is y-up and SVG is y-down;
|
|
71
|
+
// the viewBox is negated to match, so the flip is a coordinate convention rather
|
|
72
|
+
// than a transform the caller has to know about.
|
|
73
|
+
const subpath = (pts) =>
|
|
74
|
+
`M ${pts.map(([x, y], i) => `${i ? "L " : ""}${+x.toFixed(3)} ${+(-y).toFixed(3)}`).join(" ")} Z`;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a partforge-vector document as an inline <svg>, or return `null` when
|
|
78
|
+
* there is nothing renderable — an empty document, or one whose coordinates are
|
|
79
|
+
* not finite. Returning null rather than throwing keeps a malformed document
|
|
80
|
+
* from taking the control down with it; the caller falls back to a placeholder.
|
|
81
|
+
*/
|
|
82
|
+
export function vectorThumb(doc) {
|
|
83
|
+
const shapes = doc?.shapes;
|
|
84
|
+
if (!shapes || typeof shapes !== "object") return null;
|
|
85
|
+
|
|
86
|
+
// Every region from every shape lands in ONE path so `evenodd` composes them:
|
|
87
|
+
// a `subtract` shape's regions then cut the shapes they overlap, which is what
|
|
88
|
+
// the document means. The known limitation is that two overlapping regions of
|
|
89
|
+
// the SAME role also cancel — real composition is a boolean the panel has no
|
|
90
|
+
// business running. At preview size that trade is invisible, and a missing hole
|
|
91
|
+
// would be far more misleading than a rare cancelled overlap.
|
|
92
|
+
const subpaths = [];
|
|
93
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
94
|
+
|
|
95
|
+
for (const shape of Object.values(shapes)) {
|
|
96
|
+
// A shape is either an array of regions, or `{ role, regions }` — §2.3.
|
|
97
|
+
const regions = Array.isArray(shape) ? shape : shape?.regions;
|
|
98
|
+
if (!Array.isArray(regions)) continue;
|
|
99
|
+
for (const region of regions) {
|
|
100
|
+
for (const contour of [region?.outer, ...(region?.holes ?? [])]) {
|
|
101
|
+
if (contour === undefined) continue;
|
|
102
|
+
const pts = ring(contour);
|
|
103
|
+
if (!pts) return null; // a bad coordinate anywhere means the preview would lie
|
|
104
|
+
for (const [x, y] of pts) {
|
|
105
|
+
if (x < minX) minX = x;
|
|
106
|
+
if (x > maxX) maxX = x;
|
|
107
|
+
if (y < minY) minY = y;
|
|
108
|
+
if (y > maxY) maxY = y;
|
|
109
|
+
}
|
|
110
|
+
subpaths.push(subpath(pts));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!subpaths.length) return null;
|
|
115
|
+
|
|
116
|
+
// `bbox` is optional and "recomputed when absent" (§2.1), so a hand-authored
|
|
117
|
+
// document routinely has none. Trusting it when present keeps the preview
|
|
118
|
+
// framed the way the document says it should be; deriving it otherwise is what
|
|
119
|
+
// makes an authored file previewable at all.
|
|
120
|
+
const b = doc.bbox;
|
|
121
|
+
const box = b && [b.minX, b.minY, b.maxX, b.maxY].every(finite)
|
|
122
|
+
? b : { minX, minY, maxX, maxY };
|
|
123
|
+
const w = box.maxX - box.minX, h = box.maxY - box.minY;
|
|
124
|
+
if (!(w > 0) || !(h > 0)) return null;
|
|
125
|
+
|
|
126
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
127
|
+
svg.setAttribute("viewBox", `${+box.minX.toFixed(3)} ${+(-box.maxY).toFixed(3)} ${+w.toFixed(3)} ${+h.toFixed(3)}`);
|
|
128
|
+
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
129
|
+
svg.setAttribute("aria-hidden", "true"); // decorative; the control carries the label
|
|
130
|
+
|
|
131
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
132
|
+
path.setAttribute("d", subpaths.join(" "));
|
|
133
|
+
path.setAttribute("fill-rule", "evenodd");
|
|
134
|
+
svg.append(path);
|
|
135
|
+
return svg;
|
|
136
|
+
}
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
import { attachInfo } from "../info.js";
|
|
20
20
|
import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
|
|
21
21
|
import { mountDrop } from "./file-drop.js";
|
|
22
|
+
import { vectorThumb } from "./vector-thumb.js";
|
|
23
|
+
import { declaredVectorDoc } from "../declared-source.js";
|
|
22
24
|
|
|
23
25
|
function el(tag, className, text) {
|
|
24
26
|
const node = document.createElement(tag);
|
|
@@ -34,7 +36,7 @@ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
|
|
|
34
36
|
// byte-valued param.
|
|
35
37
|
const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
|
|
36
38
|
|
|
37
|
-
export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
|
|
39
|
+
export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload, declaredSource } = {}) {
|
|
38
40
|
const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
|
|
39
41
|
const wrap = el("div", "slider");
|
|
40
42
|
const row = el("div", "row");
|
|
@@ -66,12 +68,53 @@ export function makeVector(node, params, { onChange, onCommit, info, onAssetUplo
|
|
|
66
68
|
onCommit?.();
|
|
67
69
|
});
|
|
68
70
|
paintField();
|
|
69
|
-
|
|
71
|
+
// The URL box is OFF unless `sourceField: true` — same reasoning as
|
|
72
|
+
// widgets/image.js: the tile already carries preview, drop and click-to-choose,
|
|
73
|
+
// and typing a source by hand is the rarer intent.
|
|
74
|
+
if (node.sourceField === true) wrap.append(field);
|
|
70
75
|
|
|
76
|
+
// The thumbnail IS the drop target. A vector param holds a parsed document, so
|
|
77
|
+
// there is no URL an <img> could point at — the artwork is drawn inline
|
|
78
|
+
// instead, and that tile is what a file is dropped on and what opens the file
|
|
79
|
+
// picker. One element doing all three keeps the rail's 300 px from carrying a
|
|
80
|
+
// preview, a drop zone and a button that all mean the same thing.
|
|
71
81
|
const drop = mountDrop("vector", {
|
|
72
|
-
params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
|
|
82
|
+
params, node, onAssetUpload, onChange, onCommit, onRender: () => { paintField(); paintThumb(); },
|
|
73
83
|
});
|
|
74
|
-
|
|
84
|
+
const thumb = drop.el;
|
|
85
|
+
thumb.setAttribute("data-pf-thumb", "");
|
|
75
86
|
|
|
76
|
-
|
|
87
|
+
// `vectorThumb` returns null for a document it cannot draw — malformed, empty,
|
|
88
|
+
// or carrying a coordinate that is not finite — rather than throwing. The tile
|
|
89
|
+
// stays either way, because it is the drop target: losing it on a bad document
|
|
90
|
+
// would strand the user with no way to replace it.
|
|
91
|
+
let thumbSeq = 0;
|
|
92
|
+
function showThumb(doc) {
|
|
93
|
+
const art = thumb.querySelector("svg");
|
|
94
|
+
if (art) art.remove();
|
|
95
|
+
const svg = doc ? vectorThumb(doc) : null;
|
|
96
|
+
thumb.classList.toggle("has-thumb", !!svg);
|
|
97
|
+
if (svg) thumb.prepend(svg);
|
|
98
|
+
}
|
|
99
|
+
function paintThumb() {
|
|
100
|
+
const own = params[node.key];
|
|
101
|
+
const seq = ++thumbSeq;
|
|
102
|
+
if (isOpaque(own)) { showThumb(own); return; }
|
|
103
|
+
showThumb(null);
|
|
104
|
+
// Nothing in the param — fall back to what the PART declares, which is where
|
|
105
|
+
// a bundled default has to live (the allow list passes only https, so a
|
|
106
|
+
// file:/dev URL cannot sit in `defaults`). Unlike an image there is nothing
|
|
107
|
+
// to point at: the document must be fetched and parsed before it can be
|
|
108
|
+
// drawn, so this lands a tick or two later, and a source that never resolves
|
|
109
|
+
// just leaves the tile empty — it stays a drop target either way.
|
|
110
|
+
const source = declaredSource?.("vector", node.key);
|
|
111
|
+
if (source === undefined) return;
|
|
112
|
+
declaredVectorDoc(source).then((doc) => { if (doc && seq === thumbSeq) showThumb(doc); });
|
|
113
|
+
}
|
|
114
|
+
paintThumb();
|
|
115
|
+
|
|
116
|
+
wrap.append(thumb, drop.errorEl);
|
|
117
|
+
|
|
118
|
+
const sync = () => { paintField(); paintThumb(); };
|
|
119
|
+
return { el: wrap, sync, dispose: () => drop.dispose() };
|
|
77
120
|
}
|