@half-built/astro 0.4.2 → 0.6.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 +50 -2
- package/package.json +11 -4
- package/src/components/CategoryCard.astro +9 -1
- package/src/components/CornerBadges.astro +14 -21
- package/src/components/Footer.astro +57 -29
- package/src/components/Icon.astro +16 -0
- package/src/components/LightboxLink.astro +19 -10
- package/src/components/LinkListWidget.astro +11 -9
- package/src/components/Pagination.astro +51 -28
- package/src/components/PostCard.astro +54 -22
- package/src/components/PostNavigation.astro +40 -29
- package/src/components/Shell.astro +26 -12
- package/src/components/SiteHeader.astro +139 -80
- package/src/components/SmartImage.astro +48 -17
- package/src/components/Subscribe.astro +60 -35
- package/src/components/ThemeToggle.astro +28 -15
- package/src/components/TwoColumn.astro +5 -5
- package/src/components/Widget.astro +10 -7
- package/src/components/content/BlogImage.astro +48 -13
- package/src/components/content/Button.astro +13 -13
- package/src/components/content/Callout.astro +17 -8
- package/src/components/content/CodeBlock.astro +6 -1
- package/src/components/content/EditorNote.astro +19 -21
- package/src/components/content/Gallery.astro +18 -5
- package/src/components/content/GalleryImage.astro +26 -7
- package/src/components/content/Group.astro +5 -6
- package/src/components/content/MediaText.astro +43 -9
- package/src/components/content/Palette.astro +37 -21
- package/src/components/content/PostLink.astro +15 -0
- package/src/components/content/Quote.astro +10 -9
- package/src/components/content/Spacer.astro +11 -2
- package/src/components/content/Step.astro +34 -15
- package/src/components/content/Walkthrough.astro +10 -6
- package/src/components/content/WhenPublished.astro +14 -0
- package/src/components/models.ts +2 -2
- package/src/lib/archive.ts +18 -4
- package/src/lib/drafts.ts +16 -5
- package/src/lib/format-date.ts +4 -1
- package/src/lib/ordering.ts +3 -1
- package/src/lib/paginate.ts +5 -1
- package/src/lib/slug.ts +30 -7
- package/src/scripts/code-island.ts +27 -4
- package/src/scripts/core/badges.ts +28 -0
- package/src/scripts/core/dom.ts +6 -1
- package/src/scripts/core/frame-loop.ts +4 -1
- package/src/scripts/core/icons.ts +92 -15
- package/src/scripts/ecosystem.ts +72 -12
- package/src/scripts/focus-mode.ts +14 -3
- package/src/scripts/lightbox.ts +227 -41
- package/src/scripts/link-tip.ts +55 -11
- package/src/scripts/path-player-math.ts +11 -2
- package/src/scripts/path-player-paint.ts +54 -8
- package/src/scripts/path-player.ts +131 -29
- package/src/scripts/plate-modal.ts +45 -9
- package/src/scripts/scroll-top.ts +6 -1
- package/src/scripts/site-header.ts +34 -6
- package/src/scripts/subscribe.ts +50 -9
- package/src/scripts/theme-toggle.ts +32 -5
- package/src/shiki/code-theme.mjs +25 -5
- package/src/shiki/code-vars.mjs +5 -1
package/src/scripts/lightbox.ts
CHANGED
|
@@ -9,19 +9,29 @@ import { iconButton, docOf } from "./core/dom";
|
|
|
9
9
|
import { buildPlateModal } from "./plate-modal";
|
|
10
10
|
import { ICON_CHEVRON_LEFT, ICON_CHEVRON_RIGHT } from "./core/icons";
|
|
11
11
|
|
|
12
|
-
export interface ZoomView {
|
|
12
|
+
export interface ZoomView {
|
|
13
|
+
zoom: number;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
}
|
|
13
17
|
|
|
14
18
|
export const MIN_NATIVE_ZOOM = 0.1;
|
|
15
19
|
export const MAX_NATIVE_ZOOM = 8;
|
|
16
20
|
export const TALL_ASPECT = 3;
|
|
17
21
|
export const FALLBACK_BOX = { w: 800, h: 600 };
|
|
22
|
+
|
|
18
23
|
/* HOME appears once the visible image-window overlap drops below this
|
|
19
24
|
fraction of whichever is smaller, the scaled image or the window. */
|
|
20
25
|
const WHACK_FRACTION = 0.25;
|
|
21
26
|
|
|
22
27
|
/* zoom is CSS px per image px (1 = 100%); fit contains the image, except
|
|
23
28
|
tall schematics fit width, and small images never upscale past 100%. */
|
|
24
|
-
export function fitZoom(
|
|
29
|
+
export function fitZoom(
|
|
30
|
+
imgW: number,
|
|
31
|
+
imgH: number,
|
|
32
|
+
boxW: number,
|
|
33
|
+
boxH: number,
|
|
34
|
+
): number {
|
|
25
35
|
if (imgH / imgW >= TALL_ASPECT) return boxW / imgW;
|
|
26
36
|
return Math.min(boxW / imgW, boxH / imgH, 1);
|
|
27
37
|
}
|
|
@@ -31,7 +41,10 @@ export function fitZoom(imgW: number, imgH: number, boxW: number, boxH: number):
|
|
|
31
41
|
2026-08-01; originally 50% of fit, which read as arbitrary per-image
|
|
32
42
|
percentages). Ceiling: 800% of native. */
|
|
33
43
|
export function clampZoom(zoom: number, fit: number): number {
|
|
34
|
-
return Math.min(
|
|
44
|
+
return Math.min(
|
|
45
|
+
Math.max(zoom, Math.min(MIN_NATIVE_ZOOM, fit)),
|
|
46
|
+
MAX_NATIVE_ZOOM,
|
|
47
|
+
);
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/* x/y offset the image center from the viewbox center. Pan is unbounded
|
|
@@ -43,29 +56,62 @@ export function normalizePan(view: ZoomView): ZoomView {
|
|
|
43
56
|
return { zoom: view.zoom, x: view.x + 0, y: view.y + 0 };
|
|
44
57
|
}
|
|
45
58
|
|
|
46
|
-
export function initialView(
|
|
59
|
+
export function initialView(
|
|
60
|
+
imgW: number,
|
|
61
|
+
imgH: number,
|
|
62
|
+
boxW: number,
|
|
63
|
+
boxH: number,
|
|
64
|
+
): ZoomView {
|
|
47
65
|
const zoom = fitZoom(imgW, imgH, boxW, boxH);
|
|
48
|
-
|
|
66
|
+
|
|
67
|
+
const tall = imgH / imgW >= TALL_ASPECT;
|
|
68
|
+
const centered = Math.max(0, (imgH * zoom - boxH) / 2);
|
|
69
|
+
const y = tall ? centered : 0;
|
|
70
|
+
|
|
49
71
|
return normalizePan({ zoom, x: 0, y });
|
|
50
72
|
}
|
|
51
73
|
|
|
52
74
|
/* Anchor-point zoom: the image point under (cx, cy) stays put. Cursor
|
|
53
75
|
coordinates are relative to the viewbox center. */
|
|
54
|
-
export function zoomAt(
|
|
76
|
+
export function zoomAt(
|
|
77
|
+
view: ZoomView,
|
|
78
|
+
factor: number,
|
|
79
|
+
cx: number,
|
|
80
|
+
cy: number,
|
|
81
|
+
imgW: number,
|
|
82
|
+
imgH: number,
|
|
83
|
+
boxW: number,
|
|
84
|
+
boxH: number,
|
|
85
|
+
): ZoomView {
|
|
55
86
|
const fit = fitZoom(imgW, imgH, boxW, boxH);
|
|
56
87
|
const zoom = clampZoom(view.zoom * factor, fit);
|
|
57
88
|
const s = zoom / view.zoom;
|
|
58
|
-
return normalizePan({
|
|
89
|
+
return normalizePan({
|
|
90
|
+
zoom,
|
|
91
|
+
x: cx - (cx - view.x) * s,
|
|
92
|
+
y: cy - (cy - view.y) * s,
|
|
93
|
+
});
|
|
59
94
|
}
|
|
60
95
|
|
|
61
96
|
/* True when the view has wandered far enough that the user likely wants
|
|
62
97
|
a way home: the image-window overlap is under WHACK_FRACTION of the
|
|
63
98
|
smaller of the two areas (so a centered deep zoom is never "whack"). */
|
|
64
|
-
export function outOfWhack(
|
|
99
|
+
export function outOfWhack(
|
|
100
|
+
view: ZoomView,
|
|
101
|
+
imgW: number,
|
|
102
|
+
imgH: number,
|
|
103
|
+
boxW: number,
|
|
104
|
+
boxH: number,
|
|
105
|
+
): boolean {
|
|
65
106
|
const w = imgW * view.zoom;
|
|
66
107
|
const h = imgH * view.zoom;
|
|
67
|
-
|
|
68
|
-
const
|
|
108
|
+
|
|
109
|
+
const overlapW =
|
|
110
|
+
Math.min(view.x + w / 2, boxW / 2) - Math.max(view.x - w / 2, -boxW / 2);
|
|
111
|
+
|
|
112
|
+
const overlapH =
|
|
113
|
+
Math.min(view.y + h / 2, boxH / 2) - Math.max(view.y - h / 2, -boxH / 2);
|
|
114
|
+
|
|
69
115
|
const overlap = Math.max(0, overlapW) * Math.max(0, overlapH);
|
|
70
116
|
return overlap < WHACK_FRACTION * Math.min(w * h, boxW * boxH);
|
|
71
117
|
}
|
|
@@ -75,7 +121,11 @@ export function readout(view: ZoomView, fit: number, prefix = "FIT"): string {
|
|
|
75
121
|
const r = Math.round(n);
|
|
76
122
|
return r >= 0 ? `+${String(r)}` : String(r);
|
|
77
123
|
};
|
|
78
|
-
|
|
124
|
+
|
|
125
|
+
if (view.zoom === fit && view.x === 0 && view.y === 0) {
|
|
126
|
+
return `${prefix} · 0,0`;
|
|
127
|
+
}
|
|
128
|
+
|
|
79
129
|
if (view.zoom === fit) return `${prefix} · ${sign(view.x)},${sign(view.y)}`;
|
|
80
130
|
return `${String(Math.round(view.zoom * 100))}% · ${sign(view.x)},${sign(view.y)}`;
|
|
81
131
|
}
|
|
@@ -111,7 +161,13 @@ const DEFAULT_LABELS: LightboxLabels = {
|
|
|
111
161
|
image: (i, n) => `Image ${String(i)} of ${String(n)}`,
|
|
112
162
|
};
|
|
113
163
|
|
|
114
|
-
interface Item {
|
|
164
|
+
interface Item {
|
|
165
|
+
href: string;
|
|
166
|
+
w: number;
|
|
167
|
+
h: number;
|
|
168
|
+
caption: string;
|
|
169
|
+
thumb: string;
|
|
170
|
+
}
|
|
115
171
|
|
|
116
172
|
interface Refs {
|
|
117
173
|
dialog: HTMLDialogElement;
|
|
@@ -137,14 +193,21 @@ function itemFor(link: HTMLAnchorElement): Item {
|
|
|
137
193
|
w: Number(link.dataset.lbW ?? "0"),
|
|
138
194
|
h: Number(link.dataset.lbH ?? "0"),
|
|
139
195
|
caption: link.dataset.lbCaption ?? "",
|
|
140
|
-
thumb:
|
|
196
|
+
thumb:
|
|
197
|
+
(img?.currentSrc ?? "") ||
|
|
198
|
+
(img?.getAttribute("src") ?? "") ||
|
|
199
|
+
(link.getAttribute("href") ?? ""),
|
|
141
200
|
};
|
|
142
201
|
}
|
|
143
202
|
|
|
144
203
|
/* Gallery plates sharing a .gallery-plates ancestor form a set, and so do
|
|
145
204
|
the photos of one Walkthrough (spec 2026-08-15-walkthrough-steps-design).
|
|
146
205
|
Every other image opens solo (spec: no implicit chaining). */
|
|
147
|
-
function setFor(
|
|
206
|
+
function setFor(
|
|
207
|
+
link: HTMLAnchorElement,
|
|
208
|
+
selector: string,
|
|
209
|
+
groupSelector: string,
|
|
210
|
+
): HTMLAnchorElement[] {
|
|
148
211
|
const set = link.closest(groupSelector);
|
|
149
212
|
if (!set) return [link];
|
|
150
213
|
return [...set.querySelectorAll<HTMLAnchorElement>(selector)];
|
|
@@ -153,30 +216,52 @@ function setFor(link: HTMLAnchorElement, selector: string, groupSelector: string
|
|
|
153
216
|
/* The in-page rendition shows instantly; the untransformed original swaps
|
|
154
217
|
in when loaded. dataset.pending guards against stale loads after
|
|
155
218
|
navigating within a set. Returns the probe for tests. */
|
|
156
|
-
export function swapToFull(
|
|
219
|
+
export function swapToFull(
|
|
220
|
+
target: HTMLImageElement,
|
|
221
|
+
href: string,
|
|
222
|
+
doc: Document,
|
|
223
|
+
): HTMLImageElement | null {
|
|
157
224
|
if (target.getAttribute("src") === href) return null;
|
|
158
225
|
target.dataset.pending = href;
|
|
159
226
|
const probe = doc.createElement("img");
|
|
227
|
+
|
|
160
228
|
probe.addEventListener("load", () => {
|
|
161
229
|
if (target.dataset.pending === href) {
|
|
162
230
|
target.src = href;
|
|
163
231
|
delete target.dataset.pending;
|
|
164
232
|
}
|
|
165
233
|
});
|
|
234
|
+
|
|
166
235
|
probe.src = href;
|
|
167
236
|
return probe;
|
|
168
237
|
}
|
|
169
238
|
|
|
170
239
|
function buildDialog(doc: Document, labels: LightboxLabels): Refs {
|
|
171
|
-
const pm = buildPlateModal(doc, {
|
|
240
|
+
const pm = buildPlateModal(doc, {
|
|
241
|
+
ariaLabel: labels.viewer,
|
|
242
|
+
legacyPrefix: "lb",
|
|
243
|
+
});
|
|
244
|
+
|
|
172
245
|
const { dialog, zone, plate } = pm;
|
|
173
246
|
const closeBtn = pm.closeBtn;
|
|
174
247
|
closeBtn.setAttribute("aria-label", labels.close);
|
|
175
248
|
|
|
176
249
|
const main = doc.createElement("div");
|
|
177
250
|
main.className = "lb-main";
|
|
178
|
-
|
|
179
|
-
const
|
|
251
|
+
|
|
252
|
+
const prevBtn = iconButton(
|
|
253
|
+
doc,
|
|
254
|
+
"lb-arrow lb-prev icon-box",
|
|
255
|
+
labels.prev,
|
|
256
|
+
ICON_CHEVRON_LEFT,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const nextBtn = iconButton(
|
|
260
|
+
doc,
|
|
261
|
+
"lb-arrow lb-next icon-box",
|
|
262
|
+
labels.next,
|
|
263
|
+
ICON_CHEVRON_RIGHT,
|
|
264
|
+
);
|
|
180
265
|
|
|
181
266
|
/* The bar owns counter + close so the mobile header-row layout keeps
|
|
182
267
|
working; the close button is re-parented out of the plate root. */
|
|
@@ -194,7 +279,11 @@ function buildDialog(doc: Document, labels: LightboxLabels): Refs {
|
|
|
194
279
|
drag (no-drop cursor) and fires pointercancel, killing pan tracking.
|
|
195
280
|
Opt the pan surface out of it on both layers. */
|
|
196
281
|
img.draggable = false;
|
|
197
|
-
|
|
282
|
+
|
|
283
|
+
img.addEventListener("dragstart", (ev) => {
|
|
284
|
+
ev.preventDefault();
|
|
285
|
+
});
|
|
286
|
+
|
|
198
287
|
const homeBtn = doc.createElement("button");
|
|
199
288
|
homeBtn.type = "button";
|
|
200
289
|
homeBtn.className = "lb-home boxed-label micro-label";
|
|
@@ -219,14 +308,41 @@ function buildDialog(doc: Document, labels: LightboxLabels): Refs {
|
|
|
219
308
|
thumbs.className = "lb-thumbs";
|
|
220
309
|
|
|
221
310
|
zone.append(main, thumbs);
|
|
222
|
-
return {
|
|
311
|
+
return {
|
|
312
|
+
dialog,
|
|
313
|
+
zone,
|
|
314
|
+
prevBtn,
|
|
315
|
+
nextBtn,
|
|
316
|
+
closeBtn,
|
|
317
|
+
counter,
|
|
318
|
+
viewbox,
|
|
319
|
+
img,
|
|
320
|
+
caption,
|
|
321
|
+
dims,
|
|
322
|
+
readoutEl,
|
|
323
|
+
homeBtn,
|
|
324
|
+
thumbs,
|
|
325
|
+
pmOpen: pm.open,
|
|
326
|
+
};
|
|
223
327
|
}
|
|
224
328
|
|
|
225
|
-
export const mountLightbox: Island<LightboxOptions> = (
|
|
226
|
-
|
|
329
|
+
export const mountLightbox: Island<LightboxOptions> = (
|
|
330
|
+
root,
|
|
331
|
+
options = {},
|
|
332
|
+
): IslandHandle => {
|
|
333
|
+
const {
|
|
334
|
+
selector = "a.lightbox-link",
|
|
335
|
+
groupSelector = ".gallery-plates, .walkthrough",
|
|
336
|
+
labels: labelsOverride = {},
|
|
337
|
+
} = options;
|
|
338
|
+
|
|
227
339
|
const labels: LightboxLabels = { ...DEFAULT_LABELS, ...labelsOverride };
|
|
228
340
|
const doc = docOf(root);
|
|
229
|
-
|
|
341
|
+
|
|
342
|
+
const links = [...root.querySelectorAll<HTMLAnchorElement>(selector)].filter(
|
|
343
|
+
(link) => claim(link, "lightbox"),
|
|
344
|
+
);
|
|
345
|
+
|
|
230
346
|
if (links.length === 0) {
|
|
231
347
|
return {
|
|
232
348
|
destroy(): void {
|
|
@@ -256,11 +372,17 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
256
372
|
const goTo = (r: Refs, i: number): void => {
|
|
257
373
|
const n = items.length;
|
|
258
374
|
show(r, ((i % n) + n) % n);
|
|
375
|
+
|
|
259
376
|
r.thumbs.querySelectorAll(".lb-thumb").forEach((t, ti) => {
|
|
260
377
|
t.classList.toggle("active", ti === index);
|
|
378
|
+
|
|
261
379
|
/* The strip scrolls rather than wraps; keep the active thumb in
|
|
262
380
|
view (guarded: jsdom has no scrollIntoView). */
|
|
263
|
-
if (
|
|
381
|
+
if (
|
|
382
|
+
ti === index &&
|
|
383
|
+
t instanceof HTMLElement &&
|
|
384
|
+
typeof t.scrollIntoView === "function"
|
|
385
|
+
) {
|
|
264
386
|
t.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
265
387
|
}
|
|
266
388
|
});
|
|
@@ -268,6 +390,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
268
390
|
|
|
269
391
|
const buildThumbs = (r: Refs): void => {
|
|
270
392
|
r.thumbs.replaceChildren();
|
|
393
|
+
|
|
271
394
|
items.forEach((item, i) => {
|
|
272
395
|
const btn = doc.createElement("button");
|
|
273
396
|
btn.type = "button";
|
|
@@ -277,7 +400,11 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
277
400
|
timg.src = item.thumb;
|
|
278
401
|
timg.alt = "";
|
|
279
402
|
btn.append(timg);
|
|
280
|
-
|
|
403
|
+
|
|
404
|
+
btn.addEventListener("click", () => {
|
|
405
|
+
goTo(r, i);
|
|
406
|
+
});
|
|
407
|
+
|
|
281
408
|
r.thumbs.append(btn);
|
|
282
409
|
});
|
|
283
410
|
};
|
|
@@ -299,22 +426,32 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
299
426
|
const ensureRefs = (): Refs => {
|
|
300
427
|
if (refs) return refs;
|
|
301
428
|
const r = buildDialog(doc, labels);
|
|
302
|
-
|
|
303
|
-
r.
|
|
429
|
+
|
|
430
|
+
r.prevBtn.addEventListener("click", () => {
|
|
431
|
+
goTo(r, index - 1);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
r.nextBtn.addEventListener("click", () => {
|
|
435
|
+
goTo(r, index + 1);
|
|
436
|
+
});
|
|
437
|
+
|
|
304
438
|
r.dialog.addEventListener("keydown", (ev) => {
|
|
305
439
|
if (items.length < 2) return;
|
|
306
440
|
if (ev.key === "ArrowRight") goTo(r, index + 1);
|
|
307
441
|
if (ev.key === "ArrowLeft") goTo(r, index - 1);
|
|
308
442
|
});
|
|
443
|
+
|
|
309
444
|
/* Swipe navigation was removed with unbounded pan (owner adjustment
|
|
310
445
|
2026-08-01): drag always pans, and set navigation is arrows, keys,
|
|
311
446
|
and the scrollable thumb strip. */
|
|
312
447
|
const ZOOM_STEP = 1.2;
|
|
313
448
|
const item = (): Item => items[index];
|
|
449
|
+
|
|
314
450
|
const rezoom = (factor: number, cx: number, cy: number): void => {
|
|
315
451
|
view = zoomAt(view, factor, cx, cy, item().w, item().h, boxW, boxH);
|
|
316
452
|
applyView(r);
|
|
317
453
|
};
|
|
454
|
+
|
|
318
455
|
/* Cursor position relative to the viewbox center. */
|
|
319
456
|
const rel = (ev: MouseEvent): { cx: number; cy: number } => {
|
|
320
457
|
const rect = r.viewbox.getBoundingClientRect();
|
|
@@ -327,6 +464,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
327
464
|
r.dialog.addEventListener("keydown", (ev) => {
|
|
328
465
|
if (ev.key === "+" || ev.key === "=") rezoom(ZOOM_STEP, 0, 0);
|
|
329
466
|
if (ev.key === "-") rezoom(1 / ZOOM_STEP, 0, 0);
|
|
467
|
+
|
|
330
468
|
if (ev.key === "0") {
|
|
331
469
|
view = initialView(item().w, item().h, boxW, boxH);
|
|
332
470
|
applyView(r);
|
|
@@ -338,11 +476,15 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
338
476
|
applyView(r);
|
|
339
477
|
});
|
|
340
478
|
|
|
341
|
-
r.viewbox.addEventListener(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
479
|
+
r.viewbox.addEventListener(
|
|
480
|
+
"wheel",
|
|
481
|
+
(ev) => {
|
|
482
|
+
ev.preventDefault();
|
|
483
|
+
const { cx, cy } = rel(ev);
|
|
484
|
+
rezoom(ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, cx, cy);
|
|
485
|
+
},
|
|
486
|
+
{ passive: false },
|
|
487
|
+
);
|
|
346
488
|
|
|
347
489
|
/* At the untouched initial view, double-click dives to 100% under the
|
|
348
490
|
cursor; from any other zoom or pan state it returns to fit, centered
|
|
@@ -352,7 +494,10 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
352
494
|
listener on the image never fires in real browsers. */
|
|
353
495
|
r.viewbox.addEventListener("dblclick", (ev) => {
|
|
354
496
|
const init = initialView(item().w, item().h, boxW, boxH);
|
|
355
|
-
|
|
497
|
+
|
|
498
|
+
const atInit =
|
|
499
|
+
view.zoom === init.zoom && view.x === init.x && view.y === init.y;
|
|
500
|
+
|
|
356
501
|
if (atInit && view.zoom < 1) {
|
|
357
502
|
const { cx, cy } = rel(ev);
|
|
358
503
|
rezoom(1 / view.zoom, cx, cy);
|
|
@@ -365,6 +510,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
365
510
|
/* Drag pans while zoomed past fit; pinch zooms. Two live pointers
|
|
366
511
|
mean pinch; one means pan (or, at fit, the Task 5 swipe). */
|
|
367
512
|
const pointers = new Map<number, { x: number; y: number }>();
|
|
513
|
+
|
|
368
514
|
r.viewbox.addEventListener("pointerdown", (ev) => {
|
|
369
515
|
/* A press on HOME is a button press, not a drag. Capturing it
|
|
370
516
|
would retarget the pointerup and click to the viewbox, so the
|
|
@@ -372,30 +518,54 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
372
518
|
which is why only the browser showed this). */
|
|
373
519
|
if (ev.target === r.homeBtn) return;
|
|
374
520
|
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
375
|
-
|
|
521
|
+
|
|
522
|
+
if (typeof r.viewbox.setPointerCapture === "function") {
|
|
523
|
+
r.viewbox.setPointerCapture(ev.pointerId);
|
|
524
|
+
}
|
|
376
525
|
});
|
|
526
|
+
|
|
377
527
|
r.viewbox.addEventListener("pointermove", (ev) => {
|
|
378
528
|
const prev = pointers.get(ev.pointerId);
|
|
379
529
|
if (!prev) return;
|
|
530
|
+
|
|
380
531
|
if (pointers.size === 2) {
|
|
381
|
-
const other = [...pointers.entries()].find(
|
|
532
|
+
const other = [...pointers.entries()].find(
|
|
533
|
+
([id]) => id !== ev.pointerId,
|
|
534
|
+
)?.[1];
|
|
535
|
+
|
|
382
536
|
if (other) {
|
|
383
537
|
const d0 = Math.hypot(prev.x - other.x, prev.y - other.y);
|
|
384
538
|
const d1 = Math.hypot(ev.clientX - other.x, ev.clientY - other.y);
|
|
539
|
+
|
|
385
540
|
if (d0 > 0) {
|
|
386
541
|
const rect = r.viewbox.getBoundingClientRect();
|
|
387
|
-
|
|
388
|
-
const
|
|
542
|
+
|
|
543
|
+
const midX =
|
|
544
|
+
(ev.clientX + other.x) / 2 - rect.left - (rect.width || boxW) / 2;
|
|
545
|
+
|
|
546
|
+
const midY =
|
|
547
|
+
(ev.clientY + other.y) / 2 - rect.top - (rect.height || boxH) / 2;
|
|
548
|
+
|
|
389
549
|
rezoom(d1 / d0, midX, midY);
|
|
390
550
|
}
|
|
391
551
|
}
|
|
392
552
|
} else {
|
|
393
|
-
view = normalizePan({
|
|
553
|
+
view = normalizePan({
|
|
554
|
+
zoom: view.zoom,
|
|
555
|
+
x: view.x + ev.clientX - prev.x,
|
|
556
|
+
y: view.y + ev.clientY - prev.y,
|
|
557
|
+
});
|
|
558
|
+
|
|
394
559
|
applyView(r);
|
|
395
560
|
}
|
|
561
|
+
|
|
396
562
|
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
397
563
|
});
|
|
398
|
-
|
|
564
|
+
|
|
565
|
+
const lift = (ev: PointerEvent): void => {
|
|
566
|
+
pointers.delete(ev.pointerId);
|
|
567
|
+
};
|
|
568
|
+
|
|
399
569
|
r.viewbox.addEventListener("pointerup", lift);
|
|
400
570
|
r.viewbox.addEventListener("pointercancel", lift);
|
|
401
571
|
|
|
@@ -420,13 +590,28 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
420
590
|
goTo(r, Math.max(0, set.indexOf(link)));
|
|
421
591
|
};
|
|
422
592
|
|
|
423
|
-
const mounted: {
|
|
593
|
+
const mounted: {
|
|
594
|
+
link: HTMLAnchorElement;
|
|
595
|
+
onClick: (ev: MouseEvent) => void;
|
|
596
|
+
}[] = [];
|
|
597
|
+
|
|
424
598
|
for (const link of links) {
|
|
425
599
|
const onClick = (ev: MouseEvent): void => {
|
|
426
|
-
if (
|
|
600
|
+
if (
|
|
601
|
+
ev.defaultPrevented ||
|
|
602
|
+
ev.button !== 0 ||
|
|
603
|
+
ev.ctrlKey ||
|
|
604
|
+
ev.metaKey ||
|
|
605
|
+
ev.shiftKey ||
|
|
606
|
+
ev.altKey
|
|
607
|
+
) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
427
611
|
ev.preventDefault();
|
|
428
612
|
open(link);
|
|
429
613
|
};
|
|
614
|
+
|
|
430
615
|
link.addEventListener("click", onClick);
|
|
431
616
|
mounted.push({ link, onClick });
|
|
432
617
|
}
|
|
@@ -437,6 +622,7 @@ export const mountLightbox: Island<LightboxOptions> = (root, options = {}): Isla
|
|
|
437
622
|
link.removeEventListener("click", onClick);
|
|
438
623
|
release(link, "lightbox");
|
|
439
624
|
}
|
|
625
|
+
|
|
440
626
|
if (refs) {
|
|
441
627
|
refs.dialog.remove();
|
|
442
628
|
refs = null;
|
package/src/scripts/link-tip.ts
CHANGED
|
@@ -7,10 +7,25 @@ const EDGE = 12; /* px of breathing room against either viewport edge */
|
|
|
7
7
|
|
|
8
8
|
export type TipPlace = "above" | "below";
|
|
9
9
|
export type TipAlign = "start" | "end";
|
|
10
|
-
export interface AnchorRect {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
export interface AnchorRect {
|
|
11
|
+
left: number;
|
|
12
|
+
right: number;
|
|
13
|
+
top: number;
|
|
14
|
+
bottom: number;
|
|
15
|
+
}
|
|
16
|
+
export interface TipSize {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ViewportSize {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
export interface TipPosition {
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
place: TipPlace;
|
|
28
|
+
}
|
|
14
29
|
|
|
15
30
|
/* Pure placement for the singleton tip: start/end alignment against
|
|
16
31
|
the anchor, horizontal clamp into [edge, vw - edge] with the left
|
|
@@ -24,14 +39,28 @@ export function placeTip(
|
|
|
24
39
|
align: TipAlign = "start",
|
|
25
40
|
edge: number = EDGE,
|
|
26
41
|
): TipPosition {
|
|
27
|
-
|
|
42
|
+
const startX = anchor.left - INSET;
|
|
43
|
+
const endX = anchor.right + INSET - tip.width;
|
|
44
|
+
let x = align === "start" ? startX : endX;
|
|
45
|
+
|
|
28
46
|
const overRight = x + tip.width - (viewport.width - edge);
|
|
29
47
|
if (overRight > 0) x -= overRight;
|
|
30
48
|
if (x < edge) x = edge;
|
|
31
49
|
let finalPlace = place;
|
|
32
|
-
|
|
33
|
-
if (place === "
|
|
34
|
-
|
|
50
|
+
|
|
51
|
+
if (place === "above" && anchor.top - GAP - tip.height < 0) {
|
|
52
|
+
finalPlace = "below";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) {
|
|
56
|
+
finalPlace = "above";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const y =
|
|
60
|
+
finalPlace === "above"
|
|
61
|
+
? anchor.top - GAP - tip.height
|
|
62
|
+
: anchor.bottom + GAP;
|
|
63
|
+
|
|
35
64
|
return { x: Math.round(x), y: Math.round(y), place: finalPlace };
|
|
36
65
|
}
|
|
37
66
|
|
|
@@ -47,9 +76,15 @@ export interface LinkTipOptions {
|
|
|
47
76
|
point (spec 2026-09-02; three positioning defects shipped from
|
|
48
77
|
pseudo-element prediction). Containers steer placement with
|
|
49
78
|
data-tip-place="below" and data-tip-align="end" (the masthead). */
|
|
50
|
-
export const mountLinkTips: Island<LinkTipOptions> = (
|
|
51
|
-
|
|
79
|
+
export const mountLinkTips: Island<LinkTipOptions> = (
|
|
80
|
+
root,
|
|
81
|
+
options = {},
|
|
82
|
+
): IslandHandle => {
|
|
83
|
+
const { selector = 'a[href^="http"], a[data-tooltip]', edge = EDGE } =
|
|
84
|
+
options;
|
|
85
|
+
|
|
52
86
|
const doc = docOf(root);
|
|
87
|
+
|
|
53
88
|
if (!claim(doc.documentElement, "link-tip")) {
|
|
54
89
|
return {
|
|
55
90
|
destroy(): void {
|
|
@@ -66,6 +101,7 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
|
|
|
66
101
|
path-player.ts): the plain Window type always declares the method,
|
|
67
102
|
so an unwidened optional chain reads as always-true to the lint. */
|
|
68
103
|
const mmWin = win as { matchMedia?: typeof window.matchMedia } | null;
|
|
104
|
+
|
|
69
105
|
if (mmWin?.matchMedia?.("(hover: none)").matches) {
|
|
70
106
|
return {
|
|
71
107
|
destroy(): void {
|
|
@@ -108,7 +144,12 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
|
|
|
108
144
|
const box = tip.getBoundingClientRect();
|
|
109
145
|
const place = a.closest('[data-tip-place="below"]') ? "below" : "above";
|
|
110
146
|
const align = a.closest('[data-tip-align="end"]') ? "end" : "start";
|
|
111
|
-
|
|
147
|
+
|
|
148
|
+
const viewport = {
|
|
149
|
+
width: doc.documentElement.clientWidth,
|
|
150
|
+
height: doc.documentElement.clientHeight,
|
|
151
|
+
};
|
|
152
|
+
|
|
112
153
|
const pos = placeTip(anchor, box, viewport, place, align, edge);
|
|
113
154
|
tip.style.left = `${String(pos.x)}px`;
|
|
114
155
|
tip.style.top = `${String(pos.y)}px`;
|
|
@@ -119,12 +160,15 @@ export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): Islan
|
|
|
119
160
|
const a = linkFor(ev.target);
|
|
120
161
|
if (a) show(a);
|
|
121
162
|
};
|
|
163
|
+
|
|
122
164
|
const onOut = (ev: Event): void => {
|
|
123
165
|
if (linkFor(ev.target)) hide();
|
|
124
166
|
};
|
|
167
|
+
|
|
125
168
|
const onKey = (ev: KeyboardEvent): void => {
|
|
126
169
|
if (ev.key === "Escape") hide();
|
|
127
170
|
};
|
|
171
|
+
|
|
128
172
|
const onHide = (): void => {
|
|
129
173
|
hide();
|
|
130
174
|
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/* Pure geometry for the path-player timeline; the canvas painter in
|
|
2
2
|
path-player.ts consumes these, jsdom tests never need a 2D context. */
|
|
3
3
|
|
|
4
|
-
export function normalizeSeries(values: number[]): {
|
|
4
|
+
export function normalizeSeries(values: number[]): {
|
|
5
|
+
min: number;
|
|
6
|
+
max: number;
|
|
7
|
+
frac: (v: number) => number;
|
|
8
|
+
} {
|
|
5
9
|
const min = Math.min(...values);
|
|
6
10
|
const max = Math.max(...values);
|
|
7
11
|
return { min, max, frac: (v) => (max > min ? (v - min) / (max - min) : 0.5) };
|
|
@@ -29,6 +33,11 @@ export function columnIndex(px: number, width: number, length: number): number {
|
|
|
29
33
|
|
|
30
34
|
/* Vertical position of a normalized value inside a track: frac 0 sits
|
|
31
35
|
pad above the track's bottom edge, frac 1 sits pad below its top. */
|
|
32
|
-
export function seriesY(
|
|
36
|
+
export function seriesY(
|
|
37
|
+
frac: number,
|
|
38
|
+
top: number,
|
|
39
|
+
height: number,
|
|
40
|
+
pad: number,
|
|
41
|
+
): number {
|
|
33
42
|
return top + height - pad - frac * (height - 2 * pad);
|
|
34
43
|
}
|