1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066 |
- 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,
- 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,
- getLineHeightInPx,
- 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 "../../math";
- import { getCornerRadius } from "../shapes";
- import { getUncroppedImageElement } from "../element/cropElement";
- // 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,
- ) => {
- // 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) ||
- (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;
- 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,
- };
- };
- 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 containingFrameOpacity =
- getContainingFrame(element, elementsMap)?.opacity || 100;
- if (
- !prevElementWithCanvas ||
- shouldRegenerateBecauseZoom ||
- prevElementWithCanvas.theme !== appState.theme ||
- prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
- 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,
- ) => {
- context.globalAlpha = getRenderOpacity(
- element,
- getContainingFrame(element, elementsMap),
- renderConfig.elementsPendingErasure,
- renderConfig.pendingFlowchartNodes,
- );
- 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();
- }
- const _elementWithCanvas = generateElementCanvas(
- elementWithCanvas.element,
- allElementsMap,
- appState.zoom,
- renderConfig,
- appState,
- );
- if (_elementWithCanvas) {
- 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");
- }
|