aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/scene/Shape.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/scene/Shape.ts')
-rw-r--r--packages/excalidraw/scene/Shape.ts603
1 files changed, 603 insertions, 0 deletions
diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts
new file mode 100644
index 0000000..9c49db0
--- /dev/null
+++ b/packages/excalidraw/scene/Shape.ts
@@ -0,0 +1,603 @@
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
+import type { Drawable, Options } from "roughjs/bin/core";
+import type { RoughGenerator } from "roughjs/bin/generator";
+import { getDiamondPoints, getArrowheadPoints } from "../element";
+import type { ElementShapes } from "./types";
+import type {
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawLinearElement,
+ Arrowhead,
+} from "../element/types";
+import { generateFreeDrawShape } from "../renderer/renderElement";
+import { isTransparent, assertNever } from "../utils";
+import { simplify } from "points-on-curve";
+import { ROUGHNESS } from "../constants";
+import {
+ isElbowArrow,
+ isEmbeddableElement,
+ isIframeElement,
+ isIframeLikeElement,
+ isLinearElement,
+} from "../element/typeChecks";
+import { canChangeRoundness } from "./comparisons";
+import type { EmbedsValidationStatus } from "../types";
+import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
+import { getCornerRadius, isPathALoop } from "../shapes";
+import { headingForPointIsHorizontal } from "../element/heading";
+
+const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
+
+const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
+
+function adjustRoughness(element: ExcalidrawElement): number {
+ const roughness = element.roughness;
+
+ const maxSize = Math.max(element.width, element.height);
+ const minSize = Math.min(element.width, element.height);
+
+ // don't reduce roughness if
+ if (
+ // both sides relatively big
+ (minSize >= 20 && maxSize >= 50) ||
+ // is round & both sides above 15px
+ (minSize >= 15 &&
+ !!element.roundness &&
+ canChangeRoundness(element.type)) ||
+ // relatively long linear element
+ (isLinearElement(element) && maxSize >= 50)
+ ) {
+ return roughness;
+ }
+
+ return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
+}
+
+export const generateRoughOptions = (
+ element: ExcalidrawElement,
+ continuousPath = false,
+): Options => {
+ const options: Options = {
+ seed: element.seed,
+ strokeLineDash:
+ element.strokeStyle === "dashed"
+ ? getDashArrayDashed(element.strokeWidth)
+ : element.strokeStyle === "dotted"
+ ? getDashArrayDotted(element.strokeWidth)
+ : undefined,
+ // for non-solid strokes, disable multiStroke because it tends to make
+ // dashes/dots overlay each other
+ disableMultiStroke: element.strokeStyle !== "solid",
+ // for non-solid strokes, increase the width a bit to make it visually
+ // similar to solid strokes, because we're also disabling multiStroke
+ strokeWidth:
+ element.strokeStyle !== "solid"
+ ? element.strokeWidth + 0.5
+ : element.strokeWidth,
+ // when increasing strokeWidth, we must explicitly set fillWeight and
+ // hachureGap because if not specified, roughjs uses strokeWidth to
+ // calculate them (and we don't want the fills to be modified)
+ fillWeight: element.strokeWidth / 2,
+ hachureGap: element.strokeWidth * 4,
+ roughness: adjustRoughness(element),
+ stroke: element.strokeColor,
+ preserveVertices:
+ continuousPath || element.roughness < ROUGHNESS.cartoonist,
+ };
+
+ switch (element.type) {
+ case "rectangle":
+ case "iframe":
+ case "embeddable":
+ case "diamond":
+ case "ellipse": {
+ options.fillStyle = element.fillStyle;
+ options.fill = isTransparent(element.backgroundColor)
+ ? undefined
+ : element.backgroundColor;
+ if (element.type === "ellipse") {
+ options.curveFitting = 1;
+ }
+ return options;
+ }
+ case "line":
+ case "freedraw": {
+ if (isPathALoop(element.points)) {
+ options.fillStyle = element.fillStyle;
+ options.fill =
+ element.backgroundColor === "transparent"
+ ? undefined
+ : element.backgroundColor;
+ }
+ return options;
+ }
+ case "arrow":
+ return options;
+ default: {
+ throw new Error(`Unimplemented type ${element.type}`);
+ }
+ }
+};
+
+const modifyIframeLikeForRoughOptions = (
+ element: NonDeletedExcalidrawElement,
+ isExporting: boolean,
+ embedsValidationStatus: EmbedsValidationStatus | null,
+) => {
+ if (
+ isIframeLikeElement(element) &&
+ (isExporting ||
+ (isEmbeddableElement(element) &&
+ embedsValidationStatus?.get(element.id) !== true)) &&
+ isTransparent(element.backgroundColor) &&
+ isTransparent(element.strokeColor)
+ ) {
+ return {
+ ...element,
+ roughness: 0,
+ backgroundColor: "#d3d3d3",
+ fillStyle: "solid",
+ } as const;
+ } else if (isIframeElement(element)) {
+ return {
+ ...element,
+ strokeColor: isTransparent(element.strokeColor)
+ ? "#000000"
+ : element.strokeColor,
+ backgroundColor: isTransparent(element.backgroundColor)
+ ? "#f4f4f6"
+ : element.backgroundColor,
+ };
+ }
+ return element;
+};
+
+const getArrowheadShapes = (
+ element: ExcalidrawLinearElement,
+ shape: Drawable[],
+ position: "start" | "end",
+ arrowhead: Arrowhead,
+ generator: RoughGenerator,
+ options: Options,
+ canvasBackgroundColor: string,
+) => {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const generateCrowfootOne = (
+ arrowheadPoints: number[] | null,
+ options: Options,
+ ) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [, , x3, y3, x4, y4] = arrowheadPoints;
+
+ return [generator.line(x3, y3, x4, y4, options)];
+ };
+
+ switch (arrowhead) {
+ case "dot":
+ case "circle":
+ case "circle_outline": {
+ const [x, y, diameter] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.circle(x, y, diameter, {
+ ...options,
+ fill:
+ arrowhead === "circle_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+
+ fillStyle: "solid",
+ stroke: element.strokeColor,
+ roughness: Math.min(0.5, options.roughness || 0),
+ }),
+ ];
+ }
+ case "triangle":
+ case "triangle_outline": {
+ const [x, y, x2, y2, x3, y3] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.polygon(
+ [
+ [x, y],
+ [x2, y2],
+ [x3, y3],
+ [x, y],
+ ],
+ {
+ ...options,
+ fill:
+ arrowhead === "triangle_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+ fillStyle: "solid",
+ roughness: Math.min(1, options.roughness || 0),
+ },
+ ),
+ ];
+ }
+ case "diamond":
+ case "diamond_outline": {
+ const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.polygon(
+ [
+ [x, y],
+ [x2, y2],
+ [x3, y3],
+ [x4, y4],
+ [x, y],
+ ],
+ {
+ ...options,
+ fill:
+ arrowhead === "diamond_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+ fillStyle: "solid",
+ roughness: Math.min(1, options.roughness || 0),
+ },
+ ),
+ ];
+ }
+ case "crowfoot_one":
+ return generateCrowfootOne(arrowheadPoints, options);
+ case "bar":
+ case "arrow":
+ case "crowfoot_many":
+ case "crowfoot_one_or_many":
+ default: {
+ const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ if (element.strokeStyle === "dotted") {
+ // for dotted arrows caps, reduce gap to make it more legible
+ const dash = getDashArrayDotted(element.strokeWidth - 1);
+ options.strokeLineDash = [dash[0], dash[1] - 1];
+ } else {
+ // for solid/dashed, keep solid arrow cap
+ delete options.strokeLineDash;
+ }
+ options.roughness = Math.min(1, options.roughness || 0);
+ return [
+ generator.line(x3, y3, x2, y2, options),
+ generator.line(x4, y4, x2, y2, options),
+ ...(arrowhead === "crowfoot_one_or_many"
+ ? generateCrowfootOne(
+ getArrowheadPoints(element, shape, position, "crowfoot_one"),
+ options,
+ )
+ : []),
+ ];
+ }
+ }
+};
+
+/**
+ * Generates the roughjs shape for given element.
+ *
+ * Low-level. Use `ShapeCache.generateElementShape` instead.
+ *
+ * @private
+ */
+export const _generateElementShape = (
+ element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
+ generator: RoughGenerator,
+ {
+ isExporting,
+ canvasBackgroundColor,
+ embedsValidationStatus,
+ }: {
+ isExporting: boolean;
+ canvasBackgroundColor: string;
+ embedsValidationStatus: EmbedsValidationStatus | null;
+ },
+): Drawable | Drawable[] | null => {
+ switch (element.type) {
+ case "rectangle":
+ case "iframe":
+ case "embeddable": {
+ let shape: ElementShapes[typeof element.type];
+ // this is for rendering the stroke/bg of the embeddable, especially
+ // when the src url is not set
+
+ if (element.roundness) {
+ const w = element.width;
+ const h = element.height;
+ const r = getCornerRadius(Math.min(w, h), element);
+ shape = generator.path(
+ `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
+ h - r
+ } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
+ h - r
+ } L 0 ${r} Q 0 0, ${r} 0`,
+ generateRoughOptions(
+ modifyIframeLikeForRoughOptions(
+ element,
+ isExporting,
+ embedsValidationStatus,
+ ),
+ true,
+ ),
+ );
+ } else {
+ shape = generator.rectangle(
+ 0,
+ 0,
+ element.width,
+ element.height,
+ generateRoughOptions(
+ modifyIframeLikeForRoughOptions(
+ element,
+ isExporting,
+ embedsValidationStatus,
+ ),
+ false,
+ ),
+ );
+ }
+ return shape;
+ }
+ case "diamond": {
+ let shape: ElementShapes[typeof element.type];
+
+ const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
+ getDiamondPoints(element);
+ if (element.roundness) {
+ const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
+
+ const horizontalRadius = getCornerRadius(
+ Math.abs(rightY - topY),
+ element,
+ );
+
+ shape = generator.path(
+ `M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
+ rightX - verticalRadius
+ } ${rightY - horizontalRadius}
+ C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
+ rightX - verticalRadius
+ } ${rightY + horizontalRadius}
+ L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
+ C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
+ bottomX - verticalRadius
+ } ${bottomY - horizontalRadius}
+ L ${leftX + verticalRadius} ${leftY + horizontalRadius}
+ C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
+ leftY - horizontalRadius
+ }
+ L ${topX - verticalRadius} ${topY + horizontalRadius}
+ C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
+ topY + horizontalRadius
+ }`,
+ generateRoughOptions(element, true),
+ );
+ } else {
+ shape = generator.polygon(
+ [
+ [topX, topY],
+ [rightX, rightY],
+ [bottomX, bottomY],
+ [leftX, leftY],
+ ],
+ generateRoughOptions(element),
+ );
+ }
+ return shape;
+ }
+ case "ellipse": {
+ const shape: ElementShapes[typeof element.type] = generator.ellipse(
+ element.width / 2,
+ element.height / 2,
+ element.width,
+ element.height,
+ generateRoughOptions(element),
+ );
+ return shape;
+ }
+ case "line":
+ case "arrow": {
+ let shape: ElementShapes[typeof element.type];
+ const options = generateRoughOptions(element);
+
+ // points array can be empty in the beginning, so it is important to add
+ // initial position to it
+ const points = element.points.length
+ ? element.points
+ : [pointFrom<LocalPoint>(0, 0)];
+
+ if (isElbowArrow(element)) {
+ // NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
+ if (
+ !points.every(
+ (point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
+ )
+ ) {
+ console.error(
+ `Elbow arrow with extreme point positions detected. Arrow not rendered.`,
+ element.id,
+ JSON.stringify(points),
+ );
+ shape = [];
+ } else {
+ shape = [
+ generator.path(
+ generateElbowArrowShape(points, 16),
+ generateRoughOptions(element, true),
+ ),
+ ];
+ }
+ } else if (!element.roundness) {
+ // curve is always the first element
+ // this simplifies finding the curve for an element
+ if (options.fill) {
+ shape = [
+ generator.polygon(points as unknown as RoughPoint[], options),
+ ];
+ } else {
+ shape = [
+ generator.linearPath(points as unknown as RoughPoint[], options),
+ ];
+ }
+ } else {
+ shape = [generator.curve(points as unknown as RoughPoint[], options)];
+ }
+
+ // add lines only in arrow
+ if (element.type === "arrow") {
+ const { startArrowhead = null, endArrowhead = "arrow" } = element;
+
+ if (startArrowhead !== null) {
+ const shapes = getArrowheadShapes(
+ element,
+ shape,
+ "start",
+ startArrowhead,
+ generator,
+ options,
+ canvasBackgroundColor,
+ );
+ shape.push(...shapes);
+ }
+
+ if (endArrowhead !== null) {
+ if (endArrowhead === undefined) {
+ // Hey, we have an old arrow here!
+ }
+
+ const shapes = getArrowheadShapes(
+ element,
+ shape,
+ "end",
+ endArrowhead,
+ generator,
+ options,
+ canvasBackgroundColor,
+ );
+ shape.push(...shapes);
+ }
+ }
+ return shape;
+ }
+ case "freedraw": {
+ let shape: ElementShapes[typeof element.type];
+ generateFreeDrawShape(element);
+
+ if (isPathALoop(element.points)) {
+ // generate rough polygon to fill freedraw shape
+ const simplifiedPoints = simplify(element.points, 0.75);
+ shape = generator.curve(simplifiedPoints as [number, number][], {
+ ...generateRoughOptions(element),
+ stroke: "none",
+ });
+ } else {
+ shape = null;
+ }
+ return shape;
+ }
+ case "frame":
+ case "magicframe":
+ case "text":
+ case "image": {
+ const shape: ElementShapes[typeof element.type] = null;
+ // we return (and cache) `null` to make sure we don't regenerate
+ // `element.canvas` on rerenders
+ return shape;
+ }
+ default: {
+ assertNever(
+ element,
+ `generateElementShape(): Unimplemented type ${(element as any)?.type}`,
+ );
+ return null;
+ }
+ }
+};
+
+const generateElbowArrowShape = (
+ points: readonly LocalPoint[],
+ radius: number,
+) => {
+ const subpoints = [] as [number, number][];
+ for (let i = 1; i < points.length - 1; i += 1) {
+ const prev = points[i - 1];
+ const next = points[i + 1];
+ const point = points[i];
+ const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
+ const nextIsHorizontal = headingForPointIsHorizontal(next, point);
+ const corner = Math.min(
+ radius,
+ pointDistance(points[i], next) / 2,
+ pointDistance(points[i], prev) / 2,
+ );
+
+ if (prevIsHorizontal) {
+ if (prev[0] < point[0]) {
+ // LEFT
+ subpoints.push([points[i][0] - corner, points[i][1]]);
+ } else {
+ // RIGHT
+ subpoints.push([points[i][0] + corner, points[i][1]]);
+ }
+ } else if (prev[1] < point[1]) {
+ // UP
+ subpoints.push([points[i][0], points[i][1] - corner]);
+ } else {
+ subpoints.push([points[i][0], points[i][1] + corner]);
+ }
+
+ subpoints.push(points[i] as [number, number]);
+
+ if (nextIsHorizontal) {
+ if (next[0] < point[0]) {
+ // LEFT
+ subpoints.push([points[i][0] - corner, points[i][1]]);
+ } else {
+ // RIGHT
+ subpoints.push([points[i][0] + corner, points[i][1]]);
+ }
+ } else if (next[1] < point[1]) {
+ // UP
+ subpoints.push([points[i][0], points[i][1] - corner]);
+ } else {
+ // DOWN
+ subpoints.push([points[i][0], points[i][1] + corner]);
+ }
+ }
+
+ const d = [`M ${points[0][0]} ${points[0][1]}`];
+ for (let i = 0; i < subpoints.length; i += 3) {
+ d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
+ d.push(
+ `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
+ subpoints[i + 2][0]
+ } ${subpoints[i + 2][1]}`,
+ );
+ }
+ d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
+
+ return d.join(" ");
+};