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.
- package/bin/cli.js +9 -4
- package/docs/AUTHORING-PARTS.md +75 -1
- package/docs/KERNEL-CONTRACT.md +3 -1
- package/package.json +1 -1
- package/src/framework/app.css +112 -0
- package/src/framework/font-source.js +70 -0
- package/src/framework/fonts.js +15 -0
- package/src/framework/jobs.js +96 -14
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-fonts.js +45 -0
- package/src/framework/measure/dim3-place.js +77 -8
- package/src/framework/measure/measure-mode.js +80 -15
- package/src/framework/mount.js +3 -1
- package/src/framework/panel/font-picker.js +400 -0
- package/src/framework/panel/render.js +7 -2
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/font.js +125 -0
- package/src/framework/panel/widgets/index.js +8 -0
- package/src/framework/part-model.js +9 -1
- package/src/parts/nameplate.js +16 -2
|
@@ -221,22 +221,91 @@ export function choicesEqual(a, b) {
|
|
|
221
221
|
// The vertex realizing the extreme along `axis` over the posed meshes; ties
|
|
222
222
|
// within tolerance (a flat base is all "the minimum") break toward `near`, so
|
|
223
223
|
// the anchor lands on the side of the part the dimension is drawn on.
|
|
224
|
+
//
|
|
225
|
+
// placeBox asks for all six ±axis directions, and the naive shape of this —
|
|
226
|
+
// one pass to find the extreme, a second to tie-break, per call — walked every
|
|
227
|
+
// vertex twelve times over. On a soup carrying three points per triangle that
|
|
228
|
+
// is the dominant cost of a placement, so the walk happens ONCE per (geometry,
|
|
229
|
+
// pose) and every later call is answered from `extremeCache`: the six extreme
|
|
230
|
+
// VALUES, plus, per direction, the posed vertices tied with that direction's
|
|
231
|
+
// own extreme. `near` varies per call (it is the side the dim hangs off), so
|
|
232
|
+
// the tie-break itself still runs — over the handful of tied points instead of
|
|
233
|
+
// the whole soup.
|
|
234
|
+
//
|
|
235
|
+
// Per-mesh candidates are exact for the multi-mesh scan they feed: a vertex
|
|
236
|
+
// within TIE_TOL of the GLOBAL extreme is necessarily within TIE_TOL of its own
|
|
237
|
+
// mesh's extreme too (the mesh extreme sits between them), so nothing that
|
|
238
|
+
// could win is missing from the union — and the loop below re-filters against
|
|
239
|
+
// the global value regardless.
|
|
240
|
+
//
|
|
241
|
+
// A candidate is stored as its INDEX into the soup, not as a posed point: four
|
|
242
|
+
// bytes instead of twenty-four, and the tie-break still runs the same f64
|
|
243
|
+
// transform the un-cached version did, so the anchor it picks is bit-identical.
|
|
244
|
+
// The size question that buys is a real one — most parts sit on a flat face, so
|
|
245
|
+
// one direction's tie set can be a whole face of the mesh — and on the parts
|
|
246
|
+
// this was measured against the whole cache lands around 0.1% of the soup.
|
|
247
|
+
const TIE_TOL = 1e-3;
|
|
248
|
+
// The extremes and the tie sets are both POSED, so a record is only valid for
|
|
249
|
+
// the pose it was built from. A mesh's `matrix` is mutated in place by the
|
|
250
|
+
// viewer's pose fast path (setSubPose), so identity says nothing about it — the
|
|
251
|
+
// sixteen elements do.
|
|
252
|
+
const extremeCache = new WeakMap(); // positions -> { pose, lo, hi, cand: Uint32Array[6] }
|
|
224
253
|
const _sv = new THREE.Vector3();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
254
|
+
|
|
255
|
+
const dirIndex = (axis, sign) => axis * 2 + (sign > 0 ? 0 : 1);
|
|
256
|
+
|
|
257
|
+
const samePose = (a, b) => {
|
|
258
|
+
for (let i = 0; i < 16; i++) if (a[i] !== b[i]) return false;
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function meshExtremes(positions, matrix) {
|
|
263
|
+
const hit = extremeCache.get(positions);
|
|
264
|
+
if (hit && samePose(hit.pose, matrix.elements)) return hit;
|
|
265
|
+
const lo = [Infinity, Infinity, Infinity];
|
|
266
|
+
const hi = [-Infinity, -Infinity, -Infinity];
|
|
267
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
268
|
+
_sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
|
|
269
|
+
for (let a = 0; a < 3; a++) {
|
|
270
|
+
const v = _sv.getComponent(a);
|
|
271
|
+
if (v < lo[a]) lo[a] = v;
|
|
272
|
+
if (v > hi[a]) hi[a] = v;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const cand = [[], [], [], [], [], []];
|
|
276
|
+
if (Number.isFinite(lo[0])) {
|
|
228
277
|
for (let i = 0; i < positions.length; i += 3) {
|
|
229
278
|
_sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
|
|
230
|
-
|
|
231
|
-
|
|
279
|
+
for (let a = 0; a < 3; a++) {
|
|
280
|
+
const v = _sv.getComponent(a);
|
|
281
|
+
if (Math.abs(v - hi[a]) <= TIE_TOL) cand[a * 2].push(i);
|
|
282
|
+
if (Math.abs(v - lo[a]) <= TIE_TOL) cand[a * 2 + 1].push(i);
|
|
283
|
+
}
|
|
232
284
|
}
|
|
233
285
|
}
|
|
286
|
+
const rec = { pose: matrix.elements.slice(), lo, hi, cand: cand.map((c) => Uint32Array.from(c)) };
|
|
287
|
+
extremeCache.set(positions, rec);
|
|
288
|
+
return rec;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function extremeVertex(meshData, axis, sign, near) {
|
|
292
|
+
const dir = dirIndex(axis, sign);
|
|
293
|
+
let bestVal = sign > 0 ? -Infinity : Infinity;
|
|
294
|
+
const scan = [];
|
|
295
|
+
for (const { positions, matrix } of meshData) {
|
|
296
|
+
const rec = meshExtremes(positions, matrix);
|
|
297
|
+
const v = sign > 0 ? rec.hi[axis] : rec.lo[axis];
|
|
298
|
+
if (!Number.isFinite(v)) continue; // empty mesh
|
|
299
|
+
scan.push({ positions, matrix, cand: rec.cand[dir] });
|
|
300
|
+
if (sign > 0 ? v > bestVal : v < bestVal) bestVal = v;
|
|
301
|
+
}
|
|
234
302
|
if (!Number.isFinite(bestVal)) return null;
|
|
235
303
|
let best = null, bestD = Infinity;
|
|
236
|
-
for (const { positions, matrix } of
|
|
237
|
-
for (let
|
|
304
|
+
for (const { positions, matrix, cand } of scan) {
|
|
305
|
+
for (let k = 0; k < cand.length; k++) {
|
|
306
|
+
const i = cand[k];
|
|
238
307
|
_sv.set(positions[i], positions[i + 1], positions[i + 2]).applyMatrix4(matrix);
|
|
239
|
-
if (Math.abs(_sv.getComponent(axis) - bestVal) >
|
|
308
|
+
if (Math.abs(_sv.getComponent(axis) - bestVal) > TIE_TOL) continue;
|
|
240
309
|
const d = _sv.distanceToSquared(near);
|
|
241
310
|
if (d < bestD) { bestD = d; best = _sv.clone(); }
|
|
242
311
|
}
|
|
@@ -19,6 +19,35 @@ import { createPinStore, occurrenceOf } from "./pins.js";
|
|
|
19
19
|
import { evaluateChoices, choicesEqual, placeDims, specSig, laneCounts } from "./dim3-place.js";
|
|
20
20
|
import { createDimScene } from "./dim3-scene.js";
|
|
21
21
|
|
|
22
|
+
// How many distinct placements each cache holds. An orbit sweeps a bounded set
|
|
23
|
+
// of side choices, and a session hovers a bounded set of features; anything
|
|
24
|
+
// past that is ground the user has moved on from. Entries are plain drawing
|
|
25
|
+
// records (numbers, no GL objects), so the ceiling is about retention hygiene
|
|
26
|
+
// on a memory-tight device, not about a runaway.
|
|
27
|
+
const PLACEMENT_CACHE_LIMIT = 32;
|
|
28
|
+
|
|
29
|
+
// Bounded LRU. A Map iterates in insertion order, so re-inserting on a hit
|
|
30
|
+
// moves the entry to the back and the oldest key is always the first one out.
|
|
31
|
+
function keyedCache(limit) {
|
|
32
|
+
const entries = new Map();
|
|
33
|
+
return {
|
|
34
|
+
get(key) {
|
|
35
|
+
if (!entries.has(key)) return undefined;
|
|
36
|
+
const value = entries.get(key);
|
|
37
|
+
entries.delete(key);
|
|
38
|
+
entries.set(key, value);
|
|
39
|
+
return value;
|
|
40
|
+
},
|
|
41
|
+
set(key, value) {
|
|
42
|
+
entries.delete(key);
|
|
43
|
+
entries.set(key, value);
|
|
44
|
+
if (entries.size > limit) entries.delete(entries.keys().next().value);
|
|
45
|
+
return value;
|
|
46
|
+
},
|
|
47
|
+
clear() { entries.clear(); },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
export function createMeasureMode(viewer, { part, getContext, revealParams, getParamsVersion, schedule = (cb) => requestAnimationFrame(cb) }) {
|
|
23
52
|
const pins = createPinStore();
|
|
24
53
|
const pinListeners = new Set();
|
|
@@ -218,7 +247,23 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
218
247
|
// hover dim, which changes on every pointer move. placeBox scans every
|
|
219
248
|
// vertex of the meshes it covers (tens of ms on a big part), so re-placing
|
|
220
249
|
// the whole set per hover frame is the one path that must not exist.
|
|
221
|
-
|
|
250
|
+
//
|
|
251
|
+
// Both caches are KEYED and bounded rather than one slot each. A one-slot
|
|
252
|
+
// cache is right for a monotonic sequence and wrong for an orbit, which
|
|
253
|
+
// sweeps back and forth over the same handful of side choices — measured at
|
|
254
|
+
// ~15 flips per 360° turn, every one of them a full re-place of ground
|
|
255
|
+
// already covered. Keyed, an orbit places each distinct choice once.
|
|
256
|
+
const baseCache = keyedCache(PLACEMENT_CACHE_LIMIT);
|
|
257
|
+
// ...and the PAINTED list — base plus the hover dim — cached the same way, on
|
|
258
|
+
// the base key plus everything the hover pass alone depends on. The hover
|
|
259
|
+
// spec is identical for every pointer position over one feature, so without
|
|
260
|
+
// this a pointer resting on a face re-ran that item's vertex scan and its
|
|
261
|
+
// surface raycasts on every single rAF.
|
|
262
|
+
const paintCache = keyedCache(PLACEMENT_CACHE_LIMIT);
|
|
263
|
+
// The drawing list currently on screen, held by IDENTITY: scene.update()
|
|
264
|
+
// rebuilds every child object (and repaints every label texture), so a list
|
|
265
|
+
// the cache just handed back unchanged must not reach it.
|
|
266
|
+
let painted = null;
|
|
222
267
|
const _rc = new THREE.Raycaster();
|
|
223
268
|
const _origin = new THREE.Vector3();
|
|
224
269
|
const _dir = new THREE.Vector3();
|
|
@@ -274,12 +319,26 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
274
319
|
return key;
|
|
275
320
|
}
|
|
276
321
|
|
|
322
|
+
// The hover half of the choice table, which baseCacheKey deliberately omits
|
|
323
|
+
// (hover entries come and go with the pointer and must not evict the base).
|
|
324
|
+
// A camera move that flips which side the hovered dim hangs off has to
|
|
325
|
+
// re-place it, so the paint key carries them.
|
|
326
|
+
function hoverChoiceKey() {
|
|
327
|
+
let key = "";
|
|
328
|
+
for (const ck of Object.keys(choices)) {
|
|
329
|
+
if (!ck.startsWith("hover|")) continue;
|
|
330
|
+
const c = choices[ck];
|
|
331
|
+
key += `|${ck}=${c.key ?? ""}${c.du ? `,${c.du.map((n) => n.toFixed(4))}` : ""}`;
|
|
332
|
+
}
|
|
333
|
+
return key;
|
|
334
|
+
}
|
|
335
|
+
|
|
277
336
|
function rebuild() {
|
|
278
337
|
if (!enabled || !scene) return;
|
|
279
338
|
const { items, meshes, bounds } = buildItems();
|
|
280
339
|
lastItems = items;
|
|
281
340
|
lastBounds = bounds ?? null;
|
|
282
|
-
if (!items.length || !bounds) { scene.clear();
|
|
341
|
+
if (!items.length || !bounds) { scene.clear(); painted = null; return; }
|
|
283
342
|
const env = buildEnv(meshes);
|
|
284
343
|
choices = evaluateChoices(items, { camPos: env.camPos, center: centerOf(bounds), prev: choices });
|
|
285
344
|
const place = (list, suppress, lanes) =>
|
|
@@ -287,10 +346,8 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
287
346
|
const hoverItem = items.find((i) => i.id === "hover");
|
|
288
347
|
const baseItems = hoverItem ? items.filter((i) => i !== hoverItem) : items;
|
|
289
348
|
const key = baseCacheKey(`${units}|${meshSig()}`, baseItems);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
baseCache.drawings = place(baseItems);
|
|
293
|
-
}
|
|
349
|
+
let baseDrawings = baseCache.get(key);
|
|
350
|
+
if (!baseDrawings) baseDrawings = baseCache.set(key, place(baseItems));
|
|
294
351
|
// The hover pass can't see the base pass's items (they're cached), so it
|
|
295
352
|
// hands over (a) their sigs as `suppress` — a hover duplicating an
|
|
296
353
|
// already-drawn measurement (the sub-part bounds over the overall, a
|
|
@@ -298,13 +355,20 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
298
355
|
// occupancy, so a hovered dim staggers into the SAME lane it will occupy
|
|
299
356
|
// once pinned (pins append after the base items in the same order) and
|
|
300
357
|
// clicking never moves it.
|
|
301
|
-
|
|
302
|
-
?
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
358
|
+
const paintKey = hoverItem
|
|
359
|
+
? `h|${key}|${specSig(hoverItem.spec)}|${hoverChoiceKey()}`
|
|
360
|
+
: `b|${key}`;
|
|
361
|
+
let drawings = paintCache.get(paintKey);
|
|
362
|
+
if (!drawings) {
|
|
363
|
+
drawings = paintCache.set(paintKey, hoverItem
|
|
364
|
+
? baseDrawings.concat(place(
|
|
365
|
+
[hoverItem],
|
|
366
|
+
new Set(baseItems.map((i) => specSig(i.spec))),
|
|
367
|
+
laneCounts(baseDrawings),
|
|
368
|
+
))
|
|
369
|
+
: baseDrawings);
|
|
370
|
+
}
|
|
371
|
+
if (painted !== drawings) { painted = drawings; scene.update(drawings); }
|
|
308
372
|
}
|
|
309
373
|
|
|
310
374
|
// ---- frame dirty check ---------------------------------------------------
|
|
@@ -500,8 +564,9 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
|
|
|
500
564
|
scene?.clear();
|
|
501
565
|
lastItems = [];
|
|
502
566
|
lastBounds = null;
|
|
503
|
-
baseCache.
|
|
504
|
-
|
|
567
|
+
baseCache.clear();
|
|
568
|
+
paintCache.clear();
|
|
569
|
+
painted = null;
|
|
505
570
|
dom.style.cursor = "";
|
|
506
571
|
}
|
|
507
572
|
notifyMode();
|
package/src/framework/mount.js
CHANGED
|
@@ -247,6 +247,7 @@ function createCleanupStack() {
|
|
|
247
247
|
// exactly once here — submodules take element refs and never query the document.
|
|
248
248
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
249
249
|
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
|
|
250
|
+
fontCatalog,
|
|
250
251
|
annotateSend = "viewbar",
|
|
251
252
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
252
253
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
@@ -835,7 +836,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
835
836
|
onParamChange();
|
|
836
837
|
}, onParamsCommit
|
|
837
838
|
? (changed) => onParamsCommit({ changed, params: { ...params } })
|
|
838
|
-
: undefined
|
|
839
|
+
: undefined,
|
|
840
|
+
{ fontCatalog });
|
|
839
841
|
cleanup.defer(() => panel.dispose());
|
|
840
842
|
panelRef = panel;
|
|
841
843
|
const updateRelevance = () => {
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// The `type: "font"` picker: a takeover panel over the rail with two sliding
|
|
2
|
+
// panes (families, then that family's weights) and a shared footer.
|
|
3
|
+
//
|
|
4
|
+
// Main-thread only — it is DOM-heavy and is NOT part of the worker graph.
|
|
5
|
+
// It draws list rows in each family's own face by loading Google's name-only
|
|
6
|
+
// `menuUrl` subset through a FontFace, which is why a row costs a few KB and
|
|
7
|
+
// not the whole family.
|
|
8
|
+
//
|
|
9
|
+
// Ported from spike/font-picker.html, whose layout and interaction were settled
|
|
10
|
+
// against a running build over the real 1,942-family catalog (spec §6). The
|
|
11
|
+
// spike's own data path — a bundled catalog.json plus the Google CSS API — does
|
|
12
|
+
// NOT come along: here the families arrive from the host's `fontCatalog` and
|
|
13
|
+
// every face is a `FontFace` over a URL that catalog handed us.
|
|
14
|
+
import { fontLabel, variantLabel, setFontPicker } from "./widgets/font.js";
|
|
15
|
+
import { fontSourceAllowed } from "../font-source.js";
|
|
16
|
+
|
|
17
|
+
const ROW_H = 44; // comfortable density (spec §6)
|
|
18
|
+
const OVERSCAN = 4; // rows rendered above/below the viewport
|
|
19
|
+
const SEARCH_LIMIT = 200;
|
|
20
|
+
const SEARCH_DEBOUNCE_MS = 120;
|
|
21
|
+
const SAMPLE = "Hamburgefonstiv 0123"; // the default variant-pane sample; `preview` overrides it
|
|
22
|
+
// A variants pane can hold 18 weights; auto-loading every real face for a CJK
|
|
23
|
+
// family would be tens of megabytes on one click. Past this, the sample line
|
|
24
|
+
// falls back to the panel font with the weight synthesized.
|
|
25
|
+
const VARIANT_FACE_MAX_BYTES = 1_500_000;
|
|
26
|
+
|
|
27
|
+
function el(tag, className, text) {
|
|
28
|
+
const node = document.createElement(tag);
|
|
29
|
+
if (className) node.className = className;
|
|
30
|
+
if (text != null) node.textContent = text;
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Family names come from the host and land in a `font-family` declaration, so
|
|
35
|
+
// strip the two characters that could end the quoted string early.
|
|
36
|
+
const faceStack = (...names) =>
|
|
37
|
+
[...names.map((n) => `"${String(n).replace(/["\\]/g, "")}"`), "var(--pf-sans)"].join(", ");
|
|
38
|
+
|
|
39
|
+
const kbLabel = (bytes) => (Number.isFinite(bytes) ? `${Math.round(bytes / 1024)}K` : "");
|
|
40
|
+
|
|
41
|
+
// The face a row advertises, and the one a click lands on when the user has no
|
|
42
|
+
// standing weight preference. Kept separate from `pickVariant` so the row's
|
|
43
|
+
// size caption does not change under the user as they audition weights.
|
|
44
|
+
const listVariant = (f) =>
|
|
45
|
+
f.variants.find((v) => v.variant === "400" || v.variant === "regular") ?? f.variants[0];
|
|
46
|
+
|
|
47
|
+
// At most one picker is open at a time, and the previous one has to be CLOSED
|
|
48
|
+
// rather than merely detached: its `keydown` listener lives on `document`, so
|
|
49
|
+
// dropping the element off the DOM leaves the handler — and the whole closure,
|
|
50
|
+
// up to 200 admitted families — alive forever, one more on every re-open.
|
|
51
|
+
// Only close() unregisters it, so every path that supersedes a picker goes
|
|
52
|
+
// through here.
|
|
53
|
+
let openPicker = null;
|
|
54
|
+
|
|
55
|
+
export function openFontPicker({ node, params, allow, fontCatalog, anchor, onPicked }) {
|
|
56
|
+
// Takeover: the picker covers the rail on desktop and the single visible pane
|
|
57
|
+
// below the narrow breakpoint. One layout for both widths (spec §6).
|
|
58
|
+
const host = anchor?.closest?.(".pf-rail") ?? anchor?.parentElement ?? document.body;
|
|
59
|
+
openPicker?.close(); // never two at once
|
|
60
|
+
|
|
61
|
+
// ── state ───────────────────────────────────────────────────────────────
|
|
62
|
+
// The author's `preview` string, when they set one. A part lettered in digits,
|
|
63
|
+
// or in a script "Hamburgefonstiv" cannot even render, is auditioned against
|
|
64
|
+
// the wrong glyphs by the generic sample — which is the whole point of the
|
|
65
|
+
// field (spec §1). Blank or non-string falls back to the default.
|
|
66
|
+
const sampleText = typeof node.preview === "string" && node.preview.trim() ? node.preview : SAMPLE;
|
|
67
|
+
let results = []; // what the catalog last returned…
|
|
68
|
+
let resultsQuery = ""; // …for this query
|
|
69
|
+
let query = ""; // what is in the box right now
|
|
70
|
+
let rows = []; // what the list is showing
|
|
71
|
+
let closed = false;
|
|
72
|
+
let searchSeq = 0;
|
|
73
|
+
let debounce = null;
|
|
74
|
+
let failed = false;
|
|
75
|
+
// The value alone cannot name a live-picked face (a gstatic filename is a
|
|
76
|
+
// content hash), so start from `fontLabel` and sharpen it the moment the
|
|
77
|
+
// catalog hands us a family whose variant URL is this exact value.
|
|
78
|
+
const initial = fontLabel(params[node.key]);
|
|
79
|
+
let selFamily = initial.family;
|
|
80
|
+
let selVariant = initial.variant ?? "400";
|
|
81
|
+
let selBytes = null;
|
|
82
|
+
let openFamily = null; // the family the variants pane is showing
|
|
83
|
+
|
|
84
|
+
const faceRequested = new Set(); // families whose menu face we have asked for
|
|
85
|
+
// …and the ones we are no longer waiting on: arrived, or definitively failed.
|
|
86
|
+
// A row is dimmed while its face is PENDING; a 404 is settled, not pending,
|
|
87
|
+
// so it goes back to full strength in the panel font rather than staying grey.
|
|
88
|
+
const faceSettled = new Set();
|
|
89
|
+
const variantFaces = new Set(); // variant URLs already loaded
|
|
90
|
+
|
|
91
|
+
// ── DOM ─────────────────────────────────────────────────────────────────
|
|
92
|
+
const picker = el("div", "picker");
|
|
93
|
+
const panes = el("div", "pk-panes");
|
|
94
|
+
|
|
95
|
+
const browse = el("div", "pk-pane");
|
|
96
|
+
browse.dataset.pane = "browse";
|
|
97
|
+
const head = el("div", "pk-head");
|
|
98
|
+
const titlebar = el("div", "pk-titlebar");
|
|
99
|
+
const closeBtn = el("button", "pk-x", "\u00d7");
|
|
100
|
+
closeBtn.type = "button";
|
|
101
|
+
closeBtn.title = "Close";
|
|
102
|
+
titlebar.append(el("b", "", node.label ?? node.key), closeBtn);
|
|
103
|
+
const search = document.createElement("input");
|
|
104
|
+
search.className = "pk-search";
|
|
105
|
+
search.type = "text";
|
|
106
|
+
search.placeholder = "Search fonts";
|
|
107
|
+
search.autocomplete = "off";
|
|
108
|
+
search.spellcheck = false;
|
|
109
|
+
head.append(titlebar, search);
|
|
110
|
+
const hint = el("div", "pk-hint");
|
|
111
|
+
hint.hidden = true;
|
|
112
|
+
const list = el("div", "pk-list");
|
|
113
|
+
const spacer = el("div", "pk-spacer");
|
|
114
|
+
const empty = el("p", "pk-empty");
|
|
115
|
+
empty.hidden = true;
|
|
116
|
+
list.append(spacer, empty);
|
|
117
|
+
browse.append(head, hint, list);
|
|
118
|
+
|
|
119
|
+
const variants = el("div", "pk-pane");
|
|
120
|
+
variants.dataset.pane = "variants";
|
|
121
|
+
const vhead = el("div", "pk-head");
|
|
122
|
+
const back = el("button", "pk-back", "\u2190 all families");
|
|
123
|
+
back.type = "button";
|
|
124
|
+
const vtitlebar = el("div", "pk-titlebar");
|
|
125
|
+
const vtitle = el("b");
|
|
126
|
+
vtitlebar.append(vtitle);
|
|
127
|
+
vhead.append(back, vtitlebar);
|
|
128
|
+
const vlist = el("div", "pk-vlist");
|
|
129
|
+
variants.append(vhead, vlist);
|
|
130
|
+
|
|
131
|
+
panes.append(browse, variants);
|
|
132
|
+
|
|
133
|
+
// The footer sits BELOW the sliding pane box, not inside it, so Done stays
|
|
134
|
+
// reachable from either pane — picking a weight never has to exit to commit.
|
|
135
|
+
const foot = el("div", "pk-foot");
|
|
136
|
+
const sel = el("span", "pk-sel");
|
|
137
|
+
const done = el("button", "pk-done", "Done");
|
|
138
|
+
done.type = "button";
|
|
139
|
+
foot.append(sel, done);
|
|
140
|
+
|
|
141
|
+
picker.append(panes, foot);
|
|
142
|
+
host.append(picker);
|
|
143
|
+
paintSel();
|
|
144
|
+
search.focus?.();
|
|
145
|
+
|
|
146
|
+
// ── faces ───────────────────────────────────────────────────────────────
|
|
147
|
+
// happy-dom (and any non-browser host) may not implement FontFace at all; a
|
|
148
|
+
// missing one must degrade to un-styled rows, never throw.
|
|
149
|
+
const canLoadFaces = () => typeof FontFace === "function" && typeof document.fonts?.add === "function";
|
|
150
|
+
|
|
151
|
+
function settle(family) {
|
|
152
|
+
faceSettled.add(family);
|
|
153
|
+
if (closed) return;
|
|
154
|
+
for (const row of spacer.children) {
|
|
155
|
+
if (row.dataset.family === family) row.classList.remove("loading");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requestFaces(families) {
|
|
160
|
+
if (!canLoadFaces()) return;
|
|
161
|
+
for (const f of families) {
|
|
162
|
+
if (faceRequested.has(f.family)) continue;
|
|
163
|
+
faceRequested.add(f.family);
|
|
164
|
+
// The menu file is fetched, so it goes through the same allowlist as the
|
|
165
|
+
// value itself — a catalog is host-supplied, not trusted.
|
|
166
|
+
if (!f.menuUrl || !fontSourceAllowed(f.menuUrl, allow)) { settle(f.family); continue; }
|
|
167
|
+
let face;
|
|
168
|
+
try { face = new FontFace(f.family, `url(${f.menuUrl})`); } catch { settle(f.family); continue; }
|
|
169
|
+
face.load()
|
|
170
|
+
.then((loaded) => { document.fonts.add(loaded); })
|
|
171
|
+
.catch(() => { /* a family that will not load stays in the panel font */ })
|
|
172
|
+
.then(() => settle(f.family));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The variants pane needs the REAL weights — the menu subset carries only the
|
|
177
|
+
// family name's glyphs at one weight, so it cannot show what 700 looks like.
|
|
178
|
+
// Each face is registered under `<family> <variant>` so the weights do not
|
|
179
|
+
// collide with each other or with the menu face.
|
|
180
|
+
function requestVariantFace(family, v) {
|
|
181
|
+
if (!canLoadFaces()) return;
|
|
182
|
+
if (variantFaces.has(v.url) || !fontSourceAllowed(v.url, allow)) return;
|
|
183
|
+
if (Number.isFinite(v.bytes) && v.bytes > VARIANT_FACE_MAX_BYTES) return;
|
|
184
|
+
variantFaces.add(v.url);
|
|
185
|
+
let face;
|
|
186
|
+
try { face = new FontFace(`${family} ${v.variant}`, `url(${v.url})`); } catch { return; }
|
|
187
|
+
face.load().then((loaded) => document.fonts.add(loaded)).catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── the list ────────────────────────────────────────────────────────────
|
|
191
|
+
// Reconcile by (index, family, selected) — index ALONE is wrong: after a
|
|
192
|
+
// search the same index holds a different family, and an index-keyed row
|
|
193
|
+
// keeps rendering the old one. The spike paid a screenshot to find this.
|
|
194
|
+
const rowKey = (i, f) => `${i}|${f.family}|${f.family === selFamily ? 1 : 0}`;
|
|
195
|
+
|
|
196
|
+
function rowEl(i, f) {
|
|
197
|
+
const row = el("div", "pk-row" + (f.family === selFamily ? " sel" : ""));
|
|
198
|
+
row.dataset.i = String(i);
|
|
199
|
+
row.dataset.key = rowKey(i, f);
|
|
200
|
+
row.dataset.family = f.family;
|
|
201
|
+
if (canLoadFaces() && !faceSettled.has(f.family)) row.classList.add("loading");
|
|
202
|
+
const main = el("div", "pk-main");
|
|
203
|
+
const face = el("div", "pk-face", f.family);
|
|
204
|
+
face.style.fontFamily = faceStack(f.family);
|
|
205
|
+
const n = f.variants.length;
|
|
206
|
+
main.append(face, el("div", "pk-sub", `${n} style${n === 1 ? "" : "s"} · ${f.category ?? "—"}`));
|
|
207
|
+
row.append(main, el("div", "pk-meta", kbLabel(listVariant(f)?.bytes)));
|
|
208
|
+
row.addEventListener("click", () => choose(f));
|
|
209
|
+
return row;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function render() {
|
|
213
|
+
if (closed) return;
|
|
214
|
+
spacer.style.height = `${rows.length * ROW_H}px`;
|
|
215
|
+
const top = list.scrollTop || 0;
|
|
216
|
+
const vh = list.clientHeight || 360;
|
|
217
|
+
const first = Math.max(0, Math.floor(top / ROW_H) - OVERSCAN);
|
|
218
|
+
const last = Math.min(rows.length, Math.ceil((top + vh) / ROW_H) + OVERSCAN);
|
|
219
|
+
|
|
220
|
+
const wanted = new Map();
|
|
221
|
+
for (let i = first; i < last; i++) wanted.set(i, rows[i]);
|
|
222
|
+
requestFaces([...wanted.values()]);
|
|
223
|
+
|
|
224
|
+
for (const node_ of [...spacer.children]) {
|
|
225
|
+
const i = Number(node_.dataset.i);
|
|
226
|
+
if (!wanted.has(i) || node_.dataset.key !== rowKey(i, wanted.get(i))) node_.remove();
|
|
227
|
+
else wanted.delete(i);
|
|
228
|
+
}
|
|
229
|
+
for (const [i, f] of wanted) spacer.append(rowEl(i, f));
|
|
230
|
+
for (const node_ of spacer.children) {
|
|
231
|
+
node_.style.height = `${ROW_H}px`;
|
|
232
|
+
node_.style.transform = `translateY(${Number(node_.dataset.i) * ROW_H}px)`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
empty.hidden = rows.length > 0;
|
|
236
|
+
if (!rows.length) {
|
|
237
|
+
empty.textContent = failed ? "The font catalog is unavailable."
|
|
238
|
+
: query.trim() ? `No families match "${query.trim()}".`
|
|
239
|
+
: "No families available.";
|
|
240
|
+
}
|
|
241
|
+
hint.hidden = !query.trim() || !rows.length;
|
|
242
|
+
if (!hint.hidden) hint.textContent = `${rows.length.toLocaleString()} match${rows.length === 1 ? "" : "es"}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Drop variants the allowlist refuses, and drop a family left with none — the
|
|
246
|
+
// UI half of the font-source check. A family we cannot legally write must not
|
|
247
|
+
// be offered, not merely fail on click.
|
|
248
|
+
function admissible(entries) {
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const f of entries ?? []) {
|
|
251
|
+
if (!f || typeof f.family !== "string" || !Array.isArray(f.variants)) continue;
|
|
252
|
+
const ok = f.variants.filter((v) => v && fontSourceAllowed(v.url, allow));
|
|
253
|
+
if (!ok.length) continue;
|
|
254
|
+
out.push({ ...f, variants: ok });
|
|
255
|
+
if (!selBytes) {
|
|
256
|
+
const hit = ok.find((v) => v.url === params[node.key]);
|
|
257
|
+
if (hit) { selFamily = f.family; selVariant = hit.variant; selBytes = hit.bytes; paintSel(); }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// While the user is typing ahead of the catalog, narrow what is already in
|
|
264
|
+
// hand rather than blanking the list; once the catalog has answered for this
|
|
265
|
+
// exact query, show precisely what it returned (its matching may be fuzzier
|
|
266
|
+
// than a substring test, and second-guessing it would drop real hits).
|
|
267
|
+
function recompute() {
|
|
268
|
+
// Compare TRIMMED against trimmed: runSearch stores the trimmed query, so a
|
|
269
|
+
// trailing space would otherwise never match and the list would stay stuck
|
|
270
|
+
// on the client-side narrowing instead of showing the catalog's answer.
|
|
271
|
+
const q = query.trim();
|
|
272
|
+
rows = resultsQuery === q || !q
|
|
273
|
+
? results
|
|
274
|
+
: results.filter((f) => f.family.toLowerCase().includes(q.toLowerCase()));
|
|
275
|
+
render();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function runSearch(q) {
|
|
279
|
+
const seq = ++searchSeq;
|
|
280
|
+
Promise.resolve()
|
|
281
|
+
.then(() => fontCatalog.search(q, { limit: SEARCH_LIMIT }))
|
|
282
|
+
.then((entries) => {
|
|
283
|
+
if (closed || seq !== searchSeq) return; // a newer search already won
|
|
284
|
+
failed = false;
|
|
285
|
+
results = admissible(entries);
|
|
286
|
+
resultsQuery = q;
|
|
287
|
+
recompute();
|
|
288
|
+
})
|
|
289
|
+
.catch(() => {
|
|
290
|
+
if (closed || seq !== searchSeq) return;
|
|
291
|
+
failed = true;
|
|
292
|
+
results = [];
|
|
293
|
+
resultsQuery = q;
|
|
294
|
+
recompute();
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
search.addEventListener("input", () => {
|
|
299
|
+
query = search.value;
|
|
300
|
+
list.scrollTop = 0; // a new query starts at the top
|
|
301
|
+
recompute(); // instant, from what we hold
|
|
302
|
+
clearTimeout(debounce);
|
|
303
|
+
debounce = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
|
|
304
|
+
});
|
|
305
|
+
list.addEventListener("scroll", render);
|
|
306
|
+
|
|
307
|
+
// ── choosing ────────────────────────────────────────────────────────────
|
|
308
|
+
const pickVariant = (f) =>
|
|
309
|
+
f.variants.find((v) => v.variant === selVariant) ?? listVariant(f);
|
|
310
|
+
|
|
311
|
+
function choose(f) {
|
|
312
|
+
commit(f, pickVariant(f));
|
|
313
|
+
// 1,036 of the 1,942 catalog families ship a single face. Stepping into a
|
|
314
|
+
// one-row weight list you immediately back out of is pure friction, so for
|
|
315
|
+
// those the row click IS the selection and the list stays put (spec §6).
|
|
316
|
+
if (f.variants.length > 1) openVariants(f);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function commit(f, v) {
|
|
320
|
+
if (!v) return;
|
|
321
|
+
params[node.key] = v.url;
|
|
322
|
+
selFamily = f.family;
|
|
323
|
+
selVariant = v.variant;
|
|
324
|
+
selBytes = v.bytes;
|
|
325
|
+
onPicked?.();
|
|
326
|
+
paintSel();
|
|
327
|
+
paintVariantRows();
|
|
328
|
+
render();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function paintSel() {
|
|
332
|
+
sel.textContent = "";
|
|
333
|
+
const strong = el("b", "", selFamily);
|
|
334
|
+
const rest = ` · ${variantLabel(selVariant)}` + (selBytes ? ` · ${kbLabel(selBytes)}` : "");
|
|
335
|
+
sel.append(strong, document.createTextNode(rest));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function openVariants(f) {
|
|
339
|
+
openFamily = f;
|
|
340
|
+
vtitle.textContent = f.family;
|
|
341
|
+
vlist.textContent = "";
|
|
342
|
+
for (const v of f.variants) {
|
|
343
|
+
const b = el("button", "vrow" + (v.variant === selVariant ? " on" : ""));
|
|
344
|
+
b.type = "button";
|
|
345
|
+
b.dataset.v = v.variant;
|
|
346
|
+
const sample = el("span", "vsample", sampleText);
|
|
347
|
+
sample.style.fontFamily = faceStack(`${f.family} ${v.variant}`, f.family);
|
|
348
|
+
sample.style.fontWeight = String(v.variant).replace(/i$/, "") || "400";
|
|
349
|
+
sample.style.fontStyle = /i$/.test(String(v.variant)) ? "italic" : "normal";
|
|
350
|
+
b.append(sample, el("span", "vlabel", v.label ?? variantLabel(v.variant)));
|
|
351
|
+
// Commit WITHOUT leaving — you audition weights against the live
|
|
352
|
+
// geometry, so committing and navigating are separate actions (spec §6).
|
|
353
|
+
b.addEventListener("click", () => commit(f, v));
|
|
354
|
+
requestVariantFace(f.family, v);
|
|
355
|
+
vlist.append(b);
|
|
356
|
+
}
|
|
357
|
+
vlist.scrollTop = 0;
|
|
358
|
+
picker.classList.add("at-variants");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Repaint which weight is current without rebuilding the list — the pane
|
|
362
|
+
// stays put while you audition, so the rows must not be torn down under you.
|
|
363
|
+
function paintVariantRows() {
|
|
364
|
+
if (openFamily?.family !== selFamily) return;
|
|
365
|
+
for (const b of vlist.children) b.classList.toggle("on", b.dataset.v === selVariant);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const leaveVariants = () => { openFamily = null; picker.classList.remove("at-variants"); render(); };
|
|
369
|
+
back.addEventListener("click", leaveVariants);
|
|
370
|
+
|
|
371
|
+
// ── closing ─────────────────────────────────────────────────────────────
|
|
372
|
+
// Named here so close() can clear the module-level handle; `close` is a
|
|
373
|
+
// hoisted function declaration, so this captures it.
|
|
374
|
+
const handle = { close };
|
|
375
|
+
|
|
376
|
+
function close() {
|
|
377
|
+
if (closed) return; // idempotent
|
|
378
|
+
closed = true;
|
|
379
|
+
clearTimeout(debounce);
|
|
380
|
+
document.removeEventListener("keydown", onKey);
|
|
381
|
+
picker.remove();
|
|
382
|
+
if (openPicker === handle) openPicker = null;
|
|
383
|
+
}
|
|
384
|
+
function onKey(ev) {
|
|
385
|
+
if (ev.key !== "Escape") return;
|
|
386
|
+
ev.stopPropagation();
|
|
387
|
+
if (picker.classList.contains("at-variants")) leaveVariants();
|
|
388
|
+
else close();
|
|
389
|
+
}
|
|
390
|
+
document.addEventListener("keydown", onKey);
|
|
391
|
+
closeBtn.addEventListener("click", close);
|
|
392
|
+
done.addEventListener("click", close);
|
|
393
|
+
|
|
394
|
+
runSearch("");
|
|
395
|
+
render();
|
|
396
|
+
openPicker = handle;
|
|
397
|
+
return handle;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
setFontPicker(openFontPicker);
|