Parcourir la source

feat: improved freedraw (#3512)

Co-authored-by: dwelle <luzar.david@gmail.com>
Steve Ruiz il y a 4 ans
Parent
commit
49c6bdd520
66 fichiers modifiés avec 782 ajouts et 243 suppressions
  1. 1 0
      package.json
  2. 10 8
      src/actions/actionFinalize.tsx
  3. 2 2
      src/actions/actionFlip.ts
  4. 1 0
      src/actions/types.ts
  5. 12 4
      src/components/Actions.tsx
  6. 120 32
      src/components/App.tsx
  7. 1 1
      src/components/HelpDialog.tsx
  8. 1 1
      src/components/HintViewer.tsx
  9. 13 2
      src/data/restore.ts
  10. 116 44
      src/element/bounds.ts
  11. 98 3
      src/element/collision.ts
  12. 17 0
      src/element/newElement.ts
  13. 11 3
      src/element/resizeElements.ts
  14. 3 3
      src/element/sizeHelpers.ts
  15. 1 1
      src/element/transformHandles.ts
  16. 15 2
      src/element/typeChecks.ts
  17. 11 1
      src/element/types.ts
  18. 1 1
      src/locales/ar-SA.json
  19. 1 1
      src/locales/bg-BG.json
  20. 1 1
      src/locales/ca-ES.json
  21. 1 1
      src/locales/de-DE.json
  22. 1 1
      src/locales/el-GR.json
  23. 4 1
      src/locales/en.json
  24. 1 1
      src/locales/es-ES.json
  25. 1 1
      src/locales/fa-IR.json
  26. 1 1
      src/locales/fi-FI.json
  27. 1 1
      src/locales/fr-FR.json
  28. 1 1
      src/locales/he-IL.json
  29. 1 1
      src/locales/hi-IN.json
  30. 1 1
      src/locales/hu-HU.json
  31. 1 1
      src/locales/id-ID.json
  32. 1 1
      src/locales/it-IT.json
  33. 1 1
      src/locales/ja-JP.json
  34. 1 1
      src/locales/kab-KAB.json
  35. 1 1
      src/locales/ko-KR.json
  36. 1 1
      src/locales/my-MM.json
  37. 1 1
      src/locales/nb-NO.json
  38. 1 1
      src/locales/nl-NL.json
  39. 1 1
      src/locales/nn-NO.json
  40. 1 1
      src/locales/oc-FR.json
  41. 1 1
      src/locales/pa-IN.json
  42. 1 1
      src/locales/pl-PL.json
  43. 1 1
      src/locales/pt-BR.json
  44. 1 1
      src/locales/pt-PT.json
  45. 1 1
      src/locales/ro-RO.json
  46. 1 1
      src/locales/ru-RU.json
  47. 1 1
      src/locales/sk-SK.json
  48. 1 1
      src/locales/sv-SE.json
  49. 1 1
      src/locales/tr-TR.json
  50. 1 1
      src/locales/uk-UA.json
  51. 1 1
      src/locales/zh-CN.json
  52. 1 1
      src/locales/zh-TW.json
  53. 1 0
      src/math.ts
  54. 1 0
      src/points.ts
  55. 180 28
      src/renderer/renderElement.ts
  56. 2 1
      src/renderer/renderScene.ts
  57. 8 5
      src/scene/comparisons.ts
  58. 2 1
      src/scene/index.ts
  59. 1 1
      src/shapes.tsx
  60. 91 55
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  61. 1 1
      src/tests/flip.test.tsx
  62. 13 3
      src/tests/helpers/api.ts
  63. 2 2
      src/tests/helpers/ui.ts
  64. 1 1
      src/tests/queries/toolQueries.ts
  65. 3 3
      src/tests/regressionTests.test.tsx
  66. 5 0
      yarn.lock

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
     "nanoid": "3.1.22",
     "open-color": "1.8.0",
     "pako": "1.0.11",
+    "perfect-freehand": "0.4.7",
     "png-chunk-text": "1.0.0",
     "png-chunks-encode": "1.0.0",
     "png-chunks-extract": "1.0.0",

+ 10 - 8
src/actions/actionFinalize.tsx

@@ -56,14 +56,14 @@ export const actionFinalize = register({
 
     const multiPointElement = appState.multiElement
       ? appState.multiElement
-      : appState.editingElement?.type === "draw"
+      : appState.editingElement?.type === "freedraw"
       ? appState.editingElement
       : null;
 
     if (multiPointElement) {
       // pen and mouse have hover
       if (
-        multiPointElement.type !== "draw" &&
+        multiPointElement.type !== "freedraw" &&
         appState.lastPointerDownWith !== "touch"
       ) {
         const { points, lastCommittedPoint } = multiPointElement;
@@ -86,7 +86,7 @@ export const actionFinalize = register({
       const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
       if (
         multiPointElement.type === "line" ||
-        multiPointElement.type === "draw"
+        multiPointElement.type === "freedraw"
       ) {
         if (isLoop) {
           const linePoints = multiPointElement.points;
@@ -118,22 +118,24 @@ export const actionFinalize = register({
         );
       }
 
-      if (!appState.elementLocked && appState.elementType !== "draw") {
+      if (!appState.elementLocked && appState.elementType !== "freedraw") {
         appState.selectedElementIds[multiPointElement.id] = true;
       }
     }
+
     if (
-      (!appState.elementLocked && appState.elementType !== "draw") ||
+      (!appState.elementLocked && appState.elementType !== "freedraw") ||
       !multiPointElement
     ) {
       resetCursor(canvas);
     }
+
     return {
       elements: newElements,
       appState: {
         ...appState,
         elementType:
-          (appState.elementLocked || appState.elementType === "draw") &&
+          (appState.elementLocked || appState.elementType === "freedraw") &&
           multiPointElement
             ? appState.elementType
             : "selection",
@@ -145,14 +147,14 @@ export const actionFinalize = register({
         selectedElementIds:
           multiPointElement &&
           !appState.elementLocked &&
-          appState.elementType !== "draw"
+          appState.elementType !== "freedraw"
             ? {
                 ...appState.selectedElementIds,
                 [multiPointElement.id]: true,
               }
             : appState.selectedElementIds,
       },
-      commitToHistory: appState.elementType === "draw",
+      commitToHistory: appState.elementType === "freedraw",
     };
   },
   keyTest: (event, appState) =>

+ 2 - 2
src/actions/actionFlip.ts

@@ -6,7 +6,7 @@ import { ExcalidrawElement, NonDeleted } from "../element/types";
 import { normalizeAngle, resizeSingleElement } from "../element/resizeElements";
 import { AppState } from "../types";
 import { getTransformHandles } from "../element/transformHandles";
-import { isLinearElement } from "../element/typeChecks";
+import { isFreeDrawElement, isLinearElement } from "../element/typeChecks";
 import { updateBoundElements } from "../element/binding";
 import { LinearElementEditor } from "../element/linearElementEditor";
 
@@ -114,7 +114,7 @@ const flipElement = (
   const originalAngle = normalizeAngle(element.angle);
 
   let finalOffsetX = 0;
-  if (isLinearElement(element)) {
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
     finalOffsetX =
       element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 -
       element.width;

+ 1 - 0
src/actions/types.ts

@@ -52,6 +52,7 @@ export type ActionName =
   | "changeBackgroundColor"
   | "changeFillStyle"
   | "changeStrokeWidth"
+  | "changeStrokeShape"
   | "changeSloppiness"
   | "changeStrokeStyle"
   | "changeArrowhead"

+ 12 - 4
src/components/Actions.tsx

@@ -9,7 +9,8 @@ import {
   canHaveArrowheads,
   getTargetElements,
   hasBackground,
-  hasStroke,
+  hasStrokeStyle,
+  hasStrokeWidth,
   hasText,
 } from "../scene";
 import { SHAPES } from "../shapes";
@@ -53,10 +54,17 @@ export const SelectedShapeActions = ({
       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
       {showFillIcons && renderAction("changeFillStyle")}
 
-      {(hasStroke(elementType) ||
-        targetElements.some((element) => hasStroke(element.type))) && (
+      {(hasStrokeWidth(elementType) ||
+        targetElements.some((element) => hasStrokeWidth(element.type))) &&
+        renderAction("changeStrokeWidth")}
+
+      {(elementType === "freedraw" ||
+        targetElements.some((element) => element.type === "freedraw")) &&
+        renderAction("changeStrokeShape")}
+
+      {(hasStrokeStyle(elementType) ||
+        targetElements.some((element) => hasStrokeStyle(element.type))) && (
         <>
-          {renderAction("changeStrokeWidth")}
           {renderAction("changeStrokeStyle")}
           {renderAction("changeSloppiness")}
         </>

+ 120 - 32
src/components/App.tsx

@@ -1,4 +1,3 @@
-import { Point, simplify } from "points-on-curve";
 import React, { useContext } from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
@@ -70,7 +69,7 @@ import {
 import { loadFromBlob } from "../data";
 import { isValidLibrary } from "../data/json";
 import Library from "../data/library";
-import { restore } from "../data/restore";
+import { restore, restoreElements } from "../data/restore";
 import {
   dragNewElement,
   dragSelectedElements,
@@ -111,7 +110,7 @@ import {
 } from "../element/binding";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { mutateElement } from "../element/mutateElement";
-import { deepCopyElement } from "../element/newElement";
+import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
 import { MaybeTransformHandleType } from "../element/transformHandles";
 import {
   isBindingElement,
@@ -122,6 +121,7 @@ import {
 import {
   ExcalidrawBindableElement,
   ExcalidrawElement,
+  ExcalidrawFreeDrawElement,
   ExcalidrawGenericElement,
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
@@ -1266,7 +1266,7 @@ class App extends React.Component<AppProps, AppState> {
         });
       } else if (data.elements) {
         this.addElementsFromPasteOrLibrary({
-          elements: data.elements,
+          elements: restoreElements(data.elements),
           position: "cursor",
         });
       } else if (data.text) {
@@ -2341,7 +2341,6 @@ class App extends React.Component<AppProps, AppState> {
       return;
     } else if (
       this.state.elementType === "arrow" ||
-      this.state.elementType === "draw" ||
       this.state.elementType === "line"
     ) {
       this.handleLinearElementOnPointerDown(
@@ -2349,6 +2348,12 @@ class App extends React.Component<AppProps, AppState> {
         this.state.elementType,
         pointerDownState,
       );
+    } else if (this.state.elementType === "freedraw") {
+      this.handleFreeDrawElementOnPointerDown(
+        event,
+        this.state.elementType,
+        pointerDownState,
+      );
     } else {
       this.createGenericElementOnPointerDown(
         this.state.elementType,
@@ -2845,6 +2850,65 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private handleFreeDrawElementOnPointerDown = (
+    event: React.PointerEvent<HTMLCanvasElement>,
+    elementType: ExcalidrawFreeDrawElement["type"],
+    pointerDownState: PointerDownState,
+  ) => {
+    // Begin a mark capture. This does not have to update state yet.
+    const [gridX, gridY] = getGridPoint(
+      pointerDownState.origin.x,
+      pointerDownState.origin.y,
+      null,
+    );
+
+    const element = newFreeDrawElement({
+      type: elementType,
+      x: gridX,
+      y: gridY,
+      strokeColor: this.state.currentItemStrokeColor,
+      backgroundColor: this.state.currentItemBackgroundColor,
+      fillStyle: this.state.currentItemFillStyle,
+      strokeWidth: this.state.currentItemStrokeWidth,
+      strokeStyle: this.state.currentItemStrokeStyle,
+      roughness: this.state.currentItemRoughness,
+      opacity: this.state.currentItemOpacity,
+      strokeSharpness: this.state.currentItemLinearStrokeSharpness,
+      simulatePressure: event.pressure === 0.5,
+    });
+
+    this.setState((prevState) => ({
+      selectedElementIds: {
+        ...prevState.selectedElementIds,
+        [element.id]: false,
+      },
+    }));
+
+    const pressures = element.simulatePressure
+      ? element.pressures
+      : [...element.pressures, event.pressure];
+
+    mutateElement(element, {
+      points: [[0, 0]],
+      pressures,
+    });
+
+    const boundElement = getHoveredElementForBinding(
+      pointerDownState.origin,
+      this.scene,
+    );
+    this.scene.replaceAllElements([
+      ...this.scene.getElementsIncludingDeleted(),
+      element,
+    ]);
+    this.setState({
+      draggingElement: element,
+      editingElement: element,
+      startBoundElement: boundElement,
+      suggestedBindings: [],
+    });
+  };
+
   private handleLinearElementOnPointerDown = (
     event: React.PointerEvent<HTMLCanvasElement>,
     elementType: ExcalidrawLinearElement["type"],
@@ -2899,7 +2963,7 @@ class App extends React.Component<AppProps, AppState> {
       const [gridX, gridY] = getGridPoint(
         pointerDownState.origin.x,
         pointerDownState.origin.y,
-        elementType === "draw" ? null : this.state.gridSize,
+        this.state.gridSize,
       );
 
       /* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
@@ -3107,6 +3171,7 @@ class App extends React.Component<AppProps, AppState> {
       const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
         (element) => this.isASelectedElement(element),
       );
+
       if (
         hasHitASelectedElement ||
         pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
@@ -3207,18 +3272,24 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
-      if (isLinearElement(draggingElement)) {
+      if (draggingElement.type === "freedraw") {
+        const points = draggingElement.points;
+        const dx = pointerCoords.x - draggingElement.x;
+        const dy = pointerCoords.y - draggingElement.y;
+
+        const pressures = draggingElement.simulatePressure
+          ? draggingElement.pressures
+          : [...draggingElement.pressures, event.pressure];
+
+        mutateElement(draggingElement, {
+          points: [...points, [dx, dy]],
+          pressures,
+        });
+      } else if (isLinearElement(draggingElement)) {
         pointerDownState.drag.hasOccurred = true;
         const points = draggingElement.points;
-        let dx: number;
-        let dy: number;
-        if (draggingElement.type === "draw") {
-          dx = pointerCoords.x - draggingElement.x;
-          dy = pointerCoords.y - draggingElement.y;
-        } else {
-          dx = gridX - draggingElement.x;
-          dy = gridY - draggingElement.y;
-        }
+        let dx = gridX - draggingElement.x;
+        let dy = gridY - draggingElement.y;
 
         if (getRotateWithDiscreteAngleKey(event) && points.length === 2) {
           ({ width: dx, height: dy } = getPerfectElementSize(
@@ -3231,19 +3302,11 @@ class App extends React.Component<AppProps, AppState> {
         if (points.length === 1) {
           mutateElement(draggingElement, { points: [...points, [dx, dy]] });
         } else if (points.length > 1) {
-          if (draggingElement.type === "draw") {
-            mutateElement(draggingElement, {
-              points: simplify(
-                [...(points as Point[]), [dx, dy]],
-                0.7 / this.state.zoom.value,
-              ),
-            });
-          } else {
-            mutateElement(draggingElement, {
-              points: [...points.slice(0, -1), [dx, dy]],
-            });
-          }
+          mutateElement(draggingElement, {
+            points: [...points.slice(0, -1), [dx, dy]],
+          });
         }
+
         if (isBindingElement(draggingElement)) {
           // When creating a linear element by dragging
           this.maybeSuggestBindingForLinearElementAtCursor(
@@ -3383,8 +3446,33 @@ class App extends React.Component<AppProps, AppState> {
         pointerDownState.eventListeners.onKeyUp!,
       );
 
-      if (draggingElement?.type === "draw") {
+      if (draggingElement?.type === "freedraw") {
+        const pointerCoords = viewportCoordsToSceneCoords(
+          childEvent,
+          this.state,
+        );
+
+        const points = draggingElement.points;
+        let dx = pointerCoords.x - draggingElement.x;
+        let dy = pointerCoords.y - draggingElement.y;
+
+        // Allows dots to avoid being flagged as infinitely small
+        if (dx === points[0][0] && dy === points[0][1]) {
+          dy += 0.0001;
+          dx += 0.0001;
+        }
+
+        const pressures = draggingElement.simulatePressure
+          ? []
+          : [...draggingElement.pressures, childEvent.pressure];
+
+        mutateElement(draggingElement, {
+          points: [...points, [dx, dy]],
+          pressures,
+        });
+
         this.actionManager.executeAction(actionFinalize);
+
         return;
       }
 
@@ -3428,7 +3516,7 @@ class App extends React.Component<AppProps, AppState> {
             );
           }
           this.setState({ suggestedBindings: [], startBoundElement: null });
-          if (!elementLocked && elementType !== "draw") {
+          if (!elementLocked) {
             resetCursor(this.canvas);
             this.setState((prevState) => ({
               draggingElement: null,
@@ -3575,7 +3663,7 @@ class App extends React.Component<AppProps, AppState> {
         return;
       }
 
-      if (!elementLocked && elementType !== "draw" && draggingElement) {
+      if (!elementLocked && elementType !== "freedraw" && draggingElement) {
         this.setState((prevState) => ({
           selectedElementIds: {
             ...prevState.selectedElementIds,
@@ -3599,7 +3687,7 @@ class App extends React.Component<AppProps, AppState> {
         );
       }
 
-      if (!elementLocked && elementType !== "draw") {
+      if (!elementLocked && elementType !== "freedraw") {
         resetCursor(this.canvas);
         this.setState({
           draggingElement: null,

+ 1 - 1
src/components/HelpDialog.tsx

@@ -153,7 +153,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                 <Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
                 <Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
                 <Shortcut
-                  label={t("toolBar.draw")}
+                  label={t("toolBar.freedraw")}
                   shortcuts={["Shift+P", "7"]}
                 />
                 <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />

+ 1 - 1
src/components/HintViewer.tsx

@@ -23,7 +23,7 @@ const getHints = ({ appState, elements }: Hint) => {
     return t("hints.linearElementMulti");
   }
 
-  if (elementType === "draw") {
+  if (elementType === "freedraw") {
     return t("hints.freeDraw");
   }
 

+ 13 - 2
src/data/restore.ts

@@ -37,10 +37,12 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
 
 const restoreElementWithProperties = <T extends ExcalidrawElement>(
   element: Required<T>,
-  extra: Omit<Required<T>, keyof ExcalidrawElement>,
+  extra: Omit<Required<T>, keyof ExcalidrawElement> & {
+    type?: ExcalidrawElement["type"];
+  },
 ): T => {
   const base: Pick<T, keyof ExcalidrawElement> = {
-    type: element.type,
+    type: extra.type || element.type,
     // all elements must have version > 0 so getSceneVersion() will pick up
     // newly added elements
     version: element.version || 1,
@@ -97,6 +99,14 @@ const restoreElement = (
         textAlign: element.textAlign || DEFAULT_TEXT_ALIGN,
         verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
       });
+    case "freedraw": {
+      return restoreElementWithProperties(element, {
+        points: element.points,
+        lastCommittedPoint: null,
+        simulatePressure: element.simulatePressure,
+        pressures: element.pressures,
+      });
+    }
     case "draw":
     case "line":
     case "arrow": {
@@ -106,6 +116,7 @@ const restoreElement = (
       } = element;
 
       return restoreElementWithProperties(element, {
+        type: element.type === "draw" ? "line" : element.type,
         startBinding: element.startBinding,
         endBinding: element.endBinding,
         points:

+ 116 - 44
src/element/bounds.ts

@@ -1,4 +1,9 @@
-import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
+import {
+  ExcalidrawElement,
+  ExcalidrawLinearElement,
+  Arrowhead,
+  ExcalidrawFreeDrawElement,
+} from "./types";
 import { distance2d, rotate } from "../math";
 import rough from "roughjs/bin/rough";
 import { Drawable, Op } from "roughjs/bin/core";
@@ -7,7 +12,7 @@ import {
   getShapeForElement,
   generateRoughOptions,
 } from "../renderer/renderElement";
-import { isLinearElement } from "./typeChecks";
+import { isFreeDrawElement, isLinearElement } from "./typeChecks";
 import { rescalePoints } from "../points";
 
 // x and y position of top left corner, x and y position of bottom right corner
@@ -18,7 +23,9 @@ export type Bounds = readonly [number, number, number, number];
 export const getElementAbsoluteCoords = (
   element: ExcalidrawElement,
 ): Bounds => {
-  if (isLinearElement(element)) {
+  if (isFreeDrawElement(element)) {
+    return getFreeDrawElementAbsoluteCoords(element);
+  } else if (isLinearElement(element)) {
     return getLinearElementAbsoluteCoords(element);
   }
   return [
@@ -120,9 +127,42 @@ const getMinMaxXYFromCurvePathOps = (
   return [minX, minY, maxX, maxY];
 };
 
+const getBoundsFromPoints = (
+  points: ExcalidrawFreeDrawElement["points"],
+): [number, number, number, number] => {
+  let minX = Infinity;
+  let minY = Infinity;
+  let maxX = -Infinity;
+  let maxY = -Infinity;
+
+  for (const [x, y] of points) {
+    minX = Math.min(minX, x);
+    minY = Math.min(minY, y);
+    maxX = Math.max(maxX, x);
+    maxY = Math.max(maxY, y);
+  }
+
+  return [minX, minY, maxX, maxY];
+};
+
+const getFreeDrawElementAbsoluteCoords = (
+  element: ExcalidrawFreeDrawElement,
+): [number, number, number, number] => {
+  const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
+
+  return [
+    minX + element.x,
+    minY + element.y,
+    maxX + element.x,
+    maxY + element.y,
+  ];
+};
+
 const getLinearElementAbsoluteCoords = (
   element: ExcalidrawLinearElement,
 ): [number, number, number, number] => {
+  let coords: [number, number, number, number];
+
   if (element.points.length < 2 || !getShapeForElement(element)) {
     // XXX this is just a poor estimate and not very useful
     const { minX, minY, maxX, maxY } = element.points.reduce(
@@ -137,27 +177,29 @@ const getLinearElementAbsoluteCoords = (
       },
       { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
     );
-    return [
+    coords = [
       minX + element.x,
       minY + element.y,
       maxX + element.x,
       maxY + element.y,
     ];
-  }
+  } else {
+    const shape = getShapeForElement(element) as Drawable[];
 
-  const shape = getShapeForElement(element) as Drawable[];
+    // first element is always the curve
+    const ops = getCurvePathOps(shape[0]);
 
-  // first element is always the curve
-  const ops = getCurvePathOps(shape[0]);
+    const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
 
-  const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+    coords = [
+      minX + element.x,
+      minY + element.y,
+      maxX + element.x,
+      maxY + element.y,
+    ];
+  }
 
-  return [
-    minX + element.x,
-    minY + element.y,
-    maxX + element.x,
-    maxY + element.y,
-  ];
+  return coords;
 };
 
 export const getArrowheadPoints = (
@@ -231,7 +273,7 @@ export const getArrowheadPoints = (
   const ys = y2 - ny * minSize;
 
   if (arrowhead === "dot") {
-    const r = Math.hypot(ys - y2, xs - x2);
+    const r = Math.hypot(ys - y2, xs - x2) + element.strokeWidth;
     return [x2, y2, r];
   }
 
@@ -277,16 +319,31 @@ const getLinearElementRotatedBounds = (
   return getMinMaxXYFromCurvePathOps(ops, transformXY);
 };
 
+// We could cache this stuff
 export const getElementBounds = (
   element: ExcalidrawElement,
 ): [number, number, number, number] => {
+  let bounds: [number, number, number, number];
+
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
-  if (isLinearElement(element)) {
-    return getLinearElementRotatedBounds(element, cx, cy);
-  }
-  if (element.type === "diamond") {
+  if (isFreeDrawElement(element)) {
+    const [minX, minY, maxX, maxY] = getBoundsFromPoints(
+      element.points.map(([x, y]) =>
+        rotate(x, y, cx - element.x, cy - element.y, element.angle),
+      ),
+    );
+
+    return [
+      minX + element.x,
+      minY + element.y,
+      maxX + element.x,
+      maxY + element.y,
+    ];
+  } else if (isLinearElement(element)) {
+    bounds = getLinearElementRotatedBounds(element, cx, cy);
+  } else if (element.type === "diamond") {
     const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
     const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
     const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
@@ -295,26 +352,28 @@ export const getElementBounds = (
     const minY = Math.min(y11, y12, y22, y21);
     const maxX = Math.max(x11, x12, x22, x21);
     const maxY = Math.max(y11, y12, y22, y21);
-    return [minX, minY, maxX, maxY];
-  }
-  if (element.type === "ellipse") {
+    bounds = [minX, minY, maxX, maxY];
+  } else if (element.type === "ellipse") {
     const w = (x2 - x1) / 2;
     const h = (y2 - y1) / 2;
     const cos = Math.cos(element.angle);
     const sin = Math.sin(element.angle);
     const ww = Math.hypot(w * cos, h * sin);
     const hh = Math.hypot(h * cos, w * sin);
-    return [cx - ww, cy - hh, cx + ww, cy + hh];
+    bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
+  } else {
+    const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
+    const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
+    const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
+    const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
+    const minX = Math.min(x11, x12, x22, x21);
+    const minY = Math.min(y11, y12, y22, y21);
+    const maxX = Math.max(x11, x12, x22, x21);
+    const maxY = Math.max(y11, y12, y22, y21);
+    bounds = [minX, minY, maxX, maxY];
   }
-  const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
-  const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
-  const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
-  const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
-  const minX = Math.min(x11, x12, x22, x21);
-  const minY = Math.min(y11, y12, y22, y21);
-  const maxX = Math.max(x11, x12, x22, x21);
-  const maxY = Math.max(y11, y12, y22, y21);
-  return [minX, minY, maxX, maxY];
+
+  return bounds;
 };
 
 export const getCommonBounds = (
@@ -345,7 +404,7 @@ export const getResizedElementAbsoluteCoords = (
   nextWidth: number,
   nextHeight: number,
 ): [number, number, number, number] => {
-  if (!isLinearElement(element)) {
+  if (!(isLinearElement(element) || isFreeDrawElement(element))) {
     return [
       element.x,
       element.y,
@@ -360,16 +419,29 @@ export const getResizedElementAbsoluteCoords = (
     rescalePoints(1, nextHeight, element.points),
   );
 
-  const gen = rough.generator();
-  const curve =
-    element.strokeSharpness === "sharp"
-      ? gen.linearPath(
-          points as [number, number][],
-          generateRoughOptions(element),
-        )
-      : gen.curve(points as [number, number][], generateRoughOptions(element));
-  const ops = getCurvePathOps(curve);
-  const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+  let bounds: [number, number, number, number];
+
+  if (isFreeDrawElement(element)) {
+    // Free Draw
+    bounds = getBoundsFromPoints(points);
+  } else {
+    // Line
+    const gen = rough.generator();
+    const curve =
+      element.strokeSharpness === "sharp"
+        ? gen.linearPath(
+            points as [number, number][],
+            generateRoughOptions(element),
+          )
+        : gen.curve(
+            points as [number, number][],
+            generateRoughOptions(element),
+          );
+    const ops = getCurvePathOps(curve);
+    bounds = getMinMaxXYFromCurvePathOps(ops);
+  }
+
+  const [minX, minY, maxX, maxY] = bounds;
   return [
     minX + element.x,
     minY + element.y,

+ 98 - 3
src/element/collision.ts

@@ -4,7 +4,13 @@ import * as GADirection from "../gadirections";
 import * as GALine from "../galines";
 import * as GATransform from "../gatransforms";
 
-import { isPathALoop, isPointInPolygon, rotate } from "../math";
+import {
+  distance2d,
+  rotatePoint,
+  isPathALoop,
+  isPointInPolygon,
+  rotate,
+} from "../math";
 import { pointsOnBezierCurves } from "points-on-curve";
 
 import {
@@ -16,6 +22,7 @@ import {
   ExcalidrawTextElement,
   ExcalidrawEllipseElement,
   NonDeleted,
+  ExcalidrawFreeDrawElement,
 } from "./types";
 
 import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -30,10 +37,17 @@ const isElementDraggableFromInside = (
   if (element.type === "arrow") {
     return false;
   }
+
+  if (element.type === "freedraw") {
+    return true;
+  }
+
   const isDraggableFromInside = element.backgroundColor !== "transparent";
-  if (element.type === "line" || element.type === "draw") {
+
+  if (element.type === "line") {
     return isDraggableFromInside && isPathALoop(element.points);
   }
+
   return isDraggableFromInside;
 };
 
@@ -81,6 +95,7 @@ const isHittingElementNotConsideringBoundingBox = (
       : isElementDraggableFromInside(element)
       ? isInsideCheck
       : isNearCheck;
+
   return hitTestPointAgainstElement({ element, point, threshold, check });
 };
 
@@ -151,6 +166,18 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
     case "ellipse":
       const distance = distanceToBindableElement(args.element, args.point);
       return args.check(distance, args.threshold);
+    case "freedraw": {
+      if (
+        !args.check(
+          distanceToRectangle(args.element, args.point),
+          args.threshold,
+        )
+      ) {
+        return false;
+      }
+
+      return hitTestFreeDrawElement(args.element, args.point, args.threshold);
+    }
     case "arrow":
     case "line":
     case "draw":
@@ -195,7 +222,10 @@ const isOutsideCheck = (distance: number, threshold: number): boolean => {
 };
 
 const distanceToRectangle = (
-  element: ExcalidrawRectangleElement | ExcalidrawTextElement,
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawTextElement
+    | ExcalidrawFreeDrawElement,
   point: Point,
 ): number => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -267,6 +297,71 @@ const ellipseParamsForTest = (
   return [pointRel, tangent];
 };
 
+const hitTestFreeDrawElement = (
+  element: ExcalidrawFreeDrawElement,
+  point: Point,
+  threshold: number,
+): boolean => {
+  // Check point-distance-to-line-segment for every segment in the
+  // element's points (its input points, not its outline points).
+  // This is... okay? It's plenty fast, but the GA library may
+  // have a faster option.
+
+  let x: number;
+  let y: number;
+
+  if (element.angle === 0) {
+    x = point[0] - element.x;
+    y = point[1] - element.y;
+  } else {
+    // Counter-rotate the point around center before testing
+    const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element);
+    const rotatedPoint = rotatePoint(
+      point,
+      [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
+      -element.angle,
+    );
+    x = rotatedPoint[0] - element.x;
+    y = rotatedPoint[1] - element.y;
+  }
+
+  let [A, B] = element.points;
+  let P: readonly [number, number];
+
+  // For freedraw dots
+  if (element.points.length === 2) {
+    return (
+      distance2d(A[0], A[1], x, y) < threshold ||
+      distance2d(B[0], B[1], x, y) < threshold
+    );
+  }
+
+  // For freedraw lines
+  for (let i = 1; i < element.points.length - 1; i++) {
+    const delta = [B[0] - A[0], B[1] - A[1]];
+    const length = Math.hypot(delta[1], delta[0]);
+
+    const U = [delta[0] / length, delta[1] / length];
+    const C = [x - A[0], y - A[1]];
+    const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
+    P = [A[0] + U[0] * d, A[1] + U[1] * d];
+
+    const da = distance2d(P[0], P[1], A[0], A[1]);
+    const db = distance2d(P[0], P[1], B[0], B[1]);
+
+    P = db < da && da > length ? B : da < db && db > length ? A : P;
+
+    if (Math.hypot(y - P[1], x - P[0]) < threshold) {
+      return true;
+    }
+
+    A = B;
+    B = element.points[i + 1];
+  }
+
+  return false;
+};
+
 const hitTestLinear = (args: HitTestArgs): boolean => {
   const { element, threshold } = args;
   if (!getShapeForElement(element)) {

+ 17 - 0
src/element/newElement.ts

@@ -9,6 +9,7 @@ import {
   GroupId,
   VerticalAlign,
   Arrowhead,
+  ExcalidrawFreeDrawElement,
 } from "../element/types";
 import { measureText, getFontString } from "../utils";
 import { randomInteger, randomId } from "../random";
@@ -212,6 +213,22 @@ export const updateTextElement = (
   });
 };
 
+export const newFreeDrawElement = (
+  opts: {
+    type: "freedraw";
+    points?: ExcalidrawFreeDrawElement["points"];
+    simulatePressure: boolean;
+  } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawFreeDrawElement> => {
+  return {
+    ..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
+    points: opts.points || [],
+    pressures: [],
+    simulatePressure: opts.simulatePressure,
+    lastCommittedPoint: null,
+  };
+};
+
 export const newLinearElement = (
   opts: {
     type: ExcalidrawLinearElement["type"];

+ 11 - 3
src/element/resizeElements.ts

@@ -18,7 +18,11 @@ import {
   getCommonBounds,
   getResizedElementAbsoluteCoords,
 } from "./bounds";
-import { isLinearElement, isTextElement } from "./typeChecks";
+import {
+  isFreeDrawElement,
+  isLinearElement,
+  isTextElement,
+} from "./typeChecks";
 import { mutateElement } from "./mutateElement";
 import { getPerfectElementSize } from "./sizeHelpers";
 import { measureText, getFontString } from "../utils";
@@ -244,7 +248,7 @@ const rescalePointsInElement = (
   width: number,
   height: number,
 ) =>
-  isLinearElement(element)
+  isLinearElement(element) || isFreeDrawElement(element)
     ? {
         points: rescalePoints(
           0,
@@ -404,7 +408,7 @@ export const resizeSingleElement = (
     -stateAtResizeStart.angle,
   );
 
-  //Get bounds corners rendered on screen
+  // Get bounds corners rendered on screen
   const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
     element,
     element.width,
@@ -644,11 +648,14 @@ const resizeMultipleElements = (
           font = { fontSize: nextFont.size, baseline: nextFont.baseline };
         }
         const origCoords = getElementAbsoluteCoords(element);
+
         const rescaledPoints = rescalePointsInElement(element, width, height);
+
         updateBoundElements(element, {
           newSize: { width, height },
           simultaneouslyUpdated: elements,
         });
+
         const finalCoords = getResizedElementAbsoluteCoords(
           {
             ...element,
@@ -657,6 +664,7 @@ const resizeMultipleElements = (
           width,
           height,
         );
+
         const { x, y } = getNextXY(element, origCoords, finalCoords);
         return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
       },

+ 3 - 3
src/element/sizeHelpers.ts

@@ -1,12 +1,12 @@
 import { ExcalidrawElement } from "./types";
 import { mutateElement } from "./mutateElement";
-import { isLinearElement } from "./typeChecks";
+import { isFreeDrawElement, isLinearElement } from "./typeChecks";
 import { SHIFT_LOCKING_ANGLE } from "../constants";
 
 export const isInvisiblySmallElement = (
   element: ExcalidrawElement,
 ): boolean => {
-  if (isLinearElement(element)) {
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
     return element.points.length < 2;
   }
   return element.width === 0 && element.height === 0;
@@ -26,7 +26,7 @@ export const getPerfectElementSize = (
   if (
     elementType === "line" ||
     elementType === "arrow" ||
-    elementType === "draw"
+    elementType === "freedraw"
   ) {
     const lockedAngle =
       Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *

+ 1 - 1
src/element/transformHandles.ts

@@ -225,7 +225,7 @@ export const getTransformHandles = (
   if (
     element.type === "arrow" ||
     element.type === "line" ||
-    element.type === "draw"
+    element.type === "freedraw"
   ) {
     if (element.points.length === 2) {
       // only check the last point because starting point is always (0,0)

+ 15 - 2
src/element/typeChecks.ts

@@ -4,6 +4,7 @@ import {
   ExcalidrawLinearElement,
   ExcalidrawBindableElement,
   ExcalidrawGenericElement,
+  ExcalidrawFreeDrawElement,
 } from "./types";
 
 export const isGenericElement = (
@@ -24,6 +25,18 @@ export const isTextElement = (
   return element != null && element.type === "text";
 };
 
+export const isFreeDrawElement = (
+  element?: ExcalidrawElement | null,
+): element is ExcalidrawFreeDrawElement => {
+  return element != null && isFreeDrawElementType(element.type);
+};
+
+export const isFreeDrawElementType = (
+  elementType: ExcalidrawElement["type"],
+): boolean => {
+  return elementType === "freedraw";
+};
+
 export const isLinearElement = (
   element?: ExcalidrawElement | null,
 ): element is ExcalidrawLinearElement => {
@@ -34,7 +47,7 @@ export const isLinearElementType = (
   elementType: ExcalidrawElement["type"],
 ): boolean => {
   return (
-    elementType === "arrow" || elementType === "line" || elementType === "draw"
+    elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
   );
 };
 
@@ -69,7 +82,7 @@ export const isExcalidrawElement = (element: any): boolean => {
     element?.type === "rectangle" ||
     element?.type === "ellipse" ||
     element?.type === "arrow" ||
-    element?.type === "draw" ||
+    element?.type === "freedraw" ||
     element?.type === "line"
   );
 };

+ 11 - 1
src/element/types.ts

@@ -78,7 +78,8 @@ export type ExcalidrawGenericElement =
 export type ExcalidrawElement =
   | ExcalidrawGenericElement
   | ExcalidrawTextElement
-  | ExcalidrawLinearElement;
+  | ExcalidrawLinearElement
+  | ExcalidrawFreeDrawElement;
 
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
   isDeleted: false;
@@ -121,3 +122,12 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
     startArrowhead: Arrowhead | null;
     endArrowhead: Arrowhead | null;
   }>;
+
+export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
+  Readonly<{
+    type: "freedraw";
+    points: readonly Point[];
+    pressures: readonly number[];
+    simulatePressure: boolean;
+    lastCommittedPoint: Point | null;
+  }>;

+ 1 - 1
src/locales/ar-SA.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "تحديد",
-    "draw": "الكتابة الحرة",
+    "freedraw": "الكتابة الحرة",
     "rectangle": "مستطيل",
     "diamond": "مضلع",
     "ellipse": "دائرة",

+ 1 - 1
src/locales/bg-BG.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Селекция",
-    "draw": "Рисуване",
+    "freedraw": "Рисуване",
     "rectangle": "Правоъгълник",
     "diamond": "Диамант",
     "ellipse": "Елипс",

+ 1 - 1
src/locales/ca-ES.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Selecció",
-    "draw": "Dibuix lliure",
+    "freedraw": "Dibuix lliure",
     "rectangle": "Rectangle",
     "diamond": "Rombe",
     "ellipse": "El·lipse",

+ 1 - 1
src/locales/de-DE.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Auswahl",
-    "draw": "Freies Zeichnen",
+    "freedraw": "Freies Zeichnen",
     "rectangle": "Rechteck",
     "diamond": "Raute",
     "ellipse": "Ellipse",

+ 1 - 1
src/locales/el-GR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Επιλογή",
-    "draw": "Ελεύθερο σχέδιο",
+    "freedraw": "Ελεύθερο σχέδιο",
     "rectangle": "Ορθογώνιο",
     "diamond": "Ρόμβος",
     "ellipse": "Έλλειψη",

+ 4 - 1
src/locales/en.json

@@ -20,6 +20,10 @@
     "background": "Background",
     "fill": "Fill",
     "strokeWidth": "Stroke width",
+    "strokeShape": "Stroke shape",
+    "strokeShape_gel": "Gel pen",
+    "strokeShape_fountain": "Fountain pen",
+    "strokeShape_brush": "Brush pen",
     "strokeStyle": "Stroke style",
     "strokeStyle_solid": "Solid",
     "strokeStyle_dashed": "Dashed",
@@ -153,7 +157,6 @@
   },
   "toolBar": {
     "selection": "Selection",
-    "draw": "Free draw",
     "rectangle": "Rectangle",
     "diamond": "Diamond",
     "ellipse": "Ellipse",

+ 1 - 1
src/locales/es-ES.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Selección",
-    "draw": "Dibujo libre",
+    "freedraw": "Dibujo libre",
     "rectangle": "Rectángulo",
     "diamond": "Diamante",
     "ellipse": "Elipse",

+ 1 - 1
src/locales/fa-IR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "گزینش",
-    "draw": "طراحی آزاد",
+    "freedraw": "طراحی آزاد",
     "rectangle": "مستطیل",
     "diamond": "لوزی",
     "ellipse": "بیضی",

+ 1 - 1
src/locales/fi-FI.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Valinta",
-    "draw": "Vapaa piirto",
+    "freedraw": "Vapaa piirto",
     "rectangle": "Suorakulmio",
     "diamond": "Vinoneliö",
     "ellipse": "Soikio",

+ 1 - 1
src/locales/fr-FR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Sélection",
-    "draw": "Dessin libre",
+    "freedraw": "Dessin libre",
     "rectangle": "Rectangle",
     "diamond": "Losange",
     "ellipse": "Ellipse",

+ 1 - 1
src/locales/he-IL.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "בחירה",
-    "draw": "ציור חופשי",
+    "freedraw": "ציור חופשי",
     "rectangle": "מרובע",
     "diamond": "מעוין",
     "ellipse": "אליפסה",

+ 1 - 1
src/locales/hi-IN.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "चयन",
-    "draw": "मुफ्त ड्रा",
+    "freedraw": "मुफ्त ड्रा",
     "rectangle": "आयात",
     "diamond": "तिर्यग्वर्ग",
     "ellipse": "दीर्घवृत्त",

+ 1 - 1
src/locales/hu-HU.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Kijelölés",
-    "draw": "Szabadkézi rajz",
+    "freedraw": "Szabadkézi rajz",
     "rectangle": "Téglalap",
     "diamond": "Rombusz",
     "ellipse": "Ellipszis",

+ 1 - 1
src/locales/id-ID.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Pilihan",
-    "draw": "Menggambar bebas",
+    "freedraw": "Menggambar bebas",
     "rectangle": "Persegi",
     "diamond": "Berlian",
     "ellipse": "Elips",

+ 1 - 1
src/locales/it-IT.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Selezione",
-    "draw": "Disegno libero",
+    "freedraw": "Disegno libero",
     "rectangle": "Rettangolo",
     "diamond": "Rombo",
     "ellipse": "Ellisse",

+ 1 - 1
src/locales/ja-JP.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "選択",
-    "draw": "手書き",
+    "freedraw": "手書き",
     "rectangle": "矩形",
     "diamond": "ひし形",
     "ellipse": "楕円",

+ 1 - 1
src/locales/kab-KAB.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Tafrayt",
-    "draw": "Unuɣ ilelli",
+    "freedraw": "Unuɣ ilelli",
     "rectangle": "Asrem",
     "diamond": "Ameɣṛun",
     "ellipse": "Taglayt",

+ 1 - 1
src/locales/ko-KR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "선택",
-    "draw": "자유롭게 그리기",
+    "freedraw": "자유롭게 그리기",
     "rectangle": "사각형",
     "diamond": "다이아몬드",
     "ellipse": "타원",

+ 1 - 1
src/locales/my-MM.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "ရွေးချယ်",
-    "draw": "အလွတ်ရေးဆွဲ",
+    "freedraw": "အလွတ်ရေးဆွဲ",
     "rectangle": "စတုဂံ",
     "diamond": "စိန်",
     "ellipse": "အဝိုင်း",

+ 1 - 1
src/locales/nb-NO.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Velg",
-    "draw": "Frihåndstegning",
+    "freedraw": "Frihåndstegning",
     "rectangle": "Rektangel",
     "diamond": "Diamant",
     "ellipse": "Ellipse",

+ 1 - 1
src/locales/nl-NL.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Selectie",
-    "draw": "Vrij tekenen",
+    "freedraw": "Vrij tekenen",
     "rectangle": "Rechthoek",
     "diamond": "Ruit",
     "ellipse": "Ovaal",

+ 1 - 1
src/locales/nn-NO.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Vel",
-    "draw": "Frihandsteikning",
+    "freedraw": "Frihandsteikning",
     "rectangle": "Rektangel",
     "diamond": "Diamant",
     "ellipse": "Ellipse",

+ 1 - 1
src/locales/oc-FR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Seleccion",
-    "draw": "Dessenh liure",
+    "freedraw": "Dessenh liure",
     "rectangle": "Rectangle",
     "diamond": "Lausange",
     "ellipse": "Ellipsa",

+ 1 - 1
src/locales/pa-IN.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "ਚੋਣਕਾਰ",
-    "draw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ",
+    "freedraw": "ਖੁੱਲ੍ਹੀ ਵਾਹੀ",
     "rectangle": "ਆਇਤ",
     "diamond": "ਹੀਰਾ",
     "ellipse": "ਅੰਡਾਕਾਰ",

+ 1 - 1
src/locales/pl-PL.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Zaznaczenie",
-    "draw": "Swobodne rysowanie",
+    "freedraw": "Swobodne rysowanie",
     "rectangle": "Prostokąt",
     "diamond": "Romb",
     "ellipse": "Elipsa",

+ 1 - 1
src/locales/pt-BR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Seleção",
-    "draw": "Desenho livre",
+    "freedraw": "Desenho livre",
     "rectangle": "Retângulo",
     "diamond": "Losango",
     "ellipse": "Elipse",

+ 1 - 1
src/locales/pt-PT.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Seleção",
-    "draw": "Desenho livre",
+    "freedraw": "Desenho livre",
     "rectangle": "Retângulo",
     "diamond": "Losango",
     "ellipse": "Elipse",

+ 1 - 1
src/locales/ro-RO.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Selecție",
-    "draw": "Desenare liberă",
+    "freedraw": "Desenare liberă",
     "rectangle": "Dreptunghi",
     "diamond": "Romb",
     "ellipse": "Elipsă",

+ 1 - 1
src/locales/ru-RU.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Выделение области",
-    "draw": "Свободное рисование",
+    "freedraw": "Свободное рисование",
     "rectangle": "Прямоугольник",
     "diamond": "Ромб",
     "ellipse": "Эллипс",

+ 1 - 1
src/locales/sk-SK.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Výber",
-    "draw": "Voľné kreslenie",
+    "freedraw": "Voľné kreslenie",
     "rectangle": "Obdĺžnik",
     "diamond": "Diamant",
     "ellipse": "Elipsa",

+ 1 - 1
src/locales/sv-SE.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Markering",
-    "draw": "Frihand",
+    "freedraw": "Frihand",
     "rectangle": "Rektangel",
     "diamond": "Diamant",
     "ellipse": "Ellips",

+ 1 - 1
src/locales/tr-TR.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Seçme",
-    "draw": "Serbest çizim",
+    "freedraw": "Serbest çizim",
     "rectangle": "Dikdörtgen",
     "diamond": "Elmas",
     "ellipse": "Elips",

+ 1 - 1
src/locales/uk-UA.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "Виділення",
-    "draw": "Вільне креслення",
+    "freedraw": "Вільне креслення",
     "rectangle": "Прямокутник",
     "diamond": "Ромб",
     "ellipse": "Еліпс",

+ 1 - 1
src/locales/zh-CN.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "选择",
-    "draw": "自由书写",
+    "freedraw": "自由书写",
     "rectangle": "矩形",
     "diamond": "菱形",
     "ellipse": "椭圆",

+ 1 - 1
src/locales/zh-TW.json

@@ -152,7 +152,7 @@
   },
   "toolBar": {
     "selection": "選取",
-    "draw": "繪圖",
+    "freedraw": "繪圖",
     "rectangle": "長方形",
     "diamond": "菱形",
     "ellipse": "橢圓",

+ 1 - 0
src/math.ts

@@ -249,6 +249,7 @@ const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
   return false;
 };
 
+// TODO: Rounding this point causes some shake when free drawing
 export const getGridPoint = (
   x: number,
   y: number,

+ 1 - 0
src/points.ts

@@ -8,6 +8,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
     height: Math.max(...ys) - Math.min(...ys),
   };
 };
+
 export const rescalePoints = (
   dimension: 0 | 1,
   nextDimensionSize: number,

+ 180 - 28
src/renderer/renderElement.ts

@@ -4,8 +4,13 @@ import {
   ExcalidrawTextElement,
   Arrowhead,
   NonDeletedExcalidrawElement,
+  ExcalidrawFreeDrawElement,
 } from "../element/types";
-import { isTextElement, isLinearElement } from "../element/typeChecks";
+import {
+  isTextElement,
+  isLinearElement,
+  isFreeDrawElement,
+} from "../element/typeChecks";
 import {
   getDiamondPoints,
   getElementAbsoluteCoords,
@@ -27,14 +32,17 @@ import { isPathALoop } from "../math";
 import rough from "roughjs/bin/rough";
 import { Zoom } from "../types";
 import { getDefaultAppState } from "../appState";
+import getFreeDrawShape from "perfect-freehand";
 
 const defaultAppState = getDefaultAppState();
 
-const CANVAS_PADDING = 20;
-
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
+
 const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
 
+const getCanvasPadding = (element: ExcalidrawElement) =>
+  element.type === "freedraw" ? element.strokeWidth * 12 : 20;
+
 export interface ExcalidrawElementWithCanvas {
   element: ExcalidrawElement | ExcalidrawTextElement;
   canvas: HTMLCanvasElement;
@@ -49,18 +57,25 @@ const generateElementCanvas = (
 ): ExcalidrawElementWithCanvas => {
   const canvas = document.createElement("canvas");
   const context = canvas.getContext("2d")!;
+  const padding = getCanvasPadding(element);
 
   let canvasOffsetX = 0;
   let canvasOffsetY = 0;
 
-  if (isLinearElement(element)) {
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  if (isLinearElement(element) || isFreeDrawElement(element)) {
+    let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+    x1 = Math.floor(x1);
+    x2 = Math.ceil(x2);
+    y1 = Math.floor(y1);
+    y2 = Math.ceil(y2);
+
     canvas.width =
       distance(x1, x2) * window.devicePixelRatio * zoom.value +
-      CANVAS_PADDING * zoom.value * 2;
+      padding * zoom.value * 2;
     canvas.height =
       distance(y1, y2) * window.devicePixelRatio * zoom.value +
-      CANVAS_PADDING * zoom.value * 2;
+      padding * zoom.value * 2;
 
     canvasOffsetX =
       element.x > x1
@@ -80,13 +95,13 @@ const generateElementCanvas = (
   } else {
     canvas.width =
       element.width * window.devicePixelRatio * zoom.value +
-      CANVAS_PADDING * zoom.value * 2;
+      padding * zoom.value * 2;
     canvas.height =
       element.height * window.devicePixelRatio * zoom.value +
-      CANVAS_PADDING * zoom.value * 2;
+      padding * zoom.value * 2;
   }
 
-  context.translate(CANVAS_PADDING * zoom.value, CANVAS_PADDING * zoom.value);
+  context.translate(padding * zoom.value, padding * zoom.value);
 
   context.scale(
     window.devicePixelRatio * zoom.value,
@@ -94,11 +109,10 @@ const generateElementCanvas = (
   );
 
   const rc = rough.canvas(canvas);
+
   drawElementOnCanvas(element, rc, context);
-  context.translate(
-    -(CANVAS_PADDING * zoom.value),
-    -(CANVAS_PADDING * zoom.value),
-  );
+
+  context.translate(-(padding * zoom.value), -(padding * zoom.value));
   context.scale(
     1 / (window.devicePixelRatio * zoom.value),
     1 / (window.devicePixelRatio * zoom.value),
@@ -138,6 +152,19 @@ const drawElementOnCanvas = (
       });
       break;
     }
+    case "freedraw": {
+      // Draw directly to canvas
+      context.save();
+      context.fillStyle = element.strokeColor;
+
+      const path = getFreeDrawPath2D(element) as Path2D;
+
+      context.fillStyle = element.strokeColor;
+      context.fill(path);
+
+      context.restore();
+      break;
+    }
     default: {
       if (isTextElement(element)) {
         const rtl = isRTL(element.text);
@@ -243,10 +270,8 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
       }
       return options;
     }
-    case "line":
-    case "draw": {
-      // If shape is a line and is a closed shape,
-      // fill the shape if a color is set.
+    case "draw":
+    case "line": {
       if (isPathALoop(element.points)) {
         options.fillStyle = element.fillStyle;
         options.fill =
@@ -256,6 +281,7 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
       }
       return options;
     }
+    case "freedraw":
     case "arrow":
       return options;
     default: {
@@ -264,11 +290,17 @@ export const generateRoughOptions = (element: ExcalidrawElement): Options => {
   }
 };
 
+/**
+ * Generates the element's shape and puts it into the cache.
+ * @param element
+ * @param generator
+ */
 const generateElementShape = (
   element: NonDeletedExcalidrawElement,
   generator: RoughGenerator,
 ) => {
   let shape = shapeCache.get(element) || null;
+
   if (!shape) {
     elementWithCanvasCache.delete(element);
 
@@ -327,8 +359,8 @@ const generateElementShape = (
           generateRoughOptions(element),
         );
         break;
-      case "line":
       case "draw":
+      case "line":
       case "arrow": {
         const options = generateRoughOptions(element);
 
@@ -380,15 +412,18 @@ const generateElementShape = (
                   ...options,
                   fill: element.strokeColor,
                   fillStyle: "solid",
+                  stroke: "none",
                 }),
               ];
             }
 
             // Arrow arrowheads
             const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
             if (element.strokeStyle === "dotted") {
               // for dotted arrows caps, reduce gap to make it more legible
-              options.strokeLineDash = [3, 4];
+              const dash = getDashArrayDotted(element.strokeWidth - 1);
+              options.strokeLineDash = [dash[0], dash[1] - 1];
             } else {
               // for solid/dashed, keep solid arrow cap
               delete options.strokeLineDash;
@@ -423,6 +458,12 @@ const generateElementShape = (
             shape.push(...shapes);
           }
         }
+
+        break;
+      }
+      case "freedraw": {
+        generateFreeDrawShape(element);
+        shape = [];
         break;
       }
       case "text": {
@@ -447,7 +488,9 @@ const generateElementWithCanvas = (
     !sceneState?.shouldCacheIgnoreZoom;
   if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
     const elementWithCanvas = generateElementCanvas(element, zoom);
+
     elementWithCanvasCache.set(element, elementWithCanvas);
+
     return elementWithCanvas;
   }
   return prevElementWithCanvas;
@@ -460,20 +503,29 @@ const drawElementFromCanvas = (
   sceneState: SceneState,
 ) => {
   const element = elementWithCanvas.element;
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+  const padding = getCanvasPadding(element);
+  let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+  // Free draw elements will otherwise "shuffle" as the min x and y change
+  if (isFreeDrawElement(element)) {
+    x1 = Math.floor(x1);
+    x2 = Math.ceil(x2);
+    y1 = Math.floor(y1);
+    y2 = Math.ceil(y2);
+  }
+
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
   const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
   context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
   context.translate(cx, cy);
   context.rotate(element.angle);
+
   context.drawImage(
     elementWithCanvas.canvas!,
     (-(x2 - x1) / 2) * window.devicePixelRatio -
-      (CANVAS_PADDING * elementWithCanvas.canvasZoom) /
-        elementWithCanvas.canvasZoom,
+      (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
     (-(y2 - y1) / 2) * window.devicePixelRatio -
-      (CANVAS_PADDING * elementWithCanvas.canvasZoom) /
-        elementWithCanvas.canvasZoom,
+      (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
     elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
   );
@@ -508,11 +560,37 @@ export const renderElement = (
       );
       break;
     }
+    case "freedraw": {
+      generateElementShape(element, generator);
+
+      if (renderOptimizations) {
+        const elementWithCanvas = generateElementWithCanvas(
+          element,
+          sceneState,
+        );
+        drawElementFromCanvas(elementWithCanvas, rc, context, sceneState);
+      } else {
+        const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+        const cx = (x1 + x2) / 2 + sceneState.scrollX;
+        const cy = (y1 + y2) / 2 + sceneState.scrollY;
+        const shiftX = (x2 - x1) / 2 - (element.x - x1);
+        const shiftY = (y2 - y1) / 2 - (element.y - y1);
+        context.translate(cx, cy);
+        context.rotate(element.angle);
+        context.translate(-shiftX, -shiftY);
+        drawElementOnCanvas(element, rc, context);
+        context.translate(shiftX, shiftY);
+        context.rotate(-element.angle);
+        context.translate(-cx, -cy);
+      }
+
+      break;
+    }
     case "rectangle":
     case "diamond":
     case "ellipse":
-    case "line":
     case "draw":
+    case "line":
     case "arrow":
     case "text": {
       generateElementShape(element, generator);
@@ -583,8 +661,8 @@ export const renderElementToSvg = (
       svgRoot.appendChild(node);
       break;
     }
-    case "line":
     case "draw":
+    case "line":
     case "arrow": {
       generateElementShape(element, generator);
       const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
@@ -604,7 +682,7 @@ export const renderElementToSvg = (
           }) rotate(${degree} ${cx} ${cy})`,
         );
         if (
-          (element.type === "line" || element.type === "draw") &&
+          element.type === "line" &&
           isPathALoop(element.points) &&
           element.backgroundColor !== "transparent"
         ) {
@@ -615,6 +693,28 @@ export const renderElementToSvg = (
       svgRoot.appendChild(group);
       break;
     }
+    case "freedraw": {
+      generateFreeDrawShape(element);
+      const opacity = element.opacity / 100;
+      const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+      if (opacity !== 1) {
+        node.setAttribute("stroke-opacity", `${opacity}`);
+        node.setAttribute("fill-opacity", `${opacity}`);
+      }
+      node.setAttribute(
+        "transform",
+        `translate(${offsetX || 0} ${
+          offsetY || 0
+        }) rotate(${degree} ${cx} ${cy})`,
+      );
+      const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
+      node.setAttribute("stroke", "none");
+      node.setAttribute("fill", element.strokeStyle);
+      path.setAttribute("d", getFreeDrawSvgPath(element));
+      node.appendChild(path);
+      svgRoot.appendChild(node);
+      break;
+    }
     default: {
       if (isTextElement(element)) {
         const opacity = element.opacity / 100;
@@ -666,3 +766,55 @@ export const renderElementToSvg = (
     }
   }
 };
+
+export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
+
+export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
+  const svgPathData = getFreeDrawSvgPath(element);
+  const path = new Path2D(svgPathData);
+  pathsCache.set(element, path);
+  return path;
+}
+
+export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
+  return pathsCache.get(element);
+}
+
+export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
+  const inputPoints = element.simulatePressure
+    ? element.points
+    : element.points.length
+    ? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
+    : [[0, 0, 0]];
+
+  // Consider changing the options for simulated pressure vs real pressure
+  const options = {
+    simulatePressure: element.simulatePressure,
+    size: element.strokeWidth * 6,
+    thinning: 0.5,
+    smoothing: 0.5,
+    streamline: 0.5,
+    easing: (t: number) => t * (2 - t),
+    last: true,
+  };
+
+  const points = getFreeDrawShape(inputPoints as number[][], options);
+  const d: (string | number)[] = [];
+
+  let [p0, p1] = points;
+
+  d.push("M", p0[0], p0[1], "Q");
+
+  for (let i = 0; i < points.length; i++) {
+    d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
+    p0 = p1;
+    p1 = points[i];
+  }
+
+  p1 = points[0];
+  d.push(p0[0], p0[1], (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2);
+
+  d.push("Z");
+
+  return d.join(" ");
+}

+ 2 - 1
src/renderer/renderScene.ts

@@ -201,11 +201,12 @@ export const renderScene = (
     renderGrid?: boolean;
   } = {},
 ) => {
-  if (!canvas) {
+  if (canvas === null) {
     return { atLeastOneVisibleElement: false };
   }
 
   const context = canvas.getContext("2d")!;
+
   context.scale(scale, scale);
 
   // When doing calculations based on canvas width we should used normalized one

+ 8 - 5
src/scene/comparisons.ts

@@ -9,23 +9,26 @@ export const hasBackground = (type: string) =>
   type === "rectangle" ||
   type === "ellipse" ||
   type === "diamond" ||
-  type === "draw" ||
   type === "line";
 
-export const hasStroke = (type: string) =>
+export const hasStrokeWidth = (type: string) =>
   type === "rectangle" ||
   type === "ellipse" ||
   type === "diamond" ||
+  type === "freedraw" ||
   type === "arrow" ||
-  type === "draw" ||
   type === "line";
 
-export const canChangeSharpness = (type: string) =>
+export const hasStrokeStyle = (type: string) =>
   type === "rectangle" ||
+  type === "ellipse" ||
+  type === "diamond" ||
   type === "arrow" ||
-  type === "draw" ||
   type === "line";
 
+export const canChangeSharpness = (type: string) =>
+  type === "rectangle" || type === "arrow" || type === "line";
+
 export const hasText = (type: string) => type === "text";
 
 export const canHaveArrowheads = (type: string) => type === "arrow";

+ 2 - 1
src/scene/index.ts

@@ -9,7 +9,8 @@ export {
 export { calculateScrollCenter } from "./scroll";
 export {
   hasBackground,
-  hasStroke,
+  hasStrokeWidth,
+  hasStrokeStyle,
   canHaveArrowheads,
   canChangeSharpness,
   getElementAtPosition,

+ 1 - 1
src/shapes.tsx

@@ -80,7 +80,7 @@ export const SHAPES = [
         ></path>
       </svg>
     ),
-    value: "draw",
+    value: "freedraw",
     key: KEYS.X,
   },
   {

+ 91 - 55
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -6280,7 +6280,7 @@ Object {
   "editingGroupId": null,
   "editingLinearElement": null,
   "elementLocked": false,
-  "elementType": "draw",
+  "elementType": "freedraw",
   "errorMessage": null,
   "exportBackground": true,
   "exportEmbedScene": false,
@@ -6596,8 +6596,6 @@ Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "boundElementIds": null,
-  "endArrowhead": null,
-  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 10,
@@ -6614,18 +6612,26 @@ Object {
       50,
       10,
     ],
+    Array [
+      50,
+      10,
+    ],
+  ],
+  "pressures": Array [
+    0,
+    0,
+    0,
   ],
   "roughness": 1,
   "seed": 941653321,
-  "startArrowhead": null,
-  "startBinding": null,
+  "simulatePressure": false,
   "strokeColor": "#000000",
   "strokeSharpness": "round",
   "strokeStyle": "solid",
   "strokeWidth": 1,
-  "type": "draw",
-  "version": 3,
-  "versionNonce": 1402203177,
+  "type": "freedraw",
+  "version": 4,
+  "versionNonce": 1359939303,
   "width": 50,
   "x": 550,
   "y": -10,
@@ -8246,8 +8252,6 @@ Object {
           "angle": 0,
           "backgroundColor": "transparent",
           "boundElementIds": null,
-          "endArrowhead": null,
-          "endBinding": null,
           "fillStyle": "hachure",
           "groupIds": Array [],
           "height": 10,
@@ -8264,18 +8268,26 @@ Object {
               50,
               10,
             ],
+            Array [
+              50,
+              10,
+            ],
+          ],
+          "pressures": Array [
+            0,
+            0,
+            0,
           ],
           "roughness": 1,
           "seed": 941653321,
-          "startArrowhead": null,
-          "startBinding": null,
+          "simulatePressure": false,
           "strokeColor": "#000000",
           "strokeSharpness": "round",
           "strokeStyle": "solid",
           "strokeWidth": 1,
-          "type": "draw",
-          "version": 3,
-          "versionNonce": 1402203177,
+          "type": "freedraw",
+          "version": 4,
+          "versionNonce": 1359939303,
           "width": 50,
           "x": 550,
           "y": -10,
@@ -10355,7 +10367,7 @@ exports[`regression tests key 6 selects line tool: [end of test] number of eleme
 
 exports[`regression tests key 6 selects line tool: [end of test] number of renders 1`] = `8`;
 
-exports[`regression tests key 7 selects draw tool: [end of test] appState 1`] = `
+exports[`regression tests key 7 selects freedraw tool: [end of test] appState 1`] = `
 Object {
   "collaborators": Map {},
   "currentChartType": "bar",
@@ -10379,7 +10391,7 @@ Object {
   "editingGroupId": null,
   "editingLinearElement": null,
   "elementLocked": false,
-  "elementType": "draw",
+  "elementType": "freedraw",
   "errorMessage": null,
   "exportBackground": true,
   "exportEmbedScene": false,
@@ -10434,13 +10446,11 @@ Object {
 }
 `;
 
-exports[`regression tests key 7 selects draw tool: [end of test] element 0 1`] = `
+exports[`regression tests key 7 selects freedraw tool: [end of test] element 0 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "boundElementIds": null,
-  "endArrowhead": null,
-  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 10,
@@ -10457,25 +10467,33 @@ Object {
       10,
       10,
     ],
+    Array [
+      10,
+      10,
+    ],
+  ],
+  "pressures": Array [
+    0,
+    0,
+    0,
   ],
   "roughness": 1,
   "seed": 337897,
-  "startArrowhead": null,
-  "startBinding": null,
+  "simulatePressure": false,
   "strokeColor": "#000000",
   "strokeSharpness": "round",
   "strokeStyle": "solid",
   "strokeWidth": 1,
-  "type": "draw",
-  "version": 3,
-  "versionNonce": 449462985,
+  "type": "freedraw",
+  "version": 4,
+  "versionNonce": 453191,
   "width": 10,
   "x": 10,
   "y": 10,
 }
 `;
 
-exports[`regression tests key 7 selects draw tool: [end of test] history 1`] = `
+exports[`regression tests key 7 selects freedraw tool: [end of test] history 1`] = `
 Object {
   "recording": false,
   "redoStack": Array [],
@@ -10505,8 +10523,6 @@ Object {
           "angle": 0,
           "backgroundColor": "transparent",
           "boundElementIds": null,
-          "endArrowhead": null,
-          "endBinding": null,
           "fillStyle": "hachure",
           "groupIds": Array [],
           "height": 10,
@@ -10523,18 +10539,26 @@ Object {
               10,
               10,
             ],
+            Array [
+              10,
+              10,
+            ],
+          ],
+          "pressures": Array [
+            0,
+            0,
+            0,
           ],
           "roughness": 1,
           "seed": 337897,
-          "startArrowhead": null,
-          "startBinding": null,
+          "simulatePressure": false,
           "strokeColor": "#000000",
           "strokeSharpness": "round",
           "strokeStyle": "solid",
           "strokeWidth": 1,
-          "type": "draw",
-          "version": 3,
-          "versionNonce": 449462985,
+          "type": "freedraw",
+          "version": 4,
+          "versionNonce": 453191,
           "width": 10,
           "x": 10,
           "y": 10,
@@ -10545,9 +10569,9 @@ Object {
 }
 `;
 
-exports[`regression tests key 7 selects draw tool: [end of test] number of elements 1`] = `1`;
+exports[`regression tests key 7 selects freedraw tool: [end of test] number of elements 1`] = `1`;
 
-exports[`regression tests key 7 selects draw tool: [end of test] number of renders 1`] = `8`;
+exports[`regression tests key 7 selects freedraw tool: [end of test] number of renders 1`] = `8`;
 
 exports[`regression tests key a selects arrow tool: [end of test] appState 1`] = `
 Object {
@@ -11429,7 +11453,7 @@ exports[`regression tests key r selects rectangle tool: [end of test] number of
 
 exports[`regression tests key r selects rectangle tool: [end of test] number of renders 1`] = `8`;
 
-exports[`regression tests key x selects draw tool: [end of test] appState 1`] = `
+exports[`regression tests key x selects freedraw tool: [end of test] appState 1`] = `
 Object {
   "collaborators": Map {},
   "currentChartType": "bar",
@@ -11453,7 +11477,7 @@ Object {
   "editingGroupId": null,
   "editingLinearElement": null,
   "elementLocked": false,
-  "elementType": "draw",
+  "elementType": "freedraw",
   "errorMessage": null,
   "exportBackground": true,
   "exportEmbedScene": false,
@@ -11508,13 +11532,11 @@ Object {
 }
 `;
 
-exports[`regression tests key x selects draw tool: [end of test] element 0 1`] = `
+exports[`regression tests key x selects freedraw tool: [end of test] element 0 1`] = `
 Object {
   "angle": 0,
   "backgroundColor": "transparent",
   "boundElementIds": null,
-  "endArrowhead": null,
-  "endBinding": null,
   "fillStyle": "hachure",
   "groupIds": Array [],
   "height": 10,
@@ -11531,25 +11553,33 @@ Object {
       10,
       10,
     ],
+    Array [
+      10,
+      10,
+    ],
+  ],
+  "pressures": Array [
+    0,
+    0,
+    0,
   ],
   "roughness": 1,
   "seed": 337897,
-  "startArrowhead": null,
-  "startBinding": null,
+  "simulatePressure": false,
   "strokeColor": "#000000",
   "strokeSharpness": "round",
   "strokeStyle": "solid",
   "strokeWidth": 1,
-  "type": "draw",
-  "version": 3,
-  "versionNonce": 449462985,
+  "type": "freedraw",
+  "version": 4,
+  "versionNonce": 453191,
   "width": 10,
   "x": 10,
   "y": 10,
 }
 `;
 
-exports[`regression tests key x selects draw tool: [end of test] history 1`] = `
+exports[`regression tests key x selects freedraw tool: [end of test] history 1`] = `
 Object {
   "recording": false,
   "redoStack": Array [],
@@ -11579,8 +11609,6 @@ Object {
           "angle": 0,
           "backgroundColor": "transparent",
           "boundElementIds": null,
-          "endArrowhead": null,
-          "endBinding": null,
           "fillStyle": "hachure",
           "groupIds": Array [],
           "height": 10,
@@ -11597,18 +11625,26 @@ Object {
               10,
               10,
             ],
+            Array [
+              10,
+              10,
+            ],
+          ],
+          "pressures": Array [
+            0,
+            0,
+            0,
           ],
           "roughness": 1,
           "seed": 337897,
-          "startArrowhead": null,
-          "startBinding": null,
+          "simulatePressure": false,
           "strokeColor": "#000000",
           "strokeSharpness": "round",
           "strokeStyle": "solid",
           "strokeWidth": 1,
-          "type": "draw",
-          "version": 3,
-          "versionNonce": 449462985,
+          "type": "freedraw",
+          "version": 4,
+          "versionNonce": 453191,
           "width": 10,
           "x": 10,
           "y": 10,
@@ -11619,9 +11655,9 @@ Object {
 }
 `;
 
-exports[`regression tests key x selects draw tool: [end of test] number of elements 1`] = `1`;
+exports[`regression tests key x selects freedraw tool: [end of test] number of elements 1`] = `1`;
 
-exports[`regression tests key x selects draw tool: [end of test] number of renders 1`] = `8`;
+exports[`regression tests key x selects freedraw tool: [end of test] number of renders 1`] = `8`;
 
 exports[`regression tests make a group and duplicate it: [end of test] appState 1`] = `
 Object {

+ 1 - 1
src/tests/flip.test.tsx

@@ -71,7 +71,7 @@ const createAndSelectOneLine = (angle: number = 0) => {
 };
 
 const createAndReturnOneDraw = (angle: number = 0) => {
-  return UI.createElement("draw", {
+  return UI.createElement("freedraw", {
     x: 0,
     y: 0,
     width: 50,

+ 13 - 3
src/tests/helpers/api.ts

@@ -3,6 +3,7 @@ import {
   ExcalidrawGenericElement,
   ExcalidrawTextElement,
   ExcalidrawLinearElement,
+  ExcalidrawFreeDrawElement,
 } from "../../element/types";
 import { newElement, newTextElement, newLinearElement } from "../../element";
 import { DEFAULT_VERTICAL_ALIGN } from "../../constants";
@@ -12,6 +13,7 @@ import fs from "fs";
 import util from "util";
 import path from "path";
 import { getMimeType } from "../../data/blob";
+import { newFreeDrawElement } from "../../element/newElement";
 
 const readFile = util.promisify(fs.readFile);
 
@@ -81,8 +83,10 @@ export class API {
     verticalAlign?: T extends "text"
       ? ExcalidrawTextElement["verticalAlign"]
       : never;
-  }): T extends "arrow" | "line" | "draw"
+  }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
+    : T extends "freedraw"
+    ? ExcalidrawFreeDrawElement
     : T extends "text"
     ? ExcalidrawTextElement
     : ExcalidrawGenericElement => {
@@ -125,11 +129,17 @@ export class API {
           verticalAlign: rest.verticalAlign ?? DEFAULT_VERTICAL_ALIGN,
         });
         break;
+      case "freedraw":
+        element = newFreeDrawElement({
+          type: type as "freedraw",
+          simulatePressure: true,
+          ...base,
+        });
+        break;
       case "arrow":
       case "line":
-      case "draw":
         element = newLinearElement({
-          type: type as "arrow" | "line" | "draw",
+          type: type as "arrow" | "line",
           startArrowhead: null,
           endArrowhead: null,
           ...base,

+ 2 - 2
src/tests/helpers/ui.ts

@@ -213,14 +213,14 @@ export class UI {
       height?: number;
       angle?: number;
     } = {},
-  ): (T extends "arrow" | "line" | "draw"
+  ): (T extends "arrow" | "line" | "freedraw"
     ? ExcalidrawLinearElement
     : T extends "text"
     ? ExcalidrawTextElement
     : ExcalidrawElement) & {
     /** Returns the actual, current element from the elements array, instead
         of the proxy */
-    get(): T extends "arrow" | "line" | "draw"
+    get(): T extends "arrow" | "line" | "freedraw"
       ? ExcalidrawLinearElement
       : T extends "text"
       ? ExcalidrawTextElement

+ 1 - 1
src/tests/queries/toolQueries.ts

@@ -7,7 +7,7 @@ const toolMap = {
   ellipse: "ellipse",
   arrow: "arrow",
   line: "line",
-  draw: "draw",
+  freedraw: "freedraw",
   text: "text",
 };
 

+ 3 - 3
src/tests/regressionTests.test.tsx

@@ -106,7 +106,7 @@ describe("regression tests", () => {
     mouse.click(30, 10);
     Keyboard.keyPress(KEYS.ENTER);
 
-    UI.clickTool("draw");
+    UI.clickTool("freedraw");
     mouse.down(40, -20);
     mouse.up(50, 10);
 
@@ -118,7 +118,7 @@ describe("regression tests", () => {
       "line",
       "arrow",
       "line",
-      "draw",
+      "freedraw",
     ]);
   });
 
@@ -146,7 +146,7 @@ describe("regression tests", () => {
     [`4${KEYS.E}`, "ellipse", true],
     [`5${KEYS.A}`, "arrow", true],
     [`6${KEYS.L}`, "line", true],
-    [`7${KEYS.X}`, "draw", false],
+    [`7${KEYS.X}`, "freedraw", false],
   ] as [string, ExcalidrawElement["type"], boolean][]) {
     for (const key of keys) {
       it(`key ${key} selects ${shape} tool`, () => {

+ 5 - 0
yarn.lock

@@ -9249,6 +9249,11 @@ pepjs@0.5.3:
   version "0.5.3"
   resolved "https://registry.npmjs.org/pepjs/-/pepjs-0.5.3.tgz"
 
+perfect-freehand@0.4.7:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-0.4.7.tgz#4d85fd64881ba81b2a4eaa6ac4e8983ccb21dd43"
+  integrity sha512-SSSFL8VzXiOHQdUTyNyOb0JC+btVZRy9bi6jos7Nb7PBTI0PHX5jM6RgCTSrubQ8Ul9qOYWmWgJBrwVGHwyJZQ==
+
 performance-now@^2.1.0:
   version "2.1.0"
   resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"