import { ExcalidrawElement, ExcalidrawTextElement, NonDeletedExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawImageElement, ExcalidrawTextElementWithContainer, ExcalidrawFrameLikeElement, NonDeletedSceneElementsMap, ElementsMap, } from "../element/types"; import { isTextElement, isLinearElement, isFreeDrawElement, isInitializedImageElement, isArrowElement, hasBoundTextElement, isMagicFrameElement, } 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 rough from "roughjs/bin/rough"; import { AppState, StaticCanvasAppState, BinaryFiles, Zoom, InteractiveCanvasAppState, ElementsPendingErasure, } from "../types"; import { getDefaultAppState } from "../appState"; 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 { getBoundTextElement, getContainerCoords, getContainerElement, getLineHeightInPx, getBoundTextMaxHeight, 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)"; const defaultAppState = getDefaultAppState(); const isPendingImageElement = ( element: ExcalidrawElement, renderConfig: StaticCanvasRenderConfig, ) => isInitializedImageElement(element) && !renderConfig.imageCache.has(element.fileId); const shouldResetImageFilter = ( element: ExcalidrawElement, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { return ( appState.theme === "dark" && isInitializedImageElement(element) && !isPendingImageElement(element, renderConfig) && renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg ); }; const getCanvasPadding = (element: ExcalidrawElement) => element.type === "freedraw" ? element.strokeWidth * 12 : 20; export const getRenderOpacity = ( element: ExcalidrawElement, containingFrame: ExcalidrawFrameLikeElement | null, elementsPendingErasure: ElementsPendingErasure, ) => { // multiplying frame opacity with element opacity to combine them // (e.g. frame 50% and element 50% opacity should result in 25% opacity) let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000; // if pending erasure, multiply again to combine further // (so that erasing always results in lower opacity than original) if ( elementsPendingErasure.has(element.id) || (containingFrame && elementsPendingErasure.has(containingFrame.id)) ) { opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; } return opacity; }; export interface ExcalidrawElementWithCanvas { element: ExcalidrawElement | ExcalidrawTextElement; canvas: HTMLCanvasElement; theme: AppState["theme"]; scale: number; zoomValue: AppState["zoom"]["value"]; canvasOffsetX: number; canvasOffsetY: number; boundTextElementVersion: number | null; containingFrameOpacity: number; } const cappedElementCanvasSize = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, zoom: Zoom, ): { width: number; height: number; scale: number; } => { // these limits are ballpark, they depend on specific browsers and device. // We've chosen lower limits to be safe. We might want to change these limits // based on browser/device type, if we get reports of low quality rendering // on zoom. // // ~ safari mobile canvas area limit const AREA_LIMIT = 16777216; // ~ safari width/height limit based on developer.mozilla.org. const WIDTH_HEIGHT_LIMIT = 32767; const padding = getCanvasPadding(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementWidth = isLinearElement(element) || isFreeDrawElement(element) ? distance(x1, x2) : element.width; const elementHeight = isLinearElement(element) || isFreeDrawElement(element) ? distance(y1, y2) : element.height; let width = elementWidth * window.devicePixelRatio + padding * 2; let height = elementHeight * window.devicePixelRatio + padding * 2; let scale: number = zoom.value; // rescale to ensure width and height is within limits if ( width * scale > WIDTH_HEIGHT_LIMIT || height * scale > WIDTH_HEIGHT_LIMIT ) { scale = Math.min(WIDTH_HEIGHT_LIMIT / width, WIDTH_HEIGHT_LIMIT / height); } // rescale to ensure canvas area is within limits if (width * height * scale * scale > AREA_LIMIT) { scale = Math.sqrt(AREA_LIMIT / (width * height)); } width = Math.floor(width * scale); height = Math.floor(height * scale); return { width, height, scale }; }; const generateElementCanvas = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ): ExcalidrawElementWithCanvas => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d")!; const padding = getCanvasPadding(element); const { width, height, scale } = cappedElementCanvasSize( element, elementsMap, zoom, ); canvas.width = width; canvas.height = height; let canvasOffsetX = 0; let canvasOffsetY = 0; if (isLinearElement(element) || isFreeDrawElement(element)) { const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); canvasOffsetX = element.x > x1 ? distance(element.x, x1) * window.devicePixelRatio * scale : 0; canvasOffsetY = element.y > y1 ? distance(element.y, y1) * window.devicePixelRatio * scale : 0; context.translate(canvasOffsetX, canvasOffsetY); } context.save(); context.translate(padding * scale, padding * scale); context.scale( window.devicePixelRatio * scale, window.devicePixelRatio * scale, ); const rc = rough.canvas(canvas); // in dark theme, revert the image color filter if (shouldResetImageFilter(element, renderConfig, appState)) { context.filter = IMAGE_INVERT_FILTER; } drawElementOnCanvas(element, rc, context, renderConfig, appState); context.restore(); return { element, canvas, theme: appState.theme, scale, zoomValue: zoom.value, canvasOffsetX, canvasOffsetY, boundTextElementVersion: getBoundTextElement(element, elementsMap)?.version || null, containingFrameOpacity: getContainingFrame(element, elementsMap)?.opacity || 100, }; }; export const DEFAULT_LINK_SIZE = 14; const IMAGE_PLACEHOLDER_IMG = document.createElement("img"); IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( ``, )}`; const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img"); IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( ``, )}`; const drawImagePlaceholder = ( element: ExcalidrawImageElement, context: CanvasRenderingContext2D, zoomValue: AppState["zoom"]["value"], ) => { context.fillStyle = "#E7E7E7"; context.fillRect(0, 0, element.width, element.height); const imageMinWidthOrHeight = Math.min(element.width, element.height); const size = Math.min( imageMinWidthOrHeight, Math.min(imageMinWidthOrHeight * 0.4, 100), ); context.drawImage( element.status === "error" ? IMAGE_ERROR_PLACEHOLDER_IMG : IMAGE_PLACEHOLDER_IMG, element.width / 2 - size / 2, element.height / 2 - size / 2, size, size, ); }; const drawElementOnCanvas = ( element: NonDeletedExcalidrawElement, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { switch (element.type) { case "rectangle": case "iframe": case "embeddable": case "diamond": case "ellipse": { context.lineJoin = "round"; context.lineCap = "round"; rc.draw(ShapeCache.get(element)!); break; } case "arrow": case "line": { context.lineJoin = "round"; context.lineCap = "round"; ShapeCache.get(element)!.forEach((shape) => { rc.draw(shape); }); break; } case "freedraw": { // Draw directly to canvas context.save(); context.fillStyle = element.strokeColor; const path = getFreeDrawPath2D(element) as Path2D; const fillShape = ShapeCache.get(element); if (fillShape) { rc.draw(fillShape); } context.fillStyle = element.strokeColor; context.fill(path); context.restore(); break; } case "image": { const img = isInitializedImageElement(element) ? renderConfig.imageCache.get(element.fileId)?.image : undefined; if (img != null && !(img instanceof Promise)) { if (element.roundness && context.roundRect) { context.beginPath(); context.roundRect( 0, 0, element.width, element.height, getCornerRadius(Math.min(element.width, element.height), element), ); context.clip(); } context.drawImage( img, 0 /* hardcoded for the selection box*/, 0, element.width, element.height, ); } else { drawImagePlaceholder(element, context, appState.zoom.value); } break; } default: { if (isTextElement(element)) { const rtl = isRTL(element.text); const shouldTemporarilyAttach = rtl && !context.canvas.isConnected; if (shouldTemporarilyAttach) { // to correctly render RTL text mixed with LTR, we have to append it // to the DOM document.body.appendChild(context.canvas); } context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.save(); context.font = getFontString(element); context.fillStyle = element.strokeColor; context.textAlign = element.textAlign as CanvasTextAlign; // Canvas does not support multiline text by default const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); const horizontalOffset = element.textAlign === "center" ? element.width / 2 : element.textAlign === "right" ? element.width : 0; const lineHeightPx = getLineHeightInPx( element.fontSize, element.lineHeight, ); const verticalOffset = element.height - element.baseline; for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, (index + 1) * lineHeightPx - verticalOffset, ); } context.restore(); if (shouldTemporarilyAttach) { context.canvas.remove(); } } else { throw new Error(`Unimplemented type ${element.type}`); } } } }; export const elementWithCanvasCache = new WeakMap< ExcalidrawElement, ExcalidrawElementWithCanvas >(); const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom; const prevElementWithCanvas = elementWithCanvasCache.get(element); const shouldRegenerateBecauseZoom = prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; const boundTextElementVersion = getBoundTextElement(element, elementsMap)?.version || null; const containingFrameOpacity = getContainingFrame(element, elementsMap)?.opacity || 100; if ( !prevElementWithCanvas || shouldRegenerateBecauseZoom || prevElementWithCanvas.theme !== appState.theme || prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity ) { const elementWithCanvas = generateElementCanvas( element, elementsMap, zoom, renderConfig, appState, ); elementWithCanvasCache.set(element, elementWithCanvas); return elementWithCanvas; } return prevElementWithCanvas; }; const drawElementFromCanvas = ( elementWithCanvas: ExcalidrawElementWithCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, allElementsMap: NonDeletedSceneElementsMap, ) => { const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); // Free draw elements will otherwise "shuffle" as the min x and y change if (isFreeDrawElement(element)) { x1 = Math.floor(x1); x2 = Math.ceil(x2); y1 = Math.floor(y1); y2 = Math.ceil(y2); } const cx = ((x1 + x2) / 2 + appState.scrollX) * window.devicePixelRatio; const cy = ((y1 + y2) / 2 + appState.scrollY) * window.devicePixelRatio; context.save(); context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); const tempCanvasContext = tempCanvas.getContext("2d")!; // Take max dimensions of arrow canvas so that when canvas is rotated // the arrow doesn't get clipped const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); tempCanvas.width = maxDim * window.devicePixelRatio * zoom + padding * elementWithCanvas.scale * 10; tempCanvas.height = maxDim * window.devicePixelRatio * zoom + padding * elementWithCanvas.scale * 10; const offsetX = (tempCanvas.width - elementWithCanvas.canvas!.width) / 2; const offsetY = (tempCanvas.height - elementWithCanvas.canvas!.height) / 2; tempCanvasContext.translate(tempCanvas.width / 2, tempCanvas.height / 2); tempCanvasContext.rotate(element.angle); tempCanvasContext.drawImage( elementWithCanvas.canvas!, -elementWithCanvas.canvas.width / 2, -elementWithCanvas.canvas.height / 2, elementWithCanvas.canvas.width, elementWithCanvas.canvas.height, ); const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( boundTextElement, allElementsMap, ); tempCanvasContext.rotate(-element.angle); // Shift the canvas to the center of the bound text element const shiftX = tempCanvas.width / 2 - (boundTextCx - x1) * window.devicePixelRatio * zoom - offsetX - padding * zoom; const shiftY = tempCanvas.height / 2 - (boundTextCy - y1) * window.devicePixelRatio * zoom - offsetY - padding * zoom; tempCanvasContext.translate(-shiftX, -shiftY); // Clear the bound text area tempCanvasContext.clearRect( -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * window.devicePixelRatio * zoom, -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * window.devicePixelRatio * zoom, (boundTextElement.width + BOUND_TEXT_PADDING * 2) * window.devicePixelRatio * zoom, (boundTextElement.height + BOUND_TEXT_PADDING * 2) * window.devicePixelRatio * zoom, ); context.translate(cx, cy); context.drawImage( tempCanvas, (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, tempCanvas.width / zoom, tempCanvas.height / zoom, ); } else { // we translate context to element center so that rotation and scale // originates from the element center context.translate(cx, cy); context.rotate(element.angle); if ( "scale" in elementWithCanvas.element && !isPendingImageElement(element, renderConfig) ) { context.scale( elementWithCanvas.element.scale[0], elementWithCanvas.element.scale[1], ); } // revert afterwards we don't have account for it during drawing context.translate(-cx, -cy); context.drawImage( elementWithCanvas.canvas!, (x1 + appState.scrollX) * window.devicePixelRatio - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, (y1 + appState.scrollY) * window.devicePixelRatio - (padding * elementWithCanvas.scale) / elementWithCanvas.scale, elementWithCanvas.canvas!.width / elementWithCanvas.scale, elementWithCanvas.canvas!.height / elementWithCanvas.scale, ); if ( import.meta.env.VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX === "true" && hasBoundTextElement(element) ) { const textElement = getBoundTextElement( element, allElementsMap, ) as ExcalidrawTextElementWithContainer; const coords = getContainerCoords(element); context.strokeStyle = "#c92a2a"; context.lineWidth = 3; context.strokeRect( (coords.x + appState.scrollX) * window.devicePixelRatio, (coords.y + appState.scrollY) * window.devicePixelRatio, getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, ); } } context.restore(); // Clear the nested element we appended to the DOM }; export const renderSelectionElement = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, ) => { context.save(); context.translate(element.x + appState.scrollX, element.y + appState.scrollY); context.fillStyle = "rgba(0, 0, 200, 0.04)"; // render from 0.5px offset to get 1px wide line // https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540 // TODO can be be improved by offseting to the negative when user selects // from right to left const offset = 0.5 / appState.zoom.value; context.fillRect(offset, offset, element.width, element.height); context.lineWidth = 1 / appState.zoom.value; context.strokeStyle = " rgb(105, 101, 219)"; context.strokeRect(offset, offset, element.width, element.height); context.restore(); }; export const renderElement = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, allElementsMap: NonDeletedSceneElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { context.globalAlpha = getRenderOpacity( element, getContainingFrame(element, elementsMap), renderConfig.elementsPendingErasure, ); switch (element.type) { case "magicframe": case "frame": { if (appState.frameRendering.enabled && appState.frameRendering.outline) { context.save(); context.translate( element.x + appState.scrollX, element.y + appState.scrollY, ); context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = FRAME_STYLE.strokeColor; // TODO change later to only affect AI frames if (isMagicFrameElement(element)) { context.strokeStyle = appState.theme === "light" ? "#7affd7" : "#1d8264"; } if (FRAME_STYLE.radius && context.roundRect) { context.beginPath(); context.roundRect( 0, 0, element.width, element.height, FRAME_STYLE.radius / appState.zoom.value, ); context.stroke(); context.closePath(); } else { context.strokeRect(0, 0, element.width, element.height); } context.restore(); } break; } case "freedraw": { // TODO investigate if we can do this in situ. Right now we need to call // beforehand because math helpers (such as getElementAbsoluteCoords) // rely on existing shapes ShapeCache.generateElementShape(element, null); if (renderConfig.isExporting) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); const shiftY = (y2 - y1) / 2 - (element.y - y1); context.save(); context.translate(cx, cy); context.rotate(element.angle); context.translate(-shiftX, -shiftY); drawElementOnCanvas(element, rc, context, renderConfig, appState); context.restore(); } else { const elementWithCanvas = generateElementWithCanvas( element, elementsMap, renderConfig, appState, ); drawElementFromCanvas( elementWithCanvas, context, renderConfig, appState, allElementsMap, ); } break; } case "rectangle": case "diamond": case "ellipse": case "line": case "arrow": case "image": case "text": case "iframe": case "embeddable": { // TODO investigate if we can do this in situ. Right now we need to call // beforehand because math helpers (such as getElementAbsoluteCoords) // rely on existing shapes ShapeCache.generateElementShape(element, renderConfig); if (renderConfig.isExporting) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftY = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, elementsMap, ); shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); } } context.save(); context.translate(cx, cy); if (shouldResetImageFilter(element, renderConfig, appState)) { context.filter = "none"; } const boundTextElement = getBoundTextElement(element, elementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); const tempCanvasContext = tempCanvas.getContext("2d")!; // Take max dimensions of arrow canvas so that when canvas is rotated // the arrow doesn't get clipped const maxDim = Math.max(distance(x1, x2), distance(y1, y2)); const padding = getCanvasPadding(element); tempCanvas.width = maxDim * appState.exportScale + padding * 10 * appState.exportScale; tempCanvas.height = maxDim * appState.exportScale + padding * 10 * appState.exportScale; tempCanvasContext.translate( tempCanvas.width / 2, tempCanvas.height / 2, ); tempCanvasContext.scale(appState.exportScale, appState.exportScale); // Shift the canvas to left most point of the arrow shiftX = element.width / 2 - (element.x - x1); shiftY = element.height / 2 - (element.y - y1); tempCanvasContext.rotate(element.angle); const tempRc = rough.canvas(tempCanvas); tempCanvasContext.translate(-shiftX, -shiftY); drawElementOnCanvas( element, tempRc, tempCanvasContext, renderConfig, appState, ); tempCanvasContext.translate(shiftX, shiftY); tempCanvasContext.rotate(-element.angle); // Shift the canvas to center of bound text const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( boundTextElement, elementsMap, ); const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); // Clear the bound text area tempCanvasContext.clearRect( -boundTextElement.width / 2, -boundTextElement.height / 2, boundTextElement.width, boundTextElement.height, ); context.scale(1 / appState.exportScale, 1 / appState.exportScale); context.drawImage( tempCanvas, -tempCanvas.width / 2, -tempCanvas.height / 2, tempCanvas.width, tempCanvas.height, ); } else { context.rotate(element.angle); if (element.type === "image") { // note: scale must be applied *after* rotating context.scale(element.scale[0], element.scale[1]); } context.translate(-shiftX, -shiftY); drawElementOnCanvas(element, rc, context, renderConfig, appState); } context.restore(); // not exporting → optimized rendering (cache & render from element // canvases) } else { const elementWithCanvas = generateElementWithCanvas( element, elementsMap, renderConfig, appState, ); const currentImageSmoothingStatus = context.imageSmoothingEnabled; if ( // do not disable smoothing during zoom as blurry shapes look better // on low resolution (while still zooming in) than sharp ones !appState?.shouldCacheIgnoreZoom && // angle is 0 -> always disable smoothing (!element.angle || // or check if angle is a right angle in which case we can still // disable smoothing without adversely affecting the result isRightAngle(element.angle)) ) { // Disabling smoothing makes output much sharper, especially for // text. Unless for non-right angles, where the aliasing is really // terrible on Chromium. // // Note that `context.imageSmoothingQuality="high"` has almost // zero effect. // context.imageSmoothingEnabled = false; } drawElementFromCanvas( elementWithCanvas, context, renderConfig, appState, allElementsMap, ); // reset context.imageSmoothingEnabled = currentImageSmoothingStatus; } break; } default: { // @ts-ignore throw new Error(`Unimplemented type ${element.type}`); } } 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) { const svgPathData = getFreeDrawSvgPath(element); const path = new Path2D(svgPathData); pathsCache.set(element, path); return path; } export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { return pathsCache.get(element); } export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { // If input points are empty (should they ever be?) return a dot const inputPoints = element.simulatePressure ? element.points : element.points.length ? element.points.map(([x, y], i) => [x, y, element.pressures[i]]) : [[0, 0, 0.5]]; // Consider changing the options for simulated pressure vs real pressure const options: StrokeOptions = { simulatePressure: element.simulatePressure, size: element.strokeWidth * 4.25, thinning: 0.6, smoothing: 0.5, streamline: 0.5, easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup }; return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); } function med(A: number[], B: number[]) { return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; } // Trim SVG path data so number are each two decimal points. This // improves SVG exports, and prevents rendering errors on points // with long decimals. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g; function getSvgPathFromStroke(points: number[][]): string { if (!points.length) { return ""; } const max = points.length - 1; return points .reduce( (acc, point, i, arr) => { if (i === max) { acc.push(point, med(point, arr[0]), "L", arr[0], "Z"); } else { acc.push(point, med(point, arr[i + 1])); } return acc; }, ["M", points[0], "Q"], ) .join(" ") .replace(TO_FIXED_PRECISION, "$1"); }