Преглед на файлове

Feature: Multi Point Arrows (#338)

* Add points to arrow on double click

* Use line generator instead of path to generate line segments

* Switch color of the circle when it is on an existing point in the segment

* Check point against both ends of the line segment to find collinearity

* Keep drawing the arrow based on mouse position until shape is changed

* Always select the arrow when in multi element mode

* Use curves instead of lines when drawing arrow points

* Add basic collision detection with some debug points

* Use roughjs shape when performing hit testing

* Draw proper handler rectangles for arrows

* Add argument to renderScene in export

* Globally resize all points on the arrow when bounds are resized

* Hide handler rectangles if an arrow has no size

- Allow continuing adding arrows when selected element is deleted

* Add dragging functionality to arrows

* Add SHIFT functionality to two point arrows

- Fix arrow positions when scrolling
- Revert the element back to selection when not in multi select mode

* Clean app state for export (JSON)

* Set curve options manually instead of using global options

- For some reason, this fixed the flickering issue in all shapes when arrows are rendered

* Set proper options for the arrow

* Increaase accuracy of hit testing arrows

- Additionally, skip testing if point is outside the domain of arrow and each curve

* Calculate bounding box of arrow based on roughjs curves

- Remove domain check per curve

* Change bounding box threshold to 10 and remove unnecessary code

* Fix handler rectangles for 2 and multi point arrows

- Fix margins of handler rectangles when using arrows
- Show handler rectangles in endpoints of 2-point arrows

* Remove unnecessary values from app state for export

* Use `resetTransform` instead of "retranslating" canvas space after each element rendering

* Allow resizing 2-point arrows

- Fix position of one of the handler rectangles

* refactor variable initialization

* Refactored to extract out mult-point generation to the abstracted function

* prevent dragging on arrow creation if under threshold

* Finalize selection during multi element mode when ENTER or ESC is clicked

* Set dragging element to null when finalizing

* Remove pathSegmentCircle from code

* Check if element is any "non-value" instead of NULL

* Show two points on any two point arrow and fix visibility of arrows during scroll

* Resume recording when done with drawing

- When deleting a multi select element, revert back to selection element type

* Resize arrow starting points perfectly

* Fix direction of arrow resize based for NW

* Resume recording history when there is more than one arrow

* Set dragging element to NULL when element is not locked

* Blur active element when finalizing

* Disable undo/redo for multielement, editingelement, and resizing element

- Allow undoing parts of the arrow

* Disable element visibility for arrow

* Use points array for arrow bounds when bezier curve shape is not available

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Preet <833927+pshihn@users.noreply.github.com>
Gasim Gasimzada преди 5 години
родител
ревизия
16263e942b

+ 2 - 1
src/actions/actionDeleteSelected.tsx

@@ -4,9 +4,10 @@ import { KEYS } from "../keys";
 
 export const actionDeleteSelected: Action = {
   name: "deleteSelectedElements",
-  perform: elements => {
+  perform: (elements, appState) => {
     return {
       elements: deleteSelectedElements(elements),
+      appState: { ...appState, elementType: "selection", multiElement: null },
     };
   },
   contextItemLabel: "labels.delete",

+ 27 - 0
src/actions/actionFinalize.tsx

@@ -0,0 +1,27 @@
+import { Action } from "./types";
+import { KEYS } from "../keys";
+import { clearSelection } from "../scene";
+
+export const actionFinalize: Action = {
+  name: "finalize",
+  perform: (elements, appState) => {
+    if (window.document.activeElement instanceof HTMLElement) {
+      window.document.activeElement.blur();
+    }
+    return {
+      elements: clearSelection(elements),
+      appState: {
+        ...appState,
+        elementType: "selection",
+        draggingElement: null,
+        multiElement: null,
+      },
+    };
+  },
+  keyTest: (event, appState) =>
+    (event.key === KEYS.ESCAPE &&
+      !appState.draggingElement &&
+      appState.multiElement === null) ||
+    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+      appState.multiElement !== null),
+};

+ 2 - 0
src/actions/index.ts

@@ -23,6 +23,8 @@ export {
   actionClearCanvas,
 } from "./actionCanvas";
 
+export { actionFinalize } from "./actionFinalize";
+
 export {
   actionChangeProjectName,
   actionChangeExportBackground,

+ 1 - 1
src/actions/manager.tsx

@@ -34,7 +34,7 @@ export class ActionManager implements ActionsManagerInterface {
     const data = Object.values(this.actions)
       .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
       .filter(
-        action => action.keyTest && action.keyTest(event, elements, appState),
+        action => action.keyTest && action.keyTest(event, appState, elements),
       );
 
     if (data.length === 0) return null;

+ 2 - 2
src/actions/types.ts

@@ -29,8 +29,8 @@ export interface Action {
   keyPriority?: number;
   keyTest?: (
     event: KeyboardEvent,
-    elements?: readonly ExcalidrawElement[],
-    appState?: AppState,
+    appState: AppState,
+    elements: readonly ExcalidrawElement[],
   ) => boolean;
   contextItemLabel?: string;
   contextMenuOrder?: number;

+ 7 - 0
src/appState.ts

@@ -7,6 +7,7 @@ export function getDefaultAppState(): AppState {
   return {
     draggingElement: null,
     resizingElement: null,
+    multiElement: null,
     editingElement: null,
     elementType: "selection",
     elementLocked: false,
@@ -26,3 +27,9 @@ export function getDefaultAppState(): AppState {
     name: DEFAULT_PROJECT_NAME,
   };
 }
+
+export function cleanAppStateForExport(appState: AppState) {
+  return {
+    viewBackgroundColor: appState.viewBackgroundColor,
+  };
+}

+ 94 - 5
src/element/bounds.ts

@@ -1,11 +1,16 @@
 import { ExcalidrawElement } from "./types";
 import { rotate } from "../math";
+import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
 // We can't just always normalize it since we need to remember the fact that an arrow
 // is pointing left or right.
 export function getElementAbsoluteCoords(element: ExcalidrawElement) {
+  if (element.type === "arrow") {
+    return getArrowAbsoluteBounds(element);
+  }
   return [
     element.width >= 0 ? element.x : element.x + element.width, // x1
     element.height >= 0 ? element.y : element.y + element.height, // y1
@@ -29,11 +34,95 @@ export function getDiamondPoints(element: ExcalidrawElement) {
   return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
 }
 
+export function getArrowAbsoluteBounds(element: ExcalidrawElement) {
+  if (element.points.length < 2 || !element.shape) {
+    const { minX, minY, maxX, maxY } = element.points.reduce(
+      (limits, [x, y]) => {
+        limits.minY = Math.min(limits.minY, y);
+        limits.minX = Math.min(limits.minX, x);
+
+        limits.maxX = Math.max(limits.maxX, x);
+        limits.maxY = Math.max(limits.maxY, y);
+
+        return limits;
+      },
+      { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+    );
+    return [
+      minX + element.x,
+      minY + element.y,
+      maxX + element.x,
+      maxY + element.y,
+    ];
+  }
+
+  const shape = element.shape as Drawable[];
+
+  const ops = shape[1].sets[0].ops;
+
+  let currentP: Point = [0, 0];
+
+  const { minX, minY, maxX, maxY } = ops.reduce(
+    (limits, { op, data }) => {
+      // There are only four operation types:
+      // move, bcurveTo, lineTo, and curveTo
+      if (op === "move") {
+        // change starting point
+        currentP = data as Point;
+        // move operation does not draw anything; so, it always
+        // returns false
+      } else if (op === "bcurveTo") {
+        // create points from bezier curve
+        // bezier curve stores data as a flattened array of three positions
+        // [x1, y1, x2, y2, x3, y3]
+        const p1 = [data[0], data[1]] as Point;
+        const p2 = [data[2], data[3]] as Point;
+        const p3 = [data[4], data[5]] as Point;
+
+        const p0 = currentP;
+        currentP = p3;
+
+        const equation = (t: number, idx: number) =>
+          Math.pow(1 - t, 3) * p3[idx] +
+          3 * t * Math.pow(1 - t, 2) * p2[idx] +
+          3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+          p0[idx] * Math.pow(t, 3);
+
+        let t = 0;
+        while (t <= 1.0) {
+          const x = equation(t, 0);
+          const y = equation(t, 1);
+
+          limits.minY = Math.min(limits.minY, y);
+          limits.minX = Math.min(limits.minX, x);
+
+          limits.maxX = Math.max(limits.maxX, x);
+          limits.maxY = Math.max(limits.maxY, y);
+
+          t += 0.1;
+        }
+      } else if (op === "lineTo") {
+        // TODO: Implement this
+      } else if (op === "qcurveTo") {
+        // TODO: Implement this
+      }
+      return limits;
+    },
+    { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+  );
+
+  return [
+    minX + element.x,
+    minY + element.y,
+    maxX + element.x,
+    maxY + element.y,
+  ];
+}
+
 export function getArrowPoints(element: ExcalidrawElement) {
-  const x1 = 0;
-  const y1 = 0;
-  const x2 = element.width;
-  const y2 = element.height;
+  const points = element.points;
+  const [x1, y1] = points.length >= 2 ? points[points.length - 2] : [0, 0];
+  const [x2, y2] = points[points.length - 1];
 
   const size = 30; // pixels
   const distance = Math.hypot(x2 - x1, y2 - y1);
@@ -46,7 +135,7 @@ export function getArrowPoints(element: ExcalidrawElement) {
   const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
   const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
 
-  return [x1, y1, x2, y2, x3, y3, x4, y4];
+  return [x2, y2, x3, y3, x4, y4];
 }
 
 export function getLinePoints(element: ExcalidrawElement) {

+ 99 - 11
src/element/collision.ts

@@ -2,11 +2,13 @@ import { distanceBetweenPointAndSegment } from "../math";
 
 import { ExcalidrawElement } from "./types";
 import {
-  getArrowPoints,
   getDiamondPoints,
   getElementAbsoluteCoords,
   getLinePoints,
+  getArrowAbsoluteBounds,
 } from "./bounds";
+import { Point } from "roughjs/bin/geometry";
+import { Drawable, OpSet } from "roughjs/bin/core";
 
 function isElementDraggableFromInside(element: ExcalidrawElement): boolean {
   return element.backgroundColor !== "transparent" || element.isSelected;
@@ -145,18 +147,25 @@ export function hitTest(
         lineThreshold
     );
   } else if (element.type === "arrow") {
-    let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
-    // The computation is done at the origin, we need to add a translation
-    x -= element.x;
-    y -= element.y;
+    if (!element.shape) {
+      return false;
+    }
+    const shape = element.shape as Drawable[];
+    // If shape does not consist of curve and two line segments
+    // for arrow shape, return false
+    if (shape.length < 3) return false;
+
+    const [x1, y1, x2, y2] = getArrowAbsoluteBounds(element);
+    if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) return false;
+
+    const relX = x - element.x;
+    const relY = y - element.y;
 
+    // hit test curve and lien segments for arrow
     return (
-      //    \
-      distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold ||
-      // -----
-      distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold ||
-      //    /
-      distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold
+      hitTestRoughShape(shape[0].sets, relX, relY) ||
+      hitTestRoughShape(shape[1].sets, relX, relY) ||
+      hitTestRoughShape(shape[2].sets, relX, relY)
     );
   } else if (element.type === "line") {
     const [x1, y1, x2, y2] = getLinePoints(element);
@@ -176,3 +185,82 @@ export function hitTest(
     throw new Error("Unimplemented type " + element.type);
   }
 }
+
+const pointInBezierEquation = (
+  p0: Point,
+  p1: Point,
+  p2: Point,
+  p3: Point,
+  [mx, my]: Point,
+) => {
+  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+  const equation = (t: number, idx: number) =>
+    Math.pow(1 - t, 3) * p3[idx] +
+    3 * t * Math.pow(1 - t, 2) * p2[idx] +
+    3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+    p0[idx] * Math.pow(t, 3);
+
+  const epsilon = 20;
+  // go through t in increments of 0.01
+  let t = 0;
+  while (t <= 1.0) {
+    const tx = equation(t, 0);
+    const ty = equation(t, 1);
+
+    const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
+
+    if (diff < epsilon) {
+      return true;
+    }
+
+    t += 0.01;
+  }
+
+  return false;
+};
+
+const hitTestRoughShape = (opSet: OpSet[], x: number, y: number) => {
+  // read operations from first opSet
+  const ops = opSet[0].ops;
+
+  // set start position as (0,0) just in case
+  // move operation does not exist (unlikely but it is worth safekeeping it)
+  let currentP: Point = [0, 0];
+
+  return ops.some(({ op, data }, idx) => {
+    // There are only four operation types:
+    // move, bcurveTo, lineTo, and curveTo
+    if (op === "move") {
+      // change starting point
+      currentP = data as Point;
+      // move operation does not draw anything; so, it always
+      // returns false
+    } else if (op === "bcurveTo") {
+      // create points from bezier curve
+      // bezier curve stores data as a flattened array of three positions
+      // [x1, y1, x2, y2, x3, y3]
+      const p1 = [data[0], data[1]] as Point;
+      const p2 = [data[2], data[3]] as Point;
+      const p3 = [data[4], data[5]] as Point;
+
+      const p0 = currentP;
+      currentP = p3;
+
+      // check if points are on the curve
+      // cubic bezier curves require four parameters
+      // the first parameter is the last stored position (p0)
+      let retVal = pointInBezierEquation(p0, p1, p2, p3, [x, y]);
+
+      // set end point of bezier curve as the new starting point for
+      // upcoming operations as each operation is based on the last drawn
+      // position of the previous operation
+      return retVal;
+    } else if (op === "lineTo") {
+      // TODO: Implement this
+    } else if (op === "qcurveTo") {
+      // TODO: Implement this
+    }
+
+    return false;
+  });
+};

+ 70 - 9
src/element/handlerRectangles.ts

@@ -1,5 +1,6 @@
 import { ExcalidrawElement } from "./types";
 import { SceneScroll } from "../scene/types";
+import { getArrowAbsoluteBounds } from "./bounds";
 
 type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
 
@@ -7,18 +8,31 @@ export function handlerRectangles(
   element: ExcalidrawElement,
   { scrollX, scrollY }: SceneScroll,
 ) {
-  const elementX1 = element.x;
-  const elementX2 = element.x + element.width;
-  const elementY1 = element.y;
-  const elementY2 = element.y + element.height;
+  let elementX2 = 0;
+  let elementY2 = 0;
+  let elementX1 = Infinity;
+  let elementY1 = Infinity;
+  let marginX = -8;
+  let marginY = -8;
+
+  let minimumSize = 40;
+  if (element.type === "arrow") {
+    [elementX1, elementY1, elementX2, elementY2] = getArrowAbsoluteBounds(
+      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 minimumSize = 40;
   const handlers = {} as { [T in Sides]: number[] };
 
-  const marginX = element.width < 0 ? 8 : -8;
-  const marginY = element.height < 0 ? 8 : -8;
-
   if (Math.abs(elementX2 - elementX1) > minimumSize) {
     handlers["n"] = [
       elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
@@ -76,11 +90,58 @@ export function handlerRectangles(
     8,
   ]; // se
 
-  if (element.type === "arrow" || element.type === "line") {
+  if (element.type === "line") {
     return {
       nw: handlers.nw,
       se: handlers.se,
     } as typeof handlers;
+  } else if (element.type === "arrow") {
+    if (element.points.length === 2) {
+      // only check the last point because starting point is always (0,0)
+      const [, p1] = element.points;
+
+      if (p1[0] === 0 || p1[1] === 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+
+      if (p1[0] > 0 && p1[1] < 0) {
+        return {
+          ne: handlers.ne,
+          sw: handlers.sw,
+        } as typeof handlers;
+      }
+
+      if (p1[0] > 0 && p1[1] > 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+
+      if (p1[0] < 0 && p1[1] > 0) {
+        return {
+          ne: handlers.ne,
+          sw: handlers.sw,
+        } as typeof handlers;
+      }
+
+      if (p1[0] < 0 && p1[1] < 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+    }
+
+    return {
+      n: handlers.n,
+      s: handlers.s,
+      w: handlers.w,
+      e: handlers.e,
+    } as typeof handlers;
   }
 
   return handlers;

+ 1 - 0
src/element/index.ts

@@ -5,6 +5,7 @@ export {
   getDiamondPoints,
   getArrowPoints,
   getLinePoints,
+  getArrowAbsoluteBounds,
 } from "./bounds";
 
 export { handlerRectangles } from "./handlerRectangles";

+ 2 - 0
src/element/newElement.ts

@@ -1,6 +1,7 @@
 import { randomSeed } from "roughjs/bin/math";
 import nanoid from "nanoid";
 import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 import { measureText } from "../utils";
@@ -34,6 +35,7 @@ export function newElement(
     isSelected: false,
     seed: randomSeed(),
     shape: null as Drawable | Drawable[] | null,
+    points: [] as Point[],
   };
   return element;
 }

+ 1 - 0
src/element/resizeTest.ts

@@ -17,6 +17,7 @@ export function resizeTest(
 
   const filter = Object.keys(handlers).filter(key => {
     const handler = handlers[key as HandlerRectanglesRet]!;
+    if (!handler) return false;
 
     return (
       x + scrollX >= handler[0] &&

+ 356 - 60
src/index.tsx

@@ -42,7 +42,13 @@ import { renderScene } from "./renderer";
 import { AppState } from "./types";
 import { ExcalidrawElement } from "./element/types";
 
-import { isInputLike, debounce, capitalizeString, distance } from "./utils";
+import {
+  isInputLike,
+  debounce,
+  capitalizeString,
+  distance,
+  distance2d,
+} from "./utils";
 import { KEYS, isArrowKey } from "./keys";
 
 import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
@@ -76,6 +82,7 @@ import {
   actionSaveScene,
   actionCopyStyles,
   actionPasteStyles,
+  actionFinalize,
 } from "./actions";
 import { Action, ActionResult } from "./actions/types";
 import { getDefaultAppState } from "./appState";
@@ -88,6 +95,7 @@ import { ExportDialog } from "./components/ExportDialog";
 import { withTranslation } from "react-i18next";
 import { LanguageList } from "./components/LanguageList";
 import i18n, { languages, parseDetectedLang } from "./i18n";
+import { Point } from "roughjs/bin/geometry";
 import { StoredScenesList } from "./components/StoredScenesList";
 
 let { elements } = createScene();
@@ -109,6 +117,7 @@ function setCursorForShape(shape: string) {
   }
 }
 
+const DRAGGING_THRESHOLD = 10; // 10px
 const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
 const ELEMENT_TRANSLATE_AMOUNT = 1;
 const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@@ -168,6 +177,7 @@ export class App extends React.Component<any, AppState> {
   canvasOnlyActions: Array<Action>;
   constructor(props: any) {
     super(props);
+    this.actionManager.registerAction(actionFinalize);
     this.actionManager.registerAction(actionDeleteSelected);
     this.actionManager.registerAction(actionSendToBack);
     this.actionManager.registerAction(actionBringToFront);
@@ -328,17 +338,7 @@ export class App extends React.Component<any, AppState> {
   };
 
   private onKeyDown = (event: KeyboardEvent) => {
-    if (event.key === KEYS.ESCAPE && !this.state.draggingElement) {
-      elements = clearSelection(elements);
-      this.setState({});
-      this.setState({ elementType: "selection" });
-      if (window.document.activeElement instanceof HTMLElement) {
-        window.document.activeElement.blur();
-      }
-      event.preventDefault();
-      return;
-    }
-    if (isInputLike(event.target)) return;
+    if (isInputLike(event.target) && event.key !== KEYS.ESCAPE) return;
 
     const actionResult = this.actionManager.handleKeyDown(
       event,
@@ -387,19 +387,27 @@ export class App extends React.Component<any, AppState> {
     } else if (event[KEYS.META] && event.code === "KeyZ") {
       event.preventDefault();
 
+      if (
+        this.state.resizingElement ||
+        this.state.multiElement ||
+        this.state.editingElement
+      ) {
+        return;
+      }
+
       if (event.shiftKey) {
         // Redo action
         const data = history.redoOnce();
         if (data !== null) {
           elements = data.elements;
-          this.setState(data.appState);
+          this.setState({ ...data.appState });
         }
       } else {
         // undo action
         const data = history.undoOnce();
         if (data !== null) {
           elements = data.elements;
-          this.setState(data.appState);
+          this.setState({ ...data.appState });
         }
       }
     } else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
@@ -570,7 +578,7 @@ export class App extends React.Component<any, AppState> {
               aria-label={capitalizeString(label)}
               aria-keyshortcuts={`${label[0]} ${index + 1}`}
               onChange={() => {
-                this.setState({ elementType: value });
+                this.setState({ elementType: value, multiElement: null });
                 elements = clearSelection(elements);
                 document.documentElement.style.cursor =
                   value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
@@ -1036,11 +1044,28 @@ export class App extends React.Component<any, AppState> {
                   editingElement: element,
                 });
                 return;
+              } else if (this.state.elementType === "arrow") {
+                if (this.state.multiElement) {
+                  const { multiElement } = this.state;
+                  const { x: rx, y: ry } = multiElement;
+                  multiElement.isSelected = true;
+                  multiElement.points.push([x - rx, y - ry]);
+                  multiElement.shape = null;
+                  this.setState({ draggingElement: multiElement });
+                } else {
+                  element.isSelected = false;
+                  element.points.push([0, 0]);
+                  element.shape = null;
+                  elements = [...elements, element];
+                  this.setState({
+                    draggingElement: element,
+                  });
+                }
+              } else {
+                elements = [...elements, element];
+                this.setState({ multiElement: null, draggingElement: element });
               }
 
-              elements = [...elements, element];
-              this.setState({ draggingElement: element });
-
               let lastX = x;
               let lastY = y;
 
@@ -1049,6 +1074,75 @@ export class App extends React.Component<any, AppState> {
                 lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
               }
 
+              let resizeArrowFn:
+                | ((
+                    element: ExcalidrawElement,
+                    p1: Point,
+                    deltaX: number,
+                    deltaY: number,
+                    mouseX: number,
+                    mouseY: number,
+                    perfect: boolean,
+                  ) => void)
+                | null = null;
+
+              const arrowResizeOrigin = (
+                element: ExcalidrawElement,
+                p1: Point,
+                deltaX: number,
+                deltaY: number,
+                mouseX: number,
+                mouseY: number,
+                perfect: boolean,
+              ) => {
+                // TODO: Implement perfect sizing for origin
+                if (perfect) {
+                  const absPx = p1[0] + element.x;
+                  const absPy = p1[1] + element.y;
+
+                  let { width, height } = getPerfectElementSize(
+                    "arrow",
+                    mouseX - element.x - p1[0],
+                    mouseY - element.y - p1[1],
+                  );
+
+                  const dx = element.x + width + p1[0];
+                  const dy = element.y + height + p1[1];
+                  element.x = dx;
+                  element.y = dy;
+                  p1[0] = absPx - element.x;
+                  p1[1] = absPy - element.y;
+                } else {
+                  element.x += deltaX;
+                  element.y += deltaY;
+                  p1[0] -= deltaX;
+                  p1[1] -= deltaY;
+                }
+              };
+
+              const arrowResizeEnd = (
+                element: ExcalidrawElement,
+                p1: Point,
+                deltaX: number,
+                deltaY: number,
+                mouseX: number,
+                mouseY: number,
+                perfect: boolean,
+              ) => {
+                if (perfect) {
+                  const { width, height } = getPerfectElementSize(
+                    "arrow",
+                    mouseX - element.x,
+                    mouseY - element.y,
+                  );
+                  p1[0] = width;
+                  p1[1] = height;
+                } else {
+                  p1[0] += deltaX;
+                  p1[1] += deltaY;
+                }
+              };
+
               const onMouseMove = (e: MouseEvent) => {
                 const target = e.target;
                 if (!(target instanceof HTMLElement)) {
@@ -1075,6 +1169,16 @@ export class App extends React.Component<any, AppState> {
                   return;
                 }
 
+                // for arrows, don't start dragging until a given threshold
+                //  to ensure we don't create a 2-point arrow by mistake when
+                //  user clicks mouse in a way that it moves a tiny bit (thus
+                //  triggering mousemove)
+                if (!draggingOccurred && this.state.elementType === "arrow") {
+                  const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                  if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD)
+                    return;
+                }
+
                 if (isResizingElements && this.state.resizingElement) {
                   const el = this.state.resizingElement;
                   const selectedElements = elements.filter(el => el.isSelected);
@@ -1087,73 +1191,217 @@ export class App extends React.Component<any, AppState> {
                       element.type === "line" || element.type === "arrow";
                     switch (resizeHandle) {
                       case "nw":
-                        element.width -= deltaX;
-                        element.x += deltaX;
-
-                        if (e.shiftKey) {
-                          if (isLinear) {
-                            resizePerfectLineForNWHandler(element, x, y);
-                          } else {
-                            element.y += element.height - element.width;
-                            element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+
+                          if (!resizeArrowFn) {
+                            if (p1[0] < 0 || p1[1] < 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
                           }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height -= deltaY;
-                          element.y += deltaY;
+                          element.width -= deltaX;
+                          element.x += deltaX;
+
+                          if (e.shiftKey) {
+                            if (isLinear) {
+                              resizePerfectLineForNWHandler(element, x, y);
+                            } else {
+                              element.y += element.height - element.width;
+                              element.height = element.width;
+                            }
+                          } else {
+                            element.height -= deltaY;
+                            element.y += deltaY;
+                          }
                         }
                         break;
                       case "ne":
-                        element.width += deltaX;
-                        if (e.shiftKey) {
-                          element.y += element.height - element.width;
-                          element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] >= 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height -= deltaY;
-                          element.y += deltaY;
+                          element.width += deltaX;
+                          if (e.shiftKey) {
+                            element.y += element.height - element.width;
+                            element.height = element.width;
+                          } else {
+                            element.height -= deltaY;
+                            element.y += deltaY;
+                          }
                         }
                         break;
                       case "sw":
-                        element.width -= deltaX;
-                        element.x += deltaX;
-                        if (e.shiftKey) {
-                          element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] <= 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height += deltaY;
+                          element.width -= deltaX;
+                          element.x += deltaX;
+                          if (e.shiftKey) {
+                            element.height = element.width;
+                          } else {
+                            element.height += deltaY;
+                          }
                         }
                         break;
                       case "se":
-                        if (e.shiftKey) {
-                          if (isLinear) {
-                            const { width, height } = getPerfectElementSize(
-                              element.type,
-                              x - element.x,
-                              y - element.y,
-                            );
-                            element.width = width;
-                            element.height = height;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] > 0 || p1[1] > 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
+                        } else {
+                          if (e.shiftKey) {
+                            if (isLinear) {
+                              const { width, height } = getPerfectElementSize(
+                                element.type,
+                                x - element.x,
+                                y - element.y,
+                              );
+                              element.width = width;
+                              element.height = height;
+                            } else {
+                              element.width += deltaX;
+                              element.height = element.width;
+                            }
                           } else {
                             element.width += deltaX;
-                            element.height = element.width;
+                            element.height += deltaY;
                           }
-                        } else {
-                          element.width += deltaX;
-                          element.height += deltaY;
                         }
                         break;
-                      case "n":
+                      case "n": {
                         element.height -= deltaY;
                         element.y += deltaY;
+
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+
+                          const points = [...element.points].sort(
+                            (a, b) => a[1] - b[1],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[1] -= deltaY / (len - i);
+                          }
+                        }
                         break;
-                      case "w":
+                      }
+                      case "w": {
                         element.width -= deltaX;
                         element.x += deltaX;
+
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[0] - b[0],
+                          );
+
+                          for (let i = 0; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[0] -= deltaX / (len - i);
+                          }
+                        }
                         break;
-                      case "s":
+                      }
+                      case "s": {
                         element.height += deltaY;
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[1] - b[1],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[1] += deltaY / (len - i);
+                          }
+                        }
                         break;
-                      case "e":
+                      }
+                      case "e": {
                         element.width += deltaX;
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[0] - b[0],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[0] += deltaX / (len - i);
+                          }
+                        }
                         break;
+                      }
                     }
 
                     if (resizeHandle) {
@@ -1235,6 +1483,30 @@ export class App extends React.Component<any, AppState> {
 
                 draggingElement.width = width;
                 draggingElement.height = height;
+
+                if (this.state.elementType === "arrow") {
+                  draggingOccurred = true;
+                  const points = draggingElement.points;
+                  let dx = x - draggingElement.x;
+                  let dy = y - draggingElement.y;
+
+                  if (e.shiftKey && points.length === 2) {
+                    ({ width: dx, height: dy } = getPerfectElementSize(
+                      this.state.elementType,
+                      dx,
+                      dy,
+                    ));
+                  }
+
+                  if (points.length === 1) {
+                    points.push([dx, dy]);
+                  } else if (points.length > 1) {
+                    const pnt = points[points.length - 1];
+                    pnt[0] = dx;
+                    pnt[1] = dy;
+                  }
+                }
+
                 draggingElement.shape = null;
 
                 if (this.state.elementType === "selection") {
@@ -1258,15 +1530,33 @@ export class App extends React.Component<any, AppState> {
                 const {
                   draggingElement,
                   resizingElement,
+                  multiElement,
                   elementType,
                   elementLocked,
                 } = this.state;
 
+                resizeArrowFn = null;
                 lastMouseUp = null;
                 isHoldingMouseButton = false;
                 window.removeEventListener("mousemove", onMouseMove);
                 window.removeEventListener("mouseup", onMouseUp);
 
+                if (elementType === "arrow") {
+                  if (draggingElement!.points.length > 1) {
+                    history.resumeRecording();
+                  }
+                  if (!draggingOccurred && !multiElement) {
+                    this.setState({ multiElement: this.state.draggingElement });
+                  } else if (draggingOccurred && !multiElement) {
+                    this.state.draggingElement!.isSelected = true;
+                    this.setState({
+                      draggingElement: null,
+                      elementType: "selection",
+                    });
+                  }
+                  return;
+                }
+
                 if (
                   elementType !== "selection" &&
                   draggingElement &&
@@ -1351,9 +1641,15 @@ export class App extends React.Component<any, AppState> {
               window.addEventListener("mousemove", onMouseMove);
               window.addEventListener("mouseup", onMouseUp);
 
-              // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
-              history.skipRecording();
-              this.setState({});
+              if (
+                !this.state.multiElement ||
+                (this.state.multiElement &&
+                  this.state.multiElement.points.length < 2)
+              ) {
+                // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
+                history.skipRecording();
+                this.setState({});
+              }
             }}
             onDoubleClick={e => {
               const { x, y } = viewportCoordsToSceneCoords(e, this.state);

+ 65 - 0
src/math.ts

@@ -1,3 +1,5 @@
+import { Point } from "roughjs/bin/geometry";
+
 // https://stackoverflow.com/a/6853926/232122
 export function distanceBetweenPointAndSegment(
   x: number,
@@ -52,3 +54,66 @@ export function rotate(
     (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
   ];
 }
+
+export const getPointOnAPath = (point: Point, path: Point[]) => {
+  const [px, py] = point;
+  const [start, ...other] = path;
+  let [lastX, lastY] = start;
+  let kLine: number = 0;
+  let idx: number = 0;
+
+  // if any item in the array is true, it means that a point is
+  // on some segment of a line based path
+  const retVal = other.some(([x2, y2], i) => {
+    // we always take a line when dealing with line segments
+    const x1 = lastX;
+    const y1 = lastY;
+
+    lastX = x2;
+    lastY = y2;
+
+    // if a point is not within the domain of the line segment
+    // it is not on the line segment
+    if (px < x1 || px > x2) {
+      return false;
+    }
+
+    // check if all points lie on the same line
+    // y1 = kx1 + b, y2 = kx2 + b
+    // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
+
+    // coefficient for the line (p0, p1)
+    const kL = (y2 - y1) / (x2 - x1);
+
+    // coefficient for the line segment (p0, point)
+    const kP1 = (py - y1) / (px - x1);
+
+    // coefficient for the line segment (point, p1)
+    const kP2 = (py - y2) / (px - x2);
+
+    // because we are basing both lines from the same starting point
+    // the only option for collinearity is having same coefficients
+
+    // using it for floating point comparisons
+    const epsilon = 0.3;
+
+    // if coefficient is more than an arbitrary epsilon,
+    // these lines are nor collinear
+    if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
+      return false;
+    }
+
+    // store the coefficient because we are goint to need it
+    kLine = kL;
+    idx = i;
+
+    return true;
+  });
+
+  // Return a coordinate that is always on the line segment
+  if (retVal === true) {
+    return { x: point[0], y: kLine * point[0], segment: idx };
+  }
+
+  return null;
+};

+ 8 - 3
src/renderer/renderElement.ts

@@ -7,6 +7,7 @@ import {
 } from "../element/bounds";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 import { RoughSVG } from "roughjs/bin/svg";
 import { RoughGenerator } from "roughjs/bin/generator";
 import { SVG_NS } from "../utils";
@@ -89,18 +90,23 @@ function generateElement(
         );
         break;
       case "arrow": {
-        const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
+        const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
         const options = {
           stroke: element.strokeColor,
           strokeWidth: element.strokeWidth,
           roughness: element.roughness,
           seed: element.seed,
         };
+        // points array can be empty in the beginning, so it is important to add
+        // initial position to it
+        const points: Point[] = element.points.length
+          ? element.points
+          : [[0, 0]];
         element.shape = [
           //    \
           generator.line(x3, y3, x2, y2, options),
           // -----
-          generator.line(x1, y1, x2, y2, options),
+          generator.curve(points, options),
           //    /
           generator.line(x4, y4, x2, y2, options),
         ];
@@ -169,7 +175,6 @@ export function renderElement(
         context.fillStyle = fillStyle;
         context.font = font;
         context.globalAlpha = 1;
-        break;
       } else {
         throw new Error("Unimplemented type " + element.type);
       }

+ 20 - 12
src/renderer/renderScene.ts

@@ -76,10 +76,7 @@ export function renderScene(
       element.y + sceneState.scrollY,
     );
     renderElement(element, rc, context);
-    context.translate(
-      -element.x - sceneState.scrollX,
-      -element.y - sceneState.scrollY,
-    );
+    context.resetTransform();
   });
 
   if (renderSelection) {
@@ -107,9 +104,11 @@ export function renderScene(
 
     if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
       const handlers = handlerRectangles(selectedElements[0], sceneState);
-      Object.values(handlers).forEach(handler => {
-        context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
-      });
+      Object.values(handlers)
+        .filter(handler => handler !== undefined)
+        .forEach(handler => {
+          context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
+        });
     }
   }
 
@@ -149,11 +148,20 @@ function isVisibleElement(
   canvasHeight: number,
 ) {
   let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  x1 += scrollX;
-  y1 += scrollY;
-  x2 += scrollX;
-  y2 += scrollY;
-  return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
+  if (element.type !== "arrow") {
+    x1 += scrollX;
+    y1 += scrollY;
+    x2 += scrollX;
+    y2 += scrollY;
+    return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
+  } else {
+    return (
+      x2 + scrollX >= 0 &&
+      x1 + scrollX <= canvasWidth &&
+      y2 + scrollY >= 0 &&
+      y1 + scrollY <= canvasHeight
+    );
+  }
 }
 
 // This should be only called for exporting purposes

+ 6 - 7
src/scene/data.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 
-import { getDefaultAppState } from "../appState";
+import { getDefaultAppState, cleanAppStateForExport } from "../appState";
 
 import { AppState } from "../types";
 import { ExportType, PreviousScene } from "./types";
@@ -24,7 +24,7 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
 
 interface DataState {
   elements: readonly ExcalidrawElement[];
-  appState: AppState;
+  appState: AppState | null;
   selectedId?: number;
 }
 
@@ -36,10 +36,9 @@ export function serializeAsJSON(
     {
       type: "excalidraw",
       version: 1,
-      appState: {
-        viewBackgroundColor: appState.viewBackgroundColor,
-      },
+      source: window.location.origin,
       elements: elements.map(({ shape, isSelected, ...el }) => el),
+      appState: cleanAppStateForExport(appState),
     },
     null,
     2,
@@ -255,7 +254,7 @@ export async function exportCanvas(
 
 function restore(
   savedElements: readonly ExcalidrawElement[],
-  savedState: AppState,
+  savedState: AppState | null,
 ): DataState {
   return {
     elements: savedElements.map(element => ({
@@ -291,7 +290,7 @@ export function restoreFromLocalStorage() {
   let appState = null;
   if (savedState) {
     try {
-      appState = JSON.parse(savedState);
+      appState = JSON.parse(savedState) as AppState;
     } catch (e) {
       // Do nothing because appState is already null
     }

+ 1 - 0
src/types.ts

@@ -3,6 +3,7 @@ import { ExcalidrawElement } from "./element/types";
 export type AppState = {
   draggingElement: ExcalidrawElement | null;
   resizingElement: ExcalidrawElement | null;
+  multiElement: ExcalidrawElement | null;
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: ExcalidrawElement | null;

+ 6 - 0
src/utils.ts

@@ -103,3 +103,9 @@ export function removeSelection() {
 export function distance(x: number, y: number) {
   return Math.abs(x - y);
 }
+
+export function distance2d(x1: number, y1: number, x2: number, y2: number) {
+  const xd = x2 - x1;
+  const yd = y2 - y1;
+  return Math.sqrt(xd * xd + yd * yd);
+}