import type { CanvasLayer, CanvasViewport, DragState, } from '@genarrative/image-canvas-core'; import { createMinimapModel, DEFAULT_CANVAS_SIZE, fitViewportToLayers, getCanvasDropPoint as resolveCanvasDropPoint, getCanvasPointFromClient as resolveCanvasPointFromClient, getWorldPointFromClient, moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag, moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer, resolveViewportFromWheel, scaleViewportFromScreenPoint, } from '@genarrative/image-canvas-core'; import { type Dispatch, type RefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = { x: -260, y: 70, scale: 0.5, }; export const IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS = 420; function easeOutCubic(progress: number) { return 1 - (1 - progress) ** 3; } function interpolateViewport( start: CanvasViewport, target: CanvasViewport, progress: number, ): CanvasViewport { return { x: start.x + (target.x - start.x) * progress, y: start.y + (target.y - start.y) * progress, scale: start.scale + (target.scale - start.scale) * progress, }; } function prefersReducedCanvasMotion() { return ( typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ); } 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, setViewportState] = useState( DEFAULT_IMAGE_CANVAS_VIEWPORT, ); const viewportRef = useRef(viewport); const viewportAnimationFrameRef = useRef(null); const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE); const pendingMinimapDragRef = useRef<{ dragState: Extract; clientX: number; clientY: number; } | null>(null); const minimapDragFrameRef = useRef(null); const cancelViewportAnimation = useCallback(() => { if (viewportAnimationFrameRef.current === null) { return; } window.cancelAnimationFrame(viewportAnimationFrameRef.current); viewportAnimationFrameRef.current = null; }, []); const setViewport = useCallback>>( (nextViewport) => { cancelViewportAnimation(); const resolvedViewport = typeof nextViewport === 'function' ? nextViewport(viewportRef.current) : nextViewport; viewportRef.current = resolvedViewport; setViewportState(resolvedViewport); }, [cancelViewportAnimation], ); const animateViewportTo = useCallback( (targetViewport: CanvasViewport) => { cancelViewportAnimation(); if (prefersReducedCanvasMotion()) { viewportRef.current = targetViewport; setViewportState(targetViewport); return; } const startViewport = viewportRef.current; let startedAt: number | null = null; const step = (timestamp: number) => { startedAt ??= timestamp; const elapsedMs = Math.max(0, timestamp - startedAt); const progress = Math.min( 1, elapsedMs / IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS, ); const nextViewport = interpolateViewport( startViewport, targetViewport, easeOutCubic(progress), ); viewportRef.current = nextViewport; setViewportState(nextViewport); if (progress >= 1) { viewportAnimationFrameRef.current = null; return; } viewportAnimationFrameRef.current = window.requestAnimationFrame(step); }; viewportAnimationFrameRef.current = window.requestAnimationFrame(step); }, [cancelViewportAnimation], ); 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, setViewport, ], ); 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, setViewport], ); 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 localMinimap = canvasViewportRef.current?.querySelector( '.genarrative-image-canvas__minimap', ) as HTMLElement | null; const documentMinimaps = document.querySelectorAll( '.genarrative-image-canvas__minimap', ); const minimapElement = localMinimap ?? (documentMinimaps.length === 1 ? documentMinimaps.item(0) : 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, canvasViewportRef, minimapModel, setViewport], ); 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, }), ); }, [setViewport]); 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, }), ); }); }, [setViewport], ); useEffect( () => () => { cancelViewportAnimation(); if (minimapDragFrameRef.current !== null) { window.cancelAnimationFrame(minimapDragFrameRef.current); minimapDragFrameRef.current = null; } pendingMinimapDragRef.current = null; }, [cancelViewportAnimation], ); 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(); const rect = viewportElement.getBoundingClientRect(); const screenPoint = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; setViewport( (currentViewport) => resolveViewportFromWheel({ viewport: currentViewport, deltaX: event.deltaX, deltaY: event.deltaY, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, screenPoint, }).viewport, ); }, [canvasViewportRef, setViewport], ); 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, animateViewportTo, canvasSize, minimapModel, updateScaleFromCenter, fitLayers, resolveCanvasPoint, getCanvasDropPoint, getCanvasPointFromClient, moveViewportFromMinimapPointer, updateViewportFromMinimapDrag, flushMinimapViewportDrag, }; }