excalidraw/packages/excalidraw/renderer/renderElement.ts

959 lines
31 KiB
TypeScript

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 {
StaticCanvasRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import {
AppState,
StaticCanvasAppState,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
} from "../types";
import { getDefaultAppState } from "../appState";
import {
BOUND_TEXT_PADDING,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MIME_TYPES,
} from "../constants";
import { getStroke, StrokeOptions } 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";
// 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 === "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(
`<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,
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;
};
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");
}