// Enlarge a diagram/image in a full-screen overlay with pan + zoom. Dense diagrams (a wide
// ER model, a condensed flowchart) are unreadable fit-to-screen, so the stage is a viewport
// the content pans/zooms inside: wheel/pinch to zoom-to-cursor, drag to pan, double-click to
// zoom in, +/−/0/arrows on the keyboard, and a − % + ⤢ control pill. Vector SVG stays crisp
// under the transform; raster images zoom to inspect. Reuses the shell overlay machinery
// (scrim, Escape, scroll-lock, inert). The lightbox shows a *clone*, so comment anchoring on
// the original block is untouched. All affine math lives in the pure lib/pan-zoom module.
import { type Size, fitScale, initialView, panBy, zoomAround, zoomBounds } from "../pan-zoom";
import { closeOverlay, currentOverlay, openOverlay } from "./shell";
export interface LightboxLabels {
close: string;
zoomIn: string;
zoomOut: string;
zoomReset: string;
}
const DEFAULT_LABELS: LightboxLabels = {
close: "Close",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
zoomReset: "Reset zoom",
};
interface Controller {
destroy(): void;
zoomIn(): void;
zoomOut(): void;
reset(): void;
}
let el: HTMLElement | null = null;
let stage: HTMLElement;
let content: HTMLElement;
let pct: HTMLElement;
let active: Controller | null = null;
function ensureEl(labels: LightboxLabels): HTMLElement {
if (el) return el;
const box = document.createElement("div");
box.id = "nb-lightbox";
box.className = "nb-lightbox";
box.hidden = true;
box.innerHTML =
`` +
'
' +
'
' +
`` +
'100%' +
`` +
`` +
"
";
stage = box.querySelector(".nb-lightbox-stage") as HTMLElement;
content = box.querySelector(".nb-lightbox-content") as HTMLElement;
pct = box.querySelector(".nb-lightbox-pct") as HTMLElement;
const doClose = () => {
if (currentOverlay() === "lightbox") closeOverlay();
else box.hidden = true;
};
box.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest("button[data-z]") as HTMLElement | null;
if (!btn) {
if (e.target === box) doClose(); // backdrop margin closes; the stage is for panning
return;
}
const z = btn.dataset.z;
if (z === "close") doClose();
else if (z === "in") active?.zoomIn();
else if (z === "out") active?.zoomOut();
else if (z === "reset") active?.reset();
});
document.body.appendChild(box);
el = box;
return box;
}
/** Wire pointer/wheel/keyboard on `stage`, driving `content`'s transform via pan-zoom math. */
function makeController(nat: Size, allowUpscale: boolean): Controller {
const vp = (): Size => ({ w: stage.clientWidth, h: stage.clientHeight });
let bounds = zoomBounds(fitScale(nat, vp()));
let view = initialView(nat, vp(), allowUpscale);
const pointers = new Map();
let panId: number | null = null;
let lastX = 0;
let lastY = 0;
let pinchDist = 0;
const apply = () => {
content.style.transform = `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`;
pct.textContent = `${Math.round(view.scale * 100)}%`;
};
const at = (clientX: number, clientY: number): [number, number] => {
const r = stage.getBoundingClientRect();
return [clientX - r.left, clientY - r.top];
};
const zoomAt = (factor: number, px: number, py: number) => {
view = zoomAround(view, factor, px, py, bounds.min, bounds.max);
apply();
};
const onWheel = (e: WheelEvent) => {
e.preventDefault(); // zoom instead of scrolling the (locked) page
const [px, py] = at(e.clientX, e.clientY);
zoomAt(Math.exp(-e.deltaY * 0.0015), px, py);
};
const onDown = (e: PointerEvent) => {
stage.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size >= 2) {
panId = null;
const [a, b] = [...pointers.values()];
pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
} else {
panId = e.pointerId;
lastX = e.clientX;
lastY = e.clientY;
stage.classList.add("nb-grabbing");
}
};
const onMove = (e: PointerEvent) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size >= 2) {
const [a, b] = [...pointers.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchDist > 0) {
const [mx, my] = at((a.x + b.x) / 2, (a.y + b.y) / 2);
zoomAt(d / pinchDist, mx, my);
}
pinchDist = d;
} else if (e.pointerId === panId) {
view = panBy(view, e.clientX - lastX, e.clientY - lastY);
lastX = e.clientX;
lastY = e.clientY;
apply();
}
};
const onUp = (e: PointerEvent) => {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinchDist = 0;
if (pointers.size === 1) {
// a finger lifted mid-pinch → resume single-finger pan with the one that remains
const [[id, p]] = [...pointers];
panId = id;
lastX = p.x;
lastY = p.y;
} else if (pointers.size === 0) {
panId = null;
stage.classList.remove("nb-grabbing");
}
};
const onDbl = (e: MouseEvent) => {
const [px, py] = at(e.clientX, e.clientY);
zoomAt(1.8, px, py);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "+" || e.key === "=") zoomCenter(1.25);
else if (e.key === "-" || e.key === "_") zoomCenter(0.8);
else if (e.key === "0") reset();
else if (e.key === "ArrowLeft") view = panBy(view, 60, 0);
else if (e.key === "ArrowRight") view = panBy(view, -60, 0);
else if (e.key === "ArrowUp") view = panBy(view, 0, 60);
else if (e.key === "ArrowDown") view = panBy(view, 0, -60);
else return;
e.preventDefault();
apply();
};
const zoomCenter = (factor: number) => {
const v = vp();
zoomAt(factor, v.w / 2, v.h / 2);
};
const reset = () => {
bounds = zoomBounds(fitScale(nat, vp()));
view = initialView(nat, vp(), allowUpscale);
apply();
};
const onResize = () => {
bounds = zoomBounds(fitScale(nat, vp()));
apply();
};
stage.addEventListener("wheel", onWheel, { passive: false });
stage.addEventListener("pointerdown", onDown);
stage.addEventListener("pointermove", onMove);
stage.addEventListener("pointerup", onUp);
stage.addEventListener("pointercancel", onUp);
stage.addEventListener("dblclick", onDbl);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize, { passive: true });
apply();
return {
zoomIn: () => zoomCenter(1.4),
zoomOut: () => zoomCenter(1 / 1.4),
reset,
destroy() {
stage.removeEventListener("wheel", onWheel);
stage.removeEventListener("pointerdown", onDown);
stage.removeEventListener("pointermove", onMove);
stage.removeEventListener("pointerup", onUp);
stage.removeEventListener("pointercancel", onUp);
stage.removeEventListener("dblclick", onDbl);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
stage.classList.remove("nb-grabbing");
},
};
}
/** Open the lightbox showing an enlarged, pan/zoomable copy of a commentable block (pre.mermaid / img). */
export function openLightbox(block: HTMLElement, labels: LightboxLabels = DEFAULT_LABELS): void {
const box = ensureEl(labels);
active?.destroy();
active = null;
content.innerHTML = "";
content.style.transform = "";
let nat: Size = { w: 0, h: 0 };
let allowUpscale = false;
if (block.tagName === "IMG") {
const src = block as HTMLImageElement;
const img = new Image();
img.src = src.currentSrc || src.src;
img.alt = src.alt || "";
img.draggable = false;
content.appendChild(img);
nat = { w: src.naturalWidth, h: src.naturalHeight };
// If the source's natural size isn't known yet, size + fit once the clone loads.
if (!nat.w || !nat.h)
img.addEventListener("load", () => start({ w: img.naturalWidth, h: img.naturalHeight }, false));
} else {
const svg = block.querySelector("svg") as SVGSVGElement | null;
if (svg) {
const clone = svg.cloneNode(true) as SVGSVGElement;
const vb = svg.viewBox?.baseVal;
const r = svg.getBoundingClientRect();
nat = { w: vb?.width || r.width || 300, h: vb?.height || r.height || 150 };
clone.removeAttribute("width");
clone.removeAttribute("height");
clone.style.maxWidth = "none";
clone.style.maxHeight = "none";
content.appendChild(clone);
allowUpscale = true; // vectors stay crisp; fill the stage
} else {
content.textContent = block.textContent || ""; // not rendered yet → show source
}
}
function start(size: Size, upscale: boolean): void {
active?.destroy();
content.style.width = `${size.w}px`;
content.style.height = `${size.h}px`;
active = makeController(size, upscale);
}
box.hidden = false;
// box.hidden just flipped display:none→flex; read clientWidth to force the reflow so the
// stage has real dimensions before we compute the fit (avoids a first-paint jump).
void stage.clientWidth;
start(nat, allowUpscale);
openOverlay("lightbox", box, () => {
box.hidden = true;
active?.destroy();
active = null;
content.innerHTML = "";
content.style.transform = "";
});
}