partforge 0.96.0 → 0.97.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/bin/cli.js +14 -1
- package/docs/AUTHORING-PARTS.md +199 -1
- package/docs/ERROR-PATTERNS.md +24 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/package.json +1 -1
- package/src/app-relief.js +16 -0
- package/src/framework/app.css +30 -0
- package/src/framework/backend-select.js +7 -2
- package/src/framework/geometry/heightfield.js +129 -0
- package/src/framework/geometry/kernel.js +3 -0
- package/src/framework/geometry/manifold-backend.js +61 -0
- package/src/framework/geometry/occt-backend.js +148 -1
- package/src/framework/geometry/op-options.js +10 -0
- package/src/framework/geometry/png-decode.js +107 -0
- package/src/framework/geometry/solid-hash.js +96 -0
- package/src/framework/image-ingest.js +41 -0
- package/src/framework/image-source.js +72 -0
- package/src/framework/images.js +66 -0
- package/src/framework/jobs.js +55 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-images.js +109 -0
- package/src/framework/measure/measure-mode.js +2 -1
- package/src/framework/mount.js +2 -1
- package/src/framework/oracle/verify.js +6 -1
- package/src/framework/panel/image-picker.js +152 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/image.js +164 -0
- package/src/framework/panel/widgets/index.js +9 -5
- package/src/framework/param-deps.js +7 -2
- package/src/index.js +1 -0
- package/src/parts/assets/relief-demo.png +0 -0
- package/src/parts/relief.js +84 -0
- package/src/relief-worker.js +3 -0
- package/src/testing/manifold.js +7 -1
- package/src/testing/occt.js +4 -1
- package/types/index.d.ts +13 -0
- package/types/kernel.d.ts +32 -0
- package/types/part.d.ts +21 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Resolve a part's declared `images` ({ name: source }) to a decoded luminance
|
|
2
|
+
// grid + a SHA-256 content digest, before the synchronous build — the third
|
|
3
|
+
// asset sibling beside fonts.js and imports.js, on the shared resolution core in
|
|
4
|
+
// asset-resolve.js. DOM-free and node:-free; crypto.subtle exists in workers and
|
|
5
|
+
// Node.
|
|
6
|
+
//
|
|
7
|
+
// Registration is simpler than imports': every backend can consume a normalized
|
|
8
|
+
// grid, so there are no per-format error entries and no crossover.
|
|
9
|
+
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
10
|
+
import { decodePng } from "./geometry/png-decode.js";
|
|
11
|
+
|
|
12
|
+
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47];
|
|
13
|
+
|
|
14
|
+
async function sha256Hex(bytes) {
|
|
15
|
+
const d = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
16
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cache = new Map(); // source → Promise<{digest, width, height, data}>
|
|
20
|
+
const resolveOne = makeAssetResolver(
|
|
21
|
+
cache,
|
|
22
|
+
async (bytes) => {
|
|
23
|
+
const u8 = new Uint8Array(bytes);
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
if (u8[i] !== PNG_SIG[i]) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"images: only PNG is supported — convert with imageToPng() from \"partforge\" before storing, " +
|
|
28
|
+
"or have the host normalize on upload",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const { width, height, data } = decodePng(u8);
|
|
33
|
+
return { digest: await sha256Hex(bytes), width, height, data };
|
|
34
|
+
},
|
|
35
|
+
"resolveImages: an image source must be bytes, a URL, or a thunk returning one",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// `images` may be a plain { name: source } map or a function of the resolved
|
|
39
|
+
// params — the second form is what lets a `type: "image"` control drive the
|
|
40
|
+
// source. Mirrors fontsFor.
|
|
41
|
+
export function imagesFor(part, p) {
|
|
42
|
+
const decl = part?.images;
|
|
43
|
+
return typeof decl === "function" ? decl(p) : decl;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function resolveImages(imagesDecl) {
|
|
47
|
+
// A function reaching here means a caller passed `part.images` raw. It
|
|
48
|
+
// cannot be resolved without params — resolve it with imagesFor(part, p)
|
|
49
|
+
// first (mirrors resolveFonts's guard in fonts.js).
|
|
50
|
+
if (typeof imagesDecl === "function") {
|
|
51
|
+
throw new Error("resolveImages: `images` is a function of params — resolve it with imagesFor(part, p) first");
|
|
52
|
+
}
|
|
53
|
+
return resolveDecl(imagesDecl, resolveOne);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Register a part's images on a booted kernel (idempotent per digest). Called in
|
|
57
|
+
// the async phase before every job's synchronous build — worker (jobs.js) and
|
|
58
|
+
// Node boots alike.
|
|
59
|
+
export async function ensureImages(kernel, imagesDecl) {
|
|
60
|
+
if (!imagesDecl || typeof kernel?._registerImage !== "function") return;
|
|
61
|
+
const resolved = await resolveImages(imagesDecl);
|
|
62
|
+
for (const [name, a] of resolved) {
|
|
63
|
+
if (kernel._imageDigest?.(name) === a.digest) continue;
|
|
64
|
+
await kernel._registerImage({ name, digest: a.digest, width: a.width, height: a.height, data: a.data });
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -7,6 +7,8 @@ import { exportablePartNames } from "./export-select.js";
|
|
|
7
7
|
import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
|
|
8
8
|
import { fontsFor, resolveFonts } from "./fonts.js";
|
|
9
9
|
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
10
|
+
import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
|
|
11
|
+
import { imagesFor, ensureImages } from "./images.js";
|
|
10
12
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
11
13
|
import { safeName } from "./safe-name.js";
|
|
12
14
|
import { ensureVectors } from "./vectors.js";
|
|
@@ -158,6 +160,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
158
160
|
jobWarnings.push({ part: null, message }); // …and the durable record
|
|
159
161
|
params[key] = part.defaults?.[key];
|
|
160
162
|
}
|
|
163
|
+
// Same shape for `type: "image"` controls — a param bound to one is user
|
|
164
|
+
// input, and on a shared link a STRING value is arbitrary attacker text
|
|
165
|
+
// that `images: (p) => …` would turn into a fetch URL. A BYTE value
|
|
166
|
+
// (ArrayBuffer/typed array) always passes imageSourceAllowed regardless of
|
|
167
|
+
// `allow` — see image-source.js's header: it cannot have arrived via a
|
|
168
|
+
// share link (a URL can't carry megabytes), only from the host's own
|
|
169
|
+
// trusted panel (the partforge-cloud sandbox path). Runs in this same
|
|
170
|
+
// sanitize hook, not after resolveParams returns, for the reason the
|
|
171
|
+
// comment above states: rewriting p[key] afterwards would leave derive()
|
|
172
|
+
// — and therefore `d` and the geometry — holding the refused value while
|
|
173
|
+
// build() saw the default.
|
|
174
|
+
for (const [key, allow] of imageControlAllows(part)) {
|
|
175
|
+
const v = params[key];
|
|
176
|
+
if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
|
|
177
|
+
const message = `image source for "${key}" is not allowed — using the default`;
|
|
178
|
+
onProgress(message);
|
|
179
|
+
jobWarnings.push({ part: null, message });
|
|
180
|
+
params[key] = part.defaults?.[key];
|
|
181
|
+
}
|
|
161
182
|
});
|
|
162
183
|
// Preload any part-declared fonts into the kernel before building. A lazy
|
|
163
184
|
// dynamic import because this is async context (unlike the synchronous
|
|
@@ -227,6 +248,40 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
227
248
|
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
228
249
|
// calls k.import on it.
|
|
229
250
|
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
251
|
+
// Register this part's declared images on the kernel running this job — the
|
|
252
|
+
// third asset sibling beside fonts and imports. The allow-check already ran
|
|
253
|
+
// as resolveParams' sanitize hook above, so `p` here already reflects any
|
|
254
|
+
// refusal (reset to the part's default) — this step only resolves and
|
|
255
|
+
// uploads the resulting bytes.
|
|
256
|
+
//
|
|
257
|
+
// Gated on the part DECLARING `images` at all, not on this job having a
|
|
258
|
+
// source to resolve — the prune below has to run on the empty declaration
|
|
259
|
+
// too (a cleared pick), and a part with no `images` field must not touch
|
|
260
|
+
// the kernel's image map (a host or test harness may have seeded it
|
|
261
|
+
// directly via _registerImage).
|
|
262
|
+
if (part.images && typeof kernel._registerImage === "function") {
|
|
263
|
+
const imagesDecl = imagesFor(part, p) ?? {};
|
|
264
|
+
const declared = Object.fromEntries(
|
|
265
|
+
Object.entries(imagesDecl).filter(([name, src]) => {
|
|
266
|
+
if (!isNoImageSource(src)) return true;
|
|
267
|
+
onProgress(`no image source declared for "${name}" — skipping`);
|
|
268
|
+
return false;
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
if (Object.keys(declared).length) {
|
|
272
|
+
onProgress("resolving images");
|
|
273
|
+
await ensureImages(kernel, declared);
|
|
274
|
+
}
|
|
275
|
+
// Drop every registered name this build's declaration does not supply.
|
|
276
|
+
// `_pruneImages` is the images twin of the `kernel._fonts` prune above:
|
|
277
|
+
// `heightfield(name)` looks a name up by the part's declared key, not by
|
|
278
|
+
// content, so a relief the user picked and then CLEARED would otherwise
|
|
279
|
+
// stay registered under its old name and go on rendering instead of
|
|
280
|
+
// whatever a missing source actually does — the unknown-image throw, or
|
|
281
|
+
// a branch a part's own build() takes around the call — the
|
|
282
|
+
// stale-registration bug of spec §5, one asset over.
|
|
283
|
+
kernel._pruneImages?.(new Set(Object.keys(declared)));
|
|
284
|
+
}
|
|
230
285
|
// Vector art, the third asset family after fonts and imports. Same pre-build
|
|
231
286
|
// timing; ensureVectors owns the prune, so this stays one line. Call it
|
|
232
287
|
// unconditionally, even when this part has no `vectors` at all: ensureVectors
|
|
@@ -17,10 +17,11 @@ import { ANIMATION_RULES } from "./rules-animations.js";
|
|
|
17
17
|
import { PLACE_RULES } from "./rules-place.js";
|
|
18
18
|
import { IMPORT_RULES } from "./rules-imports.js";
|
|
19
19
|
import { FONT_RULES } from "./rules-fonts.js";
|
|
20
|
+
import { IMAGE_RULES } from "./rules-images.js";
|
|
20
21
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
21
22
|
import { VECTOR_RULES } from "./rules-vector.js";
|
|
22
23
|
|
|
23
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
24
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
24
25
|
|
|
25
26
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
26
27
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Group 10 — image-control well-formedness + heightfield name resolution. The
|
|
2
|
+
// sibling of rules-fonts.js for the `type: "image"` control / `images` field /
|
|
3
|
+
// `k.heightfield()` triangle: same silent-failure shapes, same source-scheme
|
|
4
|
+
// concern, plus one this group owns alone — a `k.heightfield(name, opts)` call
|
|
5
|
+
// naming an image the part never declared.
|
|
6
|
+
//
|
|
7
|
+
// image-control-not-in-images and heightfield-unknown-image are deliberately
|
|
8
|
+
// COMPLEMENTARY, not overlapping: each fires in exactly the case the other
|
|
9
|
+
// skips. A function-form `images` can depend on a param — good, but its return
|
|
10
|
+
// value can't be read without calling it, so image-control-not-in-images
|
|
11
|
+
// actually calls it (with a sentinel substituted for the control's key) rather
|
|
12
|
+
// than settling for font-control-not-in-fonts's cheaper "is it a function at
|
|
13
|
+
// all?" question; a static `images` object provably CANNOT depend on a
|
|
14
|
+
// param — that mistake belongs to image-control-not-in-images too, and would
|
|
15
|
+
// fire on every correctly-authored fixed-image part if this rule also ran
|
|
16
|
+
// there, so it is skipped entirely for a static `images`. Conversely, only a
|
|
17
|
+
// static `images` object has statically-knowable keys, so heightfield-unknown-
|
|
18
|
+
// image runs only there and skips whenever `images` is a function.
|
|
19
|
+
import { err, warn } from "./finding.js";
|
|
20
|
+
import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "../image-source.js";
|
|
21
|
+
|
|
22
|
+
// A URL-shaped sentinel (has a "://"), not an arbitrary string — some `images`
|
|
23
|
+
// functions run `new URL(v)` or similar on the value before deciding whether to
|
|
24
|
+
// use it, and an arbitrary string would make that throw and short-circuit the
|
|
25
|
+
// probe for reasons that have nothing to do with whether the key is read.
|
|
26
|
+
const SENTINEL = "pf-lint-sentinel://image-control-not-in-images";
|
|
27
|
+
|
|
28
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
export const IMAGE_RULES = [
|
|
31
|
+
{
|
|
32
|
+
id: "image-control-not-in-images",
|
|
33
|
+
run: ({ part, p }) => {
|
|
34
|
+
// Static-object `images` always fails this check by construction — it
|
|
35
|
+
// cannot read a param at all — so it is a different mistake
|
|
36
|
+
// (image-source-scheme's business, or none) and not this rule's.
|
|
37
|
+
if (typeof part?.images !== "function") return [];
|
|
38
|
+
const controls = imageControlAllows(part);
|
|
39
|
+
if (controls.size === 0) return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const key of controls.keys()) {
|
|
42
|
+
let resolved;
|
|
43
|
+
try { resolved = part.images({ ...p, [key]: SENTINEL }); }
|
|
44
|
+
catch { continue; } // can't be probed safely — not evidence either way
|
|
45
|
+
const reached = isPlainObject(resolved) && Object.values(resolved).includes(SENTINEL);
|
|
46
|
+
if (reached) continue;
|
|
47
|
+
out.push(err("image-control-not-in-images",
|
|
48
|
+
`control "${key}" is an image picker, but this part's \`images\` function never returns the picked value — the picked value is never resolved.`,
|
|
49
|
+
`Reference p.${key} from images, e.g. images: (p) => ({ ${key}: p.${key} }), and consume it with k.heightfield("${key}", opts).`,
|
|
50
|
+
"images"));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "heightfield-unknown-image",
|
|
57
|
+
run: ({ part, probe }) => {
|
|
58
|
+
// Only a static `images` object has statically-knowable names; a
|
|
59
|
+
// function's return value depends on params lint doesn't exhaustively
|
|
60
|
+
// enumerate, so it's skipped here (see file header).
|
|
61
|
+
if (typeof part?.images === "function") return [];
|
|
62
|
+
const known = new Set(isPlainObject(part?.images) ? Object.keys(part.images) : []);
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const call of probe().calls) {
|
|
66
|
+
if (call.scope !== "kernel" || call.op !== "heightfield") continue;
|
|
67
|
+
// k.heightfield(nameOrGrid, opts) also accepts an INLINE grid object as
|
|
68
|
+
// its first argument — args are recorded via JSON.stringify (probe.js's
|
|
69
|
+
// `describe`), so a grid parses back to an object, not a string. Only a
|
|
70
|
+
// string-literal first argument names a declared image; anything else
|
|
71
|
+
// (an inline grid, a computed/non-literal value the probe can't read)
|
|
72
|
+
// is silently skipped rather than flagged — flagging a supported inline
|
|
73
|
+
// grid as an "unknown image" would be a false positive.
|
|
74
|
+
let name;
|
|
75
|
+
try { name = JSON.parse(call.args[0]); } catch { name = null; }
|
|
76
|
+
if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
|
|
77
|
+
seen.add(name);
|
|
78
|
+
out.push(err("heightfield-unknown-image",
|
|
79
|
+
`build calls k.heightfield with name "${name}", which the part's images field does not declare: ${[...known].join(", ") || "(nothing)"}`,
|
|
80
|
+
"Declare the source under images: { name: source }, or fix the name to match an existing entry.",
|
|
81
|
+
"images"));
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "image-source-scheme",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const [key, allow] of imageControlAllows(part)) {
|
|
91
|
+
const v = part?.defaults?.[key];
|
|
92
|
+
// An empty source declares no image (jobs.js's sanitize hook drops it
|
|
93
|
+
// before it reaches ensureImages, and a build() that still calls
|
|
94
|
+
// k.heightfield for it gets the ordinary unknown-image throw — there
|
|
95
|
+
// is no automatic "no relief" fallback) — a legitimate way to author
|
|
96
|
+
// an optional relief, not a source the allow list is refusing. A
|
|
97
|
+
// bytes source (ArrayBuffer/typed array) always
|
|
98
|
+
// passes imageSourceAllowed regardless of allow — see image-source.js's
|
|
99
|
+
// header — so it never reaches this branch either.
|
|
100
|
+
if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
|
|
101
|
+
out.push(warn("image-source-scheme",
|
|
102
|
+
`defaults.${key} is "${String(v).slice(0, 120)}", which control "${key}" would refuse (allow: ${allow.join(", ")}).`,
|
|
103
|
+
`Use a source the allow list accepts, or widen \`allow\` on the control. At build time this value is replaced by defaults.${key}, so as written the part has no usable image.`,
|
|
104
|
+
"defaults"));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -13,6 +13,7 @@ import { raycastViewer } from "../selection/raycast.js";
|
|
|
13
13
|
import { createFeatureHighlight } from "../selection/feature-highlight.js";
|
|
14
14
|
import { createDragTracker } from "../selection/drag-tracker.js";
|
|
15
15
|
import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
|
|
16
|
+
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
16
17
|
import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
|
|
17
18
|
import { paramMatches } from "./param-link.js";
|
|
18
19
|
import { createPinStore, occurrenceOf } from "./pins.js";
|
|
@@ -136,7 +137,7 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
136
137
|
// direct test) falls back to the content hash.
|
|
137
138
|
let readsKey = null, readsMap = null;
|
|
138
139
|
function readsFor(view, params) {
|
|
139
|
-
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params)}`;
|
|
140
|
+
const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
|
|
140
141
|
if (readsKey !== key) {
|
|
141
142
|
readsKey = key;
|
|
142
143
|
try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
|
package/src/framework/mount.js
CHANGED
|
@@ -304,6 +304,7 @@ function createCleanupStack() {
|
|
|
304
304
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
305
305
|
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
|
|
306
306
|
fontCatalog,
|
|
307
|
+
imageCatalog,
|
|
307
308
|
viewerState,
|
|
308
309
|
annotateSend = "viewbar",
|
|
309
310
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
@@ -963,7 +964,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
963
964
|
}, onParamsCommit
|
|
964
965
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
965
966
|
: undefined,
|
|
966
|
-
{ fontCatalog });
|
|
967
|
+
{ fontCatalog, imageCatalog });
|
|
967
968
|
cleanup.defer(() => panel.dispose());
|
|
968
969
|
panelRef = panel;
|
|
969
970
|
const updateRelevance = () => {
|
|
@@ -4,6 +4,7 @@ import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
|
4
4
|
import { resolveProfile } from "./dfm-profiles.js";
|
|
5
5
|
import { expandExpectations, partGatesMinWall } from "./gates.js";
|
|
6
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
|
|
7
|
+
import { byteAwareReplacer } from "../geometry/solid-hash.js";
|
|
7
8
|
import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
|
|
8
9
|
|
|
9
10
|
// Re-exported for backwards compatibility: the registries moved to framework/ so
|
|
@@ -208,9 +209,13 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
208
209
|
const expanded = expandExpectations(part);
|
|
209
210
|
const needMinWall = partGatesMinWall(part, { process, expanded });
|
|
210
211
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
212
|
+
// byteAwareReplacer on the RELEVANT_ALL branch too: an unattributable derive()
|
|
213
|
+
// still might read a byte-valued image param, and this memo key gates whether
|
|
214
|
+
// a case's geometry gets rebuilt or an earlier result reused (see the seeding
|
|
215
|
+
// block below) — the same collision the relevanceHash branch guards against.
|
|
211
216
|
const signature = (params) =>
|
|
212
217
|
readKeys === RELEVANT_ALL
|
|
213
|
-
? JSON.stringify(params)
|
|
218
|
+
? JSON.stringify(params, byteAwareReplacer)
|
|
214
219
|
: [...readKeys.entries()].map(([name, keys]) => `${name}:${relevanceHash([...keys], params)}`).join("|");
|
|
215
220
|
|
|
216
221
|
const memo = new Map();
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The `type: "image"` picker: a takeover panel over the rail — a search box
|
|
2
|
+
// above a thumbnail grid — reusing the `.picker`/`.pk-head`/`.pk-search`
|
|
3
|
+
// scaffolding font-picker.js built, since a search-then-pick takeover is the
|
|
4
|
+
// same shape for either asset kind. No variants pane: an image source has no
|
|
5
|
+
// weight/style axis to drill into, so choosing IS committing.
|
|
6
|
+
//
|
|
7
|
+
// Main-thread only — it is DOM-heavy and is NOT part of the worker graph. It
|
|
8
|
+
// draws thumbnails through plain `<img src>`, so the browser's own decoder does
|
|
9
|
+
// the work; nothing here imports `png-decode.js`.
|
|
10
|
+
import { setImagePicker } from "./widgets/image.js";
|
|
11
|
+
import { imageSourceAllowed } from "../image-source.js";
|
|
12
|
+
|
|
13
|
+
const SEARCH_LIMIT = 60;
|
|
14
|
+
const SEARCH_DEBOUNCE_MS = 120;
|
|
15
|
+
|
|
16
|
+
function el(tag, className, text) {
|
|
17
|
+
const node = document.createElement(tag);
|
|
18
|
+
if (className) node.className = className;
|
|
19
|
+
if (text != null) node.textContent = text;
|
|
20
|
+
return node;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// At most one picker is open at a time, and the previous one has to be CLOSED
|
|
24
|
+
// rather than merely detached: its `keydown` listener lives on `document`, so
|
|
25
|
+
// dropping the element off the DOM leaves the handler — and the whole closure —
|
|
26
|
+
// alive forever, one more on every re-open. Only close() unregisters it.
|
|
27
|
+
let openPicker = null;
|
|
28
|
+
|
|
29
|
+
export function openImagePicker({ node, params, allow, imageCatalog, anchor, onPicked }) {
|
|
30
|
+
// Takeover: the picker covers the rail on desktop and the single visible pane
|
|
31
|
+
// below the narrow breakpoint, same as the font picker.
|
|
32
|
+
const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
|
|
33
|
+
openPicker?.close(); // never two at once
|
|
34
|
+
|
|
35
|
+
let results = [];
|
|
36
|
+
let query = "";
|
|
37
|
+
let closed = false;
|
|
38
|
+
let searchSeq = 0;
|
|
39
|
+
let debounce = null;
|
|
40
|
+
let failed = false;
|
|
41
|
+
|
|
42
|
+
// ── DOM ─────────────────────────────────────────────────────────────────
|
|
43
|
+
const picker = el("div", "picker");
|
|
44
|
+
const head = el("div", "pk-head");
|
|
45
|
+
const titlebar = el("div", "pk-titlebar");
|
|
46
|
+
const closeBtn = el("button", "pk-x", "×");
|
|
47
|
+
closeBtn.type = "button";
|
|
48
|
+
closeBtn.title = "Close";
|
|
49
|
+
titlebar.append(el("b", "", node.label ?? node.key), closeBtn);
|
|
50
|
+
const search = document.createElement("input");
|
|
51
|
+
search.className = "pk-search";
|
|
52
|
+
search.type = "text";
|
|
53
|
+
search.placeholder = "Search images";
|
|
54
|
+
search.autocomplete = "off";
|
|
55
|
+
search.spellcheck = false;
|
|
56
|
+
head.append(titlebar, search);
|
|
57
|
+
const grid = el("div", "pk-img-grid");
|
|
58
|
+
const empty = el("p", "pk-empty");
|
|
59
|
+
empty.hidden = true;
|
|
60
|
+
picker.append(head, grid, empty);
|
|
61
|
+
host.append(picker);
|
|
62
|
+
search.focus?.();
|
|
63
|
+
|
|
64
|
+
// ── the grid ────────────────────────────────────────────────────────────
|
|
65
|
+
// Not virtualized like the font list: a search result page is bounded by
|
|
66
|
+
// SEARCH_LIMIT, so the DOM cost of every row existing at once stays small —
|
|
67
|
+
// no scroll-position bookkeeping to get wrong for what is, at most, one
|
|
68
|
+
// catalog page of thumbnails.
|
|
69
|
+
function render() {
|
|
70
|
+
if (closed) return;
|
|
71
|
+
grid.textContent = "";
|
|
72
|
+
// A catalog is host-supplied, not trusted — drop any asset the allowlist
|
|
73
|
+
// refuses, same rule the font picker applies to a family's variants.
|
|
74
|
+
const admissible = results.filter((a) => a && typeof a.url === "string" && imageSourceAllowed(a.url, allow));
|
|
75
|
+
for (const asset of admissible) {
|
|
76
|
+
const card = el("button", "pk-img-card");
|
|
77
|
+
card.type = "button";
|
|
78
|
+
card.dataset.sel = String(asset.url === params[node.key]);
|
|
79
|
+
const thumb = document.createElement("img");
|
|
80
|
+
thumb.className = "pk-img-thumb";
|
|
81
|
+
thumb.alt = "";
|
|
82
|
+
thumb.src = asset.thumbUrl || asset.url;
|
|
83
|
+
thumb.addEventListener("error", () => { thumb.hidden = true; });
|
|
84
|
+
const cap = el("span", "pk-img-cap", asset.label ?? "");
|
|
85
|
+
card.append(thumb, cap);
|
|
86
|
+
card.addEventListener("click", () => choose(asset));
|
|
87
|
+
grid.append(card);
|
|
88
|
+
}
|
|
89
|
+
empty.hidden = admissible.length > 0;
|
|
90
|
+
if (!admissible.length) {
|
|
91
|
+
empty.textContent = failed ? "The image catalog is unavailable."
|
|
92
|
+
: query.trim() ? `No images match "${query.trim()}".`
|
|
93
|
+
: "No images available.";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function choose(asset) {
|
|
98
|
+
params[node.key] = asset.url;
|
|
99
|
+
onPicked?.();
|
|
100
|
+
close();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function runSearch(q) {
|
|
104
|
+
const seq = ++searchSeq;
|
|
105
|
+
Promise.resolve()
|
|
106
|
+
.then(() => imageCatalog.search(q, { limit: SEARCH_LIMIT }))
|
|
107
|
+
.then((entries) => {
|
|
108
|
+
if (closed || seq !== searchSeq) return; // a newer search already won
|
|
109
|
+
failed = false;
|
|
110
|
+
results = Array.isArray(entries) ? entries : [];
|
|
111
|
+
render();
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {
|
|
114
|
+
if (closed || seq !== searchSeq) return;
|
|
115
|
+
failed = true;
|
|
116
|
+
results = [];
|
|
117
|
+
render();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
search.addEventListener("input", () => {
|
|
122
|
+
query = search.value;
|
|
123
|
+
clearTimeout(debounce);
|
|
124
|
+
debounce = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── closing ─────────────────────────────────────────────────────────────
|
|
128
|
+
const handle = { close };
|
|
129
|
+
|
|
130
|
+
function close() {
|
|
131
|
+
if (closed) return; // idempotent
|
|
132
|
+
closed = true;
|
|
133
|
+
clearTimeout(debounce);
|
|
134
|
+
document.removeEventListener("keydown", onKey);
|
|
135
|
+
picker.remove();
|
|
136
|
+
if (openPicker === handle) openPicker = null;
|
|
137
|
+
}
|
|
138
|
+
function onKey(ev) {
|
|
139
|
+
if (ev.key !== "Escape") return;
|
|
140
|
+
ev.stopPropagation();
|
|
141
|
+
close();
|
|
142
|
+
}
|
|
143
|
+
document.addEventListener("keydown", onKey);
|
|
144
|
+
closeBtn.addEventListener("click", close);
|
|
145
|
+
|
|
146
|
+
runSearch("");
|
|
147
|
+
render();
|
|
148
|
+
openPicker = handle;
|
|
149
|
+
return handle;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setImagePicker(openImagePicker);
|
|
@@ -227,6 +227,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
|
|
|
227
227
|
onCommit: () => commit([node.key]),
|
|
228
228
|
info,
|
|
229
229
|
fontCatalog: opts.fontCatalog,
|
|
230
|
+
imageCatalog: opts.imageCatalog,
|
|
230
231
|
});
|
|
231
232
|
nodeEls.set(node.id, widget.el);
|
|
232
233
|
if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
|
|
@@ -37,6 +37,7 @@ export const WIDGET_SPECS = [
|
|
|
37
37
|
{ type: "select", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
38
38
|
{ type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
|
|
39
39
|
{ type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
|
|
40
|
+
{ type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
|
|
40
41
|
{ type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
|
|
41
42
|
];
|
|
42
43
|
|
|
@@ -56,6 +57,7 @@ const AUTHOR_EXTRAS = {
|
|
|
56
57
|
select: ["options"],
|
|
57
58
|
radio: ["options"],
|
|
58
59
|
font: ["allow", "preview"],
|
|
60
|
+
image: ["allow"],
|
|
59
61
|
};
|
|
60
62
|
const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
|
|
61
63
|
([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));
|