"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { canvasToOverlay, getDisplayScale, positionToOverlay, screenPixelsToLogicalThreshold, screenToCanvas, } from "@/preview/preview-coords"; import { clamp, isNearlyEqual } from "@/utils/math"; import { PREVIEW_ZOOM } from "@/preview/zoom"; const MIDDLE_MOUSE_BUTTON = 1; const IS_AT_FIT_EPSILON = 0.001; const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; interface PreviewViewportContextValue { canPan: boolean; isAtFit: boolean; isAtActualSize: boolean; isPanning: boolean; sceneHeight: number; sceneLeft: number; sceneTop: number; sceneWidth: number; zoomPercent: number; canvasToOverlay: ({ canvasX, canvasY, }: { canvasX: number; canvasY: number; }) => { x: number; y: number }; fitToScreen: () => void; getDisplayScale: () => { x: number; y: number }; handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; positionToOverlay: ({ positionX, positionY, }: { positionX: number; positionY: number; }) => { x: number; y: number }; panByScreenDelta: ({ deltaX, deltaY, }: { deltaX: number; deltaY: number; }) => void; resetPan: () => void; scaleZoom: ({ factor }: { factor: number }) => void; screenPixelsToLogicalThreshold: ({ screenPixels, }: { screenPixels: number; }) => { x: number; y: number }; screenToCanvas: ({ clientX, clientY, }: { clientX: number; clientY: number; }) => { x: number; y: number } | null; setActualSize: () => void; setViewportPercent: ({ percent }: { percent: number }) => void; zoomIn: () => void; zoomOut: () => void; } const PreviewViewportContext = createContext(null); interface PreviewViewportProviderProps { children: React.ReactNode; value: PreviewViewportContextValue; } interface PreviewViewportStateOptions { canvasHeight: number; canvasWidth: number; viewportHeight: number; viewportRef: React.RefObject; viewportWidth: number; } interface PanSession { centerX: number; centerY: number; clientX: number; clientY: number; pointerId: number; } function getClampedCenterAxis({ axisSize, center, scale, viewportSize, }: { axisSize: number; center: number; scale: number; viewportSize: number; }): number { if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { return axisSize / 2; } const visibleHalfSpan = viewportSize / (2 * scale); if (visibleHalfSpan >= axisSize / 2) { return axisSize / 2; } return clamp({ value: center, min: visibleHalfSpan, max: axisSize - visibleHalfSpan, }); } function clampViewportCenter({ canvasHeight, canvasWidth, centerX, centerY, scale, viewportHeight, viewportWidth, }: { canvasHeight: number; canvasWidth: number; centerX: number; centerY: number; scale: number; viewportHeight: number; viewportWidth: number; }): { x: number; y: number } { return { x: getClampedCenterAxis({ center: centerX, axisSize: canvasWidth, scale, viewportSize: viewportWidth, }), y: getClampedCenterAxis({ center: centerY, axisSize: canvasHeight, scale, viewportSize: viewportHeight, }), }; } function getFitScale({ canvasHeight, canvasWidth, viewportHeight, viewportWidth, }: { canvasHeight: number; canvasWidth: number; viewportHeight: number; viewportWidth: number; }): number { if ( canvasHeight <= 0 || canvasWidth <= 0 || viewportHeight <= 0 || viewportWidth <= 0 ) { return 1; } return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); } function getClampedZoom({ zoom }: { zoom: number }): number { return clamp({ value: zoom, min: PREVIEW_ZOOM.min, max: PREVIEW_ZOOM.max, }); } export function PreviewViewportProvider({ children, value, }: PreviewViewportProviderProps) { return ( {children} ); } export function usePreviewViewportState({ canvasHeight, canvasWidth, viewportHeight, viewportRef, viewportWidth, }: PreviewViewportStateOptions): PreviewViewportContextValue { const [zoom, setZoomState] = useState(1); const [center, setCenter] = useState(() => ({ x: canvasWidth / 2, y: canvasHeight / 2, })); const [isPanning, setIsPanning] = useState(false); const panSessionRef = useRef(null); const centerRef = useRef(center); centerRef.current = center; const fitScale = useMemo( () => getFitScale({ canvasHeight, canvasWidth, viewportHeight, viewportWidth, }), [canvasHeight, canvasWidth, viewportHeight, viewportWidth], ); const viewportScale = fitScale * zoom; const geometry = useMemo( () => ({ canvasHeight, canvasWidth, centerX: center.x, centerY: center.y, scale: viewportScale, viewportHeight, viewportWidth, }), [ canvasHeight, canvasWidth, center.x, center.y, viewportHeight, viewportScale, viewportWidth, ], ); const scaleZoom = useCallback(({ factor }: { factor: number }) => { setZoomState((previousZoom) => getClampedZoom({ zoom: previousZoom * factor, }), ); }, []); const panByScreenDelta = useCallback( ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { return; } setCenter((previousCenter) => clampViewportCenter({ canvasHeight, canvasWidth, centerX: previousCenter.x + deltaX / viewportScale, centerY: previousCenter.y + deltaY / viewportScale, scale: viewportScale, viewportHeight, viewportWidth, }), ); }, [ canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth, zoom, ], ); const resetPan = useCallback(() => { setCenter({ x: canvasWidth / 2, y: canvasHeight / 2, }); }, [canvasHeight, canvasWidth]); const fitToScreen = useCallback(() => { setZoomState(1); setCenter({ x: canvasWidth / 2, y: canvasHeight / 2, }); }, [canvasHeight, canvasWidth]); const zoomIn = useCallback(() => { setZoomState((previousZoom) => getClampedZoom({ zoom: previousZoom * PREVIEW_ZOOM.step, }), ); }, []); const zoomOut = useCallback(() => { setZoomState((previousZoom) => getClampedZoom({ zoom: previousZoom / PREVIEW_ZOOM.step, }), ); }, []); const setActualSize = useCallback(() => { const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; setZoomState( getClampedZoom({ zoom: actualSizeZoom, }), ); }, [fitScale]); const setViewportPercent = useCallback( ({ percent }: { percent: number }) => { const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; setZoomState(getClampedZoom({ zoom: targetZoom })); }, [fitScale], ); const getViewportRect = useCallback( () => viewportRef.current?.getBoundingClientRect() ?? null, [viewportRef], ); const screenToCanvasWithViewport = useCallback( ({ clientX, clientY }: { clientX: number; clientY: number }) => { const viewportRect = getViewportRect(); if (!viewportRect) return null; return screenToCanvas({ clientX, clientY, geometry, viewportRect, }); }, [getViewportRect, geometry], ); const canvasToOverlayWithViewport = useCallback( ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => canvasToOverlay({ canvasX, canvasY, geometry, }), [geometry], ); const positionToOverlayWithViewport = useCallback( ({ positionX, positionY }: { positionX: number; positionY: number }) => positionToOverlay({ geometry, positionX, positionY, }), [geometry], ); const getDisplayScaleWithViewport = useCallback( () => getDisplayScale({ geometry }), [geometry], ); const getLogicalThresholdForScreenPixels = useCallback( ({ screenPixels }: { screenPixels: number }) => screenPixelsToLogicalThreshold({ geometry, screenPixels, }), [geometry], ); const handlePanPointerDown = useCallback( ({ event }: { event: React.PointerEvent }) => { if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { return false; } event.preventDefault(); event.stopPropagation(); panSessionRef.current = { centerX: centerRef.current.x, centerY: centerRef.current.y, clientX: event.clientX, clientY: event.clientY, pointerId: event.pointerId, }; setIsPanning(true); (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); return true; }, [zoom], ); const handlePanPointerMove = useCallback( ({ event }: { event: React.PointerEvent }) => { const panSession = panSessionRef.current; if (!panSession) { return false; } event.preventDefault(); event.stopPropagation(); const deltaX = event.clientX - panSession.clientX; const deltaY = event.clientY - panSession.clientY; const nextCenter = clampViewportCenter({ canvasHeight, canvasWidth, centerX: panSession.centerX - deltaX / viewportScale, centerY: panSession.centerY - deltaY / viewportScale, scale: viewportScale, viewportHeight, viewportWidth, }); setCenter(nextCenter); return true; }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], ); const handlePanPointerUp = useCallback( ({ event }: { event: React.PointerEvent }) => { const panSession = panSessionRef.current; if (!panSession) { return false; } if ( (event.currentTarget as HTMLElement).hasPointerCapture( panSession.pointerId, ) ) { (event.currentTarget as HTMLElement).releasePointerCapture( panSession.pointerId, ); } panSessionRef.current = null; setIsPanning(false); return true; }, [], ); useEffect(() => { setZoomState(1); setCenter({ x: canvasWidth / 2, y: canvasHeight / 2, }); panSessionRef.current = null; setIsPanning(false); }, [canvasHeight, canvasWidth]); useEffect(() => { setCenter((previousCenter) => clampViewportCenter({ canvasHeight, canvasWidth, centerX: previousCenter.x, centerY: previousCenter.y, scale: viewportScale, viewportHeight, viewportWidth, }), ); }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); const sceneWidth = canvasWidth * viewportScale; const sceneHeight = canvasHeight * viewportScale; const sceneLeft = viewportWidth / 2 - center.x * viewportScale; const sceneTop = viewportHeight / 2 - center.y * viewportScale; const canPan = zoom > 1; const zoomPercent = Math.round(viewportScale * 100); return useMemo( () => ({ canPan, isAtActualSize: isNearlyEqual({ leftValue: viewportScale, rightValue: 1, epsilon: IS_AT_ACTUAL_SIZE_EPSILON, }), isAtFit: isNearlyEqual({ leftValue: zoom, rightValue: 1, epsilon: IS_AT_FIT_EPSILON, }), isPanning, sceneHeight, sceneLeft, sceneTop, sceneWidth, zoomPercent, canvasToOverlay: canvasToOverlayWithViewport, fitToScreen, getDisplayScale: getDisplayScaleWithViewport, handlePanPointerDown, handlePanPointerMove, handlePanPointerUp, panByScreenDelta, positionToOverlay: positionToOverlayWithViewport, resetPan, scaleZoom, screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, screenToCanvas: screenToCanvasWithViewport, setActualSize, setViewportPercent, zoomIn, zoomOut, }), [ canPan, viewportScale, zoom, isPanning, sceneHeight, sceneLeft, sceneTop, sceneWidth, zoomPercent, canvasToOverlayWithViewport, fitToScreen, getDisplayScaleWithViewport, handlePanPointerDown, handlePanPointerMove, handlePanPointerUp, panByScreenDelta, positionToOverlayWithViewport, resetPan, scaleZoom, getLogicalThresholdForScreenPixels, screenToCanvasWithViewport, setActualSize, setViewportPercent, zoomIn, zoomOut, ], ); } export function usePreviewViewport() { const context = useContext(PreviewViewportContext); if (!context) { throw new Error( "usePreviewViewport must be used within PreviewViewportProvider", ); } return context; }