import { type RefObject, useCallback, useEffect, useMemo, useRef, 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, panViewportFromWheel, scaleViewportFromScreenPoint, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = { x: -260, y: 70, scale: 0.5, }; function isCanvasWheelInteractionTarget(target: EventTarget | null) { return ( target instanceof Element && target.closest( 'input, textarea, select, [contenteditable="true"]', ) !== null ); } 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 pendingMinimapDragRef = useRef<{ dragState: Extract; clientX: number; clientY: number; } | null>(null); const minimapDragFrameRef = useRef(null); 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, options: { captureHistory?: boolean } = {}, ) => { const nextViewport = fitViewportToLayers({ layers: targetLayers, canvasSize, }); if (!nextViewport) { return; } if (options.captureHistory !== false) { 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 flushMinimapViewportDrag = useCallback(() => { if (minimapDragFrameRef.current !== null) { window.cancelAnimationFrame(minimapDragFrameRef.current); minimapDragFrameRef.current = null; } const pendingDrag = pendingMinimapDragRef.current; pendingMinimapDragRef.current = null; if (!pendingDrag) { return; } setViewport( resolveViewportFromMinimapDrag(pendingDrag.dragState, { x: pendingDrag.clientX, y: pendingDrag.clientY, }), ); }, []); const updateViewportFromMinimapDrag = useCallback( ( dragState: Extract, clientX: number, clientY: number, ) => { pendingMinimapDragRef.current = { dragState, clientX, clientY }; if (minimapDragFrameRef.current !== null) { return; } minimapDragFrameRef.current = window.requestAnimationFrame(() => { minimapDragFrameRef.current = null; const pendingDrag = pendingMinimapDragRef.current; pendingMinimapDragRef.current = null; if (!pendingDrag) { return; } setViewport( resolveViewportFromMinimapDrag(pendingDrag.dragState, { x: pendingDrag.clientX, y: pendingDrag.clientY, }), ); }); }, [], ); useEffect( () => () => { if (minimapDragFrameRef.current !== null) { window.cancelAnimationFrame(minimapDragFrameRef.current); minimapDragFrameRef.current = null; } pendingMinimapDragRef.current = null; }, [], ); const handleNativeWheel = useCallback( (event: WheelEvent) => { // Let form controls and their scrollable panels keep native wheel input. if (isCanvasWheelInteractionTarget(event.target)) { return; } const viewportElement = canvasViewportRef.current; if (!viewportElement) { return; } event.preventDefault(); if (!event.ctrlKey && !event.metaKey) { const useShiftWheelHorizontalFallback = event.shiftKey && event.deltaX === 0; setViewport((currentViewport) => panViewportFromWheel({ viewport: currentViewport, deltaX: useShiftWheelHorizontalFallback ? event.deltaY : event.deltaX, deltaY: useShiftWheelHorizontalFallback ? 0 : 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, flushMinimapViewportDrag, }; }