Browse Source

feat: support background fill for freedraw shapes (#4610)

* feat: support background fill for freedraw shapes

* refactor & support fill style

* make filled freedraw shapes selectable from inside

* get hit test on solid freedraw shapes to somewhat work

* fix SVG export of unclosed freedraw shapes & improve types

* fix lint

* type tweaks

* reuse `hitTestCurveInside` for collision tests

Co-authored-by: dwelle <luzar.david@gmail.com>
Arun 3 years ago
parent
commit
0cdd0eebf1
4 changed files with 89 additions and 27 deletions
  1. 2 2
      src/element/bounds.ts
  2. 22 4
      src/element/collision.ts
  3. 63 20
      src/renderer/renderElement.ts
  4. 2 1
      src/scene/comparisons.ts

+ 2 - 2
src/element/bounds.ts

@@ -185,7 +185,7 @@ const getLinearElementAbsoluteCoords = (
       maxY + element.y,
     ];
   } else {
-    const shape = getShapeForElement(element) as Drawable[];
+    const shape = getShapeForElement(element)!;
 
     // first element is always the curve
     const ops = getCurvePathOps(shape[0]);
@@ -326,7 +326,7 @@ const getLinearElementRotatedBounds = (
     return [minX, minY, maxX, maxY];
   }
 
-  const shape = getShapeForElement(element) as Drawable[];
+  const shape = getShapeForElement(element)!;
 
   // first element is always the curve
   const ops = getCurvePathOps(shape[0]);

+ 22 - 4
src/element/collision.ts

@@ -24,6 +24,7 @@ import {
   NonDeleted,
   ExcalidrawFreeDrawElement,
   ExcalidrawImageElement,
+  ExcalidrawLinearElement,
 } from "./types";
 
 import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -361,6 +362,14 @@ const hitTestFreeDrawElement = (
     B = element.points[i + 1];
   }
 
+  const shape = getShapeForElement(element);
+
+  // for filled freedraw shapes, support
+  // selecting from inside
+  if (shape && shape.sets.length) {
+    return hitTestRoughShape(shape, x, y, threshold);
+  }
+
   return false;
 };
 
@@ -383,7 +392,11 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
   }
   const [relX, relY] = GAPoint.toTuple(point);
 
-  const shape = getShapeForElement(element) as Drawable[];
+  const shape = getShapeForElement(element as ExcalidrawLinearElement);
+
+  if (!shape) {
+    return false;
+  }
 
   if (args.check === isInsideCheck) {
     const hit = shape.some((subshape) =>
@@ -821,7 +834,7 @@ const hitTestCurveInside = (
   sharpness: ExcalidrawElement["strokeSharpness"],
 ) => {
   const ops = getCurvePathOps(drawable);
-  const points: Point[] = [];
+  const points: Mutable<Point>[] = [];
   let odd = false; // select one line out of double lines
   for (const operation of ops) {
     if (operation.op === "move") {
@@ -835,13 +848,17 @@ const hitTestCurveInside = (
         points.push([operation.data[2], operation.data[3]]);
         points.push([operation.data[4], operation.data[5]]);
       }
+    } else if (operation.op === "lineTo") {
+      if (odd) {
+        points.push([operation.data[0], operation.data[1]]);
+      }
     }
   }
   if (points.length >= 4) {
     if (sharpness === "sharp") {
       return isPointInPolygon(points, x, y);
     }
-    const polygonPoints = pointsOnBezierCurves(points as any, 10, 5);
+    const polygonPoints = pointsOnBezierCurves(points, 10, 5);
     return isPointInPolygon(polygonPoints, x, y);
   }
   return false;
@@ -896,9 +913,10 @@ const hitTestRoughShape = (
       // position of the previous operation
       return retVal;
     } else if (op === "lineTo") {
-      // TODO: Implement this
+      return hitTestCurveInside(drawable, x, y, "sharp");
     } else if (op === "qcurveTo") {
       // TODO: Implement this
+      console.warn("qcurveTo is not implemented yet");
     }
 
     return false;

+ 63 - 20
src/renderer/renderElement.ts

@@ -196,7 +196,7 @@ const drawElementOnCanvas = (
     case "ellipse": {
       context.lineJoin = "round";
       context.lineCap = "round";
-      rc.draw(getShapeForElement(element) as Drawable);
+      rc.draw(getShapeForElement(element)!);
       break;
     }
     case "arrow":
@@ -204,7 +204,7 @@ const drawElementOnCanvas = (
       context.lineJoin = "round";
       context.lineCap = "round";
 
-      (getShapeForElement(element) as Drawable[]).forEach((shape) => {
+      getShapeForElement(element)!.forEach((shape) => {
         rc.draw(shape);
       });
       break;
@@ -215,6 +215,11 @@ const drawElementOnCanvas = (
       context.fillStyle = element.strokeColor;
 
       const path = getFreeDrawPath2D(element) as Path2D;
+      const fillShape = getShapeForElement(element);
+
+      if (fillShape) {
+        rc.draw(fillShape);
+      }
 
       context.fillStyle = element.strokeColor;
       context.fill(path);
@@ -290,13 +295,29 @@ const elementWithCanvasCache = new WeakMap<
   ExcalidrawElementWithCanvas
 >();
 
-const shapeCache = new WeakMap<
-  ExcalidrawElement,
-  Drawable | Drawable[] | null
->();
+const shapeCache = new WeakMap<ExcalidrawElement, ElementShape>();
+
+type ElementShape = Drawable | Drawable[] | null;
 
-export const getShapeForElement = (element: ExcalidrawElement) =>
-  shapeCache.get(element);
+type ElementShapes = {
+  freedraw: Drawable | null;
+  arrow: Drawable[];
+  line: Drawable[];
+  text: null;
+  image: null;
+};
+
+export const getShapeForElement = <T extends ExcalidrawElement>(element: T) =>
+  shapeCache.get(element) as T["type"] extends keyof ElementShapes
+    ? ElementShapes[T["type"]] | undefined
+    : Drawable | null | undefined;
+
+export const setShapeForElement = <T extends ExcalidrawElement>(
+  element: T,
+  shape: T["type"] extends keyof ElementShapes
+    ? ElementShapes[T["type"]]
+    : Drawable,
+) => shapeCache.set(element, shape);
 
 export const invalidateShapeForElement = (element: ExcalidrawElement) =>
   shapeCache.delete(element);
@@ -346,7 +367,8 @@ export const generateRoughOptions = (
       }
       return options;
     }
-    case "line": {
+    case "line":
+    case "freedraw": {
       if (isPathALoop(element.points)) {
         options.fillStyle = element.fillStyle;
         options.fill =
@@ -356,7 +378,6 @@ export const generateRoughOptions = (
       }
       return options;
     }
-    case "freedraw":
     case "arrow":
       return options;
     default: {
@@ -374,9 +395,11 @@ const generateElementShape = (
   element: NonDeletedExcalidrawElement,
   generator: RoughGenerator,
 ) => {
-  let shape = shapeCache.get(element) || null;
+  let shape = shapeCache.get(element);
 
-  if (!shape) {
+  // `null` indicates no rc shape applicable for this element type
+  // (= do not generate anything)
+  if (shape === undefined) {
     elementWithCanvasCache.delete(element);
 
     switch (element.type) {
@@ -402,6 +425,8 @@ const generateElementShape = (
             generateRoughOptions(element),
           );
         }
+        setShapeForElement(element, shape);
+
         break;
       case "diamond": {
         const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
@@ -445,6 +470,8 @@ const generateElementShape = (
             generateRoughOptions(element),
           );
         }
+        setShapeForElement(element, shape);
+
         break;
       }
       case "ellipse":
@@ -455,6 +482,8 @@ const generateElementShape = (
           element.height,
           generateRoughOptions(element),
         );
+        setShapeForElement(element, shape);
+
         break;
       case "line":
       case "arrow": {
@@ -578,21 +607,32 @@ const generateElementShape = (
           }
         }
 
+        setShapeForElement(element, shape);
+
         break;
       }
       case "freedraw": {
         generateFreeDrawShape(element);
-        shape = [];
+
+        if (isPathALoop(element.points)) {
+          // generate rough polygon to fill freedraw shape
+          shape = generator.polygon(element.points as [number, number][], {
+            ...generateRoughOptions(element),
+            stroke: "none",
+          });
+        } else {
+          shape = null;
+        }
+        setShapeForElement(element, shape);
         break;
       }
       case "text":
       case "image": {
         // just to ensure we don't regenerate element.canvas on rerenders
-        shape = [];
+        setShapeForElement(element, null);
         break;
       }
     }
-    shapeCache.set(element, shape);
   }
 };
 
@@ -808,7 +848,7 @@ export const renderElementToSvg = (
       generateElementShape(element, generator);
       const node = roughSVGDrawWithPrecision(
         rsvg,
-        getShapeForElement(element) as Drawable,
+        getShapeForElement(element)!,
         MAX_DECIMALS_FOR_SVG_EXPORT,
       );
       const opacity = element.opacity / 100;
@@ -833,7 +873,7 @@ export const renderElementToSvg = (
       const opacity = element.opacity / 100;
       group.setAttribute("stroke-linecap", "round");
 
-      (getShapeForElement(element) as Drawable[]).forEach((shape) => {
+      getShapeForElement(element)!.forEach((shape) => {
         const node = roughSVGDrawWithPrecision(
           rsvg,
           shape,
@@ -864,7 +904,10 @@ export const renderElementToSvg = (
     case "freedraw": {
       generateFreeDrawShape(element);
       const opacity = element.opacity / 100;
-      const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
+      const shape = getShapeForElement(element);
+      const node = shape
+        ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
+        : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
       if (opacity !== 1) {
         node.setAttribute("stroke-opacity", `${opacity}`);
         node.setAttribute("fill-opacity", `${opacity}`);
@@ -875,9 +918,9 @@ export const renderElementToSvg = (
           offsetY || 0
         }) rotate(${degree} ${cx} ${cy})`,
       );
-      const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
       node.setAttribute("stroke", "none");
-      node.setAttribute("fill", element.strokeColor);
+      const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
+      path.setAttribute("fill", element.strokeColor);
       path.setAttribute("d", getFreeDrawSvgPath(element));
       node.appendChild(path);
       svgRoot.appendChild(node);

+ 2 - 1
src/scene/comparisons.ts

@@ -9,7 +9,8 @@ export const hasBackground = (type: string) =>
   type === "rectangle" ||
   type === "ellipse" ||
   type === "diamond" ||
-  type === "line";
+  type === "line" ||
+  type === "freedraw";
 
 export const hasStrokeColor = (type: string) => type !== "image";