From 26727f6d225076cfc2073614cf08e7d73654fcfe Mon Sep 17 00:00:00 2001 From: Isioma20 Date: Sun, 17 May 2026 22:27:28 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(preview):=20add=20pinch-to-zoom=20and?= =?UTF-8?q?=20keyboard=20zoom=20shortcuts=C2=A0=C2=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/canvas/use-canvas-zoom.ts | 102 ++++++++++++++---- 1 file changed, 82 insertions(+), 20 deletions(-) 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..bfa2c84f 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]); + 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, zoom]); + 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); @@ -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); } From c7c995e9d64ebce93b77d6f0d7755c54e0e90f3e Mon Sep 17 00:00:00 2001 From: Isioma20 Date: Mon, 18 May 2026 15:13:07 +0100 Subject: [PATCH 2/2] fix(preview): clean up useEffect dependency arrays --- apps/preview/app/src/components/canvas/use-canvas-zoom.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 bfa2c84f..97ebd669 100644 --- a/apps/preview/app/src/components/canvas/use-canvas-zoom.ts +++ b/apps/preview/app/src/components/canvas/use-canvas-zoom.ts @@ -40,7 +40,7 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions document.addEventListener('click', handleDocumentClick, true); return () => document.removeEventListener('click', handleDocumentClick, true); - }, [isAltDown, isZoomKeyDown, zoom]); + }, [isAltDown, isZoomKeyDown]); useEffect(() => { const canvas = canvasRef.current; @@ -72,7 +72,7 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions canvas.addEventListener('wheel', handleWheel, { passive: false }); return () => canvas.removeEventListener('wheel', handleWheel); - }, [canvasRef, zoom]); + }, [canvasRef]); useEffect(() => { function handleZoomEvent(event: Event) { @@ -124,7 +124,7 @@ export function useCanvasZoom({ canvasRef, setZoom, zoom }: UseCanvasZoomOptions window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [zoom]); + }, []); useLayoutEffect(() => { const anchor = pendingZoomAnchor.current;