import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ImagePeek, type ImagePeekHandle, type ImagePeekRow } from './ImagePeek'; /** One image of a document, for the lightbox's stepping (`images`). */ export interface LightboxImage { src: string; alt: string; /** The heading the image sits under ("Options"), for "2 of 4 · in “Options”" and the peek's rows. */ section?: string | null; } /** * The file name an image's src names: the `path` a host route carries * (`/api/image?path=…`), else the URL's last segment, else the alt text. */ export function lightboxImageName(image: Pick): string { try { const base = typeof window !== 'undefined' && /^https?:/.test(window.location.href) ? window.location.href : 'http://localhost/'; const url = new URL(image.src, base); const named = url.searchParams.get('path') ?? (url.protocol === 'data:' || url.protocol === 'blob:' ? '' : url.pathname); const last = named.split(/[\\/]/).filter(Boolean).pop(); if (last) return decodeURIComponent(last); } catch { // Not a URL: fall back to the alt text. } return image.alt || 'Image'; } /** * Every image a rendered document shows, in document order, each with the * heading it sits under: what the lightbox steps through (plan review and * annotate's light version of the Inbox's one viewer, approval * "inbox-image-viewer"). Only images that open the lightbox count * (`.cursor-zoom-in`). */ export function collectDocumentImages(container: ParentNode | null): LightboxImage[] { if (!container) return []; const out: LightboxImage[] = []; let section: string | null = null; for (const el of container.querySelectorAll('h1, h2, h3, h4, h5, h6, img')) { if (el.tagName !== 'IMG') { section = (el.textContent ?? '').trim() || section; continue; } const img = el as HTMLImageElement; if (!img.classList.contains('cursor-zoom-in')) continue; const src = img.getAttribute('src'); if (!src || out.some((i) => i.src === src)) continue; out.push({ src, alt: img.getAttribute('alt') ?? '', section }); } return out; } const ICON = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }; /** * Overlay for viewing one image enlarged. Shared by the markdown Viewer * (clicking an image in a document) and the raw-HTML surface (a link to a * local image inside the annotated page). * * The image is only ever shown through ``, so an SVG runs no script * and cannot reach the page. * * It is modal for the keyboard. Its keydown listener runs on the CAPTURE phase * at the window and stops every key there, so nothing behind it reacts: * Escape closes the lightbox before the HTML surface's Esc ladder sees it, * and Mod+Enter cannot submit a decision behind it. Focus moves to the * overlay when it opens, which also pulls focus out of an HTML page's iframe * (whose keys the parent would otherwise never see), and goes back to where * it was when it closes. * * With `images` (two or more) it is the light version of the Inbox's one * viewer: full screen, `2 of 4 · in “Options”`, ← → (and `]` `[`) through the * document's images, stopping at the ends, Actual size, and the same peek * from the pill under the image. No pen, no comments: there the person * annotates the document itself. Without `images` it is exactly the single * image overlay it always was. */ export const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void; /** The document's images in order; with two or more the lightbox steps through them. Additive: absent keeps the single image overlay. */ images?: readonly LightboxImage[]; }> = ({ src, alt, onClose, images }) => { const overlayRef = useRef(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const stepping = !!images && images.length > 1; const [index, setIndex] = useState(() => Math.max(0, images?.findIndex((i) => i.src === src) ?? 0)); const [fit, setFit] = useState<'fit' | 'actual'>('fit'); const [peekOpen, setPeekOpen] = useState(false); const [endNote, setEndNote] = useState(null); const [announced, setAnnounced] = useState(''); const peekRef = useRef(null); const pillRef = useRef(null); const endTimer = useRef | null>(null); const current = stepping ? images![index]! : { src, alt, section: null }; const name = lightboxImageName(current); const step = (dir: 1 | -1) => { if (!stepping) return; const next = index + dir; if (next < 0 || next >= images!.length) { const note = dir > 0 ? 'Last image in this document' : 'First image in this document'; setEndNote(note); setAnnounced(note); if (endTimer.current) clearTimeout(endTimer.current); endTimer.current = setTimeout(() => setEndNote(null), 1800); return; } go(next); }; const go = (next: number) => { setIndex(next); setFit('fit'); const image = images![next]!; setAnnounced(`${next + 1} of ${images!.length}, ${lightboxImageName(image)}${image.section ? `, in “${image.section}”` : ''}`); }; const stepRef = useRef(step); stepRef.current = step; const peekOpenRef = useRef(peekOpen); peekOpenRef.current = peekOpen; useEffect(() => () => { if (endTimer.current) clearTimeout(endTimer.current); }, []); useEffect(() => { const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; overlayRef.current?.focus({ preventScroll: true }); const handleKeyDown = (e: KeyboardEvent) => { // The peek owns the keys while it is open (it is inside this modal, so they reach it from here). if (peekOpenRef.current) { e.stopImmediatePropagation(); if (peekRef.current?.handleKey({ key: e.key, isComposing: e.isComposing })) e.preventDefault(); else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) e.preventDefault(); return; } // Tab stays native: the overlay's own controls take it. if (e.key === 'Tab') return; e.stopImmediatePropagation(); if (e.key === 'Escape' || (e.key === 'Enter' && (e.metaKey || e.ctrlKey))) { e.preventDefault(); } if (e.key === 'Escape') { onCloseRef.current(); return; } if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return; if (e.key === 'ArrowRight' || e.key === ']') { e.preventDefault(); stepRef.current(1); } else if (e.key === 'ArrowLeft' || e.key === '[') { e.preventDefault(); stepRef.current(-1); } else if (e.key === 'ArrowDown' && e.target === pillRef.current) { e.preventDefault(); setPeekOpen(true); } }; window.addEventListener('keydown', handleKeyDown, true); return () => { window.removeEventListener('keydown', handleKeyDown, true); if (previous && previous.isConnected) previous.focus({ preventScroll: true }); }; }, []); const rows = useMemo(() => { if (!stepping) return []; const out: ImagePeekRow[] = []; images!.forEach((image, i) => { const label = image.section ?? 'Document'; let row = out.at(-1); if (!row || row.label !== label) { row = { key: `row-${i}`, label, items: [] }; out.push(row); } row.items.push({ key: String(i), name: lightboxImageName(image), thumb: image.src }); }); return out; }, [stepping, images]); if (!stepping) { return (
{alt} e.stopPropagation()} /> {alt && (
{alt}
)}
); } const total = images!.length; const first = index === 0; const last = index === total - 1; const circle = 'flex h-11 w-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm hover:bg-muted disabled:opacity-40 aria-disabled:opacity-35'; return (
{index + 1} of {total} {current.section && ( in “{current.section}” )}
{current.alt}
{current.alt &&
{current.alt}
}
{peekOpen && (
{ setPeekOpen(false); go(Number(key)); pillRef.current?.focus({ preventScroll: true }); }} onClose={(reason) => { setPeekOpen(false); if (reason === 'key') pillRef.current?.focus({ preventScroll: true }); }} />
)}
{announced}
); };