From b09b5cb5f4b7c33c9a5d9bb73ba196f51dfffe42 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 27 Feb 2024 10:37:44 +0530 Subject: [PATCH] fix: split renderScene so that locales aren't imported unnecessarily (#7718) * fix: split renderScene so that locales aren't imported unnecessarily * lint * split export code * rename renderScene to helpers.ts * add helpers * fix typo * fixes * move renderElementToSvg to export * lint * rename export to staticSvgScene * fix --- .../components/canvases/InteractiveCanvas.tsx | 2 +- .../components/canvases/StaticCanvas.tsx | 2 +- packages/excalidraw/renderer/helpers.ts | 75 + .../{renderScene.ts => interactiveScene.ts} | 1250 +++++------------ packages/excalidraw/renderer/renderElement.ts | 583 +------- packages/excalidraw/renderer/staticScene.ts | 370 +++++ .../excalidraw/renderer/staticSvgScene.ts | 653 +++++++++ packages/excalidraw/scene/Renderer.ts | 7 +- packages/excalidraw/scene/export.ts | 3 +- packages/excalidraw/tests/App.test.tsx | 4 +- .../excalidraw/tests/contextmenu.test.tsx | 4 +- packages/excalidraw/tests/dragCreate.test.tsx | 10 +- .../tests/linearElementEditor.test.tsx | 11 +- packages/excalidraw/tests/move.test.tsx | 10 +- .../tests/multiPointCreate.test.tsx | 10 +- .../excalidraw/tests/regressionTests.test.tsx | 4 +- packages/excalidraw/tests/selection.test.tsx | 10 +- 17 files changed, 1528 insertions(+), 1480 deletions(-) create mode 100644 packages/excalidraw/renderer/helpers.ts rename packages/excalidraw/renderer/{renderScene.ts => interactiveScene.ts} (67%) create mode 100644 packages/excalidraw/renderer/staticScene.ts create mode 100644 packages/excalidraw/renderer/staticSvgScene.ts diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 0782b92b9..e76d8ae68 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef } from "react"; -import { renderInteractiveScene } from "../../renderer/renderScene"; import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils"; import { CURSOR_TYPE } from "../../constants"; import { t } from "../../i18n"; @@ -12,6 +11,7 @@ import type { } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; +import { renderInteractiveScene } from "../../renderer/interactiveScene"; type InteractiveCanvasProps = { containerRef: React.RefObject; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index bfdb669e6..f5cc3dfe5 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; -import { renderStaticScene } from "../../renderer/renderScene"; +import { renderStaticScene } from "../../renderer/staticScene"; import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; import type { diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts new file mode 100644 index 000000000..9ab85975f --- /dev/null +++ b/packages/excalidraw/renderer/helpers.ts @@ -0,0 +1,75 @@ +import { StaticCanvasAppState, AppState } from "../types"; + +import { StaticCanvasRenderConfig } from "../scene/types"; + +import { THEME_FILTER } from "../constants"; + +export const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + stroke = true, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); + context.fill(); + if (stroke) { + context.stroke(); + } +}; + +export const getNormalizedCanvasDimensions = ( + canvas: HTMLCanvasElement, + scale: number, +): [number, number] => { + // When doing calculations based on canvas width we should used normalized one + return [canvas.width / scale, canvas.height / scale]; +}; + +export const bootstrapCanvas = ({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme, + isExporting, + viewBackgroundColor, +}: { + canvas: HTMLCanvasElement; + scale: number; + normalizedWidth: number; + normalizedHeight: number; + theme?: AppState["theme"]; + isExporting?: StaticCanvasRenderConfig["isExporting"]; + viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; +}): CanvasRenderingContext2D => { + const context = canvas.getContext("2d")!; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.scale(scale, scale); + + if (isExporting && theme === "dark") { + context.filter = THEME_FILTER; + } + + // Paint background + if (typeof viewBackgroundColor === "string") { + const hasTransparence = + viewBackgroundColor === "transparent" || + viewBackgroundColor.length === 5 || // #RGBA + viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + context.save(); + context.fillStyle = viewBackgroundColor; + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + + return context; +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/interactiveScene.ts similarity index 67% rename from packages/excalidraw/renderer/renderScene.ts rename to packages/excalidraw/renderer/interactiveScene.ts index 69926b72d..a6d997770 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,24 +1,3 @@ -import { RoughSVG } from "roughjs/bin/svg"; -import oc from "open-color"; - -import { - InteractiveCanvasAppState, - StaticCanvasAppState, - BinaryFiles, - Point, - Zoom, - AppState, -} from "../types"; -import { - ExcalidrawElement, - NonDeletedExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, - GroupId, - ExcalidrawBindableElement, - ExcalidrawFrameLikeElement, - ElementsMap, -} from "../element/types"; import { getElementAbsoluteCoords, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -27,36 +6,22 @@ import { getCommonBounds, } from "../element"; -import { roundRect } from "./roundRect"; -import { - InteractiveCanvasRenderConfig, - InteractiveSceneRenderConfig, - SVGRenderConfig, - StaticCanvasRenderConfig, - StaticSceneRenderConfig, - RenderableElementsMap, -} from "../scene/types"; +import { roundRect } from "../renderer/roundRect"; + import { getScrollBars, SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - renderElement, - renderElementToSvg, - renderSelectionElement, -} from "./renderElement"; +import { renderSelectionElement } from "../renderer/renderElement"; import { getClientColor } from "../clients"; -import { LinearElementEditor } from "../element/linearElementEditor"; import { isSelectedViaGroup, getSelectedGroupIds, getElementsInGroup, selectGroupsFromGivenElements, } from "../groups"; -import { maxBindingGap } from "../element/collision"; -import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; import { OMIT_SIDES_FOR_FRAME, shouldShowBoundingBox, @@ -64,29 +29,81 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; + +import { renderSnaps } from "../renderer/renderSnaps"; + +import { maxBindingGap } from "../element/collision"; +import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; import { - DEFAULT_TRANSFORM_HANDLE_SPACING, - FRAME_STYLE, - THEME_FILTER, -} from "../constants"; + bootstrapCanvas, + fillCircle, + getNormalizedCanvasDimensions, +} from "./helpers"; +import oc from "open-color"; +import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; import { - EXTERNAL_LINK_IMG, - getLinkHandleFromCoords, -} from "../components/hyperlink/helpers"; -import { renderSnaps } from "./renderSnaps"; + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + ExcalidrawLinearElement, + GroupId, + NonDeleted, +} from "../element/types"; import { - isEmbeddableElement, - isFrameLikeElement, - isIframeLikeElement, - isLinearElement, -} from "../element/typeChecks"; -import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; -import { - elementOverlapsWithFrame, - getTargetFrame, - isElementInFrame, -} from "../frame"; + InteractiveCanvasRenderConfig, + InteractiveSceneRenderConfig, + RenderableElementsMap, +} from "../scene/types"; + +const renderLinearElementPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, +) => { + const { elementId, hoverPointIndex } = appState.selectedLinearElement!; + if ( + appState.editingLinearElement?.selectedPointsIndices?.includes( + hoverPointIndex, + ) + ) { + return; + } + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return; + } + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + hoverPointIndex, + elementsMap, + ); + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(point, context, appState); + context.restore(); +}; + +const highlightPoint = ( + point: Point, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + context.fillStyle = "rgba(105, 101, 219, 0.4)"; + + fillCircle( + context, + point[0], + point[1], + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, + false, + ); +}; const strokeRectWithRotation = ( context: CanvasRenderingContext2D, @@ -139,86 +156,6 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - -const fillCircle = ( - context: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, - stroke = true, -) => { - context.beginPath(); - context.arc(cx, cy, radius, 0, Math.PI * 2); - context.fill(); - if (stroke) { - context.stroke(); - } -}; - -const strokeGrid = ( - context: CanvasRenderingContext2D, - gridSize: number, - scrollX: number, - scrollY: number, - zoom: Zoom, - width: number, - height: number, -) => { - const BOLD_LINE_FREQUENCY = 5; - - enum GridLineColor { - Bold = "#cccccc", - Regular = "#e5e5e5", - } - - const offsetX = - -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); - const offsetY = - -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); - - const lineWidth = Math.min(1 / zoom.value, 1); - - const spaceWidth = 1 / zoom.value; - const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; - - context.save(); - context.lineWidth = lineWidth; - - for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { - const isBold = - Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(x, offsetY - gridSize); - context.lineTo(x, offsetY + height + gridSize * 2); - context.stroke(); - } - for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { - const isBold = - Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(offsetX - gridSize, y); - context.lineTo(offsetX + width + gridSize * 2, y); - context.stroke(); - } - context.restore(); -}; - const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -245,6 +182,266 @@ const renderSingleLinearPoint = ( ); }; +const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +const renderBindingHighlightForBindableElement = ( + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + const threshold = maxBindingGap(element, width, height); + + // So that we don't overlap the element itself + const strokeOffset = 4; + context.strokeStyle = "rgba(0,0,0,.05)"; + context.lineWidth = threshold - strokeOffset; + const padding = strokeOffset / 2 + threshold / 2; + + switch (element.type) { + case "rectangle": + case "text": + case "image": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + strokeRectWithRotation( + context, + x1 - padding, + y1 - padding, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "diamond": + const side = Math.hypot(width, height); + const wPadding = (padding * side) / height; + const hPadding = (padding * side) / width; + strokeDiamondWithRotation( + context, + width + wPadding * 2, + height + hPadding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "ellipse": + strokeEllipseWithRotation( + context, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + } +}; + +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + elementsMap, + ); + fillCircle(context, x, y, threshold); + }); +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementProperties: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }, + padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, +) => { + const { + angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + cx, + cy, + dashed, + activeEmbeddable, + } = elementProperties; + const elementWidth = elementX2 - elementX1; + const elementHeight = elementY2 - elementY1; + + const linePadding = padding / appState.zoom.value; + const lineWidth = 8 / appState.zoom.value; + const spaceWidth = 4 / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; + + const count = selectionColors.length; + for (let index = 0; index < count; ++index) { + context.strokeStyle = selectionColors[index]; + if (dashed) { + context.setLineDash([ + lineWidth, + spaceWidth + (lineWidth + spaceWidth) * (count - 1), + ]); + } + context.lineDashOffset = (lineWidth + spaceWidth) * index; + strokeRectWithRotation( + context, + elementX1 - linePadding, + elementY1 - linePadding, + elementWidth + linePadding * 2, + elementHeight + linePadding * 2, + cx, + cy, + angle, + ); + } + context.restore(); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, +) => { + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderHighlight(context, suggestedBinding as any, elementsMap); + + context.restore(); +}; + +const renderFrameHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + frame: NonDeleted, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgb(0,118,255)"; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + strokeRectWithRotation( + context, + x1, + y1, + width, + height, + x1 + width / 2, + y1 + height / 2, + frame.angle, + false, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.restore(); +}; + +const renderElementsBoxHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elements: NonDeleted[], +) => { + const individualElements = elements.filter( + (element) => element.groupIds.length === 0, + ); + + const elementsInGroups = elements.filter( + (element) => element.groupIds.length > 0, + ); + + const getSelectionFromElements = (elements: ExcalidrawElement[]) => { + const [elementX1, elementY1, elementX2, elementY2] = + getCommonBounds(elements); + return { + angle: 0, + elementX1, + elementX2, + elementY1, + elementY2, + selectionColors: ["rgb(0,118,255)"], + dashed: false, + cx: elementX1 + (elementX2 - elementX1) / 2, + cy: elementY1 + (elementY2 - elementY1) / 2, + activeEmbeddable: false, + }; + }; + + const getSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elements, groupId); + return getSelectionFromElements(groupElements); + }; + + Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) + .filter(([id, isSelected]) => isSelected) + .map(([id, isSelected]) => id) + .map((groupId) => getSelectionForGroupId(groupId)) + .concat( + individualElements.map((element) => getSelectionFromElements([element])), + ) + .forEach((selection) => + renderSelectionBorder(context, appState, selection), + ); +}; + const renderLinearPointHandles = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -326,130 +523,47 @@ const renderLinearPointHandles = ( context.restore(); }; -const highlightPoint = ( - point: Point, +const renderTransformHandles = ( context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, appState: InteractiveCanvasAppState, -) => { - context.fillStyle = "rgba(105, 101, 219, 0.4)"; + transformHandles: TransformHandles, + angle: number, +): void => { + Object.keys(transformHandles).forEach((key) => { + const transformHandle = transformHandles[key as TransformHandleType]; + if (transformHandle !== undefined) { + const [x, y, width, height] = transformHandle; - fillCircle( - context, - point[0], - point[1], - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, - false, - ); -}; -const renderLinearElementPointHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementsMap: ElementsMap, -) => { - const { elementId, hoverPointIndex } = appState.selectedLinearElement!; - if ( - appState.editingLinearElement?.selectedPointsIndices?.includes( - hoverPointIndex, - ) - ) { - return; - } - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (!element) { - return; - } - const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - hoverPointIndex, - elementsMap, - ); - context.save(); - context.translate(appState.scrollX, appState.scrollY); - - highlightPoint(point, context, appState); - context.restore(); -}; - -const frameClip = ( - frame: ExcalidrawFrameLikeElement, - context: CanvasRenderingContext2D, - renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, -) => { - context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); - context.beginPath(); - if (context.roundRect) { - context.roundRect( - 0, - 0, - frame.width, - frame.height, - FRAME_STYLE.radius / appState.zoom.value, - ); - } else { - context.rect(0, 0, frame.width, frame.height); - } - context.clip(); - context.translate( - -(frame.x + appState.scrollX), - -(frame.y + appState.scrollY), - ); -}; - -const getNormalizedCanvasDimensions = ( - canvas: HTMLCanvasElement, - scale: number, -): [number, number] => { - // When doing calculations based on canvas width we should used normalized one - return [canvas.width / scale, canvas.height / scale]; -}; - -const bootstrapCanvas = ({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme, - isExporting, - viewBackgroundColor, -}: { - canvas: HTMLCanvasElement; - scale: number; - normalizedWidth: number; - normalizedHeight: number; - theme?: AppState["theme"]; - isExporting?: StaticCanvasRenderConfig["isExporting"]; - viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; -}): CanvasRenderingContext2D => { - const context = canvas.getContext("2d")!; - - context.setTransform(1, 0, 0, 1, 0, 0); - context.scale(scale, scale); - - if (isExporting && theme === "dark") { - context.filter = THEME_FILTER; - } - - // Paint background - if (typeof viewBackgroundColor === "string") { - const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); - if (hasTransparence) { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); + context.save(); + context.lineWidth = 1 / appState.zoom.value; + if (renderConfig.selectionColor) { + context.strokeStyle = renderConfig.selectionColor; + } + if (key === "rotation") { + fillCircle(context, x + width / 2, y + height / 2, width / 2); + // prefer round corners if roundRect API is available + } else if (context.roundRect) { + context.beginPath(); + context.roundRect(x, y, width, height, 2 / appState.zoom.value); + context.fill(); + context.stroke(); + } else { + strokeRectWithRotation( + context, + x, + y, + width, + height, + x + width / 2, + y + height / 2, + angle, + true, // fill before stroke + ); + } + context.restore(); } - context.save(); - context.fillStyle = viewBackgroundColor; - context.fillRect(0, 0, normalizedWidth, normalizedHeight); - context.restore(); - } else { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); - } - - return context; + }); }; const _renderInteractiveScene = ({ @@ -917,192 +1031,8 @@ const _renderInteractiveScene = ({ }; }; -const _renderStaticScene = ({ - canvas, - rc, - elementsMap, - allElementsMap, - visibleElements, - scale, - appState, - renderConfig, -}: StaticSceneRenderConfig) => { - if (canvas === null) { - return; - } - - const { renderGrid = true, isExporting } = renderConfig; - - const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( - canvas, - scale, - ); - - const context = bootstrapCanvas({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme: appState.theme, - isExporting, - viewBackgroundColor: appState.viewBackgroundColor, - }); - - // Apply zoom - context.scale(appState.zoom.value, appState.zoom.value); - - // Grid - if (renderGrid && appState.gridSize) { - strokeGrid( - context, - appState.gridSize, - appState.scrollX, - appState.scrollY, - appState.zoom, - normalizedWidth / appState.zoom.value, - normalizedHeight / appState.zoom.value, - ); - } - - const groupsToBeAddedToFrame = new Set(); - - visibleElements.forEach((element) => { - if ( - element.groupIds.length > 0 && - appState.frameToHighlight && - appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame( - element, - appState.frameToHighlight, - elementsMap, - ) || - element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) - ) { - element.groupIds.forEach((groupId) => - groupsToBeAddedToFrame.add(groupId), - ); - } - }); - - // Paint visible elements - visibleElements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - try { - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - context.restore(); - } else { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - } catch (error: any) { - console.error(error); - } - }); - - // render embeddables on top - visibleElements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - try { - const render = () => { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - - if ( - isIframeLikeElement(element) && - (isExporting || - (isEmbeddableElement(element) && - renderConfig.embedsValidationStatus.get(element.id) !== - true)) && - element.width && - element.height - ) { - const label = createPlaceholderEmbeddableLabel(element); - renderElement( - label, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - }; - // - when exporting the whole canvas, we DO NOT apply clipping - // - when we are exporting a particular frame, apply clipping - // if the containing frame is not selected, apply clipping - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - render(); - context.restore(); - } else { - render(); - } - } catch (error: any) { - console.error(error); - } - }); -}; - /** throttled to animation framerate */ -const renderInteractiveSceneThrottled = throttleRAF( +export const renderInteractiveSceneThrottled = throttleRAF( (config: InteractiveSceneRenderConfig) => { const ret = _renderInteractiveScene(config); config.callback?.(ret); @@ -1111,7 +1041,7 @@ const renderInteractiveSceneThrottled = throttleRAF( ); /** - * Interactive scene is the ui-canvas where we render boundinb boxes, selections + * Interactive scene is the ui-canvas where we render bounding boxes, selections * and other ui stuff. */ export const renderInteractiveScene = < @@ -1129,435 +1059,3 @@ export const renderInteractiveScene = < renderConfig.callback(ret); return ret as T extends true ? void : ReturnType; }; - -/** throttled to animation framerate */ -const renderStaticSceneThrottled = throttleRAF( - (config: StaticSceneRenderConfig) => { - _renderStaticScene(config); - }, - { trailing: true }, -); - -/** - * Static scene is the non-ui canvas where we render elements. - */ -export const renderStaticScene = ( - renderConfig: StaticSceneRenderConfig, - throttle?: boolean, -) => { - if (throttle) { - renderStaticSceneThrottled(renderConfig); - return; - } - - _renderStaticScene(renderConfig); -}; - -export const cancelRender = () => { - renderInteractiveSceneThrottled.cancel(); - renderStaticSceneThrottled.cancel(); -}; - -const renderTransformHandles = ( - context: CanvasRenderingContext2D, - renderConfig: InteractiveCanvasRenderConfig, - appState: InteractiveCanvasAppState, - transformHandles: TransformHandles, - angle: number, -): void => { - Object.keys(transformHandles).forEach((key) => { - const transformHandle = transformHandles[key as TransformHandleType]; - if (transformHandle !== undefined) { - const [x, y, width, height] = transformHandle; - - context.save(); - context.lineWidth = 1 / appState.zoom.value; - if (renderConfig.selectionColor) { - context.strokeStyle = renderConfig.selectionColor; - } - if (key === "rotation") { - fillCircle(context, x + width / 2, y + height / 2, width / 2); - // prefer round corners if roundRect API is available - } else if (context.roundRect) { - context.beginPath(); - context.roundRect(x, y, width, height, 2 / appState.zoom.value); - context.fill(); - context.stroke(); - } else { - strokeRectWithRotation( - context, - x, - y, - width, - height, - x + width / 2, - y + height / 2, - angle, - true, // fill before stroke - ); - } - context.restore(); - } - }); -}; - -const renderSelectionBorder = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementProperties: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }, - padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, -) => { - const { - angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - cx, - cy, - dashed, - activeEmbeddable, - } = elementProperties; - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - - const linePadding = padding / appState.zoom.value; - const lineWidth = 8 / appState.zoom.value; - const spaceWidth = 4 / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; - - const count = selectionColors.length; - for (let index = 0; index < count; ++index) { - context.strokeStyle = selectionColors[index]; - if (dashed) { - context.setLineDash([ - lineWidth, - spaceWidth + (lineWidth + spaceWidth) * (count - 1), - ]); - } - context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( - context, - elementX1 - linePadding, - elementY1 - linePadding, - elementWidth + linePadding * 2, - elementHeight + linePadding * 2, - cx, - cy, - angle, - ); - } - context.restore(); -}; - -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, - elementsMap: ElementsMap, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any, elementsMap); - - context.restore(); -}; - -const renderBindingHighlightForBindableElement = ( - context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - const threshold = maxBindingGap(element, width, height); - - // So that we don't overlap the element itself - const strokeOffset = 4; - context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = threshold - strokeOffset; - const padding = strokeOffset / 2 + threshold / 2; - - switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - strokeRectWithRotation( - context, - x1 - padding, - y1 - padding, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "diamond": - const side = Math.hypot(width, height); - const wPadding = (padding * side) / height; - const hPadding = (padding * side) / width; - strokeDiamondWithRotation( - context, - width + wPadding * 2, - height + hPadding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "ellipse": - strokeEllipseWithRotation( - context, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - } -}; - -const renderFrameHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - frame: NonDeleted, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - - context.strokeStyle = "rgb(0,118,255)"; - context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - strokeRectWithRotation( - context, - x1, - y1, - width, - height, - x1 + width / 2, - y1 + height / 2, - frame.angle, - false, - FRAME_STYLE.radius / appState.zoom.value, - ); - context.restore(); -}; - -const renderElementsBoxHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elements: NonDeleted[], -) => { - const individualElements = elements.filter( - (element) => element.groupIds.length === 0, - ); - - const elementsInGroups = elements.filter( - (element) => element.groupIds.length > 0, - ); - - const getSelectionFromElements = (elements: ExcalidrawElement[]) => { - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(elements); - return { - angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, - selectionColors: ["rgb(0,118,255)"], - dashed: false, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, - activeEmbeddable: false, - }; - }; - - const getSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); - return getSelectionFromElements(groupElements); - }; - - Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) - .filter(([id, isSelected]) => isSelected) - .map(([id, isSelected]) => id) - .map((groupId) => getSelectionForGroupId(groupId)) - .concat( - individualElements.map((element) => getSelectionFromElements([element])), - ) - .forEach((selection) => - renderSelectionBorder(context, appState, selection), - ); -}; - -const renderBindingHighlightForSuggestedPointBinding = ( - context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, -) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; - - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - ); - - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - elementsMap, - ); - fillCircle(context, x, y, threshold); - }); -}; - -let linkCanvasCache: any; -const renderLinkIcon = ( - element: NonDeletedExcalidrawElement, - context: CanvasRenderingContext2D, - appState: StaticCanvasAppState, - elementsMap: ElementsMap, -) => { - if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const [x, y, width, height] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const centerX = x + width / 2; - const centerY = y + height / 2; - context.save(); - context.translate(appState.scrollX + centerX, appState.scrollY + centerY); - context.rotate(element.angle); - - if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { - linkCanvasCache = document.createElement("canvas"); - linkCanvasCache.zoom = appState.zoom.value; - linkCanvasCache.width = - width * window.devicePixelRatio * appState.zoom.value; - linkCanvasCache.height = - height * window.devicePixelRatio * appState.zoom.value; - const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; - linkCanvasCacheContext.scale( - window.devicePixelRatio * appState.zoom.value, - window.devicePixelRatio * appState.zoom.value, - ); - linkCanvasCacheContext.fillStyle = "#fff"; - linkCanvasCacheContext.fillRect(0, 0, width, height); - linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); - linkCanvasCacheContext.restore(); - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } else { - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } - context.restore(); - } -}; - -// This should be only called for exporting purposes -export const renderSceneToSvg = ( - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - renderConfig: SVGRenderConfig, -) => { - if (!svgRoot) { - return; - } - - // render elements - elements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); - - // render embeddables on top - elements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); -}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 637a9fe1e..a40e3d398 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -20,27 +20,17 @@ import { } from "../element/typeChecks"; 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 { - SVGRenderConfig, StaticCanvasRenderConfig, RenderableElementsMap, } from "../scene/types"; -import { - distance, - getFontString, - getFontFamilyString, - isRTL, - isTestEnv, -} from "../utils"; -import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; +import { distance, getFontString, isRTL } from "../utils"; +import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import { AppState, StaticCanvasAppState, - BinaryFiles, Zoom, InteractiveCanvasAppState, ElementsPendingErasure, @@ -50,9 +40,7 @@ import { BOUND_TEXT_PADDING, ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, - MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, - SVG_NS, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { @@ -64,19 +52,16 @@ import { getBoundTextMaxWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { - createPlaceholderEmbeddableLabel, - getEmbedLink, -} from "../element/embeddable"; + import { getContainingFrame } from "../frame"; -import { normalizeLink, toValidURL } from "../data/url"; import { ShapeCache } from "../scene/ShapeCache"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original // color scheme (it's still not quite there and the colors look slightly // desatured, alas...) -const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; +export const IMAGE_INVERT_FILTER = + "invert(100%) hue-rotate(180deg) saturate(1.25)"; const defaultAppState = getDefaultAppState(); @@ -905,564 +890,6 @@ export const renderElement = ( context.globalAlpha = 1; }; -const roughSVGDrawWithPrecision = ( - rsvg: RoughSVG, - drawable: Drawable, - precision?: number, -) => { - if (typeof precision === "undefined") { - return rsvg.draw(drawable); - } - const pshape: Drawable = { - sets: drawable.sets, - shape: drawable.shape, - options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, - }; - return rsvg.draw(pshape); -}; - -const maybeWrapNodesInFrameClipPath = ( - element: NonDeletedExcalidrawElement, - root: SVGElement, - nodes: SVGElement[], - frameRendering: AppState["frameRendering"], - elementsMap: RenderableElementsMap, -) => { - if (!frameRendering.enabled || !frameRendering.clip) { - return null; - } - const frame = getContainingFrame(element, elementsMap); - if (frame) { - const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); - g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); - nodes.forEach((node) => g.appendChild(node)); - return g; - } - - return null; -}; - -export const renderElementToSvg = ( - element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - offsetX: number, - offsetY: number, - renderConfig: SVGRenderConfig, -) => { - const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - let cx = (x2 - x1) / 2 - (element.x - x1); - let cy = (y2 - y1) / 2 - (element.y - y1); - if (isTextElement(element)) { - const container = getContainerElement(element, elementsMap); - if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); - - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - container, - element as ExcalidrawTextElementWithContainer, - elementsMap, - ); - cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); - cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); - offsetX = offsetX + boundTextCoords.x - element.x; - offsetY = offsetY + boundTextCoords.y - element.y; - } - } - const degree = (180 * element.angle) / Math.PI; - - // element to append node to, most of the time svgRoot - let root = svgRoot; - - // if the element has a link, create an anchor tag and make that the new root - if (element.link) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link)); - root.appendChild(anchorTag); - root = anchorTag; - } - - const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { - if (isTestEnv()) { - node.setAttribute("data-id", element.id); - } - root.appendChild(node); - }; - - const opacity = - ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * - element.opacity) / - 10000; - - switch (element.type) { - case "selection": { - // Since this is used only during editing experience, which is canvas based, - // this should not happen - throw new Error("Selection rendering is not supported for SVG"); - } - case "rectangle": - case "diamond": - case "ellipse": { - const shape = ShapeCache.generateElementShape(element, null); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "iframe": - case "embeddable": { - // render placeholder rectangle - const shape = ShapeCache.generateElementShape(element, renderConfig); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - const opacity = element.opacity / 100; - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - addToRoot(node, element); - - const label: ExcalidrawElement = - createPlaceholderEmbeddableLabel(element); - renderElementToSvg( - label, - elementsMap, - rsvg, - root, - files, - label.x + offset.x - element.x, - label.y + offset.y - element.y, - renderConfig, - ); - - // render embeddable element + iframe - const embeddableNode = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - embeddableNode.setAttribute("stroke-linecap", "round"); - embeddableNode.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - while (embeddableNode.firstChild) { - embeddableNode.removeChild(embeddableNode.firstChild); - } - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - - const embedLink = getEmbedLink(toValidURL(element.link || "")); - - // if rendering embeddables explicitly disabled or - // embedding documents via srcdoc (which doesn't seem to work for SVGs) - // replace with a link instead - if ( - renderConfig.renderEmbeddables === false || - embedLink?.type === "document" - ) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link || "")); - anchorTag.setAttribute("target", "_blank"); - anchorTag.setAttribute("rel", "noopener noreferrer"); - anchorTag.style.borderRadius = `${radius}px`; - - embeddableNode.appendChild(anchorTag); - } else { - const foreignObject = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "foreignObject", - ); - foreignObject.style.width = `${element.width}px`; - foreignObject.style.height = `${element.height}px`; - foreignObject.style.border = "none"; - const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); - div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); - div.style.width = "100%"; - div.style.height = "100%"; - const iframe = div.ownerDocument!.createElement("iframe"); - iframe.src = embedLink?.link ?? ""; - iframe.style.width = "100%"; - iframe.style.height = "100%"; - iframe.style.border = "none"; - iframe.style.borderRadius = `${radius}px`; - iframe.style.top = "0"; - iframe.style.left = "0"; - iframe.allowFullscreen = true; - div.appendChild(iframe); - foreignObject.appendChild(div); - - embeddableNode.appendChild(foreignObject); - } - addToRoot(embeddableNode, element); - break; - } - case "line": - case "arrow": { - const boundText = getBoundTextElement(element, elementsMap); - const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); - if (boundText) { - maskPath.setAttribute("id", `mask-${element.id}`); - const maskRectVisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - offsetX = offsetX || 0; - offsetY = offsetY || 0; - maskRectVisible.setAttribute("x", "0"); - maskRectVisible.setAttribute("y", "0"); - maskRectVisible.setAttribute("fill", "#fff"); - maskRectVisible.setAttribute( - "width", - `${element.width + 100 + offsetX}`, - ); - maskRectVisible.setAttribute( - "height", - `${element.height + 100 + offsetY}`, - ); - - maskPath.appendChild(maskRectVisible); - const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - element, - boundText, - elementsMap, - ); - - const maskX = offsetX + boundTextCoords.x - element.x; - const maskY = offsetY + boundTextCoords.y - element.y; - - maskRectInvisible.setAttribute("x", maskX.toString()); - maskRectInvisible.setAttribute("y", maskY.toString()); - maskRectInvisible.setAttribute("fill", "#000"); - maskRectInvisible.setAttribute("width", `${boundText.width}`); - maskRectInvisible.setAttribute("height", `${boundText.height}`); - maskRectInvisible.setAttribute("opacity", "1"); - maskPath.appendChild(maskRectInvisible); - } - const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (boundText) { - group.setAttribute("mask", `url(#mask-${element.id})`); - } - group.setAttribute("stroke-linecap", "round"); - - const shapes = ShapeCache.generateElementShape(element, renderConfig); - shapes.forEach((shape) => { - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - if ( - element.type === "line" && - isPathALoop(element.points) && - element.backgroundColor !== "transparent" - ) { - node.setAttribute("fill-rule", "evenodd"); - } - group.appendChild(node); - }); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [group, maskPath], - renderConfig.frameRendering, - elementsMap, - ); - if (g) { - addToRoot(g, element); - root.appendChild(g); - } else { - addToRoot(group, element); - root.append(maskPath); - } - break; - } - case "freedraw": { - const backgroundFillShape = ShapeCache.generateElementShape( - element, - renderConfig, - ); - 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}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - node.setAttribute("stroke", "none"); - const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); - path.setAttribute("fill", element.strokeColor); - path.setAttribute("d", getFreeDrawSvgPath(element)); - node.appendChild(path); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "image": { - const width = Math.round(element.width); - const height = Math.round(element.height); - const fileData = - isInitializedImageElement(element) && files[element.fileId]; - if (fileData) { - const symbolId = `image-${fileData.id}`; - let symbol = svgRoot.querySelector(`#${symbolId}`); - if (!symbol) { - symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); - symbol.id = symbolId; - - const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); - - image.setAttribute("width", "100%"); - image.setAttribute("height", "100%"); - image.setAttribute("href", fileData.dataURL); - - symbol.appendChild(image); - - root.prepend(symbol); - } - - const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); - use.setAttribute("href", `#${symbolId}`); - - // in dark theme, revert the image color filter - if ( - renderConfig.exportWithDarkMode && - fileData.mimeType !== MIME_TYPES.svg - ) { - use.setAttribute("filter", IMAGE_INVERT_FILTER); - } - - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); - use.setAttribute("opacity", `${opacity}`); - - // We first apply `scale` transforms (horizontal/vertical mirroring) - // on the element, then apply translation and rotation - // on the element which wraps the . - // Doing this separately is a quick hack to to work around compositing - // the transformations correctly (the transform-origin was not being - // applied correctly). - if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; - use.setAttribute( - "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, - ); - } - - const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - g.appendChild(use); - g.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - if (element.roundness) { - const clipPath = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "clipPath", - ); - clipPath.id = `image-clipPath-${element.id}`; - - const clipRect = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - clipRect.setAttribute("width", `${element.width}`); - clipRect.setAttribute("height", `${element.height}`); - clipRect.setAttribute("rx", `${radius}`); - clipRect.setAttribute("ry", `${radius}`); - clipPath.appendChild(clipRect); - addToRoot(clipPath, element); - - g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); - } - - const clipG = maybeWrapNodesInFrameClipPath( - element, - root, - [g], - renderConfig.frameRendering, - elementsMap, - ); - addToRoot(clipG || g, element); - } - break; - } - // frames are not rendered and only acts as a container - case "frame": - case "magicframe": { - if ( - renderConfig.frameRendering.enabled && - renderConfig.frameRendering.outline - ) { - const rect = document.createElementNS(SVG_NS, "rect"); - - rect.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - rect.setAttribute("width", `${element.width}px`); - rect.setAttribute("height", `${element.height}px`); - // Rounded corners - rect.setAttribute("rx", FRAME_STYLE.radius.toString()); - rect.setAttribute("ry", FRAME_STYLE.radius.toString()); - - rect.setAttribute("fill", "none"); - rect.setAttribute("stroke", FRAME_STYLE.strokeColor); - rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); - - addToRoot(rect, element); - } - break; - } - default: { - if (isTextElement(element)) { - const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeightPx = getLineHeightInPx( - element.fontSize, - element.lineHeight, - ); - const horizontalOffset = - element.textAlign === "center" - ? element.width / 2 - : element.textAlign === "right" - ? element.width - : 0; - const direction = isRTL(element.text) ? "rtl" : "ltr"; - const textAnchor = - element.textAlign === "center" - ? "middle" - : element.textAlign === "right" || direction === "rtl" - ? "end" - : "start"; - for (let i = 0; i < lines.length; i++) { - const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); - text.textContent = lines[i]; - text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); - text.setAttribute("font-family", getFontFamilyString(element)); - text.setAttribute("font-size", `${element.fontSize}px`); - text.setAttribute("fill", element.strokeColor); - text.setAttribute("text-anchor", textAnchor); - text.setAttribute("style", "white-space: pre;"); - text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); - node.appendChild(text); - } - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - } else { - // @ts-ignore - throw new Error(`Unimplemented type ${element.type}`); - } - } - } -}; - export const pathsCache = new WeakMap([]); export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts new file mode 100644 index 000000000..c036226a0 --- /dev/null +++ b/packages/excalidraw/renderer/staticScene.ts @@ -0,0 +1,370 @@ +import { FRAME_STYLE } from "../constants"; +import { getElementAbsoluteCoords } from "../element"; + +import { + elementOverlapsWithFrame, + getTargetFrame, + isElementInFrame, +} from "../frame"; +import { + isEmbeddableElement, + isIframeLikeElement, +} from "../element/typeChecks"; +import { renderElement } from "../renderer/renderElement"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; +import { StaticCanvasAppState, Zoom } from "../types"; +import { + ElementsMap, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { + StaticCanvasRenderConfig, + StaticSceneRenderConfig, +} from "../scene/types"; +import { + EXTERNAL_LINK_IMG, + getLinkHandleFromCoords, +} from "../components/hyperlink/helpers"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { throttleRAF } from "../utils"; + +const strokeGrid = ( + context: CanvasRenderingContext2D, + gridSize: number, + scrollX: number, + scrollY: number, + zoom: Zoom, + width: number, + height: number, +) => { + const BOLD_LINE_FREQUENCY = 5; + + enum GridLineColor { + Bold = "#cccccc", + Regular = "#e5e5e5", + } + + const offsetX = + -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); + const offsetY = + -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); + + const lineWidth = Math.min(1 / zoom.value, 1); + + const spaceWidth = 1 / zoom.value; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.save(); + context.lineWidth = lineWidth; + + for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { + const isBold = + Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(x, offsetY - gridSize); + context.lineTo(x, offsetY + height + gridSize * 2); + context.stroke(); + } + for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { + const isBold = + Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(offsetX - gridSize, y); + context.lineTo(offsetX + width + gridSize * 2, y); + context.stroke(); + } + context.restore(); +}; + +const frameClip = ( + frame: ExcalidrawFrameLikeElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.beginPath(); + if (context.roundRect) { + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(0, 0, frame.width, frame.height); + } + context.clip(); + context.translate( + -(frame.x + appState.scrollX), + -(frame.y + appState.scrollY), + ); +}; + +let linkCanvasCache: any; +const renderLinkIcon = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, + elementsMap: ElementsMap, +) => { + if (element.link && !appState.selectedElementIds[element.id]) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y, width, height] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const centerX = x + width / 2; + const centerY = y + height / 2; + context.save(); + context.translate(appState.scrollX + centerX, appState.scrollY + centerY); + context.rotate(element.angle); + + if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { + linkCanvasCache = document.createElement("canvas"); + linkCanvasCache.zoom = appState.zoom.value; + linkCanvasCache.width = + width * window.devicePixelRatio * appState.zoom.value; + linkCanvasCache.height = + height * window.devicePixelRatio * appState.zoom.value; + const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; + linkCanvasCacheContext.scale( + window.devicePixelRatio * appState.zoom.value, + window.devicePixelRatio * appState.zoom.value, + ); + linkCanvasCacheContext.fillStyle = "#fff"; + linkCanvasCacheContext.fillRect(0, 0, width, height); + linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); + linkCanvasCacheContext.restore(); + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } else { + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } + context.restore(); + } +}; +const _renderStaticScene = ({ + canvas, + rc, + elementsMap, + allElementsMap, + visibleElements, + scale, + appState, + renderConfig, +}: StaticSceneRenderConfig) => { + if (canvas === null) { + return; + } + + const { renderGrid = true, isExporting } = renderConfig; + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme: appState.theme, + isExporting, + viewBackgroundColor: appState.viewBackgroundColor, + }); + + // Apply zoom + context.scale(appState.zoom.value, appState.zoom.value); + + // Grid + if (renderGrid && appState.gridSize) { + strokeGrid( + context, + appState.gridSize, + appState.scrollX, + appState.scrollY, + appState.zoom, + normalizedWidth / appState.zoom.value, + normalizedHeight / appState.zoom.value, + ); + } + + const groupsToBeAddedToFrame = new Set(); + + visibleElements.forEach((element) => { + if ( + element.groupIds.length > 0 && + appState.frameToHighlight && + appState.selectedElementIds[element.id] && + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || + element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) + ) { + element.groupIds.forEach((groupId) => + groupsToBeAddedToFrame.add(groupId), + ); + } + }); + + // Paint visible elements + visibleElements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + try { + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + // TODO do we need to check isElementInFrame here? + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + context.restore(); + } else { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + } catch (error: any) { + console.error(error); + } + }); + + // render embeddables on top + visibleElements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + try { + const render = () => { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + + if ( + isIframeLikeElement(element) && + (isExporting || + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && + element.width && + element.height + ) { + const label = createPlaceholderEmbeddableLabel(element); + renderElement( + label, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + }; + // - when exporting the whole canvas, we DO NOT apply clipping + // - when we are exporting a particular frame, apply clipping + // if the containing frame is not selected, apply clipping + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + render(); + context.restore(); + } else { + render(); + } + } catch (error: any) { + console.error(error); + } + }); +}; + +/** throttled to animation framerate */ +export const renderStaticSceneThrottled = throttleRAF( + (config: StaticSceneRenderConfig) => { + _renderStaticScene(config); + }, + { trailing: true }, +); + +/** + * Static scene is the non-ui canvas where we render elements. + */ +export const renderStaticScene = ( + renderConfig: StaticSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderStaticSceneThrottled(renderConfig); + return; + } + + _renderStaticScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 000000000..de026300e --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,653 @@ +import { Drawable } from "roughjs/bin/core"; +import { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, + getLineHeightInPx, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { getCornerRadius, isPathALoop } from "../math"; +import { ShapeCache } from "../scene/ShapeCache"; +import { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; + +const roughSVGDrawWithPrecision = ( + rsvg: RoughSVG, + drawable: Drawable, + precision?: number, +) => { + if (typeof precision === "undefined") { + return rsvg.draw(drawable); + } + const pshape: Drawable = { + sets: drawable.sets, + shape: drawable.shape, + options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, + }; + return rsvg.draw(pshape); +}; + +const maybeWrapNodesInFrameClipPath = ( + element: NonDeletedExcalidrawElement, + root: SVGElement, + nodes: SVGElement[], + frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, +) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } + const frame = getContainingFrame(element, elementsMap); + if (frame) { + const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); + g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); + nodes.forEach((node) => g.appendChild(node)); + return g; + } + + return null; +}; + +const renderElementToSvg = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + offsetX: number, + offsetY: number, + renderConfig: SVGRenderConfig, +) => { + const offset = { x: offsetX, y: offsetY }; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } + const degree = (180 * element.angle) / Math.PI; + + // element to append node to, most of the time svgRoot + let root = svgRoot; + + // if the element has a link, create an anchor tag and make that the new root + if (element.link) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link)); + root.appendChild(anchorTag); + root = anchorTag; + } + + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + + const opacity = + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; + + switch (element.type) { + case "selection": { + // Since this is used only during editing experience, which is canvas based, + // this should not happen + throw new Error("Selection rendering is not supported for SVG"); + } + case "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "iframe": + case "embeddable": { + // render placeholder rectangle + const shape = ShapeCache.generateElementShape(element, renderConfig); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + const opacity = element.opacity / 100; + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + addToRoot(node, element); + + const label: ExcalidrawElement = + createPlaceholderEmbeddableLabel(element); + renderElementToSvg( + label, + elementsMap, + rsvg, + root, + files, + label.x + offset.x - element.x, + label.y + offset.y - element.y, + renderConfig, + ); + + // render embeddable element + iframe + const embeddableNode = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + embeddableNode.setAttribute("stroke-linecap", "round"); + embeddableNode.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + while (embeddableNode.firstChild) { + embeddableNode.removeChild(embeddableNode.firstChild); + } + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + const embedLink = getEmbedLink(toValidURL(element.link || "")); + + // if rendering embeddables explicitly disabled or + // embedding documents via srcdoc (which doesn't seem to work for SVGs) + // replace with a link instead + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link || "")); + anchorTag.setAttribute("target", "_blank"); + anchorTag.setAttribute("rel", "noopener noreferrer"); + anchorTag.style.borderRadius = `${radius}px`; + + embeddableNode.appendChild(anchorTag); + } else { + const foreignObject = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "foreignObject", + ); + foreignObject.style.width = `${element.width}px`; + foreignObject.style.height = `${element.height}px`; + foreignObject.style.border = "none"; + const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); + div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + div.style.width = "100%"; + div.style.height = "100%"; + const iframe = div.ownerDocument!.createElement("iframe"); + iframe.src = embedLink?.link ?? ""; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = `${radius}px`; + iframe.style.top = "0"; + iframe.style.left = "0"; + iframe.allowFullscreen = true; + div.appendChild(iframe); + foreignObject.appendChild(div); + + embeddableNode.appendChild(foreignObject); + } + addToRoot(embeddableNode, element); + break; + } + case "line": + case "arrow": { + const boundText = getBoundTextElement(element, elementsMap); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + elementsMap, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } + const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } + group.setAttribute("stroke-linecap", "round"); + + const shapes = ShapeCache.generateElementShape(element, renderConfig); + shapes.forEach((shape) => { + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + if ( + element.type === "line" && + isPathALoop(element.points) && + element.backgroundColor !== "transparent" + ) { + node.setAttribute("fill-rule", "evenodd"); + } + group.appendChild(node); + }); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [group, maskPath], + renderConfig.frameRendering, + elementsMap, + ); + if (g) { + addToRoot(g, element); + root.appendChild(g); + } else { + addToRoot(group, element); + root.append(maskPath); + } + break; + } + case "freedraw": { + const backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + 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}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if (fileData) { + const symbolId = `image-${fileData.id}`; + let symbol = svgRoot.querySelector(`#${symbolId}`); + if (!symbol) { + symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); + symbol.id = symbolId; + + const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); + + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + image.setAttribute("href", fileData.dataURL); + + symbol.appendChild(image); + + root.prepend(symbol); + } + + const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); + use.setAttribute("href", `#${symbolId}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + use.setAttribute("width", `${width}`); + use.setAttribute("height", `${height}`); + use.setAttribute("opacity", `${opacity}`); + + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the element, then apply translation and rotation + // on the element which wraps the . + // Doing this separately is a quick hack to to work around compositing + // the transformations correctly (the transform-origin was not being + // applied correctly). + if (element.scale[0] !== 1 || element.scale[1] !== 1) { + const translateX = element.scale[0] !== 1 ? -width : 0; + const translateY = element.scale[1] !== 1 ? -height : 0; + use.setAttribute( + "transform", + `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + g.appendChild(use); + g.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + + const clipG = maybeWrapNodesInFrameClipPath( + element, + root, + [g], + renderConfig.frameRendering, + elementsMap, + ); + addToRoot(clipG || g, element); + } + break; + } + // frames are not rendered and only acts as a container + case "frame": + case "magicframe": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } + break; + } + default: { + if (isTextElement(element)) { + const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + const direction = isRTL(element.text) ? "rtl" : "ltr"; + const textAnchor = + element.textAlign === "center" + ? "middle" + : element.textAlign === "right" || direction === "rtl" + ? "end" + : "start"; + for (let i = 0; i < lines.length; i++) { + const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); + text.textContent = lines[i]; + text.setAttribute("x", `${horizontalOffset}`); + text.setAttribute("y", `${i * lineHeightPx}`); + text.setAttribute("font-family", getFontFamilyString(element)); + text.setAttribute("font-size", `${element.fontSize}px`); + text.setAttribute("fill", element.strokeColor); + text.setAttribute("text-anchor", textAnchor); + text.setAttribute("style", "white-space: pre;"); + text.setAttribute("direction", direction); + text.setAttribute("dominant-baseline", "text-before-edge"); + node.appendChild(text); + } + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + } else { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const renderSceneToSvg = ( + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + renderConfig: SVGRenderConfig, +) => { + if (!svgRoot) { + return; + } + + // render elements + elements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); + + // render embeddables on top + elements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); +}; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 0875b9f05..7970f8c1c 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -4,7 +4,9 @@ import { NonDeletedElementsMap, NonDeletedExcalidrawElement, } from "../element/types"; -import { cancelRender } from "../renderer/renderScene"; +import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; +import { renderStaticSceneThrottled } from "../renderer/staticScene"; + import { AppState } from "../types"; import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; @@ -147,7 +149,8 @@ export class Renderer { // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // safe to break TS contract here (for upstream cases) public destroy() { - cancelRender(); + renderInteractiveSceneThrottled.cancel(); + renderStaticSceneThrottled.cancel(); this.getRenderableElements.clear(); } } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 42a417cc8..8733c997e 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -11,7 +11,7 @@ import { getCommonBounds, getElementAbsoluteCoords, } from "../element/bounds"; -import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; +import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { @@ -38,6 +38,7 @@ import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { RenderableElementsMap } from "./types"; +import { renderStaticScene } from "../renderer/staticScene"; const SVG_EXPORT_TAG = ``; diff --git a/packages/excalidraw/tests/App.test.tsx b/packages/excalidraw/tests/App.test.tsx index 316d274ef..9fb055453 100644 --- a/packages/excalidraw/tests/App.test.tsx +++ b/packages/excalidraw/tests/App.test.tsx @@ -1,12 +1,12 @@ import ReactDOM from "react-dom"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { render, queryByTestId } from "../tests/test-utils"; import { Excalidraw } from "../index"; import { vi } from "vitest"; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); describe("Test ", () => { beforeEach(async () => { diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx index 8c413d003..f034dbd8c 100644 --- a/packages/excalidraw/tests/contextmenu.test.tsx +++ b/packages/excalidraw/tests/contextmenu.test.tsx @@ -12,7 +12,7 @@ import { togglePopover, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; @@ -39,7 +39,7 @@ const mouse = new Pointer("mouse"); // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); renderStaticScene.mockClear(); diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index a34696d81..7bde27b1c 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -1,6 +1,7 @@ import ReactDOM from "react-dom"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveScene from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { render, @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveScene, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 6c01987c9..551b79479 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -8,7 +8,9 @@ import { import { Excalidraw } from "../index"; import { centerPoint } from "../math"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; + import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; @@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const { h } = window; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 06086f119..8a0e562be 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,7 +1,8 @@ import ReactDOM from "react-dom"; import { render, fireEvent } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { reseed } from "../random"; import { bindOrUnbindLinearElement } from "../element/binding"; import { @@ -16,8 +17,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index f462cfacf..bc8c7843d 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -6,7 +6,8 @@ import { restoreOriginalGetBoundingClientRect, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { ExcalidrawLinearElement } from "../element/types"; import { reseed } from "../random"; @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 22f2c0159..e15a12ed2 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types"; import { CODES, KEYS } from "../keys"; import { Excalidraw } from "../index"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { setDateTimeForTests } from "../utils"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; @@ -19,7 +19,7 @@ import { vi } from "vitest"; const { h } = window; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const mouse = new Pointer("mouse"); const finger1 = new Pointer("touch", 1); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 54f244ece..18e0dfe2a 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -7,7 +7,8 @@ import { assertSelectedElements, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { reseed } from "../random"; import { API } from "./helpers/api"; @@ -18,8 +19,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear();