From 9e0bfd178e77e7a524496aa4fa22d81dd3b4e526 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Mon, 14 Aug 2023 13:52:25 +0200 Subject: [PATCH] refactor: factor out shape generation from `renderElement.ts` pt 2 (#6878) --- src/element/bounds.ts | 2 +- src/renderer/renderElement.ts | 425 +++------------------------------- src/scene/Shape.ts | 362 +++++++++++++++++++++++++++++ src/scene/ShapeCache.ts | 47 ++-- src/scene/types.ts | 18 +- 5 files changed, 438 insertions(+), 416 deletions(-) create mode 100644 src/scene/Shape.ts diff --git a/src/element/bounds.ts b/src/element/bounds.ts index c5af06974..c90d145d8 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -10,7 +10,7 @@ import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; import { Drawable, Op } from "roughjs/bin/core"; import { Point } from "../types"; -import { generateRoughOptions } from "../renderer/renderElement"; +import { generateRoughOptions } from "../scene/Shape"; import { isArrowElement, isFreeDrawElement, diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 837fd62de..9b040e9a5 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -1,8 +1,6 @@ import { ExcalidrawElement, - ExcalidrawLinearElement, ExcalidrawTextElement, - Arrowhead, NonDeletedExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawImageElement, @@ -16,24 +14,13 @@ import { isArrowElement, hasBoundTextElement, } from "../element/typeChecks"; -import { - getDiamondPoints, - getElementAbsoluteCoords, - getArrowheadPoints, -} 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 { getElementAbsoluteCoords } from "../element/bounds"; +import type { RoughCanvas } from "roughjs/bin/canvas"; +import type { Drawable } from "roughjs/bin/core"; +import type { RoughSVG } from "roughjs/bin/svg"; import { StaticCanvasRenderConfig } from "../scene/types"; -import { - distance, - getFontString, - getFontFamilyString, - isRTL, - isTransparent, -} from "../utils"; +import { distance, getFontString, getFontFamilyString, isRTL } from "../utils"; import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; 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) => element.type === "freedraw" ? element.strokeWidth * 12 : 20; @@ -384,369 +367,11 @@ const drawElementOnCanvas = ( context.globalAlpha = 1; }; -const elementWithCanvasCache = new WeakMap< +export const elementWithCanvasCache = new WeakMap< ExcalidrawElement, 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 = ( element: NonDeletedExcalidrawElement, renderConfig: StaticCanvasRenderConfig, @@ -962,7 +587,6 @@ export const renderElement = ( renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { - const generator = rc.generator; switch (element.type) { case "frame": { if ( @@ -1000,7 +624,10 @@ export const renderElement = ( break; } 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) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); @@ -1038,7 +665,10 @@ export const renderElement = ( case "image": case "text": 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) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2 + appState.scrollX; @@ -1255,7 +885,6 @@ export const renderElementToSvg = ( } } const degree = (180 * element.angle) / Math.PI; - const generator = rsvg.generator; // element to append node to, most of the time svgRoot let root = svgRoot; @@ -1280,10 +909,10 @@ export const renderElementToSvg = ( case "rectangle": case "diamond": case "ellipse": { - generateElementShape(element, generator); + const shape = ShapeCache.generateElementShape(element); const node = roughSVGDrawWithPrecision( rsvg, - ShapeCache.get(element)!, + shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); if (opacity !== 1) { @@ -1310,10 +939,10 @@ export const renderElementToSvg = ( } case "embeddable": { // render placeholder rectangle - generateElementShape(element, generator, true); + const shape = ShapeCache.generateElementShape(element, true); const node = roughSVGDrawWithPrecision( rsvg, - ShapeCache.get(element)!, + shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); const opacity = element.opacity / 100; @@ -1347,7 +976,7 @@ export const renderElementToSvg = ( // render embeddable element + iframe const embeddableNode = roughSVGDrawWithPrecision( rsvg, - ShapeCache.get(element)!, + shape, MAX_DECIMALS_FOR_SVG_EXPORT, ); embeddableNode.setAttribute("stroke-linecap", "round"); @@ -1453,14 +1082,14 @@ export const renderElementToSvg = ( maskRectInvisible.setAttribute("opacity", "1"); maskPath.appendChild(maskRectInvisible); } - generateElementShape(element, generator); const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); if (boundText) { group.setAttribute("mask", `url(#mask-${element.id})`); } group.setAttribute("stroke-linecap", "round"); - ShapeCache.get(element)!.forEach((shape) => { + const shapes = ShapeCache.generateElementShape(element); + shapes.forEach((shape) => { const node = roughSVGDrawWithPrecision( rsvg, shape, @@ -1501,11 +1130,13 @@ export const renderElementToSvg = ( break; } case "freedraw": { - generateElementShape(element, generator); - generateFreeDrawShape(element); - const shape = ShapeCache.get(element); - const node = shape - ? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT) + const backgroundFillShape = ShapeCache.generateElementShape(element); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); if (opacity !== 1) { node.setAttribute("stroke-opacity", `${opacity}`); diff --git a/src/scene/Shape.ts b/src/scene/Shape.ts new file mode 100644 index 000000000..f43a1260d --- /dev/null +++ b/src/scene/Shape.ts @@ -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, + 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; + } + } +}; diff --git a/src/scene/ShapeCache.ts b/src/scene/ShapeCache.ts index d2237220b..ded1b88fa 100644 --- a/src/scene/ShapeCache.ts +++ b/src/scene/ShapeCache.ts @@ -1,28 +1,27 @@ import { Drawable } from "roughjs/bin/core"; import { RoughGenerator } from "roughjs/bin/generator"; -import { ExcalidrawElement } from "../element/types"; -import { generateElementShape } from "../renderer/renderElement"; - -type ElementShape = Drawable | Drawable[] | null; - -type ElementShapes = { - freedraw: Drawable | null; - arrow: Drawable[]; - line: Drawable[]; - text: null; - image: null; -}; +import { + ExcalidrawElement, + ExcalidrawSelectionElement, +} from "../element/types"; +import { elementWithCanvasCache } from "../renderer/renderElement"; +import { _generateElementShape } from "./Shape"; +import { ElementShape, ElementShapes } from "./types"; export class ShapeCache { private static rg = new RoughGenerator(); private static cache = new WeakMap(); + /** + * 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 = (element: T) => { return ShapeCache.cache.get( element, ) as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] | undefined - : Drawable | null | undefined; + : ElementShape | undefined; }; public static set = ( @@ -41,15 +40,29 @@ export class ShapeCache { /** * Generates & caches shape for element if not already cached, otherwise - * return cached shape. + * returns cached shape. */ - public static generateElementShape = ( + public static generateElementShape = < + T extends Exclude, + >( 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, ShapeCache.rg, - /* so it prefers cache */ false, + isExporting, ) as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] : Drawable | null; diff --git a/src/scene/types.ts b/src/scene/types.ts index 9298f5967..f1c861a9f 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -1,4 +1,5 @@ -import { RoughCanvas } from "roughjs/bin/canvas"; +import type { RoughCanvas } from "roughjs/bin/canvas"; +import { Drawable } from "roughjs/bin/core"; import { ExcalidrawTextElement, NonDeletedExcalidrawElement, @@ -90,3 +91,18 @@ export type ScrollBars = { height: number; } | 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; +};