();
let maxGroup = 0;
@@ -369,3 +380,25 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
return maxGroup === elements.length;
};
+
+export const isInGroup = (element: NonDeletedExcalidrawElement) => {
+ return element.groupIds.length > 0;
+};
+
+export const getNewGroupIdsForDuplication = (
+ groupIds: ExcalidrawElement["groupIds"],
+ editingGroupId: AppState["editingGroupId"],
+ mapper: (groupId: GroupId) => GroupId,
+) => {
+ const copy = [...groupIds];
+ const positionOfEditingGroupId = editingGroupId
+ ? groupIds.indexOf(editingGroupId)
+ : -1;
+ const endIndex =
+ positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
+ for (let index = 0; index < endIndex; index++) {
+ copy[index] = mapper(copy[index]);
+ }
+
+ return copy;
+};
diff --git a/packages/element/src/heading.ts b/packages/element/src/heading.ts
new file mode 100644
index 0000000000..1e9ab37132
--- /dev/null
+++ b/packages/element/src/heading.ts
@@ -0,0 +1,282 @@
+import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
+
+import {
+ pointFrom,
+ pointFromVector,
+ pointRotateRads,
+ pointScaleFromOrigin,
+ pointsEqual,
+ triangleIncludesPoint,
+ vectorCross,
+ vectorFromPoint,
+ vectorScale,
+} from "@excalidraw/math";
+
+import type {
+ LocalPoint,
+ GlobalPoint,
+ Triangle,
+ Vector,
+} from "@excalidraw/math";
+
+import { getCenterForBounds, type Bounds } from "./bounds";
+
+import type { ExcalidrawBindableElement } from "./types";
+
+export const HEADING_RIGHT = [1, 0] as Heading;
+export const HEADING_DOWN = [0, 1] as Heading;
+export const HEADING_LEFT = [-1, 0] as Heading;
+export const HEADING_UP = [0, -1] as Heading;
+export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
+
+export const vectorToHeading = (vec: Vector): Heading => {
+ const [x, y] = vec;
+ const absX = Math.abs(x);
+ const absY = Math.abs(y);
+ if (x > absY) {
+ return HEADING_RIGHT;
+ } else if (x <= -absY) {
+ return HEADING_LEFT;
+ } else if (y > absX) {
+ return HEADING_DOWN;
+ }
+ return HEADING_UP;
+};
+
+export const headingForPoint = (
+ p: P,
+ o: P,
+) => vectorToHeading(vectorFromPoint
(p, o));
+
+export const headingForPointIsHorizontal =
(
+ p: P,
+ o: P,
+) => headingIsHorizontal(headingForPoint
(p, o));
+
+export const compareHeading = (a: Heading, b: Heading) =>
+ a[0] === b[0] && a[1] === b[1];
+
+export const headingIsHorizontal = (a: Heading) =>
+ compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
+
+export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
+
+const headingForPointFromDiamondElement = (
+ element: Readonly,
+ aabb: Readonly,
+ point: Readonly,
+): Heading => {
+ const midPoint = getCenterForBounds(aabb);
+
+ if (isDevEnv() || isTestEnv()) {
+ invariant(
+ element.width > 0 && element.height > 0,
+ "Diamond element has no width or height",
+ );
+ invariant(
+ !pointsEqual(midPoint, point),
+ "The point is too close to the element mid point to determine heading",
+ );
+ }
+
+ const SHRINK = 0.95; // Rounded elements tolerance
+ const top = pointFromVector(
+ vectorScale(
+ vectorFromPoint(
+ pointRotateRads(
+ pointFrom(element.x + element.width / 2, element.y),
+ midPoint,
+ element.angle,
+ ),
+ midPoint,
+ ),
+ SHRINK,
+ ),
+ midPoint,
+ );
+ const right = pointFromVector(
+ vectorScale(
+ vectorFromPoint(
+ pointRotateRads(
+ pointFrom(
+ element.x + element.width,
+ element.y + element.height / 2,
+ ),
+ midPoint,
+ element.angle,
+ ),
+ midPoint,
+ ),
+ SHRINK,
+ ),
+ midPoint,
+ );
+ const bottom = pointFromVector(
+ vectorScale(
+ vectorFromPoint(
+ pointRotateRads(
+ pointFrom(
+ element.x + element.width / 2,
+ element.y + element.height,
+ ),
+ midPoint,
+ element.angle,
+ ),
+ midPoint,
+ ),
+ SHRINK,
+ ),
+ midPoint,
+ );
+ const left = pointFromVector(
+ vectorScale(
+ vectorFromPoint(
+ pointRotateRads(
+ pointFrom(element.x, element.y + element.height / 2),
+ midPoint,
+ element.angle,
+ ),
+ midPoint,
+ ),
+ SHRINK,
+ ),
+ midPoint,
+ );
+
+ // Corners
+ if (
+ vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
+ 0 &&
+ vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
+ ) {
+ return headingForPoint(top, midPoint);
+ } else if (
+ vectorCross(
+ vectorFromPoint(point, right),
+ vectorFromPoint(right, bottom),
+ ) <= 0 &&
+ vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
+ ) {
+ return headingForPoint(right, midPoint);
+ } else if (
+ vectorCross(
+ vectorFromPoint(point, bottom),
+ vectorFromPoint(bottom, left),
+ ) <= 0 &&
+ vectorCross(
+ vectorFromPoint(point, bottom),
+ vectorFromPoint(bottom, right),
+ ) > 0
+ ) {
+ return headingForPoint(bottom, midPoint);
+ } else if (
+ vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
+ 0 &&
+ vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
+ ) {
+ return headingForPoint(left, midPoint);
+ }
+
+ // Sides
+ if (
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(top, midPoint),
+ ) <= 0 &&
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(right, midPoint),
+ ) > 0
+ ) {
+ const p = element.width > element.height ? top : right;
+ return headingForPoint(p, midPoint);
+ } else if (
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(right, midPoint),
+ ) <= 0 &&
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(bottom, midPoint),
+ ) > 0
+ ) {
+ const p = element.width > element.height ? bottom : right;
+ return headingForPoint(p, midPoint);
+ } else if (
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(bottom, midPoint),
+ ) <= 0 &&
+ vectorCross(
+ vectorFromPoint(point, midPoint),
+ vectorFromPoint(left, midPoint),
+ ) > 0
+ ) {
+ const p = element.width > element.height ? bottom : left;
+ return headingForPoint(p, midPoint);
+ }
+
+ const p = element.width > element.height ? top : left;
+ return headingForPoint(p, midPoint);
+};
+
+// Gets the heading for the point by creating a bounding box around the rotated
+// close fitting bounding box, then creating 4 search cones around the center of
+// the external bbox.
+export const headingForPointFromElement = (
+ element: Readonly,
+ aabb: Readonly,
+ p: Readonly,
+): Heading => {
+ const SEARCH_CONE_MULTIPLIER = 2;
+
+ const midPoint = getCenterForBounds(aabb);
+
+ if (element.type === "diamond") {
+ return headingForPointFromDiamondElement(element, aabb, p);
+ }
+
+ const topLeft = pointScaleFromOrigin(
+ pointFrom(aabb[0], aabb[1]),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ) as Point;
+ const topRight = pointScaleFromOrigin(
+ pointFrom(aabb[2], aabb[1]),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ) as Point;
+ const bottomLeft = pointScaleFromOrigin(
+ pointFrom(aabb[0], aabb[3]),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ) as Point;
+ const bottomRight = pointScaleFromOrigin(
+ pointFrom(aabb[2], aabb[3]),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ) as Point;
+
+ return triangleIncludesPoint(
+ [topLeft, topRight, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_UP
+ : triangleIncludesPoint(
+ [topRight, bottomRight, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_RIGHT
+ : triangleIncludesPoint(
+ [bottomRight, bottomLeft, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_DOWN
+ : HEADING_LEFT;
+};
+
+export const flipHeading = (h: Heading): Heading =>
+ [
+ h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
+ h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
+ ] as Heading;
diff --git a/packages/excalidraw/element/image.ts b/packages/element/src/image.ts
similarity index 77%
rename from packages/excalidraw/element/image.ts
rename to packages/element/src/image.ts
index bd9bcd6270..562b489048 100644
--- a/packages/excalidraw/element/image.ts
+++ b/packages/element/src/image.ts
@@ -2,11 +2,17 @@
// ExcalidrawImageElement & related helpers
// -----------------------------------------------------------------------------
-import { MIME_TYPES, SVG_NS } from "../constants";
-import { t } from "../i18n";
-import { AppClassProperties, DataURL, BinaryFiles } from "../types";
+import { MIME_TYPES, SVG_NS } from "@excalidraw/common";
+
+import type {
+ AppClassProperties,
+ DataURL,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+
import { isInitializedImageElement } from "./typeChecks";
-import {
+
+import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
@@ -95,31 +101,53 @@ export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
return node?.nodeName.toLowerCase() === "svg";
};
-export const normalizeSVG = async (SVGString: string) => {
+export const normalizeSVG = (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
- throw new Error(t("errors.invalidSVGString"));
+ throw new Error("Invalid SVG");
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
}
- if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
- const viewBox = svg.getAttribute("viewBox");
- let width = svg.getAttribute("width") || "50";
- let height = svg.getAttribute("height") || "50";
+ let width = svg.getAttribute("width");
+ let height = svg.getAttribute("height");
+
+ // Do not use % or auto values for width/height
+ // to avoid scaling issues when rendering at different sizes/zoom levels
+ if (width?.includes("%") || width === "auto") {
+ width = null;
+ }
+ if (height?.includes("%") || height === "auto") {
+ height = null;
+ }
+
+ const viewBox = svg.getAttribute("viewBox");
+
+ if (!width || !height) {
+ width = width || "50";
+ height = height || "50";
+
if (viewBox) {
- const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
+ const match = viewBox.match(
+ /\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
+ );
if (match) {
[, width, height] = match;
}
}
+
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
+ // Make sure viewBox is set
+ if (!viewBox) {
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ }
+
return svg.outerHTML;
}
};
diff --git a/packages/excalidraw/element/index.ts b/packages/element/src/index.ts
similarity index 52%
rename from packages/excalidraw/element/index.ts
rename to packages/element/src/index.ts
index 093ef48290..d7edec8ae9 100644
--- a/packages/excalidraw/element/index.ts
+++ b/packages/element/src/index.ts
@@ -1,68 +1,42 @@
-import {
+import { isInvisiblySmallElement } from "./sizeHelpers";
+import { isLinearElementType } from "./typeChecks";
+
+import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
} from "./types";
-import { isInvisiblySmallElement } from "./sizeHelpers";
-import { isLinearElementType } from "./typeChecks";
-
-export {
- newElement,
- newTextElement,
- updateTextElement,
- refreshTextDimensions,
- newLinearElement,
- newImageElement,
- duplicateElement,
-} from "./newElement";
-export {
- getElementAbsoluteCoords,
- getElementBounds,
- getCommonBounds,
- getDiamondPoints,
- getArrowheadPoints,
- getClosestElementBounds,
-} from "./bounds";
-
-export {
- OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
- getTransformHandlesFromCoords,
- getTransformHandles,
-} from "./transformHandles";
-export {
- hitTest,
- isHittingElementBoundingBoxWithoutHittingElement,
-} from "./collision";
-export {
- resizeTest,
- getCursorForResizingElement,
- getElementWithTransformHandleType,
- getTransformHandleTypeFromCoords,
-} from "./resizeTest";
-export {
- transformElements,
- getResizeOffsetXY,
- getResizeArrowDirection,
-} from "./resizeElements";
-export {
- dragSelectedElements,
- getDragOffsetXY,
- dragNewElement,
-} from "./dragElements";
-export { isTextElement, isExcalidrawElement } from "./typeChecks";
-export { redrawTextBoundingBox } from "./textElement";
-export {
- getPerfectElementSize,
- getLockedLinearCursorAlignSize,
- isInvisiblySmallElement,
- resizePerfectLineForNWHandler,
- getNormalizedDimensions,
-} from "./sizeHelpers";
-export { showSelectedShapeActions } from "./showSelectedShapeActions";
+/**
+ * @deprecated unsafe, use hashElementsVersion instead
+ */
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
elements.reduce((acc, el) => acc + el.version, 0);
+/**
+ * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
+ */
+export const hashElementsVersion = (
+ elements: readonly ExcalidrawElement[],
+): number => {
+ let hash = 5381;
+ for (let i = 0; i < elements.length; i++) {
+ hash = (hash << 5) + hash + elements[i].versionNonce;
+ }
+ return hash >>> 0; // Ensure unsigned 32-bit integer
+};
+
+// string hash function (using djb2). Not cryptographically secure, use only
+// for versioning and such.
+export const hashString = (s: string): number => {
+ let hash: number = 5381;
+ for (let i = 0; i < s.length; i++) {
+ const char: number = s.charCodeAt(i);
+ hash = (hash << 5) + hash + char;
+ }
+ return hash >>> 0; // Ensure unsigned 32-bit integer
+};
+
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter(
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts
similarity index 61%
rename from packages/excalidraw/element/linearElementEditor.ts
rename to packages/element/src/linearElementEditor.ts
index 5c3c6acaa2..8a9117bf88 100644
--- a/packages/excalidraw/element/linearElementEditor.ts
+++ b/packages/element/src/linearElementEditor.ts
@@ -1,4 +1,80 @@
import {
+ pointCenter,
+ pointFrom,
+ pointRotateRads,
+ pointsEqual,
+ type GlobalPoint,
+ type LocalPoint,
+ pointDistance,
+ vectorFromPoint,
+} from "@excalidraw/math";
+
+import { getCurvePathOps } from "@excalidraw/utils/shape";
+
+import {
+ DRAGGING_THRESHOLD,
+ KEYS,
+ shouldRotateWithDiscreteAngle,
+ getGridPoint,
+ invariant,
+ tupleToCoors,
+} from "@excalidraw/common";
+
+// TODO: remove direct dependency on the scene, should be passed in or injected instead
+// eslint-disable-next-line @typescript-eslint/no-restricted-imports
+import Scene from "@excalidraw/excalidraw/scene/Scene";
+
+import type { Store } from "@excalidraw/excalidraw/store";
+
+import type { Radians } from "@excalidraw/math";
+
+import type {
+ AppState,
+ PointerCoords,
+ InteractiveCanvasAppState,
+ AppClassProperties,
+ NullableGridSize,
+ Zoom,
+} from "@excalidraw/excalidraw/types";
+
+import type { Mutable } from "@excalidraw/common/utility-types";
+
+import {
+ bindOrUnbindLinearElement,
+ getHoveredElementForBinding,
+ isBindingEnabled,
+} from "./binding";
+import {
+ getElementAbsoluteCoords,
+ getElementPointsCoords,
+ getMinMaxXYFromCurvePathOps,
+} from "./bounds";
+
+import { updateElbowArrowPoints } from "./elbowArrow";
+
+import { headingIsHorizontal, vectorToHeading } from "./heading";
+import { bumpVersion, mutateElement } from "./mutateElement";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
+import {
+ isBindingElement,
+ isElbowArrow,
+ isFixedPointBinding,
+} from "./typeChecks";
+
+import { ShapeCache } from "./ShapeCache";
+
+import {
+ isPathALoop,
+ getBezierCurveLength,
+ getControlPointsForBezierCurve,
+ mapIntervalToBezierT,
+ getBezierXY,
+} from "./shapes";
+
+import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
+
+import type { Bounds } from "./bounds";
+import type {
NonDeleted,
ExcalidrawLinearElement,
ExcalidrawElement,
@@ -6,53 +82,16 @@ import {
ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
ElementsMap,
+ NonDeletedSceneElementsMap,
+ FixedPointBinding,
+ SceneElementsMap,
+ FixedSegment,
+ ExcalidrawElbowArrowElement,
} from "./types";
-import {
- distance2d,
- rotate,
- isPathALoop,
- getGridPoint,
- rotatePoint,
- centerPoint,
- getControlPointsForBezierCurve,
- getBezierXY,
- getBezierCurveLength,
- mapIntervalToBezierT,
- arePointsEqual,
-} from "../math";
-import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
-import {
- Bounds,
- getCurvePathOps,
- getElementPointsCoords,
- getMinMaxXYFromCurvePathOps,
-} from "./bounds";
-import {
- Point,
- AppState,
- PointerCoords,
- InteractiveCanvasAppState,
-} from "../types";
-import { mutateElement } from "./mutateElement";
-import History from "../history";
-
-import Scene from "../scene/Scene";
-import {
- bindOrUnbindLinearElement,
- getHoveredElementForBinding,
- isBindingEnabled,
-} from "./binding";
-import { tupleToCoors } from "../utils";
-import { isBindingElement } from "./typeChecks";
-import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
-import { getBoundTextElement, handleBindTextResize } from "./textElement";
-import { DRAGGING_THRESHOLD } from "../constants";
-import { Mutable } from "../utility-types";
-import { ShapeCache } from "../scene/ShapeCache";
const editorMidPointsCache: {
version: number | null;
- points: (Point | null)[];
+ points: (GlobalPoint | null)[];
zoom: number | null;
} = { version: null, points: [], zoom: null };
export class LinearElementEditor {
@@ -66,9 +105,10 @@ export class LinearElementEditor {
prevSelectedPointsIndices: readonly number[] | null;
/** index */
lastClickedPoint: number;
+ lastClickedIsEndPoint: boolean;
origin: Readonly<{ x: number; y: number }> | null;
segmentMidpoint: {
- value: Point | null;
+ value: GlobalPoint | null;
index: number | null;
added: boolean;
};
@@ -76,7 +116,7 @@ export class LinearElementEditor {
/** whether you're dragging a point */
public readonly isDragging: boolean;
- public readonly lastUncommittedPoint: Point | null;
+ public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
| ExcalidrawBindableElement
@@ -84,14 +124,17 @@ export class LinearElementEditor {
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
- public readonly segmentMidPointHoveredCoords: Point | null;
+ public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
+ public readonly elbowed: boolean;
- constructor(element: NonDeleted, scene: Scene) {
+ constructor(element: NonDeleted) {
this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId";
};
- Scene.mapElementToScene(this.elementId, scene);
- LinearElementEditor.normalizePoints(element);
+ if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
+ console.error("Linear element is not normalized", Error().stack);
+ LinearElementEditor.normalizePoints(element);
+ }
this.selectedPointsIndices = null;
this.lastUncommittedPoint = null;
@@ -102,6 +145,7 @@ export class LinearElementEditor {
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
+ lastClickedIsEndPoint: false,
origin: null,
segmentMidpoint: {
@@ -112,6 +156,7 @@ export class LinearElementEditor {
};
this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null;
+ this.elbowed = isElbowArrow(element) && element.elbowed;
}
// ---------------------------------------------------------------------------
@@ -123,10 +168,13 @@ export class LinearElementEditor {
* @param id the `elementId` from the instance of this class (so that we can
* statically guarantee this method returns an ExcalidrawLinearElement)
*/
- static getElement(id: InstanceType["elementId"]) {
- const element = Scene.getScene(id)?.getNonDeletedElement(id);
+ static getElement(
+ id: InstanceType["elementId"],
+ elementsMap: ElementsMap,
+ ): T | null {
+ const element = elementsMap.get(id);
if (element) {
- return element as NonDeleted;
+ return element as NonDeleted;
}
return null;
}
@@ -135,29 +183,29 @@ export class LinearElementEditor {
event: PointerEvent,
appState: AppState,
setState: React.Component["setState"],
+ elementsMap: NonDeletedSceneElementsMap,
) {
- if (
- !appState.editingLinearElement ||
- appState.draggingElement?.type !== "selection"
- ) {
+ if (!appState.editingLinearElement || !appState.selectionElement) {
return false;
}
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement;
- const element = LinearElementEditor.getElement(elementId);
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return false;
}
const [selectionX1, selectionY1, selectionX2, selectionY2] =
- getElementAbsoluteCoords(appState.draggingElement);
+ getElementAbsoluteCoords(appState.selectionElement, elementsMap);
- const pointsSceneCoords =
- LinearElementEditor.getPointsGlobalCoordinates(element);
+ const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
- const nextSelectedPoints = pointsSceneCoords.reduce(
- (acc: number[], point, index) => {
+ const nextSelectedPoints = pointsSceneCoords
+ .reduce((acc: number[], point, index) => {
if (
(point[0] >= selectionX1 &&
point[0] <= selectionX2 &&
@@ -169,9 +217,17 @@ export class LinearElementEditor {
}
return acc;
- },
- [],
- );
+ }, [])
+ .filter((index) => {
+ if (
+ isElbowArrow(element) &&
+ index !== 0 &&
+ index !== element.points.length - 1
+ ) {
+ return false;
+ }
+ return true;
+ });
setState({
editingLinearElement: {
@@ -183,10 +239,12 @@ export class LinearElementEditor {
});
}
- /** @returns whether point was dragged */
+ /**
+ * @returns whether point was dragged
+ */
static handlePointDragging(
event: PointerEvent,
- appState: AppState,
+ app: AppClassProperties,
scenePointerX: number,
scenePointerY: number,
maybeSuggestBinding: (
@@ -194,21 +252,44 @@ export class LinearElementEditor {
pointSceneCoords: { x: number; y: number }[],
) => void,
linearElementEditor: LinearElementEditor,
- elementsMap: ElementsMap,
- ): boolean {
+ scene: Scene,
+ ): LinearElementEditor | null {
if (!linearElementEditor) {
- return false;
+ return null;
}
- const { selectedPointsIndices, elementId } = linearElementEditor;
- const element = LinearElementEditor.getElement(elementId);
+ const { elementId } = linearElementEditor;
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
- return false;
+ return null;
}
+ if (
+ isElbowArrow(element) &&
+ !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
+ linearElementEditor.pointerDownState.lastClickedPoint !== 0
+ ) {
+ return null;
+ }
+
+ const selectedPointsIndices = isElbowArrow(element)
+ ? [
+ !!linearElementEditor.selectedPointsIndices?.includes(0)
+ ? 0
+ : undefined,
+ !!linearElementEditor.selectedPointsIndices?.find((idx) => idx > 0)
+ ? element.points.length - 1
+ : undefined,
+ ].filter((idx): idx is number => idx !== undefined)
+ : linearElementEditor.selectedPointsIndices;
+ const lastClickedPoint = isElbowArrow(element)
+ ? linearElementEditor.pointerDownState.lastClickedPoint > 0
+ ? element.points.length - 1
+ : 0
+ : linearElementEditor.pointerDownState.lastClickedPoint;
+
// point that's being dragged (out of all selected points)
- const draggingPoint = element.points[
- linearElementEditor.pointerDownState.lastClickedPoint
- ] as [number, number] | undefined;
+ const draggingPoint = element.points[lastClickedPoint];
if (selectedPointsIndices && draggingPoint) {
if (
@@ -222,26 +303,29 @@ export class LinearElementEditor {
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
+ elementsMap,
referencePoint,
- [scenePointerX, scenePointerY],
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ pointFrom(scenePointerX, scenePointerY),
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
LinearElementEditor.movePoints(element, [
{
index: selectedIndex,
- point: [width + referencePoint[0], height + referencePoint[1]],
- isDragging:
- selectedIndex ===
- linearElementEditor.pointerDownState.lastClickedPoint,
+ point: pointFrom(
+ width + referencePoint[0],
+ height + referencePoint[1],
+ ),
+ isDragging: selectedIndex === lastClickedPoint,
},
]);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
element,
+ elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
@@ -250,25 +334,23 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(
element,
selectedPointsIndices.map((pointIndex) => {
- const newPointPosition =
- pointIndex ===
- linearElementEditor.pointerDownState.lastClickedPoint
+ const newPointPosition: LocalPoint =
+ pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
+ elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
)
- : ([
+ : pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
- ] as const);
+ );
return {
index: pointIndex,
point: newPointPosition,
- isDragging:
- pointIndex ===
- linearElementEditor.pointerDownState.lastClickedPoint,
+ isDragging: pointIndex === lastClickedPoint,
};
}),
);
@@ -290,6 +372,7 @@ export class LinearElementEditor {
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[0],
+ elementsMap,
),
),
);
@@ -303,6 +386,7 @@ export class LinearElementEditor {
LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[lastSelectedIndex],
+ elementsMap,
),
),
);
@@ -313,20 +397,42 @@ export class LinearElementEditor {
}
}
- return true;
+ return {
+ ...linearElementEditor,
+ selectedPointsIndices,
+ segmentMidPointHoveredCoords:
+ lastClickedPoint !== 0 &&
+ lastClickedPoint !== element.points.length - 1
+ ? this.getPointGlobalCoordinates(
+ element,
+ draggingPoint,
+ elementsMap,
+ )
+ : null,
+ hoverPointIndex:
+ lastClickedPoint === 0 ||
+ lastClickedPoint === element.points.length - 1
+ ? lastClickedPoint
+ : -1,
+ isDragging: true,
+ };
}
- return false;
+ return null;
}
static handlePointerUp(
event: PointerEvent,
editingLinearElement: LinearElementEditor,
appState: AppState,
+ scene: Scene,
): LinearElementEditor {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
- const element = LinearElementEditor.getElement(elementId);
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return editingLinearElement;
}
@@ -364,9 +470,14 @@ export class LinearElementEditor {
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
selectedPoint!,
+ elementsMap,
),
),
- Scene.getScene(element)!,
+ elements,
+ elementsMap,
+ appState.zoom,
+ isElbowArrow(element),
+ isElbowArrow(element),
)
: null;
@@ -413,6 +524,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
+ !isElbowArrow(element) &&
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
@@ -425,24 +537,33 @@ export class LinearElementEditor {
) {
return editorMidPointsCache.points;
}
- LinearElementEditor.updateEditorMidPointsCache(element, appState);
+ LinearElementEditor.updateEditorMidPointsCache(
+ element,
+ elementsMap,
+ appState,
+ );
return editorMidPointsCache.points!;
};
static updateEditorMidPointsCache = (
element: NonDeleted,
+ elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
) => {
- const points = LinearElementEditor.getPointsGlobalCoordinates(element);
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
let index = 0;
- const midpoints: (Point | null)[] = [];
+ const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) {
if (
LinearElementEditor.isSegmentTooShort(
element,
element.points[index],
element.points[index + 1],
+ index,
appState.zoom,
)
) {
@@ -455,6 +576,7 @@ export class LinearElementEditor {
points[index],
points[index + 1],
index + 1,
+ elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -469,37 +591,46 @@ export class LinearElementEditor {
scenePointer: { x: number; y: number },
appState: AppState,
elementsMap: ElementsMap,
- ) => {
+ ): GlobalPoint | null => {
const { elementId } = linearElementEditor;
- const element = LinearElementEditor.getElement(elementId);
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return null;
}
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
+ elementsMap,
appState.zoom,
scenePointer.x,
scenePointer.y,
);
- if (clickedPointIndex >= 0) {
+ if (!isElbowArrow(element) && clickedPointIndex >= 0) {
return null;
}
- const points = LinearElementEditor.getPointsGlobalCoordinates(element);
- if (points.length >= 3 && !appState.editingLinearElement) {
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
+ if (
+ points.length >= 3 &&
+ !appState.editingLinearElement &&
+ !isElbowArrow(element)
+ ) {
return null;
}
const threshold =
- LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
+ (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
const existingSegmentMidpointHitCoords =
linearElementEditor.segmentMidPointHoveredCoords;
if (existingSegmentMidpointHitCoords) {
- const distance = distance2d(
- existingSegmentMidpointHitCoords[0],
- existingSegmentMidpointHitCoords[1],
- scenePointer.x,
- scenePointer.y,
+ const distance = pointDistance(
+ pointFrom(
+ existingSegmentMidpointHitCoords[0],
+ existingSegmentMidpointHitCoords[1],
+ ),
+ pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return existingSegmentMidpointHitCoords;
@@ -508,13 +639,12 @@ export class LinearElementEditor {
let index = 0;
const midPoints: typeof editorMidPointsCache["points"] =
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
+
while (index < midPoints.length) {
if (midPoints[index] !== null) {
- const distance = distance2d(
- midPoints[index]![0],
- midPoints[index]![1],
- scenePointer.x,
- scenePointer.y,
+ const distance = pointDistance(
+ midPoints[index]!,
+ pointFrom(scenePointer.x, scenePointer.y),
);
if (distance <= threshold) {
return midPoints[index];
@@ -526,18 +656,25 @@ export class LinearElementEditor {
return null;
};
- static isSegmentTooShort(
+ static isSegmentTooShort(
element: NonDeleted,
- startPoint: Point,
- endPoint: Point,
- zoom: AppState["zoom"],
+ startPoint: P,
+ endPoint: P,
+ index: number,
+ zoom: Zoom,
) {
- let distance = distance2d(
- startPoint[0],
- startPoint[1],
- endPoint[0],
- endPoint[1],
- );
+ if (isElbowArrow(element)) {
+ if (index >= 0 && index < element.points.length) {
+ return (
+ pointDistance(startPoint, endPoint) * zoom.value <
+ LinearElementEditor.POINT_HANDLE_SIZE / 2
+ );
+ }
+
+ return false;
+ }
+
+ let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
distance = getBezierCurveLength(element, endPoint);
}
@@ -547,11 +684,12 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted,
- startPoint: Point,
- endPoint: Point,
+ startPoint: GlobalPoint,
+ endPoint: GlobalPoint,
endPointIndex: number,
- ) {
- let segmentMidPoint = centerPoint(startPoint, endPoint);
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
+ let segmentMidPoint = pointCenter(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
const controlPoints = getControlPointsForBezierCurve(
element,
@@ -564,16 +702,16 @@ export class LinearElementEditor {
0.5,
);
- const [tx, ty] = getBezierXY(
- controlPoints[0],
- controlPoints[1],
- controlPoints[2],
- controlPoints[3],
- t,
- );
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
- [tx, ty],
+ getBezierXY(
+ controlPoints[0],
+ controlPoints[1],
+ controlPoints[2],
+ controlPoints[3],
+ t,
+ ),
+ elementsMap,
);
}
}
@@ -584,11 +722,12 @@ export class LinearElementEditor {
static getSegmentMidPointIndex(
linearElementEditor: LinearElementEditor,
appState: AppState,
- midPoint: Point,
+ midPoint: GlobalPoint,
elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
+ elementsMap,
);
if (!element) {
return -1;
@@ -610,16 +749,20 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent,
- appState: AppState,
- history: History,
+ app: AppClassProperties,
+ store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
- elementsMap: ElementsMap,
+ scene: Scene,
): {
didAddPoint: boolean;
hitElement: NonDeleted | null;
linearElementEditor: LinearElementEditor | null;
} {
+ const appState = app.state;
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+
const ret: ReturnType = {
didAddPoint: false,
hitElement: null,
@@ -631,7 +774,7 @@ export class LinearElementEditor {
}
const { elementId } = linearElementEditor;
- const element = LinearElementEditor.getElement(elementId);
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return ret;
@@ -650,28 +793,29 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
- }
- if (event.altKey && appState.editingLinearElement) {
+ } else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, {
points: [
...element.points,
LinearElementEditor.createPointAt(
element,
+ elementsMap,
scenePointer.x,
scenePointer.y,
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
),
],
});
ret.didAddPoint = true;
}
- history.resumeRecording();
+ store.shouldCaptureIncrement();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: -1,
+ lastClickedIsEndPoint: false,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
@@ -683,7 +827,10 @@ export class LinearElementEditor {
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
- Scene.getScene(element)!,
+ elements,
+ elementsMap,
+ app.state.zoom,
+ linearElementEditor.elbowed,
),
};
@@ -693,6 +840,7 @@ export class LinearElementEditor {
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
element,
+ elementsMap,
appState.zoom,
scenePointer.x,
scenePointer.y,
@@ -713,20 +861,23 @@ export class LinearElementEditor {
element,
startBindingElement,
endBindingElement,
+ elementsMap,
+ scene,
);
}
}
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const targetPoint =
clickedPointIndex > -1 &&
- rotate(
- element.x + element.points[clickedPointIndex][0],
- element.y + element.points[clickedPointIndex][1],
- cx,
- cy,
+ pointRotateRads(
+ pointFrom(
+ element.x + element.points[clickedPointIndex][0],
+ element.y + element.points[clickedPointIndex][1],
+ ),
+ pointFrom(cx, cy),
element.angle,
);
@@ -745,6 +896,7 @@ export class LinearElementEditor {
pointerDownState: {
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
lastClickedPoint: clickedPointIndex,
+ lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1,
origin: { x: scenePointer.x, y: scenePointer.y },
segmentMidpoint: {
value: segmentMidpoint,
@@ -764,27 +916,32 @@ export class LinearElementEditor {
return ret;
}
- static arePointsEqual(point1: Point | null, point2: Point | null) {
+ static arePointsEqual(
+ point1: Point | null,
+ point2: Point | null,
+ ) {
if (!point1 && !point2) {
return true;
}
if (!point1 || !point2) {
return false;
}
- return arePointsEqual(point1, point2);
+ return pointsEqual(point1, point2);
}
static handlePointerMove(
event: React.PointerEvent,
scenePointerX: number,
scenePointerY: number,
- appState: AppState,
+ app: AppClassProperties,
+ elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null {
+ const appState = app.state;
if (!appState.editingLinearElement) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
- const element = LinearElementEditor.getElement(elementId);
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.editingLinearElement;
}
@@ -802,28 +959,32 @@ export class LinearElementEditor {
};
}
- let newPoint: Point;
+ let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
+ elementsMap,
lastCommittedPoint,
- [scenePointerX, scenePointerY],
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ pointFrom(scenePointerX, scenePointerY),
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
- newPoint = [
+ newPoint = pointFrom(
width + lastCommittedPoint[0],
height + lastCommittedPoint[1],
- ];
+ );
} else {
newPoint = LinearElementEditor.createPointAt(
element,
+ elementsMap,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
- event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize,
+ event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
+ ? null
+ : app.getEffectiveGridSize(),
);
}
@@ -835,7 +996,7 @@ export class LinearElementEditor {
},
]);
} else {
- LinearElementEditor.addPoints(element, appState, [{ point: newPoint }]);
+ LinearElementEditor.addPoints(element, [{ point: newPoint }]);
}
return {
...appState.editingLinearElement,
@@ -846,83 +1007,107 @@ export class LinearElementEditor {
/** scene coords */
static getPointGlobalCoordinates(
element: NonDeleted,
- point: Point,
- ) {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ p: LocalPoint,
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
- let { x, y } = element;
- [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
- return [x, y] as const;
+ const { x, y } = element;
+ return pointRotateRads(
+ pointFrom(x + p[0], y + p[1]),
+ pointFrom(cx, cy),
+ element.angle,
+ );
}
/** scene coords */
static getPointsGlobalCoordinates(
element: NonDeleted,
- ): Point[] {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ elementsMap: ElementsMap,
+ ): GlobalPoint[] {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
- return element.points.map((point) => {
- let { x, y } = element;
- [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
- return [x, y] as const;
+ return element.points.map((p) => {
+ const { x, y } = element;
+ return pointRotateRads(
+ pointFrom(x + p[0], y + p[1]),
+ pointFrom(cx, cy),
+ element.angle,
+ );
});
}
static getPointAtIndexGlobalCoordinates(
element: NonDeleted,
+
indexMaybeFromEnd: number, // -1 for last element
- ): Point {
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
const index =
indexMaybeFromEnd < 0
? element.points.length + indexMaybeFromEnd
: indexMaybeFromEnd;
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
-
- const point = element.points[index];
+ const p = element.points[index];
const { x, y } = element;
- return point
- ? rotate(x + point[0], y + point[1], cx, cy, element.angle)
- : rotate(x, y, cx, cy, element.angle);
+
+ return p
+ ? pointRotateRads(
+ pointFrom(x + p[0], y + p[1]),
+ pointFrom(cx, cy),
+ element.angle,
+ )
+ : pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
}
static pointFromAbsoluteCoords(
element: NonDeleted,
- absoluteCoords: Point,
- ): Point {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ absoluteCoords: GlobalPoint,
+ elementsMap: ElementsMap,
+ ): LocalPoint {
+ if (isElbowArrow(element)) {
+ // No rotation for elbow arrows
+ return pointFrom(
+ absoluteCoords[0] - element.x,
+ absoluteCoords[1] - element.y,
+ );
+ }
+
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
- const [x, y] = rotate(
- absoluteCoords[0],
- absoluteCoords[1],
- cx,
- cy,
- -element.angle,
+ const [x, y] = pointRotateRads(
+ pointFrom(absoluteCoords[0], absoluteCoords[1]),
+ pointFrom(cx, cy),
+ -element.angle as Radians,
);
- return [x - element.x, y - element.y];
+ return pointFrom(x - element.x, y - element.y);
}
static getPointIndexUnderCursor(
element: NonDeleted,
+ elementsMap: ElementsMap,
zoom: AppState["zoom"],
x: number,
y: number,
) {
- const pointHandles =
- LinearElementEditor.getPointsGlobalCoordinates(element);
+ const pointHandles = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
let idx = pointHandles.length;
// loop from right to left because points on the right are rendered over
// points on the left, thus should take precedence when clicking, if they
// overlap
while (--idx > -1) {
- const point = pointHandles[idx];
+ const p = pointHandles[idx];
if (
- distance2d(x, y, point[0], point[1]) * zoom.value <
+ pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
// +1px to account for outline stroke
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
@@ -934,23 +1119,22 @@ export class LinearElementEditor {
static createPointAt(
element: NonDeleted,
+ elementsMap: ElementsMap,
scenePointerX: number,
scenePointerY: number,
- gridSize: number | null,
- ): Point {
+ gridSize: NullableGridSize,
+ ): LocalPoint {
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
- const [rotatedX, rotatedY] = rotate(
- pointerOnGrid[0],
- pointerOnGrid[1],
- cx,
- cy,
- -element.angle,
+ const [rotatedX, rotatedY] = pointRotateRads(
+ pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
+ pointFrom(cx, cy),
+ -element.angle as Radians,
);
- return [rotatedX - element.x, rotatedY - element.y];
+ return pointFrom(rotatedX - element.x, rotatedY - element.y);
}
/**
@@ -958,15 +1142,19 @@ export class LinearElementEditor {
* expected in various parts of the codebase. Also returns new x/y to account
* for the potential normalization.
*/
- static getNormalizedPoints(element: ExcalidrawLinearElement) {
+ static getNormalizedPoints(element: ExcalidrawLinearElement): {
+ points: LocalPoint[];
+ x: number;
+ y: number;
+ } {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
- points: points.map((point, _idx) => {
- return [point[0] - offsetX, point[1] - offsetY] as const;
+ points: points.map((p) => {
+ return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX,
y: element.y + offsetY,
@@ -980,18 +1168,26 @@ export class LinearElementEditor {
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
}
- static duplicateSelectedPoints(appState: AppState) {
- if (!appState.editingLinearElement) {
- return false;
- }
+ static duplicateSelectedPoints(
+ appState: AppState,
+ elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+ ): AppState {
+ invariant(
+ appState.editingLinearElement,
+ "Not currently editing a linear element",
+ );
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
- const element = LinearElementEditor.getElement(elementId);
-
- if (!element || selectedPointsIndices === null) {
- return false;
- }
+ invariant(
+ element,
+ "The linear element does not exist in the provided Scene",
+ );
+ invariant(
+ selectedPointsIndices != null,
+ "There are no selected points to duplicate",
+ );
const { points } = element;
@@ -999,9 +1195,9 @@ export class LinearElementEditor {
let pointAddedToEnd = false;
let indexCursor = -1;
- const nextPoints = points.reduce((acc: Point[], point, index) => {
+ const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
++indexCursor;
- acc.push(point);
+ acc.push(p);
const isSelected = selectedPointsIndices.includes(index);
if (isSelected) {
@@ -1012,8 +1208,8 @@ export class LinearElementEditor {
}
acc.push(
nextPoint
- ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
- : [point[0], point[1]],
+ ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
+ : pointFrom(p[0], p[1]),
);
nextSelectedIndices.push(indexCursor + 1);
@@ -1032,18 +1228,16 @@ export class LinearElementEditor {
LinearElementEditor.movePoints(element, [
{
index: element.points.length - 1,
- point: [lastPoint[0] + 30, lastPoint[1] + 30],
+ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
},
]);
}
return {
- appState: {
- ...appState,
- editingLinearElement: {
- ...appState.editingLinearElement,
- selectedPointsIndices: nextSelectedIndices,
- },
+ ...appState,
+ editingLinearElement: {
+ ...appState.editingLinearElement,
+ selectedPointsIndices: nextSelectedIndices,
},
};
}
@@ -1069,10 +1263,12 @@ export class LinearElementEditor {
}
}
- const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
+ const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
if (!pointIndices.includes(idx)) {
acc.push(
- !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
+ !acc.length
+ ? pointFrom(0, 0)
+ : pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
@@ -1083,8 +1279,7 @@ export class LinearElementEditor {
static addPoints(
element: NonDeleted,
- appState: AppState,
- targetPoints: { point: Point }[],
+ targetPoints: { point: LocalPoint }[],
) {
const offsetX = 0;
const offsetY = 0;
@@ -1095,8 +1290,12 @@ export class LinearElementEditor {
static movePoints(
element: NonDeleted,
- targetPoints: { index: number; point: Point; isDragging?: boolean }[],
- otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
+ targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
+ otherUpdates?: {
+ startBinding?: PointBinding | null;
+ endBinding?: PointBinding | null;
+ },
+ sceneElementsMap?: NonDeletedSceneElementsMap,
) {
const { points } = element;
@@ -1105,36 +1304,28 @@ export class LinearElementEditor {
// all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user.
- let offsetX = 0;
- let offsetY = 0;
+ const [deltaX, deltaY] =
+ targetPoints.find(({ index }) => index === 0)?.point ??
+ pointFrom(0, 0);
+ const [offsetX, offsetY] = pointFrom(
+ deltaX - points[0][0],
+ deltaY - points[0][1],
+ );
- const selectedOriginPoint = targetPoints.find(({ index }) => index === 0);
+ const nextPoints = isElbowArrow(element)
+ ? [
+ targetPoints.find((t) => t.index === 0)?.point ?? points[0],
+ targetPoints.find((t) => t.index === points.length - 1)?.point ??
+ points[points.length - 1],
+ ]
+ : points.map((p, idx) => {
+ const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
- if (selectedOriginPoint) {
- offsetX =
- selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
- offsetY =
- selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
- }
-
- const nextPoints = points.map((point, idx) => {
- const selectedPointData = targetPoints.find((p) => p.index === idx);
- if (selectedPointData) {
- if (selectedOriginPoint) {
- return point;
- }
-
- const deltaX =
- selectedPointData.point[0] - points[selectedPointData.index][0];
- const deltaY =
- selectedPointData.point[1] - points[selectedPointData.index][1];
-
- return [point[0] + deltaX, point[1] + deltaY] as const;
- }
- return offsetX || offsetY
- ? ([point[0] - offsetX, point[1] - offsetY] as const)
- : point;
- });
+ return pointFrom(
+ current[0] - offsetX,
+ current[1] - offsetY,
+ );
+ });
LinearElementEditor._updatePoints(
element,
@@ -1142,6 +1333,14 @@ export class LinearElementEditor {
offsetX,
offsetY,
otherUpdates,
+ {
+ isDragging: targetPoints.reduce(
+ (dragging, targetPoint): boolean =>
+ dragging || targetPoint.isDragging === true,
+ false,
+ ),
+ sceneElementsMap,
+ },
);
}
@@ -1149,11 +1348,18 @@ export class LinearElementEditor {
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
appState: AppState,
+ elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
+ elementsMap,
);
+ // Elbow arrows don't allow midpoints
+ if (element && isElbowArrow(element)) {
+ return false;
+ }
+
if (!element) {
return false;
}
@@ -1170,11 +1376,9 @@ export class LinearElementEditor {
}
const origin = linearElementEditor.pointerDownState.origin!;
- const dist = distance2d(
- origin.x,
- origin.y,
- pointerCoords.x,
- pointerCoords.y,
+ const dist = pointDistance(
+ pointFrom(origin.x, origin.y),
+ pointFrom(pointerCoords.x, pointerCoords.y),
);
if (
!appState.editingLinearElement &&
@@ -1188,11 +1392,13 @@ export class LinearElementEditor {
static addMidpoint(
linearElementEditor: LinearElementEditor,
pointerCoords: PointerCoords,
- appState: AppState,
+ app: AppClassProperties,
snapToGrid: boolean,
+ elementsMap: ElementsMap,
) {
const element = LinearElementEditor.getElement(
linearElementEditor.elementId,
+ elementsMap,
);
if (!element) {
return;
@@ -1208,9 +1414,10 @@ export class LinearElementEditor {
const midpoint = LinearElementEditor.createPointAt(
element,
+ elementsMap,
pointerCoords.x,
pointerCoords.y,
- snapToGrid ? appState.gridSize : null,
+ snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
);
const points = [
...element.points.slice(0, segmentMidpoint.index!),
@@ -1236,39 +1443,107 @@ export class LinearElementEditor {
private static _updatePoints(
element: NonDeleted,
- nextPoints: readonly Point[],
+ nextPoints: readonly LocalPoint[],
offsetX: number,
offsetY: number,
- otherUpdates?: { startBinding?: PointBinding; endBinding?: PointBinding },
+ otherUpdates?: {
+ startBinding?: PointBinding | null;
+ endBinding?: PointBinding | null;
+ },
+ options?: {
+ isDragging?: boolean;
+ zoom?: AppState["zoom"];
+ sceneElementsMap?: NonDeletedSceneElementsMap;
+ },
) {
- const nextCoords = getElementPointsCoords(element, nextPoints);
- const prevCoords = getElementPointsCoords(element, element.points);
- const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
- const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
- const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
- const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
- const dX = prevCenterX - nextCenterX;
- const dY = prevCenterY - nextCenterY;
- const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
- mutateElement(element, {
- ...otherUpdates,
- points: nextPoints,
- x: element.x + rotated[0],
- y: element.y + rotated[1],
- });
+ if (isElbowArrow(element)) {
+ const updates: {
+ startBinding?: FixedPointBinding | null;
+ endBinding?: FixedPointBinding | null;
+ points?: LocalPoint[];
+ } = {};
+ if (otherUpdates?.startBinding !== undefined) {
+ updates.startBinding =
+ otherUpdates.startBinding !== null &&
+ isFixedPointBinding(otherUpdates.startBinding)
+ ? otherUpdates.startBinding
+ : null;
+ }
+ if (otherUpdates?.endBinding !== undefined) {
+ updates.endBinding =
+ otherUpdates.endBinding !== null &&
+ isFixedPointBinding(otherUpdates.endBinding)
+ ? otherUpdates.endBinding
+ : null;
+ }
+
+ updates.points = Array.from(nextPoints);
+
+ if (!options?.sceneElementsMap || Scene.getScene(element)) {
+ mutateElement(element, updates, true, {
+ isDragging: options?.isDragging,
+ });
+ } else {
+ // The element is not in the scene, so we need to use the provided
+ // scene map.
+ Object.assign(element, {
+ ...updates,
+ angle: 0 as Radians,
+
+ ...updateElbowArrowPoints(
+ element,
+ options.sceneElementsMap,
+ updates,
+ {
+ isDragging: options?.isDragging,
+ },
+ ),
+ });
+ }
+ bumpVersion(element);
+ } else {
+ const nextCoords = getElementPointsCoords(element, nextPoints);
+ const prevCoords = getElementPointsCoords(element, element.points);
+ const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
+ const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
+ const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;
+ const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
+ const dX = prevCenterX - nextCenterX;
+ const dY = prevCenterY - nextCenterY;
+ const rotated = pointRotateRads(
+ pointFrom(offsetX, offsetY),
+ pointFrom(dX, dY),
+ element.angle,
+ );
+ mutateElement(element, {
+ ...otherUpdates,
+ points: nextPoints,
+ x: element.x + rotated[0],
+ y: element.y + rotated[1],
+ });
+ }
}
private static _getShiftLockedDelta(
element: NonDeleted,
- referencePoint: Point,
- scenePointer: Point,
- gridSize: number | null,
+ elementsMap: ElementsMap,
+ referencePoint: LocalPoint,
+ scenePointer: GlobalPoint,
+ gridSize: NullableGridSize,
) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element,
referencePoint,
+ elementsMap,
);
+ if (isElbowArrow(element)) {
+ return [
+ scenePointer[0] - referencePointCoords[0],
+ scenePointer[1] - referencePointCoords[1],
+ ];
+ }
+
const [gridX, gridY] = getGridPoint(
scenePointer[0],
scenePointer[1],
@@ -1282,14 +1557,22 @@ export class LinearElementEditor {
gridY,
);
- return rotatePoint([width, height], [0, 0], -element.angle);
+ return pointRotateRads(
+ pointFrom(width, height),
+ pointFrom(0, 0),
+ -element.angle as Radians,
+ );
}
static getBoundTextElementPosition = (
element: ExcalidrawLinearElement,
boundTextElement: ExcalidrawTextElementWithContainer,
+ elementsMap: ElementsMap,
): { x: number; y: number } => {
- const points = LinearElementEditor.getPointsGlobalCoordinates(element);
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true });
}
@@ -1300,6 +1583,7 @@ export class LinearElementEditor {
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
element,
element.points[index],
+ elementsMap,
);
x = midPoint[0] - boundTextElement.width / 2;
y = midPoint[1] - boundTextElement.height / 2;
@@ -1308,7 +1592,7 @@ export class LinearElementEditor {
let midSegmentMidpoint = editorMidPointsCache.points[index];
if (element.points.length === 2) {
- midSegmentMidpoint = centerPoint(points[0], points[1]);
+ midSegmentMidpoint = pointCenter(points[0], points[1]);
}
if (
!midSegmentMidpoint ||
@@ -1319,6 +1603,7 @@ export class LinearElementEditor {
points[index],
points[index + 1],
index + 1,
+ elementsMap,
);
}
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -1329,6 +1614,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
+ elementsMap: ElementsMap,
elementBounds: Bounds,
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
@@ -1339,40 +1625,42 @@ export class LinearElementEditor {
LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
+ elementsMap,
);
const boundTextX2 = boundTextX1 + boundTextElement.width;
const boundTextY2 = boundTextY1 + boundTextElement.height;
+ const centerPoint = pointFrom(cx, cy);
- const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
- const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
-
- const counterRotateBoundTextTopLeft = rotatePoint(
- [boundTextX1, boundTextY1],
-
- [cx, cy],
-
- -element.angle,
+ const topLeftRotatedPoint = pointRotateRads(
+ pointFrom(x1, y1),
+ centerPoint,
+ element.angle,
);
- const counterRotateBoundTextTopRight = rotatePoint(
- [boundTextX2, boundTextY1],
-
- [cx, cy],
-
- -element.angle,
+ const topRightRotatedPoint = pointRotateRads(
+ pointFrom(x2, y1),
+ centerPoint,
+ element.angle,
);
- const counterRotateBoundTextBottomLeft = rotatePoint(
- [boundTextX1, boundTextY2],
- [cx, cy],
-
- -element.angle,
+ const counterRotateBoundTextTopLeft = pointRotateRads(
+ pointFrom(boundTextX1, boundTextY1),
+ centerPoint,
+ -element.angle as Radians,
);
- const counterRotateBoundTextBottomRight = rotatePoint(
- [boundTextX2, boundTextY2],
-
- [cx, cy],
-
- -element.angle,
+ const counterRotateBoundTextTopRight = pointRotateRads(
+ pointFrom(boundTextX2, boundTextY1),
+ centerPoint,
+ -element.angle as Radians,
+ );
+ const counterRotateBoundTextBottomLeft = pointRotateRads(
+ pointFrom(boundTextX1, boundTextY2),
+ centerPoint,
+ -element.angle as Radians,
+ );
+ const counterRotateBoundTextBottomRight = pointRotateRads(
+ pointFrom(boundTextX2, boundTextY2),
+ centerPoint,
+ -element.angle as Radians,
);
if (
@@ -1479,6 +1767,7 @@ export class LinearElementEditor {
if (boundTextElement) {
coords = LinearElementEditor.getMinMaxXYWithBoundText(
element,
+ elementsMap,
[x1, y1, x2, y2],
boundTextElement,
);
@@ -1486,6 +1775,99 @@ export class LinearElementEditor {
return coords;
};
+
+ static moveFixedSegment(
+ linearElement: LinearElementEditor,
+ index: number,
+ x: number,
+ y: number,
+ elementsMap: ElementsMap,
+ ): LinearElementEditor {
+ const element = LinearElementEditor.getElement(
+ linearElement.elementId,
+ elementsMap,
+ );
+
+ if (!element || !isElbowArrow(element)) {
+ return linearElement;
+ }
+
+ if (index && index > 0 && index < element.points.length) {
+ const isHorizontal = headingIsHorizontal(
+ vectorToHeading(
+ vectorFromPoint(element.points[index], element.points[index - 1]),
+ ),
+ );
+
+ const fixedSegments = (element.fixedSegments ?? []).reduce(
+ (segments, s) => {
+ segments[s.index] = s;
+ return segments;
+ },
+ {} as Record,
+ );
+ fixedSegments[index] = {
+ index,
+ start: pointFrom(
+ !isHorizontal ? x - element.x : element.points[index - 1][0],
+ isHorizontal ? y - element.y : element.points[index - 1][1],
+ ),
+ end: pointFrom(
+ !isHorizontal ? x - element.x : element.points[index][0],
+ isHorizontal ? y - element.y : element.points[index][1],
+ ),
+ };
+ const nextFixedSegments = Object.values(fixedSegments).sort(
+ (a, b) => a.index - b.index,
+ );
+
+ const offset = nextFixedSegments
+ .map((segment) => segment.index)
+ .reduce((count, idx) => (idx < index ? count + 1 : count), 0);
+
+ mutateElement(element, {
+ fixedSegments: nextFixedSegments,
+ });
+
+ const point = pointFrom(
+ element.x +
+ (element.fixedSegments![offset].start[0] +
+ element.fixedSegments![offset].end[0]) /
+ 2,
+ element.y +
+ (element.fixedSegments![offset].start[1] +
+ element.fixedSegments![offset].end[1]) /
+ 2,
+ );
+
+ return {
+ ...linearElement,
+ segmentMidPointHoveredCoords: point,
+ pointerDownState: {
+ ...linearElement.pointerDownState,
+ segmentMidpoint: {
+ added: false,
+ index: element.fixedSegments![offset].index,
+ value: point,
+ },
+ },
+ };
+ }
+
+ return linearElement;
+ }
+
+ static deleteFixedSegment(
+ element: ExcalidrawElbowArrowElement,
+ index: number,
+ ): void {
+ mutateElement(element, {
+ fixedSegments: element.fixedSegments?.filter(
+ (segment) => segment.index !== index,
+ ),
+ });
+ mutateElement(element, {}, true);
+ }
}
const normalizeSelectedPoints = (
diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/element/src/mutateElement.ts
similarity index 61%
rename from packages/excalidraw/element/mutateElement.ts
rename to packages/element/src/mutateElement.ts
index d4dbd8cd25..d870073690 100644
--- a/packages/excalidraw/element/mutateElement.ts
+++ b/packages/element/src/mutateElement.ts
@@ -1,15 +1,28 @@
-import { ExcalidrawElement } from "./types";
-import Scene from "../scene/Scene";
-import { getSizeFromPoints } from "../points";
-import { randomInteger } from "../random";
-import { Point } from "../types";
-import { getUpdatedTimestamp } from "../utils";
-import { Mutable } from "../utility-types";
-import { ShapeCache } from "../scene/ShapeCache";
+import {
+ getSizeFromPoints,
+ randomInteger,
+ getUpdatedTimestamp,
+ toBrandedType,
+} from "@excalidraw/common";
-type ElementUpdate = Omit<
+// TODO: remove direct dependency on the scene, should be passed in or injected instead
+// eslint-disable-next-line @typescript-eslint/no-restricted-imports
+import Scene from "@excalidraw/excalidraw/scene/Scene";
+
+import type { Radians } from "@excalidraw/math";
+
+import type { Mutable } from "@excalidraw/common/utility-types";
+
+import { ShapeCache } from "./ShapeCache";
+
+import { updateElbowArrowPoints } from "./elbowArrow";
+import { isElbowArrow } from "./typeChecks";
+
+import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
+
+export type ElementUpdate = Omit<
Partial,
- "id" | "version" | "versionNonce"
+ "id" | "version" | "versionNonce" | "updated"
>;
// This function tracks updates of text elements for the purposes for collaboration.
@@ -20,14 +33,54 @@ export const mutateElement = >(
element: TElement,
updates: ElementUpdate,
informMutation = true,
+ options?: {
+ // Currently only for elbow arrows.
+ // If true, the elbow arrow tries to bind to the nearest element. If false
+ // it tries to keep the same bound element, if any.
+ isDragging?: boolean;
+ },
): TElement => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
- const { points, fileId } = updates as any;
+ const { points, fixedSegments, fileId, startBinding, endBinding } =
+ updates as any;
- if (typeof points !== "undefined") {
+ if (
+ isElbowArrow(element) &&
+ (Object.keys(updates).length === 0 || // normalization case
+ typeof points !== "undefined" || // repositioning
+ typeof fixedSegments !== "undefined" || // segment fixing
+ typeof startBinding !== "undefined" ||
+ typeof endBinding !== "undefined") // manual binding to element
+ ) {
+ const elementsMap = toBrandedType(
+ Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
+ );
+
+ updates = {
+ ...updates,
+ angle: 0 as Radians,
+ ...updateElbowArrowPoints(
+ {
+ ...element,
+ x: updates.x || element.x,
+ y: updates.y || element.y,
+ },
+ elementsMap,
+ {
+ fixedSegments,
+ points,
+ startBinding,
+ endBinding,
+ },
+ {
+ isDragging: options?.isDragging,
+ },
+ ),
+ };
+ } else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}
@@ -59,8 +112,8 @@ export const mutateElement = >(
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
- const prevPoint: Point = prevPoints[index];
- const nextPoint: Point = nextPoints[index];
+ const prevPoint = prevPoints[index];
+ const nextPoint = nextPoints[index];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]
@@ -79,6 +132,7 @@ export const mutateElement = >(
didChange = true;
}
}
+
if (!didChange) {
return element;
}
@@ -97,7 +151,7 @@ export const mutateElement = >(
element.updated = getUpdatedTimestamp();
if (informMutation) {
- Scene.getScene(element)?.informMutation();
+ Scene.getScene(element)?.triggerUpdate();
}
return element;
@@ -106,6 +160,8 @@ export const mutateElement = >(
export const newElementWith = (
element: TElement,
updates: ElementUpdate,
+ /** pass `true` to always regenerate */
+ force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
@@ -122,7 +178,7 @@ export const newElementWith = (
}
}
- if (!didChange) {
+ if (!didChange && !force) {
return element;
}
diff --git a/packages/element/src/newElement.ts b/packages/element/src/newElement.ts
new file mode 100644
index 0000000000..53a2f05aed
--- /dev/null
+++ b/packages/element/src/newElement.ts
@@ -0,0 +1,535 @@
+import {
+ DEFAULT_ELEMENT_PROPS,
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ DEFAULT_TEXT_ALIGN,
+ DEFAULT_VERTICAL_ALIGN,
+ VERTICAL_ALIGN,
+ randomInteger,
+ randomId,
+ getFontString,
+ getUpdatedTimestamp,
+ getLineHeight,
+} from "@excalidraw/common";
+
+import type { Radians } from "@excalidraw/math";
+
+import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
+
+import {
+ getElementAbsoluteCoords,
+ getResizedElementAbsoluteCoords,
+} from "./bounds";
+import { newElementWith } from "./mutateElement";
+import { getBoundTextMaxWidth } from "./textElement";
+import { normalizeText, measureText } from "./textMeasurements";
+import { wrapText } from "./textWrapping";
+
+import type {
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ ExcalidrawTextElement,
+ ExcalidrawLinearElement,
+ ExcalidrawGenericElement,
+ NonDeleted,
+ TextAlign,
+ VerticalAlign,
+ Arrowhead,
+ ExcalidrawFreeDrawElement,
+ FontFamilyValues,
+ ExcalidrawTextContainer,
+ ExcalidrawFrameElement,
+ ExcalidrawEmbeddableElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawIframeElement,
+ ElementsMap,
+ ExcalidrawArrowElement,
+ FixedSegment,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+
+export type ElementConstructorOpts = MarkOptional<
+ Omit,
+ | "width"
+ | "height"
+ | "angle"
+ | "groupIds"
+ | "frameId"
+ | "index"
+ | "boundElements"
+ | "seed"
+ | "version"
+ | "versionNonce"
+ | "link"
+ | "strokeStyle"
+ | "fillStyle"
+ | "strokeColor"
+ | "backgroundColor"
+ | "roughness"
+ | "strokeWidth"
+ | "roundness"
+ | "locked"
+ | "opacity"
+ | "customData"
+>;
+
+const _newElementBase = (
+ type: T["type"],
+ {
+ x,
+ y,
+ strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
+ backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
+ fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
+ strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
+ strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
+ roughness = DEFAULT_ELEMENT_PROPS.roughness,
+ opacity = DEFAULT_ELEMENT_PROPS.opacity,
+ width = 0,
+ height = 0,
+ angle = 0 as Radians,
+ groupIds = [],
+ frameId = null,
+ index = null,
+ roundness = null,
+ boundElements = null,
+ link = null,
+ locked = DEFAULT_ELEMENT_PROPS.locked,
+ ...rest
+ }: ElementConstructorOpts & Omit, "type">,
+) => {
+ // NOTE (mtolmacs): This is a temporary check to detect extremely large
+ // element position or sizing
+ if (
+ x < -1e6 ||
+ x > 1e6 ||
+ y < -1e6 ||
+ y > 1e6 ||
+ width < -1e6 ||
+ width > 1e6 ||
+ height < -1e6 ||
+ height > 1e6
+ ) {
+ console.error("New element size or position is too large", {
+ x,
+ y,
+ width,
+ height,
+ // @ts-ignore
+ points: rest.points,
+ });
+ }
+
+ // assign type to guard against excess properties
+ const element: Merge = {
+ id: rest.id || randomId(),
+ type,
+ x,
+ y,
+ width,
+ height,
+ angle,
+ strokeColor,
+ backgroundColor,
+ fillStyle,
+ strokeWidth,
+ strokeStyle,
+ roughness,
+ opacity,
+ groupIds,
+ frameId,
+ index,
+ roundness,
+ seed: rest.seed ?? randomInteger(),
+ version: rest.version || 1,
+ versionNonce: rest.versionNonce ?? 0,
+ isDeleted: false as false,
+ boundElements,
+ updated: getUpdatedTimestamp(),
+ link,
+ locked,
+ customData: rest.customData,
+ };
+ return element;
+};
+
+export const newElement = (
+ opts: {
+ type: ExcalidrawGenericElement["type"];
+ } & ElementConstructorOpts,
+): NonDeleted =>
+ _newElementBase(opts.type, opts);
+
+export const newEmbeddableElement = (
+ opts: {
+ type: "embeddable";
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ return _newElementBase("embeddable", opts);
+};
+
+export const newIframeElement = (
+ opts: {
+ type: "iframe";
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ return {
+ ..._newElementBase("iframe", opts),
+ };
+};
+
+export const newFrameElement = (
+ opts: {
+ name?: string;
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ const frameElement = newElementWith(
+ {
+ ..._newElementBase("frame", opts),
+ type: "frame",
+ name: opts?.name || null,
+ },
+ {},
+ );
+
+ return frameElement;
+};
+
+export const newMagicFrameElement = (
+ opts: {
+ name?: string;
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ const frameElement = newElementWith(
+ {
+ ..._newElementBase("magicframe", opts),
+ type: "magicframe",
+ name: opts?.name || null,
+ },
+ {},
+ );
+
+ return frameElement;
+};
+
+/** computes element x/y offset based on textAlign/verticalAlign */
+const getTextElementPositionOffsets = (
+ opts: {
+ textAlign: ExcalidrawTextElement["textAlign"];
+ verticalAlign: ExcalidrawTextElement["verticalAlign"];
+ },
+ metrics: {
+ width: number;
+ height: number;
+ },
+) => {
+ return {
+ x:
+ opts.textAlign === "center"
+ ? metrics.width / 2
+ : opts.textAlign === "right"
+ ? metrics.width
+ : 0,
+ y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
+ };
+};
+
+export const newTextElement = (
+ opts: {
+ text: string;
+ originalText?: string;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
+ containerId?: ExcalidrawTextContainer["id"] | null;
+ lineHeight?: ExcalidrawTextElement["lineHeight"];
+ autoResize?: ExcalidrawTextElement["autoResize"];
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
+ const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
+ const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
+ const text = normalizeText(opts.text);
+ const metrics = measureText(
+ text,
+ getFontString({ fontFamily, fontSize }),
+ lineHeight,
+ );
+ const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
+ const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
+ const offsets = getTextElementPositionOffsets(
+ { textAlign, verticalAlign },
+ metrics,
+ );
+
+ const textElementProps: ExcalidrawTextElement = {
+ ..._newElementBase("text", opts),
+ text,
+ fontSize,
+ fontFamily,
+ textAlign,
+ verticalAlign,
+ x: opts.x - offsets.x,
+ y: opts.y - offsets.y,
+ width: metrics.width,
+ height: metrics.height,
+ containerId: opts.containerId || null,
+ originalText: opts.originalText ?? text,
+ autoResize: opts.autoResize ?? true,
+ lineHeight,
+ };
+
+ const textElement: ExcalidrawTextElement = newElementWith(
+ textElementProps,
+ {},
+ );
+
+ return textElement;
+};
+
+const getAdjustedDimensions = (
+ element: ExcalidrawTextElement,
+ elementsMap: ElementsMap,
+ nextText: string,
+): {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+} => {
+ let { width: nextWidth, height: nextHeight } = measureText(
+ nextText,
+ getFontString(element),
+ element.lineHeight,
+ );
+
+ // wrapped text
+ if (!element.autoResize) {
+ nextWidth = element.width;
+ }
+
+ const { textAlign, verticalAlign } = element;
+ let x: number;
+ let y: number;
+ if (
+ textAlign === "center" &&
+ verticalAlign === VERTICAL_ALIGN.MIDDLE &&
+ !element.containerId &&
+ element.autoResize
+ ) {
+ const prevMetrics = measureText(
+ element.text,
+ getFontString(element),
+ element.lineHeight,
+ );
+ const offsets = getTextElementPositionOffsets(element, {
+ width: nextWidth - prevMetrics.width,
+ height: nextHeight - prevMetrics.height,
+ });
+
+ x = element.x - offsets.x;
+ y = element.y - offsets.y;
+ } else {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+
+ const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
+ element,
+ nextWidth,
+ nextHeight,
+ false,
+ );
+ const deltaX1 = (x1 - nextX1) / 2;
+ const deltaY1 = (y1 - nextY1) / 2;
+ const deltaX2 = (x2 - nextX2) / 2;
+ const deltaY2 = (y2 - nextY2) / 2;
+
+ [x, y] = adjustXYWithRotation(
+ {
+ s: true,
+ e: textAlign === "center" || textAlign === "left",
+ w: textAlign === "center" || textAlign === "right",
+ },
+ element.x,
+ element.y,
+ element.angle,
+ deltaX1,
+ deltaY1,
+ deltaX2,
+ deltaY2,
+ );
+ }
+
+ return {
+ width: nextWidth,
+ height: nextHeight,
+ x: Number.isFinite(x) ? x : element.x,
+ y: Number.isFinite(y) ? y : element.y,
+ };
+};
+
+const adjustXYWithRotation = (
+ sides: {
+ n?: boolean;
+ e?: boolean;
+ s?: boolean;
+ w?: boolean;
+ },
+ x: number,
+ y: number,
+ angle: number,
+ deltaX1: number,
+ deltaY1: number,
+ deltaX2: number,
+ deltaY2: number,
+): [number, number] => {
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ if (sides.e && sides.w) {
+ x += deltaX1 + deltaX2;
+ } else if (sides.e) {
+ x += deltaX1 * (1 + cos);
+ y += deltaX1 * sin;
+ x += deltaX2 * (1 - cos);
+ y += deltaX2 * -sin;
+ } else if (sides.w) {
+ x += deltaX1 * (1 - cos);
+ y += deltaX1 * -sin;
+ x += deltaX2 * (1 + cos);
+ y += deltaX2 * sin;
+ }
+
+ if (sides.n && sides.s) {
+ y += deltaY1 + deltaY2;
+ } else if (sides.n) {
+ x += deltaY1 * sin;
+ y += deltaY1 * (1 - cos);
+ x += deltaY2 * -sin;
+ y += deltaY2 * (1 + cos);
+ } else if (sides.s) {
+ x += deltaY1 * -sin;
+ y += deltaY1 * (1 + cos);
+ x += deltaY2 * sin;
+ y += deltaY2 * (1 - cos);
+ }
+ return [x, y];
+};
+
+export const refreshTextDimensions = (
+ textElement: ExcalidrawTextElement,
+ container: ExcalidrawTextContainer | null,
+ elementsMap: ElementsMap,
+ text = textElement.text,
+) => {
+ if (textElement.isDeleted) {
+ return;
+ }
+ if (container || !textElement.autoResize) {
+ text = wrapText(
+ text,
+ getFontString(textElement),
+ container
+ ? getBoundTextMaxWidth(container, textElement)
+ : textElement.width,
+ );
+ }
+ const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
+ return { text, ...dimensions };
+};
+
+export const newFreeDrawElement = (
+ opts: {
+ type: "freedraw";
+ points?: ExcalidrawFreeDrawElement["points"];
+ simulatePressure: boolean;
+ pressures?: ExcalidrawFreeDrawElement["pressures"];
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ return {
+ ..._newElementBase(opts.type, opts),
+ points: opts.points || [],
+ pressures: opts.pressures || [],
+ simulatePressure: opts.simulatePressure,
+ lastCommittedPoint: null,
+ };
+};
+
+export const newLinearElement = (
+ opts: {
+ type: ExcalidrawLinearElement["type"];
+ points?: ExcalidrawLinearElement["points"];
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ return {
+ ..._newElementBase(opts.type, opts),
+ points: opts.points || [],
+ lastCommittedPoint: null,
+ startBinding: null,
+ endBinding: null,
+ startArrowhead: null,
+ endArrowhead: null,
+ };
+};
+
+export const newArrowElement = (
+ opts: {
+ type: ExcalidrawArrowElement["type"];
+ startArrowhead?: Arrowhead | null;
+ endArrowhead?: Arrowhead | null;
+ points?: ExcalidrawArrowElement["points"];
+ elbowed?: T;
+ fixedSegments?: FixedSegment[] | null;
+ } & ElementConstructorOpts,
+): T extends true
+ ? NonDeleted
+ : NonDeleted => {
+ if (opts.elbowed) {
+ return {
+ ..._newElementBase(opts.type, opts),
+ points: opts.points || [],
+ lastCommittedPoint: null,
+ startBinding: null,
+ endBinding: null,
+ startArrowhead: opts.startArrowhead || null,
+ endArrowhead: opts.endArrowhead || null,
+ elbowed: true,
+ fixedSegments: opts.fixedSegments || [],
+ startIsSpecial: false,
+ endIsSpecial: false,
+ } as NonDeleted;
+ }
+
+ return {
+ ..._newElementBase(opts.type, opts),
+ points: opts.points || [],
+ lastCommittedPoint: null,
+ startBinding: null,
+ endBinding: null,
+ startArrowhead: opts.startArrowhead || null,
+ endArrowhead: opts.endArrowhead || null,
+ elbowed: false,
+ } as T extends true
+ ? NonDeleted
+ : NonDeleted;
+};
+
+export const newImageElement = (
+ opts: {
+ type: ExcalidrawImageElement["type"];
+ status?: ExcalidrawImageElement["status"];
+ fileId?: ExcalidrawImageElement["fileId"];
+ scale?: ExcalidrawImageElement["scale"];
+ crop?: ExcalidrawImageElement["crop"];
+ } & ElementConstructorOpts,
+): NonDeleted => {
+ return {
+ ..._newElementBase("image", opts),
+ // in the future we'll support changing stroke color for some SVG elements,
+ // and `transparent` will likely mean "use original colors of the image"
+ strokeColor: "transparent",
+ status: opts.status ?? "pending",
+ fileId: opts.fileId ?? null,
+ scale: opts.scale ?? [1, 1],
+ crop: opts.crop ?? null,
+ };
+};
diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/element/src/renderElement.ts
similarity index 55%
rename from packages/excalidraw/renderer/renderElement.ts
rename to packages/element/src/renderElement.ts
index de4bcfe533..c8091e8edb 100644
--- a/packages/excalidraw/renderer/renderElement.ts
+++ b/packages/element/src/renderElement.ts
@@ -1,13 +1,48 @@
+import rough from "roughjs/bin/rough";
+import { getStroke } from "perfect-freehand";
+
+import { isRightAngleRads } from "@excalidraw/math";
+
import {
- ExcalidrawElement,
- ExcalidrawTextElement,
- NonDeletedExcalidrawElement,
- ExcalidrawFreeDrawElement,
- ExcalidrawImageElement,
- ExcalidrawTextElementWithContainer,
- ExcalidrawFrameLikeElement,
- NonDeletedSceneElementsMap,
-} from "../element/types";
+ 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,
@@ -16,68 +51,34 @@ import {
isArrowElement,
hasBoundTextElement,
isMagicFrameElement,
-} from "../element/typeChecks";
-import { getElementAbsoluteCoords } from "../element/bounds";
-import type { RoughCanvas } from "roughjs/bin/canvas";
-import type { Drawable } from "roughjs/bin/core";
-import type { RoughSVG } from "roughjs/bin/svg";
+ isImageElement,
+} from "./typeChecks";
+import { getContainingFrame } from "./frame";
+import { getCornerRadius } from "./shapes";
-import {
- SVGRenderConfig,
- StaticCanvasRenderConfig,
- RenderableElementsMap,
-} from "../scene/types";
-import {
- distance,
- getFontString,
- getFontFamilyString,
- isRTL,
- isTestEnv,
-} from "../utils";
-import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
-import rough from "roughjs/bin/rough";
-import {
- AppState,
- StaticCanvasAppState,
- BinaryFiles,
- Zoom,
- InteractiveCanvasAppState,
- ElementsPendingErasure,
-} from "../types";
-import { getDefaultAppState } from "../appState";
-import {
- BOUND_TEXT_PADDING,
- ELEMENT_READY_TO_ERASE_OPACITY,
- FRAME_STYLE,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- MIME_TYPES,
- SVG_NS,
-} from "../constants";
-import { getStroke, StrokeOptions } from "perfect-freehand";
-import {
- getBoundTextElement,
- getContainerCoords,
- getContainerElement,
- getLineHeightInPx,
- getBoundTextMaxHeight,
- getBoundTextMaxWidth,
-} from "../element/textElement";
-import { LinearElementEditor } from "../element/linearElementEditor";
-import {
- createPlaceholderEmbeddableLabel,
- getEmbedLink,
-} from "../element/embeddable";
-import { getContainingFrame } from "../frame";
-import { normalizeLink, toValidURL } from "../data/url";
-import { ShapeCache } from "../scene/ShapeCache";
+import { ShapeCache } from "./ShapeCache";
+
+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...)
-const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)";
-
-const defaultAppState = getDefaultAppState();
+export const IMAGE_INVERT_FILTER =
+ "invert(100%) hue-rotate(180deg) saturate(1.25)";
const isPendingImageElement = (
element: ExcalidrawElement,
@@ -92,29 +93,42 @@ const shouldResetImageFilter = (
appState: StaticCanvasAppState,
) => {
return (
- appState.theme === "dark" &&
+ appState.theme === 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;
+const getCanvasPadding = (element: ExcalidrawElement) => {
+ switch (element.type) {
+ case "freedraw":
+ return element.strokeWidth * 12;
+ case "text":
+ return element.fontSize / 2;
+ default:
+ return 20;
+ }
+};
export const getRenderOpacity = (
element: ExcalidrawElement,
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
+ pendingNodes: Readonly | 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;
+ 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;
@@ -128,15 +142,19 @@ export interface ExcalidrawElementWithCanvas {
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;
@@ -155,7 +173,7 @@ const cappedElementCanvasSize = (
const padding = getCanvasPadding(element);
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const elementWidth =
isLinearElement(element) || isFreeDrawElement(element)
? distance(x1, x2)
@@ -191,25 +209,33 @@ const cappedElementCanvasSize = (
const generateElementCanvas = (
element: NonDeletedExcalidrawElement,
- elementsMap: RenderableElementsMap,
+ elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
-): ExcalidrawElementWithCanvas => {
+): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
const padding = getCanvasPadding(element);
- const { width, height, scale } = cappedElementCanvasSize(element, zoom);
+ const { width, height, scale } = cappedElementCanvasSize(
+ element,
+ elementsMap,
+ zoom,
+ );
+
+ if (!width || !height) {
+ return null;
+ }
canvas.width = width;
canvas.height = height;
- let canvasOffsetX = 0;
+ let canvasOffsetX = -100;
let canvasOffsetY = 0;
if (isLinearElement(element) || isFreeDrawElement(element)) {
- const [x1, y1] = getElementAbsoluteCoords(element);
+ const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
canvasOffsetX =
element.x > x1
@@ -239,8 +265,72 @@ const generateElementCanvas = (
}
drawElementOnCanvas(element, rc, context, renderConfig, appState);
+
context.restore();
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ const boundTextCanvas = document.createElement("canvas");
+ const boundTextCanvasContext = boundTextCanvas.getContext("2d")!;
+
+ if (isArrowElement(element) && boundTextElement) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ // Take max dimensions of arrow canvas so that when canvas is rotated
+ // the arrow doesn't get clipped
+ const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
+ boundTextCanvas.width =
+ maxDim * window.devicePixelRatio * scale + padding * scale * 10;
+ boundTextCanvas.height =
+ maxDim * window.devicePixelRatio * scale + padding * scale * 10;
+ boundTextCanvasContext.translate(
+ boundTextCanvas.width / 2,
+ boundTextCanvas.height / 2,
+ );
+ boundTextCanvasContext.rotate(element.angle);
+ boundTextCanvasContext.drawImage(
+ canvas!,
+ -canvas.width / 2,
+ -canvas.height / 2,
+ canvas.width,
+ canvas.height,
+ );
+
+ const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
+ boundTextElement,
+ elementsMap,
+ );
+
+ boundTextCanvasContext.rotate(-element.angle);
+ const offsetX = (boundTextCanvas.width - canvas!.width) / 2;
+ const offsetY = (boundTextCanvas.height - canvas!.height) / 2;
+ const shiftX =
+ boundTextCanvas.width / 2 -
+ (boundTextCx - x1) * window.devicePixelRatio * scale -
+ offsetX -
+ padding * scale;
+
+ const shiftY =
+ boundTextCanvas.height / 2 -
+ (boundTextCy - y1) * window.devicePixelRatio * scale -
+ offsetY -
+ padding * scale;
+ boundTextCanvasContext.translate(-shiftX, -shiftY);
+ // Clear the bound text area
+ boundTextCanvasContext.clearRect(
+ -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ scale,
+ -(boundTextElement.height / 2 + BOUND_TEXT_PADDING) *
+ window.devicePixelRatio *
+ scale,
+ (boundTextElement.width + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ scale,
+ (boundTextElement.height + BOUND_TEXT_PADDING * 2) *
+ window.devicePixelRatio *
+ scale,
+ );
+ }
+
return {
element,
canvas,
@@ -251,7 +341,11 @@ const generateElementCanvas = (
canvasOffsetY,
boundTextElementVersion:
getBoundTextElement(element, elementsMap)?.version || null,
- containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
+ containingFrameOpacity:
+ getContainingFrame(element, elementsMap)?.opacity || 100,
+ boundTextCanvas,
+ angle: element.angle,
+ imageCrop: isImageElement(element) ? element.crop : null,
};
};
@@ -270,7 +364,6 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
const drawImagePlaceholder = (
element: ExcalidrawImageElement,
context: CanvasRenderingContext2D,
- zoomValue: AppState["zoom"]["value"],
) => {
context.fillStyle = "#E7E7E7";
context.fillRect(0, 0, element.width, element.height);
@@ -355,15 +448,29 @@ const drawElementOnCanvas = (
);
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, appState.zoom.value);
+ drawImagePlaceholder(element, context);
}
break;
}
@@ -391,16 +498,23 @@ const drawElementOnCanvas = (
: element.textAlign === "right"
? element.width
: 0;
+
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
- const verticalOffset = element.height - element.baseline;
+
+ const verticalOffset = getVerticalOffset(
+ element.fontFamily,
+ element.fontSize,
+ lineHeightPx,
+ );
+
for (let index = 0; index < lines.length; index++) {
context.fillText(
lines[index],
horizontalOffset,
- (index + 1) * lineHeightPx - verticalOffset,
+ index * lineHeightPx + verticalOffset,
);
}
context.restore();
@@ -421,27 +535,41 @@ export const elementWithCanvasCache = new WeakMap<
const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement,
- elementsMap: RenderableElementsMap,
+ elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
- const zoom: Zoom = renderConfig ? appState.zoom : defaultAppState.zoom;
+ 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 boundTextElementVersion =
- getBoundTextElement(element, elementsMap)?.version || null;
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ const boundTextElementVersion = boundTextElement?.version || null;
+ const imageCrop = isImageElement(element) ? element.crop : null;
- const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
+ const containingFrameOpacity =
+ getContainingFrame(element, elementsMap)?.opacity || 100;
if (
!prevElementWithCanvas ||
shouldRegenerateBecauseZoom ||
prevElementWithCanvas.theme !== appState.theme ||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
- prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
+ 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,
@@ -451,6 +579,10 @@ const generateElementWithCanvas = (
appState,
);
+ if (!elementWithCanvas) {
+ return null;
+ }
+
elementWithCanvasCache.set(element, elementWithCanvas);
return elementWithCanvas;
@@ -468,16 +600,7 @@ const drawElementFromCanvas = (
const element = elementWithCanvas.element;
const padding = getCanvasPadding(element);
const zoom = elementWithCanvas.scale;
- let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-
- // 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 [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;
@@ -487,73 +610,21 @@ const drawElementFromCanvas = (
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);
-
- 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,
- );
-
+ const offsetX =
+ (elementWithCanvas.boundTextCanvas.width -
+ elementWithCanvas.canvas!.width) /
+ 2;
+ const offsetY =
+ (elementWithCanvas.boundTextCanvas.height -
+ elementWithCanvas.canvas!.height) /
+ 2;
context.translate(cx, cy);
context.drawImage(
- tempCanvas,
+ elementWithCanvas.boundTextCanvas,
(-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
(-(y2 - y1) / 2) * window.devicePixelRatio - offsetY / zoom - padding,
- tempCanvas.width / zoom,
- tempCanvas.height / zoom,
+ elementWithCanvas.boundTextCanvas.width / zoom,
+ elementWithCanvas.boundTextCanvas.height / zoom,
);
} else {
// we translate context to element center so that rotation and scale
@@ -614,6 +685,7 @@ export const renderSelectionElement = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
+ selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => {
context.save();
context.translate(element.x + appState.scrollX, element.y + appState.scrollY);
@@ -627,7 +699,7 @@ export const renderSelectionElement = (
context.fillRect(offset, offset, element.width, element.height);
context.lineWidth = 1 / appState.zoom.value;
- context.strokeStyle = " rgb(105, 101, 219)";
+ context.strokeStyle = selectionColor;
context.strokeRect(offset, offset, element.width, element.height);
context.restore();
@@ -642,10 +714,17 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
+ const reduceAlphaForSelection =
+ appState.openDialog?.name === "elementLinkSelector" &&
+ !appState.selectedElementIds[element.id] &&
+ !appState.hoveredElementIds[element.id];
+
context.globalAlpha = getRenderOpacity(
element,
- getContainingFrame(element),
+ getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
+ renderConfig.pendingFlowchartNodes,
+ reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
);
switch (element.type) {
@@ -665,7 +744,7 @@ export const renderElement = (
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
- appState.theme === "light" ? "#7affd7" : "#1d8264";
+ appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
}
if (FRAME_STYLE.radius && context.roundRect) {
@@ -694,7 +773,7 @@ export const renderElement = (
ShapeCache.generateElementShape(element, null);
if (renderConfig.isExporting) {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ 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);
@@ -708,10 +787,14 @@ export const renderElement = (
} else {
const elementWithCanvas = generateElementWithCanvas(
element,
- elementsMap,
+ allElementsMap,
renderConfig,
appState,
);
+ if (!elementWithCanvas) {
+ return;
+ }
+
drawElementFromCanvas(
elementWithCanvas,
context,
@@ -737,7 +820,7 @@ export const renderElement = (
// rely on existing shapes
ShapeCache.generateElementShape(element, renderConfig);
if (renderConfig.isExporting) {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+ 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);
@@ -749,6 +832,7 @@ export const renderElement = (
LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
+ elementsMap,
);
shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1);
shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1);
@@ -804,8 +888,10 @@ export const renderElement = (
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
- const [, , , , boundTextCx, boundTextCy] =
- getElementAbsoluteCoords(boundTextElement);
+ const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
+ boundTextElement,
+ elementsMap,
+ );
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
@@ -843,11 +929,15 @@ export const renderElement = (
} else {
const elementWithCanvas = generateElementWithCanvas(
element,
- elementsMap,
+ allElementsMap,
renderConfig,
appState,
);
+ if (!elementWithCanvas) {
+ return;
+ }
+
const currentImageSmoothingStatus = context.imageSmoothingEnabled;
if (
@@ -858,7 +948,8 @@ export const renderElement = (
(!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))
+ // 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
@@ -870,6 +961,35 @@ export const renderElement = (
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,
@@ -892,554 +1012,6 @@ export const renderElement = (
context.globalAlpha = 1;
};
-const roughSVGDrawWithPrecision = (
- rsvg: RoughSVG,
- drawable: Drawable,
- precision?: number,
-) => {
- if (typeof precision === "undefined") {
- return rsvg.draw(drawable);
- }
- const pshape: Drawable = {
- sets: drawable.sets,
- shape: drawable.shape,
- options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
- };
- return rsvg.draw(pshape);
-};
-
-const maybeWrapNodesInFrameClipPath = (
- element: NonDeletedExcalidrawElement,
- root: SVGElement,
- nodes: SVGElement[],
- frameRendering: AppState["frameRendering"],
-) => {
- if (!frameRendering.enabled || !frameRendering.clip) {
- return null;
- }
- const frame = getContainingFrame(element);
- if (frame) {
- const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
- g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
- nodes.forEach((node) => g.appendChild(node));
- return g;
- }
-
- return null;
-};
-
-export const renderElementToSvg = (
- element: NonDeletedExcalidrawElement,
- elementsMap: RenderableElementsMap,
- rsvg: RoughSVG,
- svgRoot: SVGElement,
- files: BinaryFiles,
- offsetX: number,
- offsetY: number,
- renderConfig: SVGRenderConfig,
-) => {
- const offset = { x: offsetX, y: offsetY };
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
- let cx = (x2 - x1) / 2 - (element.x - x1);
- let cy = (y2 - y1) / 2 - (element.y - y1);
- if (isTextElement(element)) {
- const container = getContainerElement(element, elementsMap);
- if (isArrowElement(container)) {
- const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
-
- const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
- container,
- element as ExcalidrawTextElementWithContainer,
- );
- cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
- cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
- offsetX = offsetX + boundTextCoords.x - element.x;
- offsetY = offsetY + boundTextCoords.y - element.y;
- }
- }
- const degree = (180 * element.angle) / Math.PI;
-
- // element to append node to, most of the time svgRoot
- let root = svgRoot;
-
- // if the element has a link, create an anchor tag and make that the new root
- if (element.link) {
- const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
- anchorTag.setAttribute("href", normalizeLink(element.link));
- root.appendChild(anchorTag);
- root = anchorTag;
- }
-
- const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
- if (isTestEnv()) {
- node.setAttribute("data-id", element.id);
- }
- root.appendChild(node);
- };
-
- const opacity =
- ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
-
- switch (element.type) {
- case "selection": {
- // Since this is used only during editing experience, which is canvas based,
- // this should not happen
- throw new Error("Selection rendering is not supported for SVG");
- }
- case "rectangle":
- case "diamond":
- case "ellipse": {
- const shape = ShapeCache.generateElementShape(element, null);
- const node = roughSVGDrawWithPrecision(
- rsvg,
- shape,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- );
- if (opacity !== 1) {
- node.setAttribute("stroke-opacity", `${opacity}`);
- node.setAttribute("fill-opacity", `${opacity}`);
- }
- node.setAttribute("stroke-linecap", "round");
- node.setAttribute(
- "transform",
- `translate(${offsetX || 0} ${
- offsetY || 0
- }) rotate(${degree} ${cx} ${cy})`,
- );
-
- const g = maybeWrapNodesInFrameClipPath(
- element,
- root,
- [node],
- renderConfig.frameRendering,
- );
-
- addToRoot(g || node, element);
- break;
- }
- case "iframe":
- case "embeddable": {
- // render placeholder rectangle
- const shape = ShapeCache.generateElementShape(element, renderConfig);
- const node = roughSVGDrawWithPrecision(
- rsvg,
- shape,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- );
- const opacity = element.opacity / 100;
- if (opacity !== 1) {
- node.setAttribute("stroke-opacity", `${opacity}`);
- node.setAttribute("fill-opacity", `${opacity}`);
- }
- node.setAttribute("stroke-linecap", "round");
- node.setAttribute(
- "transform",
- `translate(${offsetX || 0} ${
- offsetY || 0
- }) rotate(${degree} ${cx} ${cy})`,
- );
- addToRoot(node, element);
-
- const label: ExcalidrawElement =
- createPlaceholderEmbeddableLabel(element);
- renderElementToSvg(
- label,
- elementsMap,
- rsvg,
- root,
- files,
- label.x + offset.x - element.x,
- label.y + offset.y - element.y,
- renderConfig,
- );
-
- // render embeddable element + iframe
- const embeddableNode = roughSVGDrawWithPrecision(
- rsvg,
- shape,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- );
- embeddableNode.setAttribute("stroke-linecap", "round");
- embeddableNode.setAttribute(
- "transform",
- `translate(${offsetX || 0} ${
- offsetY || 0
- }) rotate(${degree} ${cx} ${cy})`,
- );
- while (embeddableNode.firstChild) {
- embeddableNode.removeChild(embeddableNode.firstChild);
- }
- const radius = getCornerRadius(
- Math.min(element.width, element.height),
- element,
- );
-
- const embedLink = getEmbedLink(toValidURL(element.link || ""));
-
- // if rendering embeddables explicitly disabled or
- // embedding documents via srcdoc (which doesn't seem to work for SVGs)
- // replace with a link instead
- if (
- renderConfig.renderEmbeddables === false ||
- embedLink?.type === "document"
- ) {
- const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
- anchorTag.setAttribute("href", normalizeLink(element.link || ""));
- anchorTag.setAttribute("target", "_blank");
- anchorTag.setAttribute("rel", "noopener noreferrer");
- anchorTag.style.borderRadius = `${radius}px`;
-
- embeddableNode.appendChild(anchorTag);
- } else {
- const foreignObject = svgRoot.ownerDocument!.createElementNS(
- SVG_NS,
- "foreignObject",
- );
- foreignObject.style.width = `${element.width}px`;
- foreignObject.style.height = `${element.height}px`;
- foreignObject.style.border = "none";
- const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
- div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
- div.style.width = "100%";
- div.style.height = "100%";
- const iframe = div.ownerDocument!.createElement("iframe");
- iframe.src = embedLink?.link ?? "";
- iframe.style.width = "100%";
- iframe.style.height = "100%";
- iframe.style.border = "none";
- iframe.style.borderRadius = `${radius}px`;
- iframe.style.top = "0";
- iframe.style.left = "0";
- iframe.allowFullscreen = true;
- div.appendChild(iframe);
- foreignObject.appendChild(div);
-
- embeddableNode.appendChild(foreignObject);
- }
- addToRoot(embeddableNode, element);
- break;
- }
- case "line":
- case "arrow": {
- const boundText = getBoundTextElement(element, elementsMap);
- const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
- if (boundText) {
- maskPath.setAttribute("id", `mask-${element.id}`);
- const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
- SVG_NS,
- "rect",
- );
- offsetX = offsetX || 0;
- offsetY = offsetY || 0;
- maskRectVisible.setAttribute("x", "0");
- maskRectVisible.setAttribute("y", "0");
- maskRectVisible.setAttribute("fill", "#fff");
- maskRectVisible.setAttribute(
- "width",
- `${element.width + 100 + offsetX}`,
- );
- maskRectVisible.setAttribute(
- "height",
- `${element.height + 100 + offsetY}`,
- );
-
- maskPath.appendChild(maskRectVisible);
- const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
- SVG_NS,
- "rect",
- );
- const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
- element,
- boundText,
- );
-
- const maskX = offsetX + boundTextCoords.x - element.x;
- const maskY = offsetY + boundTextCoords.y - element.y;
-
- maskRectInvisible.setAttribute("x", maskX.toString());
- maskRectInvisible.setAttribute("y", maskY.toString());
- maskRectInvisible.setAttribute("fill", "#000");
- maskRectInvisible.setAttribute("width", `${boundText.width}`);
- maskRectInvisible.setAttribute("height", `${boundText.height}`);
- maskRectInvisible.setAttribute("opacity", "1");
- maskPath.appendChild(maskRectInvisible);
- }
- const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
- if (boundText) {
- group.setAttribute("mask", `url(#mask-${element.id})`);
- }
- group.setAttribute("stroke-linecap", "round");
-
- const shapes = ShapeCache.generateElementShape(element, renderConfig);
- shapes.forEach((shape) => {
- const node = roughSVGDrawWithPrecision(
- rsvg,
- shape,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- );
- if (opacity !== 1) {
- node.setAttribute("stroke-opacity", `${opacity}`);
- node.setAttribute("fill-opacity", `${opacity}`);
- }
- node.setAttribute(
- "transform",
- `translate(${offsetX || 0} ${
- offsetY || 0
- }) rotate(${degree} ${cx} ${cy})`,
- );
- if (
- element.type === "line" &&
- isPathALoop(element.points) &&
- element.backgroundColor !== "transparent"
- ) {
- node.setAttribute("fill-rule", "evenodd");
- }
- group.appendChild(node);
- });
-
- const g = maybeWrapNodesInFrameClipPath(
- element,
- root,
- [group, maskPath],
- renderConfig.frameRendering,
- );
- if (g) {
- addToRoot(g, element);
- root.appendChild(g);
- } else {
- addToRoot(group, element);
- root.append(maskPath);
- }
- break;
- }
- case "freedraw": {
- const backgroundFillShape = ShapeCache.generateElementShape(
- element,
- renderConfig,
- );
- const node = backgroundFillShape
- ? roughSVGDrawWithPrecision(
- rsvg,
- backgroundFillShape,
- MAX_DECIMALS_FOR_SVG_EXPORT,
- )
- : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
- if (opacity !== 1) {
- node.setAttribute("stroke-opacity", `${opacity}`);
- node.setAttribute("fill-opacity", `${opacity}`);
- }
- node.setAttribute(
- "transform",
- `translate(${offsetX || 0} ${
- offsetY || 0
- }) rotate(${degree} ${cx} ${cy})`,
- );
- node.setAttribute("stroke", "none");
- const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
- path.setAttribute("fill", element.strokeColor);
- path.setAttribute("d", getFreeDrawSvgPath(element));
- node.appendChild(path);
-
- const g = maybeWrapNodesInFrameClipPath(
- element,
- root,
- [node],
- renderConfig.frameRendering,
- );
-
- addToRoot(g || node, element);
- break;
- }
- case "image": {
- const width = Math.round(element.width);
- const height = Math.round(element.height);
- const fileData =
- isInitializedImageElement(element) && files[element.fileId];
- if (fileData) {
- const symbolId = `image-${fileData.id}`;
- let symbol = svgRoot.querySelector(`#${symbolId}`);
- if (!symbol) {
- symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
- symbol.id = symbolId;
-
- const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
-
- image.setAttribute("width", "100%");
- image.setAttribute("height", "100%");
- image.setAttribute("href", fileData.dataURL);
-
- symbol.appendChild(image);
-
- root.prepend(symbol);
- }
-
- const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
- use.setAttribute("href", `#${symbolId}`);
-
- // in dark theme, revert the image color filter
- if (
- renderConfig.exportWithDarkMode &&
- fileData.mimeType !== MIME_TYPES.svg
- ) {
- use.setAttribute("filter", IMAGE_INVERT_FILTER);
- }
-
- use.setAttribute("width", `${width}`);
- use.setAttribute("height", `${height}`);
- use.setAttribute("opacity", `${opacity}`);
-
- // We first apply `scale` transforms (horizontal/vertical mirroring)
- // on the