import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' import { CloseIcon } from '@/components/icons' const MIN_SCALE = 0.25 /** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */ const MIN_FIT_SCALE = 0.01 const MAX_SCALE = 8 const SCALE_STEP = 0.25 const BACKDROP_CLICK_MAX_MOVEMENT = 4 /** Edge margin when fitting to the device screen (not the inner panel only). */ const SCREEN_FIT_PADDING_PX = 12 export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } { const viewport = window.visualViewport const width = viewport ? viewport.width : window.innerWidth const fullHeight = viewport ? viewport.height : window.innerHeight const reserved = Math.max(0, reservedTopPx) return { width, height: Math.max(0, fullHeight - reserved) } } type Point = { x: number; y: number } function clampScale(value: number, minScale = MIN_SCALE): number { return Math.min(MAX_SCALE, Math.max(minScale, value)) } function getPointDistance(a: Point, b: Point): number { return Math.hypot(a.x - b.x, a.y - b.y) } function getPointCenter(a: Point, b: Point): Point { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, } } /** * Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform. * Order: viewBox -> width/height attrs -> bounding rect divided by current scale. */ export function measureSvgIntrinsicSize( svg: SVGSVGElement, currentScale = 1, ): { width: number; height: number } | null { const viewBox = svg.viewBox?.baseVal if (viewBox && viewBox.width > 0 && viewBox.height > 0) { return { width: viewBox.width, height: viewBox.height } } const widthAttr = svg.getAttribute('width') ?? '' const heightAttr = svg.getAttribute('height') ?? '' const parsedWidth = Number.parseFloat(widthAttr) const parsedHeight = Number.parseFloat(heightAttr) if (parsedWidth > 0 && parsedHeight > 0) { return { width: parsedWidth, height: parsedHeight } } const safeScale = currentScale > 0 ? currentScale : 1 const box = svg.getBoundingClientRect() if (box.width > 0 && box.height > 0) { return { width: box.width / safeScale, height: box.height / safeScale } } return null } /** * Measure rendered content size, ignoring any ancestor `scale(...)` transform. * Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the * bounding rect, which otherwise compounds with `scaleRef.current` on retry. */ export function measureContentSize( content: HTMLElement, currentScale = 1, ): { width: number; height: number } | null { const safeScale = currentScale > 0 ? currentScale : 1 const img = content.querySelector('img') if (img) { if (img.naturalWidth > 0 && img.naturalHeight > 0) { return { width: img.naturalWidth, height: img.naturalHeight } } const box = img.getBoundingClientRect() if (box.width > 0 && box.height > 0) { return { width: box.width / safeScale, height: box.height / safeScale } } } const svg = content.querySelector('svg') if (svg) { const intrinsic = measureSvgIntrinsicSize(svg, safeScale) if (intrinsic) return intrinsic } const rect = content.getBoundingClientRect() if (rect.width > 0 && rect.height > 0) { return { width: rect.width / safeScale, height: rect.height / safeScale } } return null } export type ZoomableLightboxProps = { open: boolean onClose: () => void title?: string ariaLabel: string children: ReactNode /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ fitContentKey?: string | number | null /** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */ fitContentSize?: { width: number; height: number } | null /** Compute initial scale to fill the device screen (default true). */ fitOnOpen?: boolean } export function ZoomableLightbox(props: ZoomableLightboxProps) { const { open, onClose, title, ariaLabel, children, fitContentKey = null, fitContentSize = null, fitOnOpen = true, } = props const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const [toolbarHeight, setToolbarHeight] = useState(0) const scaleRef = useRef(scale) const offsetRef = useRef(offset) const baseScaleRef = useRef(1) const viewportRef = useRef(null) const contentRef = useRef(null) const toolbarRef = useRef(null) const activePointersRef = useRef(new Map()) const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) const updateScale = useCallback((next: number | ((current: number) => number)) => { setScale((current) => { const value = typeof next === 'function' ? next(current) : next scaleRef.current = value return value }) }, []) const updateOffset = useCallback((next: Point) => { offsetRef.current = next setOffset(next) }, []) const applyFitScale = useCallback(() => { if (!fitOnOpen) { baseScaleRef.current = 1 updateScale(1) updateOffset({ x: 0, y: 0 }) return } const content = contentRef.current if (!content) return const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) if (!contentSize) return const screen = getScreenFitSize(toolbarHeight) const pad = SCREEN_FIT_PADDING_PX * 2 const fitWidth = (screen.width - pad) / contentSize.width const fitHeight = (screen.height - pad) / contentSize.height const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE) baseScaleRef.current = fitScale updateScale(fitScale) updateOffset({ x: 0, y: 0 }) }, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale]) const resetView = useCallback(() => { updateScale(baseScaleRef.current) updateOffset({ x: 0, y: 0 }) }, [updateOffset, updateScale]) const closeViewer = useCallback(() => { onClose() activePointersRef.current.clear() dragRef.current = null pinchRef.current = null backdropPressRef.current = null baseScaleRef.current = 1 updateScale(1) updateOffset({ x: 0, y: 0 }) }, [onClose, updateOffset, updateScale]) /** * Lower bound for interactive zoom. Carries the fit floor when the diagram * was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE. */ const getMinInteractiveScale = useCallback(() => { return Math.min(MIN_SCALE, baseScaleRef.current) }, []) const zoomBy = useCallback((delta: number) => { updateScale((current) => clampScale(current + delta, getMinInteractiveScale())) }, [getMinInteractiveScale, updateScale]) const handleWheel = useCallback((event: WheelEvent) => { event.preventDefault() const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP zoomBy(delta) }, [zoomBy]) const beginPinch = useCallback(() => { const pointers = Array.from(activePointersRef.current.values()) if (pointers.length < 2) return const [first, second] = pointers pinchRef.current = { startDistance: getPointDistance(first, second), startScale: scaleRef.current, startCenter: getPointCenter(first, second), origin: offsetRef.current, } dragRef.current = null }, []) const handlePointerDown = useCallback((event: PointerEvent) => { if (event.button !== 0) return event.currentTarget.setPointerCapture(event.pointerId) activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) backdropPressRef.current = event.target === event.currentTarget ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY } : null if (activePointersRef.current.size >= 2) { backdropPressRef.current = null beginPinch() return } dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offsetRef.current.x, originY: offsetRef.current.y, } }, [beginPinch]) const handlePointerMove = useCallback((event: PointerEvent) => { if (!activePointersRef.current.has(event.pointerId)) return activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (activePointersRef.current.size >= 2 && pinchRef.current) { const pointers = Array.from(activePointersRef.current.values()) const [first, second] = pointers const distance = getPointDistance(first, second) const center = getPointCenter(first, second) const pinch = pinchRef.current const nextScale = pinch.startDistance > 0 ? clampScale( pinch.startScale * (distance / pinch.startDistance), getMinInteractiveScale(), ) : pinch.startScale updateScale(nextScale) updateOffset({ x: pinch.origin.x + center.x - pinch.startCenter.x, y: pinch.origin.y + center.y - pinch.startCenter.y, }) return } const drag = dragRef.current if (!drag || drag.pointerId !== event.pointerId) return updateOffset({ x: drag.originX + event.clientX - drag.startX, y: drag.originY + event.clientY - drag.startY, }) }, [getMinInteractiveScale, updateOffset, updateScale]) const handlePointerUp = useCallback((event: PointerEvent) => { const backdropPress = backdropPressRef.current const moved = backdropPress ? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y) : Number.POSITIVE_INFINITY const shouldCloseFromBackdrop = event.type === 'pointerup' && backdropPress?.pointerId === event.pointerId && event.target === event.currentTarget && activePointersRef.current.size === 1 && moved <= BACKDROP_CLICK_MAX_MOVEMENT activePointersRef.current.delete(event.pointerId) if (backdropPress?.pointerId === event.pointerId) { backdropPressRef.current = null } if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = null } pinchRef.current = null const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined if (remainingPointer) { dragRef.current = { pointerId: remainingPointer[0], startX: remainingPointer[1].x, startY: remainingPointer[1].y, originX: offsetRef.current.x, originY: offsetRef.current.y, } } if (shouldCloseFromBackdrop) { closeViewer() } }, [closeViewer]) useLayoutEffect(() => { if (!open) return if (fitOnOpen && !fitContentKey) return let frame = 0 let attempt = 0 const maxAttempts = 16 const scheduleFit = () => { frame = requestAnimationFrame(() => { const content = contentRef.current const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null) applyFitScale() attempt += 1 if (!hadSize && attempt < maxAttempts) { scheduleFit() } }) } scheduleFit() const retry = window.setTimeout(scheduleFit, 50) const lateRetry = window.setTimeout(scheduleFit, 200) return () => { cancelAnimationFrame(frame) window.clearTimeout(retry) window.clearTimeout(lateRetry) } }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) useLayoutEffect(() => { if (!open) return undefined const toolbar = toolbarRef.current if (!toolbar) return undefined const apply = () => { const next = toolbar.getBoundingClientRect().height setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next)) } apply() window.addEventListener('resize', apply) if (typeof ResizeObserver === 'undefined') { return () => window.removeEventListener('resize', apply) } const resize = new ResizeObserver(apply) resize.observe(toolbar) return () => { resize.disconnect() window.removeEventListener('resize', apply) } }, [open]) useEffect(() => { if (!open) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeViewer() } if (event.key === '0') { resetView() } if (event.key === '+' || event.key === '=') { zoomBy(SCALE_STEP) } if (event.key === '-') { zoomBy(-SCALE_STEP) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [closeViewer, open, resetView, zoomBy]) if (!open) return null const baseScale = baseScaleRef.current const zoomLabel = baseScale > 0 ? `${Math.round((scale / baseScale) * 100)}%` : `${Math.round(scale * 100)}%` const minInteractiveScale = Math.min(MIN_SCALE, baseScale) return (
{children}
event.stopPropagation()} >
{title ?? ariaLabel}
) }