Skip to content

Commit 44f78a1

Browse files
Brian WestphalBrian Westphal
authored andcommitted
Ground-truth Expected/Actual labels + named source list, lightbox zoom/pan, and ground-truth P3 design (GB-965, GB-963, GB-962)
Ground-truth UX polish (GB-965): the side-by-side comparison panes now read "Expected (A)" / "Actual (B)" instead of the generic Old/New in ground-truth mode, and the sidebar renders a flat list of named comparisons (manifest label with a basename fallback, plus a Spec/Reference/Baseline expectedKind badge, most-different-first) rather than an actual/ file tree. New pure helper src/ground-truth/presentation.ts (groundTruthSideLabels, groundTruthMetaByFileId, EXPECTED_KIND_LABELS) shared by the diff page and the /files route; the response carries an optional per-file groundTruth map that the client stores on reviewStore. Threaded sideLabels through ImageDiff and DiffView. Documented as FR-26.7 / FR-26.8. Lightbox zoom/pan (GB-963): the shared lightbox now zooms (wheel / pinch / on-screen +- controls, up to 8x) and pans (drag a zoomed image, or wheel), so a reviewer can mark a precise region on a large artifact; note-artifact and attachment image previews get this for free since they share the component. Zoom/pan math lives in the pure, unit-tested src/client/lightboxZoom.ts; region fractions stay correct under any transform because they are read from the overlay's live post-transform bounding rect. Restructured the lightbox DOM to a clipping viewport around the transformed frame. Documented as FR-25.7. Image-file-diff lightbox adoption is filed as GB-966. Ground-truth P3 design (GB-962, design only): documented sets / multi-step flows in doc 26 - a version:2 additive manifest with ordered sets/steps and per-step navigation, per-step plus max-aggregate perceptual scoring, a consumer-only capture contract (Glassbox runs no capturer and takes no domotion dependency) with proof-export guidance for projects, and external baseline rotation. Implementation is tracked in phased follow-ups GB-967 (loader/model), GB-968 (UI), GB-969 (guidance doc/skill), and GB-970 (optional promote helper). Tests: new unit suites tests/unit/ground-truth/presentation.test.ts and tests/unit/client/lightboxZoom.test.ts; e2e assertions added in tests/e2e/ground-truth.test.ts (label + expectedKind badge, Expected/Actual pane captions) and tests/e2e/diff-viewing.test.ts (lightbox zoom controls). Full unit suite and the ground-truth + lightbox e2e tests pass; typecheck and lint clean.
1 parent 8cc5687 commit 44f78a1

22 files changed

Lines changed: 852 additions & 58 deletions

docs/20-ai-review-notes.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ Authoring shall support a combined live-plus-coalesce flow (not either/or):
200200
served by `GET /api/review-notes/artifact` (path-contained, content-typed,
201201
size-capped). The reviewer can **mark a rectangle on an image artifact** two
202202
ways: **inline** — drag directly on the thumbnail (a plain click instead opens
203-
the full-screen lightbox) — or in the **lightbox** itself. Either way the
203+
the full-screen lightbox, which can be **zoomed/panned** to mark a precise spot
204+
on a large artifact — doc 25 FR-25.7 / GB-963) — or in the **lightbox** itself.
205+
Either way the
204206
rectangle is carried into the reply they then write, and a reply can carry
205207
**several** marks (GB-959): the reply renders each artifact with its
206208
rectangle(s) over it (a "see this spot" thumbnail). Each region reuses doc 23's

docs/25-attachments.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ for which UI surfaces are wired today.
3131
when clicked or activated with **Space**, shall open in a full-screen in-app
3232
overlay (lightbox) — instant, stays in the app, and works in any browser.
3333
Esc, Space, or a backdrop click dismisses it.
34+
- **FR-25.7 — Zoom/pan in the lightbox (shipped, GB-963).** An image in the
35+
lightbox can be **zoomed** (wheel / pinch / the on-screen +/− controls, up to
36+
8×) and **panned** (drag a zoomed image, or two-finger / wheel), so a reviewer
37+
can inspect — and, on a markable artifact, precisely mark — a region on a large
38+
image. The zoom/pan math is pure + unit-tested (`src/client/lightboxZoom.ts`);
39+
region fractions stay correct under any transform because they're read from the
40+
overlay's live post-transform bounding rect. PDFs are not zoomed (native
41+
iframe). The lightbox (`src/client/lightbox.tsx`) is shared by attachment
42+
previews and AI-note image artifacts (doc 20 §20.5).
3443
- **FR-25.5 — OS opener for other types.** A non-previewable attachment shall
3544
open in the OS default application: macOS **Quick Look** (`qlmanage -p`), the
3645
platform default opener on Windows (`start`) and Linux (`xdg-open`). Because

