1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069 |
- import { RoughCanvas } from "roughjs/bin/canvas";
- import { RoughSVG } from "roughjs/bin/svg";
- import oc from "open-color";
- import { AppState, BinaryFiles, Point, Zoom } from "../types";
- import {
- ExcalidrawElement,
- NonDeletedExcalidrawElement,
- ExcalidrawLinearElement,
- NonDeleted,
- GroupId,
- ExcalidrawBindableElement,
- } from "../element/types";
- import {
- getElementAbsoluteCoords,
- OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
- getTransformHandlesFromCoords,
- getTransformHandles,
- getElementBounds,
- getCommonBounds,
- } from "../element";
- import { roundRect } from "./roundRect";
- import { RenderConfig } from "../scene/types";
- import {
- getScrollBars,
- SCROLLBAR_COLOR,
- SCROLLBAR_WIDTH,
- } from "../scene/scrollbars";
- import { getSelectedElements } from "../scene/selection";
- import { renderElement, renderElementToSvg } from "./renderElement";
- import { getClientColors } from "../clients";
- import { LinearElementEditor } from "../element/linearElementEditor";
- import {
- isSelectedViaGroup,
- getSelectedGroupIds,
- getElementsInGroup,
- } from "../groups";
- import { maxBindingGap } from "../element/collision";
- import {
- SuggestedBinding,
- SuggestedPointBinding,
- isBindingEnabled,
- } from "../element/binding";
- import {
- shouldShowBoundingBox,
- TransformHandles,
- TransformHandleType,
- } from "../element/transformHandles";
- import {
- viewportCoordsToSceneCoords,
- supportsEmoji,
- throttleRAF,
- } from "../utils";
- import { UserIdleState } from "../types";
- import { THEME_FILTER } from "../constants";
- import {
- EXTERNAL_LINK_IMG,
- getLinkHandleFromCoords,
- } from "../element/Hyperlink";
- import { isLinearElement } from "../element/typeChecks";
- const hasEmojiSupport = supportsEmoji();
- export const DEFAULT_SPACING = 4;
- const strokeRectWithRotation = (
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- width: number,
- height: number,
- cx: number,
- cy: number,
- angle: number,
- fill: boolean = false,
- ) => {
- context.save();
- context.translate(cx, cy);
- context.rotate(angle);
- if (fill) {
- context.fillRect(x - cx, y - cy, width, height);
- }
- context.strokeRect(x - cx, y - cy, width, height);
- context.restore();
- };
- const strokeDiamondWithRotation = (
- context: CanvasRenderingContext2D,
- width: number,
- height: number,
- cx: number,
- cy: number,
- angle: number,
- ) => {
- context.save();
- context.translate(cx, cy);
- context.rotate(angle);
- context.beginPath();
- context.moveTo(0, height / 2);
- context.lineTo(width / 2, 0);
- context.lineTo(0, -height / 2);
- context.lineTo(-width / 2, 0);
- context.closePath();
- context.stroke();
- context.restore();
- };
- const strokeEllipseWithRotation = (
- context: CanvasRenderingContext2D,
- width: number,
- height: number,
- cx: number,
- cy: number,
- angle: number,
- ) => {
- context.beginPath();
- context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
- context.stroke();
- };
- const fillCircle = (
- context: CanvasRenderingContext2D,
- cx: number,
- cy: number,
- radius: number,
- stroke = true,
- ) => {
- context.beginPath();
- context.arc(cx, cy, radius, 0, Math.PI * 2);
- context.fill();
- if (stroke) {
- context.stroke();
- }
- };
- const strokeGrid = (
- context: CanvasRenderingContext2D,
- gridSize: number,
- offsetX: number,
- offsetY: number,
- width: number,
- height: number,
- ) => {
- context.save();
- context.strokeStyle = "rgba(0,0,0,0.1)";
- context.beginPath();
- for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
- context.moveTo(x, offsetY - gridSize);
- context.lineTo(x, offsetY + height + gridSize * 2);
- }
- for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
- context.moveTo(offsetX - gridSize, y);
- context.lineTo(offsetX + width + gridSize * 2, y);
- }
- context.stroke();
- context.restore();
- };
- const renderSingleLinearPoint = (
- context: CanvasRenderingContext2D,
- appState: AppState,
- renderConfig: RenderConfig,
- point: Point,
- isSelected: boolean,
- isPhantomPoint = false,
- ) => {
- context.strokeStyle = "#5e5ad8";
- context.setLineDash([]);
- context.fillStyle = "rgba(255, 255, 255, 0.9)";
- if (isSelected) {
- context.fillStyle = "rgba(134, 131, 226, 0.9)";
- } else if (isPhantomPoint) {
- context.fillStyle = "rgba(177, 151, 252, 0.7)";
- }
- const { POINT_HANDLE_SIZE } = LinearElementEditor;
- const radius = appState.editingLinearElement
- ? POINT_HANDLE_SIZE
- : POINT_HANDLE_SIZE / 2;
- fillCircle(
- context,
- point[0],
- point[1],
- radius / renderConfig.zoom.value,
- !isPhantomPoint,
- );
- };
- const renderLinearPointHandles = (
- context: CanvasRenderingContext2D,
- appState: AppState,
- renderConfig: RenderConfig,
- element: NonDeleted<ExcalidrawLinearElement>,
- ) => {
- if (!appState.selectedLinearElement) {
- return;
- }
- context.save();
- context.translate(renderConfig.scrollX, renderConfig.scrollY);
- context.lineWidth = 1 / renderConfig.zoom.value;
- const points = LinearElementEditor.getPointsGlobalCoordinates(element);
- const centerPoint = LinearElementEditor.getMidPoint(
- appState.selectedLinearElement,
- );
- if (!centerPoint) {
- return;
- }
- points.forEach((point, idx) => {
- const isSelected =
- !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
- renderSingleLinearPoint(context, appState, renderConfig, point, isSelected);
- });
- if (!appState.editingLinearElement && points.length < 3) {
- if (appState.selectedLinearElement.midPointHovered) {
- const centerPoint = LinearElementEditor.getMidPoint(
- appState.selectedLinearElement,
- )!;
- highlightPoint(centerPoint, context, appState, renderConfig);
- renderSingleLinearPoint(
- context,
- appState,
- renderConfig,
- centerPoint,
- false,
- );
- } else {
- renderSingleLinearPoint(
- context,
- appState,
- renderConfig,
- centerPoint,
- false,
- true,
- );
- }
- }
- context.restore();
- };
- const highlightPoint = (
- point: Point,
- context: CanvasRenderingContext2D,
- appState: AppState,
- renderConfig: RenderConfig,
- ) => {
- context.fillStyle = "rgba(105, 101, 219, 0.4)";
- fillCircle(
- context,
- point[0],
- point[1],
- LinearElementEditor.POINT_HANDLE_SIZE / renderConfig.zoom.value,
- false,
- );
- };
- const renderLinearElementPointHighlight = (
- context: CanvasRenderingContext2D,
- appState: AppState,
- renderConfig: RenderConfig,
- ) => {
- const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
- if (
- appState.editingLinearElement?.selectedPointsIndices?.includes(
- hoverPointIndex,
- )
- ) {
- return;
- }
- const element = LinearElementEditor.getElement(elementId);
- if (!element) {
- return;
- }
- const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
- element,
- hoverPointIndex,
- );
- context.save();
- context.translate(renderConfig.scrollX, renderConfig.scrollY);
- highlightPoint(point, context, appState, renderConfig);
- context.restore();
- };
- export const _renderScene = ({
- elements,
- appState,
- scale,
- rc,
- canvas,
- renderConfig,
- }: {
- elements: readonly NonDeletedExcalidrawElement[];
- appState: AppState;
- scale: number;
- rc: RoughCanvas;
- canvas: HTMLCanvasElement;
- renderConfig: RenderConfig;
- }) =>
- // extra options passed to the renderer
- {
- if (canvas === null) {
- return { atLeastOneVisibleElement: false };
- }
- const {
- renderScrollbars = true,
- renderSelection = true,
- renderGrid = true,
- isExporting,
- } = renderConfig;
- const context = canvas.getContext("2d")!;
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.save();
- context.scale(scale, scale);
- // When doing calculations based on canvas width we should used normalized one
- const normalizedCanvasWidth = canvas.width / scale;
- const normalizedCanvasHeight = canvas.height / scale;
- if (isExporting && renderConfig.theme === "dark") {
- context.filter = THEME_FILTER;
- }
- // Paint background
- if (typeof renderConfig.viewBackgroundColor === "string") {
- const hasTransparence =
- renderConfig.viewBackgroundColor === "transparent" ||
- renderConfig.viewBackgroundColor.length === 5 || // #RGBA
- renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
- /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
- if (hasTransparence) {
- context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- }
- context.save();
- context.fillStyle = renderConfig.viewBackgroundColor;
- context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- context.restore();
- } else {
- context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- }
- // Apply zoom
- context.save();
- context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
- // Grid
- if (renderGrid && appState.gridSize) {
- strokeGrid(
- context,
- appState.gridSize,
- -Math.ceil(renderConfig.zoom.value / appState.gridSize) *
- appState.gridSize +
- (renderConfig.scrollX % appState.gridSize),
- -Math.ceil(renderConfig.zoom.value / appState.gridSize) *
- appState.gridSize +
- (renderConfig.scrollY % appState.gridSize),
- normalizedCanvasWidth / renderConfig.zoom.value,
- normalizedCanvasHeight / renderConfig.zoom.value,
- );
- }
- // Paint visible elements
- const visibleElements = elements.filter((element) =>
- isVisibleElement(element, normalizedCanvasWidth, normalizedCanvasHeight, {
- zoom: renderConfig.zoom,
- offsetLeft: appState.offsetLeft,
- offsetTop: appState.offsetTop,
- scrollX: renderConfig.scrollX,
- scrollY: renderConfig.scrollY,
- }),
- );
- visibleElements.forEach((element) => {
- try {
- renderElement(element, rc, context, renderConfig);
- if (!isExporting) {
- renderLinkIcon(element, context, appState);
- }
- } catch (error: any) {
- console.error(error);
- }
- });
- if (appState.editingLinearElement) {
- const element = LinearElementEditor.getElement(
- appState.editingLinearElement.elementId,
- );
- if (element) {
- renderLinearPointHandles(context, appState, renderConfig, element);
- }
- }
- // Paint selection element
- if (appState.selectionElement) {
- try {
- renderElement(appState.selectionElement, rc, context, renderConfig);
- } catch (error: any) {
- console.error(error);
- }
- }
- if (isBindingEnabled(appState)) {
- appState.suggestedBindings
- .filter((binding) => binding != null)
- .forEach((suggestedBinding) => {
- renderBindingHighlight(context, renderConfig, suggestedBinding!);
- });
- }
- if (
- appState.selectedLinearElement &&
- appState.selectedLinearElement.hoverPointIndex >= 0
- ) {
- renderLinearElementPointHighlight(context, appState, renderConfig);
- }
- // Paint selected elements
- if (
- renderSelection &&
- !appState.multiElement &&
- !appState.editingLinearElement
- ) {
- const locallySelectedElements = getSelectedElements(elements, appState);
- const showBoundingBox = shouldShowBoundingBox(
- locallySelectedElements,
- appState,
- );
- const locallySelectedIds = locallySelectedElements.map(
- (element) => element.id,
- );
- const isSingleLinearElementSelected =
- locallySelectedElements.length === 1 &&
- isLinearElement(locallySelectedElements[0]);
- // render selected linear element points
- if (
- isSingleLinearElementSelected &&
- appState.selectedLinearElement?.elementId ===
- locallySelectedElements[0].id &&
- !locallySelectedElements[0].locked
- ) {
- renderLinearPointHandles(
- context,
- appState,
- renderConfig,
- locallySelectedElements[0] as ExcalidrawLinearElement,
- );
- }
- if (showBoundingBox) {
- const selections = elements.reduce((acc, element) => {
- const selectionColors = [];
- // local user
- if (
- locallySelectedIds.includes(element.id) &&
- !isSelectedViaGroup(appState, element)
- ) {
- selectionColors.push(oc.black);
- }
- // remote users
- if (renderConfig.remoteSelectedElementIds[element.id]) {
- selectionColors.push(
- ...renderConfig.remoteSelectedElementIds[element.id].map(
- (socketId) => {
- const { background } = getClientColors(socketId, appState);
- return background;
- },
- ),
- );
- }
- if (selectionColors.length) {
- const [elementX1, elementY1, elementX2, elementY2] =
- getElementAbsoluteCoords(element);
- acc.push({
- angle: element.angle,
- elementX1,
- elementY1,
- elementX2,
- elementY2,
- selectionColors,
- });
- }
- return acc;
- }, [] as { angle: number; elementX1: number; elementY1: number; elementX2: number; elementY2: number; selectionColors: string[] }[]);
- const addSelectionForGroupId = (groupId: GroupId) => {
- const groupElements = getElementsInGroup(elements, groupId);
- const [elementX1, elementY1, elementX2, elementY2] =
- getCommonBounds(groupElements);
- selections.push({
- angle: 0,
- elementX1,
- elementX2,
- elementY1,
- elementY2,
- selectionColors: [oc.black],
- });
- };
- for (const groupId of getSelectedGroupIds(appState)) {
- // TODO: support multiplayer selected group IDs
- addSelectionForGroupId(groupId);
- }
- if (appState.editingGroupId) {
- addSelectionForGroupId(appState.editingGroupId);
- }
- selections.forEach((selection) =>
- renderSelectionBorder(
- context,
- renderConfig,
- selection,
- isSingleLinearElementSelected
- ? DEFAULT_SPACING * 2
- : DEFAULT_SPACING,
- ),
- );
- }
- // Paint resize transformHandles
- context.save();
- context.translate(renderConfig.scrollX, renderConfig.scrollY);
- if (locallySelectedElements.length === 1) {
- context.fillStyle = oc.white;
- const transformHandles = getTransformHandles(
- locallySelectedElements[0],
- renderConfig.zoom,
- "mouse", // when we render we don't know which pointer type so use mouse
- );
- if (!appState.viewModeEnabled && showBoundingBox) {
- renderTransformHandles(
- context,
- renderConfig,
- transformHandles,
- locallySelectedElements[0].angle,
- );
- }
- } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
- const dashedLinePadding = 4 / renderConfig.zoom.value;
- context.fillStyle = oc.white;
- const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements);
- const initialLineDash = context.getLineDash();
- context.setLineDash([2 / renderConfig.zoom.value]);
- const lineWidth = context.lineWidth;
- context.lineWidth = 1 / renderConfig.zoom.value;
- strokeRectWithRotation(
- context,
- x1 - dashedLinePadding,
- y1 - dashedLinePadding,
- x2 - x1 + dashedLinePadding * 2,
- y2 - y1 + dashedLinePadding * 2,
- (x1 + x2) / 2,
- (y1 + y2) / 2,
- 0,
- );
- context.lineWidth = lineWidth;
- context.setLineDash(initialLineDash);
- const transformHandles = getTransformHandlesFromCoords(
- [x1, y1, x2, y2],
- 0,
- renderConfig.zoom,
- "mouse",
- OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
- );
- if (locallySelectedElements.some((element) => !element.locked)) {
- renderTransformHandles(context, renderConfig, transformHandles, 0);
- }
- }
- context.restore();
- }
- // Reset zoom
- context.restore();
- // Paint remote pointers
- for (const clientId in renderConfig.remotePointerViewportCoords) {
- let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
- x -= appState.offsetLeft;
- y -= appState.offsetTop;
- const width = 9;
- const height = 14;
- const isOutOfBounds =
- x < 0 ||
- x > normalizedCanvasWidth - width ||
- y < 0 ||
- y > normalizedCanvasHeight - height;
- x = Math.max(x, 0);
- x = Math.min(x, normalizedCanvasWidth - width);
- y = Math.max(y, 0);
- y = Math.min(y, normalizedCanvasHeight - height);
- const { background, stroke } = getClientColors(clientId, appState);
- context.save();
- context.strokeStyle = stroke;
- context.fillStyle = background;
- const userState = renderConfig.remotePointerUserStates[clientId];
- if (isOutOfBounds || userState === UserIdleState.AWAY) {
- context.globalAlpha = 0.48;
- }
- if (
- renderConfig.remotePointerButton &&
- renderConfig.remotePointerButton[clientId] === "down"
- ) {
- context.beginPath();
- context.arc(x, y, 15, 0, 2 * Math.PI, false);
- context.lineWidth = 3;
- context.strokeStyle = "#ffffff88";
- context.stroke();
- context.closePath();
- context.beginPath();
- context.arc(x, y, 15, 0, 2 * Math.PI, false);
- context.lineWidth = 1;
- context.strokeStyle = stroke;
- context.stroke();
- context.closePath();
- }
- context.beginPath();
- context.moveTo(x, y);
- context.lineTo(x + 1, y + 14);
- context.lineTo(x + 4, y + 9);
- context.lineTo(x + 9, y + 10);
- context.lineTo(x, y);
- context.fill();
- context.stroke();
- const username = renderConfig.remotePointerUsernames[clientId];
- let idleState = "";
- if (userState === UserIdleState.AWAY) {
- idleState = hasEmojiSupport ? "⚫️" : ` (${UserIdleState.AWAY})`;
- } else if (userState === UserIdleState.IDLE) {
- idleState = hasEmojiSupport ? "💤" : ` (${UserIdleState.IDLE})`;
- } else if (userState === UserIdleState.ACTIVE) {
- idleState = hasEmojiSupport ? "🟢" : "";
- }
- const usernameAndIdleState = `${
- username ? `${username} ` : ""
- }${idleState}`;
- if (!isOutOfBounds && usernameAndIdleState) {
- const offsetX = x + width;
- const offsetY = y + height;
- const paddingHorizontal = 4;
- const paddingVertical = 4;
- const measure = context.measureText(usernameAndIdleState);
- const measureHeight =
- measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
- // Border
- context.fillStyle = stroke;
- context.fillRect(
- offsetX - 1,
- offsetY - 1,
- measure.width + 2 * paddingHorizontal + 2,
- measureHeight + 2 * paddingVertical + 2,
- );
- // Background
- context.fillStyle = background;
- context.fillRect(
- offsetX,
- offsetY,
- measure.width + 2 * paddingHorizontal,
- measureHeight + 2 * paddingVertical,
- );
- context.fillStyle = oc.white;
- context.fillText(
- usernameAndIdleState,
- offsetX + paddingHorizontal,
- offsetY + paddingVertical + measure.actualBoundingBoxAscent,
- );
- }
- context.restore();
- context.closePath();
- }
- // Paint scrollbars
- let scrollBars;
- if (renderScrollbars) {
- scrollBars = getScrollBars(
- elements,
- normalizedCanvasWidth,
- normalizedCanvasHeight,
- renderConfig,
- );
- context.save();
- context.fillStyle = SCROLLBAR_COLOR;
- context.strokeStyle = "rgba(255,255,255,0.8)";
- [scrollBars.horizontal, scrollBars.vertical].forEach((scrollBar) => {
- if (scrollBar) {
- roundRect(
- context,
- scrollBar.x,
- scrollBar.y,
- scrollBar.width,
- scrollBar.height,
- SCROLLBAR_WIDTH / 2,
- );
- }
- });
- context.restore();
- }
- context.restore();
- return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
- };
- const renderSceneThrottled = throttleRAF(
- (config: {
- elements: readonly NonDeletedExcalidrawElement[];
- appState: AppState;
- scale: number;
- rc: RoughCanvas;
- canvas: HTMLCanvasElement;
- renderConfig: RenderConfig;
- callback?: (data: ReturnType<typeof _renderScene>) => void;
- }) => {
- const ret = _renderScene(config);
- config.callback?.(ret);
- },
- { trailing: true },
- );
- /** renderScene throttled to animation framerate */
- export const renderScene = <T extends boolean = false>(
- config: {
- elements: readonly NonDeletedExcalidrawElement[];
- appState: AppState;
- scale: number;
- rc: RoughCanvas;
- canvas: HTMLCanvasElement;
- renderConfig: RenderConfig;
- callback?: (data: ReturnType<typeof _renderScene>) => void;
- },
- /** Whether to throttle rendering. Defaults to false.
- * When throttling, no value is returned. Use the callback instead. */
- throttle?: T,
- ): T extends true ? void : ReturnType<typeof _renderScene> => {
- if (throttle) {
- renderSceneThrottled(config);
- return undefined as T extends true ? void : ReturnType<typeof _renderScene>;
- }
- const ret = _renderScene(config);
- config.callback?.(ret);
- return ret as T extends true ? void : ReturnType<typeof _renderScene>;
- };
- const renderTransformHandles = (
- context: CanvasRenderingContext2D,
- renderConfig: RenderConfig,
- transformHandles: TransformHandles,
- angle: number,
- ): void => {
- Object.keys(transformHandles).forEach((key) => {
- const transformHandle = transformHandles[key as TransformHandleType];
- if (transformHandle !== undefined) {
- const [x, y, width, height] = transformHandle;
- context.save();
- context.lineWidth = 1 / renderConfig.zoom.value;
- if (key === "rotation") {
- fillCircle(context, x + width / 2, y + height / 2, width / 2);
- } else {
- strokeRectWithRotation(
- context,
- x,
- y,
- width,
- height,
- x + width / 2,
- y + height / 2,
- angle,
- true, // fill before stroke
- );
- }
- context.restore();
- }
- });
- };
- const renderSelectionBorder = (
- context: CanvasRenderingContext2D,
- renderConfig: RenderConfig,
- elementProperties: {
- angle: number;
- elementX1: number;
- elementY1: number;
- elementX2: number;
- elementY2: number;
- selectionColors: string[];
- },
- padding = 4,
- ) => {
- const { angle, elementX1, elementY1, elementX2, elementY2, selectionColors } =
- elementProperties;
- const elementWidth = elementX2 - elementX1;
- const elementHeight = elementY2 - elementY1;
- const dashedLinePadding = padding / renderConfig.zoom.value;
- const dashWidth = 8 / renderConfig.zoom.value;
- const spaceWidth = 4 / renderConfig.zoom.value;
- context.save();
- context.translate(renderConfig.scrollX, renderConfig.scrollY);
- context.lineWidth = 1 / renderConfig.zoom.value;
- const count = selectionColors.length;
- for (let index = 0; index < count; ++index) {
- context.strokeStyle = selectionColors[index];
- context.setLineDash([
- dashWidth,
- spaceWidth + (dashWidth + spaceWidth) * (count - 1),
- ]);
- context.lineDashOffset = (dashWidth + spaceWidth) * index;
- strokeRectWithRotation(
- context,
- elementX1 - dashedLinePadding,
- elementY1 - dashedLinePadding,
- elementWidth + dashedLinePadding * 2,
- elementHeight + dashedLinePadding * 2,
- elementX1 + elementWidth / 2,
- elementY1 + elementHeight / 2,
- angle,
- );
- }
- context.restore();
- };
- const renderBindingHighlight = (
- context: CanvasRenderingContext2D,
- renderConfig: RenderConfig,
- suggestedBinding: SuggestedBinding,
- ) => {
- const renderHighlight = Array.isArray(suggestedBinding)
- ? renderBindingHighlightForSuggestedPointBinding
- : renderBindingHighlightForBindableElement;
- context.save();
- context.translate(renderConfig.scrollX, renderConfig.scrollY);
- renderHighlight(context, suggestedBinding as any);
- context.restore();
- };
- const renderBindingHighlightForBindableElement = (
- context: CanvasRenderingContext2D,
- element: ExcalidrawBindableElement,
- ) => {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const width = x2 - x1;
- const height = y2 - y1;
- const threshold = maxBindingGap(element, width, height);
- // So that we don't overlap the element itself
- const strokeOffset = 4;
- context.strokeStyle = "rgba(0,0,0,.05)";
- context.lineWidth = threshold - strokeOffset;
- const padding = strokeOffset / 2 + threshold / 2;
- switch (element.type) {
- case "rectangle":
- case "text":
- case "image":
- strokeRectWithRotation(
- context,
- x1 - padding,
- y1 - padding,
- width + padding * 2,
- height + padding * 2,
- x1 + width / 2,
- y1 + height / 2,
- element.angle,
- );
- break;
- case "diamond":
- const side = Math.hypot(width, height);
- const wPadding = (padding * side) / height;
- const hPadding = (padding * side) / width;
- strokeDiamondWithRotation(
- context,
- width + wPadding * 2,
- height + hPadding * 2,
- x1 + width / 2,
- y1 + height / 2,
- element.angle,
- );
- break;
- case "ellipse":
- strokeEllipseWithRotation(
- context,
- width + padding * 2,
- height + padding * 2,
- x1 + width / 2,
- y1 + height / 2,
- element.angle,
- );
- break;
- }
- };
- const renderBindingHighlightForSuggestedPointBinding = (
- context: CanvasRenderingContext2D,
- suggestedBinding: SuggestedPointBinding,
- ) => {
- const [element, startOrEnd, bindableElement] = suggestedBinding;
- const threshold = maxBindingGap(
- bindableElement,
- bindableElement.width,
- bindableElement.height,
- );
- context.strokeStyle = "rgba(0,0,0,0)";
- context.fillStyle = "rgba(0,0,0,.05)";
- const pointIndices =
- startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
- pointIndices.forEach((index) => {
- const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
- element,
- index,
- );
- fillCircle(context, x, y, threshold);
- });
- };
- let linkCanvasCache: any;
- const renderLinkIcon = (
- element: NonDeletedExcalidrawElement,
- context: CanvasRenderingContext2D,
- appState: AppState,
- ) => {
- if (element.link && !appState.selectedElementIds[element.id]) {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- const [x, y, width, height] = getLinkHandleFromCoords(
- [x1, y1, x2, y2],
- element.angle,
- appState,
- );
- const centerX = x + width / 2;
- const centerY = y + height / 2;
- context.save();
- context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
- context.rotate(element.angle);
- if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
- linkCanvasCache = document.createElement("canvas");
- linkCanvasCache.zoom = appState.zoom.value;
- linkCanvasCache.width =
- width * window.devicePixelRatio * appState.zoom.value;
- linkCanvasCache.height =
- height * window.devicePixelRatio * appState.zoom.value;
- const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
- linkCanvasCacheContext.scale(
- window.devicePixelRatio * appState.zoom.value,
- window.devicePixelRatio * appState.zoom.value,
- );
- linkCanvasCacheContext.fillStyle = "#fff";
- linkCanvasCacheContext.fillRect(0, 0, width, height);
- linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
- linkCanvasCacheContext.restore();
- context.drawImage(
- linkCanvasCache,
- x - centerX,
- y - centerY,
- width,
- height,
- );
- } else {
- context.drawImage(
- linkCanvasCache,
- x - centerX,
- y - centerY,
- width,
- height,
- );
- }
- context.restore();
- }
- };
- const isVisibleElement = (
- element: ExcalidrawElement,
- canvasWidth: number,
- canvasHeight: number,
- viewTransformations: {
- zoom: Zoom;
- offsetLeft: number;
- offsetTop: number;
- scrollX: number;
- scrollY: number;
- },
- ) => {
- const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates
- const topLeftSceneCoords = viewportCoordsToSceneCoords(
- {
- clientX: viewTransformations.offsetLeft,
- clientY: viewTransformations.offsetTop,
- },
- viewTransformations,
- );
- const bottomRightSceneCoords = viewportCoordsToSceneCoords(
- {
- clientX: viewTransformations.offsetLeft + canvasWidth,
- clientY: viewTransformations.offsetTop + canvasHeight,
- },
- viewTransformations,
- );
- return (
- topLeftSceneCoords.x <= x2 &&
- topLeftSceneCoords.y <= y2 &&
- bottomRightSceneCoords.x >= x1 &&
- bottomRightSceneCoords.y >= y1
- );
- };
- // This should be only called for exporting purposes
- export const renderSceneToSvg = (
- elements: readonly NonDeletedExcalidrawElement[],
- rsvg: RoughSVG,
- svgRoot: SVGElement,
- files: BinaryFiles,
- {
- offsetX = 0,
- offsetY = 0,
- exportWithDarkMode = false,
- }: {
- offsetX?: number;
- offsetY?: number;
- exportWithDarkMode?: boolean;
- } = {},
- ) => {
- if (!svgRoot) {
- return;
- }
- // render elements
- elements.forEach((element) => {
- if (!element.isDeleted) {
- try {
- renderElementToSvg(
- element,
- rsvg,
- svgRoot,
- files,
- element.x + offsetX,
- element.y + offsetY,
- exportWithDarkMode,
- );
- } catch (error: any) {
- console.error(error);
- }
- }
- });
- };
|