diff --git a/apps/preview/app/src/components/canvas/use-canvas-zoom.ts b/apps/preview/app/src/components/canvas/use-canvas-zoom.ts index 03e0f725..97ebd669 100644 --- a/apps/preview/app/src/components/canvas/use-canvas-zoom.ts +++ b/apps/preview/app/src/components/canvas/use-canvas-zoom.ts @@ -1,7 +1,7 @@ import { type RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { isZoomExcludedTarget } from '../../helpers/canvas-positioning'; -import { isZoomKey, nextZoom } from '../../helpers/zoom'; +import { isZoomKey, maxZoom, minZoom, nextZoom } from '../../helpers/zoom'; interface UseCanvasZoomOptions { canvasRef: RefObject; @@ -13,6 +13,8 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions const pendingZoomAnchor = useRef(null); const [isZoomKeyDown, setIsZoomKeyDown] = useState(false); const [isAltDown, setIsAltDown] = useState(false); + const zoomRef = useRef(zoom); + zoomRef.current = zoom; useEffect(() => { const root = document.documentElement; @@ -30,26 +32,82 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - changeZoom(event.altKey || isAltDown ? -1 : 1, { x: event.clientX, y: event.clientY }); + changeZoom(event.altKey || isAltDown ? -1 : 1, { + x: event.clientX, + y: event.clientY + }); } document.addEventListener('click', handleDocumentClick, true); return () => document.removeEventListener('click', handleDocumentClick, true); - }, [isAltDown, isZoomKeyDown, zoom]); + }, [isAltDown, isZoomKeyDown]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + + function handleWheel(event: WheelEvent) { + const { ctrlKey, metaKey } = event; + if (!ctrlKey && !metaKey) return; + if (isZoomExcludedTarget(event.target)) return; + + event.preventDefault(); + + const { current } = zoomRef; + const anchor = { x: event.clientX, y: event.clientY }; + const delta = -event.deltaY; + + if (ctrlKey) { + // Pinch / Ctrl+scroll — continuous + const next = Math.round(Math.min(maxZoom, Math.max(minZoom, current + delta * 0.5))); + if (next !== current) { + setZoomAnchor(current, anchor); + setZoom(next); + } + } else { + // Cmd+scroll — step zoom + changeZoom(delta > 0 ? 1 : -1, anchor); + } + } + + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => canvas.removeEventListener('wheel', handleWheel); + }, [canvasRef]); useEffect(() => { function handleZoomEvent(event: Event) { const direction = Number((event as CustomEvent).detail); if (direction === -1 || direction === 1) changeZoom(direction); } + function handleKeyDown(event: KeyboardEvent) { setIsAltDown(event.altKey); if (isZoomKey(event)) setIsZoomKeyDown(true); + + const isMod = event.metaKey || event.ctrlKey; + if (!isMod) return; + + if (event.key === '=' || event.key === '+') { + event.preventDefault(); + changeZoom(1); + } else if (event.key === '-') { + event.preventDefault(); + changeZoom(-1); + } else if (event.key === '0') { + event.preventDefault(); + const { current } = zoomRef; + if (current !== 100) { + setZoomAnchor(current); + setZoom(100); + } + } } + function handleKeyUp(event: KeyboardEvent) { setIsAltDown(event.altKey); if (isZoomKey(event)) setIsZoomKeyDown(false); } + function handleBlur() { setIsZoomKeyDown(false); setIsAltDown(false); @@ -66,7 +124,7 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [zoom]); + }, []); useLayoutEffect(() => { const anchor = pendingZoomAnchor.current; @@ -87,27 +145,31 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions pendingZoomAnchor.current = null; }, [canvasRef, zoom]); - function changeZoom(direction: number, anchorPoint?: { x: number; y: number }) { - const next = nextZoom(zoom, direction); - if (next === zoom) return; + function setZoomAnchor(currentZoom: number, anchorPoint?: { x: number; y: number }) { const canvas = canvasRef.current; const content = canvas?.firstElementChild; + if (!canvas || !content) return; + + const canvasRect = canvas.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + const scale = currentZoom / 100; + const anchorX = anchorPoint?.x ?? canvasRect.left + canvasRect.width / 2; + const anchorY = anchorPoint?.y ?? canvasRect.top + canvasRect.height / 2; + + pendingZoomAnchor.current = { + localX: (anchorX - contentRect.left) / scale, + localY: (anchorY - contentRect.top) / scale, + scale, + scrollLeft: canvas.scrollLeft, + scrollTop: canvas.scrollTop + }; + } - if (canvas && content) { - const canvasRect = canvas.getBoundingClientRect(); - const contentRect = content.getBoundingClientRect(); - const scale = zoom / 100; - const anchorX = anchorPoint?.x ?? canvasRect.left + canvasRect.width / 2; - const anchorY = anchorPoint?.y ?? canvasRect.top + canvasRect.height / 2; - pendingZoomAnchor.current = { - localX: (anchorX - contentRect.left) / scale, - localY: (anchorY - contentRect.top) / scale, - scale, - scrollLeft: canvas.scrollLeft, - scrollTop: canvas.scrollTop - }; - } - + function changeZoom(direction: number, anchorPoint?: { x: number; y: number }) { + const { current } = zoomRef; + const next = nextZoom(current, direction); + if (next === current) return; + setZoomAnchor(current, anchorPoint); setZoom(next); }