diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 231b0453..307c5bac 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -4,14 +4,16 @@ import { getDefaultAppState } from "../appState"; import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; -import { getNormalizedZoom, normalizeScroll } from "../scene"; +import { getNormalizedZoom } from "../scene"; import { KEYS } from "../keys"; import { getShortcutKey } from "../utils"; import useIsMobile from "../is-mobile"; import { register } from "./register"; import { newElementWith } from "../element/mutateElement"; -import { AppState, FlooredNumber } from "../types"; +import { AppState, NormalizedZoomValue } from "../types"; import { getCommonBounds } from "../element"; +import { getNewZoom } from "../scene/zoom"; +import { centerScrollOn } from "../scene/scroll"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -84,7 +86,11 @@ export const actionZoomIn = register({ return { appState: { ...appState, - zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP), + zoom: getNewZoom( + getNormalizedZoom(appState.zoom.value + ZOOM_STEP), + appState.zoom, + { x: appState.width / 2, y: appState.height / 2 }, + ), }, commitToHistory: false, }; @@ -111,7 +117,11 @@ export const actionZoomOut = register({ return { appState: { ...appState, - zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP), + zoom: getNewZoom( + getNormalizedZoom(appState.zoom.value - ZOOM_STEP), + appState.zoom, + { x: appState.width / 2, y: appState.height / 2 }, + ), }, commitToHistory: false, }; @@ -138,7 +148,10 @@ export const actionResetZoom = register({ return { appState: { ...appState, - zoom: 1, + zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, { + x: appState.width / 2, + y: appState.height / 2, + }), }, commitToHistory: false, }; @@ -159,40 +172,23 @@ export const actionResetZoom = register({ (event[KEYS.CTRL_OR_CMD] || event.shiftKey), }); -const calculateZoom = ( - commonBounds: number[], - currentZoom: number, - { - scrollX, - scrollY, - }: { - scrollX: FlooredNumber; - scrollY: FlooredNumber; - }, -): number => { - const { innerWidth, innerHeight } = window; - const [x, y] = commonBounds; - const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth); - const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight); - const margin = 0.01; - let newZoom; - - if (zoomX < zoomY) { - newZoom = zoomX - margin; - } else if (zoomY <= zoomX) { - newZoom = zoomY - margin; - } else { - newZoom = currentZoom; - } - - if (newZoom <= 0.1) { - return 0.1; - } - if (newZoom >= 1) { - return 1; - } - - return newZoom; +const zoomValueToFitBoundsOnViewport = ( + bounds: [number, number, number, number], + viewportDimensions: { width: number; height: number }, +) => { + const [x1, y1, x2, y2] = bounds; + const commonBoundsWidth = x2 - x1; + const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth; + const commonBoundsHeight = y2 - y1; + const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight; + const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight); + const zoomAdjustedToSteps = + Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; + const clampedZoomValueToFitElements = Math.min( + Math.max(zoomAdjustedToSteps, ZOOM_STEP), + 1, + ); + return clampedZoomValueToFitElements as NormalizedZoomValue; }; export const actionZoomToFit = register({ @@ -200,22 +196,29 @@ export const actionZoomToFit = register({ perform: (elements, appState) => { const nonDeletedElements = elements.filter((element) => !element.isDeleted); const commonBounds = getCommonBounds(nonDeletedElements); + + const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, { + width: appState.width, + height: appState.height, + }); + const newZoom = getNewZoom(zoomValue, appState.zoom); + const [x1, y1, x2, y2] = commonBounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; - const scrollX = normalizeScroll(appState.width / 2 - centerX); - const scrollY = normalizeScroll(appState.height / 2 - centerY); - const zoom = calculateZoom(commonBounds, appState.zoom, { - scrollX, - scrollY, - }); return { appState: { ...appState, - scrollX, - scrollY, - zoom, + ...centerScrollOn({ + scenePoint: { x: centerX, y: centerY }, + viewportDimensions: { + width: appState.width, + height: appState.height, + }, + zoom: newZoom, + }), + zoom: newZoom, }, commitToHistory: false, }; diff --git a/src/actions/actionNavigate.tsx b/src/actions/actionNavigate.tsx index 4b57072b..ab0265b4 100644 --- a/src/actions/actionNavigate.tsx +++ b/src/actions/actionNavigate.tsx @@ -3,7 +3,7 @@ import { Avatar } from "../components/Avatar"; import { register } from "./register"; import { getClientColors, getClientInitials } from "../clients"; import { Collaborator } from "../types"; -import { normalizeScroll } from "../scene"; +import { centerScrollOn } from "../scene/scroll"; export const actionGoToCollaborator = register({ name: "goToCollaborator", @@ -16,8 +16,14 @@ export const actionGoToCollaborator = register({ return { appState: { ...appState, - scrollX: normalizeScroll(appState.width / 2 - point.x), - scrollY: normalizeScroll(appState.height / 2 - point.y), + ...centerScrollOn({ + scenePoint: point, + viewportDimensions: { + width: appState.width, + height: appState.height, + }, + zoom: appState.zoom, + }), // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, diff --git a/src/appState.ts b/src/appState.ts index 3c65ba81..8452b18c 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -1,5 +1,5 @@ import oc from "open-color"; -import { AppState, FlooredNumber } from "./types"; +import { AppState, FlooredNumber, NormalizedZoomValue } from "./types"; import { getDateTime } from "./utils"; import { t } from "./i18n"; import { @@ -53,7 +53,10 @@ export const getDefaultAppState = (): Omit< isResizing: false, isRotating: false, selectionElement: null, - zoom: 1, + zoom: { + value: 1 as NormalizedZoomValue, + translation: { x: 0, y: 0 }, + }, openMenu: null, lastPointerDownWith: "mouse", selectedElementIds: {}, diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 4866a7b5..dbf6e785 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { AppState } from "../types"; +import { AppState, Zoom } from "../types"; import { ExcalidrawElement } from "../element/types"; import { ActionManager } from "../actions/manager"; import { @@ -183,14 +183,16 @@ export const ZoomActions = ({ zoom, }: { renderAction: ActionManager["renderAction"]; - zoom: number; + zoom: Zoom; }) => ( {renderAction("zoomIn")} {renderAction("zoomOut")} {renderAction("resetZoom")} - {(zoom * 100).toFixed(0)}% + + {(zoom.value * 100).toFixed(0)}% + ); diff --git a/src/components/App.tsx b/src/components/App.tsx index 6f86e1a6..05325ff0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -180,6 +180,7 @@ import { saveToFirebase, isSavedToFirebase, } from "../data/firebase"; +import { getNewZoom } from "../scene/zoom"; /** * @param func handler taking at most single parameter (event). @@ -935,8 +936,6 @@ class App extends React.Component { sceneY: user.pointer.y, }, this.state, - this.canvas, - window.devicePixelRatio, ); cursorButton[socketId] = user.button; }); @@ -1146,8 +1145,6 @@ class App extends React.Component { const { x, y } = viewportCoordsToSceneCoords( { clientX, clientY }, this.state, - this.canvas, - window.devicePixelRatio, ); const dx = x - elementsCenterX; @@ -1205,8 +1202,6 @@ class App extends React.Component { const { x, y } = viewportCoordsToSceneCoords( { clientX: cursorX, clientY: cursorY }, this.state, - this.canvas, - window.devicePixelRatio, ); const element = newTextElement({ @@ -1719,15 +1714,19 @@ class App extends React.Component { this.setState({ selectedElementIds: {}, }); - gesture.initialScale = this.state.zoom; + gesture.initialScale = this.state.zoom.value; }); private onGestureChange = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); - - this.setState({ - zoom: getNormalizedZoom(gesture.initialScale! * event.scale), - }); + const gestureCenter = getCenter(gesture.pointers); + this.setState(({ zoom }) => ({ + zoom: getNewZoom( + getNormalizedZoom(gesture.initialScale! * event.scale), + zoom, + gestureCenter, + ), + })); }); private onGestureEnd = withBatchedUpdates((event: GestureEvent) => { @@ -1771,8 +1770,6 @@ class App extends React.Component { sceneY: y, }, this.state, - this.canvas, - window.devicePixelRatio, ); return [viewportX, viewportY]; }, @@ -1990,8 +1987,6 @@ class App extends React.Component { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( event, this.state, - this.canvas, - window.devicePixelRatio, ); const selectedGroupIds = getSelectedGroupIds(this.state); @@ -2051,12 +2046,16 @@ class App extends React.Component { const distance = getDistance(Array.from(gesture.pointers.values())); const scaleFactor = distance / gesture.initialDistance!; - this.setState({ - scrollX: normalizeScroll(this.state.scrollX + deltaX / this.state.zoom), - scrollY: normalizeScroll(this.state.scrollY + deltaY / this.state.zoom), - zoom: getNormalizedZoom(gesture.initialScale! * scaleFactor), + this.setState(({ zoom, scrollX, scrollY }) => ({ + scrollX: normalizeScroll(scrollX + deltaX / zoom.value), + scrollY: normalizeScroll(scrollY + deltaY / zoom.value), + zoom: getNewZoom( + getNormalizedZoom(gesture.initialScale! * scaleFactor), + zoom, + center, + ), shouldCacheIgnoreZoom: true, - }); + })); this.resetShouldCacheIgnoreZoomDebounced(); } else { gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null; @@ -2079,12 +2078,7 @@ class App extends React.Component { } } - const scenePointer = viewportCoordsToSceneCoords( - event, - this.state, - this.canvas, - window.devicePixelRatio, - ); + const scenePointer = viewportCoordsToSceneCoords(event, this.state); const { x: scenePointerX, y: scenePointerY } = scenePointer; if ( @@ -2453,8 +2447,12 @@ class App extends React.Component { } this.setState({ - scrollX: normalizeScroll(this.state.scrollX - deltaX / this.state.zoom), - scrollY: normalizeScroll(this.state.scrollY - deltaY / this.state.zoom), + scrollX: normalizeScroll( + this.state.scrollX - deltaX / this.state.zoom.value, + ), + scrollY: normalizeScroll( + this.state.scrollY - deltaY / this.state.zoom.value, + ), }); }); const teardown = withBatchedUpdates( @@ -2491,7 +2489,7 @@ class App extends React.Component { if (gesture.pointers.size === 2) { gesture.lastCenter = getCenter(gesture.pointers); - gesture.initialScale = this.state.zoom; + gesture.initialScale = this.state.zoom.value; gesture.initialDistance = getDistance( Array.from(gesture.pointers.values()), ); @@ -2501,12 +2499,7 @@ class App extends React.Component { private initialPointerDownState( event: React.PointerEvent, ): PointerDownState { - const origin = viewportCoordsToSceneCoords( - event, - this.state, - this.canvas, - window.devicePixelRatio, - ); + const origin = viewportCoordsToSceneCoords(event, this.state); const selectedElements = getSelectedElements( this.scene.getElements(), this.state, @@ -2790,7 +2783,7 @@ class App extends React.Component { } // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / this.state.zoom; + const threshold = 10 / this.state.zoom.value; const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( point.x > x1 - threshold && @@ -2985,12 +2978,7 @@ class App extends React.Component { return; } - const pointerCoords = viewportCoordsToSceneCoords( - event, - this.state, - this.canvas, - window.devicePixelRatio, - ); + const pointerCoords = viewportCoordsToSceneCoords(event, this.state); const [gridX, gridY] = getGridPoint( pointerCoords.x, pointerCoords.y, @@ -3212,7 +3200,7 @@ class App extends React.Component { mutateElement(draggingElement, { points: simplify( [...(points as Point[]), [dx, dy]], - 0.7 / this.state.zoom, + 0.7 / this.state.zoom.value, ), }); } else { @@ -3300,7 +3288,9 @@ class App extends React.Component { const x = event.clientX; const dx = x - pointerDownState.lastCoords.x; this.setState({ - scrollX: normalizeScroll(this.state.scrollX - dx / this.state.zoom), + scrollX: normalizeScroll( + this.state.scrollX - dx / this.state.zoom.value, + ), }); pointerDownState.lastCoords.x = x; return true; @@ -3310,7 +3300,9 @@ class App extends React.Component { const y = event.clientY; const dy = y - pointerDownState.lastCoords.y; this.setState({ - scrollY: normalizeScroll(this.state.scrollY - dy / this.state.zoom), + scrollY: normalizeScroll( + this.state.scrollY - dy / this.state.zoom.value, + ), }); pointerDownState.lastCoords.y = y; return true; @@ -3387,8 +3379,6 @@ class App extends React.Component { const pointerCoords = viewportCoordsToSceneCoords( childEvent, this.state, - this.canvas, - window.devicePixelRatio, ); if ( @@ -3808,8 +3798,6 @@ class App extends React.Component { const { x, y } = viewportCoordsToSceneCoords( { clientX, clientY }, this.state, - this.canvas, - window.devicePixelRatio, ); const elements = this.scene.getElements(); @@ -3885,7 +3873,6 @@ class App extends React.Component { const { deltaX, deltaY } = event; const { selectedElementIds, previousSelectedElementIds } = this.state; - // note that event.ctrlKey is necessary to handle pinch zooming if (event.metaKey || event.ctrlKey) { const sign = Math.sign(deltaY); @@ -3903,8 +3890,12 @@ class App extends React.Component { }); }, 1000); } + this.setState(({ zoom }) => ({ - zoom: getNormalizedZoom(zoom - delta / 100), + zoom: getNewZoom(getNormalizedZoom(zoom.value - delta / 100), zoom, { + x: cursorX, + y: cursorY, + }), selectedElementIds: {}, previousSelectedElementIds: Object.keys(selectedElementIds).length !== 0 @@ -3920,14 +3911,14 @@ class App extends React.Component { if (event.shiftKey) { this.setState(({ zoom, scrollX }) => ({ // on Mac, shift+wheel tends to result in deltaX - scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom), + scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value), })); return; } this.setState(({ zoom, scrollX, scrollY }) => ({ - scrollX: normalizeScroll(scrollX - deltaX / zoom), - scrollY: normalizeScroll(scrollY - deltaY / zoom), + scrollX: normalizeScroll(scrollX - deltaX / zoom.value), + scrollY: normalizeScroll(scrollY - deltaY / zoom.value), })); }); @@ -3960,8 +3951,6 @@ class App extends React.Component { const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: elementCenterX, sceneY: elementCenterY }, appState, - canvas, - scale, ); return { viewportX, viewportY, elementCenterX, elementCenterY }; } @@ -3975,8 +3964,6 @@ class App extends React.Component { const pointer = viewportCoordsToSceneCoords( { clientX: x, clientY: y }, this.state, - this.canvas, - window.devicePixelRatio, ); if (isNaN(pointer.x) || isNaN(pointer.y)) { diff --git a/src/data/restore.ts b/src/data/restore.ts index 97a99203..e428df86 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -3,7 +3,7 @@ import { FontFamily, ExcalidrawSelectionElement, } from "../element/types"; -import { AppState } from "../types"; +import { AppState, NormalizedZoomValue } from "../types"; import { DataState, ImportedDataState } from "./types"; import { isInvisiblySmallElement, getNormalizedDimensions } from "../element"; import { isLinearElementType } from "../element/typeChecks"; @@ -161,6 +161,14 @@ const restoreAppState = ( ...nextAppState, offsetLeft: appState.offsetLeft || 0, offsetTop: appState.offsetTop || 0, + /* Migrates from previous version where appState.zoom was a number */ + zoom: + typeof appState.zoom === "number" + ? { + value: appState.zoom as NormalizedZoomValue, + translation: defaultAppState.zoom.translation, + } + : appState.zoom || defaultAppState.zoom, }; }; diff --git a/src/element/collision.ts b/src/element/collision.ts index 099427e4..6d0a7ccc 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -44,7 +44,7 @@ export const hitTest = ( y: number, ): boolean => { // How many pixels off the shape boundary we still consider a hit - const threshold = 10 / appState.zoom; + const threshold = 10 / appState.zoom.value; const point: Point = [x, y]; if (isElementSelected(appState, element)) { @@ -60,7 +60,7 @@ export const isHittingElementBoundingBoxWithoutHittingElement = ( x: number, y: number, ): boolean => { - const threshold = 10 / appState.zoom; + const threshold = 10 / appState.zoom.value; return ( !isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) && @@ -73,7 +73,7 @@ const isHittingElementNotConsideringBoundingBox = ( appState: AppState, point: Point, ): boolean => { - const threshold = 10 / appState.zoom; + const threshold = 10 / appState.zoom.value; const check = element.type === "text" diff --git a/src/element/linearElementEditor.ts b/src/element/linearElementEditor.ts index eaafc0e5..079953c4 100644 --- a/src/element/linearElementEditor.ts +++ b/src/element/linearElementEditor.ts @@ -384,7 +384,7 @@ export class LinearElementEditor { while (--idx > -1) { const point = pointHandles[idx]; if ( - distance2d(x, y, point[0], point[1]) * zoom < + distance2d(x, y, point[0], point[1]) * zoom.value < // +1px to account for outline stroke this.POINT_HANDLE_SIZE / 2 + 1 ) { diff --git a/src/element/newElement.ts b/src/element/newElement.ts index b28266b7..25c7f101 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -148,7 +148,6 @@ const getAdjustedDimensions = ( height: nextHeight, baseline: nextBaseline, } = measureText(nextText, getFontString(element)); - const { textAlign, verticalAlign } = element; let x, y; diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 4e0e6d2c..4471244d 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -12,7 +12,7 @@ import { TransformHandle, MaybeTransformHandleType, } from "./transformHandles"; -import { AppState } from "../types"; +import { AppState, Zoom } from "../types"; const isInsideTransformHandle = ( transformHandle: TransformHandle, @@ -29,7 +29,7 @@ export const resizeTest = ( appState: AppState, x: number, y: number, - zoom: number, + zoom: Zoom, pointerType: PointerType, ): MaybeTransformHandleType => { if (!appState.selectedElementIds[element.id]) { @@ -70,7 +70,7 @@ export const getElementWithTransformHandleType = ( appState: AppState, scenePointerX: number, scenePointerY: number, - zoom: number, + zoom: Zoom, pointerType: PointerType, ) => { return elements.reduce((result, element) => { @@ -93,7 +93,7 @@ export const getTransformHandleTypeFromCoords = ( [x1, y1, x2, y2]: readonly [number, number, number, number], scenePointerX: number, scenePointerY: number, - zoom: number, + zoom: Zoom, pointerType: PointerType, ): MaybeTransformHandleType => { const transformHandles = getTransformHandlesFromCoords( diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 1045a5ec..7befbd61 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -26,9 +26,9 @@ const getTransform = ( const degree = (180 * angle) / Math.PI; // offsets must be multiplied by 2 to account for the division by 2 of // the whole expression afterwards - return `translate(${((width - offsetLeft * 2) * (zoom - 1)) / 2}px, ${ - ((height - offsetTop * 2) * (zoom - 1)) / 2 - }px) scale(${zoom}) rotate(${degree}deg)`; + return `translate(${((width - offsetLeft * 2) * (zoom.value - 1)) / 2}px, ${ + ((height - offsetTop * 2) * (zoom.value - 1)) / 2 + }px) scale(${zoom.value}) rotate(${degree}deg)`; }; export const textWysiwyg = ({ diff --git a/src/element/transformHandles.ts b/src/element/transformHandles.ts index 3f9f0b2f..a6850ca3 100644 --- a/src/element/transformHandles.ts +++ b/src/element/transformHandles.ts @@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType } from "./types"; import { getElementAbsoluteCoords, Bounds } from "./bounds"; import { rotate } from "../math"; +import { Zoom } from "../types"; export type TransformHandleType = | "n" @@ -76,25 +77,25 @@ const generateTransformHandle = ( export const getTransformHandlesFromCoords = ( [x1, y1, x2, y2]: Bounds, angle: number, - zoom: number, + zoom: Zoom, pointerType: PointerType = "mouse", omitSides: { [T in TransformHandleType]?: boolean } = {}, ): TransformHandles => { const size = transformHandleSizes[pointerType]; - const handleWidth = size / zoom; - const handleHeight = size / zoom; + const handleWidth = size / zoom.value; + const handleHeight = size / zoom.value; - const handleMarginX = size / zoom; - const handleMarginY = size / zoom; + const handleMarginX = size / zoom.value; + const handleMarginY = size / zoom.value; const width = x2 - x1; const height = y2 - y1; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const dashedLineMargin = 4 / zoom; + const dashedLineMargin = 4 / zoom.value; - const centeringOffset = (size - 8) / (2 * zoom); + const centeringOffset = (size - 8) / (2 * zoom.value); const transformHandles: TransformHandles = { nw: omitSides["nw"] @@ -149,7 +150,7 @@ export const getTransformHandlesFromCoords = ( dashedLineMargin - handleMarginY + centeringOffset - - ROTATION_RESIZE_HANDLE_GAP / zoom, + ROTATION_RESIZE_HANDLE_GAP / zoom.value, handleWidth, handleHeight, cx, @@ -159,7 +160,7 @@ export const getTransformHandlesFromCoords = ( }; // We only want to show height handles (all cardinal directions) above a certain size - const minimumSizeForEightHandles = (5 * size) / zoom; + const minimumSizeForEightHandles = (5 * size) / zoom.value; if (Math.abs(width) > minimumSizeForEightHandles) { if (!omitSides["n"]) { transformHandles["n"] = generateTransformHandle( @@ -214,7 +215,7 @@ export const getTransformHandlesFromCoords = ( export const getTransformHandles = ( element: ExcalidrawElement, - zoom: number, + zoom: Zoom, pointerType: PointerType = "mouse", ): TransformHandles => { let omitSides: { [T in TransformHandleType]?: boolean } = {}; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 2d43ec86..d59e337c 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -23,6 +23,10 @@ import { } from "../utils"; import { isPathALoop } from "../math"; import rough from "roughjs/bin/rough"; +import { Zoom } from "../types"; +import { getDefaultAppState } from "../appState"; + +const defaultAppState = getDefaultAppState(); const CANVAS_PADDING = 20; @@ -32,14 +36,14 @@ const DASHARRAY_DOTTED = [3, 6]; export interface ExcalidrawElementWithCanvas { element: ExcalidrawElement | ExcalidrawTextElement; canvas: HTMLCanvasElement; - canvasZoom: number; + canvasZoom: Zoom["value"]; canvasOffsetX: number; canvasOffsetY: number; } const generateElementCanvas = ( element: NonDeletedExcalidrawElement, - zoom: number, + zoom: Zoom, ): ExcalidrawElementWithCanvas => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; @@ -50,9 +54,11 @@ const generateElementCanvas = ( if (isLinearElement(element)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); canvas.width = - distance(x1, x2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + distance(x1, x2) * window.devicePixelRatio * zoom.value + + CANVAS_PADDING * 2; canvas.height = - distance(y1, y2) * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + distance(y1, y2) * window.devicePixelRatio * zoom.value + + CANVAS_PADDING * 2; canvasOffsetX = element.x > x1 @@ -62,25 +68,35 @@ const generateElementCanvas = ( element.y > y1 ? Math.floor(distance(element.y, y1)) * window.devicePixelRatio : 0; - context.translate(canvasOffsetX * zoom, canvasOffsetY * zoom); + context.translate(canvasOffsetX * zoom.value, canvasOffsetY * zoom.value); } else { canvas.width = - element.width * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + element.width * window.devicePixelRatio * zoom.value + CANVAS_PADDING * 2; canvas.height = - element.height * window.devicePixelRatio * zoom + CANVAS_PADDING * 2; + element.height * window.devicePixelRatio * zoom.value + + CANVAS_PADDING * 2; } context.translate(CANVAS_PADDING, CANVAS_PADDING); - context.scale(window.devicePixelRatio * zoom, window.devicePixelRatio * zoom); + context.scale( + window.devicePixelRatio * zoom.value, + window.devicePixelRatio * zoom.value, + ); const rc = rough.canvas(canvas); drawElementOnCanvas(element, rc, context); context.translate(-CANVAS_PADDING, -CANVAS_PADDING); context.scale( - 1 / (window.devicePixelRatio * zoom), - 1 / (window.devicePixelRatio * zoom), + 1 / (window.devicePixelRatio * zoom.value), + 1 / (window.devicePixelRatio * zoom.value), ); - return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY }; + return { + element, + canvas, + canvasZoom: zoom.value, + canvasOffsetX, + canvasOffsetY, + }; }; const drawElementOnCanvas = ( @@ -352,11 +368,11 @@ const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, sceneState?: SceneState, ) => { - const zoom = sceneState ? sceneState.zoom : 1; + const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom; const prevElementWithCanvas = elementWithCanvasCache.get(element); const shouldRegenerateBecauseZoom = prevElementWithCanvas && - prevElementWithCanvas.canvasZoom !== zoom && + prevElementWithCanvas.canvasZoom !== zoom.value && !sceneState?.shouldCacheIgnoreZoom; if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) { const elementWithCanvas = generateElementCanvas(element, zoom); diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index b24859cb..30a1cfb7 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughSVG } from "roughjs/bin/svg"; import oc from "open-color"; -import { FlooredNumber, AppState } from "../types"; +import { AppState, Zoom } from "../types"; import { ExcalidrawElement, NonDeletedExcalidrawElement, @@ -47,6 +47,7 @@ import { TransformHandles, TransformHandleType, } from "../element/transformHandles"; +import { viewportCoordsToSceneCoords } from "../utils"; const strokeRectWithRotation = ( context: CanvasRenderingContext2D, @@ -147,7 +148,7 @@ const renderLinearPointHandles = ( context.translate(sceneState.scrollX, sceneState.scrollY); const origStrokeStyle = context.strokeStyle; const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; + context.lineWidth = 1 / sceneState.zoom.value; LinearElementEditor.getPointsGlobalCoordinates(element).forEach( (point, idx) => { @@ -162,7 +163,7 @@ const renderLinearPointHandles = ( context, point[0], point[1], - POINT_HANDLE_SIZE / 2 / sceneState.zoom, + POINT_HANDLE_SIZE / 2 / sceneState.zoom.value, ); }, ); @@ -226,36 +227,36 @@ export const renderScene = ( } // Apply zoom - const zoomTranslationX = (-normalizedCanvasWidth * (sceneState.zoom - 1)) / 2; - const zoomTranslationY = - (-normalizedCanvasHeight * (sceneState.zoom - 1)) / 2; + const zoomTranslationX = sceneState.zoom.translation.x; + const zoomTranslationY = sceneState.zoom.translation.y; context.translate(zoomTranslationX, zoomTranslationY); - context.scale(sceneState.zoom, sceneState.zoom); + context.scale(sceneState.zoom.value, sceneState.zoom.value); // Grid if (renderGrid && appState.gridSize) { strokeGrid( context, appState.gridSize, - -Math.ceil(zoomTranslationX / sceneState.zoom / appState.gridSize) * + -Math.ceil(zoomTranslationX / sceneState.zoom.value / appState.gridSize) * appState.gridSize + (sceneState.scrollX % appState.gridSize), - -Math.ceil(zoomTranslationY / sceneState.zoom / appState.gridSize) * + -Math.ceil(zoomTranslationY / sceneState.zoom.value / appState.gridSize) * appState.gridSize + (sceneState.scrollY % appState.gridSize), - normalizedCanvasWidth / sceneState.zoom, - normalizedCanvasHeight / sceneState.zoom, + normalizedCanvasWidth / sceneState.zoom.value, + normalizedCanvasHeight / sceneState.zoom.value, ); } // Paint visible elements const visibleElements = elements.filter((element) => - isVisibleElement( - element, - normalizedCanvasWidth, - normalizedCanvasHeight, - sceneState, - ), + isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, { + zoom: sceneState.zoom, + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + scrollX: sceneState.scrollX, + scrollY: sceneState.scrollY, + }), ); visibleElements.forEach((element) => { @@ -378,13 +379,13 @@ export const renderScene = ( locallySelectedElements[0].angle, ); } else if (locallySelectedElements.length > 1 && !appState.isRotating) { - const dashedLinePadding = 4 / sceneState.zoom; + const dashedLinePadding = 4 / sceneState.zoom.value; context.fillStyle = oc.white; const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); const initialLineDash = context.getLineDash(); - context.setLineDash([2 / sceneState.zoom]); + context.setLineDash([2 / sceneState.zoom.value]); const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; + context.lineWidth = 1 / sceneState.zoom.value; strokeRectWithRotation( context, x1 - dashedLinePadding, @@ -410,7 +411,7 @@ export const renderScene = ( } // Reset zoom - context.scale(1 / sceneState.zoom, 1 / sceneState.zoom); + context.scale(1 / sceneState.zoom.value, 1 / sceneState.zoom.value); context.translate(-zoomTranslationX, -zoomTranslationY); // Paint remote pointers @@ -556,7 +557,7 @@ const renderTransformHandles = ( const transformHandle = transformHandles[key as TransformHandleType]; if (transformHandle !== undefined) { const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; + context.lineWidth = 1 / sceneState.zoom.value; if (key === "rotation") { fillCircle( context, @@ -610,11 +611,11 @@ const renderSelectionBorder = ( const lineDashOffset = context.lineDashOffset; const strokeStyle = context.strokeStyle; - const dashedLinePadding = 4 / sceneState.zoom; - const dashWidth = 8 / sceneState.zoom; - const spaceWidth = 4 / sceneState.zoom; + const dashedLinePadding = 4 / sceneState.zoom.value; + const dashWidth = 8 / sceneState.zoom.value; + const spaceWidth = 4 / sceneState.zoom.value; - context.lineWidth = 1 / sceneState.zoom; + context.lineWidth = 1 / sceneState.zoom.value; context.translate(sceneState.scrollX, sceneState.scrollY); @@ -749,32 +750,30 @@ const renderBindingHighlightForSuggestedPointBinding = ( const isVisibleElement = ( element: ExcalidrawElement, - viewportWidth: number, - viewportHeight: number, - { - scrollX, - scrollY, - zoom, - }: { - scrollX: FlooredNumber; - scrollY: FlooredNumber; - zoom: number; + canvasWidth: number, + canvasHeight: number, + viewTransformations: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; }, ) => { - const [x1, y1, x2, y2] = getElementBounds(element); - - // Apply zoom - const viewportWidthWithZoom = viewportWidth / zoom; - const viewportHeightWithZoom = viewportHeight / zoom; - - const viewportWidthDiff = viewportWidth - viewportWidthWithZoom; - const viewportHeightDiff = viewportHeight - viewportHeightWithZoom; - + const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates + const topLeftSceneCoords = viewportCoordsToSceneCoords( + { clientX: 0, clientY: 0 }, + viewTransformations, + ); + const bottomRightSceneCoords = viewportCoordsToSceneCoords( + { clientX: canvasWidth, clientY: canvasHeight }, + viewTransformations, + ); return ( - x2 + scrollX - viewportWidthDiff / 2 >= 0 && - x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom && - y2 + scrollY - viewportHeightDiff / 2 >= 0 && - y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom + topLeftSceneCoords.x <= x2 && + topLeftSceneCoords.y <= y2 && + bottomRightSceneCoords.x >= x1 && + bottomRightSceneCoords.y >= y1 ); }; diff --git a/src/scene/export.ts b/src/scene/export.ts index 829343ea..57d3c18f 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -9,6 +9,7 @@ import { normalizeScroll } from "./scroll"; import { AppState } from "../types"; import { t } from "../i18n"; import { DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_ALIGN } from "../constants"; +import { getDefaultAppState } from "../appState"; export const SVG_EXPORT_TAG = ``; const WATERMARK_HEIGHT = 16; @@ -60,7 +61,7 @@ export const exportToCanvas = ( viewBackgroundColor: exportBackground ? viewBackgroundColor : null, scrollX: normalizeScroll(-minX + exportPadding), scrollY: normalizeScroll(-minY + exportPadding), - zoom: 1, + zoom: getDefaultAppState().zoom, remotePointerViewportCoords: {}, remoteSelectedElementIds: {}, shouldCacheIgnoreZoom: false, diff --git a/src/scene/index.ts b/src/scene/index.ts index dddeeeb6..50a91792 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -16,4 +16,4 @@ export { hasText, getElementsAtPosition, } from "./comparisons"; -export { getZoomOrigin, getNormalizedZoom } from "./zoom"; +export { normalizeZoomValue as getNormalizedZoom, getNewZoom } from "./zoom"; diff --git a/src/scene/scroll.ts b/src/scene/scroll.ts index 75756a2e..f976faa1 100644 --- a/src/scene/scroll.ts +++ b/src/scene/scroll.ts @@ -1,4 +1,4 @@ -import { AppState, FlooredNumber } from "../types"; +import { AppState, FlooredNumber, PointerCoords, Zoom } from "../types"; import { ExcalidrawElement } from "../element/types"; import { getCommonBounds, getClosestElementBounds } from "../element"; @@ -19,14 +19,10 @@ function isOutsideViewPort( const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords( { sceneX: x1, sceneY: y1 }, appState, - canvas, - window.devicePixelRatio, ); const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords( { sceneX: x2, sceneY: y2 }, appState, - canvas, - window.devicePixelRatio, ); return ( viewportX2 - viewportX1 > appState.width || @@ -34,6 +30,29 @@ function isOutsideViewPort( ); } +export const centerScrollOn = ({ + scenePoint, + viewportDimensions, + zoom, +}: { + scenePoint: PointerCoords; + viewportDimensions: { height: number; width: number }; + zoom: Zoom; +}) => { + return { + scrollX: normalizeScroll( + (viewportDimensions.width / 2) * (1 / zoom.value) - + scenePoint.x - + zoom.translation.x * (1 / zoom.value), + ), + scrollY: normalizeScroll( + (viewportDimensions.height / 2) * (1 / zoom.value) - + scenePoint.y - + zoom.translation.y * (1 / zoom.value), + ), + }; +}; + export const calculateScrollCenter = ( elements: readonly ExcalidrawElement[], appState: AppState, @@ -45,7 +64,6 @@ export const calculateScrollCenter = ( scrollY: normalizeScroll(0), }; } - const scale = window.devicePixelRatio; let [x1, y1, x2, y2] = getCommonBounds(elements); if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) { @@ -54,8 +72,6 @@ export const calculateScrollCenter = ( viewportCoordsToSceneCoords( { clientX: appState.scrollX, clientY: appState.scrollY }, appState, - canvas, - scale, ), ); } @@ -63,8 +79,9 @@ export const calculateScrollCenter = ( const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; - return { - scrollX: normalizeScroll(appState.width / 2 - centerX), - scrollY: normalizeScroll(appState.height / 2 - centerY), - }; + return centerScrollOn({ + scenePoint: { x: centerX, y: centerY }, + viewportDimensions: { width: appState.width, height: appState.height }, + zoom: appState.zoom, + }); }; diff --git a/src/scene/scrollbars.ts b/src/scene/scrollbars.ts index af43b264..91e3a627 100644 --- a/src/scene/scrollbars.ts +++ b/src/scene/scrollbars.ts @@ -1,6 +1,6 @@ import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element"; -import { FlooredNumber } from "../types"; +import { FlooredNumber, Zoom } from "../types"; import { ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; @@ -20,7 +20,7 @@ export const getScrollBars = ( }: { scrollX: FlooredNumber; scrollY: FlooredNumber; - zoom: number; + zoom: Zoom; }, ): ScrollBars => { // This is the bounding box of all the elements @@ -32,8 +32,8 @@ export const getScrollBars = ( ] = getCommonBounds(elements); // Apply zoom - const viewportWidthWithZoom = viewportWidth / zoom; - const viewportHeightWithZoom = viewportHeight / zoom; + const viewportWidthWithZoom = viewportWidth / zoom.value; + const viewportHeightWithZoom = viewportHeight / zoom.value; const viewportWidthDiff = viewportWidth - viewportWidthWithZoom; const viewportHeightDiff = viewportHeight - viewportHeightWithZoom; diff --git a/src/scene/types.ts b/src/scene/types.ts index b2947308..5bb3f701 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -1,12 +1,12 @@ import { ExcalidrawTextElement } from "../element/types"; -import { FlooredNumber } from "../types"; +import { FlooredNumber, Zoom } from "../types"; export type SceneState = { scrollX: FlooredNumber; scrollY: FlooredNumber; // null indicates transparent bg viewBackgroundColor: string | null; - zoom: number; + zoom: Zoom; shouldCacheIgnoreZoom: boolean; remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; remotePointerButton?: { [id: string]: string | undefined }; diff --git a/src/scene/zoom.ts b/src/scene/zoom.ts index e300a2df..634ead09 100644 --- a/src/scene/zoom.ts +++ b/src/scene/zoom.ts @@ -1,26 +1,27 @@ -export const getZoomOrigin = ( - canvas: HTMLCanvasElement | null, - scale: number, -) => { - if (canvas === null) { - return { x: 0, y: 0 }; - } - const context = canvas.getContext("2d"); - if (context === null) { - return { x: 0, y: 0 }; - } - - const normalizedCanvasWidth = canvas.width / scale; - const normalizedCanvasHeight = canvas.height / scale; +import { NormalizedZoomValue, PointerCoords, Zoom } from "../types"; +export const getNewZoom = ( + newZoomValue: NormalizedZoomValue, + prevZoom: Zoom, + zoomOnViewportPoint: PointerCoords = { x: 0, y: 0 }, +): Zoom => { return { - x: normalizedCanvasWidth / 2, - y: normalizedCanvasHeight / 2, + value: newZoomValue, + translation: { + x: + zoomOnViewportPoint.x - + (zoomOnViewportPoint.x - prevZoom.translation.x) * + (newZoomValue / prevZoom.value), + y: + zoomOnViewportPoint.y - + (zoomOnViewportPoint.y - prevZoom.translation.y) * + (newZoomValue / prevZoom.value), + }, }; }; -export const getNormalizedZoom = (zoom: number): number => { +export const normalizeZoomValue = (zoom: number): NormalizedZoomValue => { const normalizedZoom = parseFloat(zoom.toFixed(2)); const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2)); - return clampedZoom; + return clampedZoom as NormalizedZoomValue; }; diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index d208a697..dc0f2408 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -73,7 +73,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -528,7 +534,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -965,7 +977,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -1730,7 +1748,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -1926,7 +1950,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -2366,7 +2396,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -2603,7 +2639,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -2757,7 +2799,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -3218,7 +3266,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -3516,7 +3570,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -3709,7 +3769,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -3938,7 +4004,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -4178,7 +4250,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -4568,7 +4646,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -4828,7 +4912,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -5142,7 +5232,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -5315,7 +5411,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -5466,7 +5568,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -5913,7 +6021,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -6211,7 +6325,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -8183,7 +8303,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -8533,7 +8659,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -8773,7 +8905,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9014,7 +9152,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9311,7 +9455,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9462,7 +9612,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9613,7 +9769,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9764,7 +9926,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -9941,7 +10109,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10118,7 +10292,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10295,7 +10475,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10472,7 +10658,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10623,7 +10815,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10774,7 +10972,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -10951,7 +11155,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -11102,7 +11312,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -11290,7 +11506,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -11986,7 +12208,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -12222,7 +12450,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0.3333333333333357, + "y": 0, + }, + "value": 1, + }, } `; @@ -12309,7 +12543,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -12414,7 +12654,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -13284,7 +13530,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -13720,7 +13972,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -14069,7 +14327,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -14335,7 +14599,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -14538,7 +14808,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -15362,7 +15638,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -16083,7 +16365,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -16705,7 +16993,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -17233,7 +17527,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -17714,7 +18014,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -18106,7 +18412,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -18413,7 +18725,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -18655,7 +18973,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -19532,7 +19856,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -20304,7 +20634,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -20975,7 +21311,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -21541,7 +21883,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -21692,7 +22040,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -21987,7 +22341,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -22282,7 +22642,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -22431,7 +22797,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -22616,7 +22988,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -22858,7 +23236,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -23163,7 +23547,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -23989,7 +24379,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -24284,7 +24680,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -24583,7 +24985,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -24948,7 +25356,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -25108,7 +25522,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -25413,7 +25833,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -25657,7 +26083,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -25961,7 +26393,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -26050,7 +26488,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -26212,7 +26656,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -27007,7 +27457,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -27096,7 +27552,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -27861,7 +28323,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -28251,7 +28719,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -28487,7 +28961,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1.99, + "zoom": Object { + "translation": Object { + "x": -60.420000000000016, + "y": -48.66347517730496, + }, + "value": 1.99, + }, } `; @@ -28576,7 +29056,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -29053,7 +29539,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; @@ -29140,7 +29632,13 @@ Object { "viewBackgroundColor": "#ffffff", "width": 1024, "zenModeEnabled": false, - "zoom": 1, + "zoom": Object { + "translation": Object { + "x": 0, + "y": 0, + }, + "value": 1, + }, } `; diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index b4dee6d8..2dde72d3 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -364,15 +364,15 @@ describe("regression tests", () => { }); it("pinch-to-zoom works", () => { - expect(h.state.zoom).toBe(1); + expect(h.state.zoom.value).toBe(1); finger1.down(50, 50); finger2.down(60, 50); finger1.move(-10, 0); - expect(h.state.zoom).toBeGreaterThan(1); - const zoomed = h.state.zoom; + expect(h.state.zoom.value).toBeGreaterThan(1); + const zoomed = h.state.zoom.value; finger1.move(5, 0); finger2.move(-5, 0); - expect(h.state.zoom).toBeLessThan(zoomed); + expect(h.state.zoom.value).toBeLessThan(zoomed); }); it("two-finger scroll works", () => { @@ -500,13 +500,13 @@ describe("regression tests", () => { }); it("zoom hotkeys", () => { - expect(h.state.zoom).toBe(1); + expect(h.state.zoom.value).toBe(1); fireEvent.keyDown(document, { code: "Equal", ctrlKey: true }); fireEvent.keyUp(document, { code: "Equal", ctrlKey: true }); - expect(h.state.zoom).toBeGreaterThan(1); + expect(h.state.zoom.value).toBeGreaterThan(1); fireEvent.keyDown(document, { code: "Minus", ctrlKey: true }); fireEvent.keyUp(document, { code: "Minus", ctrlKey: true }); - expect(h.state.zoom).toBe(1); + expect(h.state.zoom.value).toBe(1); }); it("rerenders UI on language change", async () => { diff --git a/src/types.ts b/src/types.ts index ffd08a30..23f06666 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,7 +73,7 @@ export type AppState = { isCollaborating: boolean; isResizing: boolean; isRotating: boolean; - zoom: number; + zoom: Zoom; openMenu: "canvas" | "shape" | null; lastPointerDownWith: PointerType; selectedElementIds: { [id: string]: boolean }; @@ -99,6 +99,16 @@ export type AppState = { fileHandle: import("browser-nativefs").FileSystemHandle | null; }; +export type NormalizedZoomValue = number & { _brand: "normalizedZoom" }; + +export type Zoom = Readonly<{ + value: NormalizedZoomValue; + translation: Readonly<{ + x: number; + y: number; + }>; +}>; + export type PointerCoords = Readonly<{ x: number; y: number; diff --git a/src/utils.ts b/src/utils.ts index 142aede4..1018d785 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ -import { AppState } from "./types"; -import { getZoomOrigin } from "./scene"; +import { Zoom } from "./types"; import { CURSOR_TYPE, FONT_FAMILY, @@ -183,42 +182,47 @@ export const getShortcutKey = (shortcut: string): string => { } return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`; }; + export const viewportCoordsToSceneCoords = ( { clientX, clientY }: { clientX: number; clientY: number }, - appState: AppState, - canvas: HTMLCanvasElement | null, - scale: number, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, ) => { - const zoomOrigin = getZoomOrigin(canvas, scale); - const clientXWithZoom = - zoomOrigin.x + - (clientX - zoomOrigin.x - appState.offsetLeft) / appState.zoom; - const clientYWithZoom = - zoomOrigin.y + - (clientY - zoomOrigin.y - appState.offsetTop) / appState.zoom; - - const x = clientXWithZoom - appState.scrollX; - const y = clientYWithZoom - appState.scrollY; - + const invScale = 1 / zoom.value; + const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX; + const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY; return { x, y }; }; export const sceneCoordsToViewportCoords = ( { sceneX, sceneY }: { sceneX: number; sceneY: number }, - appState: AppState, - canvas: HTMLCanvasElement | null, - scale: number, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, ) => { - const zoomOrigin = getZoomOrigin(canvas, scale); - const x = - zoomOrigin.x - - (zoomOrigin.x - sceneX - appState.scrollX - appState.offsetLeft) * - appState.zoom; - const y = - zoomOrigin.y - - (zoomOrigin.y - sceneY - appState.scrollY - appState.offsetTop) * - appState.zoom; - + const x = (sceneX + scrollX + offsetLeft) * zoom.value + zoom.translation.x; + const y = (sceneY + scrollY + offsetTop) * zoom.value + zoom.translation.y; return { x, y }; };