excalidraw/packages/element/src/renderElement.ts
Mark Tolmacs 4438137a57
Fixed point binding for simple arrows
Tests added

Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

Binding highlight fixes

Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Only transparent bindables allow binding fallthrough

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements

Restore drag

Removed point binding

Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

Remove invariants from debug

feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point at finalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix type errors

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

New arrow binding rules

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix cyclical dep

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix all tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

chore: Trigger build

Remove binding gap

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix delayed bind mode for multiElement arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix multi-point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fixes for arrow dragging

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug
2025-09-01 21:56:20 +02:00

1143 lines
36 KiB
TypeScript

import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math";
import {
BOUND_TEXT_PADDING,
DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MIME_TYPES,
THEME,
distance,
getFontString,
isRTL,
getVerticalOffset,
} from "@excalidraw/common";
import type {
AppState,
StaticCanvasAppState,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
PendingExcalidrawElements,
NormalizedZoomValue,
} from "@excalidraw/excalidraw/types";
import type {
StaticCanvasRenderConfig,
RenderableElementsMap,
InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords } from "./bounds";
import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor";
import {
getBoundTextElement,
getContainerCoords,
getContainerElement,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
} from "./textElement";
import { getLineHeightInPx } from "./textMeasurements";
import {
isTextElement,
isLinearElement,
isFreeDrawElement,
isInitializedImageElement,
isArrowElement,
hasBoundTextElement,
isMagicFrameElement,
isImageElement,
} from "./typeChecks";
import { getContainingFrame } from "./frame";
import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawFrameLikeElement,
NonDeletedSceneElementsMap,
ElementsMap,
} from "./types";
import type { StrokeOptions } from "perfect-freehand";
import type { RoughCanvas } from "roughjs/bin/canvas";
// 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 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;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
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);
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 =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
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 =
typeof document !== "undefined"
? document.createElement("img")
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
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,
) => {
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
: {
value: 1 as NormalizedZoomValue,
};
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 drawElementHighlight = (
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState,
) => {
if (appState.suggestedBinding) {
const cx =
(appState.suggestedBinding.x +
appState.suggestedBinding.width / 2 +
appState.scrollX) *
window.devicePixelRatio;
const cy =
(appState.suggestedBinding.y +
appState.suggestedBinding.height / 2 +
appState.scrollY) *
window.devicePixelRatio;
context.save();
context.translate(cx, cy);
context.rotate(appState.suggestedBinding.angle);
context.translate(-cx, -cy);
context.translate(
appState.scrollX + appState.suggestedBinding.x,
appState.scrollY + appState.suggestedBinding.y,
);
const drawable = ShapeCache.generateBindableElementHighlight(
appState.suggestedBinding,
appState,
);
rough.canvas(context.canvas).draw(drawable);
context.restore();
}
};
const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap,
) => {
const isHighlighted =
appState.suggestedBinding?.id === elementWithCanvas.element.id;
if (
!isHighlighted ||
["image", "text"].includes(elementWithCanvas.element.type)
) {
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
}
if (isHighlighted) {
drawElementHighlight(context, appState);
}
};
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) {
const isHighlighted = element.id === appState.suggestedBinding?.id;
const {
options: { stroke: highlightStroke },
} = ShapeCache.generateBindableElementHighlight(element, appState);
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 = isHighlighted
? highlightStroke
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle = isHighlighted
? highlightStroke
: 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);
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);
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);
}
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");
}