Ver Fonte

Canvas zooming (#716)

* Zoom icons.

* Actions.

* Min zoom of 0 does not make sense.

* Zoom logic.

* Modify how zoom affects selection rendering.

* More precise scrollbar dimensions.

* Adjust elements visibility and scrollbars.

* Normalized canvas width and height.

* Apply zoom to resize test.

* [WIP] Zoom using canvas center as an origin.

* Undo zoom on `getScrollBars`.

* WIP: center zoom origin via scroll

* This was wrong for sure.

* Finish scaling using center as origin.

* Almost there.

* Scroll offset should be not part of zoom transforms.

* Better naming.

* Wheel movement should be the same no matter the zoom level.

* Panning movement should be the same no matter the zoom level.

* Fix elements pasting.

* Fix text WYSIWGT.

* Fix scrollbars and visibility.
Enzo Ferey há 5 anos atrás
pai
commit
c7ff4c2ed6

+ 55 - 1
src/actions/actionCanvas.tsx

@@ -2,7 +2,7 @@ import React from "react";
 import { Action } from "./types";
 import { ColorPicker } from "../components/ColorPicker";
 import { getDefaultAppState } from "../appState";
-import { trash } from "../components/icons";
+import { trash, zoomIn, zoomOut } from "../components/icons";
 import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 
@@ -53,3 +53,57 @@ export const actionClearCanvas: Action = {
     />
   ),
 };
+
+const ZOOM_STEP = 0.1;
+
+function getNormalizedZoom(zoom: number): number {
+  const normalizedZoom = parseFloat(zoom.toFixed(2));
+  const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2));
+  return clampedZoom;
+}
+
+export const actionZoomIn: Action = {
+  name: "zoomIn",
+  perform: (elements, appState) => {
+    return {
+      appState: {
+        ...appState,
+        zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
+      },
+    };
+  },
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={zoomIn}
+      title={t("buttons.zoomIn")}
+      aria-label={t("buttons.zoomIn")}
+      onClick={() => {
+        updateData(null);
+      }}
+    />
+  ),
+};
+
+export const actionZoomOut: Action = {
+  name: "zoomOut",
+  perform: (elements, appState) => {
+    return {
+      appState: {
+        ...appState,
+        zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
+      },
+    };
+  },
+  PanelComponent: ({ updateData }) => (
+    <ToolButton
+      type="button"
+      icon={zoomOut}
+      title={t("buttons.zoomOut")}
+      aria-label={t("buttons.zoomOut")}
+      onClick={() => {
+        updateData(null);
+      }}
+    />
+  ),
+};

+ 2 - 0
src/actions/index.ts

@@ -21,6 +21,8 @@ export {
 export {
   actionChangeViewBackgroundColor,
   actionClearCanvas,
+  actionZoomIn,
+  actionZoomOut,
 } from "./actionCanvas";
 
 export { actionFinalize } from "./actionFinalize";

+ 1 - 0
src/appState.ts

@@ -28,6 +28,7 @@ export function getDefaultAppState(): AppState {
     name: DEFAULT_PROJECT_NAME,
     isResizing: false,
     selectionElement: null,
+    zoom: 1,
   };
 }
 

+ 18 - 0
src/components/icons.tsx

@@ -76,3 +76,21 @@ export const exportFile = (
     />
   </svg>
 );
+
+export const zoomIn = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
+    <path
+      fill="currentColor"
+      d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
+    />
+  </svg>
+);
+
+export const zoomOut = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
+    <path
+      fill="currentColor"
+      d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
+    />
+  </svg>
+);

+ 3 - 1
src/element/collision.ts

@@ -1,6 +1,7 @@
 import { distanceBetweenPointAndSegment } from "../math";
 
 import { ExcalidrawElement } from "./types";
