Pārlūkot izejas kodu

Multi Point Lines (based on Multi Point Arrows) (#660)

* Enable multi points in lines

* Stop retrieving arrow points for lines

* Migrate lines to new spec during load

* Clean up and refactor some code

- Normalize shape dimensions during load
- Rename getArrowAbsoluteBounds

* Fix linter issues
Gasim Gasimzada 5 gadi atpakaļ
vecāks
revīzija
dab35c9033

+ 4 - 28
src/element/bounds.test.ts

@@ -17,51 +17,27 @@ const _ce = ({ x, y, w, h }: { x: number; y: number; w: number; h: number }) =>
   } as ExcalidrawElement);
 
 describe("getElementAbsoluteCoords", () => {
-  it("test x1 coordinate if width is positive or zero", () => {
+  it("test x1 coordinate", () => {
     const [x1] = getElementAbsoluteCoords(_ce({ x: 10, y: 0, w: 10, h: 0 }));
     expect(x1).toEqual(10);
   });
 
-  it("test x1 coordinate if width is negative", () => {
-    const [x1] = getElementAbsoluteCoords(_ce({ x: 20, y: 0, w: -10, h: 0 }));
-    expect(x1).toEqual(10);
-  });
-
-  it("test x2 coordinate if width is positive or zero", () => {
+  it("test x2 coordinate", () => {
     const [, , x2] = getElementAbsoluteCoords(
       _ce({ x: 10, y: 0, w: 10, h: 0 }),
     );
     expect(x2).toEqual(20);
   });
 
-  it("test x2 coordinate if width is negative", () => {
-    const [, , x2] = getElementAbsoluteCoords(
-      _ce({ x: 10, y: 0, w: -10, h: 0 }),
-    );
-    expect(x2).toEqual(10);
-  });
-
-  it("test y1 coordinate if height is positive or zero", () => {
+  it("test y1 coordinate", () => {
     const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 10, w: 0, h: 10 }));
     expect(y1).toEqual(10);
   });
 
-  it("test y1 coordinate if height is negative", () => {
-    const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 20, w: 0, h: -10 }));
-    expect(y1).toEqual(10);
-  });
-
-  it("test y2 coordinate if height is positive or zero", () => {
+  it("test y2 coordinate", () => {
     const [, , , y2] = getElementAbsoluteCoords(
       _ce({ x: 0, y: 10, w: 0, h: 10 }),
     );
     expect(y2).toEqual(20);
   });
-
-  it("test y2 coordinate if height is negative", () => {
-    const [, , , y2] = getElementAbsoluteCoords(
-      _ce({ x: 0, y: 10, w: 0, h: -10 }),
-    );
-    expect(y2).toEqual(10);
-  });
 });

+ 9 - 19
src/element/bounds.ts

@@ -5,17 +5,15 @@ 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);
+  if (element.type === "arrow" || element.type === "line") {
+    return getLinearElementAbsoluteBounds(element);
   }
   return [
-    element.width >= 0 ? element.x : element.x + element.width, // x1
-    element.height >= 0 ? element.y : element.y + element.height, // y1
-    element.width >= 0 ? element.x + element.width : element.x, // x2
-    element.height >= 0 ? element.y + element.height : element.y, // y2
+    element.x,
+    element.y,
+    element.x + element.width,
+    element.y + element.height,
   ];
 }
 
@@ -34,7 +32,7 @@ export function getDiamondPoints(element: ExcalidrawElement) {
   return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
 }
 
-export function getArrowAbsoluteBounds(element: ExcalidrawElement) {
+export function getLinearElementAbsoluteBounds(element: ExcalidrawElement) {
   if (element.points.length < 2 || !element.shape) {
     const { minX, minY, maxX, maxY } = element.points.reduce(
       (limits, [x, y]) => {
@@ -58,7 +56,8 @@ export function getArrowAbsoluteBounds(element: ExcalidrawElement) {
 
   const shape = element.shape as Drawable[];
 
-  const ops = shape[1].sets[0].ops;
+  // first element is always the curve
+  const ops = shape[0].sets[0].ops;
 
   let currentP: Point = [0, 0];
 
@@ -138,15 +137,6 @@ export function getArrowPoints(element: ExcalidrawElement) {
   return [x2, y2, x3, y3, x4, y4];
 }
 
-export function getLinePoints(element: ExcalidrawElement) {
-  const x1 = 0;
-  const y1 = 0;
-  const x2 = element.width;
-  const y2 = element.height;
-
-  return [x1, y1, x2, y2];
-}
-
 export function getCommonBounds(elements: readonly ExcalidrawElement[]) {
   let minX = Infinity;
   let maxX = -Infinity;

+ 5 - 22
src/element/collision.ts

@@ -4,8 +4,7 @@ import { ExcalidrawElement } from "./types";
 import {
   getDiamondPoints,
   getElementAbsoluteCoords,
-  getLinePoints,
-  getArrowAbsoluteBounds,
+  getLinearElementAbsoluteBounds,
 } from "./bounds";
 import { Point } from "roughjs/bin/geometry";
 import { Drawable, OpSet } from "roughjs/bin/core";
@@ -148,18 +147,13 @@ export function hitTest(
       distanceBetweenPointAndSegment(x, y, leftX, leftY, topX, topY) <
         lineThreshold
     );
-  } else if (element.type === "arrow") {
+  } else if (element.type === "arrow" || element.type === "line") {
     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);
+    const [x1, y1, x2, y2] = getLinearElementAbsoluteBounds(element);
     if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) {
       return false;
     }
@@ -167,19 +161,8 @@ export function hitTest(
     const relX = x - element.x;
     const relY = y - element.y;
 
-    // hit test curve and lien segments for arrow
-    return (
-      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);
-    // The computation is done at the origin, we need to add a translation
-    x -= element.x;
-    y -= element.y;
-
-    return distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold;
+    // hit thest all "subshapes" of the linear element
+    return shape.some(s => hitTestRoughShape(s.sets, relX, relY));
   } else if (element.type === "text") {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
 

+ 9 - 11
src/element/handlerRectangles.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "./types";
 import { SceneScroll } from "../scene/types";
-import { getArrowAbsoluteBounds } from "./bounds";
+import { getLinearElementAbsoluteBounds } from "./bounds";
 
 type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
 
@@ -16,10 +16,13 @@ export function handlerRectangles(
   let marginY = -8;
 
   const minimumSize = 40;
-  if (element.type === "arrow") {
-    [elementX1, elementY1, elementX2, elementY2] = getArrowAbsoluteBounds(
-      element,
-    );
+  if (element.type === "arrow" || element.type === "line") {
+    [
+      elementX1,
+      elementY1,
+      elementX2,
+      elementY2,
+    ] = getLinearElementAbsoluteBounds(element);
   } else {
     elementX1 = element.x;
     elementX2 = element.x + element.width;
@@ -90,12 +93,7 @@ export function handlerRectangles(
     8,
   ]; // se
 
-  if (element.type === "line") {
-    return {
-      nw: handlers.nw,
-      se: handlers.se,
-    } as typeof handlers;
-  } else if (element.type === "arrow") {
+  if (element.type === "arrow" || element.type === "line") {
     if (element.points.length === 2) {
       // only check the last point because starting point is always (0,0)
       const [, p1] = element.points;

+ 1 - 2
src/element/index.ts

@@ -4,8 +4,7 @@ export {
   getCommonBounds,
   getDiamondPoints,
   getArrowPoints,
-  getLinePoints,
-  getArrowAbsoluteBounds,
+  getLinearElementAbsoluteBounds,
 } from "./bounds";
 
 export { handlerRectangles } from "./handlerRectangles";

+ 3 - 0
src/element/sizeHelpers.ts

@@ -1,6 +1,9 @@
 import { ExcalidrawElement } from "./types";
 
 export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
+  if (element.type === "arrow" || element.type === "line") {
+    return element.points.length === 0;
+  }
   return element.width === 0 && element.height === 0;
 }
 

+ 39 - 68
src/index.tsx

@@ -16,7 +16,6 @@ import {
   getCommonBounds,
   getCursorForResizingElement,
   getPerfectElementSize,
-  resizePerfectLineForNWHandler,
   normalizeDimensions,
 } from "./element";
 import {
@@ -1050,7 +1049,10 @@ export class App extends React.Component<any, AppState> {
                   editingElement: element,
                 });
                 return;
-              } else if (this.state.elementType === "arrow") {
+              } else if (
+                this.state.elementType === "arrow" ||
+                this.state.elementType === "line"
+              ) {
                 if (this.state.multiElement) {
                   const { multiElement } = this.state;
                   const { x: rx, y: ry } = multiElement;
@@ -1107,7 +1109,7 @@ export class App extends React.Component<any, AppState> {
                   const absPy = p1[1] + element.y;
 
                   const { width, height } = getPerfectElementSize(
-                    "arrow",
+                    element.type,
                     mouseX - element.x - p1[0],
                     mouseY - element.y - p1[1],
                   );
@@ -1137,7 +1139,7 @@ export class App extends React.Component<any, AppState> {
               ) => {
                 if (perfect) {
                   const { width, height } = getPerfectElementSize(
-                    "arrow",
+                    element.type,
                     mouseX - element.x,
                     mouseY - element.y,
                   );
@@ -1179,7 +1181,11 @@ export class App extends React.Component<any, AppState> {
                 //  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") {
+                if (
+                  !draggingOccurred &&
+                  (this.state.elementType === "arrow" ||
+                    this.state.elementType === "line")
+                ) {
                   const { x, y } = viewportCoordsToSceneCoords(e, this.state);
                   if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) {
                     return;
@@ -1199,10 +1205,7 @@ export class App extends React.Component<any, AppState> {
                       element.type === "line" || element.type === "arrow";
                     switch (resizeHandle) {
                       case "nw":
-                        if (
-                          element.type === "arrow" &&
-                          element.points.length === 2
-                        ) {
+                        if (isLinear && element.points.length === 2) {
                           const [, p1] = element.points;
 
                           if (!resizeArrowFn) {
@@ -1226,12 +1229,8 @@ export class App extends React.Component<any, AppState> {
                           element.x += deltaX;
 
                           if (e.shiftKey) {
-                            if (isLinear) {
-                              resizePerfectLineForNWHandler(element, x, y);
-                            } else {
-                              element.y += element.height - element.width;
-                              element.height = element.width;
-                            }
+                            element.y += element.height - element.width;
+                            element.height = element.width;
                           } else {
                             element.height -= deltaY;
                             element.y += deltaY;
@@ -1239,10 +1238,7 @@ export class App extends React.Component<any, AppState> {
                         }
                         break;
                       case "ne":
-                        if (
-                          element.type === "arrow" &&
-                          element.points.length === 2
-                        ) {
+                        if (isLinear && element.points.length === 2) {
                           const [, p1] = element.points;
                           if (!resizeArrowFn) {
                             if (p1[0] >= 0) {
@@ -1272,10 +1268,7 @@ export class App extends React.Component<any, AppState> {
                         }
                         break;
                       case "sw":
-                        if (
-                          element.type === "arrow" &&
-                          element.points.length === 2
-                        ) {
+                        if (isLinear && element.points.length === 2) {
                           const [, p1] = element.points;
                           if (!resizeArrowFn) {
                             if (p1[0] <= 0) {
@@ -1304,10 +1297,7 @@ export class App extends React.Component<any, AppState> {
                         }
                         break;
                       case "se":
-                        if (
-                          element.type === "arrow" &&
-                          element.points.length === 2
-                        ) {
+                        if (isLinear && element.points.length === 2) {
                           const [, p1] = element.points;
                           if (!resizeArrowFn) {
                             if (p1[0] > 0 || p1[1] > 0) {
@@ -1327,18 +1317,8 @@ export class App extends React.Component<any, AppState> {
                           );
                         } 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;
-                            }
+                            element.width += deltaX;
+                            element.height = element.width;
                           } else {
                             element.width += deltaX;
                             element.height += deltaY;
@@ -1473,34 +1453,7 @@ export class App extends React.Component<any, AppState> {
                   this.state.elementType === "line" ||
                   this.state.elementType === "arrow";
 
-                if (isLinear && x < originX) {
-                  width = -width;
-                }
-                if (isLinear && y < originY) {
-                  height = -height;
-                }
-
-                if (e.shiftKey) {
-                  ({ width, height } = getPerfectElementSize(
-                    this.state.elementType,
-                    width,
-                    !isLinear && y < originY ? -height : height,
-                  ));
-
-                  if (!isLinear && height < 0) {
-                    height = -height;
-                  }
-                }
-
-                if (!isLinear) {
-                  draggingElement.x = x < originX ? originX - width : originX;
-                  draggingElement.y = y < originY ? originY - height : originY;
-                }
-
-                draggingElement.width = width;
-                draggingElement.height = height;
-
-                if (this.state.elementType === "arrow") {
+                if (isLinear) {
                   draggingOccurred = true;
                   const points = draggingElement.points;
                   let dx = x - draggingElement.x;
@@ -1521,6 +1474,24 @@ export class App extends React.Component<any, AppState> {
                     pnt[0] = dx;
                     pnt[1] = dy;
                   }
+                } else {
+                  if (e.shiftKey) {
+                    ({ width, height } = getPerfectElementSize(
+                      this.state.elementType,
+                      width,
+                      y < originY ? -height : height,
+                    ));
+
+                    if (height < 0) {
+                      height = -height;
+                    }
+                  }
+
+                  draggingElement.x = x < originX ? originX - width : originX;
+                  draggingElement.y = y < originY ? originY - height : originY;
+
+                  draggingElement.width = width;
+                  draggingElement.height = height;
                 }
 
                 draggingElement.shape = null;
@@ -1558,7 +1529,7 @@ export class App extends React.Component<any, AppState> {
                 window.removeEventListener("mousemove", onMouseMove);
                 window.removeEventListener("mouseup", onMouseUp);
 
-                if (elementType === "arrow") {
+                if (elementType === "arrow" || elementType === "line") {
                   if (draggingElement!.points.length > 1) {
                     history.resumeRecording();
                   }

+ 18 - 27
src/renderer/renderElement.ts

@@ -1,10 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 import { isTextElement } from "../element/typeChecks";
-import {
-  getDiamondPoints,
-  getArrowPoints,
-  getLinePoints,
-} from "../element/bounds";
+import { getDiamondPoints, getArrowPoints } from "../element/bounds";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
 import { Point } from "roughjs/bin/geometry";
@@ -89,8 +85,8 @@ function generateElement(
           },
         );
         break;
+      case "line":
       case "arrow": {
-        const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
         const options = {
           stroke: element.strokeColor,
           strokeWidth: element.strokeWidth,
@@ -102,25 +98,21 @@ function generateElement(
         const points: Point[] = element.points.length
           ? element.points
           : [[0, 0]];
-        element.shape = [
-          //    \
-          generator.line(x3, y3, x2, y2, options),
-          // -----
-          generator.curve(points, options),
-          //    /
-          generator.line(x4, y4, x2, y2, options),
-        ];
-        break;
-      }
-      case "line": {
-        const [x1, y1, x2, y2] = getLinePoints(element);
-        const options = {
-          stroke: element.strokeColor,
-          strokeWidth: element.strokeWidth,
-          roughness: element.roughness,
-          seed: element.seed,
-        };
-        element.shape = generator.line(x1, y1, x2, y2, options);
+
+        // curve is always the first element
+        // this simplifies finding the curve for an element
+        element.shape = [generator.curve(points, options)];
+
+        // add lines only in arrow
+        if (element.type === "arrow") {
+          const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
+          element.shape.push(
+            ...[
+              generator.line(x3, y3, x2, y2, options),
+              generator.line(x4, y4, x2, y2, options),
+            ],
+          );
+        }
         break;
       }
     }
@@ -144,13 +136,12 @@ export function renderElement(
     case "rectangle":
     case "diamond":
     case "ellipse":
-    case "line": {
       generateElement(element, generator);
       context.globalAlpha = element.opacity / 100;
       rc.draw(element.shape as Drawable);
       context.globalAlpha = 1;
       break;
-    }
+    case "line":
     case "arrow": {
       generateElement(element, generator);
       context.globalAlpha = element.opacity / 100;

+ 14 - 1
src/scene/data.ts

@@ -7,7 +7,7 @@ import { ExportType, PreviousScene } from "./types";
 import { exportToCanvas, exportToSvg } from "./export";
 import nanoid from "nanoid";
 import { fileOpen, fileSave } from "browser-nativefs";
-import { getCommonBounds } from "../element";
+import { getCommonBounds, normalizeDimensions } from "../element";
 
 import { Point } from "roughjs/bin/geometry";
 import { t } from "../i18n";
@@ -291,6 +291,19 @@ function restore(
           [element.width, element.height],
         ];
       }
+    } else if (element.type === "line") {
+      // old spec, pre-arrows
+      // old spec, post-arrows
+      if (!Array.isArray(element.points) || element.points.length === 0) {
+        points = [
+          [0, 0],
+          [element.width, element.height],
+        ];
+      } else {
+        points = element.points;
+      }
+    } else {
+      normalizeDimensions(element);
     }
 
     return {