Browse Source

calculate coords based on container viewport position (#1955)

* feat: calculate coords based on parent left and top so it renders correctly in host App

* fix text

* move offsets to state & fix bugs

* fix text jumping

* account for zoom in textWysiwyg & undo incorrect offsetting

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 4 years ago
parent
commit
7eff6893c5

+ 2 - 2
src/actions/actionExport.tsx

@@ -135,7 +135,7 @@ export const actionLoadScene = register({
       commitToHistory: false,
     };
   },
-  PanelComponent: ({ updateData }) => (
+  PanelComponent: ({ updateData, appState }) => (
     <ToolButton
       type="button"
       icon={load}
@@ -143,7 +143,7 @@ export const actionLoadScene = register({
       aria-label={t("buttons.load")}
       showAriaLabel={useIsMobile()}
       onClick={() => {
-        loadFromJSON()
+        loadFromJSON(appState)
           .then(({ elements, appState }) => {
             updateData({ elements: elements, appState: appState });
           })

+ 1 - 1
src/actions/types.ts

@@ -6,7 +6,7 @@ import { AppState } from "../types";
 export type ActionResult =
   | {
       elements?: readonly ExcalidrawElement[] | null;
-      appState?: AppState | null;
+      appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
       commitToHistory: boolean;
       syncHistory?: boolean;
     }

+ 6 - 1
src/appState.ts

@@ -8,7 +8,10 @@ import {
   DEFAULT_TEXT_ALIGN,
 } from "./constants";
 
-export const getDefaultAppState = (): AppState => {
+export const getDefaultAppState = (): Omit<
+  AppState,
+  "offsetTop" | "offsetLeft"
+> => {
   return {
     isLoading: false,
     errorMessage: null,
@@ -126,6 +129,8 @@ const APP_STATE_STORAGE_CONF = (<
   width: { browser: false, export: false },
   zenModeEnabled: { browser: true, export: false },
   zoom: { browser: true, export: false },
+  offsetTop: { browser: false, export: false },
+  offsetLeft: { browser: false, export: false },
 });
 
 const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

+ 61 - 12
src/components/App.tsx

@@ -3,7 +3,7 @@ import React from "react";
 import rough from "roughjs/bin/rough";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { simplify, Point } from "points-on-curve";
-import { FlooredNumber, SocketUpdateData } from "../types";
+import { SocketUpdateData } from "../types";
 
 import {
   newElement,
@@ -244,6 +244,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   removeSceneCallback: SceneStateCallbackRemover | null = null;
   unmounted: boolean = false;
   actionManager: ActionManager;
+  private excalidrawRef: any;
 
   public static defaultProps: Partial<ExcalidrawProps> = {
     width: window.innerWidth,
@@ -260,8 +261,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       isLoading: true,
       width,
       height,
+      ...this.getCanvasOffsets(),
     };
 
+    this.excalidrawRef = React.createRef();
     this.actionManager = new ActionManager(
       this.syncActionResult,
       () => this.state,
@@ -278,6 +281,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       zenModeEnabled,
       width: canvasDOMWidth,
       height: canvasDOMHeight,
+      offsetTop,
+      offsetLeft,
     } = this.state;
 
     const canvasScale = window.devicePixelRatio;
@@ -286,7 +291,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     const canvasHeight = canvasDOMHeight * canvasScale;
 
     return (
-      <div className="excalidraw">
+      <div
+        className="excalidraw"
+        ref={this.excalidrawRef}
+        style={{
+          width: canvasDOMWidth,
+          height: canvasDOMHeight,
+          top: offsetTop,
+          left: offsetLeft,
+        }}
+      >
         <LayerUI
           canvas={this.canvas}
           appState={this.state}
@@ -369,6 +383,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
               editingElement || actionResult.appState?.editingElement || null,
             isCollaborating: state.isCollaborating,
             collaborators: state.collaborators,
+            width: state.width,
+            height: state.height,
+            offsetTop: state.offsetTop,
+            offsetLeft: state.offsetLeft,
           }),
           () => {
             if (actionResult.syncHistory) {
@@ -498,6 +516,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     if (isCollaborationScene) {
       this.initializeSocketClient({ showLoadingState: true });
     } else if (scene) {
+      if (scene.appState) {
+        scene.appState = {
+          ...scene.appState,
+          ...calculateScrollCenter(
+            scene.elements,
+            {
+              ...scene.appState,
+              offsetTop: this.state.offsetTop,
+              offsetLeft: this.state.offsetLeft,
+            },
+            null,
+          ),
+        };
+      }
       this.syncActionResult(scene);
     }
   };
@@ -533,7 +565,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     );
 
     this.addEventListeners();
-    this.initializeScene();
+    this.setState(this.getCanvasOffsets(), () => {
+      this.initializeScene();
+    });
   }
 
   public componentWillUnmount() {
@@ -667,6 +701,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.setState({
         width: currentWidth,
         height: currentHeight,
+        ...this.getCanvasOffsets(),
       });
     }
 
@@ -1548,10 +1583,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
 
     textWysiwyg({
       id: element.id,
-      zoom: this.state.zoom,
+      appState: this.state,
       getViewportCoords: (x, y) => {
         const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
-          { sceneX: x, sceneY: y },
+          {
+            sceneX: x,
+            sceneY: y,
+          },
           this.state,
           this.canvas,
           window.devicePixelRatio,
@@ -3185,7 +3223,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       file?.name.endsWith(".excalidraw")
     ) {
       this.setState({ isLoading: true });
-      loadFromBlob(file)
+      loadFromBlob(file, this.state)
         .then(({ elements, appState }) =>
           this.syncActionResult({
             elements,
@@ -3349,11 +3387,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   private getTextWysiwygSnappedToCenterPosition(
     x: number,
     y: number,
-    state: {
-      scrollX: FlooredNumber;
-      scrollY: FlooredNumber;
-      zoom: number;
-    },
+    appState: AppState,
     canvas: HTMLCanvasElement | null,
     scale: number,
   ) {
@@ -3378,7 +3412,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       if (isSnappedToCenter) {
         const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
           { sceneX: elementCenterX, sceneY: elementCenterY },
-          state,
+          appState,
           canvas,
           scale,
         );
@@ -3421,6 +3455,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       this.state,
     );
   }, 300);
+
+  private getCanvasOffsets() {
+    if (this.excalidrawRef?.current) {
+      const parentElement = this.excalidrawRef.current.parentElement;
+      const { left, top } = parentElement.getBoundingClientRect();
+      return {
+        offsetLeft: left,
+        offsetTop: top,
+      };
+    }
+    return {
+      offsetLeft: 0,
+      offsetTop: 0,
+    };
+  }
 }
 
 // -----------------------------------------------------------------------------

+ 25 - 23
src/data/blob.ts

@@ -2,28 +2,13 @@ import { getDefaultAppState, cleanAppStateForExport } from "../appState";
 import { restore } from "./restore";
 import { t } from "../i18n";
 import { AppState } from "../types";
+import { calculateScrollCenter } from "../scene";
 
-export const loadFromBlob = async (blob: any) => {
-  const updateAppState = (contents: string) => {
-    const defaultAppState = getDefaultAppState();
-    let elements = [];
-    let appState = defaultAppState;
-    try {
-      const data = JSON.parse(contents);
-      if (data.type !== "excalidraw") {
-        throw new Error(t("alerts.couldNotLoadInvalidFile"));
-      }
-      elements = data.elements || [];
-      appState = {
-        ...defaultAppState,
-        ...cleanAppStateForExport(data.appState as Partial<AppState>),
-      };
-    } catch {
-      throw new Error(t("alerts.couldNotLoadInvalidFile"));
-    }
-    return { elements, appState };
-  };
-
+/**
+ * @param blob
+ * @param appState if provided, used for centering scroll to restored scene
+ */
+export const loadFromBlob = async (blob: any, appState?: AppState) => {
   if (blob.handle) {
     (window as any).handle = blob.handle;
   }
@@ -42,6 +27,23 @@ export const loadFromBlob = async (blob: any) => {
     });
   }
 
-  const { elements, appState } = updateAppState(contents);
-  return restore(elements, appState, { scrollToContent: true });
+  const defaultAppState = getDefaultAppState();
+  let elements = [];
+  let _appState = appState || defaultAppState;
+  try {
+    const data = JSON.parse(contents);
+    if (data.type !== "excalidraw") {
+      throw new Error(t("alerts.couldNotLoadInvalidFile"));
+    }
+    elements = data.elements || [];
+    _appState = {
+      ...defaultAppState,
+      ...cleanAppStateForExport(data.appState as Partial<AppState>),
+      ...(appState ? calculateScrollCenter(elements, appState, null) : {}),
+    };
+  } catch {
+    throw new Error(t("alerts.couldNotLoadInvalidFile"));
+  }
+
+  return restore(elements, _appState);
 };

+ 3 - 3
src/data/index.ts

@@ -237,7 +237,7 @@ export const importFromBackend = async (
   privateKey: string | undefined,
 ) => {
   let elements: readonly ExcalidrawElement[] = [];
-  let appState: AppState = getDefaultAppState();
+  let appState = getDefaultAppState();
 
   try {
     const response = await fetch(
@@ -245,7 +245,7 @@ export const importFromBackend = async (
     );
     if (!response.ok) {
       window.alert(t("alerts.importBackendFailed"));
-      return restore(elements, appState, { scrollToContent: true });
+      return restore(elements, appState);
     }
     let data;
     if (privateKey) {
@@ -276,7 +276,7 @@ export const importFromBackend = async (
     window.alert(t("alerts.importBackendFailed"));
     console.error(error);
   } finally {
-    return restore(elements, appState, { scrollToContent: true });
+    return restore(elements, appState);
   }
 };
 

+ 2 - 2
src/data/json.ts

@@ -42,11 +42,11 @@ export const saveAsJSON = async (
   );
 };
 
-export const loadFromJSON = async () => {
+export const loadFromJSON = async (appState: AppState) => {
   const blob = await fileOpen({
     description: "Excalidraw files",
     extensions: ["json", "excalidraw"],
     mimeTypes: ["application/json"],
   });
-  return loadFromBlob(blob);
+  return loadFromBlob(blob, appState);
 };

+ 1 - 10
src/data/restore.ts

@@ -6,7 +6,6 @@ import {
 import { AppState } from "../types";
 import { DataState } from "./types";
 import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
-import { calculateScrollCenter } from "../scene";
 import { randomId } from "../random";
 import {
   FONT_FAMILY,
@@ -110,8 +109,7 @@ const migrateElement = (
 
 export const restore = (
   savedElements: readonly ExcalidrawElement[],
-  savedState: AppState | null,
-  opts?: { scrollToContent: boolean },
+  savedState: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null,
 ): DataState => {
   const elements = savedElements.reduce((elements, element) => {
     // filtering out selection, which is legacy, no longer kept in elements,
@@ -125,13 +123,6 @@ export const restore = (
     return elements;
   }, [] as ExcalidrawElement[]);
 
-  if (opts?.scrollToContent && savedState) {
-    savedState = {
-      ...savedState,
-      ...calculateScrollCenter(elements, savedState, null),
-    };
-  }
-
   return {
     elements: elements,
     appState: savedState,

+ 1 - 1
src/data/types.ts

@@ -6,5 +6,5 @@ export interface DataState {
   version?: string;
   source?: string;
   elements: readonly ExcalidrawElement[];
-  appState: AppState | null;
+  appState: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
 }

+ 10 - 6
src/element/textWysiwyg.tsx

@@ -4,6 +4,7 @@ import { globalSceneState } from "../scene";
 import { isTextElement } from "./typeChecks";
 import { CLASSES } from "../constants";
 import { ExcalidrawElement } from "./types";
+import { AppState } from "../types";
 
 const normalizeText = (text: string) => {
   return (
@@ -19,23 +20,26 @@ const getTransform = (
   width: number,
   height: number,
   angle: number,
-  zoom: number,
+  appState: AppState,
 ) => {
+  const { zoom, offsetTop, offsetLeft } = appState;
   const degree = (180 * angle) / Math.PI;
-  return `translate(${(width * (zoom - 1)) / 2}px, ${
-    (height * (zoom - 1)) / 2
+  // 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)`;
 };
 
 export const textWysiwyg = ({
   id,
-  zoom,
+  appState,
   onChange,
   onSubmit,
   getViewportCoords,
 }: {
   id: ExcalidrawElement["id"];
-  zoom: number;
+  appState: AppState;
   onChange?: (text: string) => void;
   onSubmit: (text: string) => void;
   getViewportCoords: (x: number, y: number) => [number, number];
@@ -66,7 +70,7 @@ export const textWysiwyg = ({
           updatedElement.width,
           updatedElement.height,
           angle,
-          zoom,
+          appState,
         ),
         textAlign: textAlign,
         color: updatedElement.strokeColor,

+ 5 - 1
src/index-node.ts

@@ -59,7 +59,11 @@ registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
 
 const canvas = exportToCanvas(
   elements as any,
-  getDefaultAppState(),
+  {
+    ...getDefaultAppState(),
+    offsetTop: 0,
+    offsetLeft: 0,
+  },
   {
     exportBackground: true,
     viewBackgroundColor: "#ffffff",

+ 1 - 0
src/scene/scroll.ts

@@ -47,6 +47,7 @@ export const calculateScrollCenter = (
   }
   const scale = window.devicePixelRatio;
   let [x1, y1, x2, y2] = getCommonBounds(elements);
+
   if (isOutsideViewPort(appState, canvas, [x1, y1, x2, y2])) {
     [x1, y1, x2, y2] = getClosestElementBounds(
       elements,

File diff suppressed because it is too large
+ 168 - 56
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 10 - 10
src/tests/dragCreate.test.tsx

@@ -37,7 +37,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
 
     expect(h.elements.length).toEqual(1);
@@ -68,7 +68,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
 
     expect(h.elements.length).toEqual(1);
@@ -99,7 +99,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
 
     expect(h.elements.length).toEqual(1);
@@ -130,7 +130,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
 
     expect(h.elements.length).toEqual(1);
@@ -165,7 +165,7 @@ describe("add element to the scene when pointer dragging long enough", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
 
     expect(h.elements.length).toEqual(1);
@@ -198,7 +198,7 @@ describe("do not add element to the scene if size is too small", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(0);
   });
@@ -217,7 +217,7 @@ describe("do not add element to the scene if size is too small", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(0);
   });
@@ -236,7 +236,7 @@ describe("do not add element to the scene if size is too small", () => {
     // finish (position does not matter)
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(0);
   });
@@ -258,7 +258,7 @@ describe("do not add element to the scene if size is too small", () => {
     // we need to finalize it because arrows and lines enter multi-mode
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(0);
   });
@@ -280,7 +280,7 @@ describe("do not add element to the scene if size is too small", () => {
     // we need to finalize it because arrows and lines enter multi-mode
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(4);
+    expect(renderScene).toHaveBeenCalledTimes(5);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(0);
   });

+ 2 - 2
src/tests/move.test.tsx

@@ -30,7 +30,7 @@ describe("move element", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(4);
+      expect(renderScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -65,7 +65,7 @@ describe("duplicate element on move when ALT is clicked", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(4);
+      expect(renderScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

+ 5 - 5
src/tests/multiPointCreate.test.tsx

@@ -30,7 +30,7 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.elements.length).toEqual(0);
   });
 
@@ -44,7 +44,7 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.elements.length).toEqual(0);
   });
 
@@ -58,7 +58,7 @@ describe("remove shape in non linear elements", () => {
     fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 });
     fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.elements.length).toEqual(0);
   });
 });
@@ -88,7 +88,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.pointerUp(canvas);
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(10);
+    expect(renderScene).toHaveBeenCalledTimes(11);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;
@@ -129,7 +129,7 @@ describe("multi point mode in linear elements", () => {
     fireEvent.pointerUp(canvas);
     fireEvent.keyDown(document, { key: KEYS.ENTER });
 
-    expect(renderScene).toHaveBeenCalledTimes(10);
+    expect(renderScene).toHaveBeenCalledTimes(11);
     expect(h.elements.length).toEqual(1);
 
     const element = h.elements[0] as ExcalidrawLinearElement;

+ 2 - 2
src/tests/resize.test.tsx

@@ -30,7 +30,7 @@ describe("resize element", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(4);
+      expect(renderScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -73,7 +73,7 @@ describe("resize element with aspect ratio when SHIFT is clicked", () => {
       fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 });
       fireEvent.pointerUp(canvas);
 
-      expect(renderScene).toHaveBeenCalledTimes(4);
+      expect(renderScene).toHaveBeenCalledTimes(5);
       expect(h.state.selectionElement).toBeNull();
       expect(h.elements.length).toEqual(1);
       expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

+ 8 - 8
src/tests/selection.test.tsx

@@ -28,7 +28,7 @@ describe("selection element", () => {
     const canvas = container.querySelector("canvas")!;
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
 
-    expect(renderScene).toHaveBeenCalledTimes(1);
+    expect(renderScene).toHaveBeenCalledTimes(2);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -49,7 +49,7 @@ describe("selection element", () => {
     fireEvent.pointerDown(canvas, { clientX: 60, clientY: 100 });
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
 
-    expect(renderScene).toHaveBeenCalledTimes(2);
+    expect(renderScene).toHaveBeenCalledTimes(3);
     const selectionElement = h.state.selectionElement!;
     expect(selectionElement).not.toBeNull();
     expect(selectionElement.type).toEqual("selection");
@@ -71,7 +71,7 @@ describe("selection element", () => {
     fireEvent.pointerMove(canvas, { clientX: 150, clientY: 30 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(3);
+    expect(renderScene).toHaveBeenCalledTimes(4);
     expect(h.state.selectionElement).toBeNull();
   });
 });
@@ -96,7 +96,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderScene).toHaveBeenCalledTimes(8);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -123,7 +123,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderScene).toHaveBeenCalledTimes(8);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -150,7 +150,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 45, clientY: 20 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderScene).toHaveBeenCalledTimes(8);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -190,7 +190,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderScene).toHaveBeenCalledTimes(8);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -229,7 +229,7 @@ describe("select single element on the scene", () => {
     fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
     fireEvent.pointerUp(canvas);
 
-    expect(renderScene).toHaveBeenCalledTimes(7);
+    expect(renderScene).toHaveBeenCalledTimes(8);
     expect(h.state.selectionElement).toBeNull();
     expect(h.elements.length).toEqual(1);
     expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();

+ 2 - 0
src/types.ts

@@ -81,6 +81,8 @@ export type AppState = {
   editingGroupId: GroupId | null;
   width: number;
   height: number;
+  offsetTop: number;
+  offsetLeft: number;
 
   isLibraryOpen: boolean;
 };

+ 19 - 25
src/utils.ts

@@ -1,4 +1,4 @@
-import { FlooredNumber } from "./types";
+import { AppState } from "./types";
 import { getZoomOrigin } from "./scene";
 import {
   CURSOR_TYPE,
@@ -185,45 +185,39 @@ export const getShortcutKey = (shortcut: string): string => {
 };
 export const viewportCoordsToSceneCoords = (
   { clientX, clientY }: { clientX: number; clientY: number },
-  {
-    scrollX,
-    scrollY,
-    zoom,
-  }: {
-    scrollX: FlooredNumber;
-    scrollY: FlooredNumber;
-    zoom: number;
-  },
+  appState: AppState,
   canvas: HTMLCanvasElement | null,
   scale: number,
 ) => {
   const zoomOrigin = getZoomOrigin(canvas, scale);
-  const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
-  const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
+  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 - scrollX;
-  const y = clientYWithZoom - scrollY;
+  const x = clientXWithZoom - appState.scrollX;
+  const y = clientYWithZoom - appState.scrollY;
 
   return { x, y };
 };
 
 export const sceneCoordsToViewportCoords = (
   { sceneX, sceneY }: { sceneX: number; sceneY: number },
-  {
-    scrollX,
-    scrollY,
-    zoom,
-  }: {
-    scrollX: FlooredNumber;
-    scrollY: FlooredNumber;
-    zoom: number;
-  },
+  appState: AppState,
   canvas: HTMLCanvasElement | null,
   scale: number,
 ) => {
   const zoomOrigin = getZoomOrigin(canvas, scale);
-  const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
-  const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
+  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;
 
   return { x, y };
 };

Some files were not shown because too many files changed in this diff