docs/26-ground-truth-comparison.md

Lines changed: 197 additions & 17 deletions
Large diffs are not rendered by default.

docs/ai/code-summary.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ See §6 for the schema itself.
140140
| `types.ts` | `ReviewMode` (incl. the doc-18 `diff` + doc-26 `ground-truth` variants), `GroundTruthEntry`, `FileDiff`, `DiffHunk`, `DiffLine`. |
141141
| `../ground-truth/manifest.ts` | doc 26 — `loadGroundTruthManifest(path)`: read/validate (`version:1` + `comparisons[]`), resolve actual/expected paths relative to the manifest dir, image-only check, dedup keys. |
142142
| `../ground-truth/perceptual-diff.ts` | doc 26 P2 — `comparePerceptual(actual, expected)`: decode PNG/JPEG to RGBA (`pngjs`/`jpeg-js`) + `pixelmatch` (YIQ, threshold 0.1, AA-tolerant) → difference score (fraction of changed pixels); `dimension-mismatch`→1, SVG/WebP/corrupt→`undecodable`. Score computed at launch + stored on `review_files.difference_score`. Format helpers in `src/utils/diffScore.ts`. |
143+
| `../ground-truth/presentation.ts` | doc 26 §26.1 polish — pure helpers shared by the diff page + `/files` route. `groundTruthSideLabels(mode)` → "Expected (A)" / "Actual (B)" pane captions (via `ImageDiff.sideLabels` / `DiffView.imageSideLabels`); `groundTruthMetaByFileId(mode, files)``fileId → {label, expectedKind}` map the `/files` response carries for ground-truth reviews (drives the flat named-comparison sidebar list, `groundTruthListJsx`); `EXPECTED_KIND_LABELS` (Spec / Reference / Baseline). |
143144
| `image.ts` | Image side retrieval from git (old/new), binary detection, format identification. |
144145
| `image-metadata.ts` | Parse image headers (PNG IHDR, JPEG SOF/JFIF, GIF, WebP VP8/VP8L/VP8X) — no native deps. |
145146
| `svg-meta.ts` | Pure SVG metadata helpers for the "Rendered" view: `parseSvgDimensions` (base size from width/height/viewBox) and `svgUsesExternalFonts` (font-caveat banner trigger). No rendering. SVGs are rendered live in the browser (GB-932): the image route serves them as raw `image/svg+xml` and the diff shows them in a native `<img>`, so animated SVGs animate. The old `@resvg/resvg-wasm` rasterizer + worker thread were removed. |
@@ -703,7 +704,7 @@ two documents intentionally overlap.
703704
| Change the DB schema | `src/db/schema.ts` (tables/indexes) + a migration in `src/db/connection.ts` (`addColumnIfMissing` pattern). Query code in `src/db/queries.ts` or `ai-queries.ts`. |
704705
| Add an annotation category | `src/client/state.ts` (CATEGORIES), `src/client/annotations/categories.tsx` (UI), `src/routes/api/annotations.ts` (`VALID_CATEGORIES`), `src/export/generate.ts` (export semantics). Update `docs/5-annotations.md` + `docs/6-export.md`. |
705706
| Work on feedback attachments | `src/client/annotations/attachments.tsx` (chips/upload/drag-drop/paste/preview), `src/routes/api/attachments.ts` + `src/api/attachments.ts` (API), `src/db/attachment-queries.ts` + `src/attachments/store.ts` (storage), `src/utils/openOS.ts` (`'quicklook'`), `src/export/generate.ts` (export paths). Doc 25. |
706-
| Work on the image lightbox / note-artifact regions | `src/client/lightbox.tsx` (shared full-screen lightbox + region draw), `src/client/diff/noteArtifactRegions.tsx` (inline drag-to-draw on the thumbnail **or** click → lightbox; accumulates pending regions per note), `src/utils/artifactRegions.ts` (pure decode/group of `region_data` arrays), `src/components/reviewNoteRegionThumb.tsx` (the marked-region thumbnail(s) on a reply). Regions carried into the reply via `createAnnotation`'s `regions` array (→ `region_data` JSON array with `artifact` uris; single object still read for back-compat). Doc 25 / GB-953, GB-959. |
707+
| Work on the image lightbox / note-artifact regions | `src/client/lightbox.tsx` (shared full-screen lightbox + zoom/pan + region draw; zoom/pan math in the pure `src/client/lightboxZoom.ts`, GB-963), `src/client/diff/noteArtifactRegions.tsx` (inline drag-to-draw on the thumbnail **or** click → lightbox; accumulates pending regions per note), `src/utils/artifactRegions.ts` (pure decode/group of `region_data` arrays), `src/components/reviewNoteRegionThumb.tsx` (the marked-region thumbnail(s) on a reply). Regions carried into the reply via `createAnnotation`'s `regions` array (→ `region_data` JSON array with `artifact` uris; single object still read for back-compat). Doc 25 / GB-953, GB-959. |
707708
| Add a CLI option | `src/cli.ts` `parseArgs()` switch; document in `docs/2-cli-and-server.md`. |
708709
| Add an AI platform | `src/ai/models.ts` (platform enum + fallback models + env key; `KEYLESS_PLATFORMS` if no key needed), `src/ai/list-models.ts` (live-discovery fetch+map), `src/ai/client.ts` (HTTP/spawn dispatch), `src/ai/config.ts` (any per-platform config like a base URL), `src/ai/api-keys.ts` (key source mapping), `src/routes/ai-config.ts` + `src/api/ai.ts` (config/discovery/key-status wiring), `src/client/settings/experimentalTab.tsx` + `dialog.tsx` (picker + any platform-specific inputs). The `local` platform (doc 22) is the worked example of a keyless, base-URL-configured provider. Update `docs/7-ai-analysis.md`. |
709710
| Update / discover AI models | Models are discovered live per provider in `src/ai/list-models.ts` (used by `GET /api/ai/models`); `src/ai/models.ts` holds the static fallback + `resolveModelId` old→new mapping. |

