Files
hapi/web/src/components/ZoomableLightbox.tsx
T
AnanovoandGitHub 99814e9668 fix(web): make share image previews interactive (#1229)
* fix(web): make share preview controls interactive

* fix(web): preserve lightbox modal focus
2026-07-30 23:20:23 +08:00

486 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
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<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const toolbarRef = useRef<HTMLDivElement>(null)
const activePointersRef = useRef(new Map<number, Point>())
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 === '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 (
<DialogPrimitive.Root
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) closeViewer()
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Content
className="pointer-events-auto fixed inset-0 z-50 h-[100dvh] w-full bg-black text-white"
aria-label={ariaLabel}
>
<div
ref={viewportRef}
className="absolute inset-x-0 bottom-0 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
style={{ top: `${toolbarHeight}px` }}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={resetView}
>
<div
ref={contentRef}
className="absolute left-1/2 top-1/2 select-none"
style={{
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
transformOrigin: 'center center',
}}
>
{children}
</div>
</div>
<div
ref={toolbarRef}
className="pointer-events-none absolute inset-x-0 top-0 z-10 pt-[env(safe-area-inset-top,0px)]"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="pointer-events-auto flex items-center gap-2 border-b border-white/10 bg-black/70 px-3 py-2 backdrop-blur-sm">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{title ?? ariaLabel}</div>
<button
type="button"
onClick={() => zoomBy(-SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale <= minInteractiveScale}
title="Zoom out"
>
</button>
<button
type="button"
onClick={resetView}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
title="Fit to screen"
>
{zoomLabel}
</button>
<button
type="button"
onClick={() => zoomBy(SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale >= MAX_SCALE}
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={closeViewer}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
title="Close"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}