import { type RefObject, useCallback, useEffect, useMemo, useState, } from 'react'; import { DEFAULT_CANVAS_SIZE, } from './ImageCanvasEditorModel'; import type { CanvasLayer, CanvasViewport, DragState, } from './ImageCanvasEditorTypes'; import { createMinimapModel, fitViewportToLayers, getCanvasDropPoint as resolveCanvasDropPoint, getCanvasPointFromClient as resolveCanvasPointFromClient, getWorldPointFromClient, moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag, moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer, scaleViewportFromScreenPoint, scrollViewportVertically, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = { x: -260, y: 70, scale: 0.82, }; type UseImageCanvasViewportControlsOptions = { canvasViewportRef: RefObject; layers: CanvasLayer[]; captureCanvasHistory: () => void; }; export function useImageCanvasViewportControls({ canvasViewportRef, layers, captureCanvasHistory, }: UseImageCanvasViewportControlsOptions) { const [viewport, setViewport] = useState( DEFAULT_IMAGE_CANVAS_VIEWPORT, ); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), [canvasSize, layers, viewport], ); useEffect(() => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { return undefined; } const updateCanvasSize = () => { setCanvasSize({ width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width, height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height, }); }; updateCanvasSize(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', updateCanvasSize); return () => window.removeEventListener('resize', updateCanvasSize); } const observer = new ResizeObserver(updateCanvasSize); observer.observe(viewportElement); return () => observer.disconnect(); }, [canvasViewportRef]); const updateScaleFromCenter = useCallback( (nextScale: number) => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { captureCanvasHistory(); setViewport((currentViewport) => scaleViewportFromScreenPoint({ viewport: currentViewport, nextScale, screenPoint: null, }), ); return; } const rect = viewportElement.getBoundingClientRect(); const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2; const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2; captureCanvasHistory(); setViewport((currentViewport) => scaleViewportFromScreenPoint({ viewport: currentViewport, nextScale, screenPoint: { x: centerX, y: centerY }, }), ); }, [canvasSize.height, canvasSize.width, canvasViewportRef, captureCanvasHistory], ); const fitLayers = useCallback( (targetLayers: CanvasLayer[] = layers) => { const nextViewport = fitViewportToLayers({ layers: targetLayers, canvasSize, }); if (!nextViewport) { return; } captureCanvasHistory(); setViewport(nextViewport); }, [captureCanvasHistory, canvasSize, layers], ); const resolveCanvasPoint = useCallback( (clientX: number, clientY: number) => { const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null; return resolveCanvasPointFromClient({ clientX, clientY, rect }); }, [canvasViewportRef], ); const getCanvasDropPoint = useCallback( (clientX: number, clientY: number) => resolveCanvasDropPoint({ clientX, clientY, rect: canvasViewportRef.current?.getBoundingClientRect() ?? null, canvasSize, }), [canvasSize, canvasViewportRef], ); const getCanvasPointFromClient = useCallback( (clientX: number, clientY: number) => getWorldPointFromClient({ clientX, clientY, rect: canvasViewportRef.current?.getBoundingClientRect() ?? null, viewport, }), [canvasViewportRef, viewport], ); const moveViewportFromMinimapPointer = useCallback( (clientX: number, clientY: number) => { if (!minimapModel) { return; } const minimapElement = document.querySelector( '.image-canvas-editor__minimap', ) as HTMLElement | null; const rect = minimapElement?.getBoundingClientRect(); if (!rect) { return; } setViewport((currentViewport) => resolveViewportFromMinimapPointer({ viewport: currentViewport, canvasSize, minimapModel, pointer: { x: clientX - rect.left, y: clientY - rect.top, }, }), ); }, [canvasSize, minimapModel], ); const updateViewportFromMinimapDrag = useCallback( ( dragState: Extract, clientX: number, clientY: number, ) => { setViewport( resolveViewportFromMinimapDrag(dragState, { x: clientX, y: clientY, }), ); }, [], ); const handleNativeWheel = useCallback( (event: WheelEvent) => { event.preventDefault(); const viewportElement = canvasViewportRef.current; if (!viewportElement) { return; } if (!event.ctrlKey && !event.metaKey) { setViewport((currentViewport) => scrollViewportVertically(currentViewport, event.deltaY), ); return; } const rect = viewportElement.getBoundingClientRect(); const screenPoint = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; setViewport((currentViewport) => zoomViewportFromWheel({ viewport: currentViewport, deltaY: event.deltaY, screenPoint, }), ); }, [canvasViewportRef], ); useEffect(() => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { return undefined; } viewportElement.addEventListener('wheel', handleNativeWheel, { passive: false, }); return () => { viewportElement.removeEventListener('wheel', handleNativeWheel); }; }, [canvasViewportRef, handleNativeWheel]); return { viewport, setViewport, canvasSize, minimapModel, updateScaleFromCenter, fitLayers, resolveCanvasPoint, getCanvasDropPoint, getCanvasPointFromClient, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, }; }