docs/ai/requirements-summary.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,20 @@ pair is (fraction of changed pixels, anti-aliasing-tolerant); identical pairs ar
163163
hidden by default (toggle to show), the score shows per source-list entry + in
164164
the diff header, and the list sorts most-different-first. Score stored on
165165
`review_files.difference_score`. PNG/JPEG only; other formats show with no score.
166-
**P3** (sets/flows + capture pipeline incl. domotion animated SVGs; baseline
167-
rotation) remains design-only — phased follow-up ticket.
166+
**UX polish** (shipped, FR-26.7/26.8): in ground-truth mode the side-by-side panes
167+
read **Expected (A)** / **Actual (B)**, and the sidebar is a **flat list of named
168+
comparisons** — each row captioned by the manifest `label` (basename fallback)
169+
with a **Spec / Reference / Baseline** `expectedKind` badge, most-different-first
170+
(`src/ground-truth/presentation.ts`). **P3** (sets/multi-step flows +
171+
capture-pipeline guidance + baseline rotation) is now **fully designed** (doc 26
172+
§26.2/§26.3/§26.5): a `version: 2` additive manifest with ordered `sets`/`steps`,
173+
source-list **set groups** with per-step rows + max-aggregate perceptual score +
174+
a per-step Prev/Next navigator, a **consumer-only** capture contract (Glassbox
175+
runs no capturer / takes no domotion dependency) plus **proof-export guidance**
176+
for projects (screenshots, logs-as-note-artifacts, animated-SVG flows), and
177+
**external** baseline rotation. Implementation is **design-only** — tracked in
178+
phased follow-up tickets (P3a loader/model, P3b UI, P3c guidance doc/skill, P3d
179+
optional `promote` helper).
168180

169181
**Image feedback** (doc 23): a panel below the image canvas lets the
170182
reviewer add general comments about an image and draw rectangle regions

src/api/files.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,25 @@ import { apiCall, OkResponseSchema } from './_runner.js';
99
export const FileStatusSchema = z.enum(['pending', 'reviewed']);
1010
export type FileStatus = z.infer<typeof FileStatusSchema>;
1111

12+
/**
13+
* Per-file ground-truth presentation metadata (doc 26 §26.1). Present only for
14+
* ground-truth reviews; lets the sidebar read like a list of named comparisons
15+
* (label + what the expected image represents) rather than a raw `actual/` file
16+
* tree. An entry exists for every ground-truth file even when both fields are
17+
* absent, so the map's keys also signal "this is a ground-truth review".
18+
*/
19+
export const GroundTruthMetaSchema = z.object({
20+
label: z.string().optional(),
21+
expectedKind: z.enum(['spec', 'reference', 'previous-actual']).optional(),
22+
});
23+
export type GroundTruthMeta = z.infer<typeof GroundTruthMetaSchema>;
24+
1225
export const ListFilesRespSchema = z.object({
1326
files: z.array(ReviewFileSchema),
1427
annotationCounts: z.record(z.string(), z.number()),
1528
staleCounts: z.record(z.string(), z.number()),
29+
/** Keyed by review-file id; omitted/empty for non-ground-truth reviews. */
30+
groundTruth: z.record(z.string(), GroundTruthMetaSchema).optional(),
1631
});
1732
export type ListFilesResp = z.infer<typeof ListFilesRespSchema>;
1833

src/client/lightbox.tsx

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,25 @@ import type { ImageRegion } from '../api/index.js';
22
import { IconX } from '../icons.js';
33
import { clientToFraction, isDrawnRegion, rectFromPoints, regionStyle } from './diff/imageDiff/regionGeometry.js';
44
import { toElement } from './dom.js';
5+
import {
6+
clampPan,
7+
LIGHTBOX_MIN_ZOOM,
8+
type LightboxZoomState,
9+
resetZoom,
10+
zoomTowardCursor,
11+
} from './lightboxZoom.js';
512

613
/**
714
* Shared full-screen lightbox (doc 25 / GB-953). Used to preview note image
8-
* artifacts and, optionally, to drag a rectangle on the image to mark a region
9-
* (normalized `{x,y,w,h}`, doc 23's model). One instance at a time; Esc, the
10-
* close button, or a backdrop click dismisses it.
15+
* artifacts and attachment images and, optionally, to drag a rectangle on the
16+
* image to mark a region (normalized `{x,y,w,h}`, doc 23's model). One instance
17+
* at a time; Esc, the close button, or a backdrop click dismisses it.
1118
*
12-
* Built to be reusable: attachment image previews and image-file diffs can adopt
13-
* it later. The region-draw layer is opt-in via `onRegion`.
19+
* Images support zoom (wheel / pinch / the +/− controls) and pan (drag when not
20+
* drawing a region, or wheel/two-finger) so a reviewer can mark a precise region
21+
* on a large artifact (GB-963). Region fractions stay correct under any transform
22+
* because they're read from the overlay's live (post-transform) bounding rect.
23+
* The region-draw layer is opt-in via `onRegion`.
1424
*/
1525

1626
interface LightboxOptions {
@@ -25,6 +35,7 @@ interface LightboxOptions {
2535

2636
let openEl: HTMLElement | null = null;
2737
let escHandler: ((e: KeyboardEvent) => void) | null = null;
38+
const ZOOM_STEP = 1.25;
2839

2940
export function closeLightbox(): void {
3041
if (openEl === null) return;
@@ -42,7 +53,8 @@ export function isLightboxOpen(): boolean {
4253

4354
export function openLightbox(opts: LightboxOptions): void {
4455
closeLightbox();
45-
const drawable = opts.kind !== 'pdf' && opts.onRegion !== undefined;
56+
const isImage = opts.kind !== 'pdf';
57+
const drawable = isImage && opts.onRegion !== undefined;
4658

4759
const media = opts.kind === 'pdf'
4860
? <iframe className="lightbox-pdf" src={opts.src} title={opts.alt ?? 'attachment'}></iframe>
@@ -51,10 +63,19 @@ export function openLightbox(opts: LightboxOptions): void {
5163
const overlay = toElement(
5264
<div className="lightbox-overlay" role="dialog" aria-label={opts.alt ?? 'Preview'}>
5365
<button className="lightbox-close" aria-label="Close"><IconX /></button>
54-
<div className={`lightbox-frame${drawable ? ' lightbox-drawable' : ''}`}>
55-
{media}
56-
<div className="lightbox-region-overlay"></div>
66+
<div className="lightbox-viewport">
67+
<div className={`lightbox-frame${drawable ? ' lightbox-drawable' : ''}`}>
68+
{media}
69+
<div className="lightbox-region-overlay"></div>
70+
</div>
5771
</div>
72+
{isImage ? (
73+
<div className="lightbox-controls" role="group" aria-label="Zoom">
74+
<button className="lightbox-zoom-btn" data-zoom="out" aria-label="Zoom out">&minus;</button>
75+
<button className="lightbox-zoom-btn lightbox-zoom-level" data-zoom="reset" aria-label="Reset zoom">100%</button>
76+
<button className="lightbox-zoom-btn" data-zoom="in" aria-label="Zoom in">+</button>
77+
</div>
78+
) : null}
5879
{drawable ? <div className="lightbox-hint">Drag a rectangle to mark a region for your reply</div> : null}
5980
</div>,
6081
);
@@ -65,6 +86,9 @@ export function openLightbox(opts: LightboxOptions): void {
6586
});
6687
overlay.querySelector('.lightbox-close')?.addEventListener('click', () => { closeLightbox(); });
6788

89+
if (isImage) {
90+
wireZoomPan(overlay, drawable);
91+
}
6892
if (drawable && opts.onRegion !== undefined) {
6993
wireRegionDraw(overlay, opts.onRegion);
7094
}
@@ -78,6 +102,80 @@ export function openLightbox(opts: LightboxOptions): void {
78102
openEl = overlay;
79103
}
80104

105+
/** Zoom (wheel / pinch / +−) and pan (drag when not drawing, or wheel) on the
106+
* image frame, clipped to the viewport. Region fractions stay accurate because
107+
* `wireRegionDraw` reads the overlay's live post-transform rect. */
108+
function wireZoomPan(overlay: HTMLElement, drawable: boolean): void {
109+
const viewport = overlay.querySelector<HTMLElement>('.lightbox-viewport');
110+
const frame = overlay.querySelector<HTMLElement>('.lightbox-frame');
111+
const level = overlay.querySelector<HTMLElement>('.lightbox-zoom-level');
112+
if (viewport === null || frame === null) return;
113+
114+
let state: LightboxZoomState = resetZoom();
115+
116+
const apply = (): void => {
117+
state = clampPan(state, frame.offsetWidth, frame.offsetHeight, viewport.clientWidth, viewport.clientHeight);
118+
frame.style.transform = state.zoom <= LIGHTBOX_MIN_ZOOM
119+
? ''
120+
: `translate(${String(state.panX)}px, ${String(state.panY)}px) scale(${String(state.zoom)})`;
121+
overlay.classList.toggle('lightbox-zoomed', state.zoom > LIGHTBOX_MIN_ZOOM);
122+
if (level !== null) level.textContent = `${String(Math.round(state.zoom * 100))}%`;
123+
};
124+
125+
const zoomBy = (factor: number, clientX: number, clientY: number): void => {
126+
state = zoomTowardCursor(state, viewport.getBoundingClientRect(), clientX, clientY, factor);
127+
apply();
128+
};
129+
130+
viewport.addEventListener('wheel', (e: WheelEvent) => {
131+
e.preventDefault();
132+
// ctrlKey is a pinch gesture (or Ctrl+wheel) → zoom; a plain wheel pans when
133+
// zoomed in. Mirrors the image-diff canvas split (GB-942).
134+
if (e.ctrlKey) {
135+
zoomBy(e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, e.clientX, e.clientY);
136+
} else if (state.zoom > LIGHTBOX_MIN_ZOOM) {
137+
state = { zoom: state.zoom, panX: state.panX - e.deltaX, panY: state.panY - e.deltaY };
138+
apply();
139+
}
140+
}, { passive: false });
141+
142+
viewport.addEventListener('mousedown', (e: MouseEvent) => {
143+
if (e.button !== 0) return;
144+
// A click on the empty margin around the image closes (the viewport fills
145+
// most of the screen now, so the backdrop is mostly this element).
146+
if (e.target === viewport && state.zoom <= LIGHTBOX_MIN_ZOOM) { closeLightbox(); return; }
147+
// Region-draw mode owns frame drags; otherwise a drag pans a zoomed image.
148+
if (drawable || state.zoom <= LIGHTBOX_MIN_ZOOM) return;
149+
e.preventDefault();
150+
overlay.classList.add('lightbox-panning');
151+
const startX = e.clientX - state.panX;
152+
const startY = e.clientY - state.panY;
153+
const onMove = (ev: MouseEvent): void => {
154+
state = { zoom: state.zoom, panX: ev.clientX - startX, panY: ev.clientY - startY };
155+
apply();
156+
};
157+
const onUp = (): void => {
158+
document.removeEventListener('mousemove', onMove);
159+
document.removeEventListener('mouseup', onUp);
160+
overlay.classList.remove('lightbox-panning');
161+
};
162+
document.addEventListener('mousemove', onMove);
163+
document.addEventListener('mouseup', onUp);
164+
});
165+
166+
overlay.querySelectorAll<HTMLElement>('.lightbox-zoom-btn').forEach((btn) => {
167+
btn.addEventListener('click', () => {
168+
const rect = viewport.getBoundingClientRect();
169+
const cx = rect.left + rect.width / 2;
170+
const cy = rect.top + rect.height / 2;
171+
const kind = btn.dataset.zoom;
172+
if (kind === 'in') zoomBy(ZOOM_STEP, cx, cy);
173+
else if (kind === 'out') zoomBy(1 / ZOOM_STEP, cx, cy);
174+
else { state = resetZoom(); apply(); }
175+
});
176+
});
177+
}
178+
81179
/** Drag-to-draw a single rectangle over the lightbox image; on a valid release,
82180
* hand the normalized region to `onRegion` and close. */
83181
function wireRegionDraw(overlay: HTMLElement, onRegion: (region: ImageRegion) => void): void {

src/client/lightboxZoom.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Pure zoom/pan geometry for the shared lightbox (GB-963). Kept DOM-free so the
3+
* math is unit-tested; `lightbox.tsx` does the imperative wiring.
4+
*
5+
* The model: an image-sized frame is centered in a fixed viewport and carries a
6+
* `translate(panX,panY) scale(zoom)` transform (transform-origin: center). Zoom
7+
* is anchored at the cursor; pan is clamped so a zoomed image can't be dragged
8+
* fully out of the viewport (and a not-larger-than-viewport image stays centered).
9+
*/
10+
11+
export interface LightboxZoomState {
12+
zoom: number;
13+
panX: number;
14+
panY: number;
15+
}
16+
17+
export const LIGHTBOX_MIN_ZOOM = 1;
18+
export const LIGHTBOX_MAX_ZOOM = 8;
19+
20+
export function clampZoom(z: number): number {
21+
return Math.max(LIGHTBOX_MIN_ZOOM, Math.min(LIGHTBOX_MAX_ZOOM, z));
22+
}
23+
24+
interface Rect { left: number; top: number; width: number; height: number }
25+
26+
/**
27+
* Multiply the zoom by `factor`, keeping the content point under the cursor
28+
* fixed on screen. Pan is *not* clamped here — call `clampPan` afterwards with
29+
* the resolved content/viewport sizes.
30+
*/
31+
export function zoomTowardCursor(
32+
state: LightboxZoomState,
33+
viewport: Rect,
34+
clientX: number,
35+
clientY: number,
36+
factor: number,
37+
): LightboxZoomState {
38+
const z0 = state.zoom;
39+
const z1 = clampZoom(z0 * factor);
40+
if (z1 === z0) return { ...state };
41+
const cx = viewport.left + viewport.width / 2;
42+
const cy = viewport.top + viewport.height / 2;
43+
// Vector from the (panned) frame center to the cursor, in screen px.
44+
const dx = clientX - cx - state.panX;
45+
const dy = clientY - cy - state.panY;
46+
const ratio = z1 / z0;
47+
return {
48+
zoom: z1,
49+
panX: state.panX + dx * (1 - ratio),
50+
panY: state.panY + dy * (1 - ratio),
51+
};
52+
}
53+
54+
/**
55+
* Clamp pan so the scaled content keeps covering the viewport when it's larger,
56+
* and snaps to centered (pan 0) on an axis where it isn't. `contentW/H` are the
57+
* frame's base (zoom-1) size; the function applies the zoom itself.
58+
*/
59+
export function clampPan(
60+
state: LightboxZoomState,
61+
contentW: number,
62+
contentH: number,
63+
viewW: number,
64+
viewH: number,
65+
): LightboxZoomState {
66+
const scaledW = contentW * state.zoom;
67+
const scaledH = contentH * state.zoom;
68+
const allowedX = Math.max(0, (scaledW - viewW) / 2);
69+
const allowedY = Math.max(0, (scaledH - viewH) / 2);
70+
return {
71+
zoom: state.zoom,
72+
panX: Math.max(-allowedX, Math.min(allowedX, state.panX)),
73+
panY: Math.max(-allowedY, Math.min(allowedY, state.panY)),
74+
};
75+
}
76+
77+
/** The reset / fit state. */
78+
export function resetZoom(): LightboxZoomState {
79+
return { zoom: 1, panX: 0, panY: 0 };
80+
}

0 commit comments

Comments
 (0)