refactor: factor out shape generation from renderElement.ts
pt 2 (#6878)
This commit is contained in:
parent
c29f19a88b
commit
9e0bfd178e
@ -10,7 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math";
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { Drawable, Op } from "roughjs/bin/core";
|
import { Drawable, Op } from "roughjs/bin/core";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import { generateRoughOptions } from "../renderer/renderElement";
|
import { generateRoughOptions } from "../scene/Shape";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
Arrowhead,
|
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
@ -16,24 +14,13 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||||
getDiamondPoints,
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
getElementAbsoluteCoords,
|
import type { Drawable } from "roughjs/bin/core";
|
||||||
getArrowheadPoints,
|
import type { RoughSVG } from "roughjs/bin/svg";
|
||||||
} from "../element/bounds";
|
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
|
||||||
import { Drawable, Options } from "roughjs/bin/core";
|
|
||||||
import { RoughSVG } from "roughjs/bin/svg";
|
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
|
|
||||||
import { StaticCanvasRenderConfig } from "../scene/types";
|
import { StaticCanvasRenderConfig } from "../scene/types";
|
||||||
import {
|
import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
|
||||||
distance,
|
|
||||||
getFontString,
|
|
||||||
getFontFamilyString,
|
|
||||||
isRTL,
|
|
||||||
isTransparent,
|
|
||||||
} from "../utils";
|
|
||||||
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import {
|
import {
|
||||||
@ -97,10 +84,6 @@ const shouldResetImageFilter = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
|
||||||
|
|
||||||
const getCanvasPadding = (element: ExcalidrawElement) =>
|
const getCanvasPadding = (element: ExcalidrawElement) =>
|
||||||
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
element.type === "freedraw" ? element.strokeWidth * 12 : 20;
|
||||||
|
|
||||||
@ -384,369 +367,11 @@ const drawElementOnCanvas = (
|
|||||||
context.globalAlpha = 1;
|
context.globalAlpha = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const elementWithCanvasCache = new WeakMap<
|
export const elementWithCanvasCache = new WeakMap<
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawElementWithCanvas
|
ExcalidrawElementWithCanvas
|
||||||
>();
|
>();
|
||||||
|
|
||||||
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 element's shape and puts it into the cache.
|
|
||||||
* @param element
|
|
||||||
* @param generator
|
|
||||||
*/
|
|
||||||
export const generateElementShape = (
|
|
||||||
element: NonDeletedExcalidrawElement,
|
|
||||||
generator: RoughGenerator,
|
|
||||||
isExporting: boolean = false,
|
|
||||||
): Drawable | Drawable[] | null => {
|
|
||||||
const cachedShape = isExporting ? undefined : ShapeCache.get(element);
|
|
||||||
|
|
||||||
if (cachedShape) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type
|
|
||||||
// (= do not generate anything)
|
|
||||||
if (cachedShape === undefined) {
|
|
||||||
let shape: Drawable | Drawable[] | null = null;
|
|
||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
|
||||||
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "embeddable": {
|
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ShapeCache.set(element, shape);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "diamond": {
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ShapeCache.set(element, shape);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ellipse":
|
|
||||||
shape = generator.ellipse(
|
|
||||||
element.width / 2,
|
|
||||||
element.height / 2,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
generateRoughOptions(element),
|
|
||||||
);
|
|
||||||
ShapeCache.set(element, shape);
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "line":
|
|
||||||
case "arrow": {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ShapeCache.set(element, shape);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "freedraw": {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
ShapeCache.set(element, shape);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "text":
|
|
||||||
case "image": {
|
|
||||||
// just to ensure we don't regenerate element.canvas on rerenders
|
|
||||||
ShapeCache.set(element, null);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateElementWithCanvas = (
|
const generateElementWithCanvas = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
@ -962,7 +587,6 @@ export const renderElement = (
|
|||||||
renderConfig: StaticCanvasRenderConfig,
|
renderConfig: StaticCanvasRenderConfig,
|
||||||
appState: StaticCanvasAppState,
|
appState: StaticCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
const generator = rc.generator;
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "frame": {
|
case "frame": {
|
||||||
if (
|
if (
|
||||||
@ -1000,7 +624,10 @@ export const renderElement = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
generateElementShape(element, generator);
|
// TODO investigate if we can do this in situ. Right now we need to call
|
||||||
|
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||||
|
// rely on existing shapes
|
||||||
|
ShapeCache.generateElementShape(element);
|
||||||
|
|
||||||
if (renderConfig.isExporting) {
|
if (renderConfig.isExporting) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
@ -1038,7 +665,10 @@ export const renderElement = (
|
|||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "embeddable": {
|
case "embeddable": {
|
||||||
generateElementShape(element, generator, renderConfig.isExporting);
|
// TODO investigate if we can do this in situ. Right now we need to call
|
||||||
|
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||||
|
// rely on existing shapes
|
||||||
|
ShapeCache.generateElementShape(element, renderConfig.isExporting);
|
||||||
if (renderConfig.isExporting) {
|
if (renderConfig.isExporting) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||||
@ -1255,7 +885,6 @@ export const renderElementToSvg = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const degree = (180 * element.angle) / Math.PI;
|
const degree = (180 * element.angle) / Math.PI;
|
||||||
const generator = rsvg.generator;
|
|
||||||
|
|
||||||
// element to append node to, most of the time svgRoot
|
// element to append node to, most of the time svgRoot
|
||||||
let root = svgRoot;
|
let root = svgRoot;
|
||||||
@ -1280,10 +909,10 @@ export const renderElementToSvg = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "ellipse": {
|
case "ellipse": {
|
||||||
generateElementShape(element, generator);
|
const shape = ShapeCache.generateElementShape(element);
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
ShapeCache.get(element)!,
|
shape,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
);
|
);
|
||||||
if (opacity !== 1) {
|
if (opacity !== 1) {
|
||||||
@ -1310,10 +939,10 @@ export const renderElementToSvg = (
|
|||||||
}
|
}
|
||||||
case "embeddable": {
|
case "embeddable": {
|
||||||
// render placeholder rectangle
|
// render placeholder rectangle
|
||||||
generateElementShape(element, generator, true);
|
const shape = ShapeCache.generateElementShape(element, true);
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
ShapeCache.get(element)!,
|
shape,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
);
|
);
|
||||||
const opacity = element.opacity / 100;
|
const opacity = element.opacity / 100;
|
||||||
@ -1347,7 +976,7 @@ export const renderElementToSvg = (
|
|||||||
// render embeddable element + iframe
|
// render embeddable element + iframe
|
||||||
const embeddableNode = roughSVGDrawWithPrecision(
|
const embeddableNode = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
ShapeCache.get(element)!,
|
shape,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
);
|
);
|
||||||
embeddableNode.setAttribute("stroke-linecap", "round");
|
embeddableNode.setAttribute("stroke-linecap", "round");
|
||||||
@ -1453,14 +1082,14 @@ export const renderElementToSvg = (
|
|||||||
maskRectInvisible.setAttribute("opacity", "1");
|
maskRectInvisible.setAttribute("opacity", "1");
|
||||||
maskPath.appendChild(maskRectInvisible);
|
maskPath.appendChild(maskRectInvisible);
|
||||||
}
|
}
|
||||||
generateElementShape(element, generator);
|
|
||||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
if (boundText) {
|
if (boundText) {
|
||||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||||
}
|
}
|
||||||
group.setAttribute("stroke-linecap", "round");
|
group.setAttribute("stroke-linecap", "round");
|
||||||
|
|
||||||
ShapeCache.get(element)!.forEach((shape) => {
|
const shapes = ShapeCache.generateElementShape(element);
|
||||||
|
shapes.forEach((shape) => {
|
||||||
const node = roughSVGDrawWithPrecision(
|
const node = roughSVGDrawWithPrecision(
|
||||||
rsvg,
|
rsvg,
|
||||||
shape,
|
shape,
|
||||||
@ -1501,11 +1130,13 @@ export const renderElementToSvg = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
generateElementShape(element, generator);
|
const backgroundFillShape = ShapeCache.generateElementShape(element);
|
||||||
generateFreeDrawShape(element);
|
const node = backgroundFillShape
|
||||||
const shape = ShapeCache.get(element);
|
? roughSVGDrawWithPrecision(
|
||||||
const node = shape
|
rsvg,
|
||||||
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
|
backgroundFillShape,
|
||||||
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
|
)
|
||||||
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
if (opacity !== 1) {
|
if (opacity !== 1) {
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
|
362
src/scene/Shape.ts
Normal file
362
src/scene/Shape.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -1,28 +1,27 @@
|
|||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import {
|
||||||
import { generateElementShape } from "../renderer/renderElement";
|
ExcalidrawElement,
|
||||||
|
ExcalidrawSelectionElement,
|
||||||
type ElementShape = Drawable | Drawable[] | null;
|
} from "../element/types";
|
||||||
|
import { elementWithCanvasCache } from "../renderer/renderElement";
|
||||||
type ElementShapes = {
|
import { _generateElementShape } from "./Shape";
|
||||||
freedraw: Drawable | null;
|
import { ElementShape, ElementShapes } from "./types";
|
||||||
arrow: Drawable[];
|
|
||||||
line: Drawable[];
|
|
||||||
text: null;
|
|
||||||
image: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ShapeCache {
|
export class ShapeCache {
|
||||||
private static rg = new RoughGenerator();
|
private static rg = new RoughGenerator();
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
|
* is optional and you have a fallback in case it's not cached.
|
||||||
|
*/
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||||
return ShapeCache.cache.get(
|
return ShapeCache.cache.get(
|
||||||
element,
|
element,
|
||||||
) as T["type"] extends keyof ElementShapes
|
) as T["type"] extends keyof ElementShapes
|
||||||
? ElementShapes[T["type"]] | undefined
|
? ElementShapes[T["type"]] | undefined
|
||||||
: Drawable | null | undefined;
|
: ElementShape | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
public static set = <T extends ExcalidrawElement>(
|
||||||
@ -41,15 +40,29 @@ export class ShapeCache {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates & caches shape for element if not already cached, otherwise
|
* Generates & caches shape for element if not already cached, otherwise
|
||||||
* return cached shape.
|
* returns cached shape.
|
||||||
*/
|
*/
|
||||||
public static generateElementShape = <T extends ExcalidrawElement>(
|
public static generateElementShape = <
|
||||||
|
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
>(
|
||||||
element: T,
|
element: T,
|
||||||
|
isExporting = false,
|
||||||
) => {
|
) => {
|
||||||
const shape = generateElementShape(
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
|
const cachedShape = isExporting ? undefined : ShapeCache.get(element);
|
||||||
|
|
||||||
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
|
if (cachedShape !== undefined) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
|
const shape = _generateElementShape(
|
||||||
element,
|
element,
|
||||||
ShapeCache.rg,
|
ShapeCache.rg,
|
||||||
/* so it prefers cache */ false,
|
isExporting,
|
||||||
) as T["type"] extends keyof ElementShapes
|
) as T["type"] extends keyof ElementShapes
|
||||||
? ElementShapes[T["type"]]
|
? ElementShapes[T["type"]]
|
||||||
: Drawable | null;
|
: Drawable | null;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import {
|
import {
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
@ -90,3 +91,18 @@ export type ScrollBars = {
|
|||||||
height: number;
|
height: number;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ElementShape = Drawable | Drawable[] | null;
|
||||||
|
|
||||||
|
export type ElementShapes = {
|
||||||
|
rectangle: Drawable;
|
||||||
|
ellipse: Drawable;
|
||||||
|
diamond: Drawable;
|
||||||
|
embeddable: Drawable;
|
||||||
|
freedraw: Drawable | null;
|
||||||
|
arrow: Drawable[];
|
||||||
|
line: Drawable[];
|
||||||
|
text: null;
|
||||||
|
image: null;
|
||||||
|
frame: null;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user