mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-30 18:34:22 +01:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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<HTMLDivElement>; | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
							
								
								
									
										75
									
								
								packages/excalidraw/renderer/helpers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								packages/excalidraw/renderer/helpers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| }; | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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 <use> element, then apply translation and rotation | ||||
|         // on the <g> element which wraps the <use>. | ||||
|         // 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<ExcalidrawFreeDrawElement, Path2D>([]); | ||||
|  | ||||
| export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { | ||||
|   | ||||
							
								
								
									
										370
									
								
								packages/excalidraw/renderer/staticScene.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										370
									
								
								packages/excalidraw/renderer/staticScene.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string>(); | ||||
|  | ||||
|   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); | ||||
| }; | ||||
							
								
								
									
										653
									
								
								packages/excalidraw/renderer/staticSvgScene.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										653
									
								
								packages/excalidraw/renderer/staticSvgScene.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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 <use> element, then apply translation and rotation | ||||
|         // on the <g> element which wraps the <use>. | ||||
|         // 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); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
| }; | ||||
| @@ -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(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 = `<!-- svg-source:excalidraw -->`; | ||||
|  | ||||
|   | ||||
| @@ -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 <App/>", () => { | ||||
|   beforeEach(async () => { | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Aakansha Doshi
					Aakansha Doshi