refactor: factor out shape generation from `renderElement.ts` pt 2 (#6878)
parent
c29f19a88b
commit
9e0bfd178e
@ -0,0 +1,362 @@
|
||||
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 { isPathALoop, getCornerRadius } from "../math";
|
||||
import { generateFreeDrawShape } from "../renderer/renderElement";
|
||||
import { isTransparent, assertNever } from "../utils";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
|
||||
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: element.roughness,
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices: continuousPath,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
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 modifyEmbeddableForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
) => {
|
||||
if (
|
||||
element.type === "embeddable" &&
|
||||
(isExporting || !element.validated) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
roughness: 0,
|
||||
backgroundColor: "#d3d3d3",
|
||||
fillStyle: "solid",
|
||||
} as const;
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the roughjs shape for given element.
|
||||
*
|
||||
* Low-level. Use `ShapeCache.generateElementShape` instead.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
isExporting: boolean = false,
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
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(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyEmbeddableForRoughOptions(element, isExporting),
|
||||
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 : [[0, 0]];
|
||||
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
if (!element.roundness) {
|
||||
if (options.fill) {
|
||||
shape = [generator.polygon(points as [number, number][], options)];
|
||||
} else {
|
||||
shape = [generator.linearPath(points as [number, number][], options)];
|
||||
}
|
||||
} else {
|
||||
shape = [generator.curve(points as [number, number][], options)];
|
||||
}
|
||||
|
||||
// add lines only in arrow
|
||||
if (element.type === "arrow") {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Other arrowheads here...
|
||||
if (arrowhead === "dot") {
|
||||
const [x, y, r] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, r, {
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
stroke: "none",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (arrowhead === "triangle") {
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for triangle arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
[
|
||||
[x, y],
|
||||
[x2, y2],
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// 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
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
};
|
||||
|
||||
if (startArrowhead !== null) {
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"start",
|
||||
startArrowhead,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
|
||||
if (endArrowhead !== null) {
|
||||
if (endArrowhead === undefined) {
|
||||
// Hey, we have an old arrow here!
|
||||
}
|
||||
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"end",
|
||||
endArrowhead,
|
||||
);
|
||||
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
|
||||
shape = generator.polygon(element.points as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "frame":
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue