diff options
Diffstat (limited to 'packages/excalidraw/renderer/renderElement.ts')
| -rw-r--r-- | packages/excalidraw/renderer/renderElement.ts | 1070 |
1 files changed, 1070 insertions, 0 deletions
diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts new file mode 100644 index 0000000..d93469c --- /dev/null +++ b/packages/excalidraw/renderer/renderElement.ts @@ -0,0 +1,1070 @@ +import type { + ExcalidrawElement, + ExcalidrawTextElement, + NonDeletedExcalidrawElement, + ExcalidrawFreeDrawElement, + ExcalidrawImageElement, + ExcalidrawTextElementWithContainer, + ExcalidrawFrameLikeElement, + NonDeletedSceneElementsMap, + ElementsMap, +} from "../element/types"; +import { + isTextElement, + isLinearElement, + isFreeDrawElement, + isInitializedImageElement, + isArrowElement, + hasBoundTextElement, + isMagicFrameElement, + isImageElement, +} from "../element/typeChecks"; +import { getElementAbsoluteCoords } from "../element/bounds"; +import type { RoughCanvas } from "roughjs/bin/canvas"; + +import type { + StaticCanvasRenderConfig, + RenderableElementsMap, + InteractiveCanvasRenderConfig, +} from "../scene/types"; +import { distance, getFontString, isRTL } from "../utils"; +import rough from "roughjs/bin/rough"; +import type { + AppState, + StaticCanvasAppState, + Zoom, + InteractiveCanvasAppState, + ElementsPendingErasure, + PendingExcalidrawElements, +} from "../types"; +import { getDefaultAppState } from "../appState"; +import { + BOUND_TEXT_PADDING, + DEFAULT_REDUCED_GLOBAL_ALPHA, + ELEMENT_READY_TO_ERASE_OPACITY, + FRAME_STYLE, + MIME_TYPES, + THEME, +} from "../constants"; +import type { StrokeOptions } from "perfect-freehand"; +import { getStroke } from "perfect-freehand"; +import { + getBoundTextElement, + getContainerCoords, + getContainerElement, + getBoundTextMaxHeight, + getBoundTextMaxWidth, +} from "../element/textElement"; +import { LinearElementEditor } from "../element/linearElementEditor"; + +import { getContainingFrame } from "../frame"; +import { ShapeCache } from "../scene/ShapeCache"; +import { getVerticalOffset } from "../fonts"; +import { isRightAngleRads } from "@excalidraw/math"; +import { getCornerRadius } from "../shapes"; +import { getUncroppedImageElement } from "../element/cropElement"; +import { getLineHeightInPx } from "../element/textMeasurements"; + +// 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...) +export 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 === THEME.DARK && + isInitializedImageElement(element) && + !isPendingImageElement(element, renderConfig) && + renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg + ); +}; + +const getCanvasPadding = (element: ExcalidrawElement) => { + switch (element.type) { + case "freedraw": + return element.strokeWidth * 12; + case "text": + return element.fontSize / 2; + default: + return 20; + } +}; + +export const getRenderOpacity = ( + element: ExcalidrawElement, + containingFrame: ExcalidrawFrameLikeElement | null, + elementsPendingErasure: ElementsPendingErasure, + pendingNodes: Readonly<PendingExcalidrawElements> | null, + globalAlpha: number = 1, +) => { + // 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) * + globalAlpha; + + // if pending erasure, multiply again to combine further + // (so that erasing always results in lower opacity than original) + if ( + elementsPendingErasure.has(element.id) || + (pendingNodes && pendingNodes.some((node) => node.id === 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; + angle: number; + zoomValue: AppState["zoom"]["value"]; + canvasOffsetX: number; + canvasOffsetY: number; + boundTextElementVersion: number | null; + imageCrop: ExcalidrawImageElement["crop"] | null; + containingFrameOpacity: number; + boundTextCanvas: HTMLCanvasElement; +} + +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: NonDeletedSceneElementsMap, + zoom: Zoom, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +): ExcalidrawElementWithCanvas | null => { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + const padding = getCanvasPadding(element); + + const { width, height, scale } = cappedElementCanvasSize( + element, + elementsMap, + zoom, + ); + + if (!width || !height) { + return null; + } + + canvas.width = width; + canvas.height = height; + + let canvasOffsetX = -100; + 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(); + + const boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextCanvas = document.createElement("canvas"); + const boundTextCanvasContext = boundTextCanvas.getContext("2d")!; + + if (isArrowElement(element) && boundTextElement) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + // 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)); + boundTextCanvas.width = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvas.height = + maxDim * window.devicePixelRatio * scale + padding * scale * 10; + boundTextCanvasContext.translate( + boundTextCanvas.width / 2, + boundTextCanvas.height / 2, + ); + boundTextCanvasContext.rotate(element.angle); + boundTextCanvasContext.drawImage( + canvas!, + -canvas.width / 2, + -canvas.height / 2, + canvas.width, + canvas.height, + ); + + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); + + boundTextCanvasContext.rotate(-element.angle); + const offsetX = (boundTextCanvas.width - canvas!.width) / 2; + const offsetY = (boundTextCanvas.height - canvas!.height) / 2; + const shiftX = + boundTextCanvas.width / 2 - + (boundTextCx - x1) * window.devicePixelRatio * scale - + offsetX - + padding * scale; + + const shiftY = + boundTextCanvas.height / 2 - + (boundTextCy - y1) * window.devicePixelRatio * scale - + offsetY - + padding * scale; + boundTextCanvasContext.translate(-shiftX, -shiftY); + // Clear the bound text area + boundTextCanvasContext.clearRect( + -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) * + window.devicePixelRatio * + scale, + (boundTextElement.width + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + (boundTextElement.height + BOUND_TEXT_PADDING * 2) * + window.devicePixelRatio * + scale, + ); + } + + return { + element, + canvas, + theme: appState.theme, + scale, + zoomValue: zoom.value, + canvasOffsetX, + canvasOffsetY, + boundTextElementVersion: + getBoundTextElement(element, elementsMap)?.version || null, + containingFrameOpacity: + getContainingFrame(element, elementsMap)?.opacity || 100, + boundTextCanvas, + angle: element.angle, + imageCrop: isImageElement(element) ? element.crop : null, + }; +}; + +export const DEFAULT_LINK_SIZE = 14; + +const IMAGE_PLACEHOLDER_IMG = document.createElement("img"); +IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( + `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`, +)}`; + +const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img"); +IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( + `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`, +)}`; + +const drawImagePlaceholder = ( + element: ExcalidrawImageElement, + context: CanvasRenderingContext2D, +) => { + 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(); + } + + const { x, y, width, height } = element.crop + ? element.crop + : { + x: 0, + y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + }; + + context.drawImage( + img, + x, + y, + width, + height, + 0 /* hardcoded for the selection box*/, + 0, + element.width, + element.height, + ); + } else { + drawImagePlaceholder(element, context); + } + 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 = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + + for (let index = 0; index < lines.length; index++) { + context.fillText( + lines[index], + horizontalOffset, + index * 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: NonDeletedSceneElementsMap, + 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 boundTextElement = getBoundTextElement(element, elementsMap); + const boundTextElementVersion = boundTextElement?.version || null; + const imageCrop = isImageElement(element) ? element.crop : null; + + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; + + if ( + !prevElementWithCanvas || + shouldRegenerateBecauseZoom || + prevElementWithCanvas.theme !== appState.theme || + prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion || + prevElementWithCanvas.imageCrop !== imageCrop || + prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity || + // since we rotate the canvas when copying from cached canvas, we don't + // regenerate the cached canvas. But we need to in case of labels which are + // cached alongside the arrow, and we want the labels to remain unrotated + // with respect to the arrow. + (isArrowElement(element) && + boundTextElement && + element.angle !== prevElementWithCanvas.angle) + ) { + const elementWithCanvas = generateElementCanvas( + element, + elementsMap, + zoom, + renderConfig, + appState, + ); + + if (!elementWithCanvas) { + return null; + } + + 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; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); + 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 offsetX = + (elementWithCanvas.boundTextCanvas.width - + elementWithCanvas.canvas!.width) / + 2; + const offsetY = + (elementWithCanvas.boundTextCanvas.height - + elementWithCanvas.canvas!.height) / + 2; + context.translate(cx, cy); + context.drawImage( + elementWithCanvas.boundTextCanvas, + (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding, + (-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding, + elementWithCanvas.boundTextCanvas.width / zoom, + elementWithCanvas.boundTextCanvas.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, + selectionColor: InteractiveCanvasRenderConfig["selectionColor"], +) => { + 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 = selectionColor; + 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, +) => { + const reduceAlphaForSelection = + appState.openDialog?.name === "elementLinkSelector" && + !appState.selectedElementIds[element.id] && + !appState.hoveredElementIds[element.id]; + + context.globalAlpha = getRenderOpacity( + element, + getContainingFrame(element, elementsMap), + renderConfig.elementsPendingErasure, + renderConfig.pendingFlowchartNodes, + reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1, + ); + + 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 === 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, + allElementsMap, + renderConfig, + appState, + ); + if (!elementWithCanvas) { + return; + } + + 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, + allElementsMap, + renderConfig, + appState, + ); + + if (!elementWithCanvas) { + return; + } + + 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 + // We need less-than comparison because of FP artihmetic + isRightAngleRads(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; + } + + if ( + element.id === appState.croppingElementId && + isImageElement(elementWithCanvas.element) && + elementWithCanvas.element.crop !== null + ) { + context.save(); + context.globalAlpha = 0.1; + + const uncroppedElementCanvas = generateElementCanvas( + getUncroppedImageElement(elementWithCanvas.element, elementsMap), + allElementsMap, + appState.zoom, + renderConfig, + appState, + ); + + if (uncroppedElementCanvas) { + drawElementFromCanvas( + uncroppedElementCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + } + + context.restore(); + } + + drawElementFromCanvas( + elementWithCanvas, + context, + renderConfig, + appState, + allElementsMap, + ); + + // reset + context.imageSmoothingEnabled = currentImageSmoothingStatus; + } + break; + } + default: { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + + context.globalAlpha = 1; +}; + +export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]); + +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"); +} |
