@half-built/astro 0.1.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/ICONS-LICENSE +43 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +18 -0
- package/src/components/CategoryCard.astro +31 -0
- package/src/components/CornerBadges.astro +40 -0
- package/src/components/Footer.astro +209 -0
- package/src/components/LightboxLink.astro +13 -0
- package/src/components/LinkListWidget.astro +19 -0
- package/src/components/Pagination.astro +72 -0
- package/src/components/PostCard.astro +166 -0
- package/src/components/PostNavigation.astro +61 -0
- package/src/components/Shell.astro +52 -0
- package/src/components/SiteHeader.astro +326 -0
- package/src/components/SmartImage.astro +34 -0
- package/src/components/Subscribe.astro +117 -0
- package/src/components/ThemeToggle.astro +41 -0
- package/src/components/TwoColumn.astro +12 -0
- package/src/components/Widget.astro +41 -0
- package/src/components/content/BlogImage.astro +39 -0
- package/src/components/content/Button.astro +57 -0
- package/src/components/content/Callout.astro +75 -0
- package/src/components/content/CodeBlock.astro +7 -0
- package/src/components/content/Gallery.astro +57 -0
- package/src/components/content/GalleryImage.astro +35 -0
- package/src/components/content/Group.astro +15 -0
- package/src/components/content/MediaText.astro +60 -0
- package/src/components/content/Palette.astro +42 -0
- package/src/components/content/Quote.astro +21 -0
- package/src/components/content/Spacer.astro +5 -0
- package/src/components/content/Step.astro +126 -0
- package/src/components/content/Walkthrough.astro +42 -0
- package/src/components/models.ts +77 -0
- package/src/lib/archive.ts +29 -0
- package/src/lib/drafts.ts +52 -0
- package/src/lib/format-date.ts +9 -0
- package/src/lib/header-date.ts +6 -0
- package/src/lib/ordering.ts +18 -0
- package/src/lib/paginate.ts +15 -0
- package/src/lib/reading-time.ts +4 -0
- package/src/lib/slug.ts +73 -0
- package/src/scripts/code-island.ts +75 -0
- package/src/scripts/core/breakpoints.ts +4 -0
- package/src/scripts/core/dom.ts +31 -0
- package/src/scripts/core/frame-loop.ts +54 -0
- package/src/scripts/core/icons.ts +30 -0
- package/src/scripts/core/island.ts +25 -0
- package/src/scripts/core/storage.ts +44 -0
- package/src/scripts/focus-mode.ts +41 -0
- package/src/scripts/lightbox.ts +446 -0
- package/src/scripts/link-tip.ts +154 -0
- package/src/scripts/path-player-math.ts +34 -0
- package/src/scripts/path-player-paint.ts +154 -0
- package/src/scripts/path-player.ts +341 -0
- package/src/scripts/plate-modal.ts +91 -0
- package/src/scripts/scroll-top.ts +32 -0
- package/src/scripts/site-header.ts +73 -0
- package/src/scripts/subscribe.ts +116 -0
- package/src/scripts/theme-toggle.ts +115 -0
- package/src/shiki/code-theme.mjs +16 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/* Image lightbox (spec docs/superpowers/specs/2026-07-30-image-lightbox-design.md).
|
|
2
|
+
Exported pure math plus mountLightbox, on the island contract (step 9):
|
|
3
|
+
mount(root, options?) returns a destroy handle; claim() makes a second
|
|
4
|
+
mount over an already-wired link a no-op. Called from Base.astro's
|
|
5
|
+
script block and testable under jsdom. */
|
|
6
|
+
|
|
7
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
8
|
+
import { iconButton, docOf } from "./core/dom";
|
|
9
|
+
import { buildPlateModal } from "./plate-modal";
|
|
10
|
+
import { ICON_CHEVRON_LEFT, ICON_CHEVRON_RIGHT } from "./core/icons";
|
|
11
|
+
|
|
12
|
+
export interface ZoomView { zoom: number; x: number; y: number }
|
|
13
|
+
|
|
14
|
+
export const MIN_NATIVE_ZOOM = 0.1;
|
|
15
|
+
export const MAX_NATIVE_ZOOM = 8;
|
|
16
|
+
export const TALL_ASPECT = 3;
|
|
17
|
+
export const FALLBACK_BOX = { w: 800, h: 600 };
|
|
18
|
+
/* HOME appears once the visible image-window overlap drops below this
|
|
19
|
+
fraction of whichever is smaller, the scaled image or the window. */
|
|
20
|
+
const WHACK_FRACTION = 0.25;
|
|
21
|
+
|
|
22
|
+
/* zoom is CSS px per image px (1 = 100%); fit contains the image, except
|
|
23
|
+
tall schematics fit width, and small images never upscale past 100%. */
|
|
24
|
+
export function fitZoom(imgW: number, imgH: number, boxW: number, boxH: number): number {
|
|
25
|
+
if (imgH / imgW >= TALL_ASPECT) return boxW / imgW;
|
|
26
|
+
return Math.min(boxW / imgW, boxH / imgH, 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Floor: 10% of native for every image, or the fit zoom when fit is
|
|
30
|
+
already below that, so the fit view stays reachable (owner adjustment
|
|
31
|
+
2026-08-01; originally 50% of fit, which read as arbitrary per-image
|
|
32
|
+
percentages). Ceiling: 800% of native. */
|
|
33
|
+
export function clampZoom(zoom: number, fit: number): number {
|
|
34
|
+
return Math.min(Math.max(zoom, Math.min(MIN_NATIVE_ZOOM, fit)), MAX_NATIVE_ZOOM);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* x/y offset the image center from the viewbox center. Pan is unbounded
|
|
38
|
+
(owner adjustment 2026-08-01): the mat is the world and the image drags
|
|
39
|
+
freely; HOME, double-click, and 0 snap it back. The + 0 flushes IEEE
|
|
40
|
+
negative zero so readouts never show "-0" and toEqual({ x: 0 }) style
|
|
41
|
+
assertions pass. */
|
|
42
|
+
export function normalizePan(view: ZoomView): ZoomView {
|
|
43
|
+
return { zoom: view.zoom, x: view.x + 0, y: view.y + 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function initialView(imgW: number, imgH: number, boxW: number, boxH: number): ZoomView {
|
|
47
|
+
const zoom = fitZoom(imgW, imgH, boxW, boxH);
|
|
48
|
+
const y = imgH / imgW >= TALL_ASPECT ? Math.max(0, (imgH * zoom - boxH) / 2) : 0;
|
|
49
|
+
return normalizePan({ zoom, x: 0, y });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Anchor-point zoom: the image point under (cx, cy) stays put. Cursor
|
|
53
|
+
coordinates are relative to the viewbox center. */
|
|
54
|
+
export function zoomAt(view: ZoomView, factor: number, cx: number, cy: number, imgW: number, imgH: number, boxW: number, boxH: number): ZoomView {
|
|
55
|
+
const fit = fitZoom(imgW, imgH, boxW, boxH);
|
|
56
|
+
const zoom = clampZoom(view.zoom * factor, fit);
|
|
57
|
+
const s = zoom / view.zoom;
|
|
58
|
+
return normalizePan({ zoom, x: cx - (cx - view.x) * s, y: cy - (cy - view.y) * s });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* True when the view has wandered far enough that the user likely wants
|
|
62
|
+
a way home: the image-window overlap is under WHACK_FRACTION of the
|
|
63
|
+
smaller of the two areas (so a centered deep zoom is never "whack"). */
|
|
64
|
+
export function outOfWhack(view: ZoomView, imgW: number, imgH: number, boxW: number, boxH: number): boolean {
|
|
65
|
+
const w = imgW * view.zoom;
|
|
66
|
+
const h = imgH * view.zoom;
|
|
67
|
+
const overlapW = Math.min(view.x + w / 2, boxW / 2) - Math.max(view.x - w / 2, -boxW / 2);
|
|
68
|
+
const overlapH = Math.min(view.y + h / 2, boxH / 2) - Math.max(view.y - h / 2, -boxH / 2);
|
|
69
|
+
const overlap = Math.max(0, overlapW) * Math.max(0, overlapH);
|
|
70
|
+
return overlap < WHACK_FRACTION * Math.min(w * h, boxW * boxH);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readout(view: ZoomView, fit: number, prefix = "FIT"): string {
|
|
74
|
+
const sign = (n: number): string => {
|
|
75
|
+
const r = Math.round(n);
|
|
76
|
+
return r >= 0 ? `+${String(r)}` : String(r);
|
|
77
|
+
};
|
|
78
|
+
if (view.zoom === fit && view.x === 0 && view.y === 0) return `${prefix} · 0,0`;
|
|
79
|
+
if (view.zoom === fit) return `${prefix} · ${sign(view.x)},${sign(view.y)}`;
|
|
80
|
+
return `${String(Math.round(view.zoom * 100))}% · ${sign(view.x)},${sign(view.y)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface LightboxLabels {
|
|
84
|
+
viewer: string;
|
|
85
|
+
close: string;
|
|
86
|
+
prev: string;
|
|
87
|
+
next: string;
|
|
88
|
+
home: string;
|
|
89
|
+
homeAction: string;
|
|
90
|
+
image: (i: number, n: number) => string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface LightboxOptions {
|
|
94
|
+
selector?: string;
|
|
95
|
+
/* Ancestors whose contained links form one navigable set. The defaults
|
|
96
|
+
are the containers the package's own Gallery and Walkthrough render.
|
|
97
|
+
Grouping keys on the mount's selector (step 9.5; it used to query the
|
|
98
|
+
default class literally, so an overridden selector silently broke
|
|
99
|
+
sets). */
|
|
100
|
+
groupSelector?: string;
|
|
101
|
+
labels?: Partial<LightboxLabels>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const DEFAULT_LABELS: LightboxLabels = {
|
|
105
|
+
viewer: "Image viewer",
|
|
106
|
+
close: "Close image viewer",
|
|
107
|
+
prev: "Previous image",
|
|
108
|
+
next: "Next image",
|
|
109
|
+
home: "HOME",
|
|
110
|
+
homeAction: "Reset view to fit",
|
|
111
|
+
image: (i, n) => `Image ${String(i)} of ${String(n)}`,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
interface Item { href: string; w: number; h: number; caption: string; thumb: string }
|
|
115
|
+
|
|
116
|
+
interface Refs {
|
|
117
|
+
dialog: HTMLDialogElement;
|
|
118
|
+
zone: HTMLDivElement;
|
|
119
|
+
prevBtn: HTMLButtonElement;
|
|
120
|
+
nextBtn: HTMLButtonElement;
|
|
121
|
+
closeBtn: HTMLButtonElement;
|
|
122
|
+
counter: HTMLSpanElement;
|
|
123
|
+
viewbox: HTMLDivElement;
|
|
124
|
+
img: HTMLImageElement;
|
|
125
|
+
caption: HTMLParagraphElement;
|
|
126
|
+
dims: HTMLSpanElement;
|
|
127
|
+
readoutEl: HTMLSpanElement;
|
|
128
|
+
homeBtn: HTMLButtonElement;
|
|
129
|
+
thumbs: HTMLDivElement;
|
|
130
|
+
pmOpen: (opener: HTMLElement | null) => void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function itemFor(link: HTMLAnchorElement): Item {
|
|
134
|
+
const img = link.querySelector("img");
|
|
135
|
+
return {
|
|
136
|
+
href: link.getAttribute("href") ?? "",
|
|
137
|
+
w: Number(link.dataset.lbW ?? "0"),
|
|
138
|
+
h: Number(link.dataset.lbH ?? "0"),
|
|
139
|
+
caption: link.dataset.lbCaption ?? "",
|
|
140
|
+
thumb: (img?.currentSrc ?? "") || (img?.getAttribute("src") ?? "") || (link.getAttribute("href") ?? ""),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Gallery plates sharing a .gallery-plates ancestor form a set, and so do
|
|
145
|
+
the photos of one Walkthrough (spec 2026-08-15-walkthrough-steps-design).
|
|
146
|
+
Every other image opens solo (spec: no implicit chaining). */
|
|
147
|
+
function setFor(link: HTMLAnchorElement, selector: string, groupSelector: string): HTMLAnchorElement[] {
|
|
148
|
+
const set = link.closest(groupSelector);
|
|
149
|
+
if (!set) return [link];
|
|
150
|
+
return [...set.querySelectorAll<HTMLAnchorElement>(selector)];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* The in-page rendition shows instantly; the untransformed original swaps
|
|
154
|
+
in when loaded. dataset.pending guards against stale loads after
|
|
155
|
+
navigating within a set. Returns the probe for tests. */
|
|
156
|
+
export function swapToFull(target: HTMLImageElement, href: string, doc: Document): HTMLImageElement | null {
|
|
157
|
+
if (target.getAttribute("src") === href) return null;
|
|
158
|
+
target.dataset.pending = href;
|
|
159
|
+
const probe = doc.createElement("img");
|
|
160
|
+
probe.addEventListener("load", () => {
|
|
161
|
+
if (target.dataset.pending === href) {
|
|
162
|
+
target.src = href;
|
|
163
|
+
delete target.dataset.pending;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
probe.src = href;
|
|
167
|
+
return probe;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildDialog(doc: Document, labels: LightboxLabels): Refs {
|
|
171
|
+
const pm = buildPlateModal(doc, { ariaLabel: labels.viewer, legacyPrefix: "lb" });
|
|
172
|
+
const { dialog, zone, plate } = pm;
|
|
173
|
+
const closeBtn = pm.closeBtn;
|
|
174
|
+
closeBtn.setAttribute("aria-label", labels.close);
|
|
175
|
+
|
|
176
|
+
const main = doc.createElement("div");
|
|
177
|
+
main.className = "lb-main";
|
|
178
|
+
const prevBtn = iconButton(doc, "lb-arrow lb-prev icon-box", labels.prev, ICON_CHEVRON_LEFT);
|
|
179
|
+
const nextBtn = iconButton(doc, "lb-arrow lb-next icon-box", labels.next, ICON_CHEVRON_RIGHT);
|
|
180
|
+
|
|
181
|
+
/* The bar owns counter + close so the mobile header-row layout keeps
|
|
182
|
+
working; the close button is re-parented out of the plate root. */
|
|
183
|
+
const bar = doc.createElement("div");
|
|
184
|
+
bar.className = "lb-bar";
|
|
185
|
+
const counter = pm.addLabel("topLeft", "lb-counter");
|
|
186
|
+
counter.setAttribute("aria-live", "polite");
|
|
187
|
+
bar.append(counter, closeBtn);
|
|
188
|
+
|
|
189
|
+
const viewbox = doc.createElement("div");
|
|
190
|
+
viewbox.className = "lb-viewbox";
|
|
191
|
+
const img = doc.createElement("img");
|
|
192
|
+
img.className = "lb-img";
|
|
193
|
+
/* Native HTML5 image drag hijacks mouse panning: the browser starts a
|
|
194
|
+
drag (no-drop cursor) and fires pointercancel, killing pan tracking.
|
|
195
|
+
Opt the pan surface out of it on both layers. */
|
|
196
|
+
img.draggable = false;
|
|
197
|
+
img.addEventListener("dragstart", (ev) => { ev.preventDefault(); });
|
|
198
|
+
const homeBtn = doc.createElement("button");
|
|
199
|
+
homeBtn.type = "button";
|
|
200
|
+
homeBtn.className = "lb-home boxed-label micro-label";
|
|
201
|
+
homeBtn.setAttribute("aria-label", labels.homeAction);
|
|
202
|
+
homeBtn.textContent = labels.home;
|
|
203
|
+
homeBtn.hidden = true;
|
|
204
|
+
viewbox.append(img, homeBtn);
|
|
205
|
+
|
|
206
|
+
const caption = doc.createElement("p");
|
|
207
|
+
caption.className = "lb-caption";
|
|
208
|
+
|
|
209
|
+
const foot = doc.createElement("div");
|
|
210
|
+
foot.className = "lb-foot";
|
|
211
|
+
const dims = pm.addLabel("bottomLeft", "lb-dims");
|
|
212
|
+
const readoutEl = pm.addLabel("bottomRight", "lb-readout");
|
|
213
|
+
foot.append(dims, readoutEl);
|
|
214
|
+
|
|
215
|
+
plate.append(bar, viewbox, caption, foot);
|
|
216
|
+
main.append(prevBtn, plate, nextBtn);
|
|
217
|
+
|
|
218
|
+
const thumbs = doc.createElement("div");
|
|
219
|
+
thumbs.className = "lb-thumbs";
|
|
220
|
+
|
|
221
|
+
zone.append(main, thumbs);
|
|
222
|
+
return { dialog, zone, prevBtn, nextBtn, closeBtn, counter, viewbox, img, caption, dims, readoutEl, homeBtn, thumbs, pmOpen: pm.open };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const mountLightbox: Island<LightboxOptions> = (root, options = {}): IslandHandle => {
|
|
226
|
+
const { selector = "a.lightbox-link", groupSelector = ".gallery-plates, .walkthrough", labels: labelsOverride = {} } = options;
|
|
227
|
+
const labels: LightboxLabels = { ...DEFAULT_LABELS, ...labelsOverride };
|
|
228
|
+
const doc = docOf(root);
|
|
229
|
+
const links = [...root.querySelectorAll<HTMLAnchorElement>(selector)].filter((link) => claim(link, "lightbox"));
|
|
230
|
+
if (links.length === 0) {
|
|
231
|
+
return {
|
|
232
|
+
destroy(): void {
|
|
233
|
+
/* nothing this mount claimed: nothing here to tear down */
|
|
234
|
+
return;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let refs: Refs | null = null;
|
|
240
|
+
let items: Item[] = [];
|
|
241
|
+
let index = 0;
|
|
242
|
+
let fit = 1;
|
|
243
|
+
let view: ZoomView = { zoom: 1, x: 0, y: 0 };
|
|
244
|
+
let boxW = FALLBACK_BOX.w;
|
|
245
|
+
let boxH = FALLBACK_BOX.h;
|
|
246
|
+
|
|
247
|
+
const applyView = (r: Refs): void => {
|
|
248
|
+
const item = items[index];
|
|
249
|
+
r.img.style.width = `${String(item.w * view.zoom)}px`;
|
|
250
|
+
r.img.style.height = `${String(item.h * view.zoom)}px`;
|
|
251
|
+
r.img.style.transform = `translate(calc(-50% + ${String(view.x)}px), calc(-50% + ${String(view.y)}px))`;
|
|
252
|
+
r.readoutEl.textContent = readout(view, fit);
|
|
253
|
+
r.homeBtn.hidden = !outOfWhack(view, item.w, item.h, boxW, boxH);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const goTo = (r: Refs, i: number): void => {
|
|
257
|
+
const n = items.length;
|
|
258
|
+
show(r, ((i % n) + n) % n);
|
|
259
|
+
r.thumbs.querySelectorAll(".lb-thumb").forEach((t, ti) => {
|
|
260
|
+
t.classList.toggle("active", ti === index);
|
|
261
|
+
/* The strip scrolls rather than wraps; keep the active thumb in
|
|
262
|
+
view (guarded: jsdom has no scrollIntoView). */
|
|
263
|
+
if (ti === index && t instanceof HTMLElement && typeof t.scrollIntoView === "function") {
|
|
264
|
+
t.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const buildThumbs = (r: Refs): void => {
|
|
270
|
+
r.thumbs.replaceChildren();
|
|
271
|
+
items.forEach((item, i) => {
|
|
272
|
+
const btn = doc.createElement("button");
|
|
273
|
+
btn.type = "button";
|
|
274
|
+
btn.className = "lb-thumb";
|
|
275
|
+
btn.setAttribute("aria-label", labels.image(i + 1, items.length));
|
|
276
|
+
const timg = doc.createElement("img");
|
|
277
|
+
timg.src = item.thumb;
|
|
278
|
+
timg.alt = "";
|
|
279
|
+
btn.append(timg);
|
|
280
|
+
btn.addEventListener("click", () => { goTo(r, i); });
|
|
281
|
+
r.thumbs.append(btn);
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const show = (r: Refs, i: number): void => {
|
|
286
|
+
index = i;
|
|
287
|
+
const item = items[index];
|
|
288
|
+
fit = fitZoom(item.w, item.h, boxW, boxH);
|
|
289
|
+
view = initialView(item.w, item.h, boxW, boxH);
|
|
290
|
+
r.caption.textContent = item.caption;
|
|
291
|
+
r.img.alt = item.caption;
|
|
292
|
+
r.dims.textContent = `${String(item.w)} × ${String(item.h)}`;
|
|
293
|
+
r.counter.textContent = `${String(index + 1).padStart(2, "0")} / ${String(items.length).padStart(2, "0")}`;
|
|
294
|
+
r.img.src = item.thumb;
|
|
295
|
+
swapToFull(r.img, item.href, doc);
|
|
296
|
+
applyView(r);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const ensureRefs = (): Refs => {
|
|
300
|
+
if (refs) return refs;
|
|
301
|
+
const r = buildDialog(doc, labels);
|
|
302
|
+
r.prevBtn.addEventListener("click", () => { goTo(r, index - 1); });
|
|
303
|
+
r.nextBtn.addEventListener("click", () => { goTo(r, index + 1); });
|
|
304
|
+
r.dialog.addEventListener("keydown", (ev) => {
|
|
305
|
+
if (items.length < 2) return;
|
|
306
|
+
if (ev.key === "ArrowRight") goTo(r, index + 1);
|
|
307
|
+
if (ev.key === "ArrowLeft") goTo(r, index - 1);
|
|
308
|
+
});
|
|
309
|
+
/* Swipe navigation was removed with unbounded pan (owner adjustment
|
|
310
|
+
2026-08-01): drag always pans, and set navigation is arrows, keys,
|
|
311
|
+
and the scrollable thumb strip. */
|
|
312
|
+
const ZOOM_STEP = 1.2;
|
|
313
|
+
const item = (): Item => items[index];
|
|
314
|
+
const rezoom = (factor: number, cx: number, cy: number): void => {
|
|
315
|
+
view = zoomAt(view, factor, cx, cy, item().w, item().h, boxW, boxH);
|
|
316
|
+
applyView(r);
|
|
317
|
+
};
|
|
318
|
+
/* Cursor position relative to the viewbox center. */
|
|
319
|
+
const rel = (ev: MouseEvent): { cx: number; cy: number } => {
|
|
320
|
+
const rect = r.viewbox.getBoundingClientRect();
|
|
321
|
+
return {
|
|
322
|
+
cx: ev.clientX - rect.left - (rect.width || boxW) / 2,
|
|
323
|
+
cy: ev.clientY - rect.top - (rect.height || boxH) / 2,
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
r.dialog.addEventListener("keydown", (ev) => {
|
|
328
|
+
if (ev.key === "+" || ev.key === "=") rezoom(ZOOM_STEP, 0, 0);
|
|
329
|
+
if (ev.key === "-") rezoom(1 / ZOOM_STEP, 0, 0);
|
|
330
|
+
if (ev.key === "0") {
|
|
331
|
+
view = initialView(item().w, item().h, boxW, boxH);
|
|
332
|
+
applyView(r);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
r.homeBtn.addEventListener("click", () => {
|
|
337
|
+
view = initialView(item().w, item().h, boxW, boxH);
|
|
338
|
+
applyView(r);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
r.viewbox.addEventListener("wheel", (ev) => {
|
|
342
|
+
ev.preventDefault();
|
|
343
|
+
const { cx, cy } = rel(ev);
|
|
344
|
+
rezoom(ev.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, cx, cy);
|
|
345
|
+
}, { passive: false });
|
|
346
|
+
|
|
347
|
+
/* At the untouched initial view, double-click dives to 100% under the
|
|
348
|
+
cursor; from any other zoom or pan state it returns to fit, centered
|
|
349
|
+
(owner adjustment 2026-08-01). */
|
|
350
|
+
/* On the viewbox, not the image: pointer capture retargets the
|
|
351
|
+
click pair that forms a dblclick to the capture element, so a
|
|
352
|
+
listener on the image never fires in real browsers. */
|
|
353
|
+
r.viewbox.addEventListener("dblclick", (ev) => {
|
|
354
|
+
const init = initialView(item().w, item().h, boxW, boxH);
|
|
355
|
+
const atInit = view.zoom === init.zoom && view.x === init.x && view.y === init.y;
|
|
356
|
+
if (atInit && view.zoom < 1) {
|
|
357
|
+
const { cx, cy } = rel(ev);
|
|
358
|
+
rezoom(1 / view.zoom, cx, cy);
|
|
359
|
+
} else {
|
|
360
|
+
view = init;
|
|
361
|
+
applyView(r);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/* Drag pans while zoomed past fit; pinch zooms. Two live pointers
|
|
366
|
+
mean pinch; one means pan (or, at fit, the Task 5 swipe). */
|
|
367
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
368
|
+
r.viewbox.addEventListener("pointerdown", (ev) => {
|
|
369
|
+
/* A press on HOME is a button press, not a drag. Capturing it
|
|
370
|
+
would retarget the pointerup and click to the viewbox, so the
|
|
371
|
+
button would never fire in real browsers (jsdom lacks capture,
|
|
372
|
+
which is why only the browser showed this). */
|
|
373
|
+
if (ev.target === r.homeBtn) return;
|
|
374
|
+
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
375
|
+
if (typeof r.viewbox.setPointerCapture === "function") r.viewbox.setPointerCapture(ev.pointerId);
|
|
376
|
+
});
|
|
377
|
+
r.viewbox.addEventListener("pointermove", (ev) => {
|
|
378
|
+
const prev = pointers.get(ev.pointerId);
|
|
379
|
+
if (!prev) return;
|
|
380
|
+
if (pointers.size === 2) {
|
|
381
|
+
const other = [...pointers.entries()].find(([id]) => id !== ev.pointerId)?.[1];
|
|
382
|
+
if (other) {
|
|
383
|
+
const d0 = Math.hypot(prev.x - other.x, prev.y - other.y);
|
|
384
|
+
const d1 = Math.hypot(ev.clientX - other.x, ev.clientY - other.y);
|
|
385
|
+
if (d0 > 0) {
|
|
386
|
+
const rect = r.viewbox.getBoundingClientRect();
|
|
387
|
+
const midX = (ev.clientX + other.x) / 2 - rect.left - (rect.width || boxW) / 2;
|
|
388
|
+
const midY = (ev.clientY + other.y) / 2 - rect.top - (rect.height || boxH) / 2;
|
|
389
|
+
rezoom(d1 / d0, midX, midY);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
view = normalizePan({ zoom: view.zoom, x: view.x + ev.clientX - prev.x, y: view.y + ev.clientY - prev.y });
|
|
394
|
+
applyView(r);
|
|
395
|
+
}
|
|
396
|
+
pointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
|
397
|
+
});
|
|
398
|
+
const lift = (ev: PointerEvent): void => { pointers.delete(ev.pointerId); };
|
|
399
|
+
r.viewbox.addEventListener("pointerup", lift);
|
|
400
|
+
r.viewbox.addEventListener("pointercancel", lift);
|
|
401
|
+
|
|
402
|
+
refs = r;
|
|
403
|
+
return r;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const open = (link: HTMLAnchorElement): void => {
|
|
407
|
+
const r = ensureRefs();
|
|
408
|
+
const set = setFor(link, selector, groupSelector);
|
|
409
|
+
items = set.map(itemFor);
|
|
410
|
+
const solo = items.length < 2;
|
|
411
|
+
r.counter.hidden = solo;
|
|
412
|
+
r.prevBtn.hidden = solo;
|
|
413
|
+
r.nextBtn.hidden = solo;
|
|
414
|
+
r.thumbs.hidden = solo;
|
|
415
|
+
if (!solo) buildThumbs(r);
|
|
416
|
+
r.pmOpen(link);
|
|
417
|
+
const rect = r.viewbox.getBoundingClientRect();
|
|
418
|
+
boxW = rect.width || FALLBACK_BOX.w;
|
|
419
|
+
boxH = rect.height || FALLBACK_BOX.h;
|
|
420
|
+
goTo(r, Math.max(0, set.indexOf(link)));
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const mounted: { link: HTMLAnchorElement; onClick: (ev: MouseEvent) => void }[] = [];
|
|
424
|
+
for (const link of links) {
|
|
425
|
+
const onClick = (ev: MouseEvent): void => {
|
|
426
|
+
if (ev.defaultPrevented || ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
|
427
|
+
ev.preventDefault();
|
|
428
|
+
open(link);
|
|
429
|
+
};
|
|
430
|
+
link.addEventListener("click", onClick);
|
|
431
|
+
mounted.push({ link, onClick });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
destroy(): void {
|
|
436
|
+
for (const { link, onClick } of mounted) {
|
|
437
|
+
link.removeEventListener("click", onClick);
|
|
438
|
+
release(link, "lightbox");
|
|
439
|
+
}
|
|
440
|
+
if (refs) {
|
|
441
|
+
refs.dialog.remove();
|
|
442
|
+
refs = null;
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
|
+
import { docOf } from "./core/dom";
|
|
3
|
+
|
|
4
|
+
const GAP = 1; /* px between anchor and tip; the old bottom: calc(100% + 1px) */
|
|
5
|
+
const INSET = 2; /* px of horizontal lead-out past the anchor edge */
|
|
6
|
+
const EDGE = 12; /* px of breathing room against either viewport edge */
|
|
7
|
+
|
|
8
|
+
export type TipPlace = "above" | "below";
|
|
9
|
+
export type TipAlign = "start" | "end";
|
|
10
|
+
export interface AnchorRect { left: number; right: number; top: number; bottom: number }
|
|
11
|
+
export interface TipSize { width: number; height: number }
|
|
12
|
+
export interface ViewportSize { width: number; height: number }
|
|
13
|
+
export interface TipPosition { x: number; y: number; place: TipPlace }
|
|
14
|
+
|
|
15
|
+
/* Pure placement for the singleton tip: start/end alignment against
|
|
16
|
+
the anchor, horizontal clamp into [edge, vw - edge] with the left
|
|
17
|
+
edge winning when both bind, and a vertical flip when the preferred
|
|
18
|
+
side would leave the viewport. All measured values; no prediction. */
|
|
19
|
+
export function placeTip(
|
|
20
|
+
anchor: AnchorRect,
|
|
21
|
+
tip: TipSize,
|
|
22
|
+
viewport: ViewportSize,
|
|
23
|
+
place: TipPlace = "above",
|
|
24
|
+
align: TipAlign = "start",
|
|
25
|
+
edge: number = EDGE,
|
|
26
|
+
): TipPosition {
|
|
27
|
+
let x = align === "start" ? anchor.left - INSET : anchor.right + INSET - tip.width;
|
|
28
|
+
const overRight = x + tip.width - (viewport.width - edge);
|
|
29
|
+
if (overRight > 0) x -= overRight;
|
|
30
|
+
if (x < edge) x = edge;
|
|
31
|
+
let finalPlace = place;
|
|
32
|
+
if (place === "above" && anchor.top - GAP - tip.height < 0) finalPlace = "below";
|
|
33
|
+
if (place === "below" && anchor.bottom + GAP + tip.height > viewport.height) finalPlace = "above";
|
|
34
|
+
const y = finalPlace === "above" ? anchor.top - GAP - tip.height : anchor.bottom + GAP;
|
|
35
|
+
return { x: Math.round(x), y: Math.round(y), place: finalPlace };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LinkTipOptions {
|
|
39
|
+
selector?: string;
|
|
40
|
+
edge?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* One island, one real tooltip element. On hover/focus of a matching
|
|
44
|
+
link the singleton is filled (data-tooltip if stamped, else the
|
|
45
|
+
href), measured with getBoundingClientRect, and placed fixed via
|
|
46
|
+
placeTip: measure then place, never predict, which is the whole
|
|
47
|
+
point (spec 2026-09-02; three positioning defects shipped from
|
|
48
|
+
pseudo-element prediction). Containers steer placement with
|
|
49
|
+
data-tip-place="below" and data-tip-align="end" (the masthead). */
|
|
50
|
+
export const mountLinkTips: Island<LinkTipOptions> = (root, options = {}): IslandHandle => {
|
|
51
|
+
const { selector = 'a[href^="http"], a[data-tooltip]', edge = EDGE } = options;
|
|
52
|
+
const doc = docOf(root);
|
|
53
|
+
if (!claim(doc.documentElement, "link-tip")) {
|
|
54
|
+
return {
|
|
55
|
+
destroy(): void {
|
|
56
|
+
/* already claimed elsewhere: nothing here to tear down */
|
|
57
|
+
return;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const win = doc.defaultView;
|
|
63
|
+
/* Touch: claims (so double mounts stay no-ops) but attaches nothing;
|
|
64
|
+
mobile browsers already show the URL themselves. jsdom has no
|
|
65
|
+
matchMedia at all (same widened-type guard as henry-loose.ts and
|
|
66
|
+
path-player.ts): the plain Window type always declares the method,
|
|
67
|
+
so an unwidened optional chain reads as always-true to the lint. */
|
|
68
|
+
const mmWin = win as { matchMedia?: typeof window.matchMedia } | null;
|
|
69
|
+
if (mmWin?.matchMedia?.("(hover: none)").matches) {
|
|
70
|
+
return {
|
|
71
|
+
destroy(): void {
|
|
72
|
+
release(doc.documentElement, "link-tip");
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tip = doc.createElement("div");
|
|
78
|
+
tip.className = "link-tip";
|
|
79
|
+
/* Presentation only: it duplicates the href or title assistive tech
|
|
80
|
+
already reads from the link itself. */
|
|
81
|
+
tip.setAttribute("aria-hidden", "true");
|
|
82
|
+
tip.hidden = true;
|
|
83
|
+
doc.body.append(tip);
|
|
84
|
+
|
|
85
|
+
const hide = (): void => {
|
|
86
|
+
tip.hidden = true;
|
|
87
|
+
tip.classList.remove("is-open");
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const linkFor = (t: EventTarget | null): HTMLElement | null => {
|
|
91
|
+
if (!(t instanceof Element)) return null;
|
|
92
|
+
const a = t.closest(selector);
|
|
93
|
+
return a instanceof HTMLElement ? a : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const show = (a: HTMLElement): void => {
|
|
97
|
+
const text = a.getAttribute("data-tooltip") ?? a.getAttribute("href") ?? "";
|
|
98
|
+
if (!text) return;
|
|
99
|
+
tip.textContent = text;
|
|
100
|
+
tip.classList.remove("is-open");
|
|
101
|
+
tip.hidden = false;
|
|
102
|
+
/* Wrapped links anchor at the first fragment (owner decision
|
|
103
|
+
2026-09-02); jsdom returns no fragments, hence the fallback. */
|
|
104
|
+
const rects = a.getClientRects();
|
|
105
|
+
const anchor = rects.length > 0 ? rects[0] : a.getBoundingClientRect();
|
|
106
|
+
/* This measurement also flushes layout, so the .is-open add below
|
|
107
|
+
lands a frame after un-hiding and the opacity fade plays. */
|
|
108
|
+
const box = tip.getBoundingClientRect();
|
|
109
|
+
const place = a.closest('[data-tip-place="below"]') ? "below" : "above";
|
|
110
|
+
const align = a.closest('[data-tip-align="end"]') ? "end" : "start";
|
|
111
|
+
const viewport = { width: doc.documentElement.clientWidth, height: doc.documentElement.clientHeight };
|
|
112
|
+
const pos = placeTip(anchor, box, viewport, place, align, edge);
|
|
113
|
+
tip.style.left = `${String(pos.x)}px`;
|
|
114
|
+
tip.style.top = `${String(pos.y)}px`;
|
|
115
|
+
tip.classList.add("is-open");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const onOver = (ev: Event): void => {
|
|
119
|
+
const a = linkFor(ev.target);
|
|
120
|
+
if (a) show(a);
|
|
121
|
+
};
|
|
122
|
+
const onOut = (ev: Event): void => {
|
|
123
|
+
if (linkFor(ev.target)) hide();
|
|
124
|
+
};
|
|
125
|
+
const onKey = (ev: KeyboardEvent): void => {
|
|
126
|
+
if (ev.key === "Escape") hide();
|
|
127
|
+
};
|
|
128
|
+
const onHide = (): void => {
|
|
129
|
+
hide();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
doc.addEventListener("mouseover", onOver);
|
|
133
|
+
doc.addEventListener("focusin", onOver);
|
|
134
|
+
doc.addEventListener("mouseout", onOut);
|
|
135
|
+
doc.addEventListener("focusout", onOut);
|
|
136
|
+
/* capture: scroll does not bubble from inner scrollers */
|
|
137
|
+
doc.addEventListener("scroll", onHide, true);
|
|
138
|
+
win?.addEventListener("resize", onHide);
|
|
139
|
+
doc.addEventListener("keydown", onKey);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
destroy(): void {
|
|
143
|
+
doc.removeEventListener("mouseover", onOver);
|
|
144
|
+
doc.removeEventListener("focusin", onOver);
|
|
145
|
+
doc.removeEventListener("mouseout", onOut);
|
|
146
|
+
doc.removeEventListener("focusout", onOut);
|
|
147
|
+
doc.removeEventListener("scroll", onHide, true);
|
|
148
|
+
win?.removeEventListener("resize", onHide);
|
|
149
|
+
doc.removeEventListener("keydown", onKey);
|
|
150
|
+
tip.remove();
|
|
151
|
+
release(doc.documentElement, "link-tip");
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* Pure geometry for the path-player timeline; the canvas painter in
|
|
2
|
+
path-player.ts consumes these, jsdom tests never need a 2D context. */
|
|
3
|
+
|
|
4
|
+
export function normalizeSeries(values: number[]): { min: number; max: number; frac: (v: number) => number } {
|
|
5
|
+
const min = Math.min(...values);
|
|
6
|
+
const max = Math.max(...values);
|
|
7
|
+
return { min, max, frac: (v) => (max > min ? (v - min) / (max - min) : 0.5) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function playheadX(t: number, duration: number, width: number): number {
|
|
11
|
+
return (t / duration) * width;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatReadout(t: number, duration: number): string {
|
|
15
|
+
return `T+${t.toFixed(1).padStart(4, "0")} / ${duration.toFixed(1)}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function brightChannel(c: number): number {
|
|
19
|
+
return Math.min(255, Math.round((c / 0.15) * 255));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Which sample a pixel column shows: the timeline maps the sample array
|
|
23
|
+
across its width, so column px of width reads sample floor(px/width *
|
|
24
|
+
length), clamped to the array. The same map serves time (t across
|
|
25
|
+
duration) for the playhead's sample. */
|
|
26
|
+
export function columnIndex(px: number, width: number, length: number): number {
|
|
27
|
+
return Math.max(0, Math.min(length - 1, Math.floor((px / width) * length)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Vertical position of a normalized value inside a track: frac 0 sits
|
|
31
|
+
pad above the track's bottom edge, frac 1 sits pad below its top. */
|
|
32
|
+
export function seriesY(frac: number, top: number, height: number, pad: number): number {
|
|
33
|
+
return top + height - pad - frac * (height - 2 * pad);
|
|
34
|
+
}
|