+
 import {
   getDiamondPoints,
   getElementAbsoluteCoords,
@@ -17,10 +18,11 @@ export function hitTest(
   element: ExcalidrawElement,
   x: number,
   y: number,
+  zoom: number,
 ): boolean {
   // For shapes that are composed of lines, we only enable point-selection when the distance
   // of the click is less than x pixels of any of the lines that the shape is composed of
-  const lineThreshold = 10;
+  const lineThreshold = 10 / zoom;
 
   if (element.type === "ellipse") {
     // https://stackoverflow.com/a/46007540/232122

+ 65 - 80
src/element/handlerRectangles.ts

@@ -1,98 +1,83 @@
 import { ExcalidrawElement } from "./types";
-import { SceneScroll } from "../scene/types";
-import { getLinearElementAbsoluteBounds } from "./bounds";
 
-type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
+import { getElementAbsoluteCoords } from "./bounds";
 
-export function handlerRectangles(
-  element: ExcalidrawElement,
-  { scrollX, scrollY }: SceneScroll,
-) {
-  let elementX2 = 0;
-  let elementY2 = 0;
-  let elementX1 = Infinity;
-  let elementY1 = Infinity;
-  let marginX = -8;
-  let marginY = -8;
-
-  const minimumSize = 40;
-  if (element.type === "arrow" || element.type === "line") {
-    [
-      elementX1,
-      elementY1,
-      elementX2,
-      elementY2,
-    ] = getLinearElementAbsoluteBounds(element);
-  } else {
-    elementX1 = element.x;
-    elementX2 = element.x + element.width;
-    elementY1 = element.y;
-    elementY2 = element.y + element.height;
-
-    marginX = element.width < 0 ? 8 : -8;
-    marginY = element.height < 0 ? 8 : -8;
-  }
-
-  const margin = 4;
-  const handlers = {} as { [T in Sides]: number[] };
+type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
 
-  if (Math.abs(elementX2 - elementX1) > minimumSize) {
+export function handlerRectangles(element: ExcalidrawElement, zoom: number) {
+  const handlerWidth = 8 / zoom;
+  const handlerHeight = 8 / zoom;
+
+  const handlerMarginX = 8 / zoom;
+  const handlerMarginY = 8 / zoom;
+
+  const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords(
+    element,
+  );
+
+  const elementWidth = elementX2 - elementX1;
+  const elementHeight = elementY2 - elementY1;
+
+  const dashedLineMargin = 4 / zoom;
+
+  const handlers = {
+    nw: [
+      elementX1 - dashedLineMargin - handlerMarginX,
+      elementY1 - dashedLineMargin - handlerMarginY,
+      handlerWidth,
+      handlerHeight,
+    ],
+    ne: [
+      elementX2 + dashedLineMargin,
+      elementY1 - dashedLineMargin - handlerMarginY,
+      handlerWidth,
+      handlerHeight,
+    ],
+    sw: [
+      elementX1 - dashedLineMargin - handlerMarginX,
+      elementY2 + dashedLineMargin,
+      handlerWidth,
+      handlerHeight,
+    ],
+    se: [
+      elementX2 + dashedLineMargin,
+      elementY2 + dashedLineMargin,
+      handlerWidth,
+      handlerHeight,
+    ],
+  } as { [T in Sides]: number[] };
+
+  // We only want to show height handlers (all cardinal directions)  above a certain size
+  const minimumSizeForEightHandlers = 40 / zoom;
+  if (Math.abs(elementWidth) > minimumSizeForEightHandlers) {
     handlers["n"] = [
-      elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
-      elementY1 - margin + scrollY + marginY,
-      8,
-      8,
+      elementX1 + elementWidth / 2,
+      elementY1 - dashedLineMargin - handlerMarginY,
+      handlerWidth,
+      handlerHeight,
     ];
-
     handlers["s"] = [
-      elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
-      elementY2 - margin + scrollY - marginY,
-      8,
-      8,
+      elementX1 + elementWidth / 2,
+      elementY2 + dashedLineMargin,
+      handlerWidth,
+      handlerHeight,
     ];
   }
-
-  if (Math.abs(elementY2 - elementY1) > minimumSize) {
+  if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
     handlers["w"] = [
-      elementX1 - margin + scrollX + marginX,
-      elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
-      8,
-      8,
+      elementX1 - dashedLineMargin - handlerMarginX,
+      elementY1 + elementHeight / 2,
+      handlerWidth,
+      handlerHeight,
     ];
-
     handlers["e"] = [
-      elementX2 - margin + scrollX - marginX,
-      elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
-      8,
-      8,
+      elementX2 + dashedLineMargin,
+      elementY1 + elementHeight / 2,
+      handlerWidth,
+      handlerHeight,
     ];
   }
 
-  handlers["nw"] = [
-    elementX1 - margin + scrollX + marginX,
-    elementY1 - margin + scrollY + marginY,
-    8,
-    8,
-  ]; // nw
-  handlers["ne"] = [
-    elementX2 - margin + scrollX - marginX,
-    elementY1 - margin + scrollY + marginY,
-    8,
-    8,
-  ]; // ne
-  handlers["sw"] = [
-    elementX1 - margin + scrollX + marginX,
-    elementY2 - margin + scrollY - marginY,
-    8,
-    8,
-  ]; // sw
-  handlers["se"] = [
-    elementX2 - margin + scrollX - marginX,
-    elementY2 - margin + scrollY - marginY,
-    8,
-    8,
-  ]; // se
-
   if (element.type === "arrow" || element.type === "line") {
     if (element.points.length === 2) {
       // only check the last point because starting point is always (0,0)

+ 8 - 12
src/element/resizeTest.ts

@@ -1,7 +1,6 @@
 import { ExcalidrawElement } from "./types";
 
 import { handlerRectangles } from "./handlerRectangles";
-import { SceneScroll } from "../scene/types";
 
 type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
 
@@ -9,13 +8,13 @@ export function resizeTest(
   element: ExcalidrawElement,
   x: number,
   y: number,
-  { scrollX, scrollY }: SceneScroll,
+  zoom: number,
 ): HandlerRectanglesRet | false {
   if (!element.isSelected || element.type === "text") {
     return false;
   }
 
-  const handlers = handlerRectangles(element, { scrollX, scrollY });
+  const handlers = handlerRectangles(element, zoom);
 
   const filter = Object.keys(handlers).filter(key => {
     const handler = handlers[key as HandlerRectanglesRet]!;
@@ -24,10 +23,10 @@ export function resizeTest(
     }
 
     return (
-      x + scrollX >= handler[0] &&
-      x + scrollX <= handler[0] + handler[2] &&
-      y + scrollY >= handler[1] &&
-      y + scrollY <= handler[1] + handler[3]
+      x >= handler[0] &&
+      x <= handler[0] + handler[2] &&
+      y >= handler[1] &&
+      y <= handler[1] + handler[3]
     );
   });
 
@@ -41,16 +40,13 @@ export function resizeTest(
 export function getElementWithResizeHandler(
   elements: readonly ExcalidrawElement[],
   { x, y }: { x: number; y: number },
-  { scrollX, scrollY }: SceneScroll,
+  zoom: number,
 ) {
   return elements.reduce((result, element) => {
     if (result) {
       return result;
     }
-    const resizeHandle = resizeTest(element, x, y, {
-      scrollX,
-      scrollY,
-    });
+    const resizeHandle = resizeTest(element, x, y, zoom);
     return resizeHandle ? { element, resizeHandle } : null;
   }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
 }

+ 3 - 1
src/element/textWysiwyg.tsx

@@ -8,6 +8,7 @@ type TextWysiwygParams = {
   strokeColor: string;
   font: string;
   opacity: number;
+  zoom: number;
   onSubmit: (text: string) => void;
   onCancel: () => void;
 };
@@ -25,6 +26,7 @@ export function textWysiwyg({
   strokeColor,
   font,
   opacity,
+  zoom,
   onSubmit,
   onCancel,
 }: TextWysiwygParams) {
@@ -43,7 +45,7 @@ export function textWysiwyg({
     opacity: opacity / 100,
     top: `${y}px`,
     left: `${x}px`,
-    transform: "translate(-50%, -50%)",
+    transform: `translate(-50%, -50%) scale(${zoom})`,
     textAlign: "left",
     display: "inline-block",
     font: font,

+ 205 - 72
src/index.tsx

@@ -34,6 +34,7 @@ import {
   loadScene,
   calculateScrollCenter,
   loadFromBlob,
+  getZoomOrigin,
 } from "./scene";
 
 import { renderScene } from "./renderer";
@@ -77,6 +78,8 @@ import {
   actionChangeFontFamily,
   actionChangeViewBackgroundColor,
   actionClearCanvas,
+  actionZoomIn,
+  actionZoomOut,
   actionChangeProjectName,
   actionChangeExportBackground,
   actionLoadScene,
@@ -127,17 +130,53 @@ const MOUSE_BUTTON = {
   SECONDARY: 2,
 };
 
-let lastCanvasWidth = -1;
-let lastCanvasHeight = -1;
-
 let lastMouseUp: ((e: any) => void) | null = null;
 
 export function viewportCoordsToSceneCoords(
   { clientX, clientY }: { clientX: number; clientY: number },
-  { scrollX, scrollY }: { scrollX: number; scrollY: number },
+  {
+    scrollX,
+    scrollY,
+    zoom,
+  }: {
+    scrollX: number;
+    scrollY: number;
+    zoom: number;
+  },
+  canvas: HTMLCanvasElement | null,
 ) {
-  const x = clientX - scrollX;
-  const y = clientY - scrollY;
+  const zoomOrigin = getZoomOrigin(canvas);
+  const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
+  const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
+
+  const x = clientXWithZoom - scrollX;
+  const y = clientYWithZoom - scrollY;
+
+  return { x, y };
+}
+
+export function sceneCoordsToViewportCoords(
+  { sceneX, sceneY }: { sceneX: number; sceneY: number },
+  {
+    scrollX,
+    scrollY,
+    zoom,
+  }: {
+    scrollX: number;
+    scrollY: number;
+    zoom: number;
+  },
+  canvas: HTMLCanvasElement | null,
+) {
+  const zoomOrigin = getZoomOrigin(canvas);
+  const sceneXWithZoomAndScroll =
+    zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
+  const sceneYWithZoomAndScroll =
+    zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
+
+  const x = sceneXWithZoomAndScroll;
+  const y = sceneYWithZoomAndScroll;
+
   return { x, y };
 }
 
@@ -320,6 +359,20 @@ const LayerUI = React.memo(
       );
     }
 
+    function renderZoomActions() {
+      return (
+        <Stack.Col gap={1}>
+          <Stack.Row gap={1} align="center">
+            {actionManager.renderAction("zoomIn")}
+            {actionManager.renderAction("zoomOut")}
+            <div style={{ marginLeft: 4 }}>
+              {(appState.zoom * 100).toFixed(0)}%
+            </div>
+          </Stack.Row>
+        </Stack.Col>
+      );
+    }
+
     return (
       <>
         <FixedSideContainer side="top">
@@ -370,6 +423,16 @@ const LayerUI = React.memo(
             </section>
             <div />
           </div>
+          <div className="App-menu App-menu_bottom">
+            <Stack.Col gap={2}>
+              <section aria-labelledby="canvas-zoom-actions-title">
+                <h2 className="visually-hidden" id="canvas-zoom-actions-title">
+                  {t("headings.canvasActions")}
+                </h2>
+                <Island padding={1}>{renderZoomActions()}</Island>
+              </section>
+            </Stack.Col>
+          </div>
         </FixedSideContainer>
         <footer role="contentinfo">
           <HintViewer
@@ -459,6 +522,8 @@ export class App extends React.Component<any, AppState> {
 
     this.actionManager.registerAction(actionChangeViewBackgroundColor);
     this.actionManager.registerAction(actionClearCanvas);
+    this.actionManager.registerAction(actionZoomIn);
+    this.actionManager.registerAction(actionZoomOut);
 
     this.actionManager.registerAction(actionChangeProjectName);
     this.actionManager.registerAction(actionChangeExportBackground);
@@ -684,8 +749,6 @@ export class App extends React.Component<any, AppState> {
     }
   };
 
-  private removeWheelEventListener: (() => void) | undefined;
-
   private copyToAppClipboard = () => {
     copyToAppClipboard(elements);
   };
@@ -708,6 +771,7 @@ export class App extends React.Component<any, AppState> {
         const { x, y } = viewportCoordsToSceneCoords(
           { clientX: cursorX, clientY: cursorY },
           this.state,
+          this.canvas,
         );
 
         const element = newTextElement(
@@ -759,8 +823,13 @@ export class App extends React.Component<any, AppState> {
   };
 
   public render() {
-    const canvasWidth = window.innerWidth;
-    const canvasHeight = window.innerHeight;
+    const canvasDOMWidth = window.innerWidth;
+    const canvasDOMHeight = window.innerHeight;
+
+    const canvasScale = window.devicePixelRatio;
+
+    const canvasWidth = canvasDOMWidth * canvasScale;
+    const canvasHeight = canvasDOMHeight * canvasScale;
 
     return (
       <div className="container">
@@ -777,46 +846,43 @@ export class App extends React.Component<any, AppState> {
           <canvas
             id="canvas"
             style={{
-              width: canvasWidth,
-              height: canvasHeight,
+              width: canvasDOMWidth,
+              height: canvasDOMHeight,
             }}
-            width={canvasWidth * window.devicePixelRatio}
-            height={canvasHeight * window.devicePixelRatio}
+            width={canvasWidth}
+            height={canvasHeight}
             ref={canvas => {
-              if (this.canvas === null) {
+              // canvas is null when unmounting
+              if (canvas !== null) {
                 this.canvas = canvas;
-                this.rc = rough.canvas(this.canvas!);
-              }
-              if (this.removeWheelEventListener) {
-                this.removeWheelEventListener();
-                this.removeWheelEventListener = undefined;
-              }
-              if (canvas) {
-                canvas.addEventListener("wheel", this.handleWheel, {
+                this.rc = rough.canvas(this.canvas);
+
+                this.canvas.addEventListener("wheel", this.handleWheel, {
                   passive: false,
                 });
-                this.removeWheelEventListener = () =>
-                  canvas.removeEventListener("wheel", this.handleWheel);
-                // Whenever React sets the width/height of the canvas element,
-                // the context loses the scale transform. We need to re-apply it
-                if (
-                  canvasWidth !== lastCanvasWidth ||
-                  canvasHeight !== lastCanvasHeight
-                ) {
-                  lastCanvasWidth = canvasWidth;
-                  lastCanvasHeight = canvasHeight;
-                  canvas
-                    .getContext("2d")!
-                    .scale(window.devicePixelRatio, window.devicePixelRatio);
-                }
+
+                this.canvas
+                  .getContext("2d")
+                  ?.setTransform(canvasScale, 0, 0, canvasScale, 0, 0);
+              } else {
+                this.canvas?.removeEventListener("wheel", this.handleWheel);
               }
             }}
             onContextMenu={e => {
               e.preventDefault();
 
-              const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+              const { x, y } = viewportCoordsToSceneCoords(
+                e,
+                this.state,
+                this.canvas,
+              );
 
-              const element = getElementAtPosition(elements, x, y);
+              const element = getElementAtPosition(
+                elements,
+                x,
+                y,
+                this.state.zoom,
+              );
               if (!element) {
                 ContextMenu.push({
                   options: [
@@ -887,8 +953,8 @@ export class App extends React.Component<any, AppState> {
                   lastY = e.clientY;
 
                   this.setState({
-                    scrollX: this.state.scrollX - deltaX,
-                    scrollY: this.state.scrollY - deltaY,
+                    scrollX: this.state.scrollX - deltaX / this.state.zoom,
+                    scrollY: this.state.scrollY - deltaY / this.state.zoom,
                   });
                 };
                 const teardown = (lastMouseUp = () => {
@@ -933,11 +999,14 @@ export class App extends React.Component<any, AppState> {
                 e.clientY / window.devicePixelRatio,
                 canvasWidth / window.devicePixelRatio,
                 canvasHeight / window.devicePixelRatio,
-                this.state.scrollX,
-                this.state.scrollY,
+                this.state,
               );
 
-              const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+              const { x, y } = viewportCoordsToSceneCoords(
+                e,
+                this.state,
+                this.canvas,
+              );
 
               const originX = x;
               const originY = y;
@@ -972,7 +1041,7 @@ export class App extends React.Component<any, AppState> {
                 const resizeElement = getElementWithResizeHandler(
                   elements,
                   { x, y },
-                  this.state,
+                  this.state.zoom,
                 );
                 this.setState({
                   resizingElement: resizeElement ? resizeElement.element : null,
@@ -985,7 +1054,12 @@ export class App extends React.Component<any, AppState> {
                   );
                   isResizingElements = true;
                 } else {
-                  hitElement = getElementAtPosition(elements, x, y);
+                  hitElement = getElementAtPosition(
+                    elements,
+                    x,
+                    y,
+                    this.state.zoom,
+                  );
                   // clear selection if shift is not clicked
                   if (!hitElement?.isSelected && !e.shiftKey) {
                     elements = clearSelection(elements);
@@ -1061,6 +1135,7 @@ export class App extends React.Component<any, AppState> {
                   strokeColor: this.state.currentItemStrokeColor,
                   opacity: this.state.currentItemOpacity,
                   font: this.state.currentItemFont,
+                  zoom: this.state.zoom,
                   onSubmit: text => {
                     if (text) {
                       elements = [
@@ -1211,7 +1286,9 @@ export class App extends React.Component<any, AppState> {
                 if (isOverHorizontalScrollBar) {
                   const x = e.clientX;
                   const dx = x - lastX;
-                  this.setState({ scrollX: this.state.scrollX - dx });
+                  this.setState({
+                    scrollX: this.state.scrollX - dx / this.state.zoom,
+                  });
                   lastX = x;
                   return;
                 }
@@ -1219,7 +1296,9 @@ export class App extends React.Component<any, AppState> {
                 if (isOverVerticalScrollBar) {
                   const y = e.clientY;
                   const dy = y - lastY;
-                  this.setState({ scrollY: this.state.scrollY - dy });
+                  this.setState({
+                    scrollY: this.state.scrollY - dy / this.state.zoom,
+                  });
                   lastY = y;
                   return;
                 }
@@ -1233,7 +1312,11 @@ export class App extends React.Component<any, AppState> {
                   (this.state.elementType === "arrow" ||
                     this.state.elementType === "line")
                 ) {
-                  const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                  const { x, y } = viewportCoordsToSceneCoords(
+                    e,
+                    this.state,
+                    this.canvas,
+                  );
                   if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
                     return;
                   }
@@ -1244,7 +1327,11 @@ export class App extends React.Component<any, AppState> {
                   const el = this.state.resizingElement;
                   const selectedElements = elements.filter(el => el.isSelected);
                   if (selectedElements.length === 1) {
-                    const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                    const { x, y } = viewportCoordsToSceneCoords(
+                      e,
+                      this.state,
+                      this.canvas,
+                    );
                     const deltaX = x - lastX;
                     const deltaY = y - lastY;
                     const element = selectedElements[0];
@@ -1467,7 +1554,11 @@ export class App extends React.Component<any, AppState> {
                   draggingOccurred = true;
                   const selectedElements = elements.filter(el => el.isSelected);
                   if (selectedElements.length) {
-                    const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                    const { x, y } = viewportCoordsToSceneCoords(
+                      e,
+                      this.state,
+                      this.canvas,
+                    );
 
                     selectedElements.forEach(element => {
                       element.x += x - lastX;
@@ -1487,7 +1578,11 @@ export class App extends React.Component<any, AppState> {
                   return;
                 }
 
-                const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                const { x, y } = viewportCoordsToSceneCoords(
+                  e,
+                  this.state,
+                  this.canvas,
+                );
 
                 let width = distance(originX, x);
                 let height = distance(originY, y);
@@ -1581,7 +1676,11 @@ export class App extends React.Component<any, AppState> {
                     this.setState({});
                   }
                   if (!draggingOccurred && draggingElement && !multiElement) {
-                    const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                    const { x, y } = viewportCoordsToSceneCoords(
+                      e,
+                      this.state,
+                      this.canvas,
+                    );
                     draggingElement.points.push([
                       x - draggingElement.x,
                       y - draggingElement.y,
@@ -1696,9 +1795,18 @@ export class App extends React.Component<any, AppState> {
             onDoubleClick={e => {
               resetCursor();
 
-              const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+              const { x, y } = viewportCoordsToSceneCoords(
+                e,
+                this.state,
+                this.canvas,
+              );
 
-              const elementAtPosition = getElementAtPosition(elements, x, y);
+              const elementAtPosition = getElementAtPosition(
+                elements,
+                x,
+                y,
+                this.state.zoom,
+              );
 
               const element =
                 elementAtPosition && isTextElement(elementAtPosition)
@@ -1730,18 +1838,26 @@ export class App extends React.Component<any, AppState> {
                 );
                 this.setState({});
 
-                textX =
-                  this.state.scrollX +
-                  elementAtPosition.x +
-                  elementAtPosition.width / 2;
-                textY =
-                  this.state.scrollY +
-                  elementAtPosition.y +
-                  elementAtPosition.height / 2;
+                const centerElementX =
+                  elementAtPosition.x + elementAtPosition.width / 2;
+                const centerElementY =
+                  elementAtPosition.y + elementAtPosition.height / 2;
+
+                const {
+                  x: centerElementXInViewport,
+                  y: centerElementYInViewport,
+                } = sceneCoordsToViewportCoords(
+                  { sceneX: centerElementX, sceneY: centerElementY },
+                  this.state,
+                  this.canvas,
+                );
+
+                textX = centerElementXInViewport;
+                textY = centerElementYInViewport;
 
                 // x and y will change after calling newTextElement function
-                element.x = elementAtPosition.x + elementAtPosition.width / 2;
-                element.y = elementAtPosition.y + elementAtPosition.height / 2;
+                element.x = centerElementX;
+                element.y = centerElementY;
               } else if (!e.altKey) {
                 const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
                   x,
@@ -1770,6 +1886,7 @@ export class App extends React.Component<any, AppState> {
                 strokeColor: element.strokeColor,
                 font: element.font,
                 opacity: this.state.currentItemOpacity,
+                zoom: this.state.zoom,
                 onSubmit: text => {
                   if (text) {
                     elements = [
@@ -1796,7 +1913,11 @@ export class App extends React.Component<any, AppState> {
               }
               const hasDeselectedButton = Boolean(e.buttons);
 
-              const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+              const { x, y } = viewportCoordsToSceneCoords(
+                e,
+                this.state,
+                this.canvas,
+              );
               if (this.state.multiElement) {
                 const { multiElement } = this.state;
                 const originX = multiElement.x;
@@ -1823,7 +1944,7 @@ export class App extends React.Component<any, AppState> {
                 const resizeElement = getElementWithResizeHandler(
                   elements,
                   { x, y },
-                  this.state,
+                  this.state.zoom,
                 );
                 if (resizeElement && resizeElement.resizeHandle) {
                   document.documentElement.style.cursor = getCursorForResizingElement(
@@ -1832,7 +1953,12 @@ export class App extends React.Component<any, AppState> {
                   return;
                 }
               }
-              const hitElement = getElementAtPosition(elements, x, y);
+              const hitElement = getElementAtPosition(
+                elements,
+                x,
+                y,
+                this.state.zoom,
+              );
               document.documentElement.style.cursor = hitElement ? "move" : "";
             }}
             onDrop={e => {
@@ -1858,8 +1984,8 @@ export class App extends React.Component<any, AppState> {
     const { deltaX, deltaY } = e;
 
     this.setState({
-      scrollX: this.state.scrollX - deltaX,
-      scrollY: this.state.scrollY - deltaY,
+      scrollX: this.state.scrollX - deltaX / this.state.zoom,
+      scrollY: this.state.scrollY - deltaY / this.state.zoom,
     });
   };
 
@@ -1873,8 +1999,14 @@ export class App extends React.Component<any, AppState> {
     const elementsCenterX = distance(minX, maxX) / 2;
     const elementsCenterY = distance(minY, maxY) / 2;
 
-    const dx = cursorX - this.state.scrollX - elementsCenterX;
-    const dy = cursorY - this.state.scrollY - elementsCenterY;
+    const { x, y } = viewportCoordsToSceneCoords(
+      { clientX: cursorX, clientY: cursorY },
+      this.state,
+      this.canvas,
+    );
+
+    const dx = x - elementsCenterX;
+    const dy = y - elementsCenterY;
 
     elements = [
       ...elements,
@@ -1933,6 +2065,7 @@ export class App extends React.Component<any, AppState> {
         scrollX: this.state.scrollX,
         scrollY: this.state.scrollY,
         viewBackgroundColor: this.state.viewBackgroundColor,
+        zoom: this.state.zoom,
       },
     );
     const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;

+ 3 - 1
src/locales/en.json

@@ -53,7 +53,9 @@
     "getShareableLink": "Get shareable link",
     "close": "Close",
     "selectLanguage": "Select Language",
-    "scrollBackToContent": "Scroll back to content"
+    "scrollBackToContent": "Scroll back to content",
+    "zoomIn": "Zoom in",
+    "zoomOut": "Zoom out"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 147 - 78
src/renderer/renderScene.ts

@@ -11,6 +11,7 @@ import {
   SCROLLBAR_COLOR,
   SCROLLBAR_WIDTH,
 } from "../scene/scrollbars";
+import { getZoomTranslation } from "../scene/zoom";
 
 import { renderElement, renderElementToSvg } from "./renderElement";
 
@@ -32,116 +33,164 @@ export function renderScene(
     renderScrollbars?: boolean;
     renderSelection?: boolean;
   } = {},
-) {
+): boolean {
   if (!canvas) {
     return false;
   }
+
+  // Use offsets insteads of scrolls if available
+  sceneState = {
+    ...sceneState,
+    scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
+    scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
+  };
+
   const context = canvas.getContext("2d")!;
 
-  const fillStyle = context.fillStyle;
+  // Get initial scale transform as reference for later usage
+  const initialContextTransform = context.getTransform();
+
+  // When doing calculations based on canvas width we should used normalized one
+  const normalizedCanvasWidth =
+    canvas.width / getContextTransformScaleX(initialContextTransform);
+  const normalizedCanvasHeight =
+    canvas.height / getContextTransformScaleY(initialContextTransform);
+
+  // Handle zoom scaling
+  function scaleContextToZoom() {
+    context.setTransform(
+      getContextTransformScaleX(initialContextTransform) * sceneState.zoom,
+      0,
+      0,
+      getContextTransformScaleY(initialContextTransform) * sceneState.zoom,
+      getContextTransformTranslateX(context.getTransform()),
+      getContextTransformTranslateY(context.getTransform()),
+    );
+  }
+
+  // Handle zoom translation
+  const zoomTranslation = getZoomTranslation(canvas, sceneState.zoom);
+  function translateContextToZoom() {
+    context.setTransform(
+      getContextTransformScaleX(context.getTransform()),
+      0,
+      0,
+      getContextTransformScaleY(context.getTransform()),
+      getContextTransformTranslateX(initialContextTransform) -
+        zoomTranslation.x,
+      getContextTransformTranslateY(initialContextTransform) -
+        zoomTranslation.y,
+    );
+  }
+
+  // Paint background
+  context.save();
   if (typeof sceneState.viewBackgroundColor === "string") {
     const hasTransparence =
       sceneState.viewBackgroundColor === "transparent" ||
       sceneState.viewBackgroundColor.length === 5 ||
       sceneState.viewBackgroundColor.length === 9;
     if (hasTransparence) {
-      context.clearRect(0, 0, canvas.width, canvas.height);
+      context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
     }
     context.fillStyle = sceneState.viewBackgroundColor;
-    context.fillRect(0, 0, canvas.width, canvas.height);
+    context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
   } else {
-    context.clearRect(0, 0, canvas.width, canvas.height);
+    context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
   }
-  context.fillStyle = fillStyle;
+  context.restore();
 
-  sceneState = {
-    ...sceneState,
-    scrollX: typeof offsetX === "number" ? offsetX : sceneState.scrollX,
-    scrollY: typeof offsetY === "number" ? offsetY : sceneState.scrollY,
-  };
+  // Paint visible elements
+  const visibleElements = elements.filter(element =>
+    isVisibleElement(
+      element,
+      normalizedCanvasWidth,
+      normalizedCanvasHeight,
+      sceneState,
+    ),
+  );
 
-  let atLeastOneVisibleElement = false;
-  elements.forEach(element => {
-    if (
-      !isVisibleElement(
-        element,
-        sceneState.scrollX,
-        sceneState.scrollY,
-        // If canvas is scaled for high pixelDeviceRatio width and height
-        // setted in the `style` attribute
-        parseInt(canvas.style.width) || canvas.width,
-        parseInt(canvas.style.height) || canvas.height,
-      )
-    ) {
-      return;
-    }
-    atLeastOneVisibleElement = true;
-    context.translate(
-      element.x + sceneState.scrollX,
-      element.y + sceneState.scrollY,
-    );
+  context.save();
+  scaleContextToZoom();
+  translateContextToZoom();
+  context.translate(sceneState.scrollX, sceneState.scrollY);
+  visibleElements.forEach(element => {
+    context.save();
+    context.translate(element.x, element.y);
     renderElement(element, rc, context);
-    context.translate(
-      -element.x - sceneState.scrollX,
-      -element.y - sceneState.scrollY,
-    );
+    context.restore();
   });
+  context.restore();
 
+  // Pain selection element
   if (selectionElement) {
-    context.translate(
-      selectionElement.x + sceneState.scrollX,
-      selectionElement.y + sceneState.scrollY,
-    );
+    context.save();
+    scaleContextToZoom();
+    translateContextToZoom();
+    context.translate(sceneState.scrollX, sceneState.scrollY);
+    context.translate(selectionElement.x, selectionElement.y);
     renderElement(selectionElement, rc, context);
-    context.translate(
-      -selectionElement.x - sceneState.scrollX,
-      -selectionElement.y - sceneState.scrollY,
-    );
+    context.restore();
   }
 
+  // Pain selected elements
   if (renderSelection) {
-    const selectedElements = elements.filter(el => el.isSelected);
+    const selectedElements = elements.filter(element => element.isSelected);
+    const dashledLinePadding = 4 / sceneState.zoom;
 
+    context.save();
+    scaleContextToZoom();
+    translateContextToZoom();
+    context.translate(sceneState.scrollX, sceneState.scrollY);
     selectedElements.forEach(element => {
-      const margin = 4;
-
       const [
         elementX1,
         elementY1,
         elementX2,
         elementY2,
       ] = getElementAbsoluteCoords(element);
-      const lineDash = context.getLineDash();
-      context.setLineDash([8, 4]);
+
+      const elementWidth = elementX2 - elementX1;
+      const elementHeight = elementY2 - elementY1;
+
+      const initialLineDash = context.getLineDash();
+      context.setLineDash([8 / sceneState.zoom, 4 / sceneState.zoom]);
       context.strokeRect(
-        elementX1 - margin + sceneState.scrollX,
-        elementY1 - margin + sceneState.scrollY,
-        elementX2 - elementX1 + margin * 2,
-        elementY2 - elementY1 + margin * 2,
+        elementX1 - dashledLinePadding,
+        elementY1 - dashledLinePadding,
+        elementWidth + dashledLinePadding * 2,
+        elementHeight + dashledLinePadding * 2,
       );
-      context.setLineDash(lineDash);
+      context.setLineDash(initialLineDash);
     });
+    context.restore();
 
+    // Paint resize handlers
     if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
-      const handlers = handlerRectangles(selectedElements[0], sceneState);
+      context.save();
+      scaleContextToZoom();
+      translateContextToZoom();
+      context.translate(sceneState.scrollX, sceneState.scrollY);
+      const handlers = handlerRectangles(selectedElements[0], sceneState.zoom);
       Object.values(handlers)
         .filter(handler => handler !== undefined)
         .forEach(handler => {
           context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
         });
+      context.restore();
     }
   }
 
+  // Paint scrollbars
   if (renderScrollbars) {
     const scrollBars = getScrollBars(
       elements,
-      context.canvas.width / window.devicePixelRatio,
-      context.canvas.height / window.devicePixelRatio,
-      sceneState.scrollX,
-      sceneState.scrollY,
+      normalizedCanvasWidth,
+      normalizedCanvasHeight,
+      sceneState,
     );
 
-    const strokeStyle = context.strokeStyle;
+    context.save();
     context.fillStyle = SCROLLBAR_COLOR;
     context.strokeStyle = "rgba(255,255,255,0.8)";
     [scrollBars.horizontal, scrollBars.vertical].forEach(scrollBar => {
@@ -156,33 +205,40 @@ export function renderScene(
         );
       }
     });
-    context.strokeStyle = strokeStyle;
-    context.fillStyle = fillStyle;
+    context.restore();
   }
 
-  return atLeastOneVisibleElement;
+  return visibleElements.length > 0;
 }
 
 function isVisibleElement(
   element: ExcalidrawElement,
-  scrollX: number,
-  scrollY: number,
-  canvasWidth: number,
-  canvasHeight: number,
+  viewportWidth: number,
+  viewportHeight: number,
+  {
+    scrollX,
+    scrollY,
+    zoom,
+  }: {
+    scrollX: number;
+    scrollY: number;
+    zoom: number;
+  },
 ) {
-  let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  if (element.type !== "arrow") {
-    x1 += scrollX;
-    y1 += scrollY;
-    x2 += scrollX;
-    y2 += scrollY;
-    return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
-  }
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+  // Apply zoom
+  const viewportWidthWithZoom = viewportWidth / zoom;
+  const viewportHeightWithZoom = viewportHeight / zoom;
+
+  const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
+  const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
+
   return (
-    x2 + scrollX >= 0 &&
-    x1 + scrollX <= canvasWidth &&
-    y2 + scrollY >= 0 &&
-    y1 + scrollY <= canvasHeight
+    x2 + scrollX - viewportWidthDiff / 2 >= 0 &&
+    x1 + scrollX - viewportWidthDiff / 2 <= viewportWidthWithZoom &&
+    y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
+    y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
   );
 }
 
@@ -213,3 +269,16 @@ export function renderSceneToSvg(
     );
   });
 }
+
+function getContextTransformScaleX(transform: DOMMatrix): number {
+  return transform.a;
+}
+function getContextTransformScaleY(transform: DOMMatrix): number {
+  return transform.d;
+}
+function getContextTransformTranslateX(transform: DOMMatrix): number {
+  return transform.e;
+}
+function getContextTransformTranslateY(transform: DOMMatrix): number {
+  return transform.f;
+}

+ 4 - 3
src/scene/comparisons.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
-import { hitTest } from "../element/collision";
-import { getElementAbsoluteCoords } from "../element";
+
+import { getElementAbsoluteCoords, hitTest } from "../element";
 
 export const hasBackground = (type: string) =>
   type === "rectangle" || type === "ellipse" || type === "diamond";
@@ -18,11 +18,12 @@ export function getElementAtPosition(
   elements: readonly ExcalidrawElement[],
   x: number,
   y: number,
+  zoom: number,
 ) {
   let hitElement = null;
   // We need to to hit testing from front (end of the array) to back (beginning of the array)
   for (let i = elements.length - 1; i >= 0; --i) {
-    if (hitTest(elements[i], x, y)) {
+    if (hitTest(elements[i], x, y, zoom)) {
       hitElement = elements[i];
       break;
     }

+ 1 - 0
src/scene/export.ts

@@ -44,6 +44,7 @@ export function exportToCanvas(
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
       scrollX: 0,
       scrollY: 0,
+      zoom: 1,
     },
     {
       offsetX: -minX + exportPadding,

+ 1 - 0
src/scene/index.ts

@@ -27,3 +27,4 @@ export {
   hasText,
 } from "./comparisons";
 export { createScene } from "./createScene";
+export { getZoomOrigin, getZoomTranslation } from "./zoom";

+ 32 - 13
src/scene/scrollbars.ts

@@ -9,8 +9,15 @@ export function getScrollBars(
   elements: readonly ExcalidrawElement[],
   viewportWidth: number,
   viewportHeight: number,
-  scrollX: number,
-  scrollY: number,
+  {
+    scrollX,
+    scrollY,
+    zoom,
+  }: {
+    scrollX: number;
+    scrollY: number;
+    zoom: number;
+  },
 ) {
   // This is the bounding box of all the elements
   const [
@@ -20,11 +27,18 @@ export function getScrollBars(
     elementsMaxY,
   ] = getCommonBounds(elements);
 
+  // Apply zoom
+  const viewportWidthWithZoom = viewportWidth / zoom;
+  const viewportHeightWithZoom = viewportHeight / zoom;
+
+  const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
+  const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
+
   // The viewport is the rectangle currently visible for the user
-  const viewportMinX = -scrollX;
-  const viewportMinY = -scrollY;
-  const viewportMaxX = -scrollX + viewportWidth;
-  const viewportMaxY = -scrollY + viewportHeight;
+  const viewportMinX = -scrollX + viewportWidthDiff / 2;
+  const viewportMinY = -scrollY + viewportHeightDiff / 2;
+  const viewportMaxX = viewportMinX + viewportWidthWithZoom;
+  const viewportMaxY = viewportMinY + viewportHeightWithZoom;
 
   // The scene is the bounding box of both the elements and viewport
   const sceneMinX = Math.min(elementsMinX, viewportMinX);
@@ -74,16 +88,21 @@ export function isOverScrollBars(
   y: number,
   viewportWidth: number,
   viewportHeight: number,
-  scrollX: number,
-  scrollY: number,
+  {
+    scrollX,
+    scrollY,
+    zoom,
+  }: {
+    scrollX: number;
+    scrollY: number;
+    zoom: number;
+  },
 ) {
-  const scrollBars = getScrollBars(
-    elements,
-    viewportWidth,
-    viewportHeight,
+  const scrollBars = getScrollBars(elements, viewportWidth, viewportHeight, {
     scrollX,
     scrollY,
-  );
+    zoom,
+  });
 
   const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
     scrollBars.horizontal,

+ 1 - 0
src/scene/types.ts

@@ -5,6 +5,7 @@ export type SceneState = {
   scrollY: number;
   // null indicates transparent bg
   viewBackgroundColor: string | null;
+  zoom: number;
 };
 
 export type SceneScroll = {

+ 30 - 0
src/scene/zoom.ts

@@ -0,0 +1,30 @@
+export function getZoomOrigin(canvas: HTMLCanvasElement | null) {
+  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 / context.getTransform().a;
+  const normalizedCanvasHeight = canvas.height / context.getTransform().d;
+
+  return {
+    x: normalizedCanvasWidth / 2,
+    y: normalizedCanvasHeight / 2,
+  };
+}
+
+export function getZoomTranslation(canvas: HTMLCanvasElement, zoom: number) {
+  const diffMiddleOfTheCanvas = {
+    x: (canvas.width / 2) * (zoom - 1),
+    y: (canvas.height / 2) * (zoom - 1),
+  };
+
+  // Due to JavaScript float precision, we fix to fix decimals count to have symmetric zoom
+  return {
+    x: parseFloat(diffMiddleOfTheCanvas.x.toFixed(8)),
+    y: parseFloat(diffMiddleOfTheCanvas.y.toFixed(8)),
+  };
+}

+ 22 - 0
src/styles.scss

@@ -142,6 +142,28 @@ button,
   justify-self: flex-end;
 }
 
+.App-menu_bottom {
+  position: fixed;
+  bottom: 0;
+
+  grid-template-columns: 1fr auto 1fr;
+  align-items: flex-start;
+  cursor: default;
+  pointer-events: none !important;
+}
+
+.App-menu_bottom > * {
+  pointer-events: all;
+}
+
+.App-menu_bottom > *:first-child {
+  justify-self: flex-start;
+}
+
+.App-menu_bottom > *:last-child {
+  justify-self: flex-end;
+}
+
 .App-menu_left {
   grid-template-rows: 1fr auto 1fr;
   height: 100%;

+ 1 - 0
src/types.ts

@@ -28,4 +28,5 @@ export type AppState = {
   name: string;
   selectedId?: string;
   isResizing: boolean;
+  zoom: number;
 };