Bläddra i källkod

Zoom on cursor | Issue #940 (#2319)

João Forja 4 år sedan
förälder
incheckning
566e6a5ede

+ 51 - 48
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,
     };

+ 9 - 3
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,
       },

+ 5 - 2
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: {},

+ 5 - 3
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;
 }) => (
   <Stack.Col gap={1}>
     <Stack.Row gap={1} align="center">
       {renderAction("zoomIn")}
       {renderAction("zoomOut")}
       {renderAction("resetZoom")}
-      <div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
+      <div style={{ marginInlineStart: 4 }}>
+        {(zoom.value * 100).toFixed(0)}%
+      </div>
     </Stack.Row>
   </Stack.Col>
 );

+ 45 - 58
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<ExcalidrawProps, AppState> {
           sceneY: user.pointer.y,
         },
         this.state,
-        this.canvas,
-        window.devicePixelRatio,
       );
       cursorButton[socketId] = user.button;
     });
@@ -1146,8 +1145,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
             sceneY: y,
           },
           this.state,
-          this.canvas,
-          window.devicePixelRatio,
         );
         return [viewportX, viewportY];
       },
@@ -1990,8 +1987,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
       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<ExcalidrawProps, AppState> {
       }
     }
 
-    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<ExcalidrawProps, AppState> {
       }
 
       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<ExcalidrawProps, AppState> {
 
     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<ExcalidrawProps, AppState> {
   private initialPointerDownState(
     event: React.PointerEvent<HTMLCanvasElement>,
   ): 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<ExcalidrawProps, AppState> {
     }
 
     // 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<ExcalidrawProps, AppState> {
         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<ExcalidrawProps, AppState> {
             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<ExcalidrawProps, AppState> {
       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<ExcalidrawProps, AppState> {
       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<ExcalidrawProps, AppState> {
         const pointerCoords = viewportCoordsToSceneCoords(
           childEvent,
           this.state,
-          this.canvas,
-          window.devicePixelRatio,
         );
 
         if (
@@ -3808,8 +3798,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
 
     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<ExcalidrawProps, AppState> {
           });
         }, 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<ExcalidrawProps, AppState> {
     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<ExcalidrawProps, AppState> {
         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<ExcalidrawProps, AppState> {
     const pointer = viewportCoordsToSceneCoords(
       { clientX: x, clientY: y },
       this.state,
-      this.canvas,
-      window.devicePixelRatio,
     );
 
     if (isNaN(pointer.x) || isNaN(pointer.y)) {

+ 9 - 1
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,
   };
 };
 

+ 3 - 3
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"

+ 1 - 1
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
       ) {

+ 0 - 1
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;

+ 4 - 4
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(

+ 3 - 3
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 = ({

+ 11 - 10
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 } = {};

+ 29 - 13
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);

+ 48 - 49
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
   );
 };
 

+ 2 - 1
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 = `<!-- svg-source:excalidraw -->`;
 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,

+ 1 - 1
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";

+ 29 - 12
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,
+  });
 };

+ 4 - 4
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;

+ 2 - 2
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 };

+ 19 - 18
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;
 };

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 469 - 67
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 7 - 7
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 () => {

+ 11 - 1
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;

+ 33 - 29
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 };
 };
 

Vissa filer visades inte eftersom för många filer har ändrats