summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element')
-rw-r--r--packages/excalidraw/element/ElementCanvasButtons.scss14
-rw-r--r--packages/excalidraw/element/ElementCanvasButtons.tsx63
-rw-r--r--packages/excalidraw/element/binding.ts2261
-rw-r--r--packages/excalidraw/element/bounds.test.ts140
-rw-r--r--packages/excalidraw/element/bounds.ts1025
-rw-r--r--packages/excalidraw/element/collision.ts312
-rw-r--r--packages/excalidraw/element/containerCache.ts33
-rw-r--r--packages/excalidraw/element/cropElement.ts625
-rw-r--r--packages/excalidraw/element/distance.ts123
-rw-r--r--packages/excalidraw/element/dragElements.ts286
-rw-r--r--packages/excalidraw/element/elbowArrow.test.tsx408
-rw-r--r--packages/excalidraw/element/elbowArrow.ts2280
-rw-r--r--packages/excalidraw/element/elementLink.ts102
-rw-r--r--packages/excalidraw/element/embeddable.ts444
-rw-r--r--packages/excalidraw/element/flowchart.test.tsx403
-rw-r--r--packages/excalidraw/element/flowchart.ts715
-rw-r--r--packages/excalidraw/element/heading.ts202
-rw-r--r--packages/excalidraw/element/image.ts146
-rw-r--r--packages/excalidraw/element/index.ts122
-rw-r--r--packages/excalidraw/element/linearElementEditor.ts1824
-rw-r--r--packages/excalidraw/element/mutateElement.ts196
-rw-r--r--packages/excalidraw/element/newElement.test.ts373
-rw-r--r--packages/excalidraw/element/newElement.ts793
-rw-r--r--packages/excalidraw/element/resizeElements.ts1545
-rw-r--r--packages/excalidraw/element/resizeTest.ts287
-rw-r--r--packages/excalidraw/element/showSelectedShapeActions.ts19
-rw-r--r--packages/excalidraw/element/sizeHelpers.test.ts67
-rw-r--r--packages/excalidraw/element/sizeHelpers.ts231
-rw-r--r--packages/excalidraw/element/sortElements.test.ts402
-rw-r--r--packages/excalidraw/element/sortElements.ts120
-rw-r--r--packages/excalidraw/element/textElement.test.ts206
-rw-r--r--packages/excalidraw/element/textElement.ts521
-rw-r--r--packages/excalidraw/element/textMeasurements.ts224
-rw-r--r--packages/excalidraw/element/textWrapping.test.ts633
-rw-r--r--packages/excalidraw/element/textWrapping.ts568
-rw-r--r--packages/excalidraw/element/textWysiwyg.test.tsx1565
-rw-r--r--packages/excalidraw/element/textWysiwyg.tsx730
-rw-r--r--packages/excalidraw/element/transformHandles.ts341
-rw-r--r--packages/excalidraw/element/typeChecks.test.ts66
-rw-r--r--packages/excalidraw/element/typeChecks.ts338
-rw-r--r--packages/excalidraw/element/types.ts412
-rw-r--r--packages/excalidraw/element/utils.ts355
42 files changed, 21520 insertions, 0 deletions
diff --git a/packages/excalidraw/element/ElementCanvasButtons.scss b/packages/excalidraw/element/ElementCanvasButtons.scss
new file mode 100644
index 0000000..e69cb65
--- /dev/null
+++ b/packages/excalidraw/element/ElementCanvasButtons.scss
@@ -0,0 +1,14 @@
+.excalidraw {
+ .excalidraw-canvas-buttons {
+ position: absolute;
+
+ box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
+ z-index: var(--zIndex-canvasButtons);
+ background: var(--island-bg-color);
+ border-radius: var(--border-radius-lg);
+
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+}
diff --git a/packages/excalidraw/element/ElementCanvasButtons.tsx b/packages/excalidraw/element/ElementCanvasButtons.tsx
new file mode 100644
index 0000000..1bcad97
--- /dev/null
+++ b/packages/excalidraw/element/ElementCanvasButtons.tsx
@@ -0,0 +1,63 @@
+import type { AppState } from "../types";
+import { sceneCoordsToViewportCoords } from "../utils";
+import type { ElementsMap, NonDeletedExcalidrawElement } from "./types";
+import { getElementAbsoluteCoords } from ".";
+import { useExcalidrawAppState } from "../components/App";
+
+import "./ElementCanvasButtons.scss";
+
+const CONTAINER_PADDING = 5;
+
+const getContainerCoords = (
+ element: NonDeletedExcalidrawElement,
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+ { sceneX: x1 + element.width, sceneY: y1 },
+ appState,
+ );
+ const x = viewportX - appState.offsetLeft + 10;
+ const y = viewportY - appState.offsetTop;
+ return { x, y };
+};
+
+export const ElementCanvasButtons = ({
+ children,
+ element,
+ elementsMap,
+}: {
+ children: React.ReactNode;
+ element: NonDeletedExcalidrawElement;
+ elementsMap: ElementsMap;
+}) => {
+ const appState = useExcalidrawAppState();
+
+ if (
+ appState.contextMenu ||
+ appState.newElement ||
+ appState.resizingElement ||
+ appState.isRotating ||
+ appState.openMenu ||
+ appState.viewModeEnabled
+ ) {
+ return null;
+ }
+
+ const { x, y } = getContainerCoords(element, appState, elementsMap);
+
+ return (
+ <div
+ className="excalidraw-canvas-buttons"
+ style={{
+ top: `${y}px`,
+ left: `${x}px`,
+ // width: CONTAINER_WIDTH,
+ padding: CONTAINER_PADDING,
+ }}
+ >
+ {children}
+ </div>
+ );
+};
diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts
new file mode 100644
index 0000000..e716b86
--- /dev/null
+++ b/packages/excalidraw/element/binding.ts
@@ -0,0 +1,2261 @@
+import type {
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ NonDeleted,
+ ExcalidrawLinearElement,
+ PointBinding,
+ NonDeletedExcalidrawElement,
+ ElementsMap,
+ NonDeletedSceneElementsMap,
+ ExcalidrawTextElement,
+ ExcalidrawArrowElement,
+ OrderedExcalidrawElement,
+ ExcalidrawElbowArrowElement,
+ FixedPoint,
+ SceneElementsMap,
+ FixedPointBinding,
+} from "./types";
+
+import type { Bounds } from "./bounds";
+import {
+ getCenterForBounds,
+ getElementBounds,
+ doBoundsIntersect,
+} from "./bounds";
+import type { AppState } from "../types";
+import { isPointOnShape } from "@excalidraw/utils/collision";
+import {
+ isArrowElement,
+ isBindableElement,
+ isBindingElement,
+ isBoundToContainer,
+ isElbowArrow,
+ isFixedPointBinding,
+ isFrameLikeElement,
+ isLinearElement,
+ isRectanguloidElement,
+ isTextElement,
+} from "./typeChecks";
+import type { ElementUpdate } from "./mutateElement";
+import { mutateElement } from "./mutateElement";
+import type Scene from "../scene/Scene";
+import { LinearElementEditor } from "./linearElementEditor";
+import {
+ arrayToMap,
+ isBindingFallthroughEnabled,
+ tupleToCoors,
+} from "../utils";
+import { KEYS } from "../keys";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
+import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
+import {
+ compareHeading,
+ HEADING_DOWN,
+ HEADING_RIGHT,
+ HEADING_UP,
+ headingForPointFromElement,
+ vectorToHeading,
+ type Heading,
+} from "./heading";
+import type { LocalPoint, Radians } from "@excalidraw/math";
+import {
+ lineSegment,
+ pointFrom,
+ pointRotateRads,
+ type GlobalPoint,
+ vectorFromPoint,
+ pointDistanceSq,
+ clamp,
+ pointDistance,
+ pointFromVector,
+ vectorScale,
+ vectorNormalize,
+ vectorCross,
+ pointsEqual,
+ lineSegmentIntersectionPoints,
+ round,
+ PRECISION,
+} from "@excalidraw/math";
+import { intersectElementWithLineSegment } from "./collision";
+import { distanceToBindableElement } from "./distance";
+
+export type SuggestedBinding =
+ | NonDeleted<ExcalidrawBindableElement>
+ | SuggestedPointBinding;
+
+export type SuggestedPointBinding = [
+ NonDeleted<ExcalidrawLinearElement>,
+ "start" | "end" | "both",
+ NonDeleted<ExcalidrawBindableElement>,
+];
+
+export const shouldEnableBindingForPointerEvent = (
+ event: React.PointerEvent<HTMLElement>,
+) => {
+ return !event[KEYS.CTRL_OR_CMD];
+};
+
+export const isBindingEnabled = (appState: AppState): boolean => {
+ return appState.isBindingEnabled;
+};
+
+export const FIXED_BINDING_DISTANCE = 5;
+export const BINDING_HIGHLIGHT_THICKNESS = 10;
+export const BINDING_HIGHLIGHT_OFFSET = 4;
+
+const getNonDeletedElements = (
+ scene: Scene,
+ ids: readonly ExcalidrawElement["id"][],
+): NonDeleted<ExcalidrawElement>[] => {
+ const result: NonDeleted<ExcalidrawElement>[] = [];
+ ids.forEach((id) => {
+ const element = scene.getNonDeletedElement(id);
+ if (element != null) {
+ result.push(element);
+ }
+ });
+ return result;
+};
+
+export const bindOrUnbindLinearElement = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ startBindingElement: ExcalidrawBindableElement | null | "keep",
+ endBindingElement: ExcalidrawBindableElement | null | "keep",
+ elementsMap: NonDeletedSceneElementsMap,
+ scene: Scene,
+): void => {
+ const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
+ const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
+ bindOrUnbindLinearElementEdge(
+ linearElement,
+ startBindingElement,
+ endBindingElement,
+ "start",
+ boundToElementIds,
+ unboundFromElementIds,
+ elementsMap,
+ );
+ bindOrUnbindLinearElementEdge(
+ linearElement,
+ endBindingElement,
+ startBindingElement,
+ "end",
+ boundToElementIds,
+ unboundFromElementIds,
+ elementsMap,
+ );
+
+ const onlyUnbound = Array.from(unboundFromElementIds).filter(
+ (id) => !boundToElementIds.has(id),
+ );
+
+ getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
+ mutateElement(element, {
+ boundElements: element.boundElements?.filter(
+ (element) =>
+ element.type !== "arrow" || element.id !== linearElement.id,
+ ),
+ });
+ });
+};
+
+const bindOrUnbindLinearElementEdge = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ bindableElement: ExcalidrawBindableElement | null | "keep",
+ otherEdgeBindableElement: ExcalidrawBindableElement | null | "keep",
+ startOrEnd: "start" | "end",
+ // Is mutated
+ boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
+ // Is mutated
+ unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
+ elementsMap: NonDeletedSceneElementsMap,
+): void => {
+ // "keep" is for method chaining convenience, a "no-op", so just bail out
+ if (bindableElement === "keep") {
+ return;
+ }
+
+ // null means break the bind, so nothing to consider here
+ if (bindableElement === null) {
+ const unbound = unbindLinearElement(linearElement, startOrEnd);
+ if (unbound != null) {
+ unboundFromElementIds.add(unbound);
+ }
+ return;
+ }
+
+ // While complext arrows can do anything, simple arrow with both ends trying
+ // to bind to the same bindable should not be allowed, start binding takes
+ // precedence
+ if (isLinearElementSimple(linearElement)) {
+ if (
+ otherEdgeBindableElement == null ||
+ (otherEdgeBindableElement === "keep"
+ ? // TODO: Refactor - Needlessly complex
+ !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+ linearElement,
+ bindableElement,
+ startOrEnd,
+ )
+ : startOrEnd === "start" ||
+ otherEdgeBindableElement.id !== bindableElement.id)
+ ) {
+ bindLinearElement(
+ linearElement,
+ bindableElement,
+ startOrEnd,
+ elementsMap,
+ );
+ boundToElementIds.add(bindableElement.id);
+ }
+ } else {
+ bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap);
+ boundToElementIds.add(bindableElement.id);
+ }
+};
+
+const getOriginalBindingIfStillCloseOfLinearElementEdge = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ edge: "start" | "end",
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom?: AppState["zoom"],
+): NonDeleted<ExcalidrawElement> | null => {
+ const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
+ const elementId =
+ edge === "start"
+ ? linearElement.startBinding?.elementId
+ : linearElement.endBinding?.elementId;
+ if (elementId) {
+ const element = elementsMap.get(elementId);
+ if (
+ isBindableElement(element) &&
+ bindingBorderTest(element, coors, elementsMap, zoom)
+ ) {
+ return element;
+ }
+ }
+
+ return null;
+};
+
+const getOriginalBindingsIfStillCloseToArrowEnds = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom?: AppState["zoom"],
+): (NonDeleted<ExcalidrawElement> | null)[] =>
+ ["start", "end"].map((edge) =>
+ getOriginalBindingIfStillCloseOfLinearElementEdge(
+ linearElement,
+ edge as "start" | "end",
+ elementsMap,
+ zoom,
+ ),
+ );
+
+const getBindingStrategyForDraggingArrowEndpoints = (
+ selectedElement: NonDeleted<ExcalidrawLinearElement>,
+ isBindingEnabled: boolean,
+ draggingPoints: readonly number[],
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ zoom?: AppState["zoom"],
+): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
+ const startIdx = 0;
+ const endIdx = selectedElement.points.length - 1;
+ const startDragged = draggingPoints.findIndex((i) => i === startIdx) > -1;
+ const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
+ const start = startDragged
+ ? isBindingEnabled
+ ? getElligibleElementForBindingElement(
+ selectedElement,
+ "start",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : null // If binding is disabled and start is dragged, break all binds
+ : !isElbowArrow(selectedElement)
+ ? // We have to update the focus and gap of the binding, so let's rebind
+ getElligibleElementForBindingElement(
+ selectedElement,
+ "start",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : "keep";
+ const end = endDragged
+ ? isBindingEnabled
+ ? getElligibleElementForBindingElement(
+ selectedElement,
+ "end",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : null // If binding is disabled and end is dragged, break all binds
+ : !isElbowArrow(selectedElement)
+ ? // We have to update the focus and gap of the binding, so let's rebind
+ getElligibleElementForBindingElement(
+ selectedElement,
+ "end",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : "keep";
+
+ return [start, end];
+};
+
+const getBindingStrategyForDraggingArrowOrJoints = (
+ selectedElement: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ isBindingEnabled: boolean,
+ zoom?: AppState["zoom"],
+): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
+ // Elbow arrows don't bind when dragged as a whole
+ if (isElbowArrow(selectedElement)) {
+ return ["keep", "keep"];
+ }
+
+ const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
+ selectedElement,
+ elementsMap,
+ zoom,
+ );
+ const start = startIsClose
+ ? isBindingEnabled
+ ? getElligibleElementForBindingElement(
+ selectedElement,
+ "start",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : null
+ : null;
+ const end = endIsClose
+ ? isBindingEnabled
+ ? getElligibleElementForBindingElement(
+ selectedElement,
+ "end",
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : null
+ : null;
+
+ return [start, end];
+};
+
+export const bindOrUnbindLinearElements = (
+ selectedElements: NonDeleted<ExcalidrawLinearElement>[],
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ scene: Scene,
+ isBindingEnabled: boolean,
+ draggingPoints: readonly number[] | null,
+ zoom?: AppState["zoom"],
+): void => {
+ selectedElements.forEach((selectedElement) => {
+ const [start, end] = draggingPoints?.length
+ ? // The arrow edge points are dragged (i.e. start, end)
+ getBindingStrategyForDraggingArrowEndpoints(
+ selectedElement,
+ isBindingEnabled,
+ draggingPoints ?? [],
+ elementsMap,
+ elements,
+ zoom,
+ )
+ : // The arrow itself (the shaft) or the inner joins are dragged
+ getBindingStrategyForDraggingArrowOrJoints(
+ selectedElement,
+ elementsMap,
+ elements,
+ isBindingEnabled,
+ zoom,
+ );
+
+ bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene);
+ });
+};
+
+export const getSuggestedBindingsForArrows = (
+ selectedElements: NonDeleted<ExcalidrawElement>[],
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom: AppState["zoom"],
+): SuggestedBinding[] => {
+ // HOT PATH: Bail out if selected elements list is too large
+ if (selectedElements.length > 50) {
+ return [];
+ }
+
+ return (
+ selectedElements
+ .filter(isLinearElement)
+ .flatMap((element) =>
+ getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap, zoom),
+ )
+ .filter(
+ (element): element is NonDeleted<ExcalidrawBindableElement> =>
+ element !== null,
+ )
+ // Filter out bind candidates which are in the
+ // same selection / group with the arrow
+ //
+ // TODO: Is it worth turning the list into a set to avoid dupes?
+ .filter(
+ (element) =>
+ selectedElements.filter((selected) => selected.id === element?.id)
+ .length === 0,
+ )
+ );
+};
+
+export const maybeBindLinearElement = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ appState: AppState,
+ pointerCoords: { x: number; y: number },
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+): void => {
+ if (appState.startBoundElement != null) {
+ bindLinearElement(
+ linearElement,
+ appState.startBoundElement,
+ "start",
+ elementsMap,
+ );
+ }
+
+ const hoveredElement = getHoveredElementForBinding(
+ pointerCoords,
+ elements,
+ elementsMap,
+ appState.zoom,
+ isElbowArrow(linearElement),
+ isElbowArrow(linearElement),
+ );
+
+ if (hoveredElement !== null) {
+ if (
+ !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
+ linearElement,
+ hoveredElement,
+ "end",
+ )
+ ) {
+ bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
+ }
+ }
+};
+
+const normalizePointBinding = (
+ binding: { focus: number; gap: number },
+ hoveredElement: ExcalidrawBindableElement,
+) => {
+ let gap = binding.gap;
+ const maxGap = maxBindingGap(
+ hoveredElement,
+ hoveredElement.width,
+ hoveredElement.height,
+ );
+
+ if (gap > maxGap) {
+ gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
+ }
+ return {
+ ...binding,
+ gap,
+ };
+};
+
+export const bindLinearElement = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ hoveredElement: ExcalidrawBindableElement,
+ startOrEnd: "start" | "end",
+ elementsMap: NonDeletedSceneElementsMap,
+): void => {
+ if (!isArrowElement(linearElement)) {
+ return;
+ }
+
+ const binding: PointBinding | FixedPointBinding = {
+ elementId: hoveredElement.id,
+ ...(isElbowArrow(linearElement)
+ ? {
+ ...calculateFixedPointForElbowArrowBinding(
+ linearElement,
+ hoveredElement,
+ startOrEnd,
+ elementsMap,
+ ),
+ focus: 0,
+ gap: 0,
+ }
+ : {
+ ...normalizePointBinding(
+ calculateFocusAndGap(
+ linearElement,
+ hoveredElement,
+ startOrEnd,
+ elementsMap,
+ ),
+ hoveredElement,
+ ),
+ }),
+ };
+
+ mutateElement(linearElement, {
+ [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
+ });
+
+ const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
+ if (!boundElementsMap.has(linearElement.id)) {
+ mutateElement(hoveredElement, {
+ boundElements: (hoveredElement.boundElements || []).concat({
+ id: linearElement.id,
+ type: "arrow",
+ }),
+ });
+ }
+};
+
+// Don't bind both ends of a simple segment
+const isLinearElementSimpleAndAlreadyBoundOnOppositeEdge = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ bindableElement: ExcalidrawBindableElement,
+ startOrEnd: "start" | "end",
+): boolean => {
+ const otherBinding =
+ linearElement[startOrEnd === "start" ? "endBinding" : "startBinding"];
+ return isLinearElementSimpleAndAlreadyBound(
+ linearElement,
+ otherBinding?.elementId,
+ bindableElement,
+ );
+};
+
+export const isLinearElementSimpleAndAlreadyBound = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ alreadyBoundToId: ExcalidrawBindableElement["id"] | undefined,
+ bindableElement: ExcalidrawBindableElement,
+): boolean => {
+ return (
+ alreadyBoundToId === bindableElement.id &&
+ isLinearElementSimple(linearElement)
+ );
+};
+
+const isLinearElementSimple = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+): boolean => linearElement.points.length < 3;
+
+const unbindLinearElement = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ startOrEnd: "start" | "end",
+): ExcalidrawBindableElement["id"] | null => {
+ const field = startOrEnd === "start" ? "startBinding" : "endBinding";
+ const binding = linearElement[field];
+ if (binding == null) {
+ return null;
+ }
+ mutateElement(linearElement, { [field]: null });
+ return binding.elementId;
+};
+
+export const getHoveredElementForBinding = (
+ pointerCoords: {
+ x: number;
+ y: number;
+ },
+ elements: readonly NonDeletedExcalidrawElement[],
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom?: AppState["zoom"],
+ fullShape?: boolean,
+ considerAllElements?: boolean,
+): NonDeleted<ExcalidrawBindableElement> | null => {
+ if (considerAllElements) {
+ let cullRest = false;
+ const candidateElements = getAllElementsAtPositionForBinding(
+ elements,
+ (element) =>
+ isBindableElement(element, false) &&
+ bindingBorderTest(
+ element,
+ pointerCoords,
+ elementsMap,
+ zoom,
+ (fullShape ||
+ !isBindingFallthroughEnabled(
+ element as ExcalidrawBindableElement,
+ )) &&
+ // disable fullshape snapping for frame elements so we
+ // can bind to frame children
+ !isFrameLikeElement(element),
+ ),
+ ).filter((element) => {
+ if (cullRest) {
+ return false;
+ }
+
+ if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
+ cullRest = true;
+ }
+
+ return true;
+ }) as NonDeleted<ExcalidrawBindableElement>[] | null;
+
+ // Return early if there are no candidates or just one candidate
+ if (!candidateElements || candidateElements.length === 0) {
+ return null;
+ }
+
+ if (candidateElements.length === 1) {
+ return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
+ }
+
+ // Prefer the shape with the border being tested (if any)
+ const borderTestElements = candidateElements.filter((element) =>
+ bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
+ );
+ if (borderTestElements.length === 1) {
+ return borderTestElements[0];
+ }
+
+ // Prefer smaller shapes
+ return candidateElements
+ .sort(
+ (a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
+ )
+ .pop() as NonDeleted<ExcalidrawBindableElement>;
+ }
+
+ const hoveredElement = getElementAtPositionForBinding(
+ elements,
+ (element) =>
+ isBindableElement(element, false) &&
+ bindingBorderTest(
+ element,
+ pointerCoords,
+ elementsMap,
+ zoom,
+ // disable fullshape snapping for frame elements so we
+ // can bind to frame children
+ (fullShape || !isBindingFallthroughEnabled(element)) &&
+ !isFrameLikeElement(element),
+ ),
+ );
+
+ return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
+};
+
+const getElementAtPositionForBinding = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
+) => {
+ let hitElement = null;
+ // We need to to hit testing from front (end of the array) to back (beginning of the array)
+ // because array is ordered from lower z-index to highest and we want element z-index
+ // with higher z-index
+ for (let index = elements.length - 1; index >= 0; --index) {
+ const element = elements[index];
+ if (element.isDeleted) {
+ continue;
+ }
+ if (isAtPositionFn(element)) {
+ hitElement = element;
+ break;
+ }
+ }
+
+ return hitElement;
+};
+
+const getAllElementsAtPositionForBinding = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
+) => {
+ const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
+ // We need to to hit testing from front (end of the array) to back (beginning of the array)
+ // because array is ordered from lower z-index to highest and we want element z-index
+ // with higher z-index
+ for (let index = elements.length - 1; index >= 0; --index) {
+ const element = elements[index];
+ if (element.isDeleted) {
+ continue;
+ }
+
+ if (isAtPositionFn(element)) {
+ elementsAtPosition.push(element);
+ }
+ }
+
+ return elementsAtPosition;
+};
+
+const calculateFocusAndGap = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ hoveredElement: ExcalidrawBindableElement,
+ startOrEnd: "start" | "end",
+ elementsMap: NonDeletedSceneElementsMap,
+): { focus: number; gap: number } => {
+ const direction = startOrEnd === "start" ? -1 : 1;
+ const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
+ const adjacentPointIndex = edgePointIndex - direction;
+
+ const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ edgePointIndex,
+ elementsMap,
+ );
+ const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ adjacentPointIndex,
+ elementsMap,
+ );
+
+ return {
+ focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
+ gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
+ };
+};
+
+// Supports translating, rotating and scaling `changedElement` with bound
+// linear elements.
+// Because scaling involves moving the focus points as well, it is
+// done before the `changedElement` is updated, and the `newSize` is passed
+// in explicitly.
+export const updateBoundElements = (
+ changedElement: NonDeletedExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+ options?: {
+ simultaneouslyUpdated?: readonly ExcalidrawElement[];
+ newSize?: { width: number; height: number };
+ changedElements?: Map<string, OrderedExcalidrawElement>;
+ },
+) => {
+ const { newSize, simultaneouslyUpdated } = options ?? {};
+ const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
+ simultaneouslyUpdated,
+ );
+
+ if (!isBindableElement(changedElement)) {
+ return;
+ }
+
+ boundElementsVisitor(elementsMap, changedElement, (element) => {
+ if (!isLinearElement(element) || element.isDeleted) {
+ return;
+ }
+
+ // In case the boundElements are stale
+ if (!doesNeedUpdate(element, changedElement)) {
+ return;
+ }
+
+ // Check for intersections before updating bound elements incase connected elements overlap
+ const startBindingElement = element.startBinding
+ ? elementsMap.get(element.startBinding.elementId)
+ : null;
+ const endBindingElement = element.endBinding
+ ? elementsMap.get(element.endBinding.elementId)
+ : null;
+
+ let startBounds: Bounds | null = null;
+ let endBounds: Bounds | null = null;
+ if (startBindingElement && endBindingElement) {
+ startBounds = getElementBounds(startBindingElement, elementsMap);
+ endBounds = getElementBounds(endBindingElement, elementsMap);
+ }
+
+ const bindings = {
+ startBinding: maybeCalculateNewGapWhenScaling(
+ changedElement,
+ element.startBinding,
+ newSize,
+ ),
+ endBinding: maybeCalculateNewGapWhenScaling(
+ changedElement,
+ element.endBinding,
+ newSize,
+ ),
+ };
+
+ // `linearElement` is being moved/scaled already, just update the binding
+ if (simultaneouslyUpdatedElementIds.has(element.id)) {
+ mutateElement(element, bindings, true);
+ return;
+ }
+
+ const updates = bindableElementsVisitor(
+ elementsMap,
+ element,
+ (bindableElement, bindingProp) => {
+ if (
+ bindableElement &&
+ isBindableElement(bindableElement) &&
+ (bindingProp === "startBinding" || bindingProp === "endBinding") &&
+ (changedElement.id === element[bindingProp]?.elementId ||
+ (changedElement.id ===
+ element[
+ bindingProp === "startBinding" ? "endBinding" : "startBinding"
+ ]?.elementId &&
+ !doBoundsIntersect(startBounds, endBounds)))
+ ) {
+ const point = updateBoundPoint(
+ element,
+ bindingProp,
+ bindings[bindingProp],
+ bindableElement,
+ elementsMap,
+ );
+ if (point) {
+ return {
+ index:
+ bindingProp === "startBinding" ? 0 : element.points.length - 1,
+ point,
+ };
+ }
+ }
+
+ return null;
+ },
+ ).filter(
+ (
+ update,
+ ): update is NonNullable<{
+ index: number;
+ point: LocalPoint;
+ isDragging?: boolean;
+ }> => update !== null,
+ );
+
+ LinearElementEditor.movePoints(element, updates, {
+ ...(changedElement.id === element.startBinding?.elementId
+ ? { startBinding: bindings.startBinding }
+ : {}),
+ ...(changedElement.id === element.endBinding?.elementId
+ ? { endBinding: bindings.endBinding }
+ : {}),
+ });
+
+ const boundText = getBoundTextElement(element, elementsMap);
+ if (boundText && !boundText.isDeleted) {
+ handleBindTextResize(element, elementsMap, false);
+ }
+ });
+};
+
+const doesNeedUpdate = (
+ boundElement: NonDeleted<ExcalidrawLinearElement>,
+ changedElement: ExcalidrawBindableElement,
+) => {
+ return (
+ boundElement.startBinding?.elementId === changedElement.id ||
+ boundElement.endBinding?.elementId === changedElement.id
+ );
+};
+
+const getSimultaneouslyUpdatedElementIds = (
+ simultaneouslyUpdated: readonly ExcalidrawElement[] | undefined,
+): Set<ExcalidrawElement["id"]> => {
+ return new Set((simultaneouslyUpdated || []).map((element) => element.id));
+};
+
+export const getHeadingForElbowArrowSnap = (
+ p: Readonly<GlobalPoint>,
+ otherPoint: Readonly<GlobalPoint>,
+ bindableElement: ExcalidrawBindableElement | undefined | null,
+ aabb: Bounds | undefined | null,
+ elementsMap: ElementsMap,
+ origPoint: GlobalPoint,
+ zoom?: AppState["zoom"],
+): Heading => {
+ const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
+
+ if (!bindableElement || !aabb) {
+ return otherPointHeading;
+ }
+
+ const distance = getDistanceForBinding(
+ origPoint,
+ bindableElement,
+ elementsMap,
+ zoom,
+ );
+
+ if (!distance) {
+ return vectorToHeading(
+ vectorFromPoint(
+ p,
+ pointFrom<GlobalPoint>(
+ bindableElement.x + bindableElement.width / 2,
+ bindableElement.y + bindableElement.height / 2,
+ ),
+ ),
+ );
+ }
+
+ return headingForPointFromElement(bindableElement, aabb, p);
+};
+
+const getDistanceForBinding = (
+ point: Readonly<GlobalPoint>,
+ bindableElement: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ zoom?: AppState["zoom"],
+) => {
+ const distance = distanceToBindableElement(bindableElement, point);
+ const bindDistance = maxBindingGap(
+ bindableElement,
+ bindableElement.width,
+ bindableElement.height,
+ zoom,
+ );
+
+ return distance > bindDistance ? null : distance;
+};
+
+export const bindPointToSnapToElementOutline = (
+ arrow: ExcalidrawElbowArrowElement,
+ bindableElement: ExcalidrawBindableElement | undefined,
+ startOrEnd: "start" | "end",
+): GlobalPoint => {
+ const aabb = bindableElement && aabbForElement(bindableElement);
+ const localP =
+ arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
+ const globalP = pointFrom<GlobalPoint>(
+ arrow.x + localP[0],
+ arrow.y + localP[1],
+ );
+ const p = isRectanguloidElement(bindableElement)
+ ? avoidRectangularCorner(bindableElement, globalP)
+ : globalP;
+
+ if (bindableElement && aabb) {
+ const center = getCenterForBounds(aabb);
+
+ const intersection = intersectElementWithLineSegment(
+ bindableElement,
+ lineSegment(
+ center,
+ pointFromVector(
+ vectorScale(
+ vectorNormalize(vectorFromPoint(p, center)),
+ Math.max(bindableElement.width, bindableElement.height),
+ ),
+ center,
+ ),
+ ),
+ )[0];
+ const currentDistance = pointDistance(p, center);
+ const fullDistance = Math.max(
+ pointDistance(intersection ?? p, center),
+ PRECISION,
+ );
+ const ratio = round(currentDistance / fullDistance, 6);
+
+ switch (true) {
+ case ratio > 0.9:
+ if (
+ currentDistance - fullDistance > FIXED_BINDING_DISTANCE ||
+ // Too close to determine vector from intersection to p
+ pointDistanceSq(p, intersection) < PRECISION
+ ) {
+ return p;
+ }
+
+ return pointFromVector(
+ vectorScale(
+ vectorNormalize(vectorFromPoint(p, intersection ?? center)),
+ ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE,
+ ),
+ intersection ?? center,
+ );
+
+ default:
+ return headingToMidBindPoint(p, bindableElement, aabb);
+ }
+ }
+
+ return p;
+};
+
+const headingToMidBindPoint = (
+ p: GlobalPoint,
+ bindableElement: ExcalidrawBindableElement,
+ aabb: Bounds,
+): GlobalPoint => {
+ const center = getCenterForBounds(aabb);
+ const heading = vectorToHeading(vectorFromPoint(p, center));
+
+ switch (true) {
+ case compareHeading(heading, HEADING_UP):
+ return pointRotateRads(
+ pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
+ center,
+ bindableElement.angle,
+ );
+ case compareHeading(heading, HEADING_RIGHT):
+ return pointRotateRads(
+ pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
+ center,
+ bindableElement.angle,
+ );
+ case compareHeading(heading, HEADING_DOWN):
+ return pointRotateRads(
+ pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
+ center,
+ bindableElement.angle,
+ );
+ default:
+ return pointRotateRads(
+ pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
+ center,
+ bindableElement.angle,
+ );
+ }
+};
+
+export const avoidRectangularCorner = (
+ element: ExcalidrawBindableElement,
+ p: GlobalPoint,
+): GlobalPoint => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+ const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
+
+ if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
+ // Top left
+ if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
+ return pointRotateRads<GlobalPoint>(
+ pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
+ center,
+ element.angle,
+ );
+ }
+ return pointRotateRads(
+ pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
+ center,
+ element.angle,
+ );
+ } else if (
+ nonRotatedPoint[0] < element.x &&
+ nonRotatedPoint[1] > element.y + element.height
+ ) {
+ // Bottom left
+ if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
+ return pointRotateRads(
+ pointFrom(
+ element.x,
+ element.y + element.height + FIXED_BINDING_DISTANCE,
+ ),
+ center,
+ element.angle,
+ );
+ }
+ return pointRotateRads(
+ pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
+ center,
+ element.angle,
+ );
+ } else if (
+ nonRotatedPoint[0] > element.x + element.width &&
+ nonRotatedPoint[1] > element.y + element.height
+ ) {
+ // Bottom right
+ if (
+ nonRotatedPoint[0] - element.x <
+ element.width + FIXED_BINDING_DISTANCE
+ ) {
+ return pointRotateRads(
+ pointFrom(
+ element.x + element.width,
+ element.y + element.height + FIXED_BINDING_DISTANCE,
+ ),
+ center,
+ element.angle,
+ );
+ }
+ return pointRotateRads(
+ pointFrom(
+ element.x + element.width + FIXED_BINDING_DISTANCE,
+ element.y + element.height,
+ ),
+ center,
+ element.angle,
+ );
+ } else if (
+ nonRotatedPoint[0] > element.x + element.width &&
+ nonRotatedPoint[1] < element.y
+ ) {
+ // Top right
+ if (
+ nonRotatedPoint[0] - element.x <
+ element.width + FIXED_BINDING_DISTANCE
+ ) {
+ return pointRotateRads(
+ pointFrom(
+ element.x + element.width,
+ element.y - FIXED_BINDING_DISTANCE,
+ ),
+ center,
+ element.angle,
+ );
+ }
+ return pointRotateRads(
+ pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
+ center,
+ element.angle,
+ );
+ }
+
+ return p;
+};
+
+export const snapToMid = (
+ element: ExcalidrawBindableElement,
+ p: GlobalPoint,
+ tolerance: number = 0.05,
+): GlobalPoint => {
+ const { x, y, width, height, angle } = element;
+ const center = pointFrom<GlobalPoint>(
+ x + width / 2 - 0.1,
+ y + height / 2 - 0.1,
+ );
+ const nonRotated = pointRotateRads(p, center, -angle as Radians);
+
+ // snap-to-center point is adaptive to element size, but we don't want to go
+ // above and below certain px distance
+ const verticalThrehsold = clamp(tolerance * height, 5, 80);
+ const horizontalThrehsold = clamp(tolerance * width, 5, 80);
+
+ if (
+ nonRotated[0] <= x + width / 2 &&
+ nonRotated[1] > center[1] - verticalThrehsold &&
+ nonRotated[1] < center[1] + verticalThrehsold
+ ) {
+ // LEFT
+ return pointRotateRads(
+ pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
+ center,
+ angle,
+ );
+ } else if (
+ nonRotated[1] <= y + height / 2 &&
+ nonRotated[0] > center[0] - horizontalThrehsold &&
+ nonRotated[0] < center[0] + horizontalThrehsold
+ ) {
+ // TOP
+ return pointRotateRads(
+ pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
+ center,
+ angle,
+ );
+ } else if (
+ nonRotated[0] >= x + width / 2 &&
+ nonRotated[1] > center[1] - verticalThrehsold &&
+ nonRotated[1] < center[1] + verticalThrehsold
+ ) {
+ // RIGHT
+ return pointRotateRads(
+ pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
+ center,
+ angle,
+ );
+ } else if (
+ nonRotated[1] >= y + height / 2 &&
+ nonRotated[0] > center[0] - horizontalThrehsold &&
+ nonRotated[0] < center[0] + horizontalThrehsold
+ ) {
+ // DOWN
+ return pointRotateRads(
+ pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
+ center,
+ angle,
+ );
+ }
+
+ return p;
+};
+
+const updateBoundPoint = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ startOrEnd: "startBinding" | "endBinding",
+ binding: PointBinding | null | undefined,
+ bindableElement: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+): LocalPoint | null => {
+ if (
+ binding == null ||
+ // We only need to update the other end if this is a 2 point line element
+ (binding.elementId !== bindableElement.id &&
+ linearElement.points.length > 2)
+ ) {
+ return null;
+ }
+
+ const direction = startOrEnd === "startBinding" ? -1 : 1;
+ const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
+
+ if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
+ const fixedPoint =
+ normalizeFixedPoint(binding.fixedPoint) ??
+ calculateFixedPointForElbowArrowBinding(
+ linearElement,
+ bindableElement,
+ startOrEnd === "startBinding" ? "start" : "end",
+ elementsMap,
+ ).fixedPoint;
+ const globalMidPoint = pointFrom<GlobalPoint>(
+ bindableElement.x + bindableElement.width / 2,
+ bindableElement.y + bindableElement.height / 2,
+ );
+ const global = pointFrom<GlobalPoint>(
+ bindableElement.x + fixedPoint[0] * bindableElement.width,
+ bindableElement.y + fixedPoint[1] * bindableElement.height,
+ );
+ const rotatedGlobal = pointRotateRads(
+ global,
+ globalMidPoint,
+ bindableElement.angle,
+ );
+
+ return LinearElementEditor.pointFromAbsoluteCoords(
+ linearElement,
+ rotatedGlobal,
+ elementsMap,
+ );
+ }
+
+ const adjacentPointIndex = edgePointIndex - direction;
+ const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ adjacentPointIndex,
+ elementsMap,
+ );
+ const focusPointAbsolute = determineFocusPoint(
+ bindableElement,
+ binding.focus,
+ adjacentPoint,
+ );
+
+ let newEdgePoint: GlobalPoint;
+
+ // The linear element was not originally pointing inside the bound shape,
+ // we can point directly at the focus point
+ if (binding.gap === 0) {
+ newEdgePoint = focusPointAbsolute;
+ } else {
+ const edgePointAbsolute =
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ edgePointIndex,
+ elementsMap,
+ );
+
+ const center = pointFrom<GlobalPoint>(
+ bindableElement.x + bindableElement.width / 2,
+ bindableElement.y + bindableElement.height / 2,
+ );
+ const interceptorLength =
+ pointDistance(adjacentPoint, edgePointAbsolute) +
+ pointDistance(adjacentPoint, center) +
+ Math.max(bindableElement.width, bindableElement.height) * 2;
+ const intersections = intersectElementWithLineSegment(
+ bindableElement,
+ lineSegment<GlobalPoint>(
+ adjacentPoint,
+ pointFromVector(
+ vectorScale(
+ vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
+ interceptorLength,
+ ),
+ adjacentPoint,
+ ),
+ ),
+ binding.gap,
+ ).sort(
+ (g, h) =>
+ pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint),
+ );
+
+ // debugClear();
+ // debugDrawPoint(intersections[0], { color: "red", permanent: true });
+ // debugDrawLine(
+ // lineSegment<GlobalPoint>(
+ // adjacentPoint,
+ // pointFromVector(
+ // vectorScale(
+ // vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)),
+ // interceptorLength,
+ // ),
+ // adjacentPoint,
+ // ),
+ // ),
+ // { permanent: true, color: "green" },
+ // );
+
+ if (intersections.length > 1) {
+ // The adjacent point is outside the shape (+ gap)
+ newEdgePoint = intersections[0];
+ } else if (intersections.length === 1) {
+ // The adjacent point is inside the shape (+ gap)
+ newEdgePoint = focusPointAbsolute;
+ } else {
+ // Shouldn't happend, but just in case
+ newEdgePoint = edgePointAbsolute;
+ }
+ }
+
+ return LinearElementEditor.pointFromAbsoluteCoords(
+ linearElement,
+ newEdgePoint,
+ elementsMap,
+ );
+};
+
+export const calculateFixedPointForElbowArrowBinding = (
+ linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
+ hoveredElement: ExcalidrawBindableElement,
+ startOrEnd: "start" | "end",
+ elementsMap: ElementsMap,
+): { fixedPoint: FixedPoint } => {
+ const bounds = [
+ hoveredElement.x,
+ hoveredElement.y,
+ hoveredElement.x + hoveredElement.width,
+ hoveredElement.y + hoveredElement.height,
+ ] as Bounds;
+ const snappedPoint = bindPointToSnapToElementOutline(
+ linearElement,
+ hoveredElement,
+ startOrEnd,
+ );
+ const globalMidPoint = pointFrom(
+ bounds[0] + (bounds[2] - bounds[0]) / 2,
+ bounds[1] + (bounds[3] - bounds[1]) / 2,
+ );
+ const nonRotatedSnappedGlobalPoint = pointRotateRads(
+ snappedPoint,
+ globalMidPoint,
+ -hoveredElement.angle as Radians,
+ );
+
+ return {
+ fixedPoint: normalizeFixedPoint([
+ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
+ hoveredElement.width,
+ (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
+ hoveredElement.height,
+ ]),
+ };
+};
+
+const maybeCalculateNewGapWhenScaling = (
+ changedElement: ExcalidrawBindableElement,
+ currentBinding: PointBinding | null | undefined,
+ newSize: { width: number; height: number } | undefined,
+): PointBinding | null | undefined => {
+ if (currentBinding == null || newSize == null) {
+ return currentBinding;
+ }
+ const { width: newWidth, height: newHeight } = newSize;
+ const { width, height } = changedElement;
+ const newGap = Math.max(
+ 1,
+ Math.min(
+ maxBindingGap(changedElement, newWidth, newHeight),
+ currentBinding.gap *
+ (newWidth < newHeight ? newWidth / width : newHeight / height),
+ ),
+ );
+
+ return { ...currentBinding, gap: newGap };
+};
+
+const getElligibleElementForBindingElement = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ startOrEnd: "start" | "end",
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ zoom?: AppState["zoom"],
+): NonDeleted<ExcalidrawBindableElement> | null => {
+ return getHoveredElementForBinding(
+ getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
+ elements,
+ elementsMap,
+ zoom,
+ isElbowArrow(linearElement),
+ isElbowArrow(linearElement),
+ );
+};
+
+const getLinearElementEdgeCoors = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ startOrEnd: "start" | "end",
+ elementsMap: NonDeletedSceneElementsMap,
+): { x: number; y: number } => {
+ const index = startOrEnd === "start" ? 0 : -1;
+ return tupleToCoors(
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ linearElement,
+ index,
+ elementsMap,
+ ),
+ );
+};
+
+// We need to:
+// 1: Update elements not selected to point to duplicated elements
+// 2: Update duplicated elements to point to other duplicated elements
+export const fixBindingsAfterDuplication = (
+ sceneElements: readonly ExcalidrawElement[],
+ oldElements: readonly ExcalidrawElement[],
+ oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+ // There are three copying mechanisms: Copy-paste, duplication and alt-drag.
+ // Only when alt-dragging the new "duplicates" act as the "old", while
+ // the "old" elements act as the "new copy" - essentially working reverse
+ // to the other two.
+ duplicatesServeAsOld?: "duplicatesServeAsOld" | undefined,
+): void => {
+ // First collect all the binding/bindable elements, so we only update
+ // each once, regardless of whether they were duplicated or not.
+ const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
+ const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
+ const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
+ const duplicateIdToOldId = new Map(
+ [...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
+ );
+ oldElements.forEach((oldElement) => {
+ const { boundElements } = oldElement;
+ if (boundElements != null && boundElements.length > 0) {
+ boundElements.forEach((boundElement) => {
+ if (shouldReverseRoles && !oldIdToDuplicatedId.has(boundElement.id)) {
+ allBoundElementIds.add(boundElement.id);
+ }
+ });
+ allBindableElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
+ }
+ if (isBindingElement(oldElement)) {
+ if (oldElement.startBinding != null) {
+ const { elementId } = oldElement.startBinding;
+ if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
+ allBindableElementIds.add(elementId);
+ }
+ }
+ if (oldElement.endBinding != null) {
+ const { elementId } = oldElement.endBinding;
+ if (shouldReverseRoles && !oldIdToDuplicatedId.has(elementId)) {
+ allBindableElementIds.add(elementId);
+ }
+ }
+ if (oldElement.startBinding != null || oldElement.endBinding != null) {
+ allBoundElementIds.add(oldIdToDuplicatedId.get(oldElement.id)!);
+ }
+ }
+ });
+
+ // Update the linear elements
+ (
+ sceneElements.filter(({ id }) =>
+ allBoundElementIds.has(id),
+ ) as ExcalidrawLinearElement[]
+ ).forEach((element) => {
+ const { startBinding, endBinding } = element;
+ mutateElement(element, {
+ startBinding: newBindingAfterDuplication(
+ startBinding,
+ oldIdToDuplicatedId,
+ ),
+ endBinding: newBindingAfterDuplication(endBinding, oldIdToDuplicatedId),
+ });
+ });
+
+ // Update the bindable shapes
+ sceneElements
+ .filter(({ id }) => allBindableElementIds.has(id))
+ .forEach((bindableElement) => {
+ const oldElementId = duplicateIdToOldId.get(bindableElement.id);
+ const boundElements = sceneElements.find(
+ ({ id }) => id === oldElementId,
+ )?.boundElements;
+
+ if (boundElements && boundElements.length > 0) {
+ mutateElement(bindableElement, {
+ boundElements: boundElements.map((boundElement) =>
+ oldIdToDuplicatedId.has(boundElement.id)
+ ? {
+ id: oldIdToDuplicatedId.get(boundElement.id)!,
+ type: boundElement.type,
+ }
+ : boundElement,
+ ),
+ });
+ }
+ });
+};
+
+const newBindingAfterDuplication = (
+ binding: PointBinding | null,
+ oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+): PointBinding | null => {
+ if (binding == null) {
+ return null;
+ }
+ return {
+ ...binding,
+ elementId: oldIdToDuplicatedId.get(binding.elementId) ?? binding.elementId,
+ };
+};
+
+export const fixBindingsAfterDeletion = (
+ sceneElements: readonly ExcalidrawElement[],
+ deletedElements: readonly ExcalidrawElement[],
+): void => {
+ const elements = arrayToMap(sceneElements);
+
+ for (const element of deletedElements) {
+ BoundElement.unbindAffected(elements, element, mutateElement);
+ BindableElement.unbindAffected(elements, element, mutateElement);
+ }
+};
+
+const newBoundElements = (
+ boundElements: ExcalidrawElement["boundElements"],
+ idsToRemove: Set<ExcalidrawElement["id"]>,
+ elementsToAdd: Array<ExcalidrawElement> = [],
+) => {
+ if (!boundElements) {
+ return null;
+ }
+
+ const nextBoundElements = boundElements.filter(
+ (boundElement) => !idsToRemove.has(boundElement.id),
+ );
+
+ nextBoundElements.push(
+ ...elementsToAdd.map(
+ (x) =>
+ ({ id: x.id, type: x.type } as
+ | ExcalidrawArrowElement
+ | ExcalidrawTextElement),
+ ),
+ );
+
+ return nextBoundElements;
+};
+
+export const bindingBorderTest = (
+ element: NonDeleted<ExcalidrawBindableElement>,
+ { x, y }: { x: number; y: number },
+ elementsMap: NonDeletedSceneElementsMap,
+ zoom?: AppState["zoom"],
+ fullShape?: boolean,
+): boolean => {
+ const threshold = maxBindingGap(element, element.width, element.height, zoom);
+
+ const shape = getElementShape(element, elementsMap);
+ return (
+ isPointOnShape(pointFrom(x, y), shape, threshold) ||
+ (fullShape === true &&
+ pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
+ );
+};
+
+export const maxBindingGap = (
+ element: ExcalidrawElement,
+ elementWidth: number,
+ elementHeight: number,
+ zoom?: AppState["zoom"],
+): number => {
+ const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
+
+ // Aligns diamonds with rectangles
+ const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
+ const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
+
+ return Math.max(
+ 16,
+ // bigger bindable boundary for bigger elements
+ Math.min(0.25 * smallerDimension, 32),
+ // keep in sync with the zoomed highlight
+ BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
+ );
+};
+
+// The focus distance is the oriented ratio between the size of
+// the `element` and the "focus image" of the element on which
+// all focus points lie, so it's a number between -1 and 1.
+// The line going through `a` and `b` is a tangent to the "focus image"
+// of the element.
+const determineFocusDistance = (
+ element: ExcalidrawBindableElement,
+ // Point on the line, in absolute coordinates
+ a: GlobalPoint,
+ // Another point on the line, in absolute coordinates (closer to element)
+ b: GlobalPoint,
+): number => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ if (pointsEqual(a, b)) {
+ return 0;
+ }
+
+ const rotatedA = pointRotateRads(a, center, -element.angle as Radians);
+ const rotatedB = pointRotateRads(b, center, -element.angle as Radians);
+ const sign =
+ Math.sign(
+ vectorCross(
+ vectorFromPoint(rotatedB, a),
+ vectorFromPoint(rotatedB, center),
+ ),
+ ) * -1;
+ const rotatedInterceptor = lineSegment(
+ rotatedB,
+ pointFromVector(
+ vectorScale(
+ vectorNormalize(vectorFromPoint(rotatedB, rotatedA)),
+ Math.max(element.width * 2, element.height * 2),
+ ),
+ rotatedB,
+ ),
+ );
+ const axes =
+ element.type === "diamond"
+ ? [
+ lineSegment(
+ pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
+ pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height,
+ ),
+ ),
+ lineSegment(
+ pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
+ pointFrom<GlobalPoint>(
+ element.x + element.width,
+ element.y + element.height / 2,
+ ),
+ ),
+ ]
+ : [
+ lineSegment(
+ pointFrom<GlobalPoint>(element.x, element.y),
+ pointFrom<GlobalPoint>(
+ element.x + element.width,
+ element.y + element.height,
+ ),
+ ),
+ lineSegment(
+ pointFrom<GlobalPoint>(element.x + element.width, element.y),
+ pointFrom<GlobalPoint>(element.x, element.y + element.height),
+ ),
+ ];
+ const interceptees =
+ element.type === "diamond"
+ ? [
+ lineSegment(
+ pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y - element.height,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height * 2,
+ ),
+ ),
+ lineSegment(
+ pointFrom<GlobalPoint>(
+ element.x - element.width,
+ element.y + element.height / 2,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x + element.width * 2,
+ element.y + element.height / 2,
+ ),
+ ),
+ ]
+ : [
+ lineSegment(
+ pointFrom<GlobalPoint>(
+ element.x - element.width,
+ element.y - element.height,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x + element.width * 2,
+ element.y + element.height * 2,
+ ),
+ ),
+ lineSegment(
+ pointFrom<GlobalPoint>(
+ element.x + element.width * 2,
+ element.y - element.height,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x - element.width,
+ element.y + element.height * 2,
+ ),
+ ),
+ ];
+
+ const ordered = [
+ lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]),
+ lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]),
+ ]
+ .filter((p): p is GlobalPoint => p !== null)
+ .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b))
+ .map(
+ (p, idx): number =>
+ (sign * pointDistance(center, p)) /
+ (element.type === "diamond"
+ ? pointDistance(axes[idx][0], axes[idx][1]) / 2
+ : Math.sqrt(element.width ** 2 + element.height ** 2) / 2),
+ )
+ .sort((g, h) => Math.abs(g) - Math.abs(h));
+
+ // debugClear();
+ // [
+ // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]),
+ // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]),
+ // ]
+ // .filter((p): p is GlobalPoint => p !== null)
+ // .forEach((p) => debugDrawPoint(p, { color: "black", permanent: true }));
+ // debugDrawPoint(determineFocusPoint(element, ordered[0] ?? 0, rotatedA), {
+ // color: "red",
+ // permanent: true,
+ // });
+ // debugDrawLine(rotatedInterceptor, { color: "green", permanent: true });
+ // debugDrawLine(interceptees[0], { color: "red", permanent: true });
+ // debugDrawLine(interceptees[1], { color: "red", permanent: true });
+
+ const signedDistanceRatio = ordered[0] ?? 0;
+
+ return signedDistanceRatio;
+};
+
+const determineFocusPoint = (
+ element: ExcalidrawBindableElement,
+ // The oriented, relative distance from the center of `element` of the
+ // returned focusPoint
+ focus: number,
+ adjacentPoint: GlobalPoint,
+): GlobalPoint => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ if (focus === 0) {
+ return center;
+ }
+
+ const candidates = (
+ element.type === "diamond"
+ ? [
+ pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
+ pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
+ pointFrom<GlobalPoint>(
+ element.x + element.width,
+ element.y + element.height / 2,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height,
+ ),
+ ]
+ : [
+ pointFrom<GlobalPoint>(element.x, element.y),
+ pointFrom<GlobalPoint>(element.x + element.width, element.y),
+ pointFrom<GlobalPoint>(
+ element.x + element.width,
+ element.y + element.height,
+ ),
+ pointFrom<GlobalPoint>(element.x, element.y + element.height),
+ ]
+ )
+ .map((p) =>
+ pointFromVector(
+ vectorScale(vectorFromPoint(p, center), Math.abs(focus)),
+ center,
+ ),
+ )
+ .map((p) => pointRotateRads(p, center, element.angle as Radians));
+
+ const selected = [
+ vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[0]),
+ vectorFromPoint(candidates[1], candidates[0]),
+ ) > 0 && // TOP
+ (focus > 0
+ ? vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[1]),
+ vectorFromPoint(candidates[2], candidates[1]),
+ ) < 0
+ : vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[3]),
+ vectorFromPoint(candidates[0], candidates[3]),
+ ) < 0),
+ vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[1]),
+ vectorFromPoint(candidates[2], candidates[1]),
+ ) > 0 && // RIGHT
+ (focus > 0
+ ? vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[2]),
+ vectorFromPoint(candidates[3], candidates[2]),
+ ) < 0
+ : vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[0]),
+ vectorFromPoint(candidates[1], candidates[0]),
+ ) < 0),
+ vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[2]),
+ vectorFromPoint(candidates[3], candidates[2]),
+ ) > 0 && // BOTTOM
+ (focus > 0
+ ? vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[3]),
+ vectorFromPoint(candidates[0], candidates[3]),
+ ) < 0
+ : vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[1]),
+ vectorFromPoint(candidates[2], candidates[1]),
+ ) < 0),
+ vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[3]),
+ vectorFromPoint(candidates[0], candidates[3]),
+ ) > 0 && // LEFT
+ (focus > 0
+ ? vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[0]),
+ vectorFromPoint(candidates[1], candidates[0]),
+ ) < 0
+ : vectorCross(
+ vectorFromPoint(adjacentPoint, candidates[2]),
+ vectorFromPoint(candidates[3], candidates[2]),
+ ) < 0),
+ ];
+
+ const focusPoint = selected[0]
+ ? focus > 0
+ ? candidates[1]
+ : candidates[0]
+ : selected[1]
+ ? focus > 0
+ ? candidates[2]
+ : candidates[1]
+ : selected[2]
+ ? focus > 0
+ ? candidates[3]
+ : candidates[2]
+ : focus > 0
+ ? candidates[0]
+ : candidates[3];
+
+ return focusPoint;
+};
+
+export const bindingProperties: Set<BindableProp | BindingProp> = new Set([
+ "boundElements",
+ "frameId",
+ "containerId",
+ "startBinding",
+ "endBinding",
+]);
+
+export type BindableProp = "boundElements";
+
+export type BindingProp =
+ | "frameId"
+ | "containerId"
+ | "startBinding"
+ | "endBinding";
+
+type BoundElementsVisitingFunc = (
+ boundElement: ExcalidrawElement | undefined,
+ bindingProp: BindableProp,
+ bindingId: string,
+) => void;
+
+type BindableElementVisitingFunc<T> = (
+ bindableElement: ExcalidrawElement | undefined,
+ bindingProp: BindingProp,
+ bindingId: string,
+) => T;
+
+/**
+ * Tries to visit each bound element (does not have to be found).
+ */
+const boundElementsVisitor = (
+ elements: ElementsMap,
+ element: ExcalidrawElement,
+ visit: BoundElementsVisitingFunc,
+) => {
+ if (isBindableElement(element)) {
+ // create new instance so that possible mutations won't play a role in visiting order
+ const boundElements = element.boundElements?.slice() ?? [];
+
+ // last added text should be the one we keep (~previous are duplicates)
+ boundElements.forEach(({ id }) => {
+ visit(elements.get(id), "boundElements", id);
+ });
+ }
+};
+
+/**
+ * Tries to visit each bindable element (does not have to be found).
+ */
+const bindableElementsVisitor = <T>(
+ elements: ElementsMap,
+ element: ExcalidrawElement,
+ visit: BindableElementVisitingFunc<T>,
+): T[] => {
+ const result: T[] = [];
+
+ if (element.frameId) {
+ const id = element.frameId;
+ result.push(visit(elements.get(id), "frameId", id));
+ }
+
+ if (isBoundToContainer(element)) {
+ const id = element.containerId;
+ result.push(visit(elements.get(id), "containerId", id));
+ }
+
+ if (isArrowElement(element)) {
+ if (element.startBinding) {
+ const id = element.startBinding.elementId;
+ result.push(visit(elements.get(id), "startBinding", id));
+ }
+
+ if (element.endBinding) {
+ const id = element.endBinding.elementId;
+ result.push(visit(elements.get(id), "endBinding", id));
+ }
+ }
+
+ return result;
+};
+
+/**
+ * Bound element containing bindings to `frameId`, `containerId`, `startBinding` or `endBinding`.
+ */
+export class BoundElement {
+ /**
+ * Unbind the affected non deleted bindable elements (removing element from `boundElements`).
+ * - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
+ * - prepares updates to unbind each bindable element's `boundElements` from the current element
+ */
+ public static unbindAffected(
+ elements: ElementsMap,
+ boundElement: ExcalidrawElement | undefined,
+ updateElementWith: (
+ affected: ExcalidrawElement,
+ updates: ElementUpdate<ExcalidrawElement>,
+ ) => void,
+ ) {
+ if (!boundElement) {
+ return;
+ }
+
+ bindableElementsVisitor(elements, boundElement, (bindableElement) => {
+ // bindable element is deleted, this is fine
+ if (!bindableElement || bindableElement.isDeleted) {
+ return;
+ }
+
+ boundElementsVisitor(
+ elements,
+ bindableElement,
+ (_, __, boundElementId) => {
+ if (boundElementId === boundElement.id) {
+ updateElementWith(bindableElement, {
+ boundElements: newBoundElements(
+ bindableElement.boundElements,
+ new Set([boundElementId]),
+ ),
+ });
+ }
+ },
+ );
+ });
+ }
+
+ /**
+ * Rebind the next affected non deleted bindable elements (adding element to `boundElements`).
+ * - iterates non deleted bindable elements (`containerId` | `startBinding.elementId` | `endBinding.elementId`) of the current element
+ * - prepares updates to rebind each bindable element's `boundElements` to the current element
+ *
+ * NOTE: rebind expects that affected elements were previously unbound with `BoundElement.unbindAffected`
+ */
+ public static rebindAffected = (
+ elements: ElementsMap,
+ boundElement: ExcalidrawElement | undefined,
+ updateElementWith: (
+ affected: ExcalidrawElement,
+ updates: ElementUpdate<ExcalidrawElement>,
+ ) => void,
+ ) => {
+ // don't try to rebind element that is deleted
+ if (!boundElement || boundElement.isDeleted) {
+ return;
+ }
+
+ bindableElementsVisitor(
+ elements,
+ boundElement,
+ (bindableElement, bindingProp) => {
+ // unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
+ if (!bindableElement || bindableElement.isDeleted) {
+ updateElementWith(boundElement, { [bindingProp]: null });
+ return;
+ }
+
+ // frame bindings are unidirectional, there is nothing to rebind
+ if (bindingProp === "frameId") {
+ return;
+ }
+
+ if (
+ bindableElement.boundElements?.find((x) => x.id === boundElement.id)
+ ) {
+ return;
+ }
+
+ if (isArrowElement(boundElement)) {
+ // rebind if not found!
+ updateElementWith(bindableElement, {
+ boundElements: newBoundElements(
+ bindableElement.boundElements,
+ new Set(),
+ new Array(boundElement),
+ ),
+ });
+ }
+
+ if (isTextElement(boundElement)) {
+ if (!bindableElement.boundElements?.find((x) => x.type === "text")) {
+ // rebind only if there is no other text bound already
+ updateElementWith(bindableElement, {
+ boundElements: newBoundElements(
+ bindableElement.boundElements,
+ new Set(),
+ new Array(boundElement),
+ ),
+ });
+ } else {
+ // unbind otherwise
+ updateElementWith(boundElement, { [bindingProp]: null });
+ }
+ }
+ },
+ );
+ };
+}
+
+/**
+ * Bindable element containing bindings to `boundElements`.
+ */
+export class BindableElement {
+ /**
+ * Unbind the affected non deleted bound elements (resetting `containerId`, `startBinding`, `endBinding` to `null`).
+ * - iterates through non deleted `boundElements` of the current element
+ * - prepares updates to unbind each bound element from the current element
+ */
+ public static unbindAffected(
+ elements: ElementsMap,
+ bindableElement: ExcalidrawElement | undefined,
+ updateElementWith: (
+ affected: ExcalidrawElement,
+ updates: ElementUpdate<ExcalidrawElement>,
+ ) => void,
+ ) {
+ if (!bindableElement) {
+ return;
+ }
+
+ boundElementsVisitor(elements, bindableElement, (boundElement) => {
+ // bound element is deleted, this is fine
+ if (!boundElement || boundElement.isDeleted) {
+ return;
+ }
+
+ bindableElementsVisitor(
+ elements,
+ boundElement,
+ (_, bindingProp, bindableElementId) => {
+ // making sure there is an element to be unbound
+ if (bindableElementId === bindableElement.id) {
+ updateElementWith(boundElement, { [bindingProp]: null });
+ }
+ },
+ );
+ });
+ }
+
+ /**
+ * Rebind the affected non deleted bound elements (for now setting only `containerId`, as we cannot rebind arrows atm).
+ * - iterates through non deleted `boundElements` of the current element
+ * - prepares updates to rebind each bound element to the current element or unbind it from `boundElements` in case of conflicts
+ *
+ * NOTE: rebind expects that affected elements were previously unbound with `BindaleElement.unbindAffected`
+ */
+ public static rebindAffected = (
+ elements: ElementsMap,
+ bindableElement: ExcalidrawElement | undefined,
+ updateElementWith: (
+ affected: ExcalidrawElement,
+ updates: ElementUpdate<ExcalidrawElement>,
+ ) => void,
+ ) => {
+ // don't try to rebind element that is deleted (i.e. updated as deleted)
+ if (!bindableElement || bindableElement.isDeleted) {
+ return;
+ }
+
+ boundElementsVisitor(
+ elements,
+ bindableElement,
+ (boundElement, _, boundElementId) => {
+ // unbind from bindable elements, as bindings from non deleted elements into deleted elements are incorrect
+ if (!boundElement || boundElement.isDeleted) {
+ updateElementWith(bindableElement, {
+ boundElements: newBoundElements(
+ bindableElement.boundElements,
+ new Set([boundElementId]),
+ ),
+ });
+ return;
+ }
+
+ if (isTextElement(boundElement)) {
+ const boundElements = bindableElement.boundElements?.slice() ?? [];
+ // check if this is the last element in the array, if not, there is an previously bound text which should be unbound
+ if (
+ boundElements.reverse().find((x) => x.type === "text")?.id ===
+ boundElement.id
+ ) {
+ if (boundElement.containerId !== bindableElement.id) {
+ // rebind if not bound already!
+ updateElementWith(boundElement, {
+ containerId: bindableElement.id,
+ } as ElementUpdate<ExcalidrawTextElement>);
+ }
+ } else {
+ if (boundElement.containerId !== null) {
+ // unbind if not unbound already
+ updateElementWith(boundElement, {
+ containerId: null,
+ } as ElementUpdate<ExcalidrawTextElement>);
+ }
+
+ // unbind from boundElements as the element got bound to some other element in the meantime
+ updateElementWith(bindableElement, {
+ boundElements: newBoundElements(
+ bindableElement.boundElements,
+ new Set([boundElement.id]),
+ ),
+ });
+ }
+ }
+ },
+ );
+ };
+}
+
+export const getGlobalFixedPointForBindableElement = (
+ fixedPointRatio: [number, number],
+ element: ExcalidrawBindableElement,
+): GlobalPoint => {
+ const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
+
+ return pointRotateRads(
+ pointFrom(
+ element.x + element.width * fixedX,
+ element.y + element.height * fixedY,
+ ),
+ pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ ),
+ element.angle,
+ );
+};
+
+export const getGlobalFixedPoints = (
+ arrow: ExcalidrawElbowArrowElement,
+ elementsMap: ElementsMap,
+): [GlobalPoint, GlobalPoint] => {
+ const startElement =
+ arrow.startBinding &&
+ (elementsMap.get(arrow.startBinding.elementId) as
+ | ExcalidrawBindableElement
+ | undefined);
+ const endElement =
+ arrow.endBinding &&
+ (elementsMap.get(arrow.endBinding.elementId) as
+ | ExcalidrawBindableElement
+ | undefined);
+ const startPoint =
+ startElement && arrow.startBinding
+ ? getGlobalFixedPointForBindableElement(
+ arrow.startBinding.fixedPoint,
+ startElement as ExcalidrawBindableElement,
+ )
+ : pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[0][0],
+ arrow.y + arrow.points[0][1],
+ );
+ const endPoint =
+ endElement && arrow.endBinding
+ ? getGlobalFixedPointForBindableElement(
+ arrow.endBinding.fixedPoint,
+ endElement as ExcalidrawBindableElement,
+ )
+ : pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[arrow.points.length - 1][0],
+ arrow.y + arrow.points[arrow.points.length - 1][1],
+ );
+
+ return [startPoint, endPoint];
+};
+
+export const getArrowLocalFixedPoints = (
+ arrow: ExcalidrawElbowArrowElement,
+ elementsMap: ElementsMap,
+) => {
+ const [startPoint, endPoint] = getGlobalFixedPoints(arrow, elementsMap);
+
+ return [
+ LinearElementEditor.pointFromAbsoluteCoords(arrow, startPoint, elementsMap),
+ LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
+ ];
+};
+
+export const normalizeFixedPoint = <T extends FixedPoint | null>(
+ fixedPoint: T,
+): T extends null ? null : FixedPoint => {
+ // Do not allow a precise 0.5 for fixed point ratio
+ // to avoid jumping arrow heading due to floating point imprecision
+ if (
+ fixedPoint &&
+ (Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
+ Math.abs(fixedPoint[1] - 0.5) < 0.0001)
+ ) {
+ return fixedPoint.map((ratio) =>
+ Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
+ ) as T extends null ? null : FixedPoint;
+ }
+ return fixedPoint as any as T extends null ? null : FixedPoint;
+};
diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts
new file mode 100644
index 0000000..9d91d09
--- /dev/null
+++ b/packages/excalidraw/element/bounds.test.ts
@@ -0,0 +1,140 @@
+import type { LocalPoint } from "@excalidraw/math";
+import { pointFrom } from "@excalidraw/math";
+import { ROUNDNESS } from "../constants";
+import { arrayToMap } from "../utils";
+import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
+import type { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
+
+const _ce = ({
+ x,
+ y,
+ w,
+ h,
+ a,
+ t,
+}: {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ a?: number;
+ t?: string;
+}) =>
+ ({
+ type: t || "rectangle",
+ strokeColor: "#000",
+ backgroundColor: "#000",
+ fillStyle: "solid",
+ strokeWidth: 1,
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ roughness: 0,
+ opacity: 1,
+ x,
+ y,
+ width: w,
+ height: h,
+ angle: a,
+ } as ExcalidrawElement);
+
+describe("getElementAbsoluteCoords", () => {
+ it("test x1 coordinate", () => {
+ const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
+ const [x1] = getElementAbsoluteCoords(element, arrayToMap([element]));
+ expect(x1).toEqual(10);
+ });
+
+ it("test x2 coordinate", () => {
+ const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
+ const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element]));
+ expect(x2).toEqual(20);
+ });
+
+ it("test y1 coordinate", () => {
+ const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
+ const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element]));
+ expect(y1).toEqual(10);
+ });
+
+ it("test y2 coordinate", () => {
+ const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
+ const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element]));
+ expect(y2).toEqual(20);
+ });
+});
+
+describe("getElementBounds", () => {
+ it("rectangle", () => {
+ const element = _ce({
+ x: 40,
+ y: 30,
+ w: 20,
+ h: 10,
+ a: Math.PI / 4,
+ t: "rectangle",
+ });
+ const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
+ expect(x1).toEqual(39.39339828220179);
+ expect(y1).toEqual(24.393398282201787);
+ expect(x2).toEqual(60.60660171779821);
+ expect(y2).toEqual(45.60660171779821);
+ });
+
+ it("diamond", () => {
+ const element = _ce({
+ x: 40,
+ y: 30,
+ w: 20,
+ h: 10,
+ a: Math.PI / 4,
+ t: "diamond",
+ });
+
+ const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
+
+ expect(x1).toEqual(42.928932188134524);
+ expect(y1).toEqual(27.928932188134524);
+ expect(x2).toEqual(57.071067811865476);
+ expect(y2).toEqual(42.071067811865476);
+ });
+
+ it("ellipse", () => {
+ const element = _ce({
+ x: 40,
+ y: 30,
+ w: 20,
+ h: 10,
+ a: Math.PI / 4,
+ t: "ellipse",
+ });
+
+ const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
+ expect(x1).toEqual(42.09430584957905);
+ expect(y1).toEqual(27.09430584957905);
+ expect(x2).toEqual(57.90569415042095);
+ expect(y2).toEqual(42.90569415042095);
+ });
+
+ it("curved line", () => {
+ const element = {
+ ..._ce({
+ t: "line",
+ x: 449.58203125,
+ y: 186.0625,
+ w: 170.12890625,
+ h: 92.48828125,
+ a: 0.6447741904932416,
+ }),
+ points: [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(67.33984375, 92.48828125),
+ pointFrom<LocalPoint>(-102.7890625, 52.15625),
+ ],
+ } as ExcalidrawLinearElement;
+
+ const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
+ expect(x1).toEqual(360.3176068760539);
+ expect(y1).toEqual(185.90654264413516);
+ expect(x2).toEqual(480.87005902729743);
+ expect(y2).toEqual(320.4751269334226);
+ });
+});
diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts
new file mode 100644
index 0000000..06f9770
--- /dev/null
+++ b/packages/excalidraw/element/bounds.ts
@@ -0,0 +1,1025 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ Arrowhead,
+ ExcalidrawFreeDrawElement,
+ NonDeleted,
+ ExcalidrawTextElementWithContainer,
+ ElementsMap,
+} from "./types";
+import rough from "roughjs/bin/rough";
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
+import type { Drawable, Op } from "roughjs/bin/core";
+import type { AppState } from "../types";
+import { generateRoughOptions } from "../scene/Shape";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isFreeDrawElement,
+ isLinearElement,
+ isTextElement,
+} from "./typeChecks";
+import { rescalePoints } from "../points";
+import { getBoundTextElement, getContainerElement } from "./textElement";
+import { LinearElementEditor } from "./linearElementEditor";
+import { ShapeCache } from "../scene/ShapeCache";
+import { arrayToMap, invariant } from "../utils";
+import type {
+ Degrees,
+ GlobalPoint,
+ LineSegment,
+ LocalPoint,
+ Radians,
+} from "@excalidraw/math";
+import {
+ degreesToRadians,
+ lineSegment,
+ pointFrom,
+ pointDistance,
+ pointFromArray,
+ pointRotateRads,
+} from "@excalidraw/math";
+import type { Mutable } from "../utility-types";
+import { getCurvePathOps } from "@excalidraw/utils/geometry/shape";
+
+export type RectangleBox = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ angle: number;
+};
+
+type MaybeQuadraticSolution = [number | null, number | null] | false;
+
+/**
+ * x and y position of top left corner, x and y position of bottom right corner
+ */
+export type Bounds = readonly [
+ minX: number,
+ minY: number,
+ maxX: number,
+ maxY: number,
+];
+
+export type SceneBounds = readonly [
+ sceneX: number,
+ sceneY: number,
+ sceneX2: number,
+ sceneY2: number,
+];
+
+export class ElementBounds {
+ private static boundsCache = new WeakMap<
+ ExcalidrawElement,
+ {
+ bounds: Bounds;
+ version: ExcalidrawElement["version"];
+ }
+ >();
+
+ static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
+ const cachedBounds = ElementBounds.boundsCache.get(element);
+
+ if (
+ cachedBounds?.version &&
+ cachedBounds.version === element.version &&
+ // we don't invalidate cache when we update containers and not labels,
+ // which is causing problems down the line. Fix TBA.
+ !isBoundToContainer(element)
+ ) {
+ return cachedBounds.bounds;
+ }
+ const bounds = ElementBounds.calculateBounds(element, elementsMap);
+
+ ElementBounds.boundsCache.set(element, {
+ version: element.version,
+ bounds,
+ });
+
+ return bounds;
+ }
+
+ private static calculateBounds(
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ ): Bounds {
+ let bounds: Bounds;
+
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+ if (isFreeDrawElement(element)) {
+ const [minX, minY, maxX, maxY] = getBoundsFromPoints(
+ element.points.map(([x, y]) =>
+ pointRotateRads(
+ pointFrom(x, y),
+ pointFrom(cx - element.x, cy - element.y),
+ element.angle,
+ ),
+ ),
+ );
+
+ return [
+ minX + element.x,
+ minY + element.y,
+ maxX + element.x,
+ maxY + element.y,
+ ];
+ } else if (isLinearElement(element)) {
+ bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
+ } else if (element.type === "diamond") {
+ const [x11, y11] = pointRotateRads(
+ pointFrom(cx, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x12, y12] = pointRotateRads(
+ pointFrom(cx, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x22, y22] = pointRotateRads(
+ pointFrom(x1, cy),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x21, y21] = pointRotateRads(
+ pointFrom(x2, cy),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const minX = Math.min(x11, x12, x22, x21);
+ const minY = Math.min(y11, y12, y22, y21);
+ const maxX = Math.max(x11, x12, x22, x21);
+ const maxY = Math.max(y11, y12, y22, y21);
+ bounds = [minX, minY, maxX, maxY];
+ } else if (element.type === "ellipse") {
+ const w = (x2 - x1) / 2;
+ const h = (y2 - y1) / 2;
+ const cos = Math.cos(element.angle);
+ const sin = Math.sin(element.angle);
+ const ww = Math.hypot(w * cos, h * sin);
+ const hh = Math.hypot(h * cos, w * sin);
+ bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
+ } else {
+ const [x11, y11] = pointRotateRads(
+ pointFrom(x1, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x12, y12] = pointRotateRads(
+ pointFrom(x1, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x22, y22] = pointRotateRads(
+ pointFrom(x2, y2),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const [x21, y21] = pointRotateRads(
+ pointFrom(x2, y1),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const minX = Math.min(x11, x12, x22, x21);
+ const minY = Math.min(y11, y12, y22, y21);
+ const maxX = Math.max(x11, x12, x22, x21);
+ const maxY = Math.max(y11, y12, y22, y21);
+ bounds = [minX, minY, maxX, maxY];
+ }
+
+ return bounds;
+ }
+}
+
+// Scene -> Scene coords, but in x1,x2,y1,y2 format.
+//
+// If the element is created from right to left, the width is going to be negative
+// This set of functions retrieves the absolute position of the 4 points.
+export const getElementAbsoluteCoords = (
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ includeBoundText: boolean = false,
+): [number, number, number, number, number, number] => {
+ if (isFreeDrawElement(element)) {
+ return getFreeDrawElementAbsoluteCoords(element);
+ } else if (isLinearElement(element)) {
+ return LinearElementEditor.getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ includeBoundText,
+ );
+ } else if (isTextElement(element)) {
+ const container = elementsMap
+ ? getContainerElement(element, elementsMap)
+ : null;
+ if (isArrowElement(container)) {
+ const { x, y } = LinearElementEditor.getBoundTextElementPosition(
+ container,
+ element as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ return [
+ x,
+ y,
+ x + element.width,
+ y + element.height,
+ x + element.width / 2,
+ y + element.height / 2,
+ ];
+ }
+ }
+ return [
+ element.x,
+ element.y,
+ element.x + element.width,
+ element.y + element.height,
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ ];
+};
+
+/*
+ * for a given element, `getElementLineSegments` returns line segments
+ * that can be used for visual collision detection (useful for frames)
+ * as opposed to bounding box collision detection
+ */
+export const getElementLineSegments = (
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+): LineSegment<GlobalPoint>[] => {
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+
+ const center: GlobalPoint = pointFrom(cx, cy);
+
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ const segments: LineSegment<GlobalPoint>[] = [];
+
+ let i = 0;
+
+ while (i < element.points.length - 1) {
+ segments.push(
+ lineSegment(
+ pointRotateRads(
+ pointFrom(
+ element.points[i][0] + element.x,
+ element.points[i][1] + element.y,
+ ),
+ center,
+ element.angle,
+ ),
+ pointRotateRads(
+ pointFrom(
+ element.points[i + 1][0] + element.x,
+ element.points[i + 1][1] + element.y,
+ ),
+ center,
+ element.angle,
+ ),
+ ),
+ );
+ i++;
+ }
+
+ return segments;
+ }
+
+ const [nw, ne, sw, se, n, s, w, e] = (
+ [
+ [x1, y1],
+ [x2, y1],
+ [x1, y2],
+ [x2, y2],
+ [cx, y1],
+ [cx, y2],
+ [x1, cy],
+ [x2, cy],
+ ] as GlobalPoint[]
+ ).map((point) => pointRotateRads(point, center, element.angle));
+
+ if (element.type === "diamond") {
+ return [
+ lineSegment(n, w),
+ lineSegment(n, e),
+ lineSegment(s, w),
+ lineSegment(s, e),
+ ];
+ }
+
+ if (element.type === "ellipse") {
+ return [
+ lineSegment(n, w),
+ lineSegment(n, e),
+ lineSegment(s, w),
+ lineSegment(s, e),
+ lineSegment(n, w),
+ lineSegment(n, e),
+ lineSegment(s, w),
+ lineSegment(s, e),
+ ];
+ }
+
+ return [
+ lineSegment(nw, ne),
+ lineSegment(sw, se),
+ lineSegment(nw, sw),
+ lineSegment(ne, se),
+ lineSegment(nw, e),
+ lineSegment(sw, e),
+ lineSegment(ne, w),
+ lineSegment(se, w),
+ ];
+};
+
+/**
+ * Scene -> Scene coords, but in x1,x2,y1,y2 format.
+ *
+ * Rectangle here means any rectangular frame, not an excalidraw element.
+ */
+export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
+ return [
+ boxSceneCoords.x,
+ boxSceneCoords.y,
+ boxSceneCoords.x + boxSceneCoords.width,
+ boxSceneCoords.y + boxSceneCoords.height,
+ boxSceneCoords.x + boxSceneCoords.width / 2,
+ boxSceneCoords.y + boxSceneCoords.height / 2,
+ ];
+};
+
+export const getDiamondPoints = (element: ExcalidrawElement) => {
+ // Here we add +1 to avoid these numbers to be 0
+ // otherwise rough.js will throw an error complaining about it
+ const topX = Math.floor(element.width / 2) + 1;
+ const topY = 0;
+ const rightX = element.width;
+ const rightY = Math.floor(element.height / 2) + 1;
+ const bottomX = topX;
+ const bottomY = element.height;
+ const leftX = 0;
+ const leftY = rightY;
+
+ return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
+};
+
+// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
+const getBezierValueForT = (
+ t: number,
+ p0: number,
+ p1: number,
+ p2: number,
+ p3: number,
+) => {
+ const oneMinusT = 1 - t;
+ return (
+ Math.pow(oneMinusT, 3) * p0 +
+ 3 * Math.pow(oneMinusT, 2) * t * p1 +
+ 3 * oneMinusT * Math.pow(t, 2) * p2 +
+ Math.pow(t, 3) * p3
+ );
+};
+
+const solveQuadratic = (
+ p0: number,
+ p1: number,
+ p2: number,
+ p3: number,
+): MaybeQuadraticSolution => {
+ const i = p1 - p0;
+ const j = p2 - p1;
+ const k = p3 - p2;
+
+ const a = 3 * i - 6 * j + 3 * k;
+ const b = 6 * j - 6 * i;
+ const c = 3 * i;
+
+ const sqrtPart = b * b - 4 * a * c;
+ const hasSolution = sqrtPart >= 0;
+
+ if (!hasSolution) {
+ return false;
+ }
+
+ let s1 = null;
+ let s2 = null;
+
+ let t1 = Infinity;
+ let t2 = Infinity;
+
+ if (a === 0) {
+ t1 = t2 = -c / b;
+ } else {
+ t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
+ t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
+ }
+
+ if (t1 >= 0 && t1 <= 1) {
+ s1 = getBezierValueForT(t1, p0, p1, p2, p3);
+ }
+
+ if (t2 >= 0 && t2 <= 1) {
+ s2 = getBezierValueForT(t2, p0, p1, p2, p3);
+ }
+
+ return [s1, s2];
+};
+
+const getCubicBezierCurveBound = (
+ p0: GlobalPoint,
+ p1: GlobalPoint,
+ p2: GlobalPoint,
+ p3: GlobalPoint,
+): Bounds => {
+ const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
+ const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
+
+ let minX = Math.min(p0[0], p3[0]);
+ let maxX = Math.max(p0[0], p3[0]);
+
+ if (solX) {
+ const xs = solX.filter((x) => x !== null) as number[];
+ minX = Math.min(minX, ...xs);
+ maxX = Math.max(maxX, ...xs);
+ }
+
+ let minY = Math.min(p0[1], p3[1]);
+ let maxY = Math.max(p0[1], p3[1]);
+ if (solY) {
+ const ys = solY.filter((y) => y !== null) as number[];
+ minY = Math.min(minY, ...ys);
+ maxY = Math.max(maxY, ...ys);
+ }
+ return [minX, minY, maxX, maxY];
+};
+
+export const getMinMaxXYFromCurvePathOps = (
+ ops: Op[],
+ transformXY?: (p: GlobalPoint) => GlobalPoint,
+): Bounds => {
+ let currentP: GlobalPoint = pointFrom(0, 0);
+
+ const { minX, minY, maxX, maxY } = ops.reduce(
+ (limits, { op, data }) => {
+ // There are only four operation types:
+ // move, bcurveTo, lineTo, and curveTo
+ if (op === "move") {
+ // change starting point
+ const p: GlobalPoint | undefined = pointFromArray(data);
+ invariant(p != null, "Op data is not a point");
+ currentP = p;
+ // move operation does not draw anything; so, it always
+ // returns false
+ } else if (op === "bcurveTo") {
+ const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
+ const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
+ const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
+
+ const p1 = transformXY ? transformXY(_p1) : _p1;
+ const p2 = transformXY ? transformXY(_p2) : _p2;
+ const p3 = transformXY ? transformXY(_p3) : _p3;
+
+ const p0 = transformXY ? transformXY(currentP) : currentP;
+ currentP = _p3;
+
+ const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
+ p0,
+ p1,
+ p2,
+ p3,
+ );
+
+ limits.minX = Math.min(limits.minX, minX);
+ limits.minY = Math.min(limits.minY, minY);
+
+ limits.maxX = Math.max(limits.maxX, maxX);
+ limits.maxY = Math.max(limits.maxY, maxY);
+ } else if (op === "lineTo") {
+ // TODO: Implement this
+ } else if (op === "qcurveTo") {
+ // TODO: Implement this
+ }
+ return limits;
+ },
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ );
+ return [minX, minY, maxX, maxY];
+};
+
+export const getBoundsFromPoints = (
+ points: ExcalidrawFreeDrawElement["points"],
+): Bounds => {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const [x, y] of points) {
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+
+ return [minX, minY, maxX, maxY];
+};
+
+const getFreeDrawElementAbsoluteCoords = (
+ element: ExcalidrawFreeDrawElement,
+): [number, number, number, number, number, number] => {
+ const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
+ const x1 = minX + element.x;
+ const y1 = minY + element.y;
+ const x2 = maxX + element.x;
+ const y2 = maxY + element.y;
+ return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
+};
+
+/** @returns number in pixels */
+export const getArrowheadSize = (arrowhead: Arrowhead): number => {
+ switch (arrowhead) {
+ case "arrow":
+ return 25;
+ case "diamond":
+ case "diamond_outline":
+ return 12;
+ case "crowfoot_many":
+ case "crowfoot_one":
+ case "crowfoot_one_or_many":
+ return 20;
+ default:
+ return 15;
+ }
+};
+
+/** @returns number in degrees */
+export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
+ switch (arrowhead) {
+ case "bar":
+ return 90 as Degrees;
+ case "arrow":
+ return 20 as Degrees;
+ default:
+ return 25 as Degrees;
+ }
+};
+
+export const getArrowheadPoints = (
+ element: ExcalidrawLinearElement,
+ shape: Drawable[],
+ position: "start" | "end",
+ arrowhead: Arrowhead,
+) => {
+ if (shape.length < 1) {
+ return null;
+ }
+
+ const ops = getCurvePathOps(shape[0]);
+ if (ops.length < 1) {
+ return null;
+ }
+
+ // The index of the bCurve operation to examine.
+ const index = position === "start" ? 1 : ops.length - 1;
+
+ const data = ops[index].data;
+
+ invariant(data.length === 6, "Op data length is not 6");
+
+ const p3 = pointFrom(data[4], data[5]);
+ const p2 = pointFrom(data[2], data[3]);
+ const p1 = pointFrom(data[0], data[1]);
+
+ // We need to find p0 of the bezier curve.
+ // It is typically the last point of the previous
+ // curve; it can also be the position of moveTo operation.
+ const prevOp = ops[index - 1];
+ let p0 = pointFrom(0, 0);
+ if (prevOp.op === "move") {
+ const p = pointFromArray(prevOp.data);
+ invariant(p != null, "Op data is not a point");
+ p0 = p;
+ } else if (prevOp.op === "bcurveTo") {
+ p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
+ }
+
+ // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+ const equation = (t: number, idx: number) =>
+ Math.pow(1 - t, 3) * p3[idx] +
+ 3 * t * Math.pow(1 - t, 2) * p2[idx] +
+ 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+ p0[idx] * Math.pow(t, 3);
+
+ // Ee know the last point of the arrow (or the first, if start arrowhead).
+ const [x2, y2] = position === "start" ? p0 : p3;
+
+ // By using cubic bezier equation (B(t)) and the given parameters,
+ // we calculate a point that is closer to the last point.
+ // The value 0.3 is chosen arbitrarily and it works best for all
+ // the tested cases.
+ const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
+
+ // Find the normalized direction vector based on the
+ // previously calculated points.
+ const distance = Math.hypot(x2 - x1, y2 - y1);
+ const nx = (x2 - x1) / distance;
+ const ny = (y2 - y1) / distance;
+
+ const size = getArrowheadSize(arrowhead);
+
+ let length = 0;
+
+ {
+ // Length for -> arrows is based on the length of the last section
+ const [cx, cy] =
+ position === "end"
+ ? element.points[element.points.length - 1]
+ : element.points[0];
+ const [px, py] =
+ element.points.length > 1
+ ? position === "end"
+ ? element.points[element.points.length - 2]
+ : element.points[1]
+ : [0, 0];
+
+ length = Math.hypot(cx - px, cy - py);
+ }
+
+ // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
+ // This value is selected by minimizing a minimum size with the last segment of the arrowhead
+ const lengthMultiplier =
+ arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
+ const minSize = Math.min(size, length * lengthMultiplier);
+ const xs = x2 - nx * minSize;
+ const ys = y2 - ny * minSize;
+
+ if (
+ arrowhead === "dot" ||
+ arrowhead === "circle" ||
+ arrowhead === "circle_outline"
+ ) {
+ const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
+ return [x2, y2, diameter];
+ }
+
+ const angle = getArrowheadAngle(arrowhead);
+
+ if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
+ // swap (xs, ys) with (x2, y2)
+ const [x3, y3] = pointRotateRads(
+ pointFrom(x2, y2),
+ pointFrom(xs, ys),
+ degreesToRadians(-angle as Degrees),
+ );
+ const [x4, y4] = pointRotateRads(
+ pointFrom(x2, y2),
+ pointFrom(xs, ys),
+ degreesToRadians(angle),
+ );
+ return [xs, ys, x3, y3, x4, y4];
+ }
+
+ // Return points
+ const [x3, y3] = pointRotateRads(
+ pointFrom(xs, ys),
+ pointFrom(x2, y2),
+ ((-angle * Math.PI) / 180) as Radians,
+ );
+ const [x4, y4] = pointRotateRads(
+ pointFrom(xs, ys),
+ pointFrom(x2, y2),
+ degreesToRadians(angle),
+ );
+
+ if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
+ // point opposite to the arrowhead point
+ let ox;
+ let oy;
+
+ if (position === "start") {
+ const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
+
+ [ox, oy] = pointRotateRads(
+ pointFrom(x2 + minSize * 2, y2),
+ pointFrom(x2, y2),
+ Math.atan2(py - y2, px - x2) as Radians,
+ );
+ } else {
+ const [px, py] =
+ element.points.length > 1
+ ? element.points[element.points.length - 2]
+ : [0, 0];
+
+ [ox, oy] = pointRotateRads(
+ pointFrom(x2 - minSize * 2, y2),
+ pointFrom(x2, y2),
+ Math.atan2(y2 - py, x2 - px) as Radians,
+ );
+ }
+
+ return [x2, y2, x3, y3, ox, oy, x4, y4];
+ }
+
+ return [x2, y2, x3, y3, x4, y4];
+};
+
+const generateLinearElementShape = (
+ element: ExcalidrawLinearElement,
+): Drawable => {
+ const generator = rough.generator();
+ const options = generateRoughOptions(element);
+
+ const method = (() => {
+ if (element.roundness) {
+ return "curve";
+ }
+ if (options.fill) {
+ return "polygon";
+ }
+ return "linearPath";
+ })();
+
+ return generator[method](
+ element.points as Mutable<LocalPoint>[] as RoughPoint[],
+ options,
+ );
+};
+
+const getLinearElementRotatedBounds = (
+ element: ExcalidrawLinearElement,
+ cx: number,
+ cy: number,
+ elementsMap: ElementsMap,
+): Bounds => {
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+
+ if (element.points.length < 2) {
+ const [pointX, pointY] = element.points[0];
+ const [x, y] = pointRotateRads(
+ pointFrom(element.x + pointX, element.y + pointY),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+
+ let coords: Bounds = [x, y, x, y];
+ if (boundTextElement) {
+ const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ elementsMap,
+ [x, y, x, y],
+ boundTextElement,
+ );
+ coords = [
+ coordsWithBoundText[0],
+ coordsWithBoundText[1],
+ coordsWithBoundText[2],
+ coordsWithBoundText[3],
+ ];
+ }
+ return coords;
+ }
+
+ // first element is always the curve
+ const cachedShape = ShapeCache.get(element)?.[0];
+ const shape = cachedShape ?? generateLinearElementShape(element);
+ const ops = getCurvePathOps(shape);
+ const transformXY = ([x, y]: GlobalPoint) =>
+ pointRotateRads<GlobalPoint>(
+ pointFrom(element.x + x, element.y + y),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
+ let coords: Bounds = [res[0], res[1], res[2], res[3]];
+ if (boundTextElement) {
+ const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ elementsMap,
+ coords,
+ boundTextElement,
+ );
+ coords = [
+ coordsWithBoundText[0],
+ coordsWithBoundText[1],
+ coordsWithBoundText[2],
+ coordsWithBoundText[3],
+ ];
+ }
+ return coords;
+};
+
+export const getElementBounds = (
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+): Bounds => {
+ return ElementBounds.getBounds(element, elementsMap);
+};
+
+export const getCommonBounds = (
+ elements: readonly ExcalidrawElement[],
+ elementsMap?: ElementsMap,
+): Bounds => {
+ if (!elements.length) {
+ return [0, 0, 0, 0];
+ }
+
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+
+ const _elementsMap = elementsMap || arrayToMap(elements);
+
+ elements.forEach((element) => {
+ const [x1, y1, x2, y2] = getElementBounds(element, _elementsMap);
+ minX = Math.min(minX, x1);
+ minY = Math.min(minY, y1);
+ maxX = Math.max(maxX, x2);
+ maxY = Math.max(maxY, y2);
+ });
+
+ return [minX, minY, maxX, maxY];
+};
+
+export const getDraggedElementsBounds = (
+ elements: ExcalidrawElement[],
+ dragOffset: { x: number; y: number },
+) => {
+ const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+ return [
+ minX + dragOffset.x,
+ minY + dragOffset.y,
+ maxX + dragOffset.x,
+ maxY + dragOffset.y,
+ ];
+};
+
+export const getResizedElementAbsoluteCoords = (
+ element: ExcalidrawElement,
+ nextWidth: number,
+ nextHeight: number,
+ normalizePoints: boolean,
+): Bounds => {
+ if (!(isLinearElement(element) || isFreeDrawElement(element))) {
+ return [
+ element.x,
+ element.y,
+ element.x + nextWidth,
+ element.y + nextHeight,
+ ];
+ }
+
+ const points = rescalePoints(
+ 0,
+ nextWidth,
+ rescalePoints(1, nextHeight, element.points, normalizePoints),
+ normalizePoints,
+ );
+
+ let bounds: Bounds;
+
+ if (isFreeDrawElement(element)) {
+ // Free Draw
+ bounds = getBoundsFromPoints(points);
+ } else {
+ // Line
+ const gen = rough.generator();
+ const curve = !element.roundness
+ ? gen.linearPath(
+ points as [number, number][],
+ generateRoughOptions(element),
+ )
+ : gen.curve(points as [number, number][], generateRoughOptions(element));
+
+ const ops = getCurvePathOps(curve);
+ bounds = getMinMaxXYFromCurvePathOps(ops);
+ }
+
+ const [minX, minY, maxX, maxY] = bounds;
+ return [
+ minX + element.x,
+ minY + element.y,
+ maxX + element.x,
+ maxY + element.y,
+ ];
+};
+
+export const getElementPointsCoords = (
+ element: ExcalidrawLinearElement,
+ points: readonly (readonly [number, number])[],
+): Bounds => {
+ // This might be computationally heavey
+ const gen = rough.generator();
+ const curve =
+ element.roundness == null
+ ? gen.linearPath(
+ points as [number, number][],
+ generateRoughOptions(element),
+ )
+ : gen.curve(points as [number, number][], generateRoughOptions(element));
+ const ops = getCurvePathOps(curve);
+ const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+ return [
+ minX + element.x,
+ minY + element.y,
+ maxX + element.x,
+ maxY + element.y,
+ ];
+};
+
+export const getClosestElementBounds = (
+ elements: readonly ExcalidrawElement[],
+ from: { x: number; y: number },
+): Bounds => {
+ if (!elements.length) {
+ return [0, 0, 0, 0];
+ }
+
+ let minDistance = Infinity;
+ let closestElement = elements[0];
+ const elementsMap = arrayToMap(elements);
+ elements.forEach((element) => {
+ const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
+ const distance = pointDistance(
+ pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
+ pointFrom(from.x, from.y),
+ );
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestElement = element;
+ }
+ });
+
+ return getElementBounds(closestElement, elementsMap);
+};
+
+export interface BoundingBox {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+ midX: number;
+ midY: number;
+ width: number;
+ height: number;
+}
+
+export const getCommonBoundingBox = (
+ elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
+): BoundingBox => {
+ const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+ return {
+ minX,
+ minY,
+ maxX,
+ maxY,
+ width: maxX - minX,
+ height: maxY - minY,
+ midX: (minX + maxX) / 2,
+ midY: (minY + maxY) / 2,
+ };
+};
+
+/**
+ * returns scene coords of user's editor viewport (visible canvas area) bounds
+ */
+export const getVisibleSceneBounds = ({
+ scrollX,
+ scrollY,
+ width,
+ height,
+ zoom,
+}: AppState): SceneBounds => {
+ return [
+ -scrollX,
+ -scrollY,
+ -scrollX + width / zoom.value,
+ -scrollY + height / zoom.value,
+ ];
+};
+
+export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
+ pointFrom(
+ bounds[0] + (bounds[2] - bounds[0]) / 2,
+ bounds[1] + (bounds[3] - bounds[1]) / 2,
+ );
+
+export const doBoundsIntersect = (
+ bounds1: Bounds | null,
+ bounds2: Bounds | null,
+): boolean => {
+ if (bounds1 == null || bounds2 == null) {
+ return false;
+ }
+
+ const [minX1, minY1, maxX1, maxY1] = bounds1;
+ const [minX2, minY2, maxX2, maxY2] = bounds2;
+
+ return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
+};
diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts
new file mode 100644
index 0000000..b0a2ce9
--- /dev/null
+++ b/packages/excalidraw/element/collision.ts
@@ -0,0 +1,312 @@
+import type {
+ ElementsMap,
+ ExcalidrawDiamondElement,
+ ExcalidrawElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawRectangleElement,
+ ExcalidrawRectanguloidElement,
+} from "./types";
+import { getElementBounds } from "./bounds";
+import type { FrameNameBounds } from "../types";
+import type { GeometricShape } from "@excalidraw/utils/geometry/shape";
+import { getPolygonShape } from "@excalidraw/utils/geometry/shape";
+import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
+import { isTransparent } from "../utils";
+import {
+ hasBoundTextElement,
+ isIframeLikeElement,
+ isImageElement,
+ isTextElement,
+} from "./typeChecks";
+import { getBoundTextShape, isPathALoop } from "../shapes";
+import type {
+ GlobalPoint,
+ LineSegment,
+ LocalPoint,
+ Polygon,
+ Radians,
+} from "@excalidraw/math";
+import {
+ curveIntersectLineSegment,
+ isPointWithinBounds,
+ line,
+ lineSegment,
+ lineSegmentIntersectionPoints,
+ pointFrom,
+ pointRotateRads,
+ pointsEqual,
+} from "@excalidraw/math";
+import {
+ ellipse,
+ ellipseLineIntersectionPoints,
+} from "@excalidraw/math/ellipse";
+import {
+ deconstructDiamondElement,
+ deconstructRectanguloidElement,
+} from "./utils";
+
+export const shouldTestInside = (element: ExcalidrawElement) => {
+ if (element.type === "arrow") {
+ return false;
+ }
+
+ const isDraggableFromInside =
+ !isTransparent(element.backgroundColor) ||
+ hasBoundTextElement(element) ||
+ isIframeLikeElement(element) ||
+ isTextElement(element);
+
+ if (element.type === "line") {
+ return isDraggableFromInside && isPathALoop(element.points);
+ }
+
+ if (element.type === "freedraw") {
+ return isDraggableFromInside && isPathALoop(element.points);
+ }
+
+ return isDraggableFromInside || isImageElement(element);
+};
+
+export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
+ x: number;
+ y: number;
+ element: ExcalidrawElement;
+ shape: GeometricShape<Point>;
+ threshold?: number;
+ frameNameBound?: FrameNameBounds | null;
+};
+
+export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
+ x,
+ y,
+ element,
+ shape,
+ threshold = 10,
+ frameNameBound = null,
+}: HitTestArgs<Point>) => {
+ let hit = shouldTestInside(element)
+ ? // Since `inShape` tests STRICTLY againt the insides of a shape
+ // we would need `onShape` as well to include the "borders"
+ isPointInShape(pointFrom(x, y), shape) ||
+ isPointOnShape(pointFrom(x, y), shape, threshold)
+ : isPointOnShape(pointFrom(x, y), shape, threshold);
+
+ // hit test against a frame's name
+ if (!hit && frameNameBound) {
+ hit = isPointInShape(pointFrom(x, y), {
+ type: "polygon",
+ data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
+ .data as Polygon<Point>,
+ });
+ }
+
+ return hit;
+};
+
+export const hitElementBoundingBox = (
+ x: number,
+ y: number,
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ tolerance = 0,
+) => {
+ let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
+ x1 -= tolerance;
+ y1 -= tolerance;
+ x2 += tolerance;
+ y2 += tolerance;
+ return isPointWithinBounds(
+ pointFrom(x1, y1),
+ pointFrom(x, y),
+ pointFrom(x2, y2),
+ );
+};
+
+export const hitElementBoundingBoxOnly = <
+ Point extends GlobalPoint | LocalPoint,
+>(
+ hitArgs: HitTestArgs<Point>,
+ elementsMap: ElementsMap,
+) => {
+ return (
+ !hitElementItself(hitArgs) &&
+ // bound text is considered part of the element (even if it's outside the bounding box)
+ !hitElementBoundText(
+ hitArgs.x,
+ hitArgs.y,
+ getBoundTextShape(hitArgs.element, elementsMap),
+ ) &&
+ hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
+ );
+};
+
+export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
+ x: number,
+ y: number,
+ textShape: GeometricShape<Point> | null,
+): boolean => {
+ return !!textShape && isPointInShape(pointFrom(x, y), textShape);
+};
+
+/**
+ * Intersect a line with an element for binding test
+ *
+ * @param element
+ * @param line
+ * @param offset
+ * @returns
+ */
+export const intersectElementWithLineSegment = (
+ element: ExcalidrawElement,
+ line: LineSegment<GlobalPoint>,
+ offset: number = 0,
+): GlobalPoint[] => {
+ switch (element.type) {
+ case "rectangle":
+ case "image":
+ case "text":
+ case "iframe":
+ case "embeddable":
+ case "frame":
+ case "magicframe":
+ return intersectRectanguloidWithLineSegment(element, line, offset);
+ case "diamond":
+ return intersectDiamondWithLineSegment(element, line, offset);
+ case "ellipse":
+ return intersectEllipseWithLineSegment(element, line, offset);
+ default:
+ throw new Error(`Unimplemented element type '${element.type}'`);
+ }
+};
+
+const intersectRectanguloidWithLineSegment = (
+ element: ExcalidrawRectanguloidElement,
+ l: LineSegment<GlobalPoint>,
+ offset: number = 0,
+): GlobalPoint[] => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+ // To emulate a rotated rectangle we rotate the point in the inverse angle
+ // instead. It's all the same distance-wise.
+ const rotatedA = pointRotateRads<GlobalPoint>(
+ l[0],
+ center,
+ -element.angle as Radians,
+ );
+ const rotatedB = pointRotateRads<GlobalPoint>(
+ l[1],
+ center,
+ -element.angle as Radians,
+ );
+
+ // Get the element's building components we can test against
+ const [sides, corners] = deconstructRectanguloidElement(element, offset);
+
+ return (
+ [
+ // Test intersection against the sides, keep only the valid
+ // intersection points and rotate them back to scene space
+ ...sides
+ .map((s) =>
+ lineSegmentIntersectionPoints(
+ lineSegment<GlobalPoint>(rotatedA, rotatedB),
+ s,
+ ),
+ )
+ .filter((x) => x != null)
+ .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)),
+ // Test intersection against the corners which are cubic bezier curves,
+ // keep only the valid intersection points and rotate them back to scene
+ // space
+ ...corners
+ .flatMap((t) =>
+ curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
+ )
+ .filter((i) => i != null)
+ .map((j) => pointRotateRads(j, center, element.angle)),
+ ]
+ // Remove duplicates
+ .filter(
+ (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
+ )
+ );
+};
+
+/**
+ *
+ * @param element
+ * @param a
+ * @param b
+ * @returns
+ */
+const intersectDiamondWithLineSegment = (
+ element: ExcalidrawDiamondElement,
+ l: LineSegment<GlobalPoint>,
+ offset: number = 0,
+): GlobalPoint[] => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ // Rotate the point to the inverse direction to simulate the rotated diamond
+ // points. It's all the same distance-wise.
+ const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
+ const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
+
+ const [sides, curves] = deconstructDiamondElement(element, offset);
+
+ return (
+ [
+ ...sides
+ .map((s) =>
+ lineSegmentIntersectionPoints(
+ lineSegment<GlobalPoint>(rotatedA, rotatedB),
+ s,
+ ),
+ )
+ .filter((p): p is GlobalPoint => p != null)
+ // Rotate back intersection points
+ .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)),
+ ...curves
+ .flatMap((p) =>
+ curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
+ )
+ .filter((p) => p != null)
+ // Rotate back intersection points
+ .map((p) => pointRotateRads(p, center, element.angle)),
+ ]
+ // Remove duplicates
+ .filter(
+ (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
+ )
+ );
+};
+
+/**
+ *
+ * @param element
+ * @param a
+ * @param b
+ * @returns
+ */
+const intersectEllipseWithLineSegment = (
+ element: ExcalidrawEllipseElement,
+ l: LineSegment<GlobalPoint>,
+ offset: number = 0,
+): GlobalPoint[] => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
+ const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
+
+ return ellipseLineIntersectionPoints(
+ ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
+ line(rotatedA, rotatedB),
+ ).map((p) => pointRotateRads(p, center, element.angle));
+};
diff --git a/packages/excalidraw/element/containerCache.ts b/packages/excalidraw/element/containerCache.ts
new file mode 100644
index 0000000..432cd4e
--- /dev/null
+++ b/packages/excalidraw/element/containerCache.ts
@@ -0,0 +1,33 @@
+import type { ExcalidrawTextContainer } from "./types";
+
+export const originalContainerCache: {
+ [id: ExcalidrawTextContainer["id"]]:
+ | {
+ height: ExcalidrawTextContainer["height"];
+ }
+ | undefined;
+} = {};
+
+export const updateOriginalContainerCache = (
+ id: ExcalidrawTextContainer["id"],
+ height: ExcalidrawTextContainer["height"],
+) => {
+ const data =
+ originalContainerCache[id] || (originalContainerCache[id] = { height });
+ data.height = height;
+ return data;
+};
+
+export const resetOriginalContainerCache = (
+ id: ExcalidrawTextContainer["id"],
+) => {
+ if (originalContainerCache[id]) {
+ delete originalContainerCache[id];
+ }
+};
+
+export const getOriginalContainerHeightFromCache = (
+ id: ExcalidrawTextContainer["id"],
+) => {
+ return originalContainerCache[id]?.height ?? null;
+};
diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts
new file mode 100644
index 0000000..b109802
--- /dev/null
+++ b/packages/excalidraw/element/cropElement.ts
@@ -0,0 +1,625 @@
+import { type Point } from "points-on-curve";
+import {
+ type Radians,
+ pointFrom,
+ pointCenter,
+ pointRotateRads,
+ vectorFromPoint,
+ vectorNormalize,
+ vectorSubtract,
+ vectorAdd,
+ vectorScale,
+ pointFromVector,
+ clamp,
+ isCloseTo,
+} from "@excalidraw/math";
+import type { TransformHandleType } from "./transformHandles";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ ImageCrop,
+ NonDeleted,
+} from "./types";
+import {
+ getElementAbsoluteCoords,
+ getResizedElementAbsoluteCoords,
+} from "./bounds";
+
+export const MINIMAL_CROP_SIZE = 10;
+
+export const cropElement = (
+ element: ExcalidrawImageElement,
+ transformHandle: TransformHandleType,
+ naturalWidth: number,
+ naturalHeight: number,
+ pointerX: number,
+ pointerY: number,
+ widthAspectRatio?: number,
+) => {
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+
+ const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
+ const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
+
+ const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
+ const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
+
+ /**
+ * uncropped width
+ * *––––––––––––––––––––––––*
+ * | (x,y) (natural) |
+ * | *–––––––* |
+ * | |///////| height | uncropped height
+ * | *–––––––* |
+ * | width (natural) |
+ * *––––––––––––––––––––––––*
+ */
+
+ const rotatedPointer = pointRotateRads(
+ pointFrom(pointerX, pointerY),
+ pointFrom(element.x + element.width / 2, element.y + element.height / 2),
+ -element.angle as Radians,
+ );
+
+ pointerX = rotatedPointer[0];
+ pointerY = rotatedPointer[1];
+
+ let nextWidth = element.width;
+ let nextHeight = element.height;
+
+ let crop: ImageCrop | null = element.crop ?? {
+ x: 0,
+ y: 0,
+ width: naturalWidth,
+ height: naturalHeight,
+ naturalWidth,
+ naturalHeight,
+ };
+
+ const previousCropHeight = crop.height;
+ const previousCropWidth = crop.width;
+
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+
+ let changeInHeight = pointerY - element.y;
+ let changeInWidth = pointerX - element.x;
+
+ if (transformHandle.includes("n")) {
+ nextHeight = clamp(
+ element.height - changeInHeight,
+ MINIMAL_CROP_SIZE,
+ isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
+ );
+ }
+
+ if (transformHandle.includes("s")) {
+ changeInHeight = pointerY - element.y - element.height;
+ nextHeight = clamp(
+ element.height + changeInHeight,
+ MINIMAL_CROP_SIZE,
+ isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
+ );
+ }
+
+ if (transformHandle.includes("e")) {
+ changeInWidth = pointerX - element.x - element.width;
+
+ nextWidth = clamp(
+ element.width + changeInWidth,
+ MINIMAL_CROP_SIZE,
+ isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
+ );
+ }
+
+ if (transformHandle.includes("w")) {
+ nextWidth = clamp(
+ element.width - changeInWidth,
+ MINIMAL_CROP_SIZE,
+ isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
+ );
+ }
+
+ const updateCropWidthAndHeight = (crop: ImageCrop) => {
+ crop.height = nextHeight * naturalHeightToUncropped;
+ crop.width = nextWidth * naturalWidthToUncropped;
+ };
+
+ updateCropWidthAndHeight(crop);
+
+ const adjustFlipForHandle = (
+ handle: TransformHandleType,
+ crop: ImageCrop,
+ ) => {
+ updateCropWidthAndHeight(crop);
+ if (handle.includes("n")) {
+ if (!isFlippedByY) {
+ crop.y += previousCropHeight - crop.height;
+ }
+ }
+ if (handle.includes("s")) {
+ if (isFlippedByY) {
+ crop.y += previousCropHeight - crop.height;
+ }
+ }
+ if (handle.includes("e")) {
+ if (isFlippedByX) {
+ crop.x += previousCropWidth - crop.width;
+ }
+ }
+ if (handle.includes("w")) {
+ if (!isFlippedByX) {
+ crop.x += previousCropWidth - crop.width;
+ }
+ }
+ };
+
+ switch (transformHandle) {
+ case "n": {
+ if (widthAspectRatio) {
+ const distanceToLeft = croppedLeft + element.width / 2;
+ const distanceToRight =
+ uncroppedWidth - croppedLeft - element.width / 2;
+
+ const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.x += (previousCropWidth - crop.width) / 2;
+ }
+
+ break;
+ }
+ case "s": {
+ if (widthAspectRatio) {
+ const distanceToLeft = croppedLeft + element.width / 2;
+ const distanceToRight =
+ uncroppedWidth - croppedLeft - element.width / 2;
+
+ const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.x += (previousCropWidth - crop.width) / 2;
+ }
+
+ break;
+ }
+ case "w": {
+ if (widthAspectRatio) {
+ const distanceToTop = croppedTop + element.height / 2;
+ const distanceToBottom =
+ uncroppedHeight - croppedTop - element.height / 2;
+
+ const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.y += (previousCropHeight - crop.height) / 2;
+ }
+
+ break;
+ }
+ case "e": {
+ if (widthAspectRatio) {
+ const distanceToTop = croppedTop + element.height / 2;
+ const distanceToBottom =
+ uncroppedHeight - croppedTop - element.height / 2;
+
+ const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+
+ if (widthAspectRatio) {
+ crop.y += (previousCropHeight - crop.height) / 2;
+ }
+
+ break;
+ }
+ case "ne": {
+ if (widthAspectRatio) {
+ if (changeInWidth > -changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? uncroppedHeight - croppedTop
+ : croppedTop + element.height;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? croppedLeft + element.width
+ : uncroppedWidth - croppedLeft;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "nw": {
+ if (widthAspectRatio) {
+ if (changeInWidth < changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? uncroppedHeight - croppedTop
+ : croppedTop + element.height;
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? uncroppedWidth - croppedLeft
+ : croppedLeft + element.width;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "se": {
+ if (widthAspectRatio) {
+ if (changeInWidth > changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? croppedTop + element.height
+ : uncroppedHeight - croppedTop;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? croppedLeft + element.width
+ : uncroppedWidth - croppedLeft;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ case "sw": {
+ if (widthAspectRatio) {
+ if (-changeInWidth > changeInHeight) {
+ const MAX_HEIGHT = isFlippedByY
+ ? croppedTop + element.height
+ : uncroppedHeight - croppedTop;
+
+ nextHeight = clamp(
+ nextWidth / widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_HEIGHT,
+ );
+ nextWidth = nextHeight * widthAspectRatio;
+ } else {
+ const MAX_WIDTH = isFlippedByX
+ ? uncroppedWidth - croppedLeft
+ : croppedLeft + element.width;
+
+ nextWidth = clamp(
+ nextHeight * widthAspectRatio,
+ MINIMAL_CROP_SIZE,
+ MAX_WIDTH,
+ );
+ nextHeight = nextWidth / widthAspectRatio;
+ }
+ }
+
+ adjustFlipForHandle(transformHandle, crop);
+ break;
+ }
+ default:
+ break;
+ }
+
+ const newOrigin = recomputeOrigin(
+ element,
+ transformHandle,
+ nextWidth,
+ nextHeight,
+ !!widthAspectRatio,
+ );
+
+ // reset crop to null if we're back to orig size
+ if (
+ isCloseTo(crop.width, crop.naturalWidth) &&
+ isCloseTo(crop.height, crop.naturalHeight)
+ ) {
+ crop = null;
+ }
+
+ return {
+ x: newOrigin[0],
+ y: newOrigin[1],
+ width: nextWidth,
+ height: nextHeight,
+ crop,
+ };
+};
+
+const recomputeOrigin = (
+ stateAtCropStart: NonDeleted<ExcalidrawElement>,
+ transformHandle: TransformHandleType,
+ width: number,
+ height: number,
+ shouldMaintainAspectRatio?: boolean,
+) => {
+ const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+ stateAtCropStart,
+ stateAtCropStart.width,
+ stateAtCropStart.height,
+ true,
+ );
+ const startTopLeft = pointFrom(x1, y1);
+ const startBottomRight = pointFrom(x2, y2);
+ const startCenter: any = pointCenter(startTopLeft, startBottomRight);
+
+ const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
+ getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
+ const newBoundsWidth = newBoundsX2 - newBoundsX1;
+ const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
+ // Calculate new topLeft based on fixed corner during resize
+ let newTopLeft = [...startTopLeft] as [number, number];
+
+ if (["n", "w", "nw"].includes(transformHandle)) {
+ newTopLeft = [
+ startBottomRight[0] - Math.abs(newBoundsWidth),
+ startBottomRight[1] - Math.abs(newBoundsHeight),
+ ];
+ }
+ if (transformHandle === "ne") {
+ const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+ newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
+ }
+ if (transformHandle === "sw") {
+ const topRight = [startBottomRight[0], startTopLeft[1]];
+ newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
+ }
+
+ if (shouldMaintainAspectRatio) {
+ if (["s", "n"].includes(transformHandle)) {
+ newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
+ }
+ if (["e", "w"].includes(transformHandle)) {
+ newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
+ }
+ }
+
+ // adjust topLeft to new rotation point
+ const angle = stateAtCropStart.angle;
+ const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
+ const newCenter: Point = [
+ newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+ newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
+ ];
+ const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+ newTopLeft = pointRotateRads(
+ rotatedTopLeft,
+ rotatedNewCenter,
+ -angle as Radians,
+ );
+
+ const newOrigin = [...newTopLeft];
+ newOrigin[0] += stateAtCropStart.x - newBoundsX1;
+ newOrigin[1] += stateAtCropStart.y - newBoundsY1;
+
+ return newOrigin;
+};
+
+// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
+export const getUncroppedImageElement = (
+ element: ExcalidrawImageElement,
+ elementsMap: ElementsMap,
+) => {
+ if (element.crop) {
+ const { width, height } = getUncroppedWidthAndHeight(element);
+
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+
+ const topLeftVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
+ );
+ const topRightVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
+ );
+ const topEdgeNormalized = vectorNormalize(
+ vectorSubtract(topRightVector, topLeftVector),
+ );
+ const bottomLeftVector = vectorFromPoint(
+ pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
+ );
+ const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
+ const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
+
+ const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
+
+ const rotatedTopLeft = vectorAdd(
+ vectorAdd(
+ topLeftVector,
+ vectorScale(
+ topEdgeNormalized,
+ (-cropX * width) / element.crop.naturalWidth,
+ ),
+ ),
+ vectorScale(
+ leftEdgeNormalized,
+ (-cropY * height) / element.crop.naturalHeight,
+ ),
+ );
+
+ const center = pointFromVector(
+ vectorAdd(
+ vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
+ vectorScale(leftEdgeNormalized, height / 2),
+ ),
+ );
+
+ const unrotatedTopLeft = pointRotateRads(
+ pointFromVector(rotatedTopLeft),
+ center,
+ -element.angle as Radians,
+ );
+
+ const uncroppedElement: ExcalidrawImageElement = {
+ ...element,
+ x: unrotatedTopLeft[0],
+ y: unrotatedTopLeft[1],
+ width,
+ height,
+ crop: null,
+ };
+
+ return uncroppedElement;
+ }
+
+ return element;
+};
+
+export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
+ if (element.crop) {
+ const width =
+ element.width / (element.crop.width / element.crop.naturalWidth);
+ const height =
+ element.height / (element.crop.height / element.crop.naturalHeight);
+
+ return {
+ width,
+ height,
+ };
+ }
+
+ return {
+ width: element.width,
+ height: element.height,
+ };
+};
+
+const adjustCropPosition = (
+ crop: ImageCrop,
+ scale: ExcalidrawImageElement["scale"],
+) => {
+ let cropX = crop.x;
+ let cropY = crop.y;
+
+ const flipX = scale[0] === -1;
+ const flipY = scale[1] === -1;
+
+ if (flipX) {
+ cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
+ }
+
+ if (flipY) {
+ cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
+ }
+
+ return {
+ cropX,
+ cropY,
+ };
+};
+
+export const getFlipAdjustedCropPosition = (
+ element: ExcalidrawImageElement,
+ natural = false,
+) => {
+ const crop = element.crop;
+ if (!crop) {
+ return null;
+ }
+
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+
+ let cropX = crop.x;
+ let cropY = crop.y;
+
+ if (isFlippedByX) {
+ cropX = crop.naturalWidth - crop.width - crop.x;
+ }
+
+ if (isFlippedByY) {
+ cropY = crop.naturalHeight - crop.height - crop.y;
+ }
+
+ if (natural) {
+ return {
+ x: cropX,
+ y: cropY,
+ };
+ }
+
+ const { width, height } = getUncroppedWidthAndHeight(element);
+
+ return {
+ x: cropX / (crop.naturalWidth / width),
+ y: cropY / (crop.naturalHeight / height),
+ };
+};
diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts
new file mode 100644
index 0000000..0010ab9
--- /dev/null
+++ b/packages/excalidraw/element/distance.ts
@@ -0,0 +1,123 @@
+import type { GlobalPoint, Radians } from "@excalidraw/math";
+import {
+ curvePointDistance,
+ distanceToLineSegment,
+ pointFrom,
+ pointRotateRads,
+} from "@excalidraw/math";
+import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
+import type {
+ ExcalidrawBindableElement,
+ ExcalidrawDiamondElement,
+ ExcalidrawEllipseElement,
+ ExcalidrawRectanguloidElement,
+} from "./types";
+import {
+ deconstructDiamondElement,
+ deconstructRectanguloidElement,
+} from "./utils";
+
+export const distanceToBindableElement = (
+ element: ExcalidrawBindableElement,
+ p: GlobalPoint,
+): number => {
+ switch (element.type) {
+ case "rectangle":
+ case "image":
+ case "text":
+ case "iframe":
+ case "embeddable":
+ case "frame":
+ case "magicframe":
+ return distanceToRectanguloidElement(element, p);
+ case "diamond":
+ return distanceToDiamondElement(element, p);
+ case "ellipse":
+ return distanceToEllipseElement(element, p);
+ }
+};
+
+/**
+ * Returns the distance of a point and the provided rectangular-shaped element,
+ * accounting for roundness and rotation
+ *
+ * @param element The rectanguloid element
+ * @param p The point to consider
+ * @returns The eucledian distance to the outline of the rectanguloid element
+ */
+const distanceToRectanguloidElement = (
+ element: ExcalidrawRectanguloidElement,
+ p: GlobalPoint,
+) => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+ // To emulate a rotated rectangle we rotate the point in the inverse angle
+ // instead. It's all the same distance-wise.
+ const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
+
+ // Get the element's building components we can test against
+ const [sides, corners] = deconstructRectanguloidElement(element);
+
+ return Math.min(
+ ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
+ ...corners
+ .map((a) => curvePointDistance(a, rotatedPoint))
+ .filter((d): d is number => d !== null),
+ );
+};
+
+/**
+ * Returns the distance of a point and the provided diamond element, accounting
+ * for roundness and rotation
+ *
+ * @param element The diamond element
+ * @param p The point to consider
+ * @returns The eucledian distance to the outline of the diamond
+ */
+const distanceToDiamondElement = (
+ element: ExcalidrawDiamondElement,
+ p: GlobalPoint,
+): number => {
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ // Rotate the point to the inverse direction to simulate the rotated diamond
+ // points. It's all the same distance-wise.
+ const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
+
+ const [sides, curves] = deconstructDiamondElement(element);
+
+ return Math.min(
+ ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
+ ...curves
+ .map((a) => curvePointDistance(a, rotatedPoint))
+ .filter((d): d is number => d !== null),
+ );
+};
+
+/**
+ * Returns the distance of a point and the provided ellipse element, accounting
+ * for roundness and rotation
+ *
+ * @param element The ellipse element
+ * @param p The point to consider
+ * @returns The eucledian distance to the outline of the ellipse
+ */
+const distanceToEllipseElement = (
+ element: ExcalidrawEllipseElement,
+ p: GlobalPoint,
+): number => {
+ const center = pointFrom(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+ return ellipseDistanceFromPoint(
+ // Instead of rotating the ellipse, rotate the point to the inverse angle
+ pointRotateRads(p, center, -element.angle as Radians),
+ ellipse(center, element.width / 2, element.height / 2),
+ );
+};
diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts
new file mode 100644
index 0000000..bb8fd23
--- /dev/null
+++ b/packages/excalidraw/element/dragElements.ts
@@ -0,0 +1,286 @@
+import { updateBoundElements } from "./binding";
+import type { Bounds } from "./bounds";
+import { getCommonBounds } from "./bounds";
+import { mutateElement } from "./mutateElement";
+import { getPerfectElementSize } from "./sizeHelpers";
+import type { NonDeletedExcalidrawElement } from "./types";
+import type {
+ AppState,
+ NormalizedZoomValue,
+ NullableGridSize,
+ PointerDownState,
+} from "../types";
+import { getBoundTextElement } from "./textElement";
+import type Scene from "../scene/Scene";
+import {
+ isArrowElement,
+ isElbowArrow,
+ isFrameLikeElement,
+ isImageElement,
+ isTextElement,
+} from "./typeChecks";
+import { getFontString } from "../utils";
+import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
+import { getGridPoint } from "../snapping";
+import { getMinTextElementWidth } from "./textMeasurements";
+
+export const dragSelectedElements = (
+ pointerDownState: PointerDownState,
+ _selectedElements: NonDeletedExcalidrawElement[],
+ offset: { x: number; y: number },
+ scene: Scene,
+ snapOffset: {
+ x: number;
+ y: number;
+ },
+ gridSize: NullableGridSize,
+) => {
+ if (
+ _selectedElements.length === 1 &&
+ isElbowArrow(_selectedElements[0]) &&
+ (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
+ ) {
+ return;
+ }
+
+ const selectedElements = _selectedElements.filter((element) => {
+ if (isElbowArrow(element) && element.startBinding && element.endBinding) {
+ const startElement = _selectedElements.find(
+ (el) => el.id === element.startBinding?.elementId,
+ );
+ const endElement = _selectedElements.find(
+ (el) => el.id === element.endBinding?.elementId,
+ );
+
+ return startElement && endElement;
+ }
+
+ return true;
+ });
+
+ // we do not want a frame and its elements to be selected at the same time
+ // but when it happens (due to some bug), we want to avoid updating element
+ // in the frame twice, hence the use of set
+ const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
+ selectedElements,
+ );
+ const frames = selectedElements
+ .filter((e) => isFrameLikeElement(e))
+ .map((f) => f.id);
+
+ if (frames.length > 0) {
+ for (const element of scene.getNonDeletedElements()) {
+ if (element.frameId !== null && frames.includes(element.frameId)) {
+ elementsToUpdate.add(element);
+ }
+ }
+ }
+
+ const commonBounds = getCommonBounds(
+ Array.from(elementsToUpdate).map(
+ (el) => pointerDownState.originalElements.get(el.id) ?? el,
+ ),
+ );
+ const adjustedOffset = calculateOffset(
+ commonBounds,
+ offset,
+ snapOffset,
+ gridSize,
+ );
+
+ elementsToUpdate.forEach((element) => {
+ updateElementCoords(pointerDownState, element, adjustedOffset);
+ if (!isArrowElement(element)) {
+ // skip arrow labels since we calculate its position during render
+ const textElement = getBoundTextElement(
+ element,
+ scene.getNonDeletedElementsMap(),
+ );
+ if (textElement) {
+ updateElementCoords(pointerDownState, textElement, adjustedOffset);
+ }
+ updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
+ simultaneouslyUpdated: Array.from(elementsToUpdate),
+ });
+ }
+ });
+};
+
+const calculateOffset = (
+ commonBounds: Bounds,
+ dragOffset: { x: number; y: number },
+ snapOffset: { x: number; y: number },
+ gridSize: NullableGridSize,
+): { x: number; y: number } => {
+ const [x, y] = commonBounds;
+ let nextX = x + dragOffset.x + snapOffset.x;
+ let nextY = y + dragOffset.y + snapOffset.y;
+
+ if (snapOffset.x === 0 || snapOffset.y === 0) {
+ const [nextGridX, nextGridY] = getGridPoint(
+ x + dragOffset.x,
+ y + dragOffset.y,
+ gridSize,
+ );
+
+ if (snapOffset.x === 0) {
+ nextX = nextGridX;
+ }
+
+ if (snapOffset.y === 0) {
+ nextY = nextGridY;
+ }
+ }
+ return {
+ x: nextX - x,
+ y: nextY - y,
+ };
+};
+
+const updateElementCoords = (
+ pointerDownState: PointerDownState,
+ element: NonDeletedExcalidrawElement,
+ dragOffset: { x: number; y: number },
+) => {
+ const originalElement =
+ pointerDownState.originalElements.get(element.id) ?? element;
+
+ const nextX = originalElement.x + dragOffset.x;
+ const nextY = originalElement.y + dragOffset.y;
+
+ mutateElement(element, {
+ x: nextX,
+ y: nextY,
+ });
+};
+
+export const getDragOffsetXY = (
+ selectedElements: NonDeletedExcalidrawElement[],
+ x: number,
+ y: number,
+): [number, number] => {
+ const [x1, y1] = getCommonBounds(selectedElements);
+ return [x - x1, y - y1];
+};
+
+export const dragNewElement = ({
+ newElement,
+ elementType,
+ originX,
+ originY,
+ x,
+ y,
+ width,
+ height,
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ zoom,
+ widthAspectRatio = null,
+ originOffset = null,
+ informMutation = true,
+}: {
+ newElement: NonDeletedExcalidrawElement;
+ elementType: AppState["activeTool"]["type"];
+ originX: number;
+ originY: number;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ shouldMaintainAspectRatio: boolean;
+ shouldResizeFromCenter: boolean;
+ zoom: NormalizedZoomValue;
+ /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
+ true */
+ widthAspectRatio?: number | null;
+ originOffset?: {
+ x: number;
+ y: number;
+ } | null;
+ informMutation?: boolean;
+}) => {
+ if (shouldMaintainAspectRatio && newElement.type !== "selection") {
+ if (widthAspectRatio) {
+ height = width / widthAspectRatio;
+ } else {
+ // Depending on where the cursor is at (x, y) relative to where the starting point is
+ // (originX, originY), we use ONLY width or height to control size increase.
+ // This allows the cursor to always "stick" to one of the sides of the bounding box.
+ if (Math.abs(y - originY) > Math.abs(x - originX)) {
+ ({ width, height } = getPerfectElementSize(
+ elementType,
+ height,
+ x < originX ? -width : width,
+ ));
+ } else {
+ ({ width, height } = getPerfectElementSize(
+ elementType,
+ width,
+ y < originY ? -height : height,
+ ));
+ }
+
+ if (height < 0) {
+ height = -height;
+ }
+ }
+ }
+
+ let newX = x < originX ? originX - width : originX;
+ let newY = y < originY ? originY - height : originY;
+
+ if (shouldResizeFromCenter) {
+ width += width;
+ height += height;
+ newX = originX - width / 2;
+ newY = originY - height / 2;
+ }
+
+ let textAutoResize = null;
+
+ if (isTextElement(newElement)) {
+ height = newElement.height;
+ const minWidth = getMinTextElementWidth(
+ getFontString({
+ fontSize: newElement.fontSize,
+ fontFamily: newElement.fontFamily,
+ }),
+ newElement.lineHeight,
+ );
+ width = Math.max(width, minWidth);
+
+ if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
+ textAutoResize = {
+ autoResize: false,
+ };
+ }
+
+ newY = originY;
+ if (shouldResizeFromCenter) {
+ newX = originX - width / 2;
+ }
+ }
+
+ if (width !== 0 && height !== 0) {
+ let imageInitialDimension = null;
+ if (isImageElement(newElement)) {
+ imageInitialDimension = {
+ initialWidth: width,
+ initialHeight: height,
+ };
+ }
+
+ mutateElement(
+ newElement,
+ {
+ x: newX + (originOffset?.x ?? 0),
+ y: newY + (originOffset?.y ?? 0),
+ width,
+ height,
+ ...textAutoResize,
+ ...imageInitialDimension,
+ },
+ informMutation,
+ );
+ }
+};
diff --git a/packages/excalidraw/element/elbowArrow.test.tsx b/packages/excalidraw/element/elbowArrow.test.tsx
new file mode 100644
index 0000000..c00eae9
--- /dev/null
+++ b/packages/excalidraw/element/elbowArrow.test.tsx
@@ -0,0 +1,408 @@
+import React from "react";
+import Scene from "../scene/Scene";
+import { API } from "../tests/helpers/api";
+import { Pointer, UI } from "../tests/helpers/ui";
+import {
+ act,
+ fireEvent,
+ GlobalTestState,
+ queryByTestId,
+ render,
+} from "../tests/test-utils";
+import { bindLinearElement } from "./binding";
+import { Excalidraw, mutateElement } from "../index";
+import type {
+ ExcalidrawArrowElement,
+ ExcalidrawBindableElement,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+import { ARROW_TYPE } from "../constants";
+import "../../utils/test-utils";
+import type { LocalPoint } from "@excalidraw/math";
+import { pointFrom } from "@excalidraw/math";
+import { actionDuplicateSelection } from "../actions/actionDuplicateSelection";
+import { actionSelectAll } from "../actions";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+describe("elbow arrow segment move", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ });
+
+ it("can move the second segment of a fully connected elbow arrow", () => {
+ UI.createElement("rectangle", {
+ x: -100,
+ y: -50,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 200,
+ y: 150,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(0, 0);
+ mouse.click();
+ mouse.moveTo(200, 200);
+ mouse.click();
+
+ mouse.reset();
+ mouse.moveTo(100, 100);
+ mouse.down();
+ mouse.moveTo(115, 100);
+ mouse.up();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawElbowArrowElement;
+
+ expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
+ expect(arrow.fixedSegments?.length).toBe(1);
+
+ expect(arrow.points).toCloselyEqualPoints([
+ [0, 0],
+ [110, 0],
+ [110, 200],
+ [190, 200],
+ ]);
+
+ mouse.reset();
+ mouse.moveTo(105, 74.275);
+ mouse.doubleClick();
+
+ expect(arrow.points).toCloselyEqualPoints([
+ [0, 0],
+ [110, 0],
+ [110, 200],
+ [190, 200],
+ ]);
+ });
+
+ it("can move the second segment of an unconnected elbow arrow", () => {
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(0, 0);
+ mouse.click();
+ mouse.moveTo(250, 200);
+ mouse.click();
+
+ mouse.reset();
+ mouse.moveTo(125, 100);
+ mouse.down();
+ mouse.moveTo(130, 100);
+ mouse.up();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ expect(arrow.points).toCloselyEqualPoints([
+ [0, 0],
+ [130, 0],
+ [130, 200],
+ [250, 200],
+ ]);
+
+ mouse.reset();
+ mouse.moveTo(130, 100);
+ mouse.doubleClick();
+
+ expect(arrow.points).toCloselyEqualPoints([
+ [0, 0],
+ [125, 0],
+ [125, 200],
+ [250, 200],
+ ]);
+ });
+});
+
+describe("elbow arrow routing", () => {
+ it("can properly generate orthogonal arrow points", () => {
+ const scene = new Scene();
+ const arrow = API.createElement({
+ type: "arrow",
+ elbowed: true,
+ }) as ExcalidrawElbowArrowElement;
+ scene.insertElement(arrow);
+ mutateElement(arrow, {
+ points: [
+ pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
+ pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
+ ],
+ });
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [0, 100],
+ [90, 100],
+ [90, 200],
+ ]);
+ expect(arrow.x).toEqual(-45);
+ expect(arrow.y).toEqual(-100.1);
+ expect(arrow.width).toEqual(90);
+ expect(arrow.height).toEqual(200);
+ });
+ it("can generate proper points for bound elbow arrow", () => {
+ const scene = new Scene();
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ }) as ExcalidrawBindableElement;
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ }) as ExcalidrawBindableElement;
+ const arrow = API.createElement({
+ type: "arrow",
+ elbowed: true,
+ x: -45,
+ y: -100.1,
+ width: 90,
+ height: 200,
+ points: [pointFrom(0, 0), pointFrom(90, 200)],
+ }) as ExcalidrawElbowArrowElement;
+ scene.insertElement(rectangle1);
+ scene.insertElement(rectangle2);
+ scene.insertElement(arrow);
+ const elementsMap = scene.getNonDeletedElementsMap();
+ bindLinearElement(arrow, rectangle1, "start", elementsMap);
+ bindLinearElement(arrow, rectangle2, "end", elementsMap);
+
+ expect(arrow.startBinding).not.toBe(null);
+ expect(arrow.endBinding).not.toBe(null);
+
+ mutateElement(arrow, {
+ points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
+ });
+
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [45, 0],
+ [45, 200],
+ [90, 200],
+ ]);
+ });
+});
+
+describe("elbow arrow ui", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+ });
+
+ it("can follow bound shapes", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ expect(arrow.type).toBe("arrow");
+ expect(arrow.elbowed).toBe(true);
+ expect(arrow.points).toEqual([
+ [0, 0],
+ [45, 0],
+ [45, 200],
+ [90, 200],
+ ]);
+ });
+
+ it("can follow bound rotated shapes", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ mouse.click(51, 51);
+
+ const inputAngle = UI.queryStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ UI.updateInput(inputAngle, String("40"));
+
+ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
+ [0, 0],
+ [35, 0],
+ [35, 165],
+ [103, 165],
+ ]);
+ });
+
+ it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+ const originalArrowId = arrow.id;
+
+ expect(arrow.startBinding).not.toBe(null);
+ expect(arrow.endBinding).not.toBe(null);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionSelectAll);
+ });
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ expect(h.elements.length).toEqual(6);
+
+ const duplicatedArrow = h.scene.getSelectedElements(
+ h.state,
+ )[2] as ExcalidrawArrowElement;
+
+ expect(duplicatedArrow.id).not.toBe(originalArrowId);
+ expect(duplicatedArrow.type).toBe("arrow");
+ expect(duplicatedArrow.elbowed).toBe(true);
+ expect(duplicatedArrow.points).toEqual([
+ [0, 0],
+ [45, 0],
+ [45, 200],
+ [90, 200],
+ ]);
+ expect(arrow.startBinding).not.toBe(null);
+ expect(arrow.endBinding).not.toBe(null);
+ });
+
+ it("keeps arrow shape when only the bound arrow is duplicated", async () => {
+ UI.createElement("rectangle", {
+ x: -150,
+ y: -150,
+ width: 100,
+ height: 100,
+ });
+ UI.createElement("rectangle", {
+ x: 50,
+ y: 50,
+ width: 100,
+ height: 100,
+ });
+
+ UI.clickTool("arrow");
+ UI.clickOnTestId("elbow-arrow");
+
+ mouse.reset();
+ mouse.moveTo(-43, -99);
+ mouse.click();
+ mouse.moveTo(43, 99);
+ mouse.click();
+
+ const arrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+ const originalArrowId = arrow.id;
+
+ expect(arrow.startBinding).not.toBe(null);
+ expect(arrow.endBinding).not.toBe(null);
+
+ act(() => {
+ h.app.actionManager.executeAction(actionDuplicateSelection);
+ });
+
+ expect(h.elements.length).toEqual(4);
+
+ const duplicatedArrow = h.scene.getSelectedElements(
+ h.state,
+ )[0] as ExcalidrawArrowElement;
+
+ expect(duplicatedArrow.id).not.toBe(originalArrowId);
+ expect(duplicatedArrow.type).toBe("arrow");
+ expect(duplicatedArrow.elbowed).toBe(true);
+ expect(duplicatedArrow.points).toEqual([
+ [0, 0],
+ [45, 0],
+ [45, 200],
+ [90, 200],
+ ]);
+ });
+});
diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts
new file mode 100644
index 0000000..ce8c3d5
--- /dev/null
+++ b/packages/excalidraw/element/elbowArrow.ts
@@ -0,0 +1,2280 @@
+import {
+ clamp,
+ pointDistance,
+ pointFrom,
+ pointScaleFromOrigin,
+ pointsEqual,
+ pointTranslate,
+ vector,
+ vectorCross,
+ vectorFromPoint,
+ vectorScale,
+ type GlobalPoint,
+ type LocalPoint,
+} from "@excalidraw/math";
+import BinaryHeap from "../binaryheap";
+import { getSizeFromPoints } from "../points";
+import { aabbForElement, pointInsideBounds } from "../shapes";
+import { invariant, isAnyTrue, tupleToCoors } from "../utils";
+import type { AppState } from "../types";
+import {
+ bindPointToSnapToElementOutline,
+ FIXED_BINDING_DISTANCE,
+ getHeadingForElbowArrowSnap,
+ getGlobalFixedPointForBindableElement,
+ snapToMid,
+ getHoveredElementForBinding,
+} from "./binding";
+import type { Bounds } from "./bounds";
+import type { Heading } from "./heading";
+import {
+ compareHeading,
+ flipHeading,
+ HEADING_DOWN,
+ HEADING_LEFT,
+ HEADING_RIGHT,
+ HEADING_UP,
+ headingForPointIsHorizontal,
+ headingIsHorizontal,
+ vectorToHeading,
+ headingForPoint,
+} from "./heading";
+import { type ElementUpdate } from "./mutateElement";
+import { isBindableElement } from "./typeChecks";
+import {
+ type ExcalidrawElbowArrowElement,
+ type NonDeletedSceneElementsMap,
+ type SceneElementsMap,
+} from "./types";
+import type {
+ Arrowhead,
+ ElementsMap,
+ ExcalidrawBindableElement,
+ FixedPointBinding,
+ FixedSegment,
+ NonDeletedExcalidrawElement,
+} from "./types";
+import { distanceToBindableElement } from "./distance";
+
+type GridAddress = [number, number] & { _brand: "gridaddress" };
+
+type Node = {
+ f: number;
+ g: number;
+ h: number;
+ closed: boolean;
+ visited: boolean;
+ parent: Node | null;
+ pos: GlobalPoint;
+ addr: GridAddress;
+};
+
+type Grid = {
+ row: number;
+ col: number;
+ data: (Node | null)[];
+};
+
+type ElbowArrowState = {
+ x: number;
+ y: number;
+ startBinding: FixedPointBinding | null;
+ endBinding: FixedPointBinding | null;
+ startArrowhead: Arrowhead | null;
+ endArrowhead: Arrowhead | null;
+};
+
+type ElbowArrowData = {
+ dynamicAABBs: Bounds[];
+ startDonglePosition: GlobalPoint | null;
+ startGlobalPoint: GlobalPoint;
+ startHeading: Heading;
+ endDonglePosition: GlobalPoint | null;
+ endGlobalPoint: GlobalPoint;
+ endHeading: Heading;
+ commonBounds: Bounds;
+ hoveredStartElement: ExcalidrawBindableElement | null;
+ hoveredEndElement: ExcalidrawBindableElement | null;
+};
+
+const DEDUP_TRESHOLD = 1;
+export const BASE_PADDING = 40;
+
+const handleSegmentRenormalization = (
+ arrow: ExcalidrawElbowArrowElement,
+ elementsMap: NonDeletedSceneElementsMap,
+) => {
+ const nextFixedSegments: FixedSegment[] | null = arrow.fixedSegments
+ ? arrow.fixedSegments.slice()
+ : null;
+
+ if (nextFixedSegments) {
+ const _nextPoints: GlobalPoint[] = [];
+
+ arrow.points
+ .map((p) => pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]))
+ .forEach((p, i, points) => {
+ if (i < 2) {
+ return _nextPoints.push(p);
+ }
+
+ const currentSegmentIsHorizontal = headingForPoint(p, points[i - 1]);
+ const prevSegmentIsHorizontal = headingForPoint(
+ points[i - 1],
+ points[i - 2],
+ );
+
+ if (
+ // Check if previous two points are on the same line
+ compareHeading(currentSegmentIsHorizontal, prevSegmentIsHorizontal)
+ ) {
+ const prevSegmentIdx =
+ nextFixedSegments?.findIndex(
+ (segment) => segment.index === i - 1,
+ ) ?? -1;
+ const segmentIdx =
+ nextFixedSegments?.findIndex((segment) => segment.index === i) ??
+ -1;
+
+ // If the current segment is a fixed segment, update its start point
+ if (segmentIdx !== -1) {
+ nextFixedSegments[segmentIdx].start = pointFrom<LocalPoint>(
+ points[i - 2][0] - arrow.x,
+ points[i - 2][1] - arrow.y,
+ );
+ }
+
+ // Remove the fixed segment status from the previous segment if it is
+ // a fixed segment, because we are going to unify that segment with
+ // the current one
+ if (prevSegmentIdx !== -1) {
+ nextFixedSegments.splice(prevSegmentIdx, 1);
+ }
+
+ // Remove the duplicate point
+ _nextPoints.splice(-1, 1);
+
+ // Update fixed point indices
+ nextFixedSegments.forEach((segment) => {
+ if (segment.index > i - 1) {
+ segment.index -= 1;
+ }
+ });
+ }
+
+ return _nextPoints.push(p);
+ });
+
+ const nextPoints: GlobalPoint[] = [];
+
+ _nextPoints.forEach((p, i, points) => {
+ if (i < 3) {
+ return nextPoints.push(p);
+ }
+
+ if (
+ // Remove segments that are too short
+ pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD
+ ) {
+ const prevPrevSegmentIdx =
+ nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
+ -1;
+ const prevSegmentIdx =
+ nextFixedSegments?.findIndex((segment) => segment.index === i - 1) ??
+ -1;
+
+ // Remove the previous fixed segment if it exists (i.e. the segment
+ // which will be removed due to being parallel or too short)
+ if (prevSegmentIdx !== -1) {
+ nextFixedSegments.splice(prevSegmentIdx, 1);
+ }
+
+ // Remove the fixed segment status from the segment 2 steps back
+ // if it is a fixed segment, because we are going to unify that
+ // segment with the current one
+ if (prevPrevSegmentIdx !== -1) {
+ nextFixedSegments.splice(prevPrevSegmentIdx, 1);
+ }
+
+ nextPoints.splice(-2, 2);
+
+ // Since we have to remove two segments, update any fixed segment
+ nextFixedSegments.forEach((segment) => {
+ if (segment.index > i - 2) {
+ segment.index -= 2;
+ }
+ });
+
+ // Remove aligned segment points
+ const isHorizontal = headingForPointIsHorizontal(p, points[i - 1]);
+
+ return nextPoints.push(
+ pointFrom<GlobalPoint>(
+ !isHorizontal ? points[i - 2][0] : p[0],
+ isHorizontal ? points[i - 2][1] : p[1],
+ ),
+ );
+ }
+
+ nextPoints.push(p);
+ });
+
+ const filteredNextFixedSegments = nextFixedSegments.filter(
+ (segment) =>
+ segment.index !== 1 && segment.index !== nextPoints.length - 1,
+ );
+ if (filteredNextFixedSegments.length === 0) {
+ return normalizeArrowElementUpdate(
+ getElbowArrowCornerPoints(
+ removeElbowArrowShortSegments(
+ routeElbowArrow(
+ arrow,
+ getElbowArrowData(
+ arrow,
+ elementsMap,
+ nextPoints.map((p) =>
+ pointFrom<LocalPoint>(p[0] - arrow.x, p[1] - arrow.y),
+ ),
+ arrow.startBinding &&
+ getBindableElementForId(
+ arrow.startBinding.elementId,
+ elementsMap,
+ ),
+ arrow.endBinding &&
+ getBindableElementForId(
+ arrow.endBinding.elementId,
+ elementsMap,
+ ),
+ ),
+ ) ?? [],
+ ),
+ ),
+ filteredNextFixedSegments,
+ null,
+ null,
+ );
+ }
+
+ import.meta.env.DEV &&
+ invariant(
+ validateElbowPoints(nextPoints),
+ "Invalid elbow points with fixed segments",
+ );
+
+ return normalizeArrowElementUpdate(
+ nextPoints,
+ filteredNextFixedSegments,
+ arrow.startIsSpecial,
+ arrow.endIsSpecial,
+ );
+ }
+
+ return {
+ x: arrow.x,
+ y: arrow.y,
+ points: arrow.points,
+ fixedSegments: arrow.fixedSegments,
+ startIsSpecial: arrow.startIsSpecial,
+ endIsSpecial: arrow.endIsSpecial,
+ };
+};
+
+const handleSegmentRelease = (
+ arrow: ExcalidrawElbowArrowElement,
+ fixedSegments: readonly FixedSegment[],
+ elementsMap: NonDeletedSceneElementsMap,
+) => {
+ const newFixedSegmentIndices = fixedSegments.map((segment) => segment.index);
+ const oldFixedSegmentIndices =
+ arrow.fixedSegments?.map((segment) => segment.index) ?? [];
+ const deletedSegmentIdx = oldFixedSegmentIndices.findIndex(
+ (idx) => !newFixedSegmentIndices.includes(idx),
+ );
+
+ if (deletedSegmentIdx === -1 || !arrow.fixedSegments?.[deletedSegmentIdx]) {
+ return {
+ points: arrow.points,
+ };
+ }
+
+ const deletedIdx = arrow.fixedSegments[deletedSegmentIdx].index;
+
+ // Find prev and next fixed segments
+ const prevSegment = arrow.fixedSegments[deletedSegmentIdx - 1];
+ const nextSegment = arrow.fixedSegments[deletedSegmentIdx + 1];
+
+ // We need to render a sub-arrow path to restore deleted segments
+ const x = arrow.x + (prevSegment ? prevSegment.end[0] : 0);
+ const y = arrow.y + (prevSegment ? prevSegment.end[1] : 0);
+ const startBinding = prevSegment ? null : arrow.startBinding;
+ const endBinding = nextSegment ? null : arrow.endBinding;
+ const {
+ startHeading,
+ endHeading,
+ startGlobalPoint,
+ endGlobalPoint,
+ hoveredStartElement,
+ hoveredEndElement,
+ ...rest
+ } = getElbowArrowData(
+ {
+ x,
+ y,
+ startBinding,
+ endBinding,
+ startArrowhead: null,
+ endArrowhead: null,
+ points: arrow.points,
+ },
+ elementsMap,
+ [
+ pointFrom<LocalPoint>(0, 0),
+ pointFrom<LocalPoint>(
+ arrow.x +
+ (nextSegment?.start[0] ?? arrow.points[arrow.points.length - 1][0]) -
+ x,
+ arrow.y +
+ (nextSegment?.start[1] ?? arrow.points[arrow.points.length - 1][1]) -
+ y,
+ ),
+ ],
+ startBinding &&
+ getBindableElementForId(startBinding.elementId, elementsMap),
+ endBinding && getBindableElementForId(endBinding.elementId, elementsMap),
+ { isDragging: false },
+ );
+
+ const { points: restoredPoints } = normalizeArrowElementUpdate(
+ getElbowArrowCornerPoints(
+ removeElbowArrowShortSegments(
+ routeElbowArrow(arrow, {
+ startHeading,
+ endHeading,
+ startGlobalPoint,
+ endGlobalPoint,
+ hoveredStartElement,
+ hoveredEndElement,
+ ...rest,
+ }) ?? [],
+ ),
+ ),
+ fixedSegments,
+ null,
+ null,
+ );
+
+ const nextPoints: GlobalPoint[] = [];
+
+ // First part of the arrow are the old points
+ if (prevSegment) {
+ for (let i = 0; i < prevSegment.index; i++) {
+ nextPoints.push(
+ pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[i][0],
+ arrow.y + arrow.points[i][1],
+ ),
+ );
+ }
+ }
+
+ restoredPoints.forEach((p) => {
+ nextPoints.push(
+ pointFrom<GlobalPoint>(
+ arrow.x + (prevSegment ? prevSegment.end[0] : 0) + p[0],
+ arrow.y + (prevSegment ? prevSegment.end[1] : 0) + p[1],
+ ),
+ );
+ });
+
+ // Last part of the arrow are the old points too
+ if (nextSegment) {
+ for (let i = nextSegment.index; i < arrow.points.length; i++) {
+ nextPoints.push(
+ pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[i][0],
+ arrow.y + arrow.points[i][1],
+ ),
+ );
+ }
+ }
+
+ // Update nextFixedSegments
+ const originalSegmentCountDiff =
+ (nextSegment?.index ?? arrow.points.length) - (prevSegment?.index ?? 0) - 1;
+
+ const nextFixedSegments = fixedSegments.map((segment) => {
+ if (segment.index > deletedIdx) {
+ return {
+ ...segment,
+ index:
+ segment.index -
+ originalSegmentCountDiff +
+ (restoredPoints.length - 1),
+ };
+ }
+
+ return segment;
+ });
+
+ const simplifiedPoints = nextPoints.flatMap((p, i) => {
+ const prev = nextPoints[i - 1];
+ const next = nextPoints[i + 1];
+
+ if (prev && next) {
+ const prevHeading = headingForPoint(p, prev);
+ const nextHeading = headingForPoint(next, p);
+
+ if (compareHeading(prevHeading, nextHeading)) {
+ // Update subsequent fixed segment indices
+ nextFixedSegments.forEach((segment) => {
+ if (segment.index > i) {
+ segment.index -= 1;
+ }
+ });
+
+ return [];
+ } else if (compareHeading(prevHeading, flipHeading(nextHeading))) {
+ // Update subsequent fixed segment indices
+ nextFixedSegments.forEach((segment) => {
+ if (segment.index > i) {
+ segment.index += 1;
+ }
+ });
+
+ return [p, p];
+ }
+ }
+
+ return [p];
+ });
+
+ return normalizeArrowElementUpdate(
+ simplifiedPoints,
+ nextFixedSegments,
+ false,
+ false,
+ );
+};
+
+/**
+ *
+ */
+const handleSegmentMove = (
+ arrow: ExcalidrawElbowArrowElement,
+ fixedSegments: readonly FixedSegment[],
+ startHeading: Heading,
+ endHeading: Heading,
+ hoveredStartElement: ExcalidrawBindableElement | null,
+ hoveredEndElement: ExcalidrawBindableElement | null,
+): ElementUpdate<ExcalidrawElbowArrowElement> => {
+ const activelyModifiedSegmentIdx = fixedSegments
+ .map((segment, i) => {
+ if (
+ arrow.fixedSegments == null ||
+ arrow.fixedSegments[i] === undefined ||
+ arrow.fixedSegments[i].index !== segment.index
+ ) {
+ return i;
+ }
+
+ return (segment.start[0] !== arrow.fixedSegments![i].start[0] &&
+ segment.end[0] !== arrow.fixedSegments![i].end[0]) !==
+ (segment.start[1] !== arrow.fixedSegments![i].start[1] &&
+ segment.end[1] !== arrow.fixedSegments![i].end[1])
+ ? i
+ : null;
+ })
+ .filter((idx) => idx !== null)
+ .shift();
+
+ if (activelyModifiedSegmentIdx == null) {
+ return { points: arrow.points };
+ }
+
+ const firstSegmentIdx =
+ arrow.fixedSegments?.findIndex((segment) => segment.index === 1) ?? -1;
+ const lastSegmentIdx =
+ arrow.fixedSegments?.findIndex(
+ (segment) => segment.index === arrow.points.length - 1,
+ ) ?? -1;
+
+ // Handle special case for first segment move
+ const segmentLength = pointDistance(
+ fixedSegments[activelyModifiedSegmentIdx].start,
+ fixedSegments[activelyModifiedSegmentIdx].end,
+ );
+ const segmentIsTooShort = segmentLength < BASE_PADDING + 5;
+ if (
+ firstSegmentIdx === -1 &&
+ fixedSegments[activelyModifiedSegmentIdx].index === 1 &&
+ hoveredStartElement
+ ) {
+ const startIsHorizontal = headingIsHorizontal(startHeading);
+ const startIsPositive = startIsHorizontal
+ ? compareHeading(startHeading, HEADING_RIGHT)
+ : compareHeading(startHeading, HEADING_DOWN);
+ const padding = startIsPositive
+ ? segmentIsTooShort
+ ? segmentLength / 2
+ : BASE_PADDING
+ : segmentIsTooShort
+ ? -segmentLength / 2
+ : -BASE_PADDING;
+ fixedSegments[activelyModifiedSegmentIdx].start = pointFrom<LocalPoint>(
+ fixedSegments[activelyModifiedSegmentIdx].start[0] +
+ (startIsHorizontal ? padding : 0),
+ fixedSegments[activelyModifiedSegmentIdx].start[1] +
+ (!startIsHorizontal ? padding : 0),
+ );
+ }
+
+ // Handle special case for last segment move
+ if (
+ lastSegmentIdx === -1 &&
+ fixedSegments[activelyModifiedSegmentIdx].index ===
+ arrow.points.length - 1 &&
+ hoveredEndElement
+ ) {
+ const endIsHorizontal = headingIsHorizontal(endHeading);
+ const endIsPositive = endIsHorizontal
+ ? compareHeading(endHeading, HEADING_RIGHT)
+ : compareHeading(endHeading, HEADING_DOWN);
+ const padding = endIsPositive
+ ? segmentIsTooShort
+ ? segmentLength / 2
+ : BASE_PADDING
+ : segmentIsTooShort
+ ? -segmentLength / 2
+ : -BASE_PADDING;
+ fixedSegments[activelyModifiedSegmentIdx].end = pointFrom<LocalPoint>(
+ fixedSegments[activelyModifiedSegmentIdx].end[0] +
+ (endIsHorizontal ? padding : 0),
+ fixedSegments[activelyModifiedSegmentIdx].end[1] +
+ (!endIsHorizontal ? padding : 0),
+ );
+ }
+
+ // Translate all fixed segments to global coordinates
+ const nextFixedSegments = fixedSegments.map((segment) => ({
+ ...segment,
+ start: pointFrom<GlobalPoint>(
+ arrow.x + segment.start[0],
+ arrow.y + segment.start[1],
+ ),
+ end: pointFrom<GlobalPoint>(
+ arrow.x + segment.end[0],
+ arrow.y + segment.end[1],
+ ),
+ }));
+
+ // For start, clone old arrow points
+ const newPoints: GlobalPoint[] = arrow.points.map((p, i) =>
+ pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
+ );
+
+ const startIdx = nextFixedSegments[activelyModifiedSegmentIdx].index - 1;
+ const endIdx = nextFixedSegments[activelyModifiedSegmentIdx].index;
+ const start = nextFixedSegments[activelyModifiedSegmentIdx].start;
+ const end = nextFixedSegments[activelyModifiedSegmentIdx].end;
+ const prevSegmentIsHorizontal =
+ newPoints[startIdx - 1] &&
+ !pointsEqual(newPoints[startIdx], newPoints[startIdx - 1])
+ ? headingForPointIsHorizontal(
+ newPoints[startIdx - 1],
+ newPoints[startIdx],
+ )
+ : undefined;
+ const nextSegmentIsHorizontal =
+ newPoints[endIdx + 1] &&
+ !pointsEqual(newPoints[endIdx], newPoints[endIdx + 1])
+ ? headingForPointIsHorizontal(newPoints[endIdx + 1], newPoints[endIdx])
+ : undefined;
+
+ // Override the segment points with the actively moved fixed segment
+ if (prevSegmentIsHorizontal !== undefined) {
+ const dir = prevSegmentIsHorizontal ? 1 : 0;
+ newPoints[startIdx - 1][dir] = start[dir];
+ }
+ newPoints[startIdx] = start;
+ newPoints[endIdx] = end;
+ if (nextSegmentIsHorizontal !== undefined) {
+ const dir = nextSegmentIsHorizontal ? 1 : 0;
+ newPoints[endIdx + 1][dir] = end[dir];
+ }
+
+ // Override neighboring fixedSegment start/end points, if any
+ const prevSegmentIdx = nextFixedSegments.findIndex(
+ (segment) => segment.index === startIdx,
+ );
+ if (prevSegmentIdx !== -1) {
+ // Align the next segment points with the moved segment
+ const dir = headingForPointIsHorizontal(
+ nextFixedSegments[prevSegmentIdx].end,
+ nextFixedSegments[prevSegmentIdx].start,
+ )
+ ? 1
+ : 0;
+ nextFixedSegments[prevSegmentIdx].start[dir] = start[dir];
+ nextFixedSegments[prevSegmentIdx].end = start;
+ }
+
+ const nextSegmentIdx = nextFixedSegments.findIndex(
+ (segment) => segment.index === endIdx + 1,
+ );
+ if (nextSegmentIdx !== -1) {
+ // Align the next segment points with the moved segment
+ const dir = headingForPointIsHorizontal(
+ nextFixedSegments[nextSegmentIdx].end,
+ nextFixedSegments[nextSegmentIdx].start,
+ )
+ ? 1
+ : 0;
+ nextFixedSegments[nextSegmentIdx].end[dir] = end[dir];
+ nextFixedSegments[nextSegmentIdx].start = end;
+ }
+
+ // First segment move needs an additional segment
+ if (firstSegmentIdx === -1 && startIdx === 0) {
+ const startIsHorizontal = hoveredStartElement
+ ? headingIsHorizontal(startHeading)
+ : headingForPointIsHorizontal(newPoints[1], newPoints[0]);
+ newPoints.unshift(
+ pointFrom<GlobalPoint>(
+ startIsHorizontal ? start[0] : arrow.x + arrow.points[0][0],
+ !startIsHorizontal ? start[1] : arrow.y + arrow.points[0][1],
+ ),
+ );
+
+ if (hoveredStartElement) {
+ newPoints.unshift(
+ pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[0][0],
+ arrow.y + arrow.points[0][1],
+ ),
+ );
+ }
+
+ for (const segment of nextFixedSegments) {
+ segment.index += hoveredStartElement ? 2 : 1;
+ }
+ }
+
+ // Last segment move needs an additional segment
+ if (lastSegmentIdx === -1 && endIdx === arrow.points.length - 1) {
+ const endIsHorizontal = headingIsHorizontal(endHeading);
+ newPoints.push(
+ pointFrom<GlobalPoint>(
+ endIsHorizontal
+ ? end[0]
+ : arrow.x + arrow.points[arrow.points.length - 1][0],
+ !endIsHorizontal
+ ? end[1]
+ : arrow.y + arrow.points[arrow.points.length - 1][1],
+ ),
+ );
+ if (hoveredEndElement) {
+ newPoints.push(
+ pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[arrow.points.length - 1][0],
+ arrow.y + arrow.points[arrow.points.length - 1][1],
+ ),
+ );
+ }
+ }
+
+ return normalizeArrowElementUpdate(
+ newPoints,
+ nextFixedSegments.map((segment) => ({
+ ...segment,
+ start: pointFrom<LocalPoint>(
+ segment.start[0] - arrow.x,
+ segment.start[1] - arrow.y,
+ ),
+ end: pointFrom<LocalPoint>(
+ segment.end[0] - arrow.x,
+ segment.end[1] - arrow.y,
+ ),
+ })),
+ false, // If you move a segment, there is no special point anymore
+ false, // If you move a segment, there is no special point anymore
+ );
+};
+
+const handleEndpointDrag = (
+ arrow: ExcalidrawElbowArrowElement,
+ updatedPoints: readonly LocalPoint[],
+ fixedSegments: readonly FixedSegment[],
+ startHeading: Heading,
+ endHeading: Heading,
+ startGlobalPoint: GlobalPoint,
+ endGlobalPoint: GlobalPoint,
+ hoveredStartElement: ExcalidrawBindableElement | null,
+ hoveredEndElement: ExcalidrawBindableElement | null,
+) => {
+ let startIsSpecial = arrow.startIsSpecial ?? null;
+ let endIsSpecial = arrow.endIsSpecial ?? null;
+ const globalUpdatedPoints = updatedPoints.map((p, i) =>
+ i === 0
+ ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
+ : i === updatedPoints.length - 1
+ ? pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1])
+ : pointFrom<GlobalPoint>(
+ arrow.x + arrow.points[i][0],
+ arrow.y + arrow.points[i][1],
+ ),
+ );
+ const nextFixedSegments = fixedSegments.map((segment) => ({
+ ...segment,
+ start: pointFrom<GlobalPoint>(
+ arrow.x + (segment.start[0] - updatedPoints[0][0]),
+ arrow.y + (segment.start[1] - updatedPoints[0][1]),
+ ),
+ end: pointFrom<GlobalPoint>(
+ arrow.x + (segment.end[0] - updatedPoints[0][0]),
+ arrow.y + (segment.end[1] - updatedPoints[0][1]),
+ ),
+ }));
+ const newPoints: GlobalPoint[] = [];
+
+ // Add the inside points
+ const offset = 2 + (startIsSpecial ? 1 : 0);
+ const endOffset = 2 + (endIsSpecial ? 1 : 0);
+ while (newPoints.length + offset < globalUpdatedPoints.length - endOffset) {
+ newPoints.push(globalUpdatedPoints[newPoints.length + offset]);
+ }
+
+ // Calculate the moving second point connection and add the start point
+ {
+ const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
+ const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
+ const startIsHorizontal = headingIsHorizontal(startHeading);
+ const secondIsHorizontal = headingIsHorizontal(
+ vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
+ );
+
+ if (hoveredStartElement && startIsHorizontal === secondIsHorizontal) {
+ const positive = startIsHorizontal
+ ? compareHeading(startHeading, HEADING_RIGHT)
+ : compareHeading(startHeading, HEADING_DOWN);
+ newPoints.unshift(
+ pointFrom<GlobalPoint>(
+ !secondIsHorizontal
+ ? thirdPoint[0]
+ : startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING),
+ secondIsHorizontal
+ ? thirdPoint[1]
+ : startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING),
+ ),
+ );
+ newPoints.unshift(
+ pointFrom<GlobalPoint>(
+ startIsHorizontal
+ ? startGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING)
+ : startGlobalPoint[0],
+ !startIsHorizontal
+ ? startGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING)
+ : startGlobalPoint[1],
+ ),
+ );
+ if (!startIsSpecial) {
+ startIsSpecial = true;
+ for (const segment of nextFixedSegments) {
+ if (segment.index > 1) {
+ segment.index += 1;
+ }
+ }
+ }
+ } else {
+ newPoints.unshift(
+ pointFrom<GlobalPoint>(
+ !secondIsHorizontal ? secondPoint[0] : startGlobalPoint[0],
+ secondIsHorizontal ? secondPoint[1] : startGlobalPoint[1],
+ ),
+ );
+ if (startIsSpecial) {
+ startIsSpecial = false;
+ for (const segment of nextFixedSegments) {
+ if (segment.index > 1) {
+ segment.index -= 1;
+ }
+ }
+ }
+ }
+ newPoints.unshift(startGlobalPoint);
+ }
+
+ // Calculate the moving second to last point connection
+ {
+ const secondToLastPoint =
+ globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
+ const thirdToLastPoint =
+ globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
+ const endIsHorizontal = headingIsHorizontal(endHeading);
+ const secondIsHorizontal = headingForPointIsHorizontal(
+ thirdToLastPoint,
+ secondToLastPoint,
+ );
+ if (hoveredEndElement && endIsHorizontal === secondIsHorizontal) {
+ const positive = endIsHorizontal
+ ? compareHeading(endHeading, HEADING_RIGHT)
+ : compareHeading(endHeading, HEADING_DOWN);
+ newPoints.push(
+ pointFrom<GlobalPoint>(
+ !secondIsHorizontal
+ ? thirdToLastPoint[0]
+ : endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING),
+ secondIsHorizontal
+ ? thirdToLastPoint[1]
+ : endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING),
+ ),
+ );
+ newPoints.push(
+ pointFrom<GlobalPoint>(
+ endIsHorizontal
+ ? endGlobalPoint[0] + (positive ? BASE_PADDING : -BASE_PADDING)
+ : endGlobalPoint[0],
+ !endIsHorizontal
+ ? endGlobalPoint[1] + (positive ? BASE_PADDING : -BASE_PADDING)
+ : endGlobalPoint[1],
+ ),
+ );
+ if (!endIsSpecial) {
+ endIsSpecial = true;
+ }
+ } else {
+ newPoints.push(
+ pointFrom<GlobalPoint>(
+ !secondIsHorizontal ? secondToLastPoint[0] : endGlobalPoint[0],
+ secondIsHorizontal ? secondToLastPoint[1] : endGlobalPoint[1],
+ ),
+ );
+ if (endIsSpecial) {
+ endIsSpecial = false;
+ }
+ }
+ }
+
+ newPoints.push(endGlobalPoint);
+
+ return normalizeArrowElementUpdate(
+ newPoints,
+ nextFixedSegments
+ .map(({ index }) => ({
+ index,
+ start: newPoints[index - 1],
+ end: newPoints[index],
+ }))
+ .map((segment) => ({
+ ...segment,
+ start: pointFrom<LocalPoint>(
+ segment.start[0] - startGlobalPoint[0],
+ segment.start[1] - startGlobalPoint[1],
+ ),
+ end: pointFrom<LocalPoint>(
+ segment.end[0] - startGlobalPoint[0],
+ segment.end[1] - startGlobalPoint[1],
+ ),
+ })),
+ startIsSpecial,
+ endIsSpecial,
+ );
+};
+
+const MAX_POS = 1e6;
+
+/**
+ *
+ */
+export const updateElbowArrowPoints = (
+ arrow: Readonly<ExcalidrawElbowArrowElement>,
+ elementsMap: NonDeletedSceneElementsMap,
+ updates: {
+ points?: readonly LocalPoint[];
+ fixedSegments?: FixedSegment[] | null;
+ startBinding?: FixedPointBinding | null;
+ endBinding?: FixedPointBinding | null;
+ },
+ options?: {
+ isDragging?: boolean;
+ },
+): ElementUpdate<ExcalidrawElbowArrowElement> => {
+ if (arrow.points.length < 2) {
+ return { points: updates.points ?? arrow.points };
+ }
+
+ // NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
+ // arrow size is valid. This check will be removed once the issue is identified
+ if (
+ arrow.x < -MAX_POS ||
+ arrow.x > MAX_POS ||
+ arrow.y < -MAX_POS ||
+ arrow.y > MAX_POS ||
+ arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
+ -MAX_POS ||
+ arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
+ MAX_POS ||
+ arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
+ -MAX_POS ||
+ arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
+ MAX_POS ||
+ arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
+ -MAX_POS ||
+ arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
+ MAX_POS ||
+ arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
+ -MAX_POS ||
+ arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
+ ) {
+ console.error(
+ "Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
+ {
+ arrow,
+ updates,
+ },
+ );
+ }
+ // @ts-ignore See above note
+ arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
+ // @ts-ignore See above note
+ arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
+ if (updates.points) {
+ updates.points = updates.points.map(([x, y]) =>
+ pointFrom<LocalPoint>(
+ clamp(x, -MAX_POS, MAX_POS),
+ clamp(y, -MAX_POS, MAX_POS),
+ ),
+ );
+ }
+
+ if (!import.meta.env.PROD) {
+ invariant(
+ !updates.points || updates.points.length >= 2,
+ "Updated point array length must match the arrow point length, contain " +
+ "exactly the new start and end points or not be specified at all (i.e. " +
+ "you can't add new points between start and end manually to elbow arrows)",
+ );
+
+ invariant(
+ !arrow.fixedSegments ||
+ arrow.fixedSegments
+ .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
+ .every(Boolean),
+ "Fixed segments must be either horizontal or vertical",
+ );
+
+ invariant(
+ !updates.fixedSegments ||
+ updates.fixedSegments
+ .map((s) => s.start[0] === s.end[0] || s.start[1] === s.end[1])
+ .every(Boolean),
+ "Updates to fixed segments must be either horizontal or vertical",
+ );
+
+ invariant(
+ arrow.points
+ .slice(1)
+ .map(
+ (p, i) => p[0] === arrow.points[i][0] || p[1] === arrow.points[i][1],
+ ),
+ "Elbow arrow segments must be either horizontal or vertical",
+ );
+ }
+
+ const updatedPoints: readonly LocalPoint[] = updates.points
+ ? updates.points && updates.points.length === 2
+ ? arrow.points.map((p, idx) =>
+ idx === 0
+ ? updates.points![0]
+ : idx === arrow.points.length - 1
+ ? updates.points![1]
+ : p,
+ )
+ : updates.points.slice()
+ : arrow.points.slice();
+
+ // 0. During all element replacement in the scene, we just need to renormalize
+ // the arrow
+ // TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
+ const startBinding =
+ typeof updates.startBinding !== "undefined"
+ ? updates.startBinding
+ : arrow.startBinding;
+ const endBinding =
+ typeof updates.endBinding !== "undefined"
+ ? updates.endBinding
+ : arrow.endBinding;
+ const startElement =
+ startBinding &&
+ getBindableElementForId(startBinding.elementId, elementsMap);
+ const endElement =
+ endBinding && getBindableElementForId(endBinding.elementId, elementsMap);
+ if (
+ (elementsMap.size === 0 && validateElbowPoints(updatedPoints)) ||
+ startElement?.id !== startBinding?.elementId ||
+ endElement?.id !== endBinding?.elementId
+ ) {
+ return normalizeArrowElementUpdate(
+ updatedPoints.map((p) =>
+ pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
+ ),
+ arrow.fixedSegments,
+ arrow.startIsSpecial,
+ arrow.endIsSpecial,
+ );
+ }
+
+ const {
+ startHeading,
+ endHeading,
+ startGlobalPoint,
+ endGlobalPoint,
+ hoveredStartElement,
+ hoveredEndElement,
+ ...rest
+ } = getElbowArrowData(
+ {
+ x: arrow.x,
+ y: arrow.y,
+ startBinding,
+ endBinding,
+ startArrowhead: arrow.startArrowhead,
+ endArrowhead: arrow.endArrowhead,
+ points: arrow.points,
+ },
+ elementsMap,
+ updatedPoints,
+ startElement,
+ endElement,
+ options,
+ );
+
+ const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
+
+ ////
+ // 1. Renormalize the arrow
+ ////
+ if (
+ !updates.points &&
+ !updates.fixedSegments &&
+ !updates.startBinding &&
+ !updates.endBinding
+ ) {
+ return handleSegmentRenormalization(arrow, elementsMap);
+ }
+
+ // Short circuit on no-op to avoid huge performance hit
+ if (
+ updates.startBinding === arrow.startBinding &&
+ updates.endBinding === arrow.endBinding &&
+ (updates.points ?? []).every((p, i) =>
+ pointsEqual(
+ p,
+ arrow.points[i] ?? pointFrom<LocalPoint>(Infinity, Infinity),
+ ),
+ )
+ ) {
+ return {};
+ }
+
+ ////
+ // 2. Just normal elbow arrow things
+ ////
+ if (fixedSegments.length === 0) {
+ return normalizeArrowElementUpdate(
+ getElbowArrowCornerPoints(
+ removeElbowArrowShortSegments(
+ routeElbowArrow(arrow, {
+ startHeading,
+ endHeading,
+ startGlobalPoint,
+ endGlobalPoint,
+ hoveredStartElement,
+ hoveredEndElement,
+ ...rest,
+ }) ?? [],
+ ),
+ ),
+ fixedSegments,
+ null,
+ null,
+ );
+ }
+
+ ////
+ // 3. Handle releasing a fixed segment
+ if ((arrow.fixedSegments?.length ?? 0) > fixedSegments.length) {
+ return handleSegmentRelease(arrow, fixedSegments, elementsMap);
+ }
+
+ ////
+ // 4. Handle manual segment move
+ ////
+ if (!updates.points) {
+ return handleSegmentMove(
+ arrow,
+ fixedSegments,
+ startHeading,
+ endHeading,
+ hoveredStartElement,
+ hoveredEndElement,
+ );
+ }
+
+ ////
+ // 5. Handle resize
+ ////
+ if (updates.points && updates.fixedSegments) {
+ return updates;
+ }
+
+ ////
+ // 6. One or more segments are fixed and endpoints are moved
+ //
+ // The key insights are:
+ // - When segments are fixed, the arrow will keep the exact amount of segments
+ // - Fixed segments are "replacements" for exactly one segment in the old arrow
+ ////
+ return handleEndpointDrag(
+ arrow,
+ updatedPoints,
+ fixedSegments,
+ startHeading,
+ endHeading,
+ startGlobalPoint,
+ endGlobalPoint,
+ hoveredStartElement,
+ hoveredEndElement,
+ );
+};
+
+/**
+ * Retrieves data necessary for calculating the elbow arrow path.
+ *
+ * @param arrow - The arrow object containing its properties.
+ * @param elementsMap - A map of elements in the scene.
+ * @param nextPoints - The next set of points for the arrow.
+ * @param options - Optional parameters for the calculation.
+ * @param options.isDragging - Indicates if the arrow is being dragged.
+ * @param options.startIsMidPoint - Indicates if the start point is a midpoint.
+ * @param options.endIsMidPoint - Indicates if the end point is a midpoint.
+ *
+ * @returns An object containing various properties needed for elbow arrow calculations:
+ * - dynamicAABBs: Dynamically generated axis-aligned bounding boxes.
+ * - startDonglePosition: The position of the start dongle.
+ * - startGlobalPoint: The global coordinates of the start point.
+ * - startHeading: The heading direction from the start point.
+ * - endDonglePosition: The position of the end dongle.
+ * - endGlobalPoint: The global coordinates of the end point.
+ * - endHeading: The heading direction from the end point.
+ * - commonBounds: The common bounding box that encompasses both start and end points.
+ * - hoveredStartElement: The element being hovered over at the start point.
+ * - hoveredEndElement: The element being hovered over at the end point.
+ */
+const getElbowArrowData = (
+ arrow: {
+ x: number;
+ y: number;
+ startBinding: FixedPointBinding | null;
+ endBinding: FixedPointBinding | null;
+ startArrowhead: Arrowhead | null;
+ endArrowhead: Arrowhead | null;
+ points: readonly LocalPoint[];
+ },
+ elementsMap: NonDeletedSceneElementsMap,
+ nextPoints: readonly LocalPoint[],
+ startElement: ExcalidrawBindableElement | null,
+ endElement: ExcalidrawBindableElement | null,
+ options?: {
+ isDragging?: boolean;
+ zoom?: AppState["zoom"];
+ },
+) => {
+ const origStartGlobalPoint: GlobalPoint = pointTranslate<
+ LocalPoint,
+ GlobalPoint
+ >(nextPoints[0], vector(arrow.x, arrow.y));
+ const origEndGlobalPoint: GlobalPoint = pointTranslate<
+ LocalPoint,
+ GlobalPoint
+ >(nextPoints[nextPoints.length - 1], vector(arrow.x, arrow.y));
+
+ let hoveredStartElement = startElement;
+ let hoveredEndElement = endElement;
+ if (options?.isDragging) {
+ const elements = Array.from(elementsMap.values());
+ hoveredStartElement =
+ getHoveredElement(
+ origStartGlobalPoint,
+ elementsMap,
+ elements,
+ options?.zoom,
+ ) || startElement;
+ hoveredEndElement =
+ getHoveredElement(
+ origEndGlobalPoint,
+ elementsMap,
+ elements,
+ options?.zoom,
+ ) || endElement;
+ }
+
+ const startGlobalPoint = getGlobalPoint(
+ {
+ ...arrow,
+ elbowed: true,
+ points: nextPoints,
+ } as ExcalidrawElbowArrowElement,
+ "start",
+ arrow.startBinding?.fixedPoint,
+ origStartGlobalPoint,
+ startElement,
+ hoveredStartElement,
+ options?.isDragging,
+ );
+ const endGlobalPoint = getGlobalPoint(
+ {
+ ...arrow,
+ elbowed: true,
+ points: nextPoints,
+ } as ExcalidrawElbowArrowElement,
+ "end",
+ arrow.endBinding?.fixedPoint,
+ origEndGlobalPoint,
+ endElement,
+ hoveredEndElement,
+ options?.isDragging,
+ );
+ const startHeading = getBindPointHeading(
+ startGlobalPoint,
+ endGlobalPoint,
+ elementsMap,
+ hoveredStartElement,
+ origStartGlobalPoint,
+ );
+ const endHeading = getBindPointHeading(
+ endGlobalPoint,
+ startGlobalPoint,
+ elementsMap,
+ hoveredEndElement,
+ origEndGlobalPoint,
+ );
+ const startPointBounds = [
+ startGlobalPoint[0] - 2,
+ startGlobalPoint[1] - 2,
+ startGlobalPoint[0] + 2,
+ startGlobalPoint[1] + 2,
+ ] as Bounds;
+ const endPointBounds = [
+ endGlobalPoint[0] - 2,
+ endGlobalPoint[1] - 2,
+ endGlobalPoint[0] + 2,
+ endGlobalPoint[1] + 2,
+ ] as Bounds;
+ const startElementBounds = hoveredStartElement
+ ? aabbForElement(
+ hoveredStartElement,
+ offsetFromHeading(
+ startHeading,
+ arrow.startArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2,
+ 1,
+ ),
+ )
+ : startPointBounds;
+ const endElementBounds = hoveredEndElement
+ ? aabbForElement(
+ hoveredEndElement,
+ offsetFromHeading(
+ endHeading,
+ arrow.endArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2,
+ 1,
+ ),
+ )
+ : endPointBounds;
+ const boundsOverlap =
+ pointInsideBounds(
+ startGlobalPoint,
+ hoveredEndElement
+ ? aabbForElement(
+ hoveredEndElement,
+ offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
+ )
+ : endPointBounds,
+ ) ||
+ pointInsideBounds(
+ endGlobalPoint,
+ hoveredStartElement
+ ? aabbForElement(
+ hoveredStartElement,
+ offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
+ )
+ : startPointBounds,
+ );
+ const commonBounds = commonAABB(
+ boundsOverlap
+ ? [startPointBounds, endPointBounds]
+ : [startElementBounds, endElementBounds],
+ );
+ const dynamicAABBs = generateDynamicAABBs(
+ boundsOverlap ? startPointBounds : startElementBounds,
+ boundsOverlap ? endPointBounds : endElementBounds,
+ commonBounds,
+ boundsOverlap
+ ? offsetFromHeading(
+ startHeading,
+ !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
+ 0,
+ )
+ : offsetFromHeading(
+ startHeading,
+ !hoveredStartElement && !hoveredEndElement
+ ? 0
+ : BASE_PADDING -
+ (arrow.startArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2),
+ BASE_PADDING,
+ ),
+ boundsOverlap
+ ? offsetFromHeading(
+ endHeading,
+ !hoveredStartElement && !hoveredEndElement ? 0 : BASE_PADDING,
+ 0,
+ )
+ : offsetFromHeading(
+ endHeading,
+ !hoveredStartElement && !hoveredEndElement
+ ? 0
+ : BASE_PADDING -
+ (arrow.endArrowhead
+ ? FIXED_BINDING_DISTANCE * 6
+ : FIXED_BINDING_DISTANCE * 2),
+ BASE_PADDING,
+ ),
+ boundsOverlap,
+ hoveredStartElement && aabbForElement(hoveredStartElement),
+ hoveredEndElement && aabbForElement(hoveredEndElement),
+ );
+ const startDonglePosition = getDonglePosition(
+ dynamicAABBs[0],
+ startHeading,
+ startGlobalPoint,
+ );
+ const endDonglePosition = getDonglePosition(
+ dynamicAABBs[1],
+ endHeading,
+ endGlobalPoint,
+ );
+
+ return {
+ dynamicAABBs,
+ startDonglePosition,
+ startGlobalPoint,
+ startHeading,
+ endDonglePosition,
+ endGlobalPoint,
+ endHeading,
+ commonBounds,
+ hoveredStartElement,
+ hoveredEndElement,
+ boundsOverlap,
+ startElementBounds,
+ endElementBounds,
+ };
+};
+
+/**
+ * Generate the elbow arrow segments
+ *
+ * @param arrow
+ * @param elementsMap
+ * @param nextPoints
+ * @param options
+ * @returns
+ */
+const routeElbowArrow = (
+ arrow: ElbowArrowState,
+ elbowArrowData: ElbowArrowData,
+): GlobalPoint[] | null => {
+ const {
+ dynamicAABBs,
+ startDonglePosition,
+ startGlobalPoint,
+ startHeading,
+ endDonglePosition,
+ endGlobalPoint,
+ endHeading,
+ commonBounds,
+ hoveredEndElement,
+ } = elbowArrowData;
+
+ // Canculate Grid positions
+ const grid = calculateGrid(
+ dynamicAABBs,
+ startDonglePosition ? startDonglePosition : startGlobalPoint,
+ startHeading,
+ endDonglePosition ? endDonglePosition : endGlobalPoint,
+ endHeading,
+ commonBounds,
+ );
+
+ const startDongle =
+ startDonglePosition && pointToGridNode(startDonglePosition, grid);
+ const endDongle =
+ endDonglePosition && pointToGridNode(endDonglePosition, grid);
+
+ // Do not allow stepping on the true end or true start points
+ const endNode = pointToGridNode(endGlobalPoint, grid);
+ if (endNode && hoveredEndElement) {
+ endNode.closed = true;
+ }
+ const startNode = pointToGridNode(startGlobalPoint, grid);
+ if (startNode && arrow.startBinding) {
+ startNode.closed = true;
+ }
+ const dongleOverlap =
+ startDongle &&
+ endDongle &&
+ (pointInsideBounds(startDongle.pos, dynamicAABBs[1]) ||
+ pointInsideBounds(endDongle.pos, dynamicAABBs[0]));
+
+ // Create path to end dongle from start dongle
+ const path = astar(
+ startDongle ? startDongle : startNode!,
+ endDongle ? endDongle : endNode!,
+ grid,
+ startHeading ? startHeading : HEADING_RIGHT,
+ endHeading ? endHeading : HEADING_RIGHT,
+ dongleOverlap ? [] : dynamicAABBs,
+ );
+
+ if (path) {
+ const points = path.map((node) => [
+ node.pos[0],
+ node.pos[1],
+ ]) as GlobalPoint[];
+ startDongle && points.unshift(startGlobalPoint);
+ endDongle && points.push(endGlobalPoint);
+
+ return points;
+ }
+
+ return null;
+};
+
+const offsetFromHeading = (
+ heading: Heading,
+ head: number,
+ side: number,
+): [number, number, number, number] => {
+ switch (heading) {
+ case HEADING_UP:
+ return [head, side, side, side];
+ case HEADING_RIGHT:
+ return [side, head, side, side];
+ case HEADING_DOWN:
+ return [side, side, head, side];
+ }
+
+ return [side, side, side, head];
+};
+
+/**
+ * Routing algorithm based on the A* path search algorithm.
+ * @see https://www.geeksforgeeks.org/a-search-algorithm/
+ *
+ * Binary heap is used to optimize node lookup.
+ * See {@link calculateGrid} for the grid calculation details.
+ *
+ * Additional modifications added due to aesthetic route reasons:
+ * 1) Arrow segment direction change is penalized by specific linear constant (bendMultiplier)
+ * 2) Arrow segments are not allowed to go "backwards", overlapping with the previous segment
+ */
+const astar = (
+ start: Node,
+ end: Node,
+ grid: Grid,
+ startHeading: Heading,
+ endHeading: Heading,
+ aabbs: Bounds[],
+) => {
+ const bendMultiplier = m_dist(start.pos, end.pos);
+ const open = new BinaryHeap<Node>((node) => node.f);
+
+ open.push(start);
+
+ while (open.size() > 0) {
+ // Grab the lowest f(x) to process next. Heap keeps this sorted for us.
+ const current = open.pop();
+
+ if (!current || current.closed) {
+ // Current is not passable, continue with next element
+ continue;
+ }
+
+ // End case -- result has been found, return the traced path.
+ if (current === end) {
+ return pathTo(start, current);
+ }
+
+ // Normal case -- move current from open to closed, process each of its neighbors.
+ current.closed = true;
+
+ // Find all neighbors for the current node.
+ const neighbors = getNeighbors(current.addr, grid);
+
+ for (let i = 0; i < 4; i++) {
+ const neighbor = neighbors[i];
+
+ if (!neighbor || neighbor.closed) {
+ // Not a valid node to process, skip to next neighbor.
+ continue;
+ }
+
+ // Intersect
+ const neighborHalfPoint = pointScaleFromOrigin(
+ neighbor.pos,
+ current.pos,
+ 0.5,
+ );
+ if (
+ isAnyTrue(
+ ...aabbs.map((aabb) => pointInsideBounds(neighborHalfPoint, aabb)),
+ )
+ ) {
+ continue;
+ }
+
+ // The g score is the shortest distance from start to current node.
+ // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
+ const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
+ const previousDirection = current.parent
+ ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
+ : startHeading;
+
+ // Do not allow going in reverse
+ const reverseHeading = flipHeading(previousDirection);
+ const neighborIsReverseRoute =
+ compareHeading(reverseHeading, neighborHeading) ||
+ (gridAddressesEqual(start.addr, neighbor.addr) &&
+ compareHeading(neighborHeading, startHeading)) ||
+ (gridAddressesEqual(end.addr, neighbor.addr) &&
+ compareHeading(neighborHeading, endHeading));
+ if (neighborIsReverseRoute) {
+ continue;
+ }
+
+ const directionChange = previousDirection !== neighborHeading;
+ const gScore =
+ current.g +
+ m_dist(neighbor.pos, current.pos) +
+ (directionChange ? Math.pow(bendMultiplier, 3) : 0);
+
+ const beenVisited = neighbor.visited;
+
+ if (!beenVisited || gScore < neighbor.g) {
+ const estBendCount = estimateSegmentCount(
+ neighbor,
+ end,
+ neighborHeading,
+ endHeading,
+ );
+ // Found an optimal (so far) path to this node. Take score for node to see how good it is.
+ neighbor.visited = true;
+ neighbor.parent = current;
+ neighbor.h =
+ m_dist(end.pos, neighbor.pos) +
+ estBendCount * Math.pow(bendMultiplier, 2);
+ neighbor.g = gScore;
+ neighbor.f = neighbor.g + neighbor.h;
+ if (!beenVisited) {
+ // Pushing to heap will put it in proper place based on the 'f' value.
+ open.push(neighbor);
+ } else {
+ // Already seen the node, but since it has been rescored we need to reorder it in the heap
+ open.rescoreElement(neighbor);
+ }
+ }
+ }
+ }
+
+ return null;
+};
+
+const pathTo = (start: Node, node: Node) => {
+ let curr = node;
+ const path = [];
+ while (curr.parent) {
+ path.unshift(curr);
+ curr = curr.parent;
+ }
+ path.unshift(start);
+
+ return path;
+};
+
+const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
+ Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
+
+/**
+ * Create dynamically resizing, always touching
+ * bounding boxes having a minimum extent represented
+ * by the given static bounds.
+ */
+const generateDynamicAABBs = (
+ a: Bounds,
+ b: Bounds,
+ common: Bounds,
+ startDifference?: [number, number, number, number],
+ endDifference?: [number, number, number, number],
+ disableSideHack?: boolean,
+ startElementBounds?: Bounds | null,
+ endElementBounds?: Bounds | null,
+): Bounds[] => {
+ const startEl = startElementBounds ?? a;
+ const endEl = endElementBounds ?? b;
+ const [startUp, startRight, startDown, startLeft] = startDifference ?? [
+ 0, 0, 0, 0,
+ ];
+ const [endUp, endRight, endDown, endLeft] = endDifference ?? [0, 0, 0, 0];
+
+ const first = [
+ a[0] > b[2]
+ ? a[1] > b[3] || a[3] < b[1]
+ ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
+ : (startEl[0] + endEl[2]) / 2
+ : a[0] > b[0]
+ ? a[0] - startLeft
+ : common[0] - startLeft,
+ a[1] > b[3]
+ ? a[0] > b[2] || a[2] < b[0]
+ ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
+ : (startEl[1] + endEl[3]) / 2
+ : a[1] > b[1]
+ ? a[1] - startUp
+ : common[1] - startUp,
+ a[2] < b[0]
+ ? a[1] > b[3] || a[3] < b[1]
+ ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
+ : (startEl[2] + endEl[0]) / 2
+ : a[2] < b[2]
+ ? a[2] + startRight
+ : common[2] + startRight,
+ a[3] < b[1]
+ ? a[0] > b[2] || a[2] < b[0]
+ ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
+ : (startEl[3] + endEl[1]) / 2
+ : a[3] < b[3]
+ ? a[3] + startDown
+ : common[3] + startDown,
+ ] as Bounds;
+ const second = [
+ b[0] > a[2]
+ ? b[1] > a[3] || b[3] < a[1]
+ ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
+ : (endEl[0] + startEl[2]) / 2
+ : b[0] > a[0]
+ ? b[0] - endLeft
+ : common[0] - endLeft,
+ b[1] > a[3]
+ ? b[0] > a[2] || b[2] < a[0]
+ ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
+ : (endEl[1] + startEl[3]) / 2
+ : b[1] > a[1]
+ ? b[1] - endUp
+ : common[1] - endUp,
+ b[2] < a[0]
+ ? b[1] > a[3] || b[3] < a[1]
+ ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
+ : (endEl[2] + startEl[0]) / 2
+ : b[2] < a[2]
+ ? b[2] + endRight
+ : common[2] + endRight,
+ b[3] < a[1]
+ ? b[0] > a[2] || b[2] < a[0]
+ ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
+ : (endEl[3] + startEl[1]) / 2
+ : b[3] < a[3]
+ ? b[3] + endDown
+ : common[3] + endDown,
+ ] as Bounds;
+
+ const c = commonAABB([first, second]);
+ if (
+ !disableSideHack &&
+ first[2] - first[0] + second[2] - second[0] > c[2] - c[0] + 0.00000000001 &&
+ first[3] - first[1] + second[3] - second[1] > c[3] - c[1] + 0.00000000001
+ ) {
+ const [endCenterX, endCenterY] = [
+ (second[0] + second[2]) / 2,
+ (second[1] + second[3]) / 2,
+ ];
+ if (b[0] > a[2] && a[1] > b[3]) {
+ // BOTTOM LEFT
+ const cX = first[2] + (second[0] - first[2]) / 2;
+ const cY = second[3] + (first[1] - second[3]) / 2;
+
+ if (
+ vectorCross(
+ vector(a[2] - endCenterX, a[1] - endCenterY),
+ vector(a[0] - endCenterX, a[3] - endCenterY),
+ ) > 0
+ ) {
+ return [
+ [first[0], first[1], cX, first[3]],
+ [cX, second[1], second[2], second[3]],
+ ];
+ }
+
+ return [
+ [first[0], cY, first[2], first[3]],
+ [second[0], second[1], second[2], cY],
+ ];
+ } else if (a[2] < b[0] && a[3] < b[1]) {
+ // TOP LEFT
+ const cX = first[2] + (second[0] - first[2]) / 2;
+ const cY = first[3] + (second[1] - first[3]) / 2;
+
+ if (
+ vectorCross(
+ vector(a[0] - endCenterX, a[1] - endCenterY),
+ vector(a[2] - endCenterX, a[3] - endCenterY),
+ ) > 0
+ ) {
+ return [
+ [first[0], first[1], first[2], cY],
+ [second[0], cY, second[2], second[3]],
+ ];
+ }
+
+ return [
+ [first[0], first[1], cX, first[3]],
+ [cX, second[1], second[2], second[3]],
+ ];
+ } else if (a[0] > b[2] && a[3] < b[1]) {
+ // TOP RIGHT
+ const cX = second[2] + (first[0] - second[2]) / 2;
+ const cY = first[3] + (second[1] - first[3]) / 2;
+
+ if (
+ vectorCross(
+ vector(a[2] - endCenterX, a[1] - endCenterY),
+ vector(a[0] - endCenterX, a[3] - endCenterY),
+ ) > 0
+ ) {
+ return [
+ [cX, first[1], first[2], first[3]],
+ [second[0], second[1], cX, second[3]],
+ ];
+ }
+
+ return [
+ [first[0], first[1], first[2], cY],
+ [second[0], cY, second[2], second[3]],
+ ];
+ } else if (a[0] > b[2] && a[1] > b[3]) {
+ // BOTTOM RIGHT
+ const cX = second[2] + (first[0] - second[2]) / 2;
+ const cY = second[3] + (first[1] - second[3]) / 2;
+
+ if (
+ vectorCross(
+ vector(a[0] - endCenterX, a[1] - endCenterY),
+ vector(a[2] - endCenterX, a[3] - endCenterY),
+ ) > 0
+ ) {
+ return [
+ [cX, first[1], first[2], first[3]],
+ [second[0], second[1], cX, second[3]],
+ ];
+ }
+
+ return [
+ [first[0], cY, first[2], first[3]],
+ [second[0], second[1], second[2], cY],
+ ];
+ }
+ }
+
+ return [first, second];
+};
+
+/**
+ * Calculates the grid which is used as nodes at
+ * the grid line intersections by the A* algorithm.
+ *
+ * NOTE: This is not a uniform grid. It is built at
+ * various intersections of bounding boxes.
+ */
+const calculateGrid = (
+ aabbs: Bounds[],
+ start: GlobalPoint,
+ startHeading: Heading,
+ end: GlobalPoint,
+ endHeading: Heading,
+ common: Bounds,
+): Grid => {
+ const horizontal = new Set<number>();
+ const vertical = new Set<number>();
+
+ if (startHeading === HEADING_LEFT || startHeading === HEADING_RIGHT) {
+ vertical.add(start[1]);
+ } else {
+ horizontal.add(start[0]);
+ }
+ if (endHeading === HEADING_LEFT || endHeading === HEADING_RIGHT) {
+ vertical.add(end[1]);
+ } else {
+ horizontal.add(end[0]);
+ }
+
+ aabbs.forEach((aabb) => {
+ horizontal.add(aabb[0]);
+ horizontal.add(aabb[2]);
+ vertical.add(aabb[1]);
+ vertical.add(aabb[3]);
+ });
+
+ horizontal.add(common[0]);
+ horizontal.add(common[2]);
+ vertical.add(common[1]);
+ vertical.add(common[3]);
+
+ const _vertical = Array.from(vertical).sort((a, b) => a - b);
+ const _horizontal = Array.from(horizontal).sort((a, b) => a - b);
+
+ return {
+ row: _vertical.length,
+ col: _horizontal.length,
+ data: _vertical.flatMap((y, row) =>
+ _horizontal.map(
+ (x, col): Node => ({
+ f: 0,
+ g: 0,
+ h: 0,
+ closed: false,
+ visited: false,
+ parent: null,
+ addr: [col, row] as GridAddress,
+ pos: [x, y] as GlobalPoint,
+ }),
+ ),
+ ),
+ };
+};
+
+const getDonglePosition = (
+ bounds: Bounds,
+ heading: Heading,
+ p: GlobalPoint,
+): GlobalPoint => {
+ switch (heading) {
+ case HEADING_UP:
+ return pointFrom(p[0], bounds[1]);
+ case HEADING_RIGHT:
+ return pointFrom(bounds[2], p[1]);
+ case HEADING_DOWN:
+ return pointFrom(p[0], bounds[3]);
+ }
+ return pointFrom(bounds[0], p[1]);
+};
+
+const estimateSegmentCount = (
+ start: Node,
+ end: Node,
+ startHeading: Heading,
+ endHeading: Heading,
+) => {
+ if (endHeading === HEADING_RIGHT) {
+ switch (startHeading) {
+ case HEADING_RIGHT: {
+ if (start.pos[0] >= end.pos[0]) {
+ return 4;
+ }
+ if (start.pos[1] === end.pos[1]) {
+ return 0;
+ }
+ return 2;
+ }
+ case HEADING_UP:
+ if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_DOWN:
+ if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_LEFT:
+ if (start.pos[1] === end.pos[1]) {
+ return 4;
+ }
+ return 2;
+ }
+ } else if (endHeading === HEADING_LEFT) {
+ switch (startHeading) {
+ case HEADING_RIGHT:
+ if (start.pos[1] === end.pos[1]) {
+ return 4;
+ }
+ return 2;
+ case HEADING_UP:
+ if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_DOWN:
+ if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_LEFT:
+ if (start.pos[0] <= end.pos[0]) {
+ return 4;
+ }
+ if (start.pos[1] === end.pos[1]) {
+ return 0;
+ }
+ return 2;
+ }
+ } else if (endHeading === HEADING_UP) {
+ switch (startHeading) {
+ case HEADING_RIGHT:
+ if (start.pos[1] > end.pos[1] && start.pos[0] < end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_UP:
+ if (start.pos[1] >= end.pos[1]) {
+ return 4;
+ }
+ if (start.pos[0] === end.pos[0]) {
+ return 0;
+ }
+ return 2;
+ case HEADING_DOWN:
+ if (start.pos[0] === end.pos[0]) {
+ return 4;
+ }
+ return 2;
+ case HEADING_LEFT:
+ if (start.pos[1] > end.pos[1] && start.pos[0] > end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ }
+ } else if (endHeading === HEADING_DOWN) {
+ switch (startHeading) {
+ case HEADING_RIGHT:
+ if (start.pos[1] < end.pos[1] && start.pos[0] < end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ case HEADING_UP:
+ if (start.pos[0] === end.pos[0]) {
+ return 4;
+ }
+ return 2;
+ case HEADING_DOWN:
+ if (start.pos[1] <= end.pos[1]) {
+ return 4;
+ }
+ if (start.pos[0] === end.pos[0]) {
+ return 0;
+ }
+ return 2;
+ case HEADING_LEFT:
+ if (start.pos[1] < end.pos[1] && start.pos[0] > end.pos[0]) {
+ return 1;
+ }
+ return 3;
+ }
+ }
+ return 0;
+};
+
+/**
+ * Get neighboring points for a gived grid address
+ */
+const getNeighbors = ([col, row]: [number, number], grid: Grid) =>
+ [
+ gridNodeFromAddr([col, row - 1], grid),
+ gridNodeFromAddr([col + 1, row], grid),
+ gridNodeFromAddr([col, row + 1], grid),
+ gridNodeFromAddr([col - 1, row], grid),
+ ] as [Node | null, Node | null, Node | null, Node | null];
+
+const gridNodeFromAddr = (
+ [col, row]: [col: number, row: number],
+ grid: Grid,
+): Node | null => {
+ if (col < 0 || col >= grid.col || row < 0 || row >= grid.row) {
+ return null;
+ }
+
+ return grid.data[row * grid.col + col] ?? null;
+};
+
+/**
+ * Get node for global point on canvas (if exists)
+ */
+const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
+ for (let col = 0; col < grid.col; col++) {
+ for (let row = 0; row < grid.row; row++) {
+ const candidate = gridNodeFromAddr([col, row], grid);
+ if (
+ candidate &&
+ point[0] === candidate.pos[0] &&
+ point[1] === candidate.pos[1]
+ ) {
+ return candidate;
+ }
+ }
+ }
+
+ return null;
+};
+
+const commonAABB = (aabbs: Bounds[]): Bounds => [
+ Math.min(...aabbs.map((aabb) => aabb[0])),
+ Math.min(...aabbs.map((aabb) => aabb[1])),
+ Math.max(...aabbs.map((aabb) => aabb[2])),
+ Math.max(...aabbs.map((aabb) => aabb[3])),
+];
+
+/// #region Utils
+
+const getBindableElementForId = (
+ id: string,
+ elementsMap: ElementsMap,
+): ExcalidrawBindableElement | null => {
+ const element = elementsMap.get(id);
+ if (element && isBindableElement(element)) {
+ return element;
+ }
+
+ return null;
+};
+
+const normalizeArrowElementUpdate = (
+ global: GlobalPoint[],
+ nextFixedSegments: readonly FixedSegment[] | null,
+ startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
+ endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
+): {
+ points: LocalPoint[];
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ fixedSegments: readonly FixedSegment[] | null;
+ startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
+ endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
+} => {
+ const offsetX = global[0][0];
+ const offsetY = global[0][1];
+ let points = global.map((p) =>
+ pointTranslate<GlobalPoint, LocalPoint>(
+ p,
+ vectorScale(vectorFromPoint(global[0]), -1),
+ ),
+ );
+
+ // NOTE (mtolmacs): This is a temporary check to see if the normalization
+ // creates an overly large arrow. This should be removed once we have an answer.
+ if (
+ offsetX < -MAX_POS ||
+ offsetX > MAX_POS ||
+ offsetY < -MAX_POS ||
+ offsetY > MAX_POS ||
+ offsetX + points[points.length - 1][0] < -MAX_POS ||
+ offsetY + points[points.length - 1][0] > MAX_POS ||
+ offsetX + points[points.length - 1][1] < -MAX_POS ||
+ offsetY + points[points.length - 1][1] > MAX_POS
+ ) {
+ console.error(
+ "Elbow arrow normalization is outside reasonable bounds (> 1e6)",
+ {
+ x: offsetX,
+ y: offsetY,
+ points,
+ ...getSizeFromPoints(points),
+ },
+ );
+ }
+
+ points = points.map(([x, y]) =>
+ pointFrom<LocalPoint>(clamp(x, -1e6, 1e6), clamp(y, -1e6, 1e6)),
+ );
+
+ return {
+ points,
+ x: clamp(offsetX, -1e6, 1e6),
+ y: clamp(offsetY, -1e6, 1e6),
+ fixedSegments:
+ (nextFixedSegments?.length ?? 0) > 0 ? nextFixedSegments : null,
+ ...getSizeFromPoints(points),
+ startIsSpecial,
+ endIsSpecial,
+ };
+};
+
+const getElbowArrowCornerPoints = (points: GlobalPoint[]): GlobalPoint[] => {
+ if (points.length > 1) {
+ let previousHorizontal =
+ Math.abs(points[0][1] - points[1][1]) <
+ Math.abs(points[0][0] - points[1][0]);
+
+ return points.filter((p, idx) => {
+ // The very first and last points are always kept
+ if (idx === 0 || idx === points.length - 1) {
+ return true;
+ }
+
+ const next = points[idx + 1];
+ const nextHorizontal =
+ Math.abs(p[1] - next[1]) < Math.abs(p[0] - next[0]);
+ if (previousHorizontal === nextHorizontal) {
+ previousHorizontal = nextHorizontal;
+ return false;
+ }
+
+ previousHorizontal = nextHorizontal;
+ return true;
+ });
+ }
+
+ return points;
+};
+
+const removeElbowArrowShortSegments = (
+ points: GlobalPoint[],
+): GlobalPoint[] => {
+ if (points.length >= 4) {
+ return points.filter((p, idx) => {
+ if (idx === 0 || idx === points.length - 1) {
+ return true;
+ }
+
+ const prev = points[idx - 1];
+ const prevDist = pointDistance(prev, p);
+ return prevDist > DEDUP_TRESHOLD;
+ });
+ }
+
+ return points;
+};
+
+const neighborIndexToHeading = (idx: number): Heading => {
+ switch (idx) {
+ case 0:
+ return HEADING_UP;
+ case 1:
+ return HEADING_RIGHT;
+ case 2:
+ return HEADING_DOWN;
+ }
+ return HEADING_LEFT;
+};
+
+const getGlobalPoint = (
+ arrow: ExcalidrawElbowArrowElement,
+ startOrEnd: "start" | "end",
+ fixedPointRatio: [number, number] | undefined | null,
+ initialPoint: GlobalPoint,
+ boundElement?: ExcalidrawBindableElement | null,
+ hoveredElement?: ExcalidrawBindableElement | null,
+ isDragging?: boolean,
+): GlobalPoint => {
+ if (isDragging) {
+ if (hoveredElement) {
+ const snapPoint = bindPointToSnapToElementOutline(
+ arrow,
+ hoveredElement,
+ startOrEnd,
+ );
+
+ return snapToMid(hoveredElement, snapPoint);
+ }
+
+ return initialPoint;
+ }
+
+ if (boundElement) {
+ const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
+ fixedPointRatio || [0, 0],
+ boundElement,
+ );
+
+ // NOTE: Resize scales the binding position point too, so we need to update it
+ return Math.abs(
+ distanceToBindableElement(boundElement, fixedGlobalPoint) -
+ FIXED_BINDING_DISTANCE,
+ ) > 0.01
+ ? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd)
+ : fixedGlobalPoint;
+ }
+
+ return initialPoint;
+};
+
+const getBindPointHeading = (
+ p: GlobalPoint,
+ otherPoint: GlobalPoint,
+ elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+ hoveredElement: ExcalidrawBindableElement | null | undefined,
+ origPoint: GlobalPoint,
+): Heading =>
+ getHeadingForElbowArrowSnap(
+ p,
+ otherPoint,
+ hoveredElement,
+ hoveredElement &&
+ aabbForElement(
+ hoveredElement,
+ Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
+ number,
+ number,
+ number,
+ number,
+ ],
+ ),
+ elementsMap,
+ origPoint,
+ );
+
+const getHoveredElement = (
+ origPoint: GlobalPoint,
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ zoom?: AppState["zoom"],
+) => {
+ return getHoveredElementForBinding(
+ tupleToCoors(origPoint),
+ elements,
+ elementsMap,
+ zoom,
+ true,
+ true,
+ );
+};
+
+const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
+ a[0] === b[0] && a[1] === b[1];
+
+export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
+ points: readonly P[],
+ tolerance: number = DEDUP_TRESHOLD,
+) =>
+ points
+ .slice(1)
+ .map(
+ (p, i) =>
+ Math.abs(p[0] - points[i][0]) < tolerance ||
+ Math.abs(p[1] - points[i][1]) < tolerance,
+ )
+ .every(Boolean);
diff --git a/packages/excalidraw/element/elementLink.ts b/packages/excalidraw/element/elementLink.ts
new file mode 100644
index 0000000..991f9ca
--- /dev/null
+++ b/packages/excalidraw/element/elementLink.ts
@@ -0,0 +1,102 @@
+/**
+ * Create and link between shapes.
+ */
+
+import { ELEMENT_LINK_KEY } from "../constants";
+import { normalizeLink } from "../data/url";
+import { elementsAreInSameGroup } from "../groups";
+import type { AppProps, AppState } from "../types";
+import type { ExcalidrawElement } from "./types";
+
+export const defaultGetElementLinkFromSelection: Exclude<
+ AppProps["generateLinkForSelection"],
+ undefined
+> = (id, type) => {
+ const url = window.location.href;
+
+ try {
+ const link = new URL(url);
+ link.searchParams.set(ELEMENT_LINK_KEY, id);
+
+ return normalizeLink(link.toString());
+ } catch (error) {
+ console.error(error);
+ }
+
+ return normalizeLink(url);
+};
+
+export const getLinkIdAndTypeFromSelection = (
+ selectedElements: ExcalidrawElement[],
+ appState: AppState,
+): {
+ id: string;
+ type: "element" | "group";
+} | null => {
+ if (
+ selectedElements.length > 0 &&
+ canCreateLinkFromElements(selectedElements)
+ ) {
+ if (selectedElements.length === 1) {
+ return {
+ id: selectedElements[0].id,
+ type: "element",
+ };
+ }
+
+ if (selectedElements.length > 1) {
+ const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
+
+ if (selectedGroupId) {
+ return {
+ id: selectedGroupId,
+ type: "group",
+ };
+ }
+ return {
+ id: selectedElements[0].groupIds[0],
+ type: "group",
+ };
+ }
+ }
+
+ return null;
+};
+
+export const canCreateLinkFromElements = (
+ selectedElements: ExcalidrawElement[],
+) => {
+ if (selectedElements.length === 1) {
+ return true;
+ }
+
+ if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
+ return true;
+ }
+
+ return false;
+};
+
+export const isElementLink = (url: string) => {
+ try {
+ const _url = new URL(url);
+ return (
+ _url.searchParams.has(ELEMENT_LINK_KEY) &&
+ _url.host === window.location.host
+ );
+ } catch (error) {
+ return false;
+ }
+};
+
+export const parseElementLinkFromURL = (url: string) => {
+ try {
+ const { searchParams } = new URL(url);
+ if (searchParams.has(ELEMENT_LINK_KEY)) {
+ const id = searchParams.get(ELEMENT_LINK_KEY);
+ return id;
+ }
+ } catch {}
+
+ return null;
+};
diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts
new file mode 100644
index 0000000..8265a0b
--- /dev/null
+++ b/packages/excalidraw/element/embeddable.ts
@@ -0,0 +1,444 @@
+import { register } from "../actions/register";
+import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
+import type { ExcalidrawProps } from "../types";
+import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
+import { setCursorForShape } from "../cursor";
+import { newTextElement } from "./newElement";
+import { wrapText } from "./textWrapping";
+import { isIframeElement } from "./typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawIframeLikeElement,
+ IframeData,
+} from "./types";
+import type { MarkRequired } from "../utility-types";
+import { CaptureUpdateAction } from "../store";
+
+type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
+
+const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
+
+const RE_YOUTUBE =
+ /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
+
+const RE_VIMEO =
+ /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
+const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
+
+const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
+const RE_GH_GIST_EMBED =
+ /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
+
+// not anchored to start to allow <blockquote> twitter embeds
+const RE_TWITTER =
+ /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
+const RE_TWITTER_EMBED =
+ /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
+
+const RE_VALTOWN =
+ /^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
+
+const RE_GENERIC_EMBED =
+ /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
+
+const RE_GIPHY =
+ /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
+
+const RE_REDDIT =
+ /^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
+
+const RE_REDDIT_EMBED =
+ /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
+
+const ALLOWED_DOMAINS = new Set([
+ "youtube.com",
+ "youtu.be",
+ "vimeo.com",
+ "player.vimeo.com",
+ "figma.com",
+ "link.excalidraw.com",
+ "gist.github.com",
+ "twitter.com",
+ "x.com",
+ "*.simplepdf.eu",
+ "stackblitz.com",
+ "val.town",
+ "giphy.com",
+ "reddit.com",
+]);
+
+const ALLOW_SAME_ORIGIN = new Set([
+ "youtube.com",
+ "youtu.be",
+ "vimeo.com",
+ "player.vimeo.com",
+ "figma.com",
+ "twitter.com",
+ "x.com",
+ "*.simplepdf.eu",
+ "stackblitz.com",
+ "reddit.com",
+]);
+
+export const createSrcDoc = (body: string) => {
+ return `<html><body>${body}</body></html>`;
+};
+
+export const getEmbedLink = (
+ link: string | null | undefined,
+): IframeDataWithSandbox | null => {
+ if (!link) {
+ return null;
+ }
+
+ if (embeddedLinkCache.has(link)) {
+ return embeddedLinkCache.get(link)!;
+ }
+
+ const originalLink = link;
+
+ const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
+ matchHostname(link, ALLOW_SAME_ORIGIN) || "",
+ );
+
+ let type: "video" | "generic" = "generic";
+ let aspectRatio = { w: 560, h: 840 };
+ const ytLink = link.match(RE_YOUTUBE);
+ if (ytLink?.[2]) {
+ const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
+ const isPortrait = link.includes("shorts");
+ type = "video";
+ switch (ytLink[1]) {
+ case "embed/":
+ case "watch?v=":
+ case "shorts/":
+ link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+ break;
+ case "playlist?list=":
+ case "embed/videoseries?list=":
+ link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
+ break;
+ default:
+ link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
+ break;
+ }
+ aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const vimeoLink = link.match(RE_VIMEO);
+ if (vimeoLink?.[1]) {
+ const target = vimeoLink?.[1];
+ const error = !/^\d+$/.test(target)
+ ? new URIError("Invalid embed link format")
+ : undefined;
+ type = "video";
+ link = `https://player.vimeo.com/video/${target}?api=1`;
+ aspectRatio = { w: 560, h: 315 };
+ //warning deliberately ommited so it is displayed only once per link
+ //same link next time will be served from cache
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ error,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const figmaLink = link.match(RE_FIGMA);
+ if (figmaLink) {
+ type = "generic";
+ link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
+ link,
+ )}`;
+ aspectRatio = { w: 550, h: 550 };
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ const valLink = link.match(RE_VALTOWN);
+ if (valLink) {
+ link =
+ valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
+ if (RE_TWITTER.test(link)) {
+ const postId = link.match(RE_TWITTER)![1];
+ // the embed srcdoc still supports twitter.com domain only.
+ // Note that we don't attempt to parse the username as it can consist of
+ // non-latin1 characters, and the username in the url can be set to anything
+ // without affecting the embed.
+ const safeURL = escapeDoubleQuotes(
+ `https://twitter.com/x/status/${postId}`,
+ );
+
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: (theme: string) =>
+ createSrcDoc(
+ `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
+ ),
+ intrinsicSize: { w: 480, h: 480 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(originalLink, ret);
+ return ret;
+ }
+
+ if (RE_REDDIT.test(link)) {
+ const [, page, postId, title] = link.match(RE_REDDIT)!;
+ const safeURL = escapeDoubleQuotes(
+ `https://reddit.com/r/${page}/comments/${postId}/${title}`,
+ );
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: (theme: string) =>
+ createSrcDoc(
+ `<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
+ ),
+ intrinsicSize: { w: 480, h: 480 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(originalLink, ret);
+ return ret;
+ }
+
+ if (RE_GH_GIST.test(link)) {
+ const [, user, gistId] = link.match(RE_GH_GIST)!;
+ const safeURL = escapeDoubleQuotes(
+ `https://gist.github.com/${user}/${gistId}`,
+ );
+ const ret: IframeDataWithSandbox = {
+ type: "document",
+ srcdoc: () =>
+ createSrcDoc(`
+ <script src="${safeURL}.js"></script>
+ <style type="text/css">
+ * { margin: 0px; }
+ table, .gist { height: 100%; }
+ .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
+ </style>
+ `),
+ intrinsicSize: { w: 550, h: 720 },
+ sandbox: { allowSameOrigin },
+ };
+ embeddedLinkCache.set(link, ret);
+ return ret;
+ }
+
+ embeddedLinkCache.set(link, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+};
+
+export const createPlaceholderEmbeddableLabel = (
+ element: ExcalidrawIframeLikeElement,
+): ExcalidrawElement => {
+ let text: string;
+ if (isIframeElement(element)) {
+ text = "IFrame element";
+ } else {
+ text =
+ !element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
+ }
+
+ const fontSize = Math.max(
+ Math.min(element.width / 2, element.width / text.length),
+ element.width / 30,
+ );
+ const fontFamily = FONT_FAMILY.Helvetica;
+
+ const fontString = getFontString({
+ fontSize,
+ fontFamily,
+ });
+
+ return newTextElement({
+ x: element.x + element.width / 2,
+ y: element.y + element.height / 2,
+ strokeColor:
+ element.strokeColor !== "transparent" ? element.strokeColor : "black",
+ backgroundColor: "transparent",
+ fontFamily,
+ fontSize,
+ text: wrapText(text, fontString, element.width - 20),
+ textAlign: "center",
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ angle: element.angle ?? 0,
+ });
+};
+
+export const actionSetEmbeddableAsActiveTool = register({
+ name: "setEmbeddableAsActiveTool",
+ trackEvent: { category: "toolbar" },
+ target: "Tool",
+ label: "toolBar.embeddable",
+ perform: (elements, appState, _, app) => {
+ const nextActiveTool = updateActiveTool(appState, {
+ type: "embeddable",
+ });
+
+ setCursorForShape(app.canvas, {
+ ...appState,
+ activeTool: nextActiveTool,
+ });
+
+ return {
+ elements,
+ appState: {
+ ...appState,
+ activeTool: updateActiveTool(appState, {
+ type: "embeddable",
+ }),
+ },
+ captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ };
+ },
+});
+
+const matchHostname = (
+ url: string,
+ /** using a Set assumes it already contains normalized bare domains */
+ allowedHostnames: Set<string> | string,
+): string | null => {
+ try {
+ const { hostname } = new URL(url);
+
+ const bareDomain = hostname.replace(/^www\./, "");
+
+ if (allowedHostnames instanceof Set) {
+ if (ALLOWED_DOMAINS.has(bareDomain)) {
+ return bareDomain;
+ }
+
+ const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
+ /^([^.]+)/,
+ "*",
+ );
+ if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
+ return bareDomainWithFirstSubdomainWildcarded;
+ }
+ return null;
+ }
+
+ const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
+ if (bareDomain === bareAllowedHostname) {
+ return bareAllowedHostname;
+ }
+ } catch (error) {
+ // ignore
+ }
+ return null;
+};
+
+export const maybeParseEmbedSrc = (str: string): string => {
+ const twitterMatch = str.match(RE_TWITTER_EMBED);
+ if (twitterMatch && twitterMatch.length === 2) {
+ return twitterMatch[1];
+ }
+
+ const redditMatch = str.match(RE_REDDIT_EMBED);
+ if (redditMatch && redditMatch.length === 2) {
+ return redditMatch[1];
+ }
+
+ const gistMatch = str.match(RE_GH_GIST_EMBED);
+ if (gistMatch && gistMatch.length === 2) {
+ return gistMatch[1];
+ }
+
+ if (RE_GIPHY.test(str)) {
+ return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
+ }
+
+ const match = str.match(RE_GENERIC_EMBED);
+ if (match && match.length === 2) {
+ return match[1];
+ }
+
+ return str;
+};
+
+export const embeddableURLValidator = (
+ url: string | null | undefined,
+ validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
+): boolean => {
+ if (!url) {
+ return false;
+ }
+ if (validateEmbeddable != null) {
+ if (typeof validateEmbeddable === "function") {
+ const ret = validateEmbeddable(url);
+ // if return value is undefined, leave validation to default
+ if (typeof ret === "boolean") {
+ return ret;
+ }
+ } else if (typeof validateEmbeddable === "boolean") {
+ return validateEmbeddable;
+ } else if (validateEmbeddable instanceof RegExp) {
+ return validateEmbeddable.test(url);
+ } else if (Array.isArray(validateEmbeddable)) {
+ for (const domain of validateEmbeddable) {
+ if (domain instanceof RegExp) {
+ if (url.match(domain)) {
+ return true;
+ }
+ } else if (matchHostname(url, domain)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ return !!matchHostname(url, ALLOWED_DOMAINS);
+};
diff --git a/packages/excalidraw/element/flowchart.test.tsx b/packages/excalidraw/element/flowchart.test.tsx
new file mode 100644
index 0000000..d47c850
--- /dev/null
+++ b/packages/excalidraw/element/flowchart.test.tsx
@@ -0,0 +1,403 @@
+import { render, unmountComponent } from "../tests/test-utils";
+import { reseed } from "../random";
+import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
+import { Excalidraw } from "../index";
+import { API } from "../tests/helpers/api";
+import { KEYS } from "../keys";
+
+unmountComponent();
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+
+beforeEach(async () => {
+ localStorage.clear();
+ reseed(7);
+ mouse.reset();
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ h.state.width = 1000;
+ h.state.height = 1000;
+
+ // The bounds of hand-drawn linear elements may change after flipping, so
+ // removing this style for testing
+ UI.clickTool("arrow");
+ UI.clickByTitle("Architect");
+ UI.clickTool("selection");
+});
+
+describe("flow chart creation", () => {
+ beforeEach(() => {
+ API.clearSelection();
+ const rectangle = API.createElement({
+ type: "rectangle",
+ width: 200,
+ height: 100,
+ });
+
+ API.setElements([rectangle]);
+ API.setSelectedElements([rectangle]);
+ });
+
+ // multiple at once
+ it("create multiple successor nodes at once", () => {
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(5);
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
+ });
+
+ it("when directions are changed, only the last same directions will apply", () => {
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ });
+
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(7);
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
+ });
+
+ it("when escaped, no nodes will be created", () => {
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ Keyboard.keyPress(KEYS.ARROW_DOWN);
+ });
+
+ Keyboard.keyPress(KEYS.ESCAPE);
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(1);
+ });
+
+ it("create nodes one at a time", () => {
+ const initialNode = h.elements[0];
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(3);
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
+
+ const firstChildNode = h.elements.filter(
+ (el) => el.type === "rectangle" && el.id !== initialNode.id,
+ )[0];
+ expect(firstChildNode).not.toBe(null);
+ expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
+
+ API.setSelectedElements([initialNode]);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(5);
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
+
+ const secondChildNode = h.elements.filter(
+ (el) =>
+ el.type === "rectangle" &&
+ el.id !== initialNode.id &&
+ el.id !== firstChildNode.id,
+ )[0];
+ expect(secondChildNode).not.toBe(null);
+ expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
+
+ API.setSelectedElements([initialNode]);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.length).toBe(7);
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
+
+ const thirdChildNode = h.elements.filter(
+ (el) =>
+ el.type === "rectangle" &&
+ el.id !== initialNode.id &&
+ el.id !== firstChildNode.id &&
+ el.id !== secondChildNode.id,
+ )[0];
+
+ expect(thirdChildNode).not.toBe(null);
+ expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
+
+ expect(firstChildNode.x).toBe(secondChildNode.x);
+ expect(secondChildNode.x).toBe(thirdChildNode.x);
+ });
+});
+
+describe("flow chart navigation", () => {
+ it("single node at each level", () => {
+ /**
+ * ▨ -> ▨ -> ▨ -> ▨ -> ▨
+ */
+
+ API.clearSelection();
+ const rectangle = API.createElement({
+ type: "rectangle",
+ width: 200,
+ height: 100,
+ });
+
+ API.setElements([rectangle]);
+ API.setSelectedElements([rectangle]);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
+ expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
+
+ // all the way to the left, gets us to the first node
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
+
+ // all the way to the right, gets us to the last node
+ const rightMostNode = h.elements[h.elements.length - 2];
+ expect(rightMostNode);
+ expect(rightMostNode.type).toBe("rectangle");
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
+ });
+
+ it("multiple nodes at each level", () => {
+ /**
+ * from the perspective of the first node, there're four layers, and
+ * there are four nodes at the second layer
+ *
+ * -> ▨
+ * ▨ -> ▨ -> ▨ -> ▨ -> ▨
+ * -> ▨
+ * -> ▨
+ */
+
+ API.clearSelection();
+ const rectangle = API.createElement({
+ type: "rectangle",
+ width: 200,
+ height: 100,
+ });
+
+ API.setElements([rectangle]);
+ API.setSelectedElements([rectangle]);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ const secondNode = h.elements[1];
+ const rightMostNode = h.elements[h.elements.length - 2];
+
+ API.setSelectedElements([rectangle]);
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ API.setSelectedElements([rectangle]);
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ API.setSelectedElements([rectangle]);
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ API.setSelectedElements([rectangle]);
+
+ // because of same level cycling,
+ // going right five times should take us back to the second node again
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
+
+ // from the second node, going right three times should take us to the rightmost node
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
+
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
+ });
+
+ it("take the most obvious link when possible", () => {
+ /**
+ * ▨ → ▨ ▨ → ▨
+ * ↓ ↑
+ * ▨ → ▨
+ */
+
+ API.clearSelection();
+ const rectangle = API.createElement({
+ type: "rectangle",
+ width: 200,
+ height: 100,
+ });
+
+ API.setElements([rectangle]);
+ API.setSelectedElements([rectangle]);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_DOWN);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.CTRL_OR_CMD);
+
+ // last node should be the one that's selected
+ const rightMostNode = h.elements[h.elements.length - 2];
+ expect(rightMostNode.type).toBe("rectangle");
+ expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
+
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ Keyboard.keyPress(KEYS.ARROW_LEFT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+
+ expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
+
+ // going any direction takes us to the predecessor as well
+ const predecessorToRightMostNode = h.elements[h.elements.length - 4];
+ expect(predecessorToRightMostNode.type).toBe("rectangle");
+
+ API.setSelectedElements([rightMostNode]);
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_RIGHT);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
+ expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
+ true,
+ );
+ API.setSelectedElements([rightMostNode]);
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_UP);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
+ expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
+ true,
+ );
+ API.setSelectedElements([rightMostNode]);
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ Keyboard.keyPress(KEYS.ARROW_DOWN);
+ });
+ Keyboard.keyUp(KEYS.ALT);
+ expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
+ expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
+ true,
+ );
+ });
+});
diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts
new file mode 100644
index 0000000..09f006d
--- /dev/null
+++ b/packages/excalidraw/element/flowchart.ts
@@ -0,0 +1,715 @@
+import {
+ HEADING_DOWN,
+ HEADING_LEFT,
+ HEADING_RIGHT,
+ HEADING_UP,
+ compareHeading,
+ headingForPointFromElement,
+ type Heading,
+} from "./heading";
+import { bindLinearElement } from "./binding";
+import { LinearElementEditor } from "./linearElementEditor";
+import { newArrowElement, newElement } from "./newElement";
+import {
+ type ElementsMap,
+ type ExcalidrawBindableElement,
+ type ExcalidrawElement,
+ type ExcalidrawFlowchartNodeElement,
+ type NonDeletedSceneElementsMap,
+ type Ordered,
+ type OrderedExcalidrawElement,
+} from "./types";
+import { KEYS } from "../keys";
+import type { AppState, PendingExcalidrawElements } from "../types";
+import { mutateElement } from "./mutateElement";
+import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
+import {
+ isBindableElement,
+ isElbowArrow,
+ isFrameElement,
+ isFlowchartNodeElement,
+} from "./typeChecks";
+import { invariant, toBrandedType } from "../utils";
+import { pointFrom, type LocalPoint } from "@excalidraw/math";
+import { aabbForElement } from "../shapes";
+import { updateElbowArrowPoints } from "./elbowArrow";
+
+type LinkDirection = "up" | "right" | "down" | "left";
+
+const VERTICAL_OFFSET = 100;
+const HORIZONTAL_OFFSET = 100;
+
+export const getLinkDirectionFromKey = (key: string): LinkDirection => {
+ switch (key) {
+ case KEYS.ARROW_UP:
+ return "up";
+ case KEYS.ARROW_DOWN:
+ return "down";
+ case KEYS.ARROW_RIGHT:
+ return "right";
+ case KEYS.ARROW_LEFT:
+ return "left";
+ default:
+ return "right";
+ }
+};
+
+const getNodeRelatives = (
+ type: "predecessors" | "successors",
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ const items = [...elementsMap.values()].reduce(
+ (acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
+ let oppositeBinding;
+ if (
+ isElbowArrow(el) &&
+ // we want check existence of the opposite binding, in the direction
+ // we're interested in
+ (oppositeBinding =
+ el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
+ // similarly, we need to filter only arrows bound to target node
+ el[type === "predecessors" ? "endBinding" : "startBinding"]
+ ?.elementId === node.id
+ ) {
+ const relative = elementsMap.get(oppositeBinding.elementId);
+
+ if (!relative) {
+ return acc;
+ }
+
+ invariant(
+ isBindableElement(relative),
+ "not an ExcalidrawBindableElement",
+ );
+
+ const edgePoint = (
+ type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
+ ) as Readonly<LocalPoint>;
+
+ const heading = headingForPointFromElement(node, aabbForElement(node), [
+ edgePoint[0] + el.x,
+ edgePoint[1] + el.y,
+ ] as Readonly<LocalPoint>);
+
+ acc.push({
+ relative,
+ heading,
+ });
+ }
+ return acc;
+ },
+ [],
+ );
+
+ switch (direction) {
+ case "up":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_UP))
+ .map((item) => item.relative);
+ case "down":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_DOWN))
+ .map((item) => item.relative);
+ case "right":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_RIGHT))
+ .map((item) => item.relative);
+ case "left":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_LEFT))
+ .map((item) => item.relative);
+ }
+};
+
+const getSuccessors = (
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ return getNodeRelatives("successors", node, elementsMap, direction);
+};
+
+export const getPredecessors = (
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ return getNodeRelatives("predecessors", node, elementsMap, direction);
+};
+
+const getOffsets = (
+ element: ExcalidrawFlowchartNodeElement,
+ linkedNodes: ExcalidrawElement[],
+ direction: LinkDirection,
+) => {
+ const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
+
+ // check if vertical space or horizontal space is available first
+ if (direction === "up" || direction === "down") {
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ // check vertical space
+ const minX = element.x;
+ const maxX = element.x + element.width;
+
+ // vertical space is available
+ if (
+ linkedNodes.every(
+ (linkedNode) =>
+ linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
+ )
+ ) {
+ return {
+ x: 0,
+ y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
+ };
+ }
+ } else if (direction === "right" || direction === "left") {
+ const minY = element.y;
+ const maxY = element.y + element.height;
+
+ if (
+ linkedNodes.every(
+ (linkedNode) =>
+ linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
+ )
+ ) {
+ return {
+ x:
+ (HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
+ y: 0,
+ };
+ }
+ }
+
+ if (direction === "up" || direction === "down") {
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
+ const x =
+ linkedNodes.length === 0
+ ? 0
+ : (linkedNodes.length + 1) % 2 === 0
+ ? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
+ : (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
+
+ if (direction === "up") {
+ return {
+ x,
+ y: y * -1,
+ };
+ }
+
+ return {
+ x,
+ y,
+ };
+ }
+
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ const x =
+ (linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
+ element.width;
+ const y =
+ linkedNodes.length === 0
+ ? 0
+ : (linkedNodes.length + 1) % 2 === 0
+ ? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
+ : (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
+
+ if (direction === "left") {
+ return {
+ x: x * -1,
+ y,
+ };
+ }
+ return {
+ x,
+ y,
+ };
+};
+
+const addNewNode = (
+ element: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+) => {
+ const successors = getSuccessors(element, elementsMap, direction);
+ const predeccessors = getPredecessors(element, elementsMap, direction);
+
+ const offsets = getOffsets(
+ element,
+ [...successors, ...predeccessors],
+ direction,
+ );
+
+ const nextNode = newElement({
+ type: element.type,
+ x: element.x + offsets.x,
+ y: element.y + offsets.y,
+ // TODO: extract this to a util
+ width: element.width,
+ height: element.height,
+ roundness: element.roundness,
+ roughness: element.roughness,
+ backgroundColor: element.backgroundColor,
+ strokeColor: element.strokeColor,
+ strokeWidth: element.strokeWidth,
+ opacity: element.opacity,
+ fillStyle: element.fillStyle,
+ strokeStyle: element.strokeStyle,
+ });
+
+ invariant(
+ isFlowchartNodeElement(nextNode),
+ "not an ExcalidrawFlowchartNodeElement",
+ );
+
+ const bindingArrow = createBindingArrow(
+ element,
+ nextNode,
+ elementsMap,
+ direction,
+ appState,
+ );
+
+ return {
+ nextNode,
+ bindingArrow,
+ };
+};
+
+export const addNewNodes = (
+ startNode: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+ numberOfNodes: number,
+) => {
+ // always start from 0 and distribute evenly
+ const newNodes: ExcalidrawElement[] = [];
+
+ for (let i = 0; i < numberOfNodes; i++) {
+ let nextX: number;
+ let nextY: number;
+ if (direction === "left" || direction === "right") {
+ const totalHeight =
+ VERTICAL_OFFSET * (numberOfNodes - 1) +
+ numberOfNodes * startNode.height;
+
+ const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
+
+ let offsetX = HORIZONTAL_OFFSET + startNode.width;
+ if (direction === "left") {
+ offsetX *= -1;
+ }
+ nextX = startNode.x + offsetX;
+ const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
+ nextY = startY + offsetY;
+ } else {
+ const totalWidth =
+ HORIZONTAL_OFFSET * (numberOfNodes - 1) +
+ numberOfNodes * startNode.width;
+ const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
+ let offsetY = VERTICAL_OFFSET + startNode.height;
+
+ if (direction === "up") {
+ offsetY *= -1;
+ }
+ nextY = startNode.y + offsetY;
+ const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
+ nextX = startX + offsetX;
+ }
+
+ const nextNode = newElement({
+ type: startNode.type,
+ x: nextX,
+ y: nextY,
+ // TODO: extract this to a util
+ width: startNode.width,
+ height: startNode.height,
+ roundness: startNode.roundness,
+ roughness: startNode.roughness,
+ backgroundColor: startNode.backgroundColor,
+ strokeColor: startNode.strokeColor,
+ strokeWidth: startNode.strokeWidth,
+ opacity: startNode.opacity,
+ fillStyle: startNode.fillStyle,
+ strokeStyle: startNode.strokeStyle,
+ });
+
+ invariant(
+ isFlowchartNodeElement(nextNode),
+ "not an ExcalidrawFlowchartNodeElement",
+ );
+
+ const bindingArrow = createBindingArrow(
+ startNode,
+ nextNode,
+ elementsMap,
+ direction,
+ appState,
+ );
+
+ newNodes.push(nextNode);
+ newNodes.push(bindingArrow);
+ }
+
+ return newNodes;
+};
+
+const createBindingArrow = (
+ startBindingElement: ExcalidrawFlowchartNodeElement,
+ endBindingElement: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+ appState: AppState,
+) => {
+ let startX: number;
+ let startY: number;
+
+ const PADDING = 6;
+
+ switch (direction) {
+ case "up": {
+ startX = startBindingElement.x + startBindingElement.width / 2;
+ startY = startBindingElement.y - PADDING;
+ break;
+ }
+ case "down": {
+ startX = startBindingElement.x + startBindingElement.width / 2;
+ startY = startBindingElement.y + startBindingElement.height + PADDING;
+ break;
+ }
+ case "right": {
+ startX = startBindingElement.x + startBindingElement.width + PADDING;
+ startY = startBindingElement.y + startBindingElement.height / 2;
+ break;
+ }
+ case "left": {
+ startX = startBindingElement.x - PADDING;
+ startY = startBindingElement.y + startBindingElement.height / 2;
+ break;
+ }
+ }
+
+ let endX: number;
+ let endY: number;
+
+ switch (direction) {
+ case "up": {
+ endX = endBindingElement.x + endBindingElement.width / 2 - startX;
+ endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
+ break;
+ }
+ case "down": {
+ endX = endBindingElement.x + endBindingElement.width / 2 - startX;
+ endY = endBindingElement.y - startY - PADDING;
+ break;
+ }
+ case "right": {
+ endX = endBindingElement.x - startX - PADDING;
+ endY = endBindingElement.y - startY + endBindingElement.height / 2;
+ break;
+ }
+ case "left": {
+ endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
+ endY = endBindingElement.y - startY + endBindingElement.height / 2;
+ break;
+ }
+ }
+
+ const bindingArrow = newArrowElement({
+ type: "arrow",
+ x: startX,
+ y: startY,
+ startArrowhead: null,
+ endArrowhead: appState.currentItemEndArrowhead,
+ strokeColor: startBindingElement.strokeColor,
+ strokeStyle: startBindingElement.strokeStyle,
+ strokeWidth: startBindingElement.strokeWidth,
+ opacity: startBindingElement.opacity,
+ roughness: startBindingElement.roughness,
+ points: [pointFrom(0, 0), pointFrom(endX, endY)],
+ elbowed: true,
+ });
+
+ bindLinearElement(
+ bindingArrow,
+ startBindingElement,
+ "start",
+ elementsMap as NonDeletedSceneElementsMap,
+ );
+ bindLinearElement(
+ bindingArrow,
+ endBindingElement,
+ "end",
+ elementsMap as NonDeletedSceneElementsMap,
+ );
+
+ const changedElements = new Map<string, OrderedExcalidrawElement>();
+ changedElements.set(
+ startBindingElement.id,
+ startBindingElement as OrderedExcalidrawElement,
+ );
+ changedElements.set(
+ endBindingElement.id,
+ endBindingElement as OrderedExcalidrawElement,
+ );
+ changedElements.set(
+ bindingArrow.id,
+ bindingArrow as OrderedExcalidrawElement,
+ );
+
+ LinearElementEditor.movePoints(bindingArrow, [
+ {
+ index: 1,
+ point: bindingArrow.points[1],
+ },
+ ]);
+
+ const update = updateElbowArrowPoints(
+ bindingArrow,
+ toBrandedType<NonDeletedSceneElementsMap>(
+ new Map([
+ ...elementsMap.entries(),
+ [startBindingElement.id, startBindingElement],
+ [endBindingElement.id, endBindingElement],
+ [bindingArrow.id, bindingArrow],
+ ] as [string, Ordered<ExcalidrawElement>][]),
+ ),
+ { points: bindingArrow.points },
+ );
+
+ return {
+ ...bindingArrow,
+ ...update,
+ };
+};
+
+export class FlowChartNavigator {
+ isExploring: boolean = false;
+ // nodes that are ONE link away (successor and predecessor both included)
+ private sameLevelNodes: ExcalidrawElement[] = [];
+ private sameLevelIndex: number = 0;
+ // set it to the opposite of the defalut creation direction
+ private direction: LinkDirection | null = null;
+ // for speedier navigation
+ private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
+
+ clear() {
+ this.isExploring = false;
+ this.sameLevelNodes = [];
+ this.sameLevelIndex = 0;
+ this.direction = null;
+ this.visitedNodes.clear();
+ }
+
+ exploreByDirection(
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+ ): ExcalidrawElement["id"] | null {
+ if (!isBindableElement(element)) {
+ return null;
+ }
+
+ // clear if going at a different direction
+ if (direction !== this.direction) {
+ this.clear();
+ }
+
+ // add the current node to the visited
+ if (!this.visitedNodes.has(element.id)) {
+ this.visitedNodes.add(element.id);
+ }
+
+ /**
+ * CASE:
+ * - already started exploring, AND
+ * - there are multiple nodes at the same level, AND
+ * - still going at the same direction, AND
+ *
+ * RESULT:
+ * - loop through nodes at the same level
+ *
+ * WHY:
+ * - provides user the capability to loop through nodes at the same level
+ */
+ if (
+ this.isExploring &&
+ direction === this.direction &&
+ this.sameLevelNodes.length > 1
+ ) {
+ this.sameLevelIndex =
+ (this.sameLevelIndex + 1) % this.sameLevelNodes.length;
+
+ return this.sameLevelNodes[this.sameLevelIndex].id;
+ }
+
+ const nodes = [
+ ...getSuccessors(element, elementsMap, direction),
+ ...getPredecessors(element, elementsMap, direction),
+ ];
+
+ /**
+ * CASE:
+ * - just started exploring at the given direction
+ *
+ * RESULT:
+ * - go to the first node in the given direction
+ */
+ if (nodes.length > 0) {
+ this.sameLevelIndex = 0;
+ this.isExploring = true;
+ this.sameLevelNodes = nodes;
+ this.direction = direction;
+ this.visitedNodes.add(nodes[0].id);
+
+ return nodes[0].id;
+ }
+
+ /**
+ * CASE:
+ * - (just started exploring or still going at the same direction) OR
+ * - there're no nodes at the given direction
+ *
+ * RESULT:
+ * - go to some other unvisited linked node
+ *
+ * WHY:
+ * - provide a speedier navigation from a given node to some predecessor
+ * without the user having to change arrow key
+ */
+ if (direction === this.direction || !this.isExploring) {
+ if (!this.isExploring) {
+ // just started and no other nodes at the given direction
+ // so the current node is technically the first visited node
+ // (this is needed so that we don't get stuck between looping through )
+ this.visitedNodes.add(element.id);
+ }
+
+ const otherDirections: LinkDirection[] = [
+ "up",
+ "right",
+ "down",
+ "left",
+ ].filter((dir): dir is LinkDirection => dir !== direction);
+
+ const otherLinkedNodes = otherDirections
+ .map((dir) => [
+ ...getSuccessors(element, elementsMap, dir),
+ ...getPredecessors(element, elementsMap, dir),
+ ])
+ .flat()
+ .filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
+
+ for (const linkedNode of otherLinkedNodes) {
+ if (!this.visitedNodes.has(linkedNode.id)) {
+ this.visitedNodes.add(linkedNode.id);
+ this.isExploring = true;
+ this.direction = direction;
+ return linkedNode.id;
+ }
+ }
+ }
+
+ return null;
+ }
+}
+
+export class FlowChartCreator {
+ isCreatingChart: boolean = false;
+ private numberOfNodes: number = 0;
+ private direction: LinkDirection | null = "right";
+ pendingNodes: PendingExcalidrawElements | null = null;
+
+ createNodes(
+ startNode: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+ ) {
+ if (direction !== this.direction) {
+ const { nextNode, bindingArrow } = addNewNode(
+ startNode,
+ elementsMap,
+ appState,
+ direction,
+ );
+
+ this.numberOfNodes = 1;
+ this.isCreatingChart = true;
+ this.direction = direction;
+ this.pendingNodes = [nextNode, bindingArrow];
+ } else {
+ this.numberOfNodes += 1;
+ const newNodes = addNewNodes(
+ startNode,
+ elementsMap,
+ appState,
+ direction,
+ this.numberOfNodes,
+ );
+
+ this.isCreatingChart = true;
+ this.direction = direction;
+ this.pendingNodes = newNodes;
+ }
+
+ // add pending nodes to the same frame as the start node
+ // if every pending node is at least intersecting with the frame
+ if (startNode.frameId) {
+ const frame = elementsMap.get(startNode.frameId);
+
+ invariant(
+ frame && isFrameElement(frame),
+ "not an ExcalidrawFrameElement",
+ );
+
+ if (
+ frame &&
+ this.pendingNodes.every(
+ (node) =>
+ elementsAreInFrameBounds([node], frame, elementsMap) ||
+ elementOverlapsWithFrame(node, frame, elementsMap),
+ )
+ ) {
+ this.pendingNodes = this.pendingNodes.map((node) =>
+ mutateElement(
+ node,
+ {
+ frameId: startNode.frameId,
+ },
+ false,
+ ),
+ );
+ }
+ }
+ }
+
+ clear() {
+ this.isCreatingChart = false;
+ this.pendingNodes = null;
+ this.direction = null;
+ this.numberOfNodes = 0;
+ }
+}
+
+export const isNodeInFlowchart = (
+ element: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+) => {
+ for (const [, el] of elementsMap) {
+ if (
+ el.type === "arrow" &&
+ (el.startBinding?.elementId === element.id ||
+ el.endBinding?.elementId === element.id)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts
new file mode 100644
index 0000000..94e8633
--- /dev/null
+++ b/packages/excalidraw/element/heading.ts
@@ -0,0 +1,202 @@
+import type {
+ LocalPoint,
+ GlobalPoint,
+ Triangle,
+ Vector,
+ Radians,
+} from "@excalidraw/math";
+import {
+ pointFrom,
+ pointRotateRads,
+ pointScaleFromOrigin,
+ radiansToDegrees,
+ triangleIncludesPoint,
+ vectorFromPoint,
+} 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 headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
+ a: Point,
+ b: Point,
+) => {
+ const angle = radiansToDegrees(
+ Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
+ );
+ if (angle >= 315 || angle < 45) {
+ return HEADING_UP;
+ } else if (angle >= 45 && angle < 135) {
+ return HEADING_RIGHT;
+ } else if (angle >= 135 && angle < 225) {
+ return HEADING_DOWN;
+ }
+ return HEADING_LEFT;
+};
+
+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 extends GlobalPoint | LocalPoint>(
+ p: P,
+ o: P,
+) => vectorToHeading(vectorFromPoint<P>(p, o));
+
+export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
+ p: P,
+ o: P,
+) => headingIsHorizontal(headingForPoint<P>(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);
+
+// 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 = <
+ Point extends GlobalPoint | LocalPoint,
+>(
+ element: Readonly<ExcalidrawBindableElement>,
+ aabb: Readonly<Bounds>,
+ p: Readonly<Point>,
+): Heading => {
+ const SEARCH_CONE_MULTIPLIER = 2;
+
+ const midPoint = getCenterForBounds(aabb);
+
+ if (element.type === "diamond") {
+ if (p[0] < element.x) {
+ return HEADING_LEFT;
+ } else if (p[1] < element.y) {
+ return HEADING_UP;
+ } else if (p[0] > element.x + element.width) {
+ return HEADING_RIGHT;
+ } else if (p[1] > element.y + element.height) {
+ return HEADING_DOWN;
+ }
+
+ const top = pointRotateRads(
+ pointScaleFromOrigin(
+ pointFrom(element.x + element.width / 2, element.y),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const right = pointRotateRads(
+ pointScaleFromOrigin(
+ pointFrom(element.x + element.width, element.y + element.height / 2),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const bottom = pointRotateRads(
+ pointScaleFromOrigin(
+ pointFrom(element.x + element.width / 2, element.y + element.height),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+ const left = pointRotateRads(
+ pointScaleFromOrigin(
+ pointFrom(element.x, element.y + element.height / 2),
+ midPoint,
+ SEARCH_CONE_MULTIPLIER,
+ ),
+ midPoint,
+ element.angle,
+ );
+
+ if (
+ triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
+ ) {
+ return headingForDiamond(top, right);
+ } else if (
+ triangleIncludesPoint<Point>(
+ [right, bottom, midPoint] as Triangle<Point>,
+ p,
+ )
+ ) {
+ return headingForDiamond(right, bottom);
+ } else if (
+ triangleIncludesPoint<Point>(
+ [bottom, left, midPoint] as Triangle<Point>,
+ p,
+ )
+ ) {
+ return headingForDiamond(bottom, left);
+ }
+
+ return headingForDiamond(left, top);
+ }
+
+ 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<Point>(
+ [topLeft, topRight, midPoint] as Triangle<Point>,
+ p,
+ )
+ ? HEADING_UP
+ : triangleIncludesPoint<Point>(
+ [topRight, bottomRight, midPoint] as Triangle<Point>,
+ p,
+ )
+ ? HEADING_RIGHT
+ : triangleIncludesPoint<Point>(
+ [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
+ 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/excalidraw/element/image.ts
new file mode 100644
index 0000000..32644b6
--- /dev/null
+++ b/packages/excalidraw/element/image.ts
@@ -0,0 +1,146 @@
+// -----------------------------------------------------------------------------
+// ExcalidrawImageElement & related helpers
+// -----------------------------------------------------------------------------
+
+import { MIME_TYPES, SVG_NS } from "../constants";
+import type { AppClassProperties, DataURL, BinaryFiles } from "../types";
+import { isInitializedImageElement } from "./typeChecks";
+import type {
+ ExcalidrawElement,
+ FileId,
+ InitializedExcalidrawImageElement,
+} from "./types";
+
+export const loadHTMLImageElement = (dataURL: DataURL) => {
+ return new Promise<HTMLImageElement>((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => {
+ resolve(image);
+ };
+ image.onerror = (error) => {
+ reject(error);
+ };
+ image.src = dataURL;
+ });
+};
+
+/** NOTE: updates cache even if already populated with given image. Thus,
+ * you should filter out the images upstream if you want to optimize this. */
+export const updateImageCache = async ({
+ fileIds,
+ files,
+ imageCache,
+}: {
+ fileIds: FileId[];
+ files: BinaryFiles;
+ imageCache: AppClassProperties["imageCache"];
+}) => {
+ const updatedFiles = new Map<FileId, true>();
+ const erroredFiles = new Map<FileId, true>();
+
+ await Promise.all(
+ fileIds.reduce((promises, fileId) => {
+ const fileData = files[fileId as string];
+ if (fileData && !updatedFiles.has(fileId)) {
+ updatedFiles.set(fileId, true);
+ return promises.concat(
+ (async () => {
+ try {
+ if (fileData.mimeType === MIME_TYPES.binary) {
+ throw new Error("Only images can be added to ImageCache");
+ }
+
+ const imagePromise = loadHTMLImageElement(fileData.dataURL);
+ const data = {
+ image: imagePromise,
+ mimeType: fileData.mimeType,
+ } as const;
+ // store the promise immediately to indicate there's an in-progress
+ // initialization
+ imageCache.set(fileId, data);
+
+ const image = await imagePromise;
+
+ imageCache.set(fileId, { ...data, image });
+ } catch (error: any) {
+ erroredFiles.set(fileId, true);
+ }
+ })(),
+ );
+ }
+ return promises;
+ }, [] as Promise<any>[]),
+ );
+
+ return {
+ imageCache,
+ /** includes errored files because they cache was updated nonetheless */
+ updatedFiles,
+ /** files that failed when creating HTMLImageElement */
+ erroredFiles,
+ };
+};
+
+export const getInitializedImageElements = (
+ elements: readonly ExcalidrawElement[],
+) =>
+ elements.filter((element) =>
+ isInitializedImageElement(element),
+ ) as InitializedExcalidrawImageElement[];
+
+export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
+ // lower-casing due to XML/HTML convention differences
+ // https://johnresig.com/blog/nodename-case-sensitivity
+ return node?.nodeName.toLowerCase() === "svg";
+};
+
+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("Invalid SVG");
+ } else {
+ if (!svg.hasAttribute("xmlns")) {
+ svg.setAttribute("xmlns", SVG_NS);
+ }
+
+ 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+)?) +(\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/excalidraw/element/index.ts
new file mode 100644
index 0000000..a9b7476
--- /dev/null
+++ b/packages/excalidraw/element/index.ts
@@ -0,0 +1,122 @@
+import type {
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ NonDeleted,
+} from "./types";
+import { isInvisiblySmallElement } from "./sizeHelpers";
+import { isLinearElementType } from "./typeChecks";
+
+export {
+ newElement,
+ newTextElement,
+ refreshTextDimensions,
+ newLinearElement,
+ newArrowElement,
+ newImageElement,
+ duplicateElement,
+} from "./newElement";
+export {
+ getElementAbsoluteCoords,
+ getElementBounds,
+ getCommonBounds,
+ getDiamondPoints,
+ getArrowheadPoints,
+ getClosestElementBounds,
+} from "./bounds";
+
+export {
+ OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
+ getTransformHandlesFromCoords,
+ getTransformHandles,
+} from "./transformHandles";
+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, getTextFromElements } 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),
+ ) as readonly NonDeletedExcalidrawElement[];
+
+export const getNonDeletedElements = <T extends ExcalidrawElement>(
+ elements: readonly T[],
+) =>
+ elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
+
+export const isNonDeletedElement = <T extends ExcalidrawElement>(
+ element: T,
+): element is NonDeleted<T> => !element.isDeleted;
+
+const _clearElements = (
+ elements: readonly ExcalidrawElement[],
+): ExcalidrawElement[] =>
+ getNonDeletedElements(elements).map((element) =>
+ isLinearElementType(element.type)
+ ? { ...element, lastCommittedPoint: null }
+ : element,
+ );
+
+export const clearElementsForDatabase = (
+ elements: readonly ExcalidrawElement[],
+) => _clearElements(elements);
+
+export const clearElementsForExport = (
+ elements: readonly ExcalidrawElement[],
+) => _clearElements(elements);
+
+export const clearElementsForLocalStorage = (
+ elements: readonly ExcalidrawElement[],
+) => _clearElements(elements);
diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts
new file mode 100644
index 0000000..b616268
--- /dev/null
+++ b/packages/excalidraw/element/linearElementEditor.ts
@@ -0,0 +1,1824 @@
+import type {
+ NonDeleted,
+ ExcalidrawLinearElement,
+ ExcalidrawElement,
+ PointBinding,
+ ExcalidrawBindableElement,
+ ExcalidrawTextElementWithContainer,
+ ElementsMap,
+ NonDeletedSceneElementsMap,
+ FixedPointBinding,
+ SceneElementsMap,
+ FixedSegment,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
+import type { Bounds } from "./bounds";
+import { getElementPointsCoords, getMinMaxXYFromCurvePathOps } from "./bounds";
+import type {
+ AppState,
+ PointerCoords,
+ InteractiveCanvasAppState,
+ AppClassProperties,
+ NullableGridSize,
+ Zoom,
+} from "../types";
+import { mutateElement } from "./mutateElement";
+
+import {
+ bindOrUnbindLinearElement,
+ getHoveredElementForBinding,
+ isBindingEnabled,
+} from "./binding";
+import { invariant, tupleToCoors } from "../utils";
+import {
+ isBindingElement,
+ isElbowArrow,
+ isFixedPointBinding,
+} from "./typeChecks";
+import { KEYS, shouldRotateWithDiscreteAngle } from "../keys";
+import { getBoundTextElement, handleBindTextResize } from "./textElement";
+import { DRAGGING_THRESHOLD } from "../constants";
+import type { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
+import type { Store } from "../store";
+import type Scene from "../scene/Scene";
+import type { Radians } from "@excalidraw/math";
+import {
+ pointCenter,
+ pointFrom,
+ pointRotateRads,
+ pointsEqual,
+ type GlobalPoint,
+ type LocalPoint,
+ pointDistance,
+ vectorFromPoint,
+} from "@excalidraw/math";
+import {
+ getBezierCurveLength,
+ getBezierXY,
+ getControlPointsForBezierCurve,
+ isPathALoop,
+ mapIntervalToBezierT,
+} from "../shapes";
+import { getGridPoint } from "../snapping";
+import { headingIsHorizontal, vectorToHeading } from "./heading";
+import { getCurvePathOps } from "@excalidraw/utils/geometry/shape";
+
+const editorMidPointsCache: {
+ version: number | null;
+ points: (GlobalPoint | null)[];
+ zoom: number | null;
+} = { version: null, points: [], zoom: null };
+export class LinearElementEditor {
+ public readonly elementId: ExcalidrawElement["id"] & {
+ _brand: "excalidrawLinearElementId";
+ };
+ /** indices */
+ public readonly selectedPointsIndices: readonly number[] | null;
+
+ public readonly pointerDownState: Readonly<{
+ prevSelectedPointsIndices: readonly number[] | null;
+ /** index */
+ lastClickedPoint: number;
+ lastClickedIsEndPoint: boolean;
+ origin: Readonly<{ x: number; y: number }> | null;
+ segmentMidpoint: {
+ value: GlobalPoint | null;
+ index: number | null;
+ added: boolean;
+ };
+ }>;
+
+ /** whether you're dragging a point */
+ public readonly isDragging: boolean;
+ public readonly lastUncommittedPoint: LocalPoint | null;
+ public readonly pointerOffset: Readonly<{ x: number; y: number }>;
+ public readonly startBindingElement:
+ | ExcalidrawBindableElement
+ | null
+ | "keep";
+ public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
+ public readonly hoverPointIndex: number;
+ public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
+ public readonly elbowed: boolean;
+
+ constructor(element: NonDeleted<ExcalidrawLinearElement>) {
+ this.elementId = element.id as string & {
+ _brand: "excalidrawLinearElementId";
+ };
+ if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
+ console.error("Linear element is not normalized", Error().stack);
+ }
+
+ this.selectedPointsIndices = null;
+ this.lastUncommittedPoint = null;
+ this.isDragging = false;
+ this.pointerOffset = { x: 0, y: 0 };
+ this.startBindingElement = "keep";
+ this.endBindingElement = "keep";
+ this.pointerDownState = {
+ prevSelectedPointsIndices: null,
+ lastClickedPoint: -1,
+ lastClickedIsEndPoint: false,
+ origin: null,
+
+ segmentMidpoint: {
+ value: null,
+ index: null,
+ added: false,
+ },
+ };
+ this.hoverPointIndex = -1;
+ this.segmentMidPointHoveredCoords = null;
+ this.elbowed = isElbowArrow(element) && element.elbowed;
+ }
+
+ // ---------------------------------------------------------------------------
+ // static methods
+ // ---------------------------------------------------------------------------
+
+ static POINT_HANDLE_SIZE = 10;
+ /**
+ * @param id the `elementId` from the instance of this class (so that we can
+ * statically guarantee this method returns an ExcalidrawLinearElement)
+ */
+ static getElement<T extends ExcalidrawLinearElement>(
+ id: InstanceType<typeof LinearElementEditor>["elementId"],
+ elementsMap: ElementsMap,
+ ): T | null {
+ const element = elementsMap.get(id);
+ if (element) {
+ return element as NonDeleted<T>;
+ }
+ return null;
+ }
+
+ static handleBoxSelection(
+ event: PointerEvent,
+ appState: AppState,
+ setState: React.Component<any, AppState>["setState"],
+ elementsMap: NonDeletedSceneElementsMap,
+ ) {
+ if (!appState.editingLinearElement || !appState.selectionElement) {
+ return false;
+ }
+ const { editingLinearElement } = appState;
+ const { selectedPointsIndices, elementId } = editingLinearElement;
+
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+ if (!element) {
+ return false;
+ }
+
+ const [selectionX1, selectionY1, selectionX2, selectionY2] =
+ getElementAbsoluteCoords(appState.selectionElement, elementsMap);
+
+ const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
+
+ const nextSelectedPoints = pointsSceneCoords
+ .reduce((acc: number[], point, index) => {
+ if (
+ (point[0] >= selectionX1 &&
+ point[0] <= selectionX2 &&
+ point[1] >= selectionY1 &&
+ point[1] <= selectionY2) ||
+ (event.shiftKey && selectedPointsIndices?.includes(index))
+ ) {
+ acc.push(index);
+ }
+
+ return acc;
+ }, [])
+ .filter((index) => {
+ if (
+ isElbowArrow(element) &&
+ index !== 0 &&
+ index !== element.points.length - 1
+ ) {
+ return false;
+ }
+ return true;
+ });
+
+ setState({
+ editingLinearElement: {
+ ...editingLinearElement,
+ selectedPointsIndices: nextSelectedPoints.length
+ ? nextSelectedPoints
+ : null,
+ },
+ });
+ }
+
+ /**
+ * @returns whether point was dragged
+ */
+ static handlePointDragging(
+ event: PointerEvent,
+ app: AppClassProperties,
+ scenePointerX: number,
+ scenePointerY: number,
+ maybeSuggestBinding: (
+ element: NonDeleted<ExcalidrawLinearElement>,
+ pointSceneCoords: { x: number; y: number }[],
+ ) => void,
+ linearElementEditor: LinearElementEditor,
+ scene: Scene,
+ ): boolean {
+ if (!linearElementEditor) {
+ return false;
+ }
+ const { elementId } = linearElementEditor;
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+ if (!element) {
+ return false;
+ }
+
+ if (
+ isElbowArrow(element) &&
+ !linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
+ linearElementEditor.pointerDownState.lastClickedPoint !== 0
+ ) {
+ return false;
+ }
+
+ const selectedPointsIndices = isElbowArrow(element)
+ ? linearElementEditor.selectedPointsIndices
+ ?.reduce(
+ (startEnd, index) =>
+ (index === 0
+ ? [0, startEnd[1]]
+ : [startEnd[0], element.points.length - 1]) as [
+ boolean | number,
+ boolean | number,
+ ],
+ [false, false] as [number | boolean, number | boolean],
+ )
+ .filter(
+ (idx: number | boolean): idx is number => typeof idx === "number",
+ )
+ : 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[lastClickedPoint] as
+ | [number, number]
+ | undefined;
+
+ if (selectedPointsIndices && draggingPoint) {
+ if (
+ shouldRotateWithDiscreteAngle(event) &&
+ selectedPointsIndices.length === 1 &&
+ element.points.length > 1
+ ) {
+ const selectedIndex = selectedPointsIndices[0];
+ const referencePoint =
+ element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
+
+ const [width, height] = LinearElementEditor._getShiftLockedDelta(
+ element,
+ elementsMap,
+ referencePoint,
+ pointFrom(scenePointerX, scenePointerY),
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
+ );
+
+ LinearElementEditor.movePoints(element, [
+ {
+ index: selectedIndex,
+ 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 : app.getEffectiveGridSize(),
+ );
+
+ const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
+ const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
+
+ LinearElementEditor.movePoints(
+ element,
+ selectedPointsIndices.map((pointIndex) => {
+ const newPointPosition: LocalPoint =
+ pointIndex === lastClickedPoint
+ ? LinearElementEditor.createPointAt(
+ element,
+ elementsMap,
+ scenePointerX - linearElementEditor.pointerOffset.x,
+ scenePointerY - linearElementEditor.pointerOffset.y,
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
+ )
+ : pointFrom(
+ element.points[pointIndex][0] + deltaX,
+ element.points[pointIndex][1] + deltaY,
+ );
+ return {
+ index: pointIndex,
+ point: newPointPosition,
+ isDragging: pointIndex === lastClickedPoint,
+ };
+ }),
+ );
+ }
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ handleBindTextResize(element, elementsMap, false);
+ }
+
+ // suggest bindings for first and last point if selected
+ if (isBindingElement(element, false)) {
+ const coords: { x: number; y: number }[] = [];
+
+ const firstSelectedIndex = selectedPointsIndices[0];
+ if (firstSelectedIndex === 0) {
+ coords.push(
+ tupleToCoors(
+ LinearElementEditor.getPointGlobalCoordinates(
+ element,
+ element.points[0],
+ elementsMap,
+ ),
+ ),
+ );
+ }
+
+ const lastSelectedIndex =
+ selectedPointsIndices[selectedPointsIndices.length - 1];
+ if (lastSelectedIndex === element.points.length - 1) {
+ coords.push(
+ tupleToCoors(
+ LinearElementEditor.getPointGlobalCoordinates(
+ element,
+ element.points[lastSelectedIndex],
+ elementsMap,
+ ),
+ ),
+ );
+ }
+
+ if (coords.length) {
+ maybeSuggestBinding(element, coords);
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ 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, elementsMap);
+ if (!element) {
+ return editingLinearElement;
+ }
+
+ const bindings: Mutable<
+ Partial<
+ Pick<
+ InstanceType<typeof LinearElementEditor>,
+ "startBindingElement" | "endBindingElement"
+ >
+ >
+ > = {};
+
+ if (isDragging && selectedPointsIndices) {
+ for (const selectedPoint of selectedPointsIndices) {
+ if (
+ selectedPoint === 0 ||
+ selectedPoint === element.points.length - 1
+ ) {
+ if (isPathALoop(element.points, appState.zoom.value)) {
+ LinearElementEditor.movePoints(element, [
+ {
+ index: selectedPoint,
+ point:
+ selectedPoint === 0
+ ? element.points[element.points.length - 1]
+ : element.points[0],
+ },
+ ]);
+ }
+
+ const bindingElement = isBindingEnabled(appState)
+ ? getHoveredElementForBinding(
+ tupleToCoors(
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ element,
+ selectedPoint!,
+ elementsMap,
+ ),
+ ),
+ elements,
+ elementsMap,
+ appState.zoom,
+ isElbowArrow(element),
+ isElbowArrow(element),
+ )
+ : null;
+
+ bindings[
+ selectedPoint === 0 ? "startBindingElement" : "endBindingElement"
+ ] = bindingElement;
+ }
+ }
+ }
+
+ return {
+ ...editingLinearElement,
+ ...bindings,
+ // if clicking without previously dragging a point(s), and not holding
+ // shift, deselect all points except the one clicked. If holding shift,
+ // toggle the point.
+ selectedPointsIndices:
+ isDragging || event.shiftKey
+ ? !isDragging &&
+ event.shiftKey &&
+ pointerDownState.prevSelectedPointsIndices?.includes(
+ pointerDownState.lastClickedPoint,
+ )
+ ? selectedPointsIndices &&
+ selectedPointsIndices.filter(
+ (pointIndex) =>
+ pointIndex !== pointerDownState.lastClickedPoint,
+ )
+ : selectedPointsIndices
+ : selectedPointsIndices?.includes(pointerDownState.lastClickedPoint)
+ ? [pointerDownState.lastClickedPoint]
+ : selectedPointsIndices,
+ isDragging: false,
+ pointerOffset: { x: 0, y: 0 },
+ };
+ }
+
+ static getEditorMidPoints = (
+ element: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: ElementsMap,
+ appState: InteractiveCanvasAppState,
+ ): typeof editorMidPointsCache["points"] => {
+ const boundText = getBoundTextElement(element, elementsMap);
+
+ // Since its not needed outside editor unless 2 pointer lines or bound text
+ if (
+ !isElbowArrow(element) &&
+ !appState.editingLinearElement &&
+ element.points.length > 2 &&
+ !boundText
+ ) {
+ return [];
+ }
+ if (
+ editorMidPointsCache.version === element.version &&
+ editorMidPointsCache.zoom === appState.zoom.value
+ ) {
+ return editorMidPointsCache.points;
+ }
+ LinearElementEditor.updateEditorMidPointsCache(
+ element,
+ elementsMap,
+ appState,
+ );
+ return editorMidPointsCache.points!;
+ };
+
+ static updateEditorMidPointsCache = (
+ element: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: ElementsMap,
+ appState: InteractiveCanvasAppState,
+ ) => {
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
+
+ let index = 0;
+ const midpoints: (GlobalPoint | null)[] = [];
+ while (index < points.length - 1) {
+ if (
+ LinearElementEditor.isSegmentTooShort(
+ element,
+ element.points[index],
+ element.points[index + 1],
+ index,
+ appState.zoom,
+ )
+ ) {
+ midpoints.push(null);
+ index++;
+ continue;
+ }
+ const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
+ element,
+ points[index],
+ points[index + 1],
+ index + 1,
+ elementsMap,
+ );
+ midpoints.push(segmentMidPoint);
+ index++;
+ }
+ editorMidPointsCache.points = midpoints;
+ editorMidPointsCache.version = element.version;
+ editorMidPointsCache.zoom = appState.zoom.value;
+ };
+
+ static getSegmentMidpointHitCoords = (
+ linearElementEditor: LinearElementEditor,
+ scenePointer: { x: number; y: number },
+ appState: AppState,
+ elementsMap: ElementsMap,
+ ): GlobalPoint | null => {
+ const { elementId } = linearElementEditor;
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+ if (!element) {
+ return null;
+ }
+ const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
+ element,
+ elementsMap,
+ appState.zoom,
+ scenePointer.x,
+ scenePointer.y,
+ );
+ if (!isElbowArrow(element) && clickedPointIndex >= 0) {
+ return null;
+ }
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ element,
+ elementsMap,
+ );
+ if (
+ points.length >= 3 &&
+ !appState.editingLinearElement &&
+ !isElbowArrow(element)
+ ) {
+ return null;
+ }
+
+ const threshold =
+ (LinearElementEditor.POINT_HANDLE_SIZE + 1) / appState.zoom.value;
+
+ const existingSegmentMidpointHitCoords =
+ linearElementEditor.segmentMidPointHoveredCoords;
+ if (existingSegmentMidpointHitCoords) {
+ const distance = pointDistance(
+ pointFrom(
+ existingSegmentMidpointHitCoords[0],
+ existingSegmentMidpointHitCoords[1],
+ ),
+ pointFrom(scenePointer.x, scenePointer.y),
+ );
+ if (distance <= threshold) {
+ return existingSegmentMidpointHitCoords;
+ }
+ }
+ let index = 0;
+ const midPoints: typeof editorMidPointsCache["points"] =
+ LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
+
+ while (index < midPoints.length) {
+ if (midPoints[index] !== null) {
+ const distance = pointDistance(
+ midPoints[index]!,
+ pointFrom(scenePointer.x, scenePointer.y),
+ );
+ if (distance <= threshold) {
+ return midPoints[index];
+ }
+ }
+
+ index++;
+ }
+ return null;
+ };
+
+ static isSegmentTooShort<P extends GlobalPoint | LocalPoint>(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ startPoint: P,
+ endPoint: P,
+ index: number,
+ zoom: Zoom,
+ ) {
+ 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);
+ }
+
+ return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
+ }
+
+ static getSegmentMidPoint(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ startPoint: GlobalPoint,
+ endPoint: GlobalPoint,
+ endPointIndex: number,
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
+ let segmentMidPoint = pointCenter(startPoint, endPoint);
+ if (element.points.length > 2 && element.roundness) {
+ const controlPoints = getControlPointsForBezierCurve(
+ element,
+ element.points[endPointIndex],
+ );
+ if (controlPoints) {
+ const t = mapIntervalToBezierT(
+ element,
+ element.points[endPointIndex],
+ 0.5,
+ );
+
+ segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
+ element,
+ getBezierXY(
+ controlPoints[0],
+ controlPoints[1],
+ controlPoints[2],
+ controlPoints[3],
+ t,
+ ),
+ elementsMap,
+ );
+ }
+ }
+
+ return segmentMidPoint;
+ }
+
+ static getSegmentMidPointIndex(
+ linearElementEditor: LinearElementEditor,
+ appState: AppState,
+ midPoint: GlobalPoint,
+ elementsMap: ElementsMap,
+ ) {
+ const element = LinearElementEditor.getElement(
+ linearElementEditor.elementId,
+ elementsMap,
+ );
+ if (!element) {
+ return -1;
+ }
+ const midPoints = LinearElementEditor.getEditorMidPoints(
+ element,
+ elementsMap,
+ appState,
+ );
+ let index = 0;
+ while (index < midPoints.length) {
+ if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) {
+ return index + 1;
+ }
+ index++;
+ }
+ return -1;
+ }
+
+ static handlePointerDown(
+ event: React.PointerEvent<HTMLElement>,
+ app: AppClassProperties,
+ store: Store,
+ scenePointer: { x: number; y: number },
+ linearElementEditor: LinearElementEditor,
+ scene: Scene,
+ ): {
+ didAddPoint: boolean;
+ hitElement: NonDeleted<ExcalidrawElement> | null;
+ linearElementEditor: LinearElementEditor | null;
+ } {
+ const appState = app.state;
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+
+ const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
+ didAddPoint: false,
+ hitElement: null,
+ linearElementEditor: null,
+ };
+
+ if (!linearElementEditor) {
+ return ret;
+ }
+
+ const { elementId } = linearElementEditor;
+ const element = LinearElementEditor.getElement(elementId, elementsMap);
+
+ if (!element) {
+ return ret;
+ }
+ const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords(
+ linearElementEditor,
+ scenePointer,
+ appState,
+ elementsMap,
+ );
+ let segmentMidpointIndex = null;
+ if (segmentMidpoint) {
+ segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex(
+ linearElementEditor,
+ appState,
+ segmentMidpoint,
+ elementsMap,
+ );
+ } 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 : app.getEffectiveGridSize(),
+ ),
+ ],
+ });
+ ret.didAddPoint = true;
+ }
+ store.shouldCaptureIncrement();
+ ret.linearElementEditor = {
+ ...linearElementEditor,
+ pointerDownState: {
+ prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
+ lastClickedPoint: -1,
+ lastClickedIsEndPoint: false,
+ origin: { x: scenePointer.x, y: scenePointer.y },
+ segmentMidpoint: {
+ value: segmentMidpoint,
+ index: segmentMidpointIndex,
+ added: false,
+ },
+ },
+ selectedPointsIndices: [element.points.length - 1],
+ lastUncommittedPoint: null,
+ endBindingElement: getHoveredElementForBinding(
+ scenePointer,
+ elements,
+ elementsMap,
+ app.state.zoom,
+ linearElementEditor.elbowed,
+ ),
+ };
+
+ ret.didAddPoint = true;
+ return ret;
+ }
+
+ const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
+ element,
+ elementsMap,
+ appState.zoom,
+ scenePointer.x,
+ scenePointer.y,
+ );
+ // if we clicked on a point, set the element as hitElement otherwise
+ // it would get deselected if the point is outside the hitbox area
+ if (clickedPointIndex >= 0 || segmentMidpoint) {
+ ret.hitElement = element;
+ } else {
+ // You might be wandering why we are storing the binding elements on
+ // LinearElementEditor and passing them in, instead of calculating them
+ // from the end points of the `linearElement` - this is to allow disabling
+ // binding (which needs to happen at the point the user finishes moving
+ // the point).
+ const { startBindingElement, endBindingElement } = linearElementEditor;
+ if (isBindingEnabled(appState) && isBindingElement(element)) {
+ bindOrUnbindLinearElement(
+ element,
+ startBindingElement,
+ endBindingElement,
+ elementsMap,
+ scene,
+ );
+ }
+ }
+
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const targetPoint =
+ clickedPointIndex > -1 &&
+ pointRotateRads(
+ pointFrom(
+ element.x + element.points[clickedPointIndex][0],
+ element.y + element.points[clickedPointIndex][1],
+ ),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+
+ const nextSelectedPointsIndices =
+ clickedPointIndex > -1 || event.shiftKey
+ ? event.shiftKey ||
+ linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
+ ? normalizeSelectedPoints([
+ ...(linearElementEditor.selectedPointsIndices || []),
+ clickedPointIndex,
+ ])
+ : [clickedPointIndex]
+ : null;
+ ret.linearElementEditor = {
+ ...linearElementEditor,
+ pointerDownState: {
+ prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
+ lastClickedPoint: clickedPointIndex,
+ lastClickedIsEndPoint: clickedPointIndex === element.points.length - 1,
+ origin: { x: scenePointer.x, y: scenePointer.y },
+ segmentMidpoint: {
+ value: segmentMidpoint,
+ index: segmentMidpointIndex,
+ added: false,
+ },
+ },
+ selectedPointsIndices: nextSelectedPointsIndices,
+ pointerOffset: targetPoint
+ ? {
+ x: scenePointer.x - targetPoint[0],
+ y: scenePointer.y - targetPoint[1],
+ }
+ : { x: 0, y: 0 },
+ };
+
+ return ret;
+ }
+
+ static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
+ point1: Point | null,
+ point2: Point | null,
+ ) {
+ if (!point1 && !point2) {
+ return true;
+ }
+ if (!point1 || !point2) {
+ return false;
+ }
+ return pointsEqual(point1, point2);
+ }
+
+ static handlePointerMove(
+ event: React.PointerEvent<HTMLCanvasElement>,
+ scenePointerX: number,
+ scenePointerY: number,
+ 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, elementsMap);
+ if (!element) {
+ return appState.editingLinearElement;
+ }
+
+ const { points } = element;
+ const lastPoint = points[points.length - 1];
+
+ if (!event.altKey) {
+ if (lastPoint === lastUncommittedPoint) {
+ LinearElementEditor.deletePoints(element, [points.length - 1]);
+ }
+ return {
+ ...appState.editingLinearElement,
+ lastUncommittedPoint: null,
+ };
+ }
+
+ let newPoint: LocalPoint;
+
+ if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
+ const lastCommittedPoint = points[points.length - 2];
+
+ const [width, height] = LinearElementEditor._getShiftLockedDelta(
+ element,
+ elementsMap,
+ lastCommittedPoint,
+ pointFrom(scenePointerX, scenePointerY),
+ event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
+ );
+
+ 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] || isElbowArrow(element)
+ ? null
+ : app.getEffectiveGridSize(),
+ );
+ }
+
+ if (lastPoint === lastUncommittedPoint) {
+ LinearElementEditor.movePoints(element, [
+ {
+ index: element.points.length - 1,
+ point: newPoint,
+ },
+ ]);
+ } else {
+ LinearElementEditor.addPoints(element, [{ point: newPoint }]);
+ }
+ return {
+ ...appState.editingLinearElement,
+ lastUncommittedPoint: element.points[element.points.length - 1],
+ };
+ }
+
+ /** scene coords */
+ static getPointGlobalCoordinates(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ p: LocalPoint,
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+
+ const { x, y } = element;
+ return pointRotateRads(
+ pointFrom(x + p[0], y + p[1]),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ }
+
+ /** scene coords */
+ static getPointsGlobalCoordinates(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ 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((p) => {
+ const { x, y } = element;
+ return pointRotateRads(
+ pointFrom(x + p[0], y + p[1]),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+ });
+ }
+
+ static getPointAtIndexGlobalCoordinates(
+ element: NonDeleted<ExcalidrawLinearElement>,
+
+ indexMaybeFromEnd: number, // -1 for last element
+ elementsMap: ElementsMap,
+ ): GlobalPoint {
+ const index =
+ indexMaybeFromEnd < 0
+ ? element.points.length + indexMaybeFromEnd
+ : indexMaybeFromEnd;
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const p = element.points[index];
+ const { x, y } = element;
+
+ 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<ExcalidrawLinearElement>,
+ 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] = pointRotateRads(
+ pointFrom(absoluteCoords[0], absoluteCoords[1]),
+ pointFrom(cx, cy),
+ -element.angle as Radians,
+ );
+ return pointFrom(x - element.x, y - element.y);
+ }
+
+ static getPointIndexUnderCursor(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: ElementsMap,
+ zoom: AppState["zoom"],
+ x: number,
+ y: number,
+ ) {
+ 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 p = pointHandles[idx];
+ if (
+ pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
+ // +1px to account for outline stroke
+ LinearElementEditor.POINT_HANDLE_SIZE + 1
+ ) {
+ return idx;
+ }
+ }
+ return -1;
+ }
+
+ static createPointAt(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ elementsMap: ElementsMap,
+ scenePointerX: number,
+ scenePointerY: number,
+ gridSize: NullableGridSize,
+ ): LocalPoint {
+ const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const [rotatedX, rotatedY] = pointRotateRads(
+ pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
+ pointFrom(cx, cy),
+ -element.angle as Radians,
+ );
+
+ return pointFrom(rotatedX - element.x, rotatedY - element.y);
+ }
+
+ /**
+ * Normalizes line points so that the start point is at [0,0]. This is
+ * expected in various parts of the codebase. Also returns new x/y to account
+ * for the potential normalization.
+ */
+ 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((p) => {
+ return pointFrom(p[0] - offsetX, p[1] - offsetY);
+ }),
+ x: element.x + offsetX,
+ y: element.y + offsetY,
+ };
+ }
+
+ // element-mutating methods
+ // ---------------------------------------------------------------------------
+
+ static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
+ mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
+ }
+
+ 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);
+
+ 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;
+
+ const nextSelectedIndices: number[] = [];
+
+ let pointAddedToEnd = false;
+ let indexCursor = -1;
+ const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
+ ++indexCursor;
+ acc.push(p);
+
+ const isSelected = selectedPointsIndices.includes(index);
+ if (isSelected) {
+ const nextPoint = points[index + 1];
+
+ if (!nextPoint) {
+ pointAddedToEnd = true;
+ }
+ acc.push(
+ nextPoint
+ ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
+ : pointFrom(p[0], p[1]),
+ );
+
+ nextSelectedIndices.push(indexCursor + 1);
+ ++indexCursor;
+ }
+
+ return acc;
+ }, []);
+
+ mutateElement(element, { points: nextPoints });
+
+ // temp hack to ensure the line doesn't move when adding point to the end,
+ // potentially expanding the bounding box
+ if (pointAddedToEnd) {
+ const lastPoint = element.points[element.points.length - 1];
+ LinearElementEditor.movePoints(element, [
+ {
+ index: element.points.length - 1,
+ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
+ },
+ ]);
+ }
+
+ return {
+ ...appState,
+ editingLinearElement: {
+ ...appState.editingLinearElement,
+ selectedPointsIndices: nextSelectedIndices,
+ },
+ };
+ }
+
+ static deletePoints(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ pointIndices: readonly number[],
+ ) {
+ let offsetX = 0;
+ let offsetY = 0;
+
+ const isDeletingOriginPoint = pointIndices.includes(0);
+
+ // if deleting first point, make the next to be [0,0] and recalculate
+ // positions of the rest with respect to it
+ if (isDeletingOriginPoint) {
+ const firstNonDeletedPoint = element.points.find((point, idx) => {
+ return !pointIndices.includes(idx);
+ });
+ if (firstNonDeletedPoint) {
+ offsetX = firstNonDeletedPoint[0];
+ offsetY = firstNonDeletedPoint[1];
+ }
+ }
+
+ const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
+ if (!pointIndices.includes(idx)) {
+ acc.push(
+ !acc.length
+ ? pointFrom(0, 0)
+ : pointFrom(p[0] - offsetX, p[1] - offsetY),
+ );
+ }
+ return acc;
+ }, []);
+
+ LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
+ }
+
+ static addPoints(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ targetPoints: { point: LocalPoint }[],
+ ) {
+ const offsetX = 0;
+ const offsetY = 0;
+
+ const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
+ LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
+ }
+
+ static movePoints(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
+ otherUpdates?: {
+ startBinding?: PointBinding | null;
+ endBinding?: PointBinding | null;
+ },
+ ) {
+ const { points } = element;
+
+ // in case we're moving start point, instead of modifying its position
+ // which would break the invariant of it being at [0,0], we move
+ // 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.
+ const [deltaX, deltaY] =
+ targetPoints.find(({ index }) => index === 0)?.point ??
+ pointFrom<LocalPoint>(0, 0);
+ const [offsetX, offsetY] = pointFrom<LocalPoint>(
+ deltaX - points[0][0],
+ deltaY - points[0][1],
+ );
+
+ 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;
+
+ return pointFrom<LocalPoint>(
+ current[0] - offsetX,
+ current[1] - offsetY,
+ );
+ });
+
+ LinearElementEditor._updatePoints(
+ element,
+ nextPoints,
+ offsetX,
+ offsetY,
+ otherUpdates,
+ {
+ isDragging: targetPoints.reduce(
+ (dragging, targetPoint): boolean =>
+ dragging || targetPoint.isDragging === true,
+ false,
+ ),
+ },
+ );
+ }
+
+ static shouldAddMidpoint(
+ 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;
+ }
+
+ const { segmentMidpoint } = linearElementEditor.pointerDownState;
+
+ if (
+ segmentMidpoint.added ||
+ segmentMidpoint.value === null ||
+ segmentMidpoint.index === null ||
+ linearElementEditor.pointerDownState.origin === null
+ ) {
+ return false;
+ }
+
+ const origin = linearElementEditor.pointerDownState.origin!;
+ const dist = pointDistance(
+ pointFrom(origin.x, origin.y),
+ pointFrom(pointerCoords.x, pointerCoords.y),
+ );
+ if (
+ !appState.editingLinearElement &&
+ dist < DRAGGING_THRESHOLD / appState.zoom.value
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ static addMidpoint(
+ linearElementEditor: LinearElementEditor,
+ pointerCoords: PointerCoords,
+ app: AppClassProperties,
+ snapToGrid: boolean,
+ elementsMap: ElementsMap,
+ ) {
+ const element = LinearElementEditor.getElement(
+ linearElementEditor.elementId,
+ elementsMap,
+ );
+ if (!element) {
+ return;
+ }
+ const { segmentMidpoint } = linearElementEditor.pointerDownState;
+ const ret: {
+ pointerDownState: LinearElementEditor["pointerDownState"];
+ selectedPointsIndices: LinearElementEditor["selectedPointsIndices"];
+ } = {
+ pointerDownState: linearElementEditor.pointerDownState,
+ selectedPointsIndices: linearElementEditor.selectedPointsIndices,
+ };
+
+ const midpoint = LinearElementEditor.createPointAt(
+ element,
+ elementsMap,
+ pointerCoords.x,
+ pointerCoords.y,
+ snapToGrid && !isElbowArrow(element) ? app.getEffectiveGridSize() : null,
+ );
+ const points = [
+ ...element.points.slice(0, segmentMidpoint.index!),
+ midpoint,
+ ...element.points.slice(segmentMidpoint.index!),
+ ];
+
+ mutateElement(element, {
+ points,
+ });
+
+ ret.pointerDownState = {
+ ...linearElementEditor.pointerDownState,
+ segmentMidpoint: {
+ ...linearElementEditor.pointerDownState.segmentMidpoint,
+ added: true,
+ },
+ lastClickedPoint: segmentMidpoint.index!,
+ };
+ ret.selectedPointsIndices = [segmentMidpoint.index!];
+ return ret;
+ }
+
+ private static _updatePoints(
+ element: NonDeleted<ExcalidrawLinearElement>,
+ nextPoints: readonly LocalPoint[],
+ offsetX: number,
+ offsetY: number,
+ otherUpdates?: {
+ startBinding?: PointBinding | null;
+ endBinding?: PointBinding | null;
+ },
+ options?: {
+ isDragging?: boolean;
+ zoom?: AppState["zoom"];
+ },
+ ) {
+ 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);
+
+ mutateElement(element, updates, true, {
+ isDragging: options?.isDragging,
+ });
+ } 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<ExcalidrawLinearElement>,
+ 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],
+ gridSize,
+ );
+
+ const { width, height } = getLockedLinearCursorAlignSize(
+ referencePointCoords[0],
+ referencePointCoords[1],
+ gridX,
+ gridY,
+ );
+
+ 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,
+ elementsMap,
+ );
+ if (points.length < 2) {
+ mutateElement(boundTextElement, { isDeleted: true });
+ }
+ let x = 0;
+ let y = 0;
+ if (element.points.length % 2 === 1) {
+ const index = Math.floor(element.points.length / 2);
+ const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+ element,
+ element.points[index],
+ elementsMap,
+ );
+ x = midPoint[0] - boundTextElement.width / 2;
+ y = midPoint[1] - boundTextElement.height / 2;
+ } else {
+ const index = element.points.length / 2 - 1;
+
+ let midSegmentMidpoint = editorMidPointsCache.points[index];
+ if (element.points.length === 2) {
+ midSegmentMidpoint = pointCenter(points[0], points[1]);
+ }
+ if (
+ !midSegmentMidpoint ||
+ editorMidPointsCache.version !== element.version
+ ) {
+ midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+ element,
+ points[index],
+ points[index + 1],
+ index + 1,
+ elementsMap,
+ );
+ }
+ x = midSegmentMidpoint[0] - boundTextElement.width / 2;
+ y = midSegmentMidpoint[1] - boundTextElement.height / 2;
+ }
+ return { x, y };
+ };
+
+ static getMinMaxXYWithBoundText = (
+ element: ExcalidrawLinearElement,
+ elementsMap: ElementsMap,
+ elementBounds: Bounds,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+ ): [number, number, number, number, number, number] => {
+ let [x1, y1, x2, y2] = elementBounds;
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const { x: boundTextX1, y: boundTextY1 } =
+ LinearElementEditor.getBoundTextElementPosition(
+ element,
+ boundTextElement,
+ elementsMap,
+ );
+ const boundTextX2 = boundTextX1 + boundTextElement.width;
+ const boundTextY2 = boundTextY1 + boundTextElement.height;
+ const centerPoint = pointFrom(cx, cy);
+
+ const topLeftRotatedPoint = pointRotateRads(
+ pointFrom(x1, y1),
+ centerPoint,
+ element.angle,
+ );
+ const topRightRotatedPoint = pointRotateRads(
+ pointFrom(x2, y1),
+ centerPoint,
+ element.angle,
+ );
+
+ const counterRotateBoundTextTopLeft = pointRotateRads(
+ pointFrom(boundTextX1, boundTextY1),
+ centerPoint,
+ -element.angle as Radians,
+ );
+ 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 (
+ topLeftRotatedPoint[0] < topRightRotatedPoint[0] &&
+ topLeftRotatedPoint[1] >= topRightRotatedPoint[1]
+ ) {
+ x1 = Math.min(x1, counterRotateBoundTextBottomLeft[0]);
+ x2 = Math.max(
+ x2,
+ Math.max(
+ counterRotateBoundTextTopRight[0],
+ counterRotateBoundTextBottomRight[0],
+ ),
+ );
+ y1 = Math.min(y1, counterRotateBoundTextTopLeft[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextBottomRight[1]);
+ } else if (
+ topLeftRotatedPoint[0] >= topRightRotatedPoint[0] &&
+ topLeftRotatedPoint[1] > topRightRotatedPoint[1]
+ ) {
+ x1 = Math.min(x1, counterRotateBoundTextBottomRight[0]);
+ x2 = Math.max(
+ x2,
+ Math.max(
+ counterRotateBoundTextTopLeft[0],
+ counterRotateBoundTextTopRight[0],
+ ),
+ );
+ y1 = Math.min(y1, counterRotateBoundTextBottomLeft[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextTopRight[1]);
+ } else if (topLeftRotatedPoint[0] >= topRightRotatedPoint[0]) {
+ x1 = Math.min(x1, counterRotateBoundTextTopRight[0]);
+ x2 = Math.max(x2, counterRotateBoundTextBottomLeft[0]);
+ y1 = Math.min(y1, counterRotateBoundTextBottomRight[1]);
+
+ y2 = Math.max(y2, counterRotateBoundTextTopLeft[1]);
+ } else if (topLeftRotatedPoint[1] <= topRightRotatedPoint[1]) {
+ x1 = Math.min(
+ x1,
+ Math.min(
+ counterRotateBoundTextTopRight[0],
+ counterRotateBoundTextTopLeft[0],
+ ),
+ );
+
+ x2 = Math.max(x2, counterRotateBoundTextBottomRight[0]);
+ y1 = Math.min(y1, counterRotateBoundTextTopRight[1]);
+ y2 = Math.max(y2, counterRotateBoundTextBottomLeft[1]);
+ }
+
+ return [x1, y1, x2, y2, cx, cy];
+ };
+
+ static getElementAbsoluteCoords = (
+ element: ExcalidrawLinearElement,
+ elementsMap: ElementsMap,
+ includeBoundText: boolean = false,
+ ): [number, number, number, number, number, number] => {
+ let coords: [number, number, number, number, number, number];
+ let x1;
+ let y1;
+ let x2;
+ let y2;
+ if (element.points.length < 2 || !ShapeCache.get(element)) {
+ // XXX this is just a poor estimate and not very useful
+ const { minX, minY, maxX, maxY } = element.points.reduce(
+ (limits, [x, y]) => {
+ limits.minY = Math.min(limits.minY, y);
+ limits.minX = Math.min(limits.minX, x);
+
+ limits.maxX = Math.max(limits.maxX, x);
+ limits.maxY = Math.max(limits.maxY, y);
+
+ return limits;
+ },
+ { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+ );
+ x1 = minX + element.x;
+ y1 = minY + element.y;
+ x2 = maxX + element.x;
+ y2 = maxY + element.y;
+ } else {
+ const shape = ShapeCache.generateElementShape(element, null);
+
+ // first element is always the curve
+ const ops = getCurvePathOps(shape[0]);
+
+ const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
+ x1 = minX + element.x;
+ y1 = minY + element.y;
+ x2 = maxX + element.x;
+ y2 = maxY + element.y;
+ }
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ coords = [x1, y1, x2, y2, cx, cy];
+
+ if (!includeBoundText) {
+ return coords;
+ }
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ coords = LinearElementEditor.getMinMaxXYWithBoundText(
+ element,
+ elementsMap,
+ [x1, y1, x2, y2],
+ boundTextElement,
+ );
+ }
+
+ 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<number, FixedSegment>,
+ );
+ fixedSegments[index] = {
+ index,
+ start: pointFrom<LocalPoint>(
+ !isHorizontal ? x - element.x : element.points[index - 1][0],
+ isHorizontal ? y - element.y : element.points[index - 1][1],
+ ),
+ end: pointFrom<LocalPoint>(
+ !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<GlobalPoint>(
+ 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 = (
+ points: (number | null)[],
+): number[] | null => {
+ let nextPoints = [
+ ...new Set(points.filter((p) => p !== null && p !== -1)),
+ ] as number[];
+ nextPoints = nextPoints.sort((a, b) => a - b);
+ return nextPoints.length ? nextPoints : null;
+};
diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts
new file mode 100644
index 0000000..cfff5c8
--- /dev/null
+++ b/packages/excalidraw/element/mutateElement.ts
@@ -0,0 +1,196 @@
+import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
+import Scene from "../scene/Scene";
+import { getSizeFromPoints } from "../points";
+import { randomInteger } from "../random";
+import { getUpdatedTimestamp, toBrandedType } from "../utils";
+import type { Mutable } from "../utility-types";
+import { ShapeCache } from "../scene/ShapeCache";
+import { isElbowArrow } from "./typeChecks";
+import { updateElbowArrowPoints } from "./elbowArrow";
+import type { Radians } from "@excalidraw/math";
+
+export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
+ Partial<TElement>,
+ "id" | "version" | "versionNonce" | "updated"
+>;
+
+// This function tracks updates of text elements for the purposes for collaboration.
+// The version is used to compare updates when more than one user is working in
+// the same drawing. Note: this will trigger the component to update. Make sure you
+// are calling it either from a React event handler or within unstable_batchedUpdates().
+export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
+ element: TElement,
+ updates: ElementUpdate<TElement>,
+ 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, fixedSegments, fileId, startBinding, endBinding } =
+ updates as any;
+
+ 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<NonDeletedSceneElementsMap>(
+ 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 };
+ }
+
+ for (const key in updates) {
+ const value = (updates as any)[key];
+ if (typeof value !== "undefined") {
+ if (
+ (element as any)[key] === value &&
+ // if object, always update because its attrs could have changed
+ // (except for specific keys we handle below)
+ (typeof value !== "object" ||
+ value === null ||
+ key === "groupIds" ||
+ key === "scale")
+ ) {
+ continue;
+ }
+
+ if (key === "scale") {
+ const prevScale = (element as any)[key];
+ const nextScale = value;
+ if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
+ continue;
+ }
+ } else if (key === "points") {
+ const prevPoints = (element as any)[key];
+ const nextPoints = value;
+ if (prevPoints.length === nextPoints.length) {
+ let didChangePoints = false;
+ let index = prevPoints.length;
+ while (--index) {
+ const prevPoint = prevPoints[index];
+ const nextPoint = nextPoints[index];
+ if (
+ prevPoint[0] !== nextPoint[0] ||
+ prevPoint[1] !== nextPoint[1]
+ ) {
+ didChangePoints = true;
+ break;
+ }
+ }
+ if (!didChangePoints) {
+ continue;
+ }
+ }
+ }
+
+ (element as any)[key] = value;
+ didChange = true;
+ }
+ }
+
+ if (!didChange) {
+ return element;
+ }
+
+ if (
+ typeof updates.height !== "undefined" ||
+ typeof updates.width !== "undefined" ||
+ typeof fileId != "undefined" ||
+ typeof points !== "undefined"
+ ) {
+ ShapeCache.delete(element);
+ }
+
+ element.version++;
+ element.versionNonce = randomInteger();
+ element.updated = getUpdatedTimestamp();
+
+ if (informMutation) {
+ Scene.getScene(element)?.triggerUpdate();
+ }
+
+ return element;
+};
+
+export const newElementWith = <TElement extends ExcalidrawElement>(
+ element: TElement,
+ updates: ElementUpdate<TElement>,
+ /** pass `true` to always regenerate */
+ force = false,
+): TElement => {
+ let didChange = false;
+ for (const key in updates) {
+ const value = (updates as any)[key];
+ if (typeof value !== "undefined") {
+ if (
+ (element as any)[key] === value &&
+ // if object, always update because its attrs could have changed
+ (typeof value !== "object" || value === null)
+ ) {
+ continue;
+ }
+ didChange = true;
+ }
+ }
+
+ if (!didChange && !force) {
+ return element;
+ }
+
+ return {
+ ...element,
+ ...updates,
+ updated: getUpdatedTimestamp(),
+ version: element.version + 1,
+ versionNonce: randomInteger(),
+ };
+};
+
+/**
+ * Mutates element, bumping `version`, `versionNonce`, and `updated`.
+ *
+ * NOTE: does not trigger re-render.
+ */
+export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
+ element: T,
+ version?: ExcalidrawElement["version"],
+) => {
+ element.version = (version ?? element.version) + 1;
+ element.versionNonce = randomInteger();
+ element.updated = getUpdatedTimestamp();
+ return element;
+};
diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts
new file mode 100644
index 0000000..9c9006b
--- /dev/null
+++ b/packages/excalidraw/element/newElement.test.ts
@@ -0,0 +1,373 @@
+import { duplicateElement, duplicateElements } from "./newElement";
+import { mutateElement } from "./mutateElement";
+import { API } from "../tests/helpers/api";
+import { FONT_FAMILY, ROUNDNESS } from "../constants";
+import { isPrimitive } from "../utils";
+import type { ExcalidrawLinearElement } from "./types";
+import type { LocalPoint } from "@excalidraw/math";
+import { pointFrom } from "@excalidraw/math";
+
+const assertCloneObjects = (source: any, clone: any) => {
+ for (const key in clone) {
+ if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
+ expect(clone[key]).not.toBe(source[key]);
+ if (source[key]) {
+ assertCloneObjects(source[key], clone[key]);
+ }
+ }
+ }
+};
+
+describe("duplicating single elements", () => {
+ it("clones arrow element", () => {
+ const element = API.createElement({
+ type: "arrow",
+ x: 0,
+ y: 0,
+ strokeColor: "#000000",
+ backgroundColor: "transparent",
+ fillStyle: "hachure",
+ strokeWidth: 1,
+ strokeStyle: "solid",
+ roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+ roughness: 1,
+ opacity: 100,
+ });
+
+ // @ts-ignore
+ element.__proto__ = { hello: "world" };
+
+ mutateElement(element, {
+ points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
+ });
+
+ const copy = duplicateElement(null, new Map(), element);
+
+ assertCloneObjects(element, copy);
+
+ // assert we clone the object's prototype
+ // @ts-ignore
+ expect(copy.__proto__).toEqual({ hello: "world" });
+ expect(copy.hasOwnProperty("hello")).toBe(false);
+
+ expect(copy.points).not.toBe(element.points);
+ expect(copy).not.toHaveProperty("shape");
+ expect(copy.id).not.toBe(element.id);
+ expect(typeof copy.id).toBe("string");
+ expect(copy.seed).not.toBe(element.seed);
+ expect(typeof copy.seed).toBe("number");
+ expect(copy).toEqual({
+ ...element,
+ id: copy.id,
+ seed: copy.seed,
+ });
+ });
+
+ it("clones text element", () => {
+ const element = API.createElement({
+ type: "text",
+ x: 0,
+ y: 0,
+ strokeColor: "#000000",
+ backgroundColor: "transparent",
+ fillStyle: "hachure",
+ strokeWidth: 1,
+ strokeStyle: "solid",
+ roundness: null,
+ roughness: 1,
+ opacity: 100,
+ text: "hello",
+ fontSize: 20,
+ fontFamily: FONT_FAMILY.Virgil,
+ textAlign: "left",
+ verticalAlign: "top",
+ });
+
+ const copy = duplicateElement(null, new Map(), element);
+
+ assertCloneObjects(element, copy);
+
+ expect(copy).not.toHaveProperty("points");
+ expect(copy).not.toHaveProperty("shape");
+ expect(copy.id).not.toBe(element.id);
+ expect(typeof copy.id).toBe("string");
+ expect(typeof copy.seed).toBe("number");
+ });
+});
+
+describe("duplicating multiple elements", () => {
+ it("duplicateElements should clone bindings", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ id: "rectangle1",
+ boundElements: [
+ { id: "arrow1", type: "arrow" },
+ { id: "arrow2", type: "arrow" },
+ { id: "text1", type: "text" },
+ ],
+ });
+
+ const text1 = API.createElement({
+ type: "text",
+ id: "text1",
+ containerId: "rectangle1",
+ });
+
+ const arrow1 = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ });
+
+ const arrow2 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ endBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ boundElements: [{ id: "text2", type: "text" }],
+ });
+
+ const text2 = API.createElement({
+ type: "text",
+ id: "text2",
+ containerId: "arrow2",
+ });
+
+ // -------------------------------------------------------------------------
+
+ const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
+ const clonedElements = duplicateElements(origElements);
+
+ // generic id in-equality checks
+ // --------------------------------------------------------------------------
+ expect(origElements.map((e) => e.type)).toEqual(
+ clonedElements.map((e) => e.type),
+ );
+ origElements.forEach((origElement, idx) => {
+ const clonedElement = clonedElements[idx];
+ expect(origElement).toEqual(
+ expect.objectContaining({
+ id: expect.not.stringMatching(clonedElement.id),
+ type: clonedElement.type,
+ }),
+ );
+ if ("containerId" in origElement) {
+ expect(origElement.containerId).not.toBe(
+ (clonedElement as any).containerId,
+ );
+ }
+ if ("endBinding" in origElement) {
+ if (origElement.endBinding) {
+ expect(origElement.endBinding.elementId).not.toBe(
+ (clonedElement as any).endBinding?.elementId,
+ );
+ } else {
+ expect((clonedElement as any).endBinding).toBeNull();
+ }
+ }
+ if ("startBinding" in origElement) {
+ if (origElement.startBinding) {
+ expect(origElement.startBinding.elementId).not.toBe(
+ (clonedElement as any).startBinding?.elementId,
+ );
+ } else {
+ expect((clonedElement as any).startBinding).toBeNull();
+ }
+ }
+ });
+ // --------------------------------------------------------------------------
+
+ const clonedArrows = clonedElements.filter(
+ (e) => e.type === "arrow",
+ ) as ExcalidrawLinearElement[];
+
+ const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
+ clonedElements as any as typeof origElements;
+
+ expect(clonedText1.containerId).toBe(clonedRectangle.id);
+ expect(
+ clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
+ ).toEqual(
+ expect.objectContaining({
+ id: clonedText1.id,
+ type: clonedText1.type,
+ }),
+ );
+
+ clonedArrows.forEach((arrow) => {
+ expect(
+ clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
+ ).toEqual(
+ expect.objectContaining({
+ id: arrow.id,
+ type: arrow.type,
+ }),
+ );
+
+ if (arrow.endBinding) {
+ expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
+ }
+ if (arrow.startBinding) {
+ expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
+ }
+ });
+
+ expect(clonedArrow2.boundElements).toEqual([
+ { type: "text", id: clonedArrowLabel.id },
+ ]);
+ expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
+ });
+
+ it("should remove id references of elements that aren't found", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ id: "rectangle1",
+ boundElements: [
+ // should keep
+ { id: "arrow1", type: "arrow" },
+ // should drop
+ { id: "arrow-not-exists", type: "arrow" },
+ // should drop
+ { id: "text-not-exists", type: "text" },
+ ],
+ });
+
+ const arrow1 = API.createElement({
+ type: "arrow",
+ id: "arrow1",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ });
+
+ const text1 = API.createElement({
+ type: "text",
+ id: "text1",
+ containerId: "rectangle-not-exists",
+ });
+
+ const arrow2 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ startBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ endBinding: {
+ elementId: "rectangle-not-exists",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ });
+
+ const arrow3 = API.createElement({
+ type: "arrow",
+ id: "arrow2",
+ startBinding: {
+ elementId: "rectangle-not-exists",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ endBinding: {
+ elementId: "rectangle1",
+ focus: 0.2,
+ gap: 7,
+ fixedPoint: [0.5, 1],
+ },
+ });
+
+ // -------------------------------------------------------------------------
+
+ const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
+ const clonedElements = duplicateElements(
+ origElements,
+ ) as any as typeof origElements;
+ const [
+ clonedRectangle,
+ clonedText1,
+ clonedArrow1,
+ clonedArrow2,
+ clonedArrow3,
+ ] = clonedElements;
+
+ expect(clonedRectangle.boundElements).toEqual([
+ { id: clonedArrow1.id, type: "arrow" },
+ ]);
+
+ expect(clonedText1.containerId).toBe(null);
+
+ expect(clonedArrow2.startBinding).toEqual({
+ ...arrow2.startBinding,
+ elementId: clonedRectangle.id,
+ });
+ expect(clonedArrow2.endBinding).toBe(null);
+
+ expect(clonedArrow3.startBinding).toBe(null);
+ expect(clonedArrow3.endBinding).toEqual({
+ ...arrow3.endBinding,
+ elementId: clonedRectangle.id,
+ });
+ });
+
+ describe("should duplicate all group ids", () => {
+ it("should regenerate all group ids and keep them consistent across elements", () => {
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g1"],
+ });
+ const rectangle2 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g2", "g1"],
+ });
+ const rectangle3 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g2", "g1"],
+ });
+
+ const origElements = [rectangle1, rectangle2, rectangle3] as const;
+ const clonedElements = duplicateElements(
+ origElements,
+ ) as any as typeof origElements;
+ const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
+ clonedElements;
+
+ expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
+ expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
+ expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
+
+ expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
+ expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
+ expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
+ });
+
+ it("should keep and regenerate ids of groups even if invalid", () => {
+ // lone element shouldn't be able to be grouped with itself,
+ // but hard to check against in a performant way so we ignore it
+ const rectangle1 = API.createElement({
+ type: "rectangle",
+ groupIds: ["g1"],
+ });
+
+ const [clonedRectangle1] = duplicateElements([rectangle1]);
+
+ expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
+ expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
+ });
+ });
+});
diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts
new file mode 100644
index 0000000..7d1f149
--- /dev/null
+++ b/packages/excalidraw/element/newElement.ts
@@ -0,0 +1,793 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ ExcalidrawTextElement,
+ ExcalidrawLinearElement,
+ ExcalidrawGenericElement,
+ NonDeleted,
+ TextAlign,
+ GroupId,
+ VerticalAlign,
+ Arrowhead,
+ ExcalidrawFreeDrawElement,
+ FontFamilyValues,
+ ExcalidrawTextContainer,
+ ExcalidrawFrameElement,
+ ExcalidrawEmbeddableElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawIframeElement,
+ ElementsMap,
+ ExcalidrawArrowElement,
+ FixedSegment,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+import {
+ arrayToMap,
+ getFontString,
+ getUpdatedTimestamp,
+ isTestEnv,
+} from "../utils";
+import { randomInteger, randomId } from "../random";
+import { bumpVersion, newElementWith } from "./mutateElement";
+import { getNewGroupIdsForDuplication } from "../groups";
+import type { AppState } from "../types";
+import { getElementAbsoluteCoords } from ".";
+import { getResizedElementAbsoluteCoords } from "./bounds";
+import { getBoundTextMaxWidth } from "./textElement";
+import { wrapText } from "./textWrapping";
+import {
+ DEFAULT_ELEMENT_PROPS,
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ DEFAULT_TEXT_ALIGN,
+ DEFAULT_VERTICAL_ALIGN,
+ ORIG_ID,
+ VERTICAL_ALIGN,
+} from "../constants";
+import type { MarkOptional, Merge, Mutable } from "../utility-types";
+import { getLineHeight } from "../fonts";
+import type { Radians } from "@excalidraw/math";
+import { normalizeText, measureText } from "./textMeasurements";
+
+export type ElementConstructorOpts = MarkOptional<
+ Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
+ | "width"
+ | "height"
+ | "angle"
+ | "groupIds"
+ | "frameId"
+ | "index"
+ | "boundElements"
+ | "seed"
+ | "version"
+ | "versionNonce"
+ | "link"
+ | "strokeStyle"
+ | "fillStyle"
+ | "strokeColor"
+ | "backgroundColor"
+ | "roughness"
+ | "strokeWidth"
+ | "roundness"
+ | "locked"
+ | "opacity"
+ | "customData"
+>;
+
+const _newElementBase = <T extends ExcalidrawElement>(
+ 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<Partial<ExcalidrawGenericElement>, "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<ExcalidrawGenericElement, { type: T["type"] }> = {
+ 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<ExcalidrawGenericElement> =>
+ _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
+
+export const newEmbeddableElement = (
+ opts: {
+ type: "embeddable";
+ } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawEmbeddableElement> => {
+ return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
+};
+
+export const newIframeElement = (
+ opts: {
+ type: "iframe";
+ } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawIframeElement> => {
+ return {
+ ..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
+ };
+};
+
+export const newFrameElement = (
+ opts: {
+ name?: string;
+ } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawFrameElement> => {
+ const frameElement = newElementWith(
+ {
+ ..._newElementBase<ExcalidrawFrameElement>("frame", opts),
+ type: "frame",
+ name: opts?.name || null,
+ },
+ {},
+ );
+
+ return frameElement;
+};
+
+export const newMagicFrameElement = (
+ opts: {
+ name?: string;
+ } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawMagicFrameElement> => {
+ const frameElement = newElementWith(
+ {
+ ..._newElementBase<ExcalidrawMagicFrameElement>("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<ExcalidrawTextElement> => {
+ 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<ExcalidrawTextElement>("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<ExcalidrawFreeDrawElement> => {
+ return {
+ ..._newElementBase<ExcalidrawFreeDrawElement>(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<ExcalidrawLinearElement> => {
+ return {
+ ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
+ points: opts.points || [],
+ lastCommittedPoint: null,
+ startBinding: null,
+ endBinding: null,
+ startArrowhead: null,
+ endArrowhead: null,
+ };
+};
+
+export const newArrowElement = <T extends boolean>(
+ opts: {
+ type: ExcalidrawArrowElement["type"];
+ startArrowhead?: Arrowhead | null;
+ endArrowhead?: Arrowhead | null;
+ points?: ExcalidrawArrowElement["points"];
+ elbowed?: T;
+ fixedSegments?: FixedSegment[] | null;
+ } & ElementConstructorOpts,
+): T extends true
+ ? NonDeleted<ExcalidrawElbowArrowElement>
+ : NonDeleted<ExcalidrawArrowElement> => {
+ if (opts.elbowed) {
+ return {
+ ..._newElementBase<ExcalidrawElbowArrowElement>(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<ExcalidrawElbowArrowElement>;
+ }
+
+ return {
+ ..._newElementBase<ExcalidrawArrowElement>(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<ExcalidrawElbowArrowElement>
+ : NonDeleted<ExcalidrawArrowElement>;
+};
+
+export const newImageElement = (
+ opts: {
+ type: ExcalidrawImageElement["type"];
+ status?: ExcalidrawImageElement["status"];
+ fileId?: ExcalidrawImageElement["fileId"];
+ scale?: ExcalidrawImageElement["scale"];
+ crop?: ExcalidrawImageElement["crop"];
+ } & ElementConstructorOpts,
+): NonDeleted<ExcalidrawImageElement> => {
+ return {
+ ..._newElementBase<ExcalidrawImageElement>("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,
+ };
+};
+
+// Simplified deep clone for the purpose of cloning ExcalidrawElement.
+//
+// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
+// Typed arrays and other non-null objects.
+//
+// Adapted from https://github.com/lukeed/klona
+//
+// The reason for `deepCopyElement()` wrapper is type safety (only allow
+// passing ExcalidrawElement as the top-level argument).
+const _deepCopyElement = (val: any, depth: number = 0) => {
+ // only clone non-primitives
+ if (val == null || typeof val !== "object") {
+ return val;
+ }
+
+ const objectType = Object.prototype.toString.call(val);
+
+ if (objectType === "[object Object]") {
+ const tmp =
+ typeof val.constructor === "function"
+ ? Object.create(Object.getPrototypeOf(val))
+ : {};
+ for (const key in val) {
+ if (val.hasOwnProperty(key)) {
+ // don't copy non-serializable objects like these caches. They'll be
+ // populated when the element is rendered.
+ if (depth === 0 && (key === "shape" || key === "canvas")) {
+ continue;
+ }
+ tmp[key] = _deepCopyElement(val[key], depth + 1);
+ }
+ }
+ return tmp;
+ }
+
+ if (Array.isArray(val)) {
+ let k = val.length;
+ const arr = new Array(k);
+ while (k--) {
+ arr[k] = _deepCopyElement(val[k], depth + 1);
+ }
+ return arr;
+ }
+
+ // we're not cloning non-array & non-plain-object objects because we
+ // don't support them on excalidraw elements yet. If we do, we need to make
+ // sure we start cloning them, so let's warn about it.
+ if (import.meta.env.DEV) {
+ if (
+ objectType !== "[object Object]" &&
+ objectType !== "[object Array]" &&
+ objectType.startsWith("[object ")
+ ) {
+ console.warn(
+ `_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
+ );
+ }
+ }
+
+ return val;
+};
+
+/**
+ * Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
+ * any value. The purpose is to to break object references for immutability
+ * reasons, whenever we want to keep the original element, but ensure it's not
+ * mutated.
+ *
+ * Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
+ * Typed arrays and other non-null objects.
+ */
+export const deepCopyElement = <T extends ExcalidrawElement>(
+ val: T,
+): Mutable<T> => {
+ return _deepCopyElement(val);
+};
+
+const __test__defineOrigId = (clonedObj: object, origId: string) => {
+ Object.defineProperty(clonedObj, ORIG_ID, {
+ value: origId,
+ writable: false,
+ enumerable: false,
+ });
+};
+
+/**
+ * utility wrapper to generate new id.
+ */
+const regenerateId = () => {
+ return randomId();
+};
+
+/**
+ * Duplicate an element, often used in the alt-drag operation.
+ * Note that this method has gotten a bit complicated since the
+ * introduction of gruoping/ungrouping elements.
+ * @param editingGroupId The current group being edited. The new
+ * element will inherit this group and its
+ * parents.
+ * @param groupIdMapForOperation A Map that maps old group IDs to
+ * duplicated ones. If you are duplicating
+ * multiple elements at once, share this map
+ * amongst all of them
+ * @param element Element to duplicate
+ * @param overrides Any element properties to override
+ */
+export const duplicateElement = <TElement extends ExcalidrawElement>(
+ editingGroupId: AppState["editingGroupId"],
+ groupIdMapForOperation: Map<GroupId, GroupId>,
+ element: TElement,
+ overrides?: Partial<TElement>,
+): Readonly<TElement> => {
+ let copy = deepCopyElement(element);
+
+ if (isTestEnv()) {
+ __test__defineOrigId(copy, element.id);
+ }
+
+ copy.id = regenerateId();
+ copy.boundElements = null;
+ copy.updated = getUpdatedTimestamp();
+ copy.seed = randomInteger();
+ copy.groupIds = getNewGroupIdsForDuplication(
+ copy.groupIds,
+ editingGroupId,
+ (groupId) => {
+ if (!groupIdMapForOperation.has(groupId)) {
+ groupIdMapForOperation.set(groupId, regenerateId());
+ }
+ return groupIdMapForOperation.get(groupId)!;
+ },
+ );
+ if (overrides) {
+ copy = Object.assign(copy, overrides);
+ }
+ return copy;
+};
+
+/**
+ * Clones elements, regenerating their ids (including bindings) and group ids.
+ *
+ * If bindings don't exist in the elements array, they are removed. Therefore,
+ * it's advised to supply the whole elements array, or sets of elements that
+ * are encapsulated (such as library items), if the purpose is to retain
+ * bindings to the cloned elements intact.
+ *
+ * NOTE by default does not randomize or regenerate anything except the id.
+ */
+export const duplicateElements = (
+ elements: readonly ExcalidrawElement[],
+ opts?: {
+ /** NOTE also updates version flags and `updated` */
+ randomizeSeed: boolean;
+ },
+) => {
+ const clonedElements: ExcalidrawElement[] = [];
+
+ const origElementsMap = arrayToMap(elements);
+
+ // used for for migrating old ids to new ids
+ const elementNewIdsMap = new Map<
+ /* orig */ ExcalidrawElement["id"],
+ /* new */ ExcalidrawElement["id"]
+ >();
+
+ const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
+ // if we've already migrated the element id, return the new one directly
+ if (elementNewIdsMap.has(id)) {
+ return elementNewIdsMap.get(id)!;
+ }
+ // if we haven't migrated the element id, but an old element with the same
+ // id exists, generate a new id for it and return it
+ if (origElementsMap.has(id)) {
+ const newId = regenerateId();
+ elementNewIdsMap.set(id, newId);
+ return newId;
+ }
+ // if old element doesn't exist, return null to mark it for removal
+ return null;
+ };
+
+ const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
+
+ for (const element of elements) {
+ const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
+
+ clonedElement.id = maybeGetNewId(element.id)!;
+ if (isTestEnv()) {
+ __test__defineOrigId(clonedElement, element.id);
+ }
+
+ if (opts?.randomizeSeed) {
+ clonedElement.seed = randomInteger();
+ bumpVersion(clonedElement);
+ }
+
+ if (clonedElement.groupIds) {
+ clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
+ if (!groupNewIdsMap.has(groupId)) {
+ groupNewIdsMap.set(groupId, regenerateId());
+ }
+ return groupNewIdsMap.get(groupId)!;
+ });
+ }
+
+ if ("containerId" in clonedElement && clonedElement.containerId) {
+ const newContainerId = maybeGetNewId(clonedElement.containerId);
+ clonedElement.containerId = newContainerId;
+ }
+
+ if ("boundElements" in clonedElement && clonedElement.boundElements) {
+ clonedElement.boundElements = clonedElement.boundElements.reduce(
+ (
+ acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
+ binding,
+ ) => {
+ const newBindingId = maybeGetNewId(binding.id);
+ if (newBindingId) {
+ acc.push({ ...binding, id: newBindingId });
+ }
+ return acc;
+ },
+ [],
+ );
+ }
+
+ if ("endBinding" in clonedElement && clonedElement.endBinding) {
+ const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
+ clonedElement.endBinding = newEndBindingId
+ ? {
+ ...clonedElement.endBinding,
+ elementId: newEndBindingId,
+ }
+ : null;
+ }
+ if ("startBinding" in clonedElement && clonedElement.startBinding) {
+ const newEndBindingId = maybeGetNewId(
+ clonedElement.startBinding.elementId,
+ );
+ clonedElement.startBinding = newEndBindingId
+ ? {
+ ...clonedElement.startBinding,
+ elementId: newEndBindingId,
+ }
+ : null;
+ }
+
+ if (clonedElement.frameId) {
+ clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
+ }
+
+ clonedElements.push(clonedElement);
+ }
+
+ return clonedElements;
+};
diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts
new file mode 100644
index 0000000..b86ba02
--- /dev/null
+++ b/packages/excalidraw/element/resizeElements.ts
@@ -0,0 +1,1545 @@
+import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
+import { rescalePoints } from "../points";
+import type {
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+ NonDeletedExcalidrawElement,
+ NonDeleted,
+ ExcalidrawElement,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawImageElement,
+ ElementsMap,
+ SceneElementsMap,
+ ExcalidrawElbowArrowElement,
+} from "./types";
+import type { Mutable } from "../utility-types";
+import {
+ getElementAbsoluteCoords,
+ getCommonBounds,
+ getResizedElementAbsoluteCoords,
+ getCommonBoundingBox,
+ getElementBounds,
+} from "./bounds";
+import type { BoundingBox } from "./bounds";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isElbowArrow,
+ isFrameLikeElement,
+ isFreeDrawElement,
+ isImageElement,
+ isLinearElement,
+ isTextElement,
+} from "./typeChecks";
+import { mutateElement } from "./mutateElement";
+import { getFontString } from "../utils";
+import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
+import type {
+ MaybeTransformHandleType,
+ TransformHandleDirection,
+} from "./transformHandles";
+import type { PointerDownState } from "../types";
+import type Scene from "../scene/Scene";
+import {
+ getBoundTextElement,
+ getBoundTextElementId,
+ getContainerElement,
+ handleBindTextResize,
+ getBoundTextMaxWidth,
+} from "./textElement";
+import { wrapText } from "./textWrapping";
+import { LinearElementEditor } from "./linearElementEditor";
+import { isInGroup } from "../groups";
+import type { GlobalPoint } from "@excalidraw/math";
+import {
+ pointCenter,
+ normalizeRadians,
+ pointFrom,
+ pointFromPair,
+ pointRotateRads,
+ type Radians,
+ type LocalPoint,
+} from "@excalidraw/math";
+import {
+ getMinTextElementWidth,
+ measureText,
+ getApproxMinLineWidth,
+ getApproxMinLineHeight,
+} from "./textMeasurements";
+
+// Returns true when transform (resizing/rotation) happened
+export const transformElements = (
+ originalElements: PointerDownState["originalElements"],
+ transformHandleType: MaybeTransformHandleType,
+ selectedElements: readonly NonDeletedExcalidrawElement[],
+ elementsMap: SceneElementsMap,
+ scene: Scene,
+ shouldRotateWithDiscreteAngle: boolean,
+ shouldResizeFromCenter: boolean,
+ shouldMaintainAspectRatio: boolean,
+ pointerX: number,
+ pointerY: number,
+ centerX: number,
+ centerY: number,
+): boolean => {
+ if (selectedElements.length === 1) {
+ const [element] = selectedElements;
+ if (transformHandleType === "rotation") {
+ if (!isElbowArrow(element)) {
+ rotateSingleElement(
+ element,
+ elementsMap,
+ scene,
+ pointerX,
+ pointerY,
+ shouldRotateWithDiscreteAngle,
+ );
+ updateBoundElements(element, elementsMap);
+ }
+ } else if (isTextElement(element) && transformHandleType) {
+ resizeSingleTextElement(
+ originalElements,
+ element,
+ elementsMap,
+ transformHandleType,
+ shouldResizeFromCenter,
+ pointerX,
+ pointerY,
+ );
+ updateBoundElements(element, elementsMap);
+ return true;
+ } else if (transformHandleType) {
+ const elementId = selectedElements[0].id;
+ const latestElement = elementsMap.get(elementId);
+ const origElement = originalElements.get(elementId);
+
+ if (latestElement && origElement) {
+ const { nextWidth, nextHeight } =
+ getNextSingleWidthAndHeightFromPointer(
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElements,
+ transformHandleType,
+ pointerX,
+ pointerY,
+ {
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ },
+ );
+
+ resizeSingleElement(
+ nextWidth,
+ nextHeight,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElements,
+ transformHandleType,
+ {
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ },
+ );
+ }
+ }
+ return true;
+ } else if (selectedElements.length > 1) {
+ if (transformHandleType === "rotation") {
+ rotateMultipleElements(
+ originalElements,
+ selectedElements,
+ elementsMap,
+ scene,
+ pointerX,
+ pointerY,
+ shouldRotateWithDiscreteAngle,
+ centerX,
+ centerY,
+ );
+ return true;
+ } else if (transformHandleType) {
+ const { nextWidth, nextHeight, flipByX, flipByY, originalBoundingBox } =
+ getNextMultipleWidthAndHeightFromPointer(
+ selectedElements,
+ originalElements,
+ elementsMap,
+ transformHandleType,
+ pointerX,
+ pointerY,
+ {
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ },
+ );
+
+ resizeMultipleElements(
+ selectedElements,
+ elementsMap,
+ transformHandleType,
+ scene,
+ originalElements,
+ {
+ shouldResizeFromCenter,
+ shouldMaintainAspectRatio,
+ flipByX,
+ flipByY,
+ nextWidth,
+ nextHeight,
+ originalBoundingBox,
+ },
+ );
+
+ return true;
+ }
+ }
+ return false;
+};
+
+const rotateSingleElement = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ scene: Scene,
+ pointerX: number,
+ pointerY: number,
+ shouldRotateWithDiscreteAngle: boolean,
+) => {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ let angle: Radians;
+ if (isFrameLikeElement(element)) {
+ angle = 0 as Radians;
+ } else {
+ angle = ((5 * Math.PI) / 2 +
+ Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
+ if (shouldRotateWithDiscreteAngle) {
+ angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
+ angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
+ }
+ angle = normalizeRadians(angle as Radians);
+ }
+ const boundTextElementId = getBoundTextElementId(element);
+
+ mutateElement(element, { angle });
+ if (boundTextElementId) {
+ const textElement =
+ scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
+
+ if (textElement && !isArrowElement(element)) {
+ mutateElement(textElement, { angle });
+ }
+ }
+};
+
+export const rescalePointsInElement = (
+ element: NonDeletedExcalidrawElement,
+ width: number,
+ height: number,
+ normalizePoints: boolean,
+) =>
+ isLinearElement(element) || isFreeDrawElement(element)
+ ? {
+ points: rescalePoints(
+ 0,
+ width,
+ rescalePoints(1, height, element.points, normalizePoints),
+ normalizePoints,
+ ),
+ }
+ : {};
+
+export const measureFontSizeFromWidth = (
+ element: NonDeleted<ExcalidrawTextElement>,
+ elementsMap: ElementsMap,
+ nextWidth: number,
+): { size: number } | null => {
+ // We only use width to scale font on resize
+ let width = element.width;
+
+ const hasContainer = isBoundToContainer(element);
+ if (hasContainer) {
+ const container = getContainerElement(element, elementsMap);
+ if (container) {
+ width = getBoundTextMaxWidth(container, element);
+ }
+ }
+ const nextFontSize = element.fontSize * (nextWidth / width);
+ if (nextFontSize < MIN_FONT_SIZE) {
+ return null;
+ }
+
+ return {
+ size: nextFontSize,
+ };
+};
+
+const resizeSingleTextElement = (
+ originalElements: PointerDownState["originalElements"],
+ element: NonDeleted<ExcalidrawTextElement>,
+ elementsMap: ElementsMap,
+ transformHandleType: TransformHandleDirection,
+ shouldResizeFromCenter: boolean,
+ pointerX: number,
+ pointerY: number,
+) => {
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+ // rotation pointer with reverse angle
+ const [rotatedX, rotatedY] = pointRotateRads(
+ pointFrom(pointerX, pointerY),
+ pointFrom(cx, cy),
+ -element.angle as Radians,
+ );
+ let scaleX = 0;
+ let scaleY = 0;
+
+ if (transformHandleType !== "e" && transformHandleType !== "w") {
+ if (transformHandleType.includes("e")) {
+ scaleX = (rotatedX - x1) / (x2 - x1);
+ }
+ if (transformHandleType.includes("w")) {
+ scaleX = (x2 - rotatedX) / (x2 - x1);
+ }
+ if (transformHandleType.includes("n")) {
+ scaleY = (y2 - rotatedY) / (y2 - y1);
+ }
+ if (transformHandleType.includes("s")) {
+ scaleY = (rotatedY - y1) / (y2 - y1);
+ }
+ }
+
+ const scale = Math.max(scaleX, scaleY);
+
+ if (scale > 0) {
+ const nextWidth = element.width * scale;
+ const nextHeight = element.height * scale;
+ const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
+ if (metrics === null) {
+ return;
+ }
+
+ const startTopLeft = [x1, y1];
+ const startBottomRight = [x2, y2];
+ const startCenter = [cx, cy];
+
+ let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
+ if (["n", "w", "nw"].includes(transformHandleType)) {
+ newTopLeft = pointFrom<GlobalPoint>(
+ startBottomRight[0] - Math.abs(nextWidth),
+ startBottomRight[1] - Math.abs(nextHeight),
+ );
+ }
+ if (transformHandleType === "ne") {
+ const bottomLeft = [startTopLeft[0], startBottomRight[1]];
+ newTopLeft = pointFrom<GlobalPoint>(
+ bottomLeft[0],
+ bottomLeft[1] - Math.abs(nextHeight),
+ );
+ }
+ if (transformHandleType === "sw") {
+ const topRight = [startBottomRight[0], startTopLeft[1]];
+ newTopLeft = pointFrom<GlobalPoint>(
+ topRight[0] - Math.abs(nextWidth),
+ topRight[1],
+ );
+ }
+
+ if (["s", "n"].includes(transformHandleType)) {
+ newTopLeft[0] = startCenter[0] - nextWidth / 2;
+ }
+ if (["e", "w"].includes(transformHandleType)) {
+ newTopLeft[1] = startCenter[1] - nextHeight / 2;
+ }
+
+ if (shouldResizeFromCenter) {
+ newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
+ newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
+ }
+
+ const angle = element.angle;
+ const rotatedTopLeft = pointRotateRads(
+ newTopLeft,
+ pointFrom(cx, cy),
+ angle,
+ );
+ const newCenter = pointFrom<GlobalPoint>(
+ newTopLeft[0] + Math.abs(nextWidth) / 2,
+ newTopLeft[1] + Math.abs(nextHeight) / 2,
+ );
+ const rotatedNewCenter = pointRotateRads(
+ newCenter,
+ pointFrom(cx, cy),
+ angle,
+ );
+ newTopLeft = pointRotateRads(
+ rotatedTopLeft,
+ rotatedNewCenter,
+ -angle as Radians,
+ );
+ const [nextX, nextY] = newTopLeft;
+
+ mutateElement(element, {
+ fontSize: metrics.size,
+ width: nextWidth,
+ height: nextHeight,
+ x: nextX,
+ y: nextY,
+ });
+ }
+
+ if (transformHandleType === "e" || transformHandleType === "w") {
+ const stateAtResizeStart = originalElements.get(element.id)!;
+ const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+ stateAtResizeStart,
+ stateAtResizeStart.width,
+ stateAtResizeStart.height,
+ true,
+ );
+ const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
+ const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
+ const startCenter = pointCenter(startTopLeft, startBottomRight);
+
+ const rotatedPointer = pointRotateRads(
+ pointFrom(pointerX, pointerY),
+ startCenter,
+ -stateAtResizeStart.angle as Radians,
+ );
+
+ const [esx1, , esx2] = getResizedElementAbsoluteCoords(
+ element,
+ element.width,
+ element.height,
+ true,
+ );
+
+ const boundsCurrentWidth = esx2 - esx1;
+
+ const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
+ const minWidth = getMinTextElementWidth(
+ getFontString({
+ fontSize: element.fontSize,
+ fontFamily: element.fontFamily,
+ }),
+ element.lineHeight,
+ );
+
+ let scaleX = atStartBoundsWidth / boundsCurrentWidth;
+
+ if (transformHandleType.includes("e")) {
+ scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
+ }
+ if (transformHandleType.includes("w")) {
+ scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
+ }
+
+ const newWidth =
+ element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
+
+ const text = wrapText(
+ element.originalText,
+ getFontString(element),
+ Math.abs(newWidth),
+ );
+ const metrics = measureText(
+ text,
+ getFontString(element),
+ element.lineHeight,
+ );
+
+ const eleNewHeight = metrics.height;
+
+ const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
+ getResizedElementAbsoluteCoords(
+ stateAtResizeStart,
+ newWidth,
+ eleNewHeight,
+ true,
+ );
+ const newBoundsWidth = newBoundsX2 - newBoundsX1;
+ const newBoundsHeight = newBoundsY2 - newBoundsY1;
+
+ let newTopLeft = [...startTopLeft] as [number, number];
+ if (["n", "w", "nw"].includes(transformHandleType)) {
+ newTopLeft = [
+ startBottomRight[0] - Math.abs(newBoundsWidth),
+ startTopLeft[1],
+ ];
+ }
+
+ // adjust topLeft to new rotation point
+ const angle = stateAtResizeStart.angle;
+ const rotatedTopLeft = pointRotateRads(
+ pointFromPair(newTopLeft),
+ startCenter,
+ angle,
+ );
+ const newCenter = pointFrom(
+ newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
+ newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
+ );
+ const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+ newTopLeft = pointRotateRads(
+ rotatedTopLeft,
+ rotatedNewCenter,
+ -angle as Radians,
+ );
+
+ const resizedElement: Partial<ExcalidrawTextElement> = {
+ width: Math.abs(newWidth),
+ height: Math.abs(metrics.height),
+ x: newTopLeft[0],
+ y: newTopLeft[1],
+ text,
+ autoResize: false,
+ };
+
+ mutateElement(element, resizedElement);
+ }
+};
+
+const rotateMultipleElements = (
+ originalElements: PointerDownState["originalElements"],
+ elements: readonly NonDeletedExcalidrawElement[],
+ elementsMap: SceneElementsMap,
+ scene: Scene,
+ pointerX: number,
+ pointerY: number,
+ shouldRotateWithDiscreteAngle: boolean,
+ centerX: number,
+ centerY: number,
+) => {
+ let centerAngle =
+ (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
+ if (shouldRotateWithDiscreteAngle) {
+ centerAngle += SHIFT_LOCKING_ANGLE / 2;
+ centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
+ }
+
+ for (const element of elements) {
+ if (!isFrameLikeElement(element)) {
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const origAngle =
+ originalElements.get(element.id)?.angle ?? element.angle;
+ const [rotatedCX, rotatedCY] = pointRotateRads(
+ pointFrom(cx, cy),
+ pointFrom(centerX, centerY),
+ (centerAngle + origAngle - element.angle) as Radians,
+ );
+
+ if (isElbowArrow(element)) {
+ // Needed to re-route the arrow
+ mutateElement(element, {
+ points: getArrowLocalFixedPoints(element, elementsMap),
+ });
+ } else {
+ mutateElement(
+ element,
+ {
+ x: element.x + (rotatedCX - cx),
+ y: element.y + (rotatedCY - cy),
+ angle: normalizeRadians((centerAngle + origAngle) as Radians),
+ },
+ false,
+ );
+ }
+
+ updateBoundElements(element, elementsMap, {
+ simultaneouslyUpdated: elements,
+ });
+
+ const boundText = getBoundTextElement(element, elementsMap);
+ if (boundText && !isArrowElement(element)) {
+ mutateElement(
+ boundText,
+ {
+ x: boundText.x + (rotatedCX - cx),
+ y: boundText.y + (rotatedCY - cy),
+ angle: normalizeRadians((centerAngle + origAngle) as Radians),
+ },
+ false,
+ );
+ }
+ }
+ }
+
+ scene.triggerUpdate();
+};
+
+export const getResizeOffsetXY = (
+ transformHandleType: MaybeTransformHandleType,
+ selectedElements: NonDeletedExcalidrawElement[],
+ elementsMap: ElementsMap,
+ x: number,
+ y: number,
+): [number, number] => {
+ const [x1, y1, x2, y2] =
+ selectedElements.length === 1
+ ? getElementAbsoluteCoords(selectedElements[0], elementsMap)
+ : getCommonBounds(selectedElements);
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+ const angle = (
+ selectedElements.length === 1 ? selectedElements[0].angle : 0
+ ) as Radians;
+ [x, y] = pointRotateRads(
+ pointFrom(x, y),
+ pointFrom(cx, cy),
+ -angle as Radians,
+ );
+ switch (transformHandleType) {
+ case "n":
+ return pointRotateRads(
+ pointFrom(x - (x1 + x2) / 2, y - y1),
+ pointFrom(0, 0),
+ angle,
+ );
+ case "s":
+ return pointRotateRads(
+ pointFrom(x - (x1 + x2) / 2, y - y2),
+ pointFrom(0, 0),
+ angle,
+ );
+ case "w":
+ return pointRotateRads(
+ pointFrom(x - x1, y - (y1 + y2) / 2),
+ pointFrom(0, 0),
+ angle,
+ );
+ case "e":
+ return pointRotateRads(
+ pointFrom(x - x2, y - (y1 + y2) / 2),
+ pointFrom(0, 0),
+ angle,
+ );
+ case "nw":
+ return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
+ case "ne":
+ return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
+ case "sw":
+ return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
+ case "se":
+ return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
+ default:
+ return [0, 0];
+ }
+};
+
+export const getResizeArrowDirection = (
+ transformHandleType: MaybeTransformHandleType,
+ element: NonDeleted<ExcalidrawLinearElement>,
+): "origin" | "end" => {
+ const [, [px, py]] = element.points;
+ const isResizeEnd =
+ (transformHandleType === "nw" && (px < 0 || py < 0)) ||
+ (transformHandleType === "ne" && px >= 0) ||
+ (transformHandleType === "sw" && px <= 0) ||
+ (transformHandleType === "se" && (px > 0 || py > 0));
+ return isResizeEnd ? "end" : "origin";
+};
+
+type ResizeAnchor =
+ | "top-left"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-right"
+ | "west-side"
+ | "north-side"
+ | "east-side"
+ | "south-side"
+ | "center";
+
+const getResizeAnchor = (
+ handleDirection: TransformHandleDirection,
+ shouldMaintainAspectRatio: boolean,
+ shouldResizeFromCenter: boolean,
+): ResizeAnchor => {
+ if (shouldResizeFromCenter) {
+ return "center";
+ }
+
+ if (shouldMaintainAspectRatio) {
+ switch (handleDirection) {
+ case "n":
+ return "south-side";
+ case "e": {
+ return "west-side";
+ }
+ case "s":
+ return "north-side";
+ case "w":
+ return "east-side";
+ case "ne":
+ return "bottom-left";
+ case "nw":
+ return "bottom-right";
+ case "se":
+ return "top-left";
+ case "sw":
+ return "top-right";
+ }
+ }
+
+ if (["e", "se", "s"].includes(handleDirection)) {
+ return "top-left";
+ } else if (["n", "nw", "w"].includes(handleDirection)) {
+ return "bottom-right";
+ } else if (handleDirection === "ne") {
+ return "bottom-left";
+ }
+ return "top-right";
+};
+
+const getResizedOrigin = (
+ prevOrigin: GlobalPoint,
+ prevWidth: number,
+ prevHeight: number,
+ newWidth: number,
+ newHeight: number,
+ angle: number,
+ handleDirection: TransformHandleDirection,
+ shouldMaintainAspectRatio: boolean,
+ shouldResizeFromCenter: boolean,
+): { x: number; y: number } => {
+ const anchor = getResizeAnchor(
+ handleDirection,
+ shouldMaintainAspectRatio,
+ shouldResizeFromCenter,
+ );
+
+ const [x, y] = prevOrigin;
+
+ switch (anchor) {
+ case "top-left":
+ return {
+ x:
+ x +
+ (prevWidth - newWidth) / 2 +
+ ((newWidth - prevWidth) / 2) * Math.cos(angle) +
+ ((prevHeight - newHeight) / 2) * Math.sin(angle),
+ y:
+ y +
+ (prevHeight - newHeight) / 2 +
+ ((newWidth - prevWidth) / 2) * Math.sin(angle) +
+ ((newHeight - prevHeight) / 2) * Math.cos(angle),
+ };
+ case "top-right":
+ return {
+ x:
+ x +
+ ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
+ ((prevHeight - newHeight) / 2) * Math.sin(angle),
+ y:
+ y +
+ (prevHeight - newHeight) / 2 +
+ ((prevWidth - newWidth) / 2) * Math.sin(angle) +
+ ((newHeight - prevHeight) / 2) * Math.cos(angle),
+ };
+
+ case "bottom-left":
+ return {
+ x:
+ x +
+ ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)) +
+ ((newHeight - prevHeight) / 2) * Math.sin(angle),
+ y:
+ y +
+ ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
+ ((newWidth - prevWidth) / 2) * Math.sin(angle),
+ };
+ case "bottom-right":
+ return {
+ x:
+ x +
+ ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1) +
+ ((newHeight - prevHeight) / 2) * Math.sin(angle),
+ y:
+ y +
+ ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1) +
+ ((prevWidth - newWidth) / 2) * Math.sin(angle),
+ };
+ case "center":
+ return {
+ x: x - (newWidth - prevWidth) / 2,
+ y: y - (newHeight - prevHeight) / 2,
+ };
+ case "east-side":
+ return {
+ x: x + ((prevWidth - newWidth) / 2) * (Math.cos(angle) + 1),
+ y:
+ y +
+ ((prevWidth - newWidth) / 2) * Math.sin(angle) +
+ (prevHeight - newHeight) / 2,
+ };
+ case "west-side":
+ return {
+ x: x + ((prevWidth - newWidth) / 2) * (1 - Math.cos(angle)),
+ y:
+ y +
+ ((newWidth - prevWidth) / 2) * Math.sin(angle) +
+ (prevHeight - newHeight) / 2,
+ };
+ case "north-side":
+ return {
+ x:
+ x +
+ (prevWidth - newWidth) / 2 +
+ ((prevHeight - newHeight) / 2) * Math.sin(angle),
+ y: y + ((newHeight - prevHeight) / 2) * (Math.cos(angle) - 1),
+ };
+ case "south-side":
+ return {
+ x:
+ x +
+ (prevWidth - newWidth) / 2 +
+ ((newHeight - prevHeight) / 2) * Math.sin(angle),
+ y: y + ((prevHeight - newHeight) / 2) * (Math.cos(angle) + 1),
+ };
+ }
+};
+
+export const resizeSingleElement = (
+ nextWidth: number,
+ nextHeight: number,
+ latestElement: ExcalidrawElement,
+ origElement: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ originalElementsMap: ElementsMap,
+ handleDirection: TransformHandleDirection,
+ {
+ shouldInformMutation = true,
+ shouldMaintainAspectRatio = false,
+ shouldResizeFromCenter = false,
+ }: {
+ shouldMaintainAspectRatio?: boolean;
+ shouldResizeFromCenter?: boolean;
+ shouldInformMutation?: boolean;
+ } = {},
+) => {
+ let boundTextFont: { fontSize?: number } = {};
+ const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+
+ if (boundTextElement) {
+ const stateOfBoundTextElementAtResize = originalElementsMap.get(
+ boundTextElement.id,
+ ) as typeof boundTextElement | undefined;
+ if (stateOfBoundTextElementAtResize) {
+ boundTextFont = {
+ fontSize: stateOfBoundTextElementAtResize.fontSize,
+ };
+ }
+ if (shouldMaintainAspectRatio) {
+ const updatedElement = {
+ ...latestElement,
+ width: nextWidth,
+ height: nextHeight,
+ };
+
+ const nextFont = measureFontSizeFromWidth(
+ boundTextElement,
+ elementsMap,
+ getBoundTextMaxWidth(updatedElement, boundTextElement),
+ );
+ if (nextFont === null) {
+ return;
+ }
+ boundTextFont = {
+ fontSize: nextFont.size,
+ };
+ } else {
+ const minWidth = getApproxMinLineWidth(
+ getFontString(boundTextElement),
+ boundTextElement.lineHeight,
+ );
+ const minHeight = getApproxMinLineHeight(
+ boundTextElement.fontSize,
+ boundTextElement.lineHeight,
+ );
+ nextWidth = Math.max(nextWidth, minWidth);
+ nextHeight = Math.max(nextHeight, minHeight);
+ }
+ }
+
+ const rescaledPoints = rescalePointsInElement(
+ origElement,
+ nextWidth,
+ nextHeight,
+ true,
+ );
+
+ let previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
+
+ if (isLinearElement(origElement)) {
+ const [x1, y1] = getElementBounds(origElement, originalElementsMap);
+ previousOrigin = pointFrom<GlobalPoint>(x1, y1);
+ }
+
+ const newOrigin: {
+ x: number;
+ y: number;
+ } = getResizedOrigin(
+ previousOrigin,
+ origElement.width,
+ origElement.height,
+ nextWidth,
+ nextHeight,
+ origElement.angle,
+ handleDirection,
+ shouldMaintainAspectRatio!!,
+ shouldResizeFromCenter!!,
+ );
+
+ if (isLinearElement(origElement) && rescaledPoints.points) {
+ const offsetX = origElement.x - previousOrigin[0];
+ const offsetY = origElement.y - previousOrigin[1];
+
+ newOrigin.x += offsetX;
+ newOrigin.y += offsetY;
+
+ const scaledX = rescaledPoints.points[0][0];
+ const scaledY = rescaledPoints.points[0][1];
+
+ newOrigin.x += scaledX;
+ newOrigin.y += scaledY;
+
+ rescaledPoints.points = rescaledPoints.points.map((p) =>
+ pointFrom<LocalPoint>(p[0] - scaledX, p[1] - scaledY),
+ );
+ }
+
+ // flipping
+ if (nextWidth < 0) {
+ newOrigin.x = newOrigin.x + nextWidth;
+ }
+ if (nextHeight < 0) {
+ newOrigin.y = newOrigin.y + nextHeight;
+ }
+
+ if ("scale" in latestElement && "scale" in origElement) {
+ mutateElement(latestElement, {
+ scale: [
+ // defaulting because scaleX/Y can be 0/-0
+ (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
+ (Math.sign(nextHeight) || origElement.scale[1]) * origElement.scale[1],
+ ],
+ });
+ }
+
+ if (
+ isArrowElement(latestElement) &&
+ boundTextElement &&
+ shouldMaintainAspectRatio
+ ) {
+ const fontSize =
+ (nextWidth / latestElement.width) * boundTextElement.fontSize;
+ if (fontSize < MIN_FONT_SIZE) {
+ return;
+ }
+ boundTextFont.fontSize = fontSize;
+ }
+
+ if (
+ nextWidth !== 0 &&
+ nextHeight !== 0 &&
+ Number.isFinite(newOrigin.x) &&
+ Number.isFinite(newOrigin.y)
+ ) {
+ const updates = {
+ ...newOrigin,
+ width: Math.abs(nextWidth),
+ height: Math.abs(nextHeight),
+ ...rescaledPoints,
+ };
+
+ mutateElement(latestElement, updates, shouldInformMutation);
+
+ updateBoundElements(latestElement, elementsMap as SceneElementsMap, {
+ // TODO: confirm with MARK if this actually makes sense
+ newSize: { width: nextWidth, height: nextHeight },
+ });
+
+ if (boundTextElement && boundTextFont != null) {
+ mutateElement(boundTextElement, {
+ fontSize: boundTextFont.fontSize,
+ });
+ }
+ handleBindTextResize(
+ latestElement,
+ elementsMap,
+ handleDirection,
+ shouldMaintainAspectRatio,
+ );
+ }
+};
+
+const getNextSingleWidthAndHeightFromPointer = (
+ latestElement: ExcalidrawElement,
+ origElement: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ originalElementsMap: ElementsMap,
+ handleDirection: TransformHandleDirection,
+ pointerX: number,
+ pointerY: number,
+ {
+ shouldMaintainAspectRatio = false,
+ shouldResizeFromCenter = false,
+ }: {
+ shouldMaintainAspectRatio?: boolean;
+ shouldResizeFromCenter?: boolean;
+ } = {},
+) => {
+ // Gets bounds corners
+ const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
+ origElement,
+ origElement.width,
+ origElement.height,
+ true,
+ );
+ const startTopLeft = pointFrom(x1, y1);
+ const startBottomRight = pointFrom(x2, y2);
+ const startCenter = pointCenter(startTopLeft, startBottomRight);
+
+ // Calculate new dimensions based on cursor position
+ const rotatedPointer = pointRotateRads(
+ pointFrom(pointerX, pointerY),
+ startCenter,
+ -origElement.angle as Radians,
+ );
+
+ // Get bounds corners rendered on screen
+ const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
+ latestElement,
+ latestElement.width,
+ latestElement.height,
+ true,
+ );
+
+ const boundsCurrentWidth = esx2 - esx1;
+ const boundsCurrentHeight = esy2 - esy1;
+
+ // It's important we set the initial scale value based on the width and height at resize start,
+ // otherwise previous dimensions affected by modifiers will be taken into account.
+ const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
+ const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
+ let scaleX = atStartBoundsWidth / boundsCurrentWidth;
+ let scaleY = atStartBoundsHeight / boundsCurrentHeight;
+
+ if (handleDirection.includes("e")) {
+ scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
+ }
+ if (handleDirection.includes("s")) {
+ scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
+ }
+ if (handleDirection.includes("w")) {
+ scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
+ }
+ if (handleDirection.includes("n")) {
+ scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
+ }
+
+ // We have to use dimensions of element on screen, otherwise the scaling of the
+ // dimensions won't match the cursor for linear elements.
+ let nextWidth = latestElement.width * scaleX;
+ let nextHeight = latestElement.height * scaleY;
+
+ if (shouldResizeFromCenter) {
+ nextWidth = 2 * nextWidth - origElement.width;
+ nextHeight = 2 * nextHeight - origElement.height;
+ }
+
+ // adjust dimensions to keep sides ratio
+ if (shouldMaintainAspectRatio) {
+ const widthRatio = Math.abs(nextWidth) / origElement.width;
+ const heightRatio = Math.abs(nextHeight) / origElement.height;
+ if (handleDirection.length === 1) {
+ nextHeight *= widthRatio;
+ nextWidth *= heightRatio;
+ }
+ if (handleDirection.length === 2) {
+ const ratio = Math.max(widthRatio, heightRatio);
+ nextWidth = origElement.width * ratio * Math.sign(nextWidth);
+ nextHeight = origElement.height * ratio * Math.sign(nextHeight);
+ }
+ }
+
+ return {
+ nextWidth,
+ nextHeight,
+ };
+};
+
+const getNextMultipleWidthAndHeightFromPointer = (
+ selectedElements: readonly NonDeletedExcalidrawElement[],
+ originalElementsMap: ElementsMap,
+ elementsMap: ElementsMap,
+ handleDirection: TransformHandleDirection,
+ pointerX: number,
+ pointerY: number,
+ {
+ shouldMaintainAspectRatio = false,
+ shouldResizeFromCenter = false,
+ }: {
+ shouldResizeFromCenter?: boolean;
+ shouldMaintainAspectRatio?: boolean;
+ } = {},
+) => {
+ const originalElementsArray = selectedElements.map(
+ (el) => originalElementsMap.get(el.id)!,
+ );
+
+ // getCommonBoundingBox() uses getBoundTextElement() which returns null for
+ // original elements from pointerDownState, so we have to find and add these
+ // bound text elements manually. Additionally, the coordinates of bound text
+ // elements aren't always up to date.
+ const boundTextElements = originalElementsArray.reduce((acc, orig) => {
+ if (!isLinearElement(orig)) {
+ return acc;
+ }
+ const textId = getBoundTextElementId(orig);
+ if (!textId) {
+ return acc;
+ }
+ const text = originalElementsMap.get(textId) ?? null;
+ if (!isBoundToContainer(text)) {
+ return acc;
+ }
+ return [
+ ...acc,
+ {
+ ...text,
+ ...LinearElementEditor.getBoundTextElementPosition(
+ orig,
+ text,
+ elementsMap,
+ ),
+ },
+ ];
+ }, [] as ExcalidrawTextElementWithContainer[]);
+
+ const originalBoundingBox = getCommonBoundingBox(
+ originalElementsArray.map((orig) => orig).concat(boundTextElements),
+ );
+
+ const { minX, minY, maxX, maxY, midX, midY } = originalBoundingBox;
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ const anchorsMap = {
+ ne: [minX, maxY],
+ se: [minX, minY],
+ sw: [maxX, minY],
+ nw: [maxX, maxY],
+ e: [minX, minY + height / 2],
+ w: [maxX, minY + height / 2],
+ n: [minX + width / 2, maxY],
+ s: [minX + width / 2, minY],
+ } as Record<TransformHandleDirection, GlobalPoint>;
+
+ // anchor point must be on the opposite side of the dragged selection handle
+ // or be the center of the selection if shouldResizeFromCenter
+ const [anchorX, anchorY] = shouldResizeFromCenter
+ ? [midX, midY]
+ : anchorsMap[handleDirection];
+
+ const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
+
+ const scale =
+ Math.max(
+ Math.abs(pointerX - anchorX) / width || 0,
+ Math.abs(pointerY - anchorY) / height || 0,
+ ) * resizeFromCenterScale;
+
+ let nextWidth =
+ handleDirection.includes("e") || handleDirection.includes("w")
+ ? Math.abs(pointerX - anchorX) * resizeFromCenterScale
+ : width;
+ let nextHeight =
+ handleDirection.includes("n") || handleDirection.includes("s")
+ ? Math.abs(pointerY - anchorY) * resizeFromCenterScale
+ : height;
+
+ if (shouldMaintainAspectRatio) {
+ nextWidth = width * scale * Math.sign(pointerX - anchorX);
+ nextHeight = height * scale * Math.sign(pointerY - anchorY);
+ }
+
+ const flipConditionsMap: Record<
+ TransformHandleDirection,
+ // Condition for which we should flip or not flip the selected elements
+ // - when evaluated to `true`, we flip
+ // - therefore, setting it to always `false` means we do not flip (in that direction) at all
+ [x: boolean, y: boolean]
+ > = {
+ ne: [pointerX < anchorX, pointerY > anchorY],
+ se: [pointerX < anchorX, pointerY < anchorY],
+ sw: [pointerX > anchorX, pointerY < anchorY],
+ nw: [pointerX > anchorX, pointerY > anchorY],
+ // e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
+ // and therefore, we do not need to flip in the `y` direction at all
+ e: [pointerX < anchorX, false],
+ w: [pointerX > anchorX, false],
+ n: [false, pointerY > anchorY],
+ s: [false, pointerY < anchorY],
+ };
+
+ const [flipByX, flipByY] = flipConditionsMap[handleDirection].map(
+ (condition) => condition,
+ );
+
+ return {
+ originalBoundingBox,
+ nextWidth,
+ nextHeight,
+ flipByX,
+ flipByY,
+ };
+};
+
+export const resizeMultipleElements = (
+ selectedElements: readonly NonDeletedExcalidrawElement[],
+ elementsMap: ElementsMap,
+ handleDirection: TransformHandleDirection,
+ scene: Scene,
+ originalElementsMap: ElementsMap,
+ {
+ shouldMaintainAspectRatio = false,
+ shouldResizeFromCenter = false,
+ flipByX = false,
+ flipByY = false,
+ nextHeight,
+ nextWidth,
+ originalBoundingBox,
+ }: {
+ nextWidth?: number;
+ nextHeight?: number;
+ shouldMaintainAspectRatio?: boolean;
+ shouldResizeFromCenter?: boolean;
+ flipByX?: boolean;
+ flipByY?: boolean;
+ // added to improve performance
+ originalBoundingBox?: BoundingBox;
+ } = {},
+) => {
+ // in the case of just flipping, there is no need to specify the next width and height
+ if (
+ nextWidth === undefined &&
+ nextHeight === undefined &&
+ flipByX === undefined &&
+ flipByY === undefined
+ ) {
+ return;
+ }
+
+ // do not allow next width or height to be 0
+ if (nextHeight === 0 || nextWidth === 0) {
+ return;
+ }
+
+ if (!originalElementsMap) {
+ originalElementsMap = elementsMap;
+ }
+
+ const targetElements = selectedElements.reduce(
+ (
+ acc: {
+ /** element at resize start */
+ orig: NonDeletedExcalidrawElement;
+ /** latest element */
+ latest: NonDeletedExcalidrawElement;
+ }[],
+ element,
+ ) => {
+ const origElement = originalElementsMap!.get(element.id);
+ if (origElement) {
+ acc.push({ orig: origElement, latest: element });
+ }
+ return acc;
+ },
+ [],
+ );
+
+ let boundingBox: BoundingBox;
+
+ if (originalBoundingBox) {
+ boundingBox = originalBoundingBox;
+ } else {
+ const boundTextElements = targetElements.reduce((acc, { orig }) => {
+ if (!isLinearElement(orig)) {
+ return acc;
+ }
+ const textId = getBoundTextElementId(orig);
+ if (!textId) {
+ return acc;
+ }
+ const text = originalElementsMap!.get(textId) ?? null;
+ if (!isBoundToContainer(text)) {
+ return acc;
+ }
+ return [
+ ...acc,
+ {
+ ...text,
+ ...LinearElementEditor.getBoundTextElementPosition(
+ orig,
+ text,
+ elementsMap,
+ ),
+ },
+ ];
+ }, [] as ExcalidrawTextElementWithContainer[]);
+
+ boundingBox = getCommonBoundingBox(
+ targetElements.map(({ orig }) => orig).concat(boundTextElements),
+ );
+ }
+ const { minX, minY, maxX, maxY, midX, midY } = boundingBox;
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ if (nextWidth === undefined && nextHeight === undefined) {
+ nextWidth = width;
+ nextHeight = height;
+ }
+
+ if (shouldMaintainAspectRatio) {
+ if (nextWidth === undefined) {
+ nextWidth = nextHeight! * (width / height);
+ } else if (nextHeight === undefined) {
+ nextHeight = nextWidth! * (height / width);
+ } else if (Math.abs(nextWidth / nextHeight - width / height) > 0.001) {
+ nextWidth = nextHeight * (width / height);
+ }
+ }
+
+ if (nextWidth && nextHeight) {
+ let scaleX =
+ handleDirection.includes("e") || handleDirection.includes("w")
+ ? Math.abs(nextWidth) / width
+ : 1;
+ let scaleY =
+ handleDirection.includes("n") || handleDirection.includes("s")
+ ? Math.abs(nextHeight) / height
+ : 1;
+
+ let scale: number;
+
+ if (handleDirection.length === 1) {
+ scale =
+ handleDirection.includes("e") || handleDirection.includes("w")
+ ? scaleX
+ : scaleY;
+ } else {
+ scale = Math.max(
+ Math.abs(nextWidth) / width || 0,
+ Math.abs(nextHeight) / height || 0,
+ );
+ }
+
+ const anchorsMap = {
+ ne: [minX, maxY],
+ se: [minX, minY],
+ sw: [maxX, minY],
+ nw: [maxX, maxY],
+ e: [minX, minY + height / 2],
+ w: [maxX, minY + height / 2],
+ n: [minX + width / 2, maxY],
+ s: [minX + width / 2, minY],
+ } as Record<TransformHandleDirection, GlobalPoint>;
+
+ // anchor point must be on the opposite side of the dragged selection handle
+ // or be the center of the selection if shouldResizeFromCenter
+ const [anchorX, anchorY] = shouldResizeFromCenter
+ ? [midX, midY]
+ : anchorsMap[handleDirection];
+
+ const keepAspectRatio =
+ shouldMaintainAspectRatio ||
+ targetElements.some(
+ (item) =>
+ item.latest.angle !== 0 ||
+ isTextElement(item.latest) ||
+ isInGroup(item.latest),
+ );
+
+ if (keepAspectRatio) {
+ scaleX = scale;
+ scaleY = scale;
+ }
+
+ /**
+ * to flip an element:
+ * 1. determine over which axis is the element being flipped
+ * (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY`
+ * 2. shift element's position by the amount of width or height (or both) or
+ * mirror points in the case of linear & freedraw elemenets
+ * 3. adjust element angle
+ */
+ const [flipFactorX, flipFactorY] = [flipByX ? -1 : 1, flipByY ? -1 : 1];
+
+ const elementsAndUpdates: {
+ element: NonDeletedExcalidrawElement;
+ update: Mutable<
+ Pick<ExcalidrawElement, "x" | "y" | "width" | "height" | "angle">
+ > & {
+ points?: ExcalidrawLinearElement["points"];
+ fontSize?: ExcalidrawTextElement["fontSize"];
+ scale?: ExcalidrawImageElement["scale"];
+ boundTextFontSize?: ExcalidrawTextElement["fontSize"];
+ startBinding?: ExcalidrawElbowArrowElement["startBinding"];
+ endBinding?: ExcalidrawElbowArrowElement["endBinding"];
+ fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"];
+ };
+ }[] = [];
+
+ for (const { orig, latest } of targetElements) {
+ // bounded text elements are updated along with their container elements
+ if (isTextElement(orig) && isBoundToContainer(orig)) {
+ continue;
+ }
+
+ const width = orig.width * scaleX;
+ const height = orig.height * scaleY;
+ const angle = normalizeRadians(
+ (orig.angle * flipFactorX * flipFactorY) as Radians,
+ );
+
+ const isLinearOrFreeDraw =
+ isLinearElement(orig) || isFreeDrawElement(orig);
+ const offsetX = orig.x - anchorX;
+ const offsetY = orig.y - anchorY;
+ const shiftX = flipByX && !isLinearOrFreeDraw ? width : 0;
+ const shiftY = flipByY && !isLinearOrFreeDraw ? height : 0;
+ const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
+ const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
+
+ const rescaledPoints = rescalePointsInElement(
+ orig,
+ width * flipFactorX,
+ height * flipFactorY,
+ false,
+ );
+
+ const update: typeof elementsAndUpdates[0]["update"] = {
+ x,
+ y,
+ width,
+ height,
+ angle,
+ ...rescaledPoints,
+ };
+
+ if (isElbowArrow(orig)) {
+ // Mirror fixed point binding for elbow arrows
+ // when resize goes into the negative direction
+ if (orig.startBinding) {
+ update.startBinding = {
+ ...orig.startBinding,
+ fixedPoint: [
+ flipByX
+ ? -orig.startBinding.fixedPoint[0] + 1
+ : orig.startBinding.fixedPoint[0],
+ flipByY
+ ? -orig.startBinding.fixedPoint[1] + 1
+ : orig.startBinding.fixedPoint[1],
+ ],
+ };
+ }
+ if (orig.endBinding) {
+ update.endBinding = {
+ ...orig.endBinding,
+ fixedPoint: [
+ flipByX
+ ? -orig.endBinding.fixedPoint[0] + 1
+ : orig.endBinding.fixedPoint[0],
+ flipByY
+ ? -orig.endBinding.fixedPoint[1] + 1
+ : orig.endBinding.fixedPoint[1],
+ ],
+ };
+ }
+ if (orig.fixedSegments && rescaledPoints.points) {
+ update.fixedSegments = orig.fixedSegments.map((segment) => ({
+ ...segment,
+ start: rescaledPoints.points[segment.index - 1],
+ end: rescaledPoints.points[segment.index],
+ }));
+ }
+ }
+
+ if (isImageElement(orig)) {
+ update.scale = [
+ orig.scale[0] * flipFactorX,
+ orig.scale[1] * flipFactorY,
+ ];
+ }
+
+ if (isTextElement(orig)) {
+ const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
+ if (!metrics) {
+ return;
+ }
+ update.fontSize = metrics.size;
+ }
+
+ const boundTextElement = originalElementsMap.get(
+ getBoundTextElementId(orig) ?? "",
+ ) as ExcalidrawTextElementWithContainer | undefined;
+
+ if (boundTextElement) {
+ if (keepAspectRatio) {
+ const newFontSize = boundTextElement.fontSize * scale;
+ if (newFontSize < MIN_FONT_SIZE) {
+ return;
+ }
+ update.boundTextFontSize = newFontSize;
+ } else {
+ update.boundTextFontSize = boundTextElement.fontSize;
+ }
+ }
+
+ elementsAndUpdates.push({
+ element: latest,
+ update,
+ });
+ }
+
+ const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
+
+ for (const {
+ element,
+ update: { boundTextFontSize, ...update },
+ } of elementsAndUpdates) {
+ const { width, height, angle } = update;
+
+ mutateElement(element, update, false, {
+ // needed for the fixed binding point udpate to take effect
+ isDragging: true,
+ });
+
+ updateBoundElements(element, elementsMap as SceneElementsMap, {
+ simultaneouslyUpdated: elementsToUpdate,
+ newSize: { width, height },
+ });
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement && boundTextFontSize) {
+ mutateElement(
+ boundTextElement,
+ {
+ fontSize: boundTextFontSize,
+ angle: isLinearElement(element) ? undefined : angle,
+ },
+ false,
+ );
+ handleBindTextResize(element, elementsMap, handleDirection, true);
+ }
+ }
+
+ scene.triggerUpdate();
+ }
+};
diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts
new file mode 100644
index 0000000..375ff98
--- /dev/null
+++ b/packages/excalidraw/element/resizeTest.ts
@@ -0,0 +1,287 @@
+import type {
+ ExcalidrawElement,
+ PointerType,
+ NonDeletedExcalidrawElement,
+ ElementsMap,
+} from "./types";
+
+import type {
+ TransformHandleType,
+ TransformHandle,
+ MaybeTransformHandleType,
+} from "./transformHandles";
+import {
+ getTransformHandlesFromCoords,
+ getTransformHandles,
+ getOmitSidesForDevice,
+ canResizeFromSides,
+} from "./transformHandles";
+import type { AppState, Device, Zoom } from "../types";
+import type { Bounds } from "./bounds";
+import { getElementAbsoluteCoords } from "./bounds";
+import { SIDE_RESIZING_THRESHOLD } from "../constants";
+import { isImageElement, isLinearElement } from "./typeChecks";
+import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
+import {
+ pointFrom,
+ pointOnLineSegment,
+ pointRotateRads,
+ type Radians,
+} from "@excalidraw/math";
+
+const isInsideTransformHandle = (
+ transformHandle: TransformHandle,
+ x: number,
+ y: number,
+) =>
+ x >= transformHandle[0] &&
+ x <= transformHandle[0] + transformHandle[2] &&
+ y >= transformHandle[1] &&
+ y <= transformHandle[1] + transformHandle[3];
+
+export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ x: number,
+ y: number,
+ zoom: Zoom,
+ pointerType: PointerType,
+ device: Device,
+): MaybeTransformHandleType => {
+ if (!appState.selectedElementIds[element.id]) {
+ return false;
+ }
+
+ const { rotation: rotationTransformHandle, ...transformHandles } =
+ getTransformHandles(
+ element,
+ zoom,
+ elementsMap,
+ pointerType,
+ getOmitSidesForDevice(device),
+ );
+
+ if (
+ rotationTransformHandle &&
+ isInsideTransformHandle(rotationTransformHandle, x, y)
+ ) {
+ return "rotation" as TransformHandleType;
+ }
+
+ const filter = Object.keys(transformHandles).filter((key) => {
+ const transformHandle =
+ transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
+ if (!transformHandle) {
+ return false;
+ }
+ return isInsideTransformHandle(transformHandle, x, y);
+ });
+
+ if (filter.length > 0) {
+ return filter[0] as TransformHandleType;
+ }
+
+ if (canResizeFromSides(device)) {
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ element,
+ elementsMap,
+ );
+
+ // do not resize from the sides for linear elements with only two points
+ if (!(isLinearElement(element) && element.points.length <= 2)) {
+ const SPACING = isImageElement(element)
+ ? 0
+ : SIDE_RESIZING_THRESHOLD / zoom.value;
+ const ZOOMED_SIDE_RESIZING_THRESHOLD =
+ SIDE_RESIZING_THRESHOLD / zoom.value;
+ const sides = getSelectionBorders(
+ pointFrom(x1 - SPACING, y1 - SPACING),
+ pointFrom(x2 + SPACING, y2 + SPACING),
+ pointFrom(cx, cy),
+ element.angle,
+ );
+
+ for (const [dir, side] of Object.entries(sides)) {
+ // test to see if x, y are on the line segment
+ if (
+ pointOnLineSegment(
+ pointFrom(x, y),
+ side as LineSegment<Point>,
+ ZOOMED_SIDE_RESIZING_THRESHOLD,
+ )
+ ) {
+ return dir as TransformHandleType;
+ }
+ }
+ }
+ }
+
+ return false;
+};
+
+export const getElementWithTransformHandleType = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: AppState,
+ scenePointerX: number,
+ scenePointerY: number,
+ zoom: Zoom,
+ pointerType: PointerType,
+ elementsMap: ElementsMap,
+ device: Device,
+) => {
+ return elements.reduce((result, element) => {
+ if (result) {
+ return result;
+ }
+ const transformHandleType = resizeTest(
+ element,
+ elementsMap,
+ appState,
+ scenePointerX,
+ scenePointerY,
+ zoom,
+ pointerType,
+ device,
+ );
+ return transformHandleType ? { element, transformHandleType } : null;
+ }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
+};
+
+export const getTransformHandleTypeFromCoords = <
+ Point extends GlobalPoint | LocalPoint,
+>(
+ [x1, y1, x2, y2]: Bounds,
+ scenePointerX: number,
+ scenePointerY: number,
+ zoom: Zoom,
+ pointerType: PointerType,
+ device: Device,
+): MaybeTransformHandleType => {
+ const transformHandles = getTransformHandlesFromCoords(
+ [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
+ 0 as Radians,
+ zoom,
+ pointerType,
+ getOmitSidesForDevice(device),
+ );
+
+ const found = Object.keys(transformHandles).find((key) => {
+ const transformHandle =
+ transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
+ return (
+ transformHandle &&
+ isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
+ );
+ });
+
+ if (found) {
+ return found as MaybeTransformHandleType;
+ }
+
+ if (canResizeFromSides(device)) {
+ const cx = (x1 + x2) / 2;
+ const cy = (y1 + y2) / 2;
+
+ const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
+
+ const sides = getSelectionBorders(
+ pointFrom(x1 - SPACING, y1 - SPACING),
+ pointFrom(x2 + SPACING, y2 + SPACING),
+ pointFrom(cx, cy),
+ 0 as Radians,
+ );
+
+ for (const [dir, side] of Object.entries(sides)) {
+ // test to see if x, y are on the line segment
+ if (
+ pointOnLineSegment(
+ pointFrom(scenePointerX, scenePointerY),
+ side as LineSegment<Point>,
+ SPACING,
+ )
+ ) {
+ return dir as TransformHandleType;
+ }
+ }
+ }
+
+ return false;
+};
+
+const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
+const rotateResizeCursor = (cursor: string, angle: number) => {
+ const index = RESIZE_CURSORS.indexOf(cursor);
+ if (index >= 0) {
+ const a = Math.round(angle / (Math.PI / 4));
+ cursor = RESIZE_CURSORS[(index + a) % RESIZE_CURSORS.length];
+ }
+ return cursor;
+};
+
+/*
+ * Returns bi-directional cursor for the element being resized
+ */
+export const getCursorForResizingElement = (resizingElement: {
+ element?: ExcalidrawElement;
+ transformHandleType: MaybeTransformHandleType;
+}): string => {
+ const { element, transformHandleType } = resizingElement;
+ const shouldSwapCursors =
+ element && Math.sign(element.height) * Math.sign(element.width) === -1;
+ let cursor = null;
+
+ switch (transformHandleType) {
+ case "n":
+ case "s":
+ cursor = "ns";
+ break;
+ case "w":
+ case "e":
+ cursor = "ew";
+ break;
+ case "nw":
+ case "se":
+ if (shouldSwapCursors) {
+ cursor = "nesw";
+ } else {
+ cursor = "nwse";
+ }
+ break;
+ case "ne":
+ case "sw":
+ if (shouldSwapCursors) {
+ cursor = "nwse";
+ } else {
+ cursor = "nesw";
+ }
+ break;
+ case "rotation":
+ return "grab";
+ }
+
+ if (cursor && element) {
+ cursor = rotateResizeCursor(cursor, element.angle);
+ }
+
+ return cursor ? `${cursor}-resize` : "";
+};
+
+const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
+ [x1, y1]: Point,
+ [x2, y2]: Point,
+ center: Point,
+ angle: Radians,
+) => {
+ const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
+ const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
+ const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
+ const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
+
+ return {
+ n: [topLeft, topRight],
+ e: [topRight, bottomRight],
+ s: [bottomRight, bottomLeft],
+ w: [bottomLeft, topLeft],
+ };
+};
diff --git a/packages/excalidraw/element/showSelectedShapeActions.ts b/packages/excalidraw/element/showSelectedShapeActions.ts
new file mode 100644
index 0000000..bbf313d
--- /dev/null
+++ b/packages/excalidraw/element/showSelectedShapeActions.ts
@@ -0,0 +1,19 @@
+import type { NonDeletedExcalidrawElement } from "./types";
+import { getSelectedElements } from "../scene";
+import type { UIAppState } from "../types";
+
+export const showSelectedShapeActions = (
+ appState: UIAppState,
+ elements: readonly NonDeletedExcalidrawElement[],
+) =>
+ Boolean(
+ !appState.viewModeEnabled &&
+ appState.openDialog?.name !== "elementLinkSelector" &&
+ ((appState.activeTool.type !== "custom" &&
+ (appState.editingTextElement ||
+ (appState.activeTool.type !== "selection" &&
+ appState.activeTool.type !== "eraser" &&
+ appState.activeTool.type !== "hand" &&
+ appState.activeTool.type !== "laser"))) ||
+ getSelectedElements(elements, appState).length),
+ );
diff --git a/packages/excalidraw/element/sizeHelpers.test.ts b/packages/excalidraw/element/sizeHelpers.test.ts
new file mode 100644
index 0000000..8e63dd9
--- /dev/null
+++ b/packages/excalidraw/element/sizeHelpers.test.ts
@@ -0,0 +1,67 @@
+import { vi } from "vitest";
+import { getPerfectElementSize } from "./sizeHelpers";
+import * as constants from "../constants";
+
+const EPSILON_DIGITS = 3;
+// Needed so that we can mock the value of constants which is done in
+// below tests. In Jest this wasn't needed as global override was possible
+// but vite doesn't allow that hence we need to mock
+vi.mock(
+ "../constants.ts",
+ //@ts-ignore
+ async (importOriginal) => {
+ const module: any = await importOriginal();
+ return { ...module };
+ },
+);
+describe("getPerfectElementSize", () => {
+ it("should return height:0 if `elementType` is line and locked angle is 0", () => {
+ const { height, width } = getPerfectElementSize("line", 149, 10);
+ expect(width).toBeCloseTo(149, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(0, EPSILON_DIGITS);
+ });
+
+ it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
+ const { height, width } = getPerfectElementSize("line", 10, 140);
+ expect(width).toBeCloseTo(0, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(140, EPSILON_DIGITS);
+ });
+
+ it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
+ const { height, width } = getPerfectElementSize("arrow", 200, 20);
+ expect(width).toBeCloseTo(200, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(0, EPSILON_DIGITS);
+ });
+ it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
+ const { height, width } = getPerfectElementSize("arrow", 10, 100);
+ expect(width).toBeCloseTo(0, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(100, EPSILON_DIGITS);
+ });
+
+ it("should return adjust height to be width * tan(locked angle)", () => {
+ const { height, width } = getPerfectElementSize("arrow", 120, 185);
+ expect(width).toBeCloseTo(120, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
+ });
+
+ it("should return height equals to width if locked angle is 45 deg", () => {
+ const { height, width } = getPerfectElementSize("arrow", 135, 145);
+ expect(width).toBeCloseTo(135, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(135, EPSILON_DIGITS);
+ });
+
+ it("should return height:0 and width:0 when width and height are 0", () => {
+ const { height, width } = getPerfectElementSize("arrow", 0, 0);
+ expect(width).toBeCloseTo(0, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(0, EPSILON_DIGITS);
+ });
+
+ describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
+ it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
+ (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
+ const { height, width } = getPerfectElementSize("arrow", 120, 185);
+ expect(width).toBeCloseTo(120, EPSILON_DIGITS);
+ expect(height).toBeCloseTo(120, EPSILON_DIGITS);
+ });
+ });
+});
diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts
new file mode 100644
index 0000000..f633789
--- /dev/null
+++ b/packages/excalidraw/element/sizeHelpers.ts
@@ -0,0 +1,231 @@
+import type { ElementsMap, ExcalidrawElement } from "./types";
+import { mutateElement } from "./mutateElement";
+import { isFreeDrawElement, isLinearElement } from "./typeChecks";
+import { SHIFT_LOCKING_ANGLE } from "../constants";
+import type { AppState, Offsets, Zoom } from "../types";
+import { getCommonBounds, getElementBounds } from "./bounds";
+import { viewportCoordsToSceneCoords } from "../utils";
+
+// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
+// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
+// - could also be part of `_clearElements`
+export const isInvisiblySmallElement = (
+ element: ExcalidrawElement,
+): boolean => {
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ return element.points.length < 2;
+ }
+ return element.width === 0 && element.height === 0;
+};
+
+export const isElementInViewport = (
+ element: ExcalidrawElement,
+ width: number,
+ height: number,
+ viewTransformations: {
+ zoom: Zoom;
+ offsetLeft: number;
+ offsetTop: number;
+ scrollX: number;
+ scrollY: number;
+ },
+ elementsMap: ElementsMap,
+) => {
+ const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
+ const topLeftSceneCoords = viewportCoordsToSceneCoords(
+ {
+ clientX: viewTransformations.offsetLeft,
+ clientY: viewTransformations.offsetTop,
+ },
+ viewTransformations,
+ );
+ const bottomRightSceneCoords = viewportCoordsToSceneCoords(
+ {
+ clientX: viewTransformations.offsetLeft + width,
+ clientY: viewTransformations.offsetTop + height,
+ },
+ viewTransformations,
+ );
+
+ return (
+ topLeftSceneCoords.x <= x2 &&
+ topLeftSceneCoords.y <= y2 &&
+ bottomRightSceneCoords.x >= x1 &&
+ bottomRightSceneCoords.y >= y1
+ );
+};
+
+export const isElementCompletelyInViewport = (
+ elements: ExcalidrawElement[],
+ width: number,
+ height: number,
+ viewTransformations: {
+ zoom: Zoom;
+ offsetLeft: number;
+ offsetTop: number;
+ scrollX: number;
+ scrollY: number;
+ },
+ elementsMap: ElementsMap,
+ padding?: Offsets,
+) => {
+ const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
+ const topLeftSceneCoords = viewportCoordsToSceneCoords(
+ {
+ clientX: viewTransformations.offsetLeft + (padding?.left || 0),
+ clientY: viewTransformations.offsetTop + (padding?.top || 0),
+ },
+ viewTransformations,
+ );
+ const bottomRightSceneCoords = viewportCoordsToSceneCoords(
+ {
+ clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
+ clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
+ },
+ viewTransformations,
+ );
+
+ return (
+ x1 >= topLeftSceneCoords.x &&
+ y1 >= topLeftSceneCoords.y &&
+ x2 <= bottomRightSceneCoords.x &&
+ y2 <= bottomRightSceneCoords.y
+ );
+};
+
+/**
+ * Makes a perfect shape or diagonal/horizontal/vertical line
+ */
+export const getPerfectElementSize = (
+ elementType: AppState["activeTool"]["type"],
+ width: number,
+ height: number,
+): { width: number; height: number } => {
+ const absWidth = Math.abs(width);
+ const absHeight = Math.abs(height);
+
+ if (
+ elementType === "line" ||
+ elementType === "arrow" ||
+ elementType === "freedraw"
+ ) {
+ const lockedAngle =
+ Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *
+ SHIFT_LOCKING_ANGLE;
+ if (lockedAngle === 0) {
+ height = 0;
+ } else if (lockedAngle === Math.PI / 2) {
+ width = 0;
+ } else {
+ height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
+ }
+ } else if (elementType !== "selection") {
+ height = absWidth * Math.sign(height);
+ }
+ return { width, height };
+};
+
+export const getLockedLinearCursorAlignSize = (
+ originX: number,
+ originY: number,
+ x: number,
+ y: number,
+) => {
+ let width = x - originX;
+ let height = y - originY;
+
+ const lockedAngle =
+ Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
+ SHIFT_LOCKING_ANGLE;
+
+ if (lockedAngle === 0) {
+ height = 0;
+ } else if (lockedAngle === Math.PI / 2) {
+ width = 0;
+ } else {
+ // locked angle line, y = mx + b => mx - y + b = 0
+ const a1 = Math.tan(lockedAngle);
+ const b1 = -1;
+ const c1 = originY - a1 * originX;
+
+ // line through cursor, perpendicular to locked angle line
+ const a2 = -1 / a1;
+ const b2 = -1;
+ const c2 = y - a2 * x;
+
+ // intersection of the two lines above
+ const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
+ const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
+
+ // delta
+ width = intersectX - originX;
+ height = intersectY - originY;
+ }
+
+ return { width, height };
+};
+
+export const resizePerfectLineForNWHandler = (
+ element: ExcalidrawElement,
+ x: number,
+ y: number,
+) => {
+ const anchorX = element.x + element.width;
+ const anchorY = element.y + element.height;
+ const distanceToAnchorX = x - anchorX;
+ const distanceToAnchorY = y - anchorY;
+ if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
+ mutateElement(element, {
+ x: anchorX,
+ width: 0,
+ y,
+ height: -distanceToAnchorY,
+ });
+ } else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
+ mutateElement(element, {
+ y: anchorY,
+ height: 0,
+ });
+ } else {
+ const nextHeight =
+ Math.sign(distanceToAnchorY) *
+ Math.sign(distanceToAnchorX) *
+ element.width;
+ mutateElement(element, {
+ x,
+ y: anchorY - nextHeight,
+ width: -distanceToAnchorX,
+ height: nextHeight,
+ });
+ }
+};
+
+export const getNormalizedDimensions = (
+ element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
+): {
+ width: ExcalidrawElement["width"];
+ height: ExcalidrawElement["height"];
+ x: ExcalidrawElement["x"];
+ y: ExcalidrawElement["y"];
+} => {
+ const ret = {
+ width: element.width,
+ height: element.height,
+ x: element.x,
+ y: element.y,
+ };
+
+ if (element.width < 0) {
+ const nextWidth = Math.abs(element.width);
+ ret.width = nextWidth;
+ ret.x = element.x - nextWidth;
+ }
+
+ if (element.height < 0) {
+ const nextHeight = Math.abs(element.height);
+ ret.height = nextHeight;
+ ret.y = element.y - nextHeight;
+ }
+
+ return ret;
+};
diff --git a/packages/excalidraw/element/sortElements.test.ts b/packages/excalidraw/element/sortElements.test.ts
new file mode 100644
index 0000000..a7b78e8
--- /dev/null
+++ b/packages/excalidraw/element/sortElements.test.ts
@@ -0,0 +1,402 @@
+import { API } from "../tests/helpers/api";
+import { mutateElement } from "./mutateElement";
+import { normalizeElementOrder } from "./sortElements";
+import type { ExcalidrawElement } from "./types";
+
+const assertOrder = (
+ elements: readonly ExcalidrawElement[],
+ expectedOrder: string[],
+) => {
+ const actualOrder = elements.map((element) => element.id);
+ expect(actualOrder).toEqual(expectedOrder);
+};
+
+describe("normalizeElementsOrder", () => {
+ it("sort bound-text elements", () => {
+ const container = API.createElement({
+ id: "container",
+ type: "rectangle",
+ });
+ const boundText = API.createElement({
+ id: "boundText",
+ type: "text",
+ containerId: container.id,
+ });
+ const otherElement = API.createElement({
+ id: "otherElement",
+ type: "rectangle",
+ boundElements: [],
+ });
+ const otherElement2 = API.createElement({
+ id: "otherElement2",
+ type: "rectangle",
+ boundElements: [],
+ });
+
+ mutateElement(container, {
+ boundElements: [{ type: "text", id: boundText.id }],
+ });
+
+ assertOrder(normalizeElementOrder([container, boundText]), [
+ "container",
+ "boundText",
+ ]);
+ assertOrder(normalizeElementOrder([boundText, container]), [
+ "container",
+ "boundText",
+ ]);
+ assertOrder(
+ normalizeElementOrder([
+ boundText,
+ container,
+ otherElement,
+ otherElement2,
+ ]),
+ ["container", "boundText", "otherElement", "otherElement2"],
+ );
+ assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
+ "container",
+ "boundText",
+ "otherElement",
+ ]);
+ assertOrder(
+ normalizeElementOrder([
+ container,
+ otherElement,
+ otherElement2,
+ boundText,
+ ]),
+ ["container", "boundText", "otherElement", "otherElement2"],
+ );
+
+ assertOrder(
+ normalizeElementOrder([
+ boundText,
+ otherElement,
+ container,
+ otherElement2,
+ ]),
+ ["otherElement", "container", "boundText", "otherElement2"],
+ );
+
+ // noop
+ assertOrder(
+ normalizeElementOrder([
+ otherElement,
+ container,
+ boundText,
+ otherElement2,
+ ]),
+ ["otherElement", "container", "boundText", "otherElement2"],
+ );
+
+ // text has existing containerId, but container doesn't list is
+ // as a boundElement
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "boundText",
+ type: "text",
+ containerId: "container",
+ }),
+ API.createElement({
+ id: "container",
+ type: "rectangle",
+ }),
+ ]),
+ ["boundText", "container"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "boundText",
+ type: "text",
+ containerId: "container",
+ }),
+ ]),
+ ["boundText"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "container",
+ type: "rectangle",
+ boundElements: [],
+ }),
+ ]),
+ ["container"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "container",
+ type: "rectangle",
+ boundElements: [{ id: "x", type: "text" }],
+ }),
+ ]),
+ ["container"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "arrow",
+ type: "arrow",
+ }),
+ API.createElement({
+ id: "container",
+ type: "rectangle",
+ boundElements: [{ id: "arrow", type: "arrow" }],
+ }),
+ ]),
+ ["arrow", "container"],
+ );
+ });
+
+ it("normalize group order", () => {
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "A_rect1",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "rect2",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "rect3",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "A_rect4",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "A_rect5",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "rect6",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "A_rect7",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ ]),
+ ["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "A_rect1",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "rect2",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "B_rect3",
+ type: "rectangle",
+ groupIds: ["B"],
+ }),
+ API.createElement({
+ id: "A_rect4",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "B_rect5",
+ type: "rectangle",
+ groupIds: ["B"],
+ }),
+ API.createElement({
+ id: "rect6",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "A_rect7",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ ]),
+ ["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
+ );
+ // nested groups
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "A_rect1",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "BA_rect2",
+ type: "rectangle",
+ groupIds: ["B", "A"],
+ }),
+ ]),
+ ["A_rect1", "BA_rect2"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "BA_rect1",
+ type: "rectangle",
+ groupIds: ["B", "A"],
+ }),
+ API.createElement({
+ id: "A_rect2",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ ]),
+ ["BA_rect1", "A_rect2"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "BA_rect1",
+ type: "rectangle",
+ groupIds: ["B", "A"],
+ }),
+ API.createElement({
+ id: "A_rect2",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "CBA_rect3",
+ type: "rectangle",
+ groupIds: ["C", "B", "A"],
+ }),
+ API.createElement({
+ id: "rect4",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "A_rect5",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "BA_rect5",
+ type: "rectangle",
+ groupIds: ["B", "A"],
+ }),
+ API.createElement({
+ id: "BA_rect6",
+ type: "rectangle",
+ groupIds: ["B", "A"],
+ }),
+ API.createElement({
+ id: "CBA_rect7",
+ type: "rectangle",
+ groupIds: ["C", "B", "A"],
+ }),
+ API.createElement({
+ id: "X_rect8",
+ type: "rectangle",
+ groupIds: ["X"],
+ }),
+ API.createElement({
+ id: "rect9",
+ type: "rectangle",
+ }),
+ API.createElement({
+ id: "YX_rect10",
+ type: "rectangle",
+ groupIds: ["Y", "X"],
+ }),
+ API.createElement({
+ id: "X_rect11",
+ type: "rectangle",
+ groupIds: ["X"],
+ }),
+ ]),
+ [
+ "BA_rect1",
+ "BA_rect5",
+ "BA_rect6",
+ "A_rect2",
+ "A_rect5",
+ "CBA_rect3",
+ "CBA_rect7",
+ "rect4",
+ "X_rect8",
+ "X_rect11",
+ "YX_rect10",
+ "rect9",
+ ],
+ );
+ });
+
+ // TODO
+ it.skip("normalize boundElements array", () => {
+ const container = API.createElement({
+ id: "container",
+ type: "rectangle",
+ boundElements: [],
+ });
+ const boundText = API.createElement({
+ id: "boundText",
+ type: "text",
+ containerId: container.id,
+ });
+
+ mutateElement(container, {
+ boundElements: [
+ { type: "text", id: boundText.id },
+ { type: "text", id: "xxx" },
+ ],
+ });
+
+ expect(normalizeElementOrder([container, boundText])).toEqual([
+ expect.objectContaining({
+ id: container.id,
+ }),
+ expect.objectContaining({ id: boundText.id }),
+ ]);
+ });
+
+ // should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
+ it.skip("normalizeElementsOrder() perf", () => {
+ const makeElements = (iterations: number) => {
+ const elements: ExcalidrawElement[] = [];
+ while (iterations--) {
+ const container = API.createElement({
+ type: "rectangle",
+ boundElements: [],
+ groupIds: ["B", "A"],
+ });
+ const boundText = API.createElement({
+ type: "text",
+ containerId: container.id,
+ groupIds: ["A"],
+ });
+ const otherElement = API.createElement({
+ type: "rectangle",
+ boundElements: [],
+ groupIds: ["C", "A"],
+ });
+ mutateElement(container, {
+ boundElements: [{ type: "text", id: boundText.id }],
+ });
+
+ elements.push(boundText, otherElement, container);
+ }
+ return elements;
+ };
+
+ const elements = makeElements(10000);
+ const t0 = Date.now();
+ normalizeElementOrder(elements);
+ console.info(`${Date.now() - t0}ms`);
+ });
+});
diff --git a/packages/excalidraw/element/sortElements.ts b/packages/excalidraw/element/sortElements.ts
new file mode 100644
index 0000000..3078a68
--- /dev/null
+++ b/packages/excalidraw/element/sortElements.ts
@@ -0,0 +1,120 @@
+import { arrayToMapWithIndex } from "../utils";
+import type { ExcalidrawElement } from "./types";
+
+const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
+ const origElements: ExcalidrawElement[] = elements.slice();
+ const sortedElements = new Set<ExcalidrawElement>();
+
+ const orderInnerGroups = (
+ elements: readonly ExcalidrawElement[],
+ ): ExcalidrawElement[] => {
+ const firstGroupSig = elements[0]?.groupIds?.join("");
+ const aGroup: ExcalidrawElement[] = [elements[0]];
+ const bGroup: ExcalidrawElement[] = [];
+ for (const element of elements.slice(1)) {
+ if (element.groupIds?.join("") === firstGroupSig) {
+ aGroup.push(element);
+ } else {
+ bGroup.push(element);
+ }
+ }
+ return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
+ };
+
+ const groupHandledElements = new Map<string, true>();
+
+ origElements.forEach((element, idx) => {
+ if (groupHandledElements.has(element.id)) {
+ return;
+ }
+ if (element.groupIds?.length) {
+ const topGroup = element.groupIds[element.groupIds.length - 1];
+ const groupElements = origElements.slice(idx).filter((element) => {
+ const ret = element?.groupIds?.some((id) => id === topGroup);
+ if (ret) {
+ groupHandledElements.set(element!.id, true);
+ }
+ return ret;
+ });
+
+ for (const elem of orderInnerGroups(groupElements)) {
+ sortedElements.add(elem);
+ }
+ } else {
+ sortedElements.add(element);
+ }
+ });
+
+ // if there's a bug which resulted in losing some of the elements, return
+ // original instead as that's better than losing data
+ if (sortedElements.size !== elements.length) {
+ console.error("normalizeGroupElementOrder: lost some elements... bailing!");
+ return elements;
+ }
+
+ return [...sortedElements];
+};
+
+/**
+ * In theory, when we have text elements bound to a container, they
+ * should be right after the container element in the elements array.
+ * However, this is not guaranteed due to old and potential future bugs.
+ *
+ * This function sorts containers and their bound texts together. It prefers
+ * original z-index of container (i.e. it moves bound text elements after
+ * containers).
+ */
+const normalizeBoundElementsOrder = (
+ elements: readonly ExcalidrawElement[],
+) => {
+ const elementsMap = arrayToMapWithIndex(elements);
+
+ const origElements: (ExcalidrawElement | null)[] = elements.slice();
+ const sortedElements = new Set<ExcalidrawElement>();
+
+ origElements.forEach((element, idx) => {
+ if (!element) {
+ return;
+ }
+ if (element.boundElements?.length) {
+ sortedElements.add(element);
+ origElements[idx] = null;
+ element.boundElements.forEach((boundElement) => {
+ const child = elementsMap.get(boundElement.id);
+ if (child && boundElement.type === "text") {
+ sortedElements.add(child[0]);
+ origElements[child[1]] = null;
+ }
+ });
+ } else if (element.type === "text" && element.containerId) {
+ const parent = elementsMap.get(element.containerId);
+ if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
+ sortedElements.add(element);
+ origElements[idx] = null;
+
+ // if element has a container and container lists it, skip this element
+ // as it'll be taken care of by the container
+ }
+ } else {
+ sortedElements.add(element);
+ origElements[idx] = null;
+ }
+ });
+
+ // if there's a bug which resulted in losing some of the elements, return
+ // original instead as that's better than losing data
+ if (sortedElements.size !== elements.length) {
+ console.error(
+ "normalizeBoundElementsOrder: lost some elements... bailing!",
+ );
+ return elements;
+ }
+
+ return [...sortedElements];
+};
+
+export const normalizeElementOrder = (
+ elements: readonly ExcalidrawElement[],
+) => {
+ return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
+};
diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts
new file mode 100644
index 0000000..2c23c2b
--- /dev/null
+++ b/packages/excalidraw/element/textElement.test.ts
@@ -0,0 +1,206 @@
+import { FONT_FAMILY } from "../constants";
+import { getLineHeight } from "../fonts";
+import { API } from "../tests/helpers/api";
+import {
+ computeContainerDimensionForBoundText,
+ getContainerCoords,
+ getBoundTextMaxWidth,
+ getBoundTextMaxHeight,
+} from "./textElement";
+import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
+import type { ExcalidrawTextElementWithContainer } from "./types";
+
+describe("Test measureText", () => {
+ describe("Test getContainerCoords", () => {
+ const params = { width: 200, height: 100, x: 10, y: 20 };
+
+ it("should compute coords correctly when ellipse", () => {
+ const element = API.createElement({
+ type: "ellipse",
+ ...params,
+ });
+ expect(getContainerCoords(element)).toEqual({
+ x: 44.2893218813452455,
+ y: 39.64466094067262,
+ });
+ });
+
+ it("should compute coords correctly when rectangle", () => {
+ const element = API.createElement({
+ type: "rectangle",
+ ...params,
+ });
+ expect(getContainerCoords(element)).toEqual({
+ x: 15,
+ y: 25,
+ });
+ });
+
+ it("should compute coords correctly when diamond", () => {
+ const element = API.createElement({
+ type: "diamond",
+ ...params,
+ });
+ expect(getContainerCoords(element)).toEqual({
+ x: 65,
+ y: 50,
+ });
+ });
+ });
+
+ describe("Test computeContainerDimensionForBoundText", () => {
+ const params = {
+ width: 178,
+ height: 194,
+ };
+
+ it("should compute container height correctly for rectangle", () => {
+ const element = API.createElement({
+ type: "rectangle",
+ ...params,
+ });
+ expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+ 160,
+ );
+ });
+
+ it("should compute container height correctly for ellipse", () => {
+ const element = API.createElement({
+ type: "ellipse",
+ ...params,
+ });
+ expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+ 226,
+ );
+ });
+
+ it("should compute container height correctly for diamond", () => {
+ const element = API.createElement({
+ type: "diamond",
+ ...params,
+ });
+ expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
+ 320,
+ );
+ });
+ });
+
+ describe("Test getBoundTextMaxWidth", () => {
+ const params = {
+ width: 178,
+ height: 194,
+ };
+
+ it("should return max width when container is rectangle", () => {
+ const container = API.createElement({ type: "rectangle", ...params });
+ expect(getBoundTextMaxWidth(container, null)).toBe(168);
+ });
+
+ it("should return max width when container is ellipse", () => {
+ const container = API.createElement({ type: "ellipse", ...params });
+ expect(getBoundTextMaxWidth(container, null)).toBe(116);
+ });
+
+ it("should return max width when container is diamond", () => {
+ const container = API.createElement({ type: "diamond", ...params });
+ expect(getBoundTextMaxWidth(container, null)).toBe(79);
+ });
+ });
+
+ describe("Test getBoundTextMaxHeight", () => {
+ const params = {
+ width: 178,
+ height: 194,
+ id: '"container-id',
+ };
+
+ const boundTextElement = API.createElement({
+ type: "text",
+ id: "text-id",
+ x: 560.51171875,
+ y: 202.033203125,
+ width: 154,
+ height: 175,
+ fontSize: 20,
+ fontFamily: 1,
+ text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
+ textAlign: "center",
+ verticalAlign: "middle",
+ containerId: params.id,
+ }) as ExcalidrawTextElementWithContainer;
+
+ it("should return max height when container is rectangle", () => {
+ const container = API.createElement({ type: "rectangle", ...params });
+ expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
+ });
+
+ it("should return max height when container is ellipse", () => {
+ const container = API.createElement({ type: "ellipse", ...params });
+ expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
+ });
+
+ it("should return max height when container is diamond", () => {
+ const container = API.createElement({ type: "diamond", ...params });
+ expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
+ });
+
+ it("should return max height when container is arrow", () => {
+ const container = API.createElement({
+ type: "arrow",
+ ...params,
+ });
+ expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
+ });
+
+ it("should return max height when container is arrow and height is less than threshold", () => {
+ const container = API.createElement({
+ type: "arrow",
+ ...params,
+ height: 70,
+ boundElements: [{ type: "text", id: "text-id" }],
+ });
+
+ expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
+ boundTextElement.height,
+ );
+ });
+ });
+});
+
+const textElement = API.createElement({
+ type: "text",
+ text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
+ fontSize: 20,
+ fontFamily: 1,
+ height: 175,
+});
+
+describe("Test detectLineHeight", () => {
+ it("should return correct line height", () => {
+ expect(detectLineHeight(textElement)).toBe(1.25);
+ });
+});
+
+describe("Test getLineHeightInPx", () => {
+ it("should return correct line height", () => {
+ expect(
+ getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
+ ).toBe(25);
+ });
+});
+
+describe("Test getDefaultLineHeight", () => {
+ it("should return line height using default font family when not passed", () => {
+ //@ts-ignore
+ expect(getLineHeight()).toBe(1.25);
+ });
+
+ it("should return line height using default font family for unknown font", () => {
+ const UNKNOWN_FONT = 5;
+ expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
+ });
+
+ it("should return correct line height", () => {
+ expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
+ });
+});
diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts
new file mode 100644
index 0000000..de948d9
--- /dev/null
+++ b/packages/excalidraw/element/textElement.ts
@@ -0,0 +1,521 @@
+import { getFontString, arrayToMap } from "../utils";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ ExcalidrawElementType,
+ ExcalidrawTextContainer,
+ ExcalidrawTextElement,
+ ExcalidrawTextElementWithContainer,
+ NonDeletedExcalidrawElement,
+} from "./types";
+import { mutateElement } from "./mutateElement";
+import {
+ ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
+ ARROW_LABEL_WIDTH_FRACTION,
+ BOUND_TEXT_PADDING,
+ DEFAULT_FONT_SIZE,
+ TEXT_ALIGN,
+ VERTICAL_ALIGN,
+} from "../constants";
+import type { MaybeTransformHandleType } from "./transformHandles";
+import { isTextElement } from ".";
+import { wrapText } from "./textWrapping";
+import { isBoundToContainer, isArrowElement } from "./typeChecks";
+import { LinearElementEditor } from "./linearElementEditor";
+import type { AppState } from "../types";
+import {
+ resetOriginalContainerCache,
+ updateOriginalContainerCache,
+} from "./containerCache";
+import type { ExtractSetType } from "../utility-types";
+import { measureText } from "./textMeasurements";
+
+export const redrawTextBoundingBox = (
+ textElement: ExcalidrawTextElement,
+ container: ExcalidrawElement | null,
+ elementsMap: ElementsMap,
+ informMutation = true,
+) => {
+ let maxWidth = undefined;
+ const boundTextUpdates = {
+ x: textElement.x,
+ y: textElement.y,
+ text: textElement.text,
+ width: textElement.width,
+ height: textElement.height,
+ angle: container?.angle ?? textElement.angle,
+ };
+
+ boundTextUpdates.text = textElement.text;
+
+ if (container || !textElement.autoResize) {
+ maxWidth = container
+ ? getBoundTextMaxWidth(container, textElement)
+ : textElement.width;
+ boundTextUpdates.text = wrapText(
+ textElement.originalText,
+ getFontString(textElement),
+ maxWidth,
+ );
+ }
+
+ const metrics = measureText(
+ boundTextUpdates.text,
+ getFontString(textElement),
+ textElement.lineHeight,
+ );
+
+ // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
+ if (textElement.autoResize) {
+ boundTextUpdates.width = metrics.width;
+ }
+ boundTextUpdates.height = metrics.height;
+
+ if (container) {
+ const maxContainerHeight = getBoundTextMaxHeight(
+ container,
+ textElement as ExcalidrawTextElementWithContainer,
+ );
+ const maxContainerWidth = getBoundTextMaxWidth(container, textElement);
+
+ if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
+ const nextHeight = computeContainerDimensionForBoundText(
+ metrics.height,
+ container.type,
+ );
+ mutateElement(container, { height: nextHeight }, informMutation);
+ updateOriginalContainerCache(container.id, nextHeight);
+ }
+ if (metrics.width > maxContainerWidth) {
+ const nextWidth = computeContainerDimensionForBoundText(
+ metrics.width,
+ container.type,
+ );
+ mutateElement(container, { width: nextWidth }, informMutation);
+ }
+ const updatedTextElement = {
+ ...textElement,
+ ...boundTextUpdates,
+ } as ExcalidrawTextElementWithContainer;
+ const { x, y } = computeBoundTextPosition(
+ container,
+ updatedTextElement,
+ elementsMap,
+ );
+ boundTextUpdates.x = x;
+ boundTextUpdates.y = y;
+ }
+
+ mutateElement(textElement, boundTextUpdates, informMutation);
+};
+
+export const bindTextToShapeAfterDuplication = (
+ newElements: ExcalidrawElement[],
+ oldElements: ExcalidrawElement[],
+ oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
+): void => {
+ const newElementsMap = arrayToMap(newElements) as Map<
+ ExcalidrawElement["id"],
+ ExcalidrawElement
+ >;
+ oldElements.forEach((element) => {
+ const newElementId = oldIdToDuplicatedId.get(element.id) as string;
+ const boundTextElementId = getBoundTextElementId(element);
+
+ if (boundTextElementId) {
+ const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
+ if (newTextElementId) {
+ const newContainer = newElementsMap.get(newElementId);
+ if (newContainer) {
+ mutateElement(newContainer, {
+ boundElements: (element.boundElements || [])
+ .filter(
+ (boundElement) =>
+ boundElement.id !== newTextElementId &&
+ boundElement.id !== boundTextElementId,
+ )
+ .concat({
+ type: "text",
+ id: newTextElementId,
+ }),
+ });
+ }
+ const newTextElement = newElementsMap.get(newTextElementId);
+ if (newTextElement && isTextElement(newTextElement)) {
+ mutateElement(newTextElement, {
+ containerId: newContainer ? newElementId : null,
+ });
+ }
+ }
+ }
+ });
+};
+
+export const handleBindTextResize = (
+ container: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ transformHandleType: MaybeTransformHandleType,
+ shouldMaintainAspectRatio = false,
+) => {
+ const boundTextElementId = getBoundTextElementId(container);
+ if (!boundTextElementId) {
+ return;
+ }
+ resetOriginalContainerCache(container.id);
+ const textElement = getBoundTextElement(container, elementsMap);
+ if (textElement && textElement.text) {
+ if (!container) {
+ return;
+ }
+
+ let text = textElement.text;
+ let nextHeight = textElement.height;
+ let nextWidth = textElement.width;
+ const maxWidth = getBoundTextMaxWidth(container, textElement);
+ const maxHeight = getBoundTextMaxHeight(container, textElement);
+ let containerHeight = container.height;
+ if (
+ shouldMaintainAspectRatio ||
+ (transformHandleType !== "n" && transformHandleType !== "s")
+ ) {
+ if (text) {
+ text = wrapText(
+ textElement.originalText,
+ getFontString(textElement),
+ maxWidth,
+ );
+ }
+ const metrics = measureText(
+ text,
+ getFontString(textElement),
+ textElement.lineHeight,
+ );
+ nextHeight = metrics.height;
+ nextWidth = metrics.width;
+ }
+ // increase height in case text element height exceeds
+ if (nextHeight > maxHeight) {
+ containerHeight = computeContainerDimensionForBoundText(
+ nextHeight,
+ container.type,
+ );
+
+ const diff = containerHeight - container.height;
+ // fix the y coord when resizing from ne/nw/n
+ const updatedY =
+ !isArrowElement(container) &&
+ (transformHandleType === "ne" ||
+ transformHandleType === "nw" ||
+ transformHandleType === "n")
+ ? container.y - diff
+ : container.y;
+ mutateElement(container, {
+ height: containerHeight,
+ y: updatedY,
+ });
+ }
+
+ mutateElement(textElement, {
+ text,
+ width: nextWidth,
+ height: nextHeight,
+ });
+
+ if (!isArrowElement(container)) {
+ mutateElement(
+ textElement,
+ computeBoundTextPosition(container, textElement, elementsMap),
+ );
+ }
+ }
+};
+
+export const computeBoundTextPosition = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+ elementsMap: ElementsMap,
+) => {
+ if (isArrowElement(container)) {
+ return LinearElementEditor.getBoundTextElementPosition(
+ container,
+ boundTextElement,
+ elementsMap,
+ );
+ }
+ const containerCoords = getContainerCoords(container);
+ const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
+ const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement);
+
+ let x;
+ let y;
+ if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
+ y = containerCoords.y;
+ } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
+ y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
+ } else {
+ y =
+ containerCoords.y +
+ (maxContainerHeight / 2 - boundTextElement.height / 2);
+ }
+ if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
+ x = containerCoords.x;
+ } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
+ x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
+ } else {
+ x =
+ containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
+ }
+ return { x, y };
+};
+
+export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
+ return container?.boundElements?.length
+ ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
+ : null;
+};
+
+export const getBoundTextElement = (
+ element: ExcalidrawElement | null,
+ elementsMap: ElementsMap,
+) => {
+ if (!element) {
+ return null;
+ }
+ const boundTextElementId = getBoundTextElementId(element);
+
+ if (boundTextElementId) {
+ return (elementsMap.get(boundTextElementId) ||
+ null) as ExcalidrawTextElementWithContainer | null;
+ }
+ return null;
+};
+
+export const getContainerElement = (
+ element: ExcalidrawTextElement | null,
+ elementsMap: ElementsMap,
+): ExcalidrawTextContainer | null => {
+ if (!element) {
+ return null;
+ }
+ if (element.containerId) {
+ return (elementsMap.get(element.containerId) ||
+ null) as ExcalidrawTextContainer | null;
+ }
+ return null;
+};
+
+export const getContainerCenter = (
+ container: ExcalidrawElement,
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ if (!isArrowElement(container)) {
+ return {
+ x: container.x + container.width / 2,
+ y: container.y + container.height / 2,
+ };
+ }
+ const points = LinearElementEditor.getPointsGlobalCoordinates(
+ container,
+ elementsMap,
+ );
+ if (points.length % 2 === 1) {
+ const index = Math.floor(container.points.length / 2);
+ const midPoint = LinearElementEditor.getPointGlobalCoordinates(
+ container,
+ container.points[index],
+ elementsMap,
+ );
+ return { x: midPoint[0], y: midPoint[1] };
+ }
+ const index = container.points.length / 2 - 1;
+ let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
+ container,
+ elementsMap,
+ appState,
+ )[index];
+ if (!midSegmentMidpoint) {
+ midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
+ container,
+ points[index],
+ points[index + 1],
+ index + 1,
+ elementsMap,
+ );
+ }
+ return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
+};
+
+export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
+ let offsetX = BOUND_TEXT_PADDING;
+ let offsetY = BOUND_TEXT_PADDING;
+
+ if (container.type === "ellipse") {
+ // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
+ offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
+ offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
+ }
+ // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
+ if (container.type === "diamond") {
+ offsetX += container.width / 4;
+ offsetY += container.height / 4;
+ }
+ return {
+ x: container.x + offsetX,
+ y: container.y + offsetY,
+ };
+};
+
+export const getTextElementAngle = (
+ textElement: ExcalidrawTextElement,
+ container: ExcalidrawTextContainer | null,
+) => {
+ if (!container || isArrowElement(container)) {
+ return textElement.angle;
+ }
+ return container.angle;
+};
+
+export const getBoundTextElementPosition = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+ elementsMap: ElementsMap,
+) => {
+ if (isArrowElement(container)) {
+ return LinearElementEditor.getBoundTextElementPosition(
+ container,
+ boundTextElement,
+ elementsMap,
+ );
+ }
+};
+
+export const shouldAllowVerticalAlign = (
+ selectedElements: NonDeletedExcalidrawElement[],
+ elementsMap: ElementsMap,
+) => {
+ return selectedElements.some((element) => {
+ if (isBoundToContainer(element)) {
+ const container = getContainerElement(element, elementsMap);
+ if (isArrowElement(container)) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ });
+};
+
+export const suppportsHorizontalAlign = (
+ selectedElements: NonDeletedExcalidrawElement[],
+ elementsMap: ElementsMap,
+) => {
+ return selectedElements.some((element) => {
+ if (isBoundToContainer(element)) {
+ const container = getContainerElement(element, elementsMap);
+ if (isArrowElement(container)) {
+ return false;
+ }
+ return true;
+ }
+
+ return isTextElement(element);
+ });
+};
+
+const VALID_CONTAINER_TYPES = new Set([
+ "rectangle",
+ "ellipse",
+ "diamond",
+ "arrow",
+]);
+
+export const isValidTextContainer = (element: {
+ type: ExcalidrawElementType;
+}) => VALID_CONTAINER_TYPES.has(element.type);
+
+export const computeContainerDimensionForBoundText = (
+ dimension: number,
+ containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
+) => {
+ dimension = Math.ceil(dimension);
+ const padding = BOUND_TEXT_PADDING * 2;
+
+ if (containerType === "ellipse") {
+ return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
+ }
+ if (containerType === "arrow") {
+ return dimension + padding * 8;
+ }
+ if (containerType === "diamond") {
+ return 2 * (dimension + padding);
+ }
+ return dimension + padding;
+};
+
+export const getBoundTextMaxWidth = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElement | null,
+) => {
+ const { width } = container;
+ if (isArrowElement(container)) {
+ const minWidth =
+ (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
+ ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
+ return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
+ }
+ if (container.type === "ellipse") {
+ // The width of the largest rectangle inscribed inside an ellipse is
+ // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
+ // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
+ return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
+ }
+ if (container.type === "diamond") {
+ // The width of the largest rectangle inscribed inside a rhombus is
+ // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
+ return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
+ }
+ return width - BOUND_TEXT_PADDING * 2;
+};
+
+export const getBoundTextMaxHeight = (
+ container: ExcalidrawElement,
+ boundTextElement: ExcalidrawTextElementWithContainer,
+) => {
+ const { height } = container;
+ if (isArrowElement(container)) {
+ const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
+ if (containerHeight <= 0) {
+ return boundTextElement.height;
+ }
+ return height;
+ }
+ if (container.type === "ellipse") {
+ // The height of the largest rectangle inscribed inside an ellipse is
+ // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
+ // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
+ return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
+ }
+ if (container.type === "diamond") {
+ // The height of the largest rectangle inscribed inside a rhombus is
+ // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
+ return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
+ }
+ return height - BOUND_TEXT_PADDING * 2;
+};
+
+/** retrieves text from text elements and concatenates to a single string */
+export const getTextFromElements = (
+ elements: readonly ExcalidrawElement[],
+ separator = "\n\n",
+) => {
+ const text = elements
+ .reduce((acc: string[], element) => {
+ if (isTextElement(element)) {
+ acc.push(element.text);
+ }
+ return acc;
+ }, [])
+ .join(separator);
+ return text;
+};
diff --git a/packages/excalidraw/element/textMeasurements.ts b/packages/excalidraw/element/textMeasurements.ts
new file mode 100644
index 0000000..f2a132a
--- /dev/null
+++ b/packages/excalidraw/element/textMeasurements.ts
@@ -0,0 +1,224 @@
+import {
+ BOUND_TEXT_PADDING,
+ DEFAULT_FONT_SIZE,
+ DEFAULT_FONT_FAMILY,
+} from "../constants";
+import { getFontString, isTestEnv, normalizeEOL } from "../utils";
+import type { FontString, ExcalidrawTextElement } from "./types";
+
+export const measureText = (
+ text: string,
+ font: FontString,
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ const _text = text
+ .split("\n")
+ // replace empty lines with single space because leading/trailing empty
+ // lines would be stripped from computation
+ .map((x) => x || " ")
+ .join("\n");
+ const fontSize = parseFloat(font);
+ const height = getTextHeight(_text, fontSize, lineHeight);
+ const width = getTextWidth(_text, font);
+ return { width, height };
+};
+
+const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
+
+// FIXME rename to getApproxMinContainerWidth
+export const getApproxMinLineWidth = (
+ font: FontString,
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ const maxCharWidth = getMaxCharWidth(font);
+ if (maxCharWidth === 0) {
+ return (
+ measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
+ BOUND_TEXT_PADDING * 2
+ );
+ }
+ return maxCharWidth + BOUND_TEXT_PADDING * 2;
+};
+
+export const getMinTextElementWidth = (
+ font: FontString,
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
+};
+
+export const isMeasureTextSupported = () => {
+ const width = getTextWidth(
+ DUMMY_TEXT,
+ getFontString({
+ fontSize: DEFAULT_FONT_SIZE,
+ fontFamily: DEFAULT_FONT_FAMILY,
+ }),
+ );
+ return width > 0;
+};
+
+export const normalizeText = (text: string) => {
+ return (
+ normalizeEOL(text)
+ // replace tabs with spaces so they render and measure correctly
+ .replace(/\t/g, " ")
+ );
+};
+
+const splitIntoLines = (text: string) => {
+ return normalizeText(text).split("\n");
+};
+
+/**
+ * To get unitless line-height (if unknown) we can calculate it by dividing
+ * height-per-line by fontSize.
+ */
+export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
+ const lineCount = splitIntoLines(textElement.text).length;
+ return (textElement.height /
+ lineCount /
+ textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
+};
+
+/**
+ * We calculate the line height from the font size and the unitless line height,
+ * aligning with the W3C spec.
+ */
+export const getLineHeightInPx = (
+ fontSize: ExcalidrawTextElement["fontSize"],
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ return fontSize * lineHeight;
+};
+
+// FIXME rename to getApproxMinContainerHeight
+export const getApproxMinLineHeight = (
+ fontSize: ExcalidrawTextElement["fontSize"],
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
+};
+
+let textMetricsProvider: TextMetricsProvider | undefined;
+
+/**
+ * Set a custom text metrics provider.
+ *
+ * Useful for overriding the width calculation algorithm where canvas API is not available / desired.
+ */
+export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
+ textMetricsProvider = provider;
+};
+
+export interface TextMetricsProvider {
+ getLineWidth(text: string, fontString: FontString): number;
+}
+
+class CanvasTextMetricsProvider implements TextMetricsProvider {
+ private canvas: HTMLCanvasElement;
+
+ constructor() {
+ this.canvas = document.createElement("canvas");
+ }
+
+ /**
+ * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
+ * - text wrapping
+ * - wysiwyg editor (+padding)
+ *
+ * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
+ */
+ public getLineWidth(text: string, fontString: FontString): number {
+ const context = this.canvas.getContext("2d")!;
+ context.font = fontString;
+ const metrics = context.measureText(text);
+ const advanceWidth = metrics.width;
+
+ // since in test env the canvas measureText algo
+ // doesn't measure text and instead just returns number of
+ // characters hence we assume that each letteris 10px
+ if (isTestEnv()) {
+ return advanceWidth * 10;
+ }
+
+ return advanceWidth;
+ }
+}
+
+export const getLineWidth = (text: string, font: FontString) => {
+ if (!textMetricsProvider) {
+ textMetricsProvider = new CanvasTextMetricsProvider();
+ }
+
+ return textMetricsProvider.getLineWidth(text, font);
+};
+
+export const getTextWidth = (text: string, font: FontString) => {
+ const lines = splitIntoLines(text);
+ let width = 0;
+ lines.forEach((line) => {
+ width = Math.max(width, getLineWidth(line, font));
+ });
+
+ return width;
+};
+
+export const getTextHeight = (
+ text: string,
+ fontSize: number,
+ lineHeight: ExcalidrawTextElement["lineHeight"],
+) => {
+ const lineCount = splitIntoLines(text).length;
+ return getLineHeightInPx(fontSize, lineHeight) * lineCount;
+};
+
+export const charWidth = (() => {
+ const cachedCharWidth: { [key: FontString]: Array<number> } = {};
+
+ const calculate = (char: string, font: FontString) => {
+ const unicode = char.charCodeAt(0);
+ if (!cachedCharWidth[font]) {
+ cachedCharWidth[font] = [];
+ }
+ if (!cachedCharWidth[font][unicode]) {
+ const width = getLineWidth(char, font);
+ cachedCharWidth[font][unicode] = width;
+ }
+
+ return cachedCharWidth[font][unicode];
+ };
+
+ const getCache = (font: FontString) => {
+ return cachedCharWidth[font];
+ };
+
+ const clearCache = (font: FontString) => {
+ cachedCharWidth[font] = [];
+ };
+
+ return {
+ calculate,
+ getCache,
+ clearCache,
+ };
+})();
+
+export const getMinCharWidth = (font: FontString) => {
+ const cache = charWidth.getCache(font);
+ if (!cache) {
+ return 0;
+ }
+ const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+
+ return Math.min(...cacheWithOutEmpty);
+};
+
+export const getMaxCharWidth = (font: FontString) => {
+ const cache = charWidth.getCache(font);
+ if (!cache) {
+ return 0;
+ }
+ const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
+ return Math.max(...cacheWithOutEmpty);
+};
diff --git a/packages/excalidraw/element/textWrapping.test.ts b/packages/excalidraw/element/textWrapping.test.ts
new file mode 100644
index 0000000..6c7bcb8
--- /dev/null
+++ b/packages/excalidraw/element/textWrapping.test.ts
@@ -0,0 +1,633 @@
+import { wrapText, parseTokens } from "./textWrapping";
+import type { FontString } from "./types";
+
+describe("Test wrapText", () => {
+ // font is irrelevant as jsdom does not support FontFace API
+ // `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
+ // https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
+ const font = "10px Cascadia, Segoe UI Emoji" as FontString;
+
+ it("should wrap the text correctly when word length is exactly equal to max width", () => {
+ const text = "Hello Excalidraw";
+ // Length of "Excalidraw" is 100 and exacty equal to max width
+ const res = wrapText(text, font, 100);
+ expect(res).toEqual(`Hello\nExcalidraw`);
+ });
+
+ it("should return the text as is if max width is invalid", () => {
+ const text = "Hello Excalidraw";
+ expect(wrapText(text, font, NaN)).toEqual(text);
+ expect(wrapText(text, font, -1)).toEqual(text);
+ expect(wrapText(text, font, Infinity)).toEqual(text);
+ });
+
+ it("should show the text correctly when max width reached", () => {
+ const text = "Hello😀";
+ const maxWidth = 10;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("H\ne\nl\nl\no\n😀");
+ });
+
+ it("should not wrap number when wrapping line", () => {
+ const text = "don't wrap this number 99,100.99";
+ const maxWidth = 300;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("don't wrap this number\n99,100.99");
+ });
+
+ it("should trim all trailing whitespaces", () => {
+ const text = "Hello ";
+ const maxWidth = 50;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hello");
+ });
+
+ it("should trim all but one trailing whitespaces", () => {
+ const text = "Hello ";
+ const maxWidth = 60;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hello ");
+ });
+
+ it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
+ const text = " Hello World";
+ const maxWidth = 90;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(" Hello\nWorld");
+ });
+
+ it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
+ const text = " Hello World ";
+ const maxWidth = 90;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(" Hello\nWorld ");
+ });
+
+ it("should trim keep those whitespace that fit in the trailing line", () => {
+ const text = "Hello Wo rl d ";
+ const maxWidth = 100;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hello Wo\nrl d ");
+ });
+
+ it("should support multiple (multi-codepoint) emojis", () => {
+ const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿";
+ const maxWidth = 1;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿");
+ });
+
+ it("should wrap the text correctly when text contains hyphen", () => {
+ let text =
+ "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
+ const res = wrapText(text, font, 110);
+ expect(res).toBe(
+ `Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
+ );
+
+ text = "Hello thereusing-now";
+ expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
+ });
+
+ it("should support wrapping nested lists", () => {
+ const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
+
+ const maxWidth = 100;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
+
+ const maxWidth2 = 50;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
+ });
+
+ describe("When text is CJK", () => {
+ it("should break each CJK character when width is very small", () => {
+ // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+ const text = "안녕하세요こんにちは世界コンニチハ你好";
+ const maxWidth = 10;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(
+ "안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
+ );
+ });
+
+ it("should break CJK text into longer segments when width is larger", () => {
+ // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+ const text = "안녕하세요こんにちは世界コンニチハ你好";
+ const maxWidth = 30;
+ const res = wrapText(text, font, maxWidth);
+
+ // measureText is mocked, so it's not precisely what would happen in prod
+ expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
+ });
+
+ it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
+ const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
+
+ const maxWidth = 150;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
+
+ const maxWidth2 = 50;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
+
+ const maxWidth3 = 30;
+ const res3 = wrapText(text, font, maxWidth3);
+ expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
+ });
+
+ it("should break before and after a regular CJK character", () => {
+ const text = "HelloたWorld";
+ const maxWidth1 = 50;
+ const res1 = wrapText(text, font, maxWidth1);
+ expect(res1).toBe("Hello\nた\nWorld");
+
+ const maxWidth2 = 60;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe("Helloた\nWorld");
+ });
+
+ it("should break before and after certain CJK symbols", () => {
+ const text = "こんにちは〃世界";
+ const maxWidth1 = 50;
+ const res1 = wrapText(text, font, maxWidth1);
+ expect(res1).toBe("こんにちは\n〃世界");
+
+ const maxWidth2 = 60;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe("こんにちは〃\n世界");
+ });
+
+ it("should break after, not before for certain CJK pairs", () => {
+ const text = "Hello た。";
+ const maxWidth = 70;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hello\nた。");
+ });
+
+ it("should break before, not after for certain CJK pairs", () => {
+ const text = "Hello「たWorld」";
+ const maxWidth = 60;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hello\n「た\nWorld」");
+ });
+
+ it("should break after, not before for certain CJK character pairs", () => {
+ const text = "「Helloた」World";
+ const maxWidth = 70;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("「Hello\nた」World");
+ });
+
+ it("should break Chinese sentences", () => {
+ const text = `中国你好!这是一个测试。
+我们来看看:人民币¥1234「很贵」
+(括号)、逗号,句号。空格 换行 全角符号…—`;
+
+ const maxWidth1 = 80;
+ const res1 = wrapText(text, font, maxWidth1);
+ expect(res1).toBe(`中国你好!这是一\n个测试。
+我们来看看:人民\n币¥1234「很\n贵」
+(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
+
+ const maxWidth2 = 50;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe(`中国你好!\n这是一个测\n试。
+我们来看\n看:人民币\n¥1234\n「很贵」
+(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
+ });
+
+ it("should break Japanese sentences", () => {
+ const text = `日本こんにちは!これはテストです。
+ 見てみましょう:円¥1234「高い」
+ (括弧)、読点、句点。
+ 空白 改行 全角記号…ー`;
+
+ const maxWidth1 = 80;
+ const res1 = wrapText(text, font, maxWidth1);
+ expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
+ 見てみましょ\nう:円¥1234\n「高い」
+ (括弧)、読\n点、句点。
+ 空白 改行\n全角記号…ー`);
+
+ const maxWidth2 = 50;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
+ 見てみ\nましょう:\n円\n¥1234\n「高い」
+ (括\n弧)、読\n点、句点。
+ 空白\n改行 全角\n記号…ー`);
+ });
+
+ it("should break Korean sentences", () => {
+ const text = `한국 안녕하세요! 이것은 테스트입니다.
+우리 보자: 원화₩1234「비싸다」
+(괄호), 쉼표, 마침표.
+공백 줄바꿈 전각기호…—`;
+
+ const maxWidth1 = 80;
+ const res1 = wrapText(text, font, maxWidth1);
+ expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
+우리 보자: 원\n화₩1234「비\n싸다」
+(괄호), 쉼\n표, 마침표.
+공백 줄바꿈 전\n각기호…—`);
+
+ const maxWidth2 = 60;
+ const res2 = wrapText(text, font, maxWidth2);
+ expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
+우리 보자:\n원화\n₩1234\n「비싸다」
+(괄호),\n쉼표, 마침\n표.
+공백 줄바꿈\n전각기호…—`);
+ });
+ });
+
+ describe("When text contains leading whitespaces", () => {
+ const text = " \t Hello world";
+
+ it("should preserve leading whitespaces", () => {
+ const maxWidth = 120;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(" \t Hello\nworld");
+ });
+
+ it("should break and collapse leading whitespaces when line breaks", () => {
+ const maxWidth = 60;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("\nHello\nworld");
+ });
+
+ it("should break and collapse leading whitespaces whe words break", () => {
+ const maxWidth = 30;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("\nHel\nlo\nwor\nld");
+ });
+ });
+
+ describe("When text contains trailing whitespaces", () => {
+ it("shouldn't add new lines for trailing spaces", () => {
+ const text = "Hello whats up ";
+ const maxWidth = 190;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe(text);
+ });
+
+ it("should ignore trailing whitespaces when line breaks", () => {
+ const text = "Hippopotomonstrosesquippedaliophobia ??????";
+ const maxWidth = 400;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
+ });
+
+ it("should not ignore trailing whitespaces when word breaks", () => {
+ const text = "Hippopotomonstrosesquippedaliophobia ??????";
+ const maxWidth = 300;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
+ });
+
+ it("should ignore trailing whitespaces when word breaks and line breaks", () => {
+ const text = "Hippopotomonstrosesquippedaliophobia ??????";
+ const maxWidth = 180;
+ const res = wrapText(text, font, maxWidth);
+ expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
+ });
+ });
+
+ describe("When text doesn't contain new lines", () => {
+ const text = "Hello whats up";
+
+ [
+ {
+ desc: "break all words when width of each word is less than container width",
+ width: 70,
+ res: `Hello\nwhats\nup`,
+ },
+ {
+ desc: "break all characters when width of each character is less than container width",
+ width: 15,
+ res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
+ },
+ {
+ desc: "break words as per the width",
+
+ width: 130,
+ res: `Hello whats\nup`,
+ },
+ {
+ desc: "fit the container",
+
+ width: 240,
+ res: "Hello whats up",
+ },
+ {
+ desc: "push the word if its equal to max width",
+ width: 50,
+ res: `Hello\nwhats\nup`,
+ },
+ ].forEach((data) => {
+ it(`should ${data.desc}`, () => {
+ const res = wrapText(text, font, data.width);
+ expect(res).toEqual(data.res);
+ });
+ });
+ });
+
+ describe("When text contain new lines", () => {
+ const text = `Hello\n whats up`;
+ [
+ {
+ desc: "break all words when width of each word is less than container width",
+ width: 70,
+ res: `Hello\n whats\nup`,
+ },
+ {
+ desc: "break all characters when width of each character is less than container width",
+ width: 15,
+ res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
+ },
+ {
+ desc: "break words as per the width",
+ width: 140,
+ res: `Hello\n whats up`,
+ },
+ ].forEach((data) => {
+ it(`should respect new lines and ${data.desc}`, () => {
+ const res = wrapText(text, font, data.width);
+ expect(res).toEqual(data.res);
+ });
+ });
+ });
+
+ describe("When text is long", () => {
+ const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
+ [
+ {
+ desc: "fit characters of long string as per container width",
+ width: 160,
+ res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
+ },
+ {
+ desc: "fit characters of long string as per container width and break words as per the width",
+
+ width: 120,
+ res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
+ },
+ {
+ desc: "fit the long text when container width is greater than text length and move the rest to next line",
+
+ width: 590,
+ res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
+ },
+ ].forEach((data) => {
+ it(`should ${data.desc}`, () => {
+ const res = wrapText(text, font, data.width);
+ expect(res).toEqual(data.res);
+ });
+ });
+ });
+
+ describe("Test parseTokens", () => {
+ it("should tokenize latin", () => {
+ let text = "Excalidraw is a virtual collaborative whiteboard";
+
+ expect(parseTokens(text)).toEqual([
+ "Excalidraw",
+ " ",
+ "is",
+ " ",
+ "a",
+ " ",
+ "virtual",
+ " ",
+ "collaborative",
+ " ",
+ "whiteboard",
+ ]);
+
+ text =
+ "Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
+ expect(parseTokens(text)).toEqual([
+ "Wikipedia",
+ " ",
+ "is",
+ " ",
+ "hosted",
+ " ",
+ "by",
+ " ",
+ "Wikimedia-",
+ " ",
+ "Foundation,",
+ " ",
+ "a",
+ " ",
+ "non-",
+ "profit",
+ " ",
+ "organization",
+ " ",
+ "that",
+ " ",
+ "also",
+ " ",
+ "hosts",
+ " ",
+ "a",
+ " ",
+ "range-",
+ "of",
+ " ",
+ "other",
+ " ",
+ "projects",
+ ]);
+ });
+
+ it("should not tokenize number", () => {
+ const text = "99,100.99";
+ const tokens = parseTokens(text);
+ expect(tokens).toEqual(["99,100.99"]);
+ });
+
+ it("should tokenize joined emojis", () => {
+ const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`;
+ const tokens = parseTokens(text);
+
+ expect(tokens).toEqual([
+ "😬",
+ "🌍",
+ "🗺",
+ "🔥",
+ "☂️",
+ "👩🏽‍🦰",
+ "👨‍👩‍👧‍👦",
+ "👩🏾‍🔬",
+ "🏳️‍🌈",
+ "🧔‍♀️",
+ "🧑‍🤝‍🧑",
+ "🙅🏽‍♂️",
+ "✅",
+ "0️⃣",
+ "🇨🇿",
+ "🦅",
+ ]);
+ });
+
+ it("should tokenize emojis mixed with mixed text", () => {
+ const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`;
+ const tokens = parseTokens(text);
+
+ expect(tokens).toEqual([
+ "😬",
+ "a",
+ "🌍",
+ "b",
+ "🗺",
+ "c",
+ "🔥",
+ "d",
+ "☂️",
+ "《",
+ "👩🏽‍🦰",
+ "》",
+ "👨‍👩‍👧‍👦",
+ "德",
+ "👩🏾‍🔬",
+ "こ",
+ "🏳️‍🌈",
+ "안",
+ "🧔‍♀️",
+ "g",
+ "🧑‍🤝‍🧑",
+ "h",
+ "🙅🏽‍♂️",
+ "e",
+ "✅",
+ "f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
+ "🇨🇿",
+ "10", // nice! do not break the number, as it's by default matched by \p{Emoji}
+ "🦅",
+ "#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
+ ]);
+ });
+
+ it("should tokenize decomposed chars into their composed variants", () => {
+ // each input character is in a decomposed form
+ const text = "čでäぴέ다й한";
+ expect(text.normalize("NFC").length).toEqual(8);
+ expect(text).toEqual(text.normalize("NFD"));
+
+ const tokens = parseTokens(text);
+ expect(tokens.length).toEqual(8);
+ expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
+ });
+
+ it("should tokenize artificial CJK", () => {
+ const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
+ // [
+ // '《道', '德', '經》', '醫-',
+ // '醫', 'こ', 'ん', 'に',
+ // 'ち', 'は', '世', '界!',
+ // '안', '녕', '하', '세',
+ // '요', '세', '계;', '요』,',
+ // '다.', '다...', '원/', '달',
+ // '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
+ // 'た…', '[Hello]', ' ', '\t',
+ // ' ', 'World?', 'ニ', 'ュ',
+ // 'ー', 'ヨ', 'ー', 'ク・',
+ // '¥3700.55', 'す。', '090-', '1234-',
+ // '5678', '¥1,000〜', '$5,000', '「素',
+ // '晴', 'ら', 'し', 'い!」',
+ // '〔重', '要〕', '#', '1:',
+ // 'Taro', '君', '30%', 'は、',
+ // '(た', 'な', 'ば', 'た)',
+ // '〰', '¥110±', '¥570', 'で',
+ // '20℃〜', '9:30〜', '10:00', '【一',
+ // '番】'
+ // ]
+ const tokens = parseTokens(text);
+
+ // Latin
+ expect(tokens).toContain("[[1]]");
+ expect(tokens).toContain("[Hello]");
+ expect(tokens).toContain("World?");
+ expect(tokens).toContain("Taro");
+
+ // Chinese
+ expect(tokens).toContain("《道");
+ expect(tokens).toContain("德");
+ expect(tokens).toContain("經》");
+ expect(tokens).toContain("醫-");
+ expect(tokens).toContain("醫");
+
+ // Japanese
+ expect(tokens).toContain("こ");
+ expect(tokens).toContain("ん");
+ expect(tokens).toContain("に");
+ expect(tokens).toContain("ち");
+ expect(tokens).toContain("は");
+ expect(tokens).toContain("世");
+ expect(tokens).toContain("ク・");
+ expect(tokens).toContain("界!");
+ expect(tokens).toContain("た…");
+ expect(tokens).toContain("す。");
+ expect(tokens).toContain("ュ");
+ expect(tokens).toContain("「素");
+ expect(tokens).toContain("晴");
+ expect(tokens).toContain("ら");
+ expect(tokens).toContain("し");
+ expect(tokens).toContain("い!」");
+ expect(tokens).toContain("君");
+ expect(tokens).toContain("は、");
+ expect(tokens).toContain("(た");
+ expect(tokens).toContain("な");
+ expect(tokens).toContain("ば");
+ expect(tokens).toContain("た)");
+ expect(tokens).toContain("で");
+ expect(tokens).toContain("【一");
+ expect(tokens).toContain("番】");
+
+ // Check for Korean
+ expect(tokens).toContain("안");
+ expect(tokens).toContain("녕");
+ expect(tokens).toContain("하");
+ expect(tokens).toContain("세");
+ expect(tokens).toContain("요");
+ expect(tokens).toContain("세");
+ expect(tokens).toContain("계;");
+ expect(tokens).toContain("요』,");
+ expect(tokens).toContain("다.");
+ expect(tokens).toContain("다...");
+ expect(tokens).toContain("원/");
+ expect(tokens).toContain("달");
+ expect(tokens).toContain("(((다)))");
+ expect(tokens).toContain("〚({((한))>)〛");
+ expect(tokens).toContain("(「た」)");
+
+ // Numbers and units
+ expect(tokens).toContain("¥3700.55");
+ expect(tokens).toContain("090-");
+ expect(tokens).toContain("1234-");
+ expect(tokens).toContain("5678");
+ expect(tokens).toContain("¥1,000〜");
+ expect(tokens).toContain("$5,000");
+ expect(tokens).toContain("1:");
+ expect(tokens).toContain("30%");
+ expect(tokens).toContain("¥110±");
+ expect(tokens).toContain("20℃〜");
+ expect(tokens).toContain("9:30〜");
+ expect(tokens).toContain("10:00");
+
+ // Punctuation and symbols
+ expect(tokens).toContain(" ");
+ expect(tokens).toContain("\t");
+ expect(tokens).toContain(" ");
+ expect(tokens).toContain("ニ");
+ expect(tokens).toContain("ー");
+ expect(tokens).toContain("ヨ");
+ expect(tokens).toContain("〰");
+ expect(tokens).toContain("#");
+ });
+ });
+});
diff --git a/packages/excalidraw/element/textWrapping.ts b/packages/excalidraw/element/textWrapping.ts
new file mode 100644
index 0000000..1913f6e
--- /dev/null
+++ b/packages/excalidraw/element/textWrapping.ts
@@ -0,0 +1,568 @@
+import { ENV } from "../constants";
+import { charWidth, getLineWidth } from "./textMeasurements";
+import type { FontString } from "./types";
+
+let cachedCjkRegex: RegExp | undefined;
+let cachedLineBreakRegex: RegExp | undefined;
+let cachedEmojiRegex: RegExp | undefined;
+
+/**
+ * Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
+ */
+export const containsCJK = (text: string) => {
+ if (!cachedCjkRegex) {
+ cachedCjkRegex = Regex.class(...Object.values(CJK));
+ }
+
+ return cachedCjkRegex.test(text);
+};
+
+const getLineBreakRegex = () => {
+ if (!cachedLineBreakRegex) {
+ try {
+ cachedLineBreakRegex = getLineBreakRegexAdvanced();
+ } catch {
+ cachedLineBreakRegex = getLineBreakRegexSimple();
+ }
+ }
+
+ return cachedLineBreakRegex;
+};
+
+const getEmojiRegex = () => {
+ if (!cachedEmojiRegex) {
+ cachedEmojiRegex = getEmojiRegexUnicode();
+ }
+
+ return cachedEmojiRegex;
+};
+
+/**
+ * Common symbols used across different languages.
+ */
+const COMMON = {
+ /**
+ * Natural breaking points for any grammars.
+ *
+ * Hello world
+ * ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
+ * Hello-world
+ * ↑ BREAK AFTER "-" → ["Hello-", "world"]
+ */
+ WHITESPACE: /\s/u,
+ HYPHEN: /-/u,
+ /**
+ * Generally do not break, unless closed symbol is followed by an opening symbol.
+ *
+ * Also, western punctation is often used in modern Korean and expects to be treated
+ * similarly to the CJK opening and closing symbols.
+ *
+ * Hello(한글)→ ["Hello", "(한", "글)"]
+ * ↑ BREAK BEFORE "("
+ * ↑ BREAK AFTER ")"
+ */
+ OPENING: /<\(\[\{/u,
+ CLOSING: />\)\]\}.,:;!\?…\//u,
+};
+
+/**
+ * Characters and symbols used in Chinese, Japanese and Korean.
+ */
+const CJK = {
+ /**
+ * Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
+ *
+ * Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
+ */
+ CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u,
+ /**
+ * Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
+ * and creates pairs with neighboring characters.
+ *
+ * Hello た。→ ["Hello", "た。"]
+ * ↑ DON'T BREAK "た。"
+ * * Hello「た」 World → ["Hello", "「た」", "World"]
+ * ↑ DON'T BREAK "「た"
+ * ↑ DON'T BREAK "た"
+ * ↑ BREAK BEFORE "「"
+ * ↑ BREAK AFTER "」"
+ */
+ // eslint-disable-next-line prettier/prettier
+ OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u,
+ CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u,
+ /**
+ * Currency symbols break before, not after
+ *
+ * Price¥100 → ["Price", "¥100"]
+ * ↑ BREAK BEFORE "¥"
+ */
+ CURRENCY: /¥₩£¢$/u,
+};
+
+const EMOJI = {
+ FLAG: /\p{RI}\p{RI}/u,
+ JOINER:
+ /(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
+ ZWJ: /\u200D/u,
+ ANY: /[\p{Emoji}]/u,
+ MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
+};
+
+/**
+ * Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
+ *
+ * Browser support as of 10/2024:
+ * - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
+ * - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
+ *
+ * Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
+ */
+const getLineBreakRegexSimple = () =>
+ Regex.or(
+ getEmojiRegex(),
+ Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
+ );
+
+/**
+ * Specifies the line breaking rules based for alphabetic-based languages,
+ * Chinese, Japanese, Korean and Emojis.
+ *
+ * "Hello-world" → ["Hello-", "world"]
+ * "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
+ */
+const getLineBreakRegexAdvanced = () =>
+ Regex.or(
+ // Unicode-defined regex for (multi-codepoint) Emojis
+ getEmojiRegex(),
+ // Rules for whitespace and hyphen
+ Break.Before(COMMON.WHITESPACE).Build(),
+ Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
+ // Rules for CJK (chars, symbols, currency)
+ Break.Before(CJK.CHAR, CJK.CURRENCY)
+ .NotPrecededBy(COMMON.OPENING, CJK.OPENING)
+ .Build(),
+ Break.After(CJK.CHAR)
+ .NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
+ .Build(),
+ // Rules for opening and closing punctuation
+ Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
+ Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
+ Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
+ );
+
+/**
+ * Matches various emoji types.
+ *
+ * 1. basic emojis (😀, 🌍)
+ * 2. flags (🇨🇿)
+ * 3. multi-codepoint emojis:
+ * - skin tones (👍🏽)
+ * - variation selectors (☂️)
+ * - keycaps (1️⃣)
+ * - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
+ * - emoji sequences (👨‍👩‍👧‍👦, 👩‍🚀, 🏳️‍🌈)
+ *
+ * Unicode points:
+ * - \uFE0F: presentation selector
+ * - \u20E3: enclosing keycap
+ * - \u200D: zero width joiner
+ * - \u{E0020}-\u{E007E}: tags
+ * - \u{E007F}: cancel tag
+ *
+ * @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
+ * - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
+ * - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
+ */
+const getEmojiRegexUnicode = () =>
+ Regex.group(
+ Regex.or(
+ EMOJI.FLAG,
+ Regex.and(
+ EMOJI.MOST,
+ EMOJI.JOINER,
+ Regex.build(
+ `(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
+ ),
+ ),
+ ),
+ );
+
+/**
+ * Regex utilities for unicode character classes.
+ */
+const Regex = {
+ /**
+ * Builds a regex from a string.
+ */
+ build: (regex: string): RegExp => new RegExp(regex, "u"),
+ /**
+ * Joins regexes into a single string.
+ */
+ join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
+ /**
+ * Joins regexes into a single regex as with "and" operator.
+ */
+ and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
+ /**
+ * Joins regexes into a single regex with "or" operator.
+ */
+ or: (...regexes: RegExp[]): RegExp =>
+ Regex.build(regexes.map((x) => x.source).join("|")),
+ /**
+ * Puts regexes into a matching group.
+ */
+ group: (...regexes: RegExp[]): RegExp =>
+ Regex.build(`(${Regex.join(...regexes)})`),
+ /**
+ * Puts regexes into a character class.
+ */
+ class: (...regexes: RegExp[]): RegExp =>
+ Regex.build(`[${Regex.join(...regexes)}]`),
+};
+
+/**
+ * Human-readable lookahead and lookbehind utilities for defining line break
+ * opportunities between pairs of character classes.
+ */
+const Break = {
+ /**
+ * Break on the given class of characters.
+ */
+ On: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ return Regex.build(`([${joined}])`);
+ },
+ /**
+ * Break before the given class of characters.
+ */
+ Before: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?=[${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "FollowedBy"
+ >;
+ },
+ /**
+ * Break after the given class of characters.
+ */
+ After: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?<=[${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "PreceededBy"
+ >;
+ },
+ /**
+ * Break before one or multiple characters of the same class.
+ */
+ BeforeMany: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "FollowedBy"
+ >;
+ },
+ /**
+ * Break after one or multiple character from the same class.
+ */
+ AfterMany: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "PreceededBy"
+ >;
+ },
+ /**
+ * Do not break before the given class of characters.
+ */
+ NotBefore: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?![${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "NotFollowedBy"
+ >;
+ },
+ /**
+ * Do not break after the given class of characters.
+ */
+ NotAfter: (...regexes: RegExp[]) => {
+ const joined = Regex.join(...regexes);
+ const builder = () => Regex.build(`(?<![${joined}])`);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "NotPrecededBy"
+ >;
+ },
+ Chain: (rootBuilder: () => RegExp) => ({
+ /**
+ * Build the root regex.
+ */
+ Build: rootBuilder,
+ /**
+ * Specify additional class of characters that should precede the root regex.
+ */
+ PreceededBy: (...regexes: RegExp[]) => {
+ const root = rootBuilder();
+ const preceeded = Break.After(...regexes).Build();
+ const builder = () => Regex.and(preceeded, root);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "PreceededBy"
+ >;
+ },
+ /**
+ * Specify additional class of characters that should follow the root regex.
+ */
+ FollowedBy: (...regexes: RegExp[]) => {
+ const root = rootBuilder();
+ const followed = Break.Before(...regexes).Build();
+ const builder = () => Regex.and(root, followed);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "FollowedBy"
+ >;
+ },
+ /**
+ * Specify additional class of characters that should not precede the root regex.
+ */
+ NotPrecededBy: (...regexes: RegExp[]) => {
+ const root = rootBuilder();
+ const notPreceeded = Break.NotAfter(...regexes).Build();
+ const builder = () => Regex.and(notPreceeded, root);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "NotPrecededBy"
+ >;
+ },
+ /**
+ * Specify additional class of characters that should not follow the root regex.
+ */
+ NotFollowedBy: (...regexes: RegExp[]) => {
+ const root = rootBuilder();
+ const notFollowed = Break.NotBefore(...regexes).Build();
+ const builder = () => Regex.and(root, notFollowed);
+ return Break.Chain(builder) as Omit<
+ ReturnType<typeof Break.Chain>,
+ "NotFollowedBy"
+ >;
+ },
+ }),
+};
+
+/**
+ * Breaks the line into the tokens based on the found line break opporutnities.
+ */
+export const parseTokens = (line: string) => {
+ const breakLineRegex = getLineBreakRegex();
+
+ // normalizing to single-codepoint composed chars due to canonical equivalence
+ // of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
+ // filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
+ return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
+};
+
+/**
+ * Wraps the original text into the lines based on the given width.
+ */
+export const wrapText = (
+ text: string,
+ font: FontString,
+ maxWidth: number,
+): string => {
+ // if maxWidth is not finite or NaN which can happen in case of bugs in
+ // computation, we need to make sure we don't continue as we'll end up
+ // in an infinite loop
+ if (!Number.isFinite(maxWidth) || maxWidth < 0) {
+ return text;
+ }
+
+ const lines: Array<string> = [];
+ const originalLines = text.split("\n");
+
+ for (const originalLine of originalLines) {
+ const currentLineWidth = getLineWidth(originalLine, font);
+
+ if (currentLineWidth <= maxWidth) {
+ lines.push(originalLine);
+ continue;
+ }
+
+ const wrappedLine = wrapLine(originalLine, font, maxWidth);
+ lines.push(...wrappedLine);
+ }
+
+ return lines.join("\n");
+};
+
+/**
+ * Wraps the original line into the lines based on the given width.
+ */
+const wrapLine = (
+ line: string,
+ font: FontString,
+ maxWidth: number,
+): string[] => {
+ const lines: Array<string> = [];
+ const tokens = parseTokens(line);
+ const tokenIterator = tokens[Symbol.iterator]();
+
+ let currentLine = "";
+ let currentLineWidth = 0;
+
+ let iterator = tokenIterator.next();
+
+ while (!iterator.done) {
+ const token = iterator.value;
+ const testLine = currentLine + token;
+
+ // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
+ const testLineWidth = isSingleCharacter(token)
+ ? currentLineWidth + charWidth.calculate(token, font)
+ : getLineWidth(testLine, font);
+
+ // build up the current line, skipping length check for possibly trailing whitespaces
+ if (/\s/.test(token) || testLineWidth <= maxWidth) {
+ currentLine = testLine;
+ currentLineWidth = testLineWidth;
+ iterator = tokenIterator.next();
+ continue;
+ }
+
+ // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
+ if (!currentLine) {
+ const wrappedWord = wrapWord(token, font, maxWidth);
+ const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
+ const precedingLines = wrappedWord.slice(0, -1);
+
+ lines.push(...precedingLines);
+
+ // trailing line of the wrapped word might still be joined with next token/s
+ currentLine = trailingLine;
+ currentLineWidth = getLineWidth(trailingLine, font);
+ iterator = tokenIterator.next();
+ } else {
+ // push & reset, but don't iterate on the next token, as we didn't use it yet!
+ lines.push(currentLine.trimEnd());
+
+ // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
+ currentLine = "";
+ currentLineWidth = 0;
+ }
+ }
+
+ // iterator done, push the trailing line if exists
+ if (currentLine) {
+ const trailingLine = trimLine(currentLine, font, maxWidth);
+ lines.push(trailingLine);
+ }
+
+ return lines;
+};
+
+/**
+ * Wraps the word into the lines based on the given width.
+ */
+const wrapWord = (
+ word: string,
+ font: FontString,
+ maxWidth: number,
+): Array<string> => {
+ // multi-codepoint emojis are already broken apart and shouldn't be broken further
+ if (getEmojiRegex().test(word)) {
+ return [word];
+ }
+
+ satisfiesWordInvariant(word);
+
+ const lines: Array<string> = [];
+ const chars = Array.from(word);
+
+ let currentLine = "";
+ let currentLineWidth = 0;
+
+ for (const char of chars) {
+ const _charWidth = charWidth.calculate(char, font);
+ const testLineWidth = currentLineWidth + _charWidth;
+
+ if (testLineWidth <= maxWidth) {
+ currentLine = currentLine + char;
+ currentLineWidth = testLineWidth;
+ continue;
+ }
+
+ if (currentLine) {
+ lines.push(currentLine);
+ }
+
+ currentLine = char;
+ currentLineWidth = _charWidth;
+ }
+
+ if (currentLine) {
+ lines.push(currentLine);
+ }
+
+ return lines;
+};
+
+/**
+ * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
+ */
+const trimLine = (line: string, font: FontString, maxWidth: number) => {
+ const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
+
+ if (!shouldTrimWhitespaces) {
+ return line;
+ }
+
+ // defensively default to `trimeEnd` in case the regex does not match
+ let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
+ line,
+ line.trimEnd(),
+ "",
+ ];
+
+ let trimmedLineWidth = getLineWidth(trimmedLine, font);
+
+ for (const whitespace of Array.from(whitespaces)) {
+ const _charWidth = charWidth.calculate(whitespace, font);
+ const testLineWidth = trimmedLineWidth + _charWidth;
+
+ if (testLineWidth > maxWidth) {
+ break;
+ }
+
+ trimmedLine = trimmedLine + whitespace;
+ trimmedLineWidth = testLineWidth;
+ }
+
+ return trimmedLine;
+};
+
+/**
+ * Check if the given string is a single character.
+ *
+ * Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨‍👩‍👧‍👦, 👩🏽‍🦰).
+ */
+const isSingleCharacter = (maybeSingleCharacter: string) => {
+ return (
+ maybeSingleCharacter.codePointAt(0) !== undefined &&
+ maybeSingleCharacter.codePointAt(1) === undefined
+ );
+};
+
+/**
+ * Invariant for the word wrapping algorithm.
+ */
+const satisfiesWordInvariant = (word: string) => {
+ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ if (/\s/.test(word)) {
+ throw new Error("Word should not contain any whitespaces!");
+ }
+ }
+};
diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx
new file mode 100644
index 0000000..0842e30
--- /dev/null
+++ b/packages/excalidraw/element/textWysiwyg.test.tsx
@@ -0,0 +1,1565 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import {
+ GlobalTestState,
+ render,
+ screen,
+ unmountComponent,
+} from "../tests/test-utils";
+import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
+import { CODES, KEYS } from "../keys";
+import {
+ fireEvent,
+ mockBoundingClientRect,
+ restoreOriginalGetBoundingClientRect,
+} from "../tests/test-utils";
+import { queryByText } from "@testing-library/react";
+
+import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
+import type {
+ ExcalidrawTextElement,
+ ExcalidrawTextElementWithContainer,
+} from "./types";
+import { API } from "../tests/helpers/api";
+import { getOriginalContainerHeightFromCache } from "./containerCache";
+import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
+import { pointFrom } from "@excalidraw/math";
+
+unmountComponent();
+
+const tab = " ";
+const mouse = new Pointer("mouse");
+
+const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+
+describe("textWysiwyg", () => {
+ describe("start text editing", () => {
+ const { h } = window;
+ beforeEach(async () => {
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ API.setElements([]);
+ });
+
+ it("should prefer editing selected text element (non-bindable container present)", async () => {
+ const line = API.createElement({
+ type: "line",
+ width: 100,
+ height: 0,
+ points: [pointFrom(0, 0), pointFrom(100, 0)],
+ });
+ const textSize = 20;
+ const text = API.createElement({
+ type: "text",
+ text: "ola",
+ x: line.width / 2 - textSize / 2,
+ y: -textSize / 2,
+ width: textSize,
+ height: textSize,
+ });
+ API.setElements([text, line]);
+
+ API.setSelectedElements([text]);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.state.editingTextElement?.id).toBe(text.id);
+ expect(
+ (h.state.editingTextElement as ExcalidrawTextElement).containerId,
+ ).toBe(null);
+ });
+
+ it("should prefer editing selected text element (bindable container present)", async () => {
+ const container = API.createElement({
+ type: "rectangle",
+ width: 100,
+ boundElements: [],
+ });
+ const textSize = 20;
+
+ const boundText = API.createElement({
+ type: "text",
+ text: "ola",
+ x: container.width / 2 - textSize / 2,
+ y: container.height / 2 - textSize / 2,
+ width: textSize,
+ height: textSize,
+ containerId: container.id,
+ });
+
+ const boundText2 = API.createElement({
+ type: "text",
+ text: "ola",
+ x: container.width / 2 - textSize / 2,
+ y: container.height / 2 - textSize / 2,
+ width: textSize,
+ height: textSize,
+ containerId: container.id,
+ });
+
+ API.setElements([container, boundText, boundText2]);
+
+ API.updateElement(container, {
+ boundElements: [{ type: "text", id: boundText.id }],
+ });
+
+ API.setSelectedElements([boundText2]);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.state.editingTextElement?.id).toBe(boundText2.id);
+ });
+
+ it("should not create bound text on ENTER if text exists at container center", () => {
+ const container = API.createElement({
+ type: "rectangle",
+ width: 100,
+ });
+ const textSize = 20;
+ const text = API.createElement({
+ type: "text",
+ text: "ola",
+ x: container.width / 2 - textSize / 2,
+ y: container.height / 2 - textSize / 2,
+ width: textSize,
+ height: textSize,
+ containerId: container.id,
+ });
+ API.updateElement(container, {
+ boundElements: [{ type: "text", id: text.id }],
+ });
+
+ API.setElements([container, text]);
+
+ API.setSelectedElements([container]);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.state.editingTextElement?.id).toBe(text.id);
+ });
+
+ it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
+ const container = API.createElement({
+ type: "rectangle",
+ width: 100,
+ boundElements: [],
+ });
+ const textSize = 20;
+
+ const boundText = API.createElement({
+ type: "text",
+ text: "ola",
+ x: container.width / 2 - textSize / 2,
+ y: container.height / 2 - textSize / 2,
+ width: textSize,
+ height: textSize,
+ containerId: container.id,
+ });
+
+ const boundText2 = API.createElement({
+ type: "text",
+ text: "ola",
+ x: container.width / 2 - textSize / 2,
+ y: container.height / 2 - textSize / 2,
+ width: textSize,
+ height: textSize,
+ containerId: container.id,
+ });
+
+ API.setElements([container, boundText, boundText2]);
+
+ API.updateElement(container, {
+ boundElements: [{ type: "text", id: boundText.id }],
+ });
+
+ API.setSelectedElements([container]);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.state.editingTextElement?.id).toBe(boundText.id);
+ });
+
+ it("should edit text under cursor when clicked with text tool", async () => {
+ const text = API.createElement({
+ type: "text",
+ text: "ola",
+ x: 60,
+ y: 0,
+ width: 100,
+ height: 100,
+ });
+
+ API.setElements([text]);
+ UI.clickTool("text");
+
+ mouse.clickAt(text.x + 50, text.y + 50);
+
+ const editor = await getTextEditor(textEditorSelector, false);
+
+ expect(editor).not.toBe(null);
+ expect(h.state.editingTextElement?.id).toBe(text.id);
+ expect(h.elements.length).toBe(1);
+ });
+
+ it("should edit text under cursor when double-clicked with selection tool", async () => {
+ const text = API.createElement({
+ type: "text",
+ text: "ola",
+ x: 60,
+ y: 0,
+ width: 100,
+ height: 100,
+ });
+
+ API.setElements([text]);
+ UI.clickTool("selection");
+
+ mouse.doubleClickAt(text.x + 50, text.y + 50);
+
+ const editor = await getTextEditor(textEditorSelector, false);
+
+ expect(editor).not.toBe(null);
+ expect(h.state.editingTextElement?.id).toBe(text.id);
+ expect(h.elements.length).toBe(1);
+ });
+
+ // FIXME too flaky. No one knows why.
+ it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
+ const arrow = UI.createElement("arrow", {
+ width: 300,
+ height: 0,
+ });
+ await UI.editText(arrow, "Hello");
+ const { version } = arrow;
+
+ await UI.editText(arrow, "Hello\nworld!");
+
+ expect(arrow.version).toEqual(version + 1);
+ });
+ });
+
+ describe("Test text wrapping", () => {
+ const { h } = window;
+ const dimensions = { height: 400, width: 800 };
+
+ beforeAll(() => {
+ mockBoundingClientRect(dimensions);
+ });
+
+ beforeEach(async () => {
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ // @ts-ignore
+ h.app.refreshViewportBreakpoints();
+ // @ts-ignore
+ h.app.refreshEditorBreakpoints();
+
+ API.setElements([]);
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should keep width when editing a wrapped text", async () => {
+ const text = API.createElement({
+ type: "text",
+ text: "Excalidraw\nEditor",
+ });
+
+ API.setElements([text]);
+
+ const prevWidth = text.width;
+ const prevHeight = text.height;
+ const prevText = text.text;
+
+ // text is wrapped
+ UI.resize(text, "e", [-20, 0]);
+ expect(text.width).not.toEqual(prevWidth);
+ expect(text.height).not.toEqual(prevHeight);
+ expect(text.text).not.toEqual(prevText);
+ expect(text.autoResize).toBe(false);
+
+ const wrappedWidth = text.width;
+ const wrappedHeight = text.height;
+ const wrappedText = text.text;
+
+ // edit text
+ UI.clickTool("selection");
+ mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+ const editor = await getTextEditor(textEditorSelector);
+ expect(editor).not.toBe(null);
+ expect(h.state.editingTextElement?.id).toBe(text.id);
+ expect(h.elements.length).toBe(1);
+
+ const nextText = `${wrappedText} is great!`;
+ updateTextEditor(editor, nextText);
+ Keyboard.exitTextEditor(editor);
+
+ expect(h.elements[0].width).toEqual(wrappedWidth);
+ expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
+
+ // remove all texts and then add it back editing
+ updateTextEditor(editor, "");
+ updateTextEditor(editor, nextText);
+ Keyboard.exitTextEditor(editor);
+
+ expect(h.elements[0].width).toEqual(wrappedWidth);
+ });
+
+ it("should restore original text after unwrapping a wrapped text", async () => {
+ const originalText = "Excalidraw\neditor\nis great!";
+ const text = API.createElement({
+ type: "text",
+ text: originalText,
+ });
+ API.setElements([text]);
+
+ // wrap
+ UI.resize(text, "e", [-40, 0]);
+ // enter text editing mode
+ UI.clickTool("selection");
+ mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+ const editor = await getTextEditor(textEditorSelector);
+ Keyboard.exitTextEditor(editor);
+ // restore after unwrapping
+ UI.resize(text, "e", [40, 0]);
+ expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
+
+ // wrap again and add a new line
+ UI.resize(text, "e", [-30, 0]);
+ const wrappedText = text.text;
+ UI.clickTool("selection");
+ mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+ updateTextEditor(editor, `${wrappedText}\nA new line!`);
+ Keyboard.exitTextEditor(editor);
+ // remove the newly added line
+ UI.clickTool("selection");
+ mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
+ updateTextEditor(editor, wrappedText);
+ Keyboard.exitTextEditor(editor);
+ // unwrap
+ UI.resize(text, "e", [30, 0]);
+ // expect the text to be restored the same
+ expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
+ });
+ });
+
+ describe("Test container-unbound text", () => {
+ const { h } = window;
+ const dimensions = { height: 400, width: 800 };
+
+ let textarea: HTMLTextAreaElement;
+ let textElement: ExcalidrawTextElement;
+
+ beforeAll(() => {
+ mockBoundingClientRect(dimensions);
+ });
+
+ beforeEach(async () => {
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ // @ts-ignore
+ h.app.refreshViewportBreakpoints();
+ // @ts-ignore
+ h.app.refreshEditorBreakpoints();
+
+ textElement = UI.createElement("text");
+
+ mouse.clickOn(textElement);
+ textarea = await getTextEditor(textEditorSelector, true);
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should add a tab at the start of the first line", () => {
+ textarea.value = "Line#1\nLine#2";
+ // cursor: "|Line#1\nLine#2"
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+ fireEvent.keyDown(textarea, { key: KEYS.TAB });
+
+ expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
+ // cursor: " |Line#1\nLine#2"
+ expect(textarea.selectionStart).toEqual(4);
+ expect(textarea.selectionEnd).toEqual(4);
+ });
+
+ it("should add a tab at the start of the second line", () => {
+ textarea.value = "Line#1\nLine#2";
+ // cursor: "Line#1\nLin|e#2"
+ textarea.selectionStart = 10;
+ textarea.selectionEnd = 10;
+
+ fireEvent.keyDown(textarea, { key: KEYS.TAB });
+
+ expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
+
+ // cursor: "Line#1\n Lin|e#2"
+ expect(textarea.selectionStart).toEqual(14);
+ expect(textarea.selectionEnd).toEqual(14);
+ });
+
+ it("should add a tab at the start of the first and second line", () => {
+ textarea.value = "Line#1\nLine#2\nLine#3";
+ // cursor: "Li|ne#1\nLi|ne#2\nLine#3"
+ textarea.selectionStart = 2;
+ textarea.selectionEnd = 9;
+
+ fireEvent.keyDown(textarea, { key: KEYS.TAB });
+
+ expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
+
+ // cursor: " Li|ne#1\n Li|ne#2\nLine#3"
+ expect(textarea.selectionStart).toEqual(6);
+ expect(textarea.selectionEnd).toEqual(17);
+ });
+
+ it("should remove a tab at the start of the first line", () => {
+ textarea.value = `${tab}Line#1\nLine#2`;
+ // cursor: "| Line#1\nLine#2"
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = 0;
+
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ expect(textarea.value).toEqual(`Line#1\nLine#2`);
+
+ // cursor: "|Line#1\nLine#2"
+ expect(textarea.selectionStart).toEqual(0);
+ expect(textarea.selectionEnd).toEqual(0);
+ });
+
+ it("should remove a tab at the start of the second line", () => {
+ // cursor: "Line#1\n Lin|e#2"
+ textarea.value = `Line#1\n${tab}Line#2`;
+ textarea.selectionStart = 15;
+ textarea.selectionEnd = 15;
+
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ expect(textarea.value).toEqual(`Line#1\nLine#2`);
+ // cursor: "Line#1\nLin|e#2"
+ expect(textarea.selectionStart).toEqual(11);
+ expect(textarea.selectionEnd).toEqual(11);
+ });
+
+ it("should remove a tab at the start of the first and second line", () => {
+ // cursor: " Li|ne#1\n Li|ne#2\nLine#3"
+ textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
+ textarea.selectionStart = 6;
+ textarea.selectionEnd = 17;
+
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
+ // cursor: "Li|ne#1\nLi|ne#2\nLine#3"
+ expect(textarea.selectionStart).toEqual(2);
+ expect(textarea.selectionEnd).toEqual(9);
+ });
+
+ it("should remove a tab at the start of the second line and cursor stay on this line", () => {
+ // cursor: "Line#1\n | Line#2"
+ textarea.value = `Line#1\n${tab}Line#2`;
+ textarea.selectionStart = 9;
+ textarea.selectionEnd = 9;
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ // cursor: "Line#1\n|Line#2"
+ expect(textarea.selectionStart).toEqual(7);
+ });
+
+ it("should remove partial tabs", () => {
+ // cursor: "Line#1\n Line#|2"
+ textarea.value = `Line#1\n Line#2`;
+ textarea.selectionStart = 15;
+ textarea.selectionEnd = 15;
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ expect(textarea.value).toEqual(`Line#1\nLine#2`);
+ });
+
+ it("should remove nothing", () => {
+ // cursor: "Line#1\n Li|ne#2"
+ textarea.value = `Line#1\nLine#2`;
+ textarea.selectionStart = 9;
+ textarea.selectionEnd = 9;
+ fireEvent.keyDown(textarea, {
+ key: KEYS.TAB,
+ shiftKey: true,
+ });
+
+ expect(textarea.value).toEqual(`Line#1\nLine#2`);
+ });
+
+ it("should resize text via shortcuts while in wysiwyg", () => {
+ textarea.value = "abc def";
+ const origFontSize = textElement.fontSize;
+ fireEvent.keyDown(textarea, {
+ key: KEYS.CHEVRON_RIGHT,
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ expect(textElement.fontSize).toBe(origFontSize * 1.1);
+
+ fireEvent.keyDown(textarea, {
+ key: KEYS.CHEVRON_LEFT,
+ ctrlKey: true,
+ shiftKey: true,
+ });
+ expect(textElement.fontSize).toBe(origFontSize);
+ });
+
+ it("zooming via keyboard should zoom canvas", () => {
+ expect(h.state.zoom.value).toBe(1);
+ fireEvent.keyDown(textarea, {
+ code: CODES.MINUS,
+ ctrlKey: true,
+ });
+ expect(h.state.zoom.value).toBe(0.9);
+ fireEvent.keyDown(textarea, {
+ code: CODES.NUM_SUBTRACT,
+ ctrlKey: true,
+ });
+ expect(h.state.zoom.value).toBe(0.8);
+ fireEvent.keyDown(textarea, {
+ code: CODES.NUM_ADD,
+ ctrlKey: true,
+ });
+ expect(h.state.zoom.value).toBe(0.9);
+ fireEvent.keyDown(textarea, {
+ code: CODES.EQUAL,
+ ctrlKey: true,
+ });
+ expect(h.state.zoom.value).toBe(1);
+ });
+
+ it("text should never go beyond max width", async () => {
+ UI.clickTool("text");
+ mouse.click(0, 0);
+
+ textarea = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(
+ textarea,
+ "Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
+ );
+ Keyboard.exitTextEditor(textarea);
+
+ expect(textarea.style.width).toBe("792px");
+ expect(h.elements[0].width).toBe(1000);
+ });
+ });
+
+ describe("Test container-bound text", () => {
+ let rectangle: any;
+ const { h } = window;
+
+ beforeEach(async () => {
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+ API.setElements([]);
+
+ rectangle = UI.createElement("rectangle", {
+ x: 10,
+ y: 20,
+ width: 90,
+ height: 75,
+ });
+ });
+
+ it("should bind text to container when double clicked inside filled container", async () => {
+ const rectangle = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 20,
+ width: 90,
+ height: 75,
+ backgroundColor: "red",
+ });
+ API.setElements([rectangle]);
+
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
+ expect(h.elements.length).toBe(2);
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ mouse.down();
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("should set the text element angle to same as container angle when binding to rotated container", async () => {
+ const rectangle = API.createElement({
+ type: "rectangle",
+ width: 90,
+ height: 75,
+ angle: 45,
+ });
+ API.setElements([rectangle]);
+ mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ expect(text.angle).toBe(rectangle.angle);
+ mouse.down();
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("should compute the container height correctly and not throw error when height is updated while editing the text", async () => {
+ const diamond = API.createElement({
+ type: "diamond",
+ x: 10,
+ y: 20,
+ width: 90,
+ height: 75,
+ });
+ API.setElements([diamond]);
+
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(diamond.id);
+
+ API.setSelectedElements([diamond]);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ const value = new Array(1000).fill("1").join("\n");
+
+ // Pasting large text to simulate height increase
+ expect(() =>
+ fireEvent.input(editor, { target: { value } }),
+ ).not.toThrow();
+
+ expect(diamond.height).toBe(50020);
+
+ // Clearing text to simulate height decrease
+ expect(() => updateTextEditor(editor, "")).not.toThrow();
+
+ expect(diamond.height).toBe(70);
+ });
+
+ it("should bind text to container when double clicked on center of transparent container", async () => {
+ const rectangle = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 20,
+ width: 90,
+ height: 75,
+ backgroundColor: "transparent",
+ });
+ API.setElements([rectangle]);
+
+ mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
+ expect(h.elements.length).toBe(2);
+ let text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(null);
+ mouse.down();
+ let editor = await getTextEditor(textEditorSelector, true);
+ Keyboard.exitTextEditor(editor);
+
+ mouse.doubleClickAt(
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ );
+ expect(h.elements.length).toBe(3);
+
+ text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+
+ mouse.down();
+ editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("should bind text to container when clicked on container and enter pressed", async () => {
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.elements.length).toBe(2);
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("should bind text to container when double clicked on container stroke", async () => {
+ const rectangle = API.createElement({
+ type: "rectangle",
+ x: 10,
+ y: 20,
+ width: 90,
+ height: 75,
+ strokeWidth: 4,
+ });
+ API.setElements([rectangle]);
+
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ mouse.doubleClickAt(rectangle.x + 2, rectangle.y + 2);
+ expect(h.elements.length).toBe(2);
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ mouse.down();
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("shouldn't bind to non-text-bindable containers", async () => {
+ const freedraw = API.createElement({
+ type: "freedraw",
+ width: 100,
+ height: 0,
+ });
+ API.setElements([freedraw]);
+
+ UI.clickTool("text");
+
+ mouse.clickAt(
+ freedraw.x + freedraw.width / 2,
+ freedraw.y + freedraw.height / 2,
+ );
+
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+
+ expect(freedraw.boundElements).toBe(null);
+ expect(h.elements[1].type).toBe("text");
+ expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
+ });
+
+ ["freedraw", "line"].forEach((type: any) => {
+ it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
+ API.setElements([]);
+ const element = UI.createElement(type, {
+ width: 100,
+ height: 50,
+ });
+ API.setSelectedElements([element]);
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(h.elements.length).toBe(1);
+ });
+ });
+
+ it("should'nt bind text to container when not double clicked on center", async () => {
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ // clicking somewhere on top left
+ mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
+ expect(h.elements.length).toBe(2);
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(null);
+ mouse.down();
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toBe(null);
+ });
+
+ it("should bind text to container when triggered via context menu", async () => {
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ UI.clickTool("text");
+ mouse.clickAt(20, 30);
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(
+ editor,
+ "Excalidraw is an opensource virtual collaborative whiteboard",
+ );
+ expect(h.elements.length).toBe(2);
+ expect(h.elements[1].type).toBe("text");
+
+ API.setSelectedElements([h.elements[0], h.elements[1]]);
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 20,
+ clientY: 30,
+ });
+ const contextMenu = document.querySelector(".context-menu");
+ fireEvent.click(
+ queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
+ );
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: h.elements[1].id, type: "text" },
+ ]);
+ expect(text.containerId).toBe(rectangle.id);
+ expect(text.verticalAlign).toBe(VERTICAL_ALIGN.MIDDLE);
+ expect(text.textAlign).toBe(TEXT_ALIGN.CENTER);
+ expect(text.x).toBe(
+ h.elements[0].x + h.elements[0].width / 2 - text.width / 2,
+ );
+ expect(text.y).toBe(
+ h.elements[0].y + h.elements[0].height / 2 - text.height / 2,
+ );
+ });
+
+ it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
+ expect(h.elements.length).toBe(1);
+
+ mouse.doubleClickAt(
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ );
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+
+ expect(await getTextEditor(textEditorSelector, false)).toBe(null);
+
+ expect(h.state.editingTextElement).toBe(null);
+
+ expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
+
+ fireEvent.click(screen.getByTitle(/code/i));
+
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
+
+ //undo
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.Z);
+ });
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY.Excalifont);
+
+ //redo
+ Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+ Keyboard.keyPress(KEYS.Z);
+ });
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
+ });
+
+ it("should wrap text and vertcially center align once text submitted", async () => {
+ expect(h.elements.length).toBe(1);
+
+ Keyboard.keyDown(KEYS.ENTER);
+ let text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ let editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.text).toBe("Hello\nWorld!");
+ expect(text.originalText).toBe("Hello World!");
+ expect(text.y).toBe(
+ rectangle.y + h.elements[0].height / 2 - text.height / 2,
+ );
+ expect(text.x).toBe(25);
+ expect(text.height).toBe(50);
+ expect(text.width).toBe(60);
+
+ // Edit and text by removing second line and it should
+ // still vertically align correctly
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+
+ Keyboard.exitTextEditor(editor);
+ text = h.elements[1] as ExcalidrawTextElementWithContainer;
+
+ expect(text.text).toBe("Hello");
+ expect(text.originalText).toBe("Hello");
+ expect(text.height).toBe(25);
+ expect(text.width).toBe(50);
+ expect(text.y).toBe(
+ rectangle.y + h.elements[0].height / 2 - text.height / 2,
+ );
+ expect(text.x).toBe(30);
+ });
+
+ it("should unbind bound text when unbind action from context menu is triggered", async () => {
+ expect(h.elements.length).toBe(1);
+ expect(h.elements[0].id).toBe(rectangle.id);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.elements.length).toBe(2);
+
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.containerId).toBe(rectangle.id);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ mouse.reset();
+ UI.clickTool("selection");
+ mouse.clickAt(10, 20);
+ mouse.down();
+ mouse.up();
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 20,
+ clientY: 30,
+ });
+ const contextMenu = document.querySelector(".context-menu");
+ fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
+ expect(h.elements[0].boundElements).toEqual([]);
+ expect((h.elements[1] as ExcalidrawTextElement).containerId).toEqual(
+ null,
+ );
+ });
+
+ it("shouldn't bind to container if container has bound text", async () => {
+ expect(h.elements.length).toBe(1);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.elements.length).toBe(2);
+
+ // Bind first text
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.containerId).toBe(rectangle.id);
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(h.elements.length).toBe(2);
+
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: h.elements[1].id, type: "text" },
+ ]);
+ expect(text.containerId).toBe(rectangle.id);
+ });
+
+ it("should respect text alignment when resizing", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+
+ let editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+
+ // should center align horizontally and vertically by default
+ UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 85,
+ "5.00000",
+ ]
+ `);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ editor = await getTextEditor(textEditorSelector, true);
+
+ editor.select();
+
+ fireEvent.click(screen.getByTitle("Left"));
+ fireEvent.click(screen.getByTitle("Align bottom"));
+ Keyboard.exitTextEditor(editor);
+
+ // should left align horizontally and bottom vertically after resize
+ UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 15,
+ 65,
+ ]
+ `);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+ editor = await getTextEditor(textEditorSelector, true);
+
+ editor.select();
+
+ fireEvent.click(screen.getByTitle("Right"));
+ fireEvent.click(screen.getByTitle("Align top"));
+
+ Keyboard.exitTextEditor(editor);
+
+ // should right align horizontally and top vertically after resize
+ UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ "375.00000",
+ "-535.00000",
+ ]
+ `);
+ });
+
+ it("should always bind to selected container and insert it in correct position", async () => {
+ const rectangle2 = UI.createElement("rectangle", {
+ x: 5,
+ y: 10,
+ width: 120,
+ height: 100,
+ });
+
+ API.setSelectedElements([rectangle]);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.elements.length).toBe(3);
+ expect(h.elements[1].type).toBe("text");
+ const text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.type).toBe("text");
+ expect(text.containerId).toBe(rectangle.id);
+ mouse.down();
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, "Hello World!");
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle2.boundElements).toBeNull();
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ });
+
+ it("should scale font size correctly when resizing using shift", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+ const textElement = h.elements[1] as ExcalidrawTextElement;
+ expect(rectangle.width).toBe(90);
+ expect(rectangle.height).toBe(75);
+ expect(textElement.fontSize).toBe(20);
+
+ UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
+ shift: true,
+ });
+ expect(rectangle.width).toBe(200);
+ expect(rectangle.height).toBe(166.66666666666669);
+ expect(textElement.fontSize).toBe(47.5);
+ });
+
+ it("should bind text correctly when container duplicated with alt-drag", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+ expect(h.elements.length).toBe(2);
+
+ mouse.select(rectangle);
+ Keyboard.withModifierKeys({ alt: true }, () => {
+ mouse.down(rectangle.x + 10, rectangle.y + 10);
+ mouse.up(rectangle.x + 10, rectangle.y + 10);
+ });
+ expect(h.elements.length).toBe(4);
+ const duplicatedRectangle = h.elements[0];
+ const duplicatedText = h
+ .elements[1] as ExcalidrawTextElementWithContainer;
+ const originalRect = h.elements[2];
+ const originalText = h.elements[3] as ExcalidrawTextElementWithContainer;
+ expect(originalRect.boundElements).toStrictEqual([
+ { id: originalText.id, type: "text" },
+ ]);
+
+ expect(originalText.containerId).toBe(originalRect.id);
+
+ expect(duplicatedRectangle.boundElements).toStrictEqual([
+ { id: duplicatedText.id, type: "text" },
+ ]);
+
+ expect(duplicatedText.containerId).toBe(duplicatedRectangle.id);
+ });
+
+ it("undo should work", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: h.elements[1].id, type: "text" },
+ ]);
+ let text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ const originalRectX = rectangle.x;
+ const originalRectY = rectangle.y;
+ const originalTextX = text.x;
+ const originalTextY = text.y;
+ mouse.select(rectangle);
+ mouse.downAt(rectangle.x, rectangle.y);
+ mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
+ mouse.up(rectangle.x + 100, rectangle.y + 50);
+ expect(rectangle.x).toBe(80);
+ expect(rectangle.y).toBe(-40);
+ expect(text.x).toBe(85);
+ expect(text.y).toBe(-35);
+
+ Keyboard.withModifierKeys({ ctrl: true }, () => {
+ Keyboard.keyPress(KEYS.Z);
+ });
+ expect(rectangle.x).toBe(originalRectX);
+ expect(rectangle.y).toBe(originalRectY);
+ text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.x).toBe(originalTextX);
+ expect(text.y).toBe(originalTextY);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ expect(text.containerId).toBe(rectangle.id);
+ });
+
+ it("should not allow bound text with only whitespaces", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(editor, " ");
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.boundElements).toStrictEqual([]);
+ expect(h.elements[1].isDeleted).toBe(true);
+ });
+
+ it("should restore original container height and clear cache once text is unbind", async () => {
+ const container = API.createElement({
+ type: "rectangle",
+ height: 75,
+ width: 90,
+ });
+ const originalRectHeight = container.height;
+ expect(container.height).toBe(originalRectHeight);
+
+ const text = API.createElement({
+ type: "text",
+ text: "Online whiteboard collaboration made easy",
+ });
+
+ API.setElements([container, text]);
+ API.setSelectedElements([container, text]);
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 20,
+ clientY: 30,
+ });
+ let contextMenu = document.querySelector(".context-menu");
+
+ fireEvent.click(
+ queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
+ );
+
+ expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
+ "Online\nwhiteboa\nrd\ncollabor\nation\nmade\neasy",
+ );
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 20,
+ clientY: 30,
+ });
+ contextMenu = document.querySelector(".context-menu");
+ fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
+ expect(h.elements[0].boundElements).toEqual([]);
+ expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
+
+ expect(container.height).toBe(originalRectHeight);
+ });
+
+ it("should reset the container height cache when resizing", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
+ let editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+
+ UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
+ expect(rectangle.height).toBeCloseTo(155, 8);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ editor = await getTextEditor(textEditorSelector, true);
+
+ Keyboard.exitTextEditor(editor);
+ expect(rectangle.height).toBeCloseTo(155, 8);
+ // cache updated again
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBeCloseTo(
+ 155,
+ 8,
+ );
+ });
+
+ it("should reset the container height cache when font properties updated", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ fireEvent.click(screen.getByTitle(/code/i));
+
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
+
+ fireEvent.click(screen.getByTitle(/Very large/i));
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
+ ).toEqual(36);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
+ });
+
+ it("should update line height when font family updated", async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
+
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello World!");
+ Keyboard.exitTextEditor(editor);
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
+ ).toEqual(1.25);
+
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+
+ fireEvent.click(screen.getByTitle(/code/i));
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY["Comic Shanns"]);
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
+ ).toEqual(1.25);
+
+ fireEvent.click(screen.getByTitle(/normal/i));
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
+ ).toEqual(FONT_FAMILY.Nunito);
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
+ ).toEqual(1.35);
+ });
+
+ describe("should align correctly", () => {
+ let editor: HTMLTextAreaElement;
+
+ beforeEach(async () => {
+ Keyboard.keyPress(KEYS.ENTER);
+ editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello");
+ Keyboard.exitTextEditor(editor);
+ mouse.select(rectangle);
+ Keyboard.keyPress(KEYS.ENTER);
+ editor = await getTextEditor(textEditorSelector, true);
+ editor.select();
+ });
+
+ it("when top left", async () => {
+ fireEvent.click(screen.getByTitle("Left"));
+ fireEvent.click(screen.getByTitle("Align top"));
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 15,
+ 25,
+ ]
+ `);
+ });
+
+ it("when top center", async () => {
+ fireEvent.click(screen.getByTitle("Center"));
+ fireEvent.click(screen.getByTitle("Align top"));
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 30,
+ 25,
+ ]
+ `);
+ });
+
+ it("when top right", async () => {
+ fireEvent.click(screen.getByTitle("Right"));
+ fireEvent.click(screen.getByTitle("Align top"));
+
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 45,
+ 25,
+ ]
+ `);
+ });
+
+ it("when center left", async () => {
+ fireEvent.click(screen.getByTitle("Center vertically"));
+ fireEvent.click(screen.getByTitle("Left"));
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 15,
+ 45,
+ ]
+ `);
+ });
+
+ it("when center center", async () => {
+ fireEvent.click(screen.getByTitle("Center"));
+ fireEvent.click(screen.getByTitle("Center vertically"));
+
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 30,
+ 45,
+ ]
+ `);
+ });
+
+ it("when center right", async () => {
+ fireEvent.click(screen.getByTitle("Right"));
+ fireEvent.click(screen.getByTitle("Center vertically"));
+
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 45,
+ 45,
+ ]
+ `);
+ });
+
+ it("when bottom left", async () => {
+ fireEvent.click(screen.getByTitle("Left"));
+ fireEvent.click(screen.getByTitle("Align bottom"));
+
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 15,
+ 65,
+ ]
+ `);
+ });
+
+ it("when bottom center", async () => {
+ fireEvent.click(screen.getByTitle("Center"));
+ fireEvent.click(screen.getByTitle("Align bottom"));
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 30,
+ 65,
+ ]
+ `);
+ });
+
+ it("when bottom right", async () => {
+ fireEvent.click(screen.getByTitle("Right"));
+ fireEvent.click(screen.getByTitle("Align bottom"));
+ expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
+ [
+ 45,
+ 65,
+ ]
+ `);
+ });
+ });
+
+ it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
+ UI.clickTool("text");
+ mouse.clickAt(20, 30);
+ const editor = await getTextEditor(textEditorSelector, true);
+
+ updateTextEditor(
+ editor,
+ "Excalidraw is an opensource virtual collaborative whiteboard",
+ );
+
+ editor.select();
+ fireEvent.click(screen.getByTitle("Left"));
+
+ Keyboard.exitTextEditor(editor);
+
+ const textElement = h.elements[1] as ExcalidrawTextElement;
+ expect(textElement.width).toBe(600);
+ expect(textElement.height).toBe(25);
+ expect(textElement.textAlign).toBe(TEXT_ALIGN.LEFT);
+ expect((textElement as ExcalidrawTextElement).text).toBe(
+ "Excalidraw is an opensource virtual collaborative whiteboard",
+ );
+
+ API.setSelectedElements([textElement]);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 20,
+ clientY: 30,
+ });
+
+ const contextMenu = document.querySelector(".context-menu");
+ fireEvent.click(
+ queryByText(contextMenu as HTMLElement, "Wrap text in a container")!,
+ );
+ expect(h.elements.length).toBe(3);
+
+ expect(h.elements[1]).toEqual(
+ expect.objectContaining({
+ angle: 0,
+ backgroundColor: "transparent",
+ boundElements: [
+ {
+ id: h.elements[2].id,
+ type: "text",
+ },
+ ],
+ fillStyle: "solid",
+ groupIds: [],
+ height: 35,
+ isDeleted: false,
+ link: null,
+ locked: false,
+ opacity: 100,
+ roughness: 1,
+ roundness: {
+ type: 3,
+ },
+ strokeColor: "#1e1e1e",
+ strokeStyle: "solid",
+ strokeWidth: 2,
+ type: "rectangle",
+ updated: 1,
+ version: 2,
+ width: 610,
+ x: 15,
+ y: 25,
+ }),
+ );
+ expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+ expect.objectContaining({
+ text: "Excalidraw is an opensource virtual collaborative whiteboard",
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ textAlign: TEXT_ALIGN.CENTER,
+ boundElements: null,
+ }),
+ );
+ });
+
+ it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
+ expect(h.elements.length).toBe(1);
+
+ Keyboard.keyPress(KEYS.ENTER);
+
+ expect(h.elements.length).toBe(2);
+
+ // Bind first text
+ let text = h.elements[1] as ExcalidrawTextElementWithContainer;
+ expect(text.containerId).toBe(rectangle.id);
+ let editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello!");
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
+ ).toBe(VERTICAL_ALIGN.MIDDLE);
+
+ fireEvent.click(screen.getByTitle("Align bottom"));
+
+ Keyboard.exitTextEditor(editor);
+
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: text.id, type: "text" },
+ ]);
+ expect(
+ (h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
+ ).toBe(VERTICAL_ALIGN.BOTTOM);
+
+ // Attempt to Bind 2nd text using text tool
+ UI.clickTool("text");
+ mouse.clickAt(
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ );
+ editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Excalidraw");
+ Keyboard.exitTextEditor(editor);
+
+ expect(h.elements.length).toBe(3);
+ expect(rectangle.boundElements).toStrictEqual([
+ { id: h.elements[1].id, type: "text" },
+ ]);
+ text = h.elements[2] as ExcalidrawTextElementWithContainer;
+ expect(text.containerId).toBe(null);
+ expect(text.text).toBe("Excalidraw");
+ });
+ });
+});
diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx
new file mode 100644
index 0000000..a757086
--- /dev/null
+++ b/packages/excalidraw/element/textWysiwyg.tsx
@@ -0,0 +1,730 @@
+import { CODES, KEYS } from "../keys";
+import {
+ isWritableElement,
+ getFontString,
+ getFontFamilyString,
+ isTestEnv,
+} from "../utils";
+import Scene from "../scene/Scene";
+import {
+ isArrowElement,
+ isBoundToContainer,
+ isTextElement,
+} from "./typeChecks";
+import { CLASSES, POINTER_BUTTON } from "../constants";
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawTextElement,
+} from "./types";
+import type { AppState } from "../types";
+import { bumpVersion, mutateElement } from "./mutateElement";
+import {
+ getBoundTextElementId,
+ getContainerElement,
+ getTextElementAngle,
+ redrawTextBoundingBox,
+ getBoundTextMaxHeight,
+ getBoundTextMaxWidth,
+ computeContainerDimensionForBoundText,
+ computeBoundTextPosition,
+ getBoundTextElement,
+} from "./textElement";
+import { wrapText } from "./textWrapping";
+import {
+ actionDecreaseFontSize,
+ actionIncreaseFontSize,
+} from "../actions/actionProperties";
+import {
+ actionResetZoom,
+ actionZoomIn,
+ actionZoomOut,
+} from "../actions/actionCanvas";
+import type App from "../components/App";
+import { LinearElementEditor } from "./linearElementEditor";
+import { parseClipboard } from "../clipboard";
+import {
+ originalContainerCache,
+ updateOriginalContainerCache,
+} from "./containerCache";
+import { getTextWidth } from "./textMeasurements";
+import { normalizeText } from "./textMeasurements";
+
+const getTransform = (
+ width: number,
+ height: number,
+ angle: number,
+ appState: AppState,
+ maxWidth: number,
+ maxHeight: number,
+) => {
+ const { zoom } = appState;
+ const degree = (180 * angle) / Math.PI;
+ let translateX = (width * (zoom.value - 1)) / 2;
+ let translateY = (height * (zoom.value - 1)) / 2;
+ if (width > maxWidth && zoom.value !== 1) {
+ translateX = (maxWidth * (zoom.value - 1)) / 2;
+ }
+ if (height > maxHeight && zoom.value !== 1) {
+ translateY = (maxHeight * (zoom.value - 1)) / 2;
+ }
+ return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
+};
+
+export const textWysiwyg = ({
+ id,
+ onChange,
+ onSubmit,
+ getViewportCoords,
+ element,
+ canvas,
+ excalidrawContainer,
+ app,
+ autoSelect = true,
+}: {
+ id: ExcalidrawElement["id"];
+ /**
+ * textWysiwyg only deals with `originalText`
+ *
+ * Note: `text`, which can be wrapped and therefore different from `originalText`,
+ * is derived from `originalText`
+ */
+ onChange?: (nextOriginalText: string) => void;
+ onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
+ getViewportCoords: (x: number, y: number) => [number, number];
+ element: ExcalidrawTextElement;
+ canvas: HTMLCanvasElement;
+ excalidrawContainer: HTMLDivElement | null;
+ app: App;
+ autoSelect?: boolean;
+}) => {
+ const textPropertiesUpdated = (
+ updatedTextElement: ExcalidrawTextElement,
+ editable: HTMLTextAreaElement,
+ ) => {
+ if (!editable.style.fontFamily || !editable.style.fontSize) {
+ return false;
+ }
+ const currentFont = editable.style.fontFamily.replace(/"/g, "");
+ if (
+ getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
+ currentFont
+ ) {
+ return true;
+ }
+ if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
+ return true;
+ }
+ return false;
+ };
+
+ const updateWysiwygStyle = () => {
+ const appState = app.state;
+ const updatedTextElement =
+ Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
+
+ if (!updatedTextElement) {
+ return;
+ }
+ const { textAlign, verticalAlign } = updatedTextElement;
+ const elementsMap = app.scene.getNonDeletedElementsMap();
+ if (updatedTextElement && isTextElement(updatedTextElement)) {
+ let coordX = updatedTextElement.x;
+ let coordY = updatedTextElement.y;
+ const container = getContainerElement(
+ updatedTextElement,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ let width = updatedTextElement.width;
+
+ // set to element height by default since that's
+ // what is going to be used for unbounded text
+ let height = updatedTextElement.height;
+
+ let maxWidth = updatedTextElement.width;
+ let maxHeight = updatedTextElement.height;
+
+ if (container && updatedTextElement.containerId) {
+ if (isArrowElement(container)) {
+ const boundTextCoords =
+ LinearElementEditor.getBoundTextElementPosition(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ coordX = boundTextCoords.x;
+ coordY = boundTextCoords.y;
+ }
+ const propertiesUpdated = textPropertiesUpdated(
+ updatedTextElement,
+ editable,
+ );
+
+ let originalContainerData;
+ if (propertiesUpdated) {
+ originalContainerData = updateOriginalContainerCache(
+ container.id,
+ container.height,
+ );
+ } else {
+ originalContainerData = originalContainerCache[container.id];
+ if (!originalContainerData) {
+ originalContainerData = updateOriginalContainerCache(
+ container.id,
+ container.height,
+ );
+ }
+ }
+
+ maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
+
+ maxHeight = getBoundTextMaxHeight(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ );
+
+ // autogrow container height if text exceeds
+ if (!isArrowElement(container) && height > maxHeight) {
+ const targetContainerHeight = computeContainerDimensionForBoundText(
+ height,
+ container.type,
+ );
+
+ mutateElement(container, { height: targetContainerHeight });
+ return;
+ } else if (
+ // autoshrink container height until original container height
+ // is reached when text is removed
+ !isArrowElement(container) &&
+ container.height > originalContainerData.height &&
+ height < maxHeight
+ ) {
+ const targetContainerHeight = computeContainerDimensionForBoundText(
+ height,
+ container.type,
+ );
+ mutateElement(container, { height: targetContainerHeight });
+ } else {
+ const { y } = computeBoundTextPosition(
+ container,
+ updatedTextElement as ExcalidrawTextElementWithContainer,
+ elementsMap,
+ );
+ coordY = y;
+ }
+ }
+ const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
+ const initialSelectionStart = editable.selectionStart;
+ const initialSelectionEnd = editable.selectionEnd;
+ const initialLength = editable.value.length;
+
+ // restore cursor position after value updated so it doesn't
+ // go to the end of text when container auto expanded
+ if (
+ initialSelectionStart === initialSelectionEnd &&
+ initialSelectionEnd !== initialLength
+ ) {
+ // get diff between length and selection end and shift
+ // the cursor by "diff" times to position correctly
+ const diff = initialLength - initialSelectionEnd;
+ editable.selectionStart = editable.value.length - diff;
+ editable.selectionEnd = editable.value.length - diff;
+ }
+
+ if (!container) {
+ maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
+ width = Math.min(width, maxWidth);
+ } else {
+ width += 0.5;
+ }
+
+ // add 5% buffer otherwise it causes wysiwyg to jump
+ height *= 1.05;
+
+ const font = getFontString(updatedTextElement);
+
+ // Make sure text editor height doesn't go beyond viewport
+ const editorMaxHeight =
+ (appState.height - viewportY) / appState.zoom.value;
+ Object.assign(editable.style, {
+ font,
+ // must be defined *after* font ¯\_(ツ)_/¯
+ lineHeight: updatedTextElement.lineHeight,
+ width: `${width}px`,
+ height: `${height}px`,
+ left: `${viewportX}px`,
+ top: `${viewportY}px`,
+ transform: getTransform(
+ width,
+ height,
+ getTextElementAngle(updatedTextElement, container),
+ appState,
+ maxWidth,
+ editorMaxHeight,
+ ),
+ textAlign,
+ verticalAlign,
+ color: updatedTextElement.strokeColor,
+ opacity: updatedTextElement.opacity / 100,
+ filter: "var(--theme-filter)",
+ maxHeight: `${editorMaxHeight}px`,
+ });
+ editable.scrollTop = 0;
+ // For some reason updating font attribute doesn't set font family
+ // hence updating font family explicitly for test environment
+ if (isTestEnv()) {
+ editable.style.fontFamily = getFontFamilyString(updatedTextElement);
+ }
+
+ mutateElement(updatedTextElement, { x: coordX, y: coordY });
+ }
+ };
+
+ const editable = document.createElement("textarea");
+
+ editable.dir = "auto";
+ editable.tabIndex = 0;
+ editable.dataset.type = "wysiwyg";
+ // prevent line wrapping on Safari
+ editable.wrap = "off";
+ editable.classList.add("excalidraw-wysiwyg");
+
+ let whiteSpace = "pre";
+ let wordBreak = "normal";
+
+ if (isBoundToContainer(element) || !element.autoResize) {
+ whiteSpace = "pre-wrap";
+ wordBreak = "break-word";
+ }
+ Object.assign(editable.style, {
+ position: "absolute",
+ display: "inline-block",
+ minHeight: "1em",
+ backfaceVisibility: "hidden",
+ margin: 0,
+ padding: 0,
+ border: 0,
+ outline: 0,
+ resize: "none",
+ background: "transparent",
+ overflow: "hidden",
+ // must be specified because in dark mode canvas creates a stacking context
+ zIndex: "var(--zIndex-wysiwyg)",
+ wordBreak,
+ // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
+ whiteSpace,
+ overflowWrap: "break-word",
+ boxSizing: "content-box",
+ });
+ editable.value = element.originalText;
+ updateWysiwygStyle();
+
+ if (onChange) {
+ editable.onpaste = async (event) => {
+ const clipboardData = await parseClipboard(event, true);
+ if (!clipboardData.text) {
+ return;
+ }
+ const data = normalizeText(clipboardData.text);
+ if (!data) {
+ return;
+ }
+ const container = getContainerElement(
+ element,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ const font = getFontString({
+ fontSize: app.state.currentItemFontSize,
+ fontFamily: app.state.currentItemFontFamily,
+ });
+ if (container) {
+ const boundTextElement = getBoundTextElement(
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ const wrappedText = wrapText(
+ `${editable.value}${data}`,
+ font,
+ getBoundTextMaxWidth(container, boundTextElement),
+ );
+ const width = getTextWidth(wrappedText, font);
+ editable.style.width = `${width}px`;
+ }
+ };
+
+ editable.oninput = () => {
+ const normalized = normalizeText(editable.value);
+ if (editable.value !== normalized) {
+ const selectionStart = editable.selectionStart;
+ editable.value = normalized;
+ // put the cursor at some position close to where it was before
+ // normalization (otherwise it'll end up at the end of the text)
+ editable.selectionStart = selectionStart;
+ editable.selectionEnd = selectionStart;
+ }
+ onChange(editable.value);
+ };
+ }
+
+ editable.onkeydown = (event) => {
+ if (!event.shiftKey && actionZoomIn.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionZoomIn);
+ updateWysiwygStyle();
+ } else if (!event.shiftKey && actionZoomOut.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionZoomOut);
+ updateWysiwygStyle();
+ } else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
+ event.preventDefault();
+ app.actionManager.executeAction(actionResetZoom);
+ updateWysiwygStyle();
+ } else if (actionDecreaseFontSize.keyTest(event)) {
+ app.actionManager.executeAction(actionDecreaseFontSize);
+ } else if (actionIncreaseFontSize.keyTest(event)) {
+ app.actionManager.executeAction(actionIncreaseFontSize);
+ } else if (event.key === KEYS.ESCAPE) {
+ event.preventDefault();
+ submittedViaKeyboard = true;
+ handleSubmit();
+ } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
+ event.preventDefault();
+ if (event.isComposing || event.keyCode === 229) {
+ return;
+ }
+ submittedViaKeyboard = true;
+ handleSubmit();
+ } else if (
+ event.key === KEYS.TAB ||
+ (event[KEYS.CTRL_OR_CMD] &&
+ (event.code === CODES.BRACKET_LEFT ||
+ event.code === CODES.BRACKET_RIGHT))
+ ) {
+ event.preventDefault();
+ if (event.isComposing) {
+ return;
+ } else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
+ outdent();
+ } else {
+ indent();
+ }
+ // We must send an input event to resize the element
+ editable.dispatchEvent(new Event("input"));
+ }
+ };
+
+ const TAB_SIZE = 4;
+ const TAB = " ".repeat(TAB_SIZE);
+ const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
+ const indent = () => {
+ const { selectionStart, selectionEnd } = editable;
+ const linesStartIndices = getSelectedLinesStartIndices();
+
+ let value = editable.value;
+ linesStartIndices.forEach((startIndex: number) => {
+ const startValue = value.slice(0, startIndex);
+ const endValue = value.slice(startIndex);
+
+ value = `${startValue}${TAB}${endValue}`;
+ });
+
+ editable.value = value;
+
+ editable.selectionStart = selectionStart + TAB_SIZE;
+ editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
+ };
+
+ const outdent = () => {
+ const { selectionStart, selectionEnd } = editable;
+ const linesStartIndices = getSelectedLinesStartIndices();
+ const removedTabs: number[] = [];
+
+ let value = editable.value;
+ linesStartIndices.forEach((startIndex) => {
+ const tabMatch = value
+ .slice(startIndex, startIndex + TAB_SIZE)
+ .match(RE_LEADING_TAB);
+
+ if (tabMatch) {
+ const startValue = value.slice(0, startIndex);
+ const endValue = value.slice(startIndex + tabMatch[0].length);
+
+ // Delete a tab from the line
+ value = `${startValue}${endValue}`;
+ removedTabs.push(startIndex);
+ }
+ });
+
+ editable.value = value;
+
+ if (removedTabs.length) {
+ if (selectionStart > removedTabs[removedTabs.length - 1]) {
+ editable.selectionStart = Math.max(
+ selectionStart - TAB_SIZE,
+ removedTabs[removedTabs.length - 1],
+ );
+ } else {
+ // If the cursor is before the first tab removed, ex:
+ // Line| #1
+ // Line #2
+ // Lin|e #3
+ // we should reset the selectionStart to his initial value.
+ editable.selectionStart = selectionStart;
+ }
+ editable.selectionEnd = Math.max(
+ editable.selectionStart,
+ selectionEnd - TAB_SIZE * removedTabs.length,
+ );
+ }
+ };
+
+ /**
+ * @returns indices of start positions of selected lines, in reverse order
+ */
+ const getSelectedLinesStartIndices = () => {
+ let { selectionStart, selectionEnd, value } = editable;
+
+ // chars before selectionStart on the same line
+ const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
+ .length;
+ // put caret at the start of the line
+ selectionStart = selectionStart - startOffset;
+
+ const selected = value.slice(selectionStart, selectionEnd);
+
+ return selected
+ .split("\n")
+ .reduce(
+ (startIndices, line, idx, lines) =>
+ startIndices.concat(
+ idx
+ ? // curr line index is prev line's start + prev line's length + \n
+ startIndices[idx - 1] + lines[idx - 1].length + 1
+ : // first selected line
+ selectionStart,
+ ),
+ [] as number[],
+ )
+ .reverse();
+ };
+
+ const stopEvent = (event: Event) => {
+ if (event.target instanceof HTMLCanvasElement) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+
+ // using a state variable instead of passing it to the handleSubmit callback
+ // so that we don't need to create separate a callback for event handlers
+ let submittedViaKeyboard = false;
+ const handleSubmit = () => {
+ // prevent double submit
+ if (isDestroyed) {
+ return;
+ }
+ isDestroyed = true;
+ // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
+ // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
+ // wysiwyg on update
+ cleanup();
+ const updateElement = Scene.getScene(element)?.getElement(
+ element.id,
+ ) as ExcalidrawTextElement;
+ if (!updateElement) {
+ return;
+ }
+ const container = getContainerElement(
+ updateElement,
+ app.scene.getNonDeletedElementsMap(),
+ );
+
+ if (container) {
+ if (editable.value.trim()) {
+ const boundTextElementId = getBoundTextElementId(container);
+ if (!boundTextElementId || boundTextElementId !== element.id) {
+ mutateElement(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: element.id,
+ }),
+ });
+ } else if (isArrowElement(container)) {
+ // updating an arrow label may change bounds, prevent stale cache:
+ bumpVersion(container);
+ }
+ } else {
+ mutateElement(container, {
+ boundElements: container.boundElements?.filter(
+ (ele) =>
+ !isTextElement(
+ ele as ExcalidrawTextElement | ExcalidrawLinearElement,
+ ),
+ ),
+ });
+ }
+ redrawTextBoundingBox(
+ updateElement,
+ container,
+ app.scene.getNonDeletedElementsMap(),
+ );
+ }
+
+ onSubmit({
+ viaKeyboard: submittedViaKeyboard,
+ nextOriginalText: editable.value,
+ });
+ };
+
+ const cleanup = () => {
+ // remove events to ensure they don't late-fire
+ editable.onblur = null;
+ editable.oninput = null;
+ editable.onkeydown = null;
+
+ if (observer) {
+ observer.disconnect();
+ }
+
+ window.removeEventListener("resize", updateWysiwygStyle);
+ window.removeEventListener("wheel", stopEvent, true);
+ window.removeEventListener("pointerdown", onPointerDown);
+ window.removeEventListener("pointerup", bindBlurEvent);
+ window.removeEventListener("blur", handleSubmit);
+ window.removeEventListener("beforeunload", handleSubmit);
+ unbindUpdate();
+ unbindOnScroll();
+
+ editable.remove();
+ };
+
+ const bindBlurEvent = (event?: MouseEvent) => {
+ window.removeEventListener("pointerup", bindBlurEvent);
+ // Deferred so that the pointerdown that initiates the wysiwyg doesn't
+ // trigger the blur on ensuing pointerup.
+ // Also to handle cases such as picking a color which would trigger a blur
+ // in that same tick.
+ const target = event?.target;
+
+ const isPropertiesTrigger =
+ target instanceof HTMLElement &&
+ target.classList.contains("properties-trigger");
+
+ setTimeout(() => {
+ editable.onblur = handleSubmit;
+
+ // case: clicking on the same property → no change → no update → no focus
+ if (!isPropertiesTrigger) {
+ editable.focus();
+ }
+ });
+ };
+
+ const temporarilyDisableSubmit = () => {
+ editable.onblur = null;
+ window.addEventListener("pointerup", bindBlurEvent);
+ // handle edge-case where pointerup doesn't fire e.g. due to user
+ // alt-tabbing away
+ window.addEventListener("blur", handleSubmit);
+ };
+
+ // prevent blur when changing properties from the menu
+ const onPointerDown = (event: MouseEvent) => {
+ const target = event?.target;
+
+ // panning canvas
+ if (event.button === POINTER_BUTTON.WHEEL) {
+ // trying to pan by clicking inside text area itself -> handle here
+ if (target instanceof HTMLTextAreaElement) {
+ event.preventDefault();
+ app.handleCanvasPanUsingWheelOrSpaceDrag(event);
+ }
+ temporarilyDisableSubmit();
+ return;
+ }
+
+ const isPropertiesTrigger =
+ target instanceof HTMLElement &&
+ target.classList.contains("properties-trigger");
+
+ if (
+ ((event.target instanceof HTMLElement ||
+ event.target instanceof SVGElement) &&
+ event.target.closest(
+ `.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
+ ) &&
+ !isWritableElement(event.target)) ||
+ isPropertiesTrigger
+ ) {
+ temporarilyDisableSubmit();
+ } else if (
+ event.target instanceof HTMLCanvasElement &&
+ // Vitest simply ignores stopPropagation, capture-mode, or rAF
+ // so without introducing crazier hacks, nothing we can do
+ !isTestEnv()
+ ) {
+ // On mobile, blur event doesn't seem to always fire correctly,
+ // so we want to also submit on pointerdown outside the wysiwyg.
+ // Done in the next frame to prevent pointerdown from creating a new text
+ // immediately (if tools locked) so that users on mobile have chance
+ // to submit first (to hide virtual keyboard).
+ // Note: revisit if we want to differ this behavior on Desktop
+ requestAnimationFrame(() => {
+ handleSubmit();
+ });
+ }
+ };
+
+ // handle updates of textElement properties of editing element
+ const unbindUpdate = app.scene.onUpdate(() => {
+ updateWysiwygStyle();
+ const isPopupOpened = !!document.activeElement?.closest(
+ ".properties-content",
+ );
+ if (!isPopupOpened) {
+ editable.focus();
+ }
+ });
+
+ const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
+ updateWysiwygStyle();
+ });
+
+ // ---------------------------------------------------------------------------
+
+ let isDestroyed = false;
+
+ if (autoSelect) {
+ // select on init (focusing is done separately inside the bindBlurEvent()
+ // because we need it to happen *after* the blur event from `pointerdown`)
+ editable.select();
+ }
+ bindBlurEvent();
+
+ // reposition wysiwyg in case of canvas is resized. Using ResizeObserver
+ // is preferred so we catch changes from host, where window may not resize.
+ let observer: ResizeObserver | null = null;
+ if (canvas && "ResizeObserver" in window) {
+ observer = new window.ResizeObserver(() => {
+ updateWysiwygStyle();
+ });
+ observer.observe(canvas);
+ } else {
+ window.addEventListener("resize", updateWysiwygStyle);
+ }
+
+ editable.onpointerdown = (event) => event.stopPropagation();
+
+ // rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
+ // triggered the wysiwyg
+ requestAnimationFrame(() => {
+ window.addEventListener("pointerdown", onPointerDown, { capture: true });
+ });
+ window.addEventListener("beforeunload", handleSubmit);
+ excalidrawContainer
+ ?.querySelector(".excalidraw-textEditorContainer")!
+ .appendChild(editable);
+};
diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts
new file mode 100644
index 0000000..d34eb32
--- /dev/null
+++ b/packages/excalidraw/element/transformHandles.ts
@@ -0,0 +1,341 @@
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ PointerType,
+} from "./types";
+
+import type { Bounds } from "./bounds";
+import { getElementAbsoluteCoords } from "./bounds";
+import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
+import {
+ isElbowArrow,
+ isFrameLikeElement,
+ isImageElement,
+ isLinearElement,
+} from "./typeChecks";
+import {
+ DEFAULT_TRANSFORM_HANDLE_SPACING,
+ isAndroid,
+ isIOS,
+} from "../constants";
+import type { Radians } from "@excalidraw/math";
+import { pointFrom, pointRotateRads } from "@excalidraw/math";
+
+export type TransformHandleDirection =
+ | "n"
+ | "s"
+ | "w"
+ | "e"
+ | "nw"
+ | "ne"
+ | "sw"
+ | "se";
+
+export type TransformHandleType = TransformHandleDirection | "rotation";
+
+export type TransformHandle = Bounds;
+export type TransformHandles = Partial<{
+ [T in TransformHandleType]: TransformHandle;
+}>;
+export type MaybeTransformHandleType = TransformHandleType | false;
+
+const transformHandleSizes: { [k in PointerType]: number } = {
+ mouse: 8,
+ pen: 16,
+ touch: 28,
+};
+
+const ROTATION_RESIZE_HANDLE_GAP = 16;
+
+export const DEFAULT_OMIT_SIDES = {
+ e: true,
+ s: true,
+ n: true,
+ w: true,
+};
+
+export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
+ e: true,
+ s: true,
+ n: true,
+ w: true,
+};
+
+export const OMIT_SIDES_FOR_FRAME = {
+ e: true,
+ s: true,
+ n: true,
+ w: true,
+ rotation: true,
+};
+
+const OMIT_SIDES_FOR_LINE_SLASH = {
+ e: true,
+ s: true,
+ n: true,
+ w: true,
+ nw: true,
+ se: true,
+};
+
+const OMIT_SIDES_FOR_LINE_BACKSLASH = {
+ e: true,
+ s: true,
+ n: true,
+ w: true,
+};
+
+const generateTransformHandle = (
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ cx: number,
+ cy: number,
+ angle: Radians,
+): TransformHandle => {
+ const [xx, yy] = pointRotateRads(
+ pointFrom(x + width / 2, y + height / 2),
+ pointFrom(cx, cy),
+ angle,
+ );
+ return [xx - width / 2, yy - height / 2, width, height];
+};
+
+export const canResizeFromSides = (device: Device) => {
+ if (device.viewport.isMobile) {
+ return false;
+ }
+
+ if (device.isTouchScreen && (isAndroid || isIOS)) {
+ return false;
+ }
+
+ return true;
+};
+
+export const getOmitSidesForDevice = (device: Device) => {
+ if (canResizeFromSides(device)) {
+ return DEFAULT_OMIT_SIDES;
+ }
+
+ return {};
+};
+
+export const getTransformHandlesFromCoords = (
+ [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
+ angle: Radians,
+ zoom: Zoom,
+ pointerType: PointerType,
+ omitSides: { [T in TransformHandleType]?: boolean } = {},
+ margin = 4,
+ spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
+): TransformHandles => {
+ const size = transformHandleSizes[pointerType];
+ const handleWidth = size / zoom.value;
+ const handleHeight = size / zoom.value;
+
+ const handleMarginX = size / zoom.value;
+ const handleMarginY = size / zoom.value;
+
+ const width = x2 - x1;
+ const height = y2 - y1;
+ const dashedLineMargin = margin / zoom.value;
+ const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
+
+ const transformHandles: TransformHandles = {
+ nw: omitSides.nw
+ ? undefined
+ : generateTransformHandle(
+ x1 - dashedLineMargin - handleMarginX + centeringOffset,
+ y1 - dashedLineMargin - handleMarginY + centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ ),
+ ne: omitSides.ne
+ ? undefined
+ : generateTransformHandle(
+ x2 + dashedLineMargin - centeringOffset,
+ y1 - dashedLineMargin - handleMarginY + centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ ),
+ sw: omitSides.sw
+ ? undefined
+ : generateTransformHandle(
+ x1 - dashedLineMargin - handleMarginX + centeringOffset,
+ y2 + dashedLineMargin - centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ ),
+ se: omitSides.se
+ ? undefined
+ : generateTransformHandle(
+ x2 + dashedLineMargin - centeringOffset,
+ y2 + dashedLineMargin - centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ ),
+ rotation: omitSides.rotation
+ ? undefined
+ : generateTransformHandle(
+ x1 + width / 2 - handleWidth / 2,
+ y1 -
+ dashedLineMargin -
+ handleMarginY +
+ centeringOffset -
+ ROTATION_RESIZE_HANDLE_GAP / zoom.value,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ ),
+ };
+
+ // We only want to show height handles (all cardinal directions) above a certain size
+ // Note: we render using "mouse" size so we should also use "mouse" size for this check
+ const minimumSizeForEightHandles =
+ (5 * transformHandleSizes.mouse) / zoom.value;
+ if (Math.abs(width) > minimumSizeForEightHandles) {
+ if (!omitSides.n) {
+ transformHandles.n = generateTransformHandle(
+ x1 + width / 2 - handleWidth / 2,
+ y1 - dashedLineMargin - handleMarginY + centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ );
+ }
+ if (!omitSides.s) {
+ transformHandles.s = generateTransformHandle(
+ x1 + width / 2 - handleWidth / 2,
+ y2 + dashedLineMargin - centeringOffset,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ );
+ }
+ }
+ if (Math.abs(height) > minimumSizeForEightHandles) {
+ if (!omitSides.w) {
+ transformHandles.w = generateTransformHandle(
+ x1 - dashedLineMargin - handleMarginX + centeringOffset,
+ y1 + height / 2 - handleHeight / 2,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ );
+ }
+ if (!omitSides.e) {
+ transformHandles.e = generateTransformHandle(
+ x2 + dashedLineMargin - centeringOffset,
+ y1 + height / 2 - handleHeight / 2,
+ handleWidth,
+ handleHeight,
+ cx,
+ cy,
+ angle,
+ );
+ }
+ }
+
+ return transformHandles;
+};
+
+export const getTransformHandles = (
+ element: ExcalidrawElement,
+ zoom: Zoom,
+ elementsMap: ElementsMap,
+ pointerType: PointerType = "mouse",
+ omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
+): TransformHandles => {
+ // so that when locked element is selected (especially when you toggle lock
+ // via keyboard) the locked element is visually distinct, indicating
+ // you can't move/resize
+ if (
+ element.locked ||
+ // Elbow arrows cannot be rotated
+ isElbowArrow(element)
+ ) {
+ return {};
+ }
+
+ if (element.type === "freedraw" || isLinearElement(element)) {
+ if (element.points.length === 2) {
+ // only check the last point because starting point is always (0,0)
+ const [, p1] = element.points;
+ if (p1[0] === 0 || p1[1] === 0) {
+ omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
+ } else if (p1[0] > 0 && p1[1] < 0) {
+ omitSides = OMIT_SIDES_FOR_LINE_SLASH;
+ } else if (p1[0] > 0 && p1[1] > 0) {
+ omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
+ } else if (p1[0] < 0 && p1[1] > 0) {
+ omitSides = OMIT_SIDES_FOR_LINE_SLASH;
+ } else if (p1[0] < 0 && p1[1] < 0) {
+ omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
+ }
+ }
+ } else if (isFrameLikeElement(element)) {
+ omitSides = {
+ ...omitSides,
+ rotation: true,
+ };
+ }
+ const margin = isLinearElement(element)
+ ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
+ : isImageElement(element)
+ ? 0
+ : DEFAULT_TRANSFORM_HANDLE_SPACING;
+ return getTransformHandlesFromCoords(
+ getElementAbsoluteCoords(element, elementsMap, true),
+ element.angle,
+ zoom,
+ pointerType,
+ omitSides,
+ margin,
+ isImageElement(element) ? 0 : undefined,
+ );
+};
+
+export const shouldShowBoundingBox = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: InteractiveCanvasAppState,
+) => {
+ if (appState.editingLinearElement) {
+ return false;
+ }
+ if (elements.length > 1) {
+ return true;
+ }
+ const element = elements[0];
+ if (isElbowArrow(element)) {
+ // Elbow arrows cannot be resized as single selected elements
+ return false;
+ }
+ if (!isLinearElement(element)) {
+ return true;
+ }
+
+ return element.points.length > 2;
+};
diff --git a/packages/excalidraw/element/typeChecks.test.ts b/packages/excalidraw/element/typeChecks.test.ts
new file mode 100644
index 0000000..60eb9e2
--- /dev/null
+++ b/packages/excalidraw/element/typeChecks.test.ts
@@ -0,0 +1,66 @@
+import { API } from "../tests/helpers/api";
+import { hasBoundTextElement } from "./typeChecks";
+
+describe("Test TypeChecks", () => {
+ describe("Test hasBoundTextElement", () => {
+ it("should return true for text bindable containers with bound text", () => {
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "rectangle",
+ boundElements: [{ type: "text", id: "text-id" }],
+ }),
+ ),
+ ).toBeTruthy();
+
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "ellipse",
+ boundElements: [{ type: "text", id: "text-id" }],
+ }),
+ ),
+ ).toBeTruthy();
+
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "arrow",
+ boundElements: [{ type: "text", id: "text-id" }],
+ }),
+ ),
+ ).toBeTruthy();
+ });
+
+ it("should return false for text bindable containers without bound text", () => {
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "freedraw",
+ boundElements: [{ type: "arrow", id: "arrow-id" }],
+ }),
+ ),
+ ).toBeFalsy();
+ });
+
+ it("should return false for non text bindable containers", () => {
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "freedraw",
+ boundElements: [{ type: "text", id: "text-id" }],
+ }),
+ ),
+ ).toBeFalsy();
+ });
+
+ expect(
+ hasBoundTextElement(
+ API.createElement({
+ type: "image",
+ boundElements: [{ type: "text", id: "text-id" }],
+ }),
+ ),
+ ).toBeFalsy();
+ });
+});
diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts
new file mode 100644
index 0000000..6bb4269
--- /dev/null
+++ b/packages/excalidraw/element/typeChecks.ts
@@ -0,0 +1,338 @@
+import { ROUNDNESS } from "../constants";
+import type { ElementOrToolType } from "../types";
+import type { MarkNonNullable } from "../utility-types";
+import { assertNever } from "../utils";
+import type { Bounds } from "./bounds";
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ ExcalidrawEmbeddableElement,
+ ExcalidrawLinearElement,
+ ExcalidrawBindableElement,
+ ExcalidrawFreeDrawElement,
+ InitializedExcalidrawImageElement,
+ ExcalidrawImageElement,
+ ExcalidrawTextElementWithContainer,
+ ExcalidrawTextContainer,
+ ExcalidrawFrameElement,
+ RoundnessType,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawElementType,
+ ExcalidrawIframeElement,
+ ExcalidrawIframeLikeElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawArrowElement,
+ ExcalidrawElbowArrowElement,
+ PointBinding,
+ FixedPointBinding,
+ ExcalidrawFlowchartNodeElement,
+} from "./types";
+
+export const isInitializedImageElement = (
+ element: ExcalidrawElement | null,
+): element is InitializedExcalidrawImageElement => {
+ return !!element && element.type === "image" && !!element.fileId;
+};
+
+export const isImageElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawImageElement => {
+ return !!element && element.type === "image";
+};
+
+export const isEmbeddableElement = (
+ element: ExcalidrawElement | null | undefined,
+): element is ExcalidrawEmbeddableElement => {
+ return !!element && element.type === "embeddable";
+};
+
+export const isIframeElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawIframeElement => {
+ return !!element && element.type === "iframe";
+};
+
+export const isIframeLikeElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawIframeLikeElement => {
+ return (
+ !!element && (element.type === "iframe" || element.type === "embeddable")
+ );
+};
+
+export const isTextElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawTextElement => {
+ return element != null && element.type === "text";
+};
+
+export const isFrameElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawFrameElement => {
+ return element != null && element.type === "frame";
+};
+
+export const isMagicFrameElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawMagicFrameElement => {
+ return element != null && element.type === "magicframe";
+};
+
+export const isFrameLikeElement = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawFrameLikeElement => {
+ return (
+ element != null &&
+ (element.type === "frame" || element.type === "magicframe")
+ );
+};
+
+export const isFreeDrawElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawFreeDrawElement => {
+ return element != null && isFreeDrawElementType(element.type);
+};
+
+export const isFreeDrawElementType = (
+ elementType: ExcalidrawElementType,
+): boolean => {
+ return elementType === "freedraw";
+};
+
+export const isLinearElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawLinearElement => {
+ return element != null && isLinearElementType(element.type);
+};
+
+export const isArrowElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawArrowElement => {
+ return element != null && element.type === "arrow";
+};
+
+export const isElbowArrow = (
+ element?: ExcalidrawElement,
+): element is ExcalidrawElbowArrowElement => {
+ return isArrowElement(element) && element.elbowed;
+};
+
+export const isLinearElementType = (
+ elementType: ElementOrToolType,
+): boolean => {
+ return (
+ elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
+ );
+};
+
+export const isBindingElement = (
+ element?: ExcalidrawElement | null,
+ includeLocked = true,
+): element is ExcalidrawLinearElement => {
+ return (
+ element != null &&
+ (!element.locked || includeLocked === true) &&
+ isBindingElementType(element.type)
+ );
+};
+
+export const isBindingElementType = (
+ elementType: ElementOrToolType,
+): boolean => {
+ return elementType === "arrow";
+};
+
+export const isBindableElement = (
+ element: ExcalidrawElement | null | undefined,
+ includeLocked = true,
+): element is ExcalidrawBindableElement => {
+ return (
+ element != null &&
+ (!element.locked || includeLocked === true) &&
+ (element.type === "rectangle" ||
+ element.type === "diamond" ||
+ element.type === "ellipse" ||
+ element.type === "image" ||
+ element.type === "iframe" ||
+ element.type === "embeddable" ||
+ element.type === "frame" ||
+ element.type === "magicframe" ||
+ (element.type === "text" && !element.containerId))
+ );
+};
+
+export const isRectanguloidElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawBindableElement => {
+ return (
+ element != null &&
+ (element.type === "rectangle" ||
+ element.type === "diamond" ||
+ element.type === "image" ||
+ element.type === "iframe" ||
+ element.type === "embeddable" ||
+ element.type === "frame" ||
+ element.type === "magicframe" ||
+ (element.type === "text" && !element.containerId))
+ );
+};
+
+// TODO: Remove this when proper distance calculation is introduced
+// @see binding.ts:distanceToBindableElement()
+export const isRectangularElement = (
+ element?: ExcalidrawElement | null,
+): element is ExcalidrawBindableElement => {
+ return (
+ element != null &&
+ (element.type === "rectangle" ||
+ element.type === "image" ||
+ element.type === "text" ||
+ element.type === "iframe" ||
+ element.type === "embeddable" ||
+ element.type === "frame" ||
+ element.type === "magicframe" ||
+ element.type === "freedraw")
+ );
+};
+
+export const isTextBindableContainer = (
+ element: ExcalidrawElement | null,
+ includeLocked = true,
+): element is ExcalidrawTextContainer => {
+ return (
+ element != null &&
+ (!element.locked || includeLocked === true) &&
+ (element.type === "rectangle" ||
+ element.type === "diamond" ||
+ element.type === "ellipse" ||
+ isArrowElement(element))
+ );
+};
+
+export const isExcalidrawElement = (
+ element: any,
+): element is ExcalidrawElement => {
+ const type: ExcalidrawElementType | undefined = element?.type;
+ if (!type) {
+ return false;
+ }
+ switch (type) {
+ case "text":
+ case "diamond":
+ case "rectangle":
+ case "iframe":
+ case "embeddable":
+ case "ellipse":
+ case "arrow":
+ case "freedraw":
+ case "line":
+ case "frame":
+ case "magicframe":
+ case "image":
+ case "selection": {
+ return true;
+ }
+ default: {
+ assertNever(type, null);
+ return false;
+ }
+ }
+};
+
+export const isFlowchartNodeElement = (
+ element: ExcalidrawElement,
+): element is ExcalidrawFlowchartNodeElement => {
+ return (
+ element.type === "rectangle" ||
+ element.type === "ellipse" ||
+ element.type === "diamond"
+ );
+};
+
+export const hasBoundTextElement = (
+ element: ExcalidrawElement | null,
+): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
+ return (
+ isTextBindableContainer(element) &&
+ !!element.boundElements?.some(({ type }) => type === "text")
+ );
+};
+
+export const isBoundToContainer = (
+ element: ExcalidrawElement | null,
+): element is ExcalidrawTextElementWithContainer => {
+ return (
+ element !== null &&
+ "containerId" in element &&
+ element.containerId !== null &&
+ isTextElement(element)
+ );
+};
+
+export const isUsingAdaptiveRadius = (type: string) =>
+ type === "rectangle" ||
+ type === "embeddable" ||
+ type === "iframe" ||
+ type === "image";
+
+export const isUsingProportionalRadius = (type: string) =>
+ type === "line" || type === "arrow" || type === "diamond";
+
+export const canApplyRoundnessTypeToElement = (
+ roundnessType: RoundnessType,
+ element: ExcalidrawElement,
+) => {
+ if (
+ (roundnessType === ROUNDNESS.ADAPTIVE_RADIUS ||
+ // if legacy roundness, it can be applied to elements that currently
+ // use adaptive radius
+ roundnessType === ROUNDNESS.LEGACY) &&
+ isUsingAdaptiveRadius(element.type)
+ ) {
+ return true;
+ }
+ if (
+ roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS &&
+ isUsingProportionalRadius(element.type)
+ ) {
+ return true;
+ }
+
+ return false;
+};
+
+export const getDefaultRoundnessTypeForElement = (
+ element: ExcalidrawElement,
+) => {
+ if (isUsingProportionalRadius(element.type)) {
+ return {
+ type: ROUNDNESS.PROPORTIONAL_RADIUS,
+ };
+ }
+
+ if (isUsingAdaptiveRadius(element.type)) {
+ return {
+ type: ROUNDNESS.ADAPTIVE_RADIUS,
+ };
+ }
+
+ return null;
+};
+
+export const isFixedPointBinding = (
+ binding: PointBinding | FixedPointBinding,
+): binding is FixedPointBinding => {
+ return (
+ Object.hasOwn(binding, "fixedPoint") &&
+ (binding as FixedPointBinding).fixedPoint != null
+ );
+};
+
+// TODO: Move this to @excalidraw/math
+export const isBounds = (box: unknown): box is Bounds =>
+ Array.isArray(box) &&
+ box.length === 4 &&
+ typeof box[0] === "number" &&
+ typeof box[1] === "number" &&
+ typeof box[2] === "number" &&
+ typeof box[3] === "number";
diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts
new file mode 100644
index 0000000..5965867
--- /dev/null
+++ b/packages/excalidraw/element/types.ts
@@ -0,0 +1,412 @@
+import type { LocalPoint, Radians } from "@excalidraw/math";
+import type {
+ FONT_FAMILY,
+ ROUNDNESS,
+ TEXT_ALIGN,
+ THEME,
+ VERTICAL_ALIGN,
+} from "../constants";
+import type {
+ MakeBrand,
+ MarkNonNullable,
+ Merge,
+ ValueOf,
+} from "../utility-types";
+
+export type ChartType = "bar" | "line";
+export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
+export type FontFamilyKeys = keyof typeof FONT_FAMILY;
+export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
+export type Theme = typeof THEME[keyof typeof THEME];
+export type FontString = string & { _brand: "fontString" };
+export type GroupId = string;
+export type PointerType = "mouse" | "pen" | "touch";
+export type StrokeRoundness = "round" | "sharp";
+export type RoundnessType = ValueOf<typeof ROUNDNESS>;
+export type StrokeStyle = "solid" | "dashed" | "dotted";
+export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
+
+type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
+export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
+export type FractionalIndex = string & { _brand: "franctionalIndex" };
+
+export type BoundElement = Readonly<{
+ id: ExcalidrawLinearElement["id"];
+ type: "arrow" | "text";
+}>;
+
+type _ExcalidrawElementBase = Readonly<{
+ id: string;
+ x: number;
+ y: number;
+ strokeColor: string;
+ backgroundColor: string;
+ fillStyle: FillStyle;
+ strokeWidth: number;
+ strokeStyle: StrokeStyle;
+ roundness: null | { type: RoundnessType; value?: number };
+ roughness: number;
+ opacity: number;
+ width: number;
+ height: number;
+ angle: Radians;
+ /** Random integer used to seed shape generation so that the roughjs shape
+ doesn't differ across renders. */
+ seed: number;
+ /** Integer that is sequentially incremented on each change. Used to reconcile
+ elements during collaboration or when saving to server. */
+ version: number;
+ /** Random integer that is regenerated on each change.
+ Used for deterministic reconciliation of updates during collaboration,
+ in case the versions (see above) are identical. */
+ versionNonce: number;
+ /** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
+ Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
+ Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
+ Could be null, i.e. for new elements which were not yet assigned to the scene. */
+ index: FractionalIndex | null;
+ isDeleted: boolean;
+ /** List of groups the element belongs to.
+ Ordered from deepest to shallowest. */
+ groupIds: readonly GroupId[];
+ frameId: string | null;
+ /** other elements that are bound to this element */
+ boundElements: readonly BoundElement[] | null;
+ /** epoch (ms) timestamp of last element update */
+ updated: number;
+ link: string | null;
+ locked: boolean;
+ customData?: Record<string, any>;
+}>;
+
+export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
+ type: "selection";
+};
+
+export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
+ type: "rectangle";
+};
+
+export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
+ type: "diamond";
+};
+
+export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
+ type: "ellipse";
+};
+
+export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "embeddable";
+ }>;
+
+export type MagicGenerationData =
+ | {
+ status: "pending";
+ }
+ | { status: "done"; html: string }
+ | {
+ status: "error";
+ message?: string;
+ code: "ERR_GENERATION_INTERRUPTED" | string;
+ };
+
+export type ExcalidrawIframeElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "iframe";
+ // TODO move later to AI-specific frame
+ customData?: { generationData?: MagicGenerationData };
+ }>;
+
+export type ExcalidrawIframeLikeElement =
+ | ExcalidrawIframeElement
+ | ExcalidrawEmbeddableElement;
+
+export type IframeData =
+ | {
+ intrinsicSize: { w: number; h: number };
+ error?: Error;
+ sandbox?: { allowSameOrigin?: boolean };
+ } & (
+ | { type: "video" | "generic"; link: string }
+ | { type: "document"; srcdoc: (theme: Theme) => string }
+ );
+
+export type ImageCrop = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ naturalWidth: number;
+ naturalHeight: number;
+};
+
+export type ExcalidrawImageElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "image";
+ fileId: FileId | null;
+ /** whether respective file is persisted */
+ status: "pending" | "saved" | "error";
+ /** X and Y scale factors <-1, 1>, used for image axis flipping */
+ scale: [number, number];
+ /** whether an element is cropped */
+ crop: ImageCrop | null;
+ }>;
+
+export type InitializedExcalidrawImageElement = MarkNonNullable<
+ ExcalidrawImageElement,
+ "fileId"
+>;
+
+export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
+ type: "frame";
+ name: string | null;
+};
+
+export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
+ type: "magicframe";
+ name: string | null;
+};
+
+export type ExcalidrawFrameLikeElement =
+ | ExcalidrawFrameElement
+ | ExcalidrawMagicFrameElement;
+
+/**
+ * These are elements that don't have any additional properties.
+ */
+export type ExcalidrawGenericElement =
+ | ExcalidrawSelectionElement
+ | ExcalidrawRectangleElement
+ | ExcalidrawDiamondElement
+ | ExcalidrawEllipseElement;
+
+export type ExcalidrawFlowchartNodeElement =
+ | ExcalidrawRectangleElement
+ | ExcalidrawDiamondElement
+ | ExcalidrawEllipseElement;
+
+export type ExcalidrawRectanguloidElement =
+ | ExcalidrawRectangleElement
+ | ExcalidrawImageElement
+ | ExcalidrawTextElement
+ | ExcalidrawFreeDrawElement
+ | ExcalidrawIframeLikeElement
+ | ExcalidrawFrameLikeElement
+ | ExcalidrawEmbeddableElement;
+
+/**
+ * ExcalidrawElement should be JSON serializable and (eventually) contain
+ * no computed data. The list of all ExcalidrawElements should be shareable
+ * between peers and contain no state local to the peer.
+ */
+export type ExcalidrawElement =
+ | ExcalidrawGenericElement
+ | ExcalidrawTextElement
+ | ExcalidrawLinearElement
+ | ExcalidrawArrowElement
+ | ExcalidrawFreeDrawElement
+ | ExcalidrawImageElement
+ | ExcalidrawFrameElement
+ | ExcalidrawMagicFrameElement
+ | ExcalidrawIframeElement
+ | ExcalidrawEmbeddableElement;
+
+export type ExcalidrawNonSelectionElement = Exclude<
+ ExcalidrawElement,
+ ExcalidrawSelectionElement
+>;
+
+export type Ordered<TElement extends ExcalidrawElement> = TElement & {
+ index: FractionalIndex;
+};
+
+export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
+
+export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
+ isDeleted: boolean;
+};
+
+export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
+
+export type ExcalidrawTextElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "text";
+ fontSize: number;
+ fontFamily: FontFamilyValues;
+ text: string;
+ textAlign: TextAlign;
+ verticalAlign: VerticalAlign;
+ containerId: ExcalidrawGenericElement["id"] | null;
+ originalText: string;
+ /**
+ * If `true` the width will fit the text. If `false`, the text will
+ * wrap to fit the width.
+ *
+ * @default true
+ */
+ autoResize: boolean;
+ /**
+ * Unitless line height (aligned to W3C). To get line height in px, multiply
+ * with font size (using `getLineHeightInPx` helper).
+ */
+ lineHeight: number & { _brand: "unitlessLineHeight" };
+ }>;
+
+export type ExcalidrawBindableElement =
+ | ExcalidrawRectangleElement
+ | ExcalidrawDiamondElement
+ | ExcalidrawEllipseElement
+ | ExcalidrawTextElement
+ | ExcalidrawImageElement
+ | ExcalidrawIframeElement
+ | ExcalidrawEmbeddableElement
+ | ExcalidrawFrameElement
+ | ExcalidrawMagicFrameElement;
+
+export type ExcalidrawTextContainer =
+ | ExcalidrawRectangleElement
+ | ExcalidrawDiamondElement
+ | ExcalidrawEllipseElement
+ | ExcalidrawArrowElement;
+
+export type ExcalidrawTextElementWithContainer = {
+ containerId: ExcalidrawTextContainer["id"];
+} & ExcalidrawTextElement;
+
+export type FixedPoint = [number, number];
+
+export type PointBinding = {
+ elementId: ExcalidrawBindableElement["id"];
+ focus: number;
+ gap: number;
+};
+
+export type FixedPointBinding = Merge<
+ PointBinding,
+ {
+ // Represents the fixed point binding information in form of a vertical and
+ // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
+ // gives the user selected fixed point by multiplying the bound element width
+ // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
+ // bound element-local point coordinate.
+ fixedPoint: FixedPoint;
+ }
+>;
+
+export type Arrowhead =
+ | "arrow"
+ | "bar"
+ | "dot" // legacy. Do not use for new elements.
+ | "circle"
+ | "circle_outline"
+ | "triangle"
+ | "triangle_outline"
+ | "diamond"
+ | "diamond_outline"
+ | "crowfoot_one"
+ | "crowfoot_many"
+ | "crowfoot_one_or_many";
+
+export type ExcalidrawLinearElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "line" | "arrow";
+ points: readonly LocalPoint[];
+ lastCommittedPoint: LocalPoint | null;
+ startBinding: PointBinding | null;
+ endBinding: PointBinding | null;
+ startArrowhead: Arrowhead | null;
+ endArrowhead: Arrowhead | null;
+ }>;
+
+export type FixedSegment = {
+ start: LocalPoint;
+ end: LocalPoint;
+ index: number;
+};
+
+export type ExcalidrawArrowElement = ExcalidrawLinearElement &
+ Readonly<{
+ type: "arrow";
+ elbowed: boolean;
+ }>;
+
+export type ExcalidrawElbowArrowElement = Merge<
+ ExcalidrawArrowElement,
+ {
+ elbowed: true;
+ startBinding: FixedPointBinding | null;
+ endBinding: FixedPointBinding | null;
+ fixedSegments: readonly FixedSegment[] | null;
+ /**
+ * Marks that the 3rd point should be used as the 2nd point of the arrow in
+ * order to temporarily hide the first segment of the arrow without losing
+ * the data from the points array. It allows creating the expected arrow
+ * path when the arrow with fixed segments is bound on a horizontal side and
+ * moved to a vertical and vica versa.
+ */
+ startIsSpecial: boolean | null;
+ /**
+ * Marks that the 3rd point backwards from the end should be used as the 2nd
+ * point of the arrow in order to temporarily hide the last segment of the
+ * arrow without losing the data from the points array. It allows creating
+ * the expected arrow path when the arrow with fixed segments is bound on a
+ * horizontal side and moved to a vertical and vica versa.
+ */
+ endIsSpecial: boolean | null;
+ }
+>;
+
+export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
+ Readonly<{
+ type: "freedraw";
+ points: readonly LocalPoint[];
+ pressures: readonly number[];
+ simulatePressure: boolean;
+ lastCommittedPoint: LocalPoint | null;
+ }>;
+
+export type FileId = string & { _brand: "FileId" };
+
+export type ExcalidrawElementType = ExcalidrawElement["type"];
+
+/**
+ * Map of excalidraw elements.
+ * Unspecified whether deleted or non-deleted.
+ * Can be a subset of Scene elements.
+ */
+export type ElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement>;
+
+/**
+ * Map of non-deleted elements.
+ * Can be a subset of Scene elements.
+ */
+export type NonDeletedElementsMap = Map<
+ ExcalidrawElement["id"],
+ NonDeletedExcalidrawElement
+> &
+ MakeBrand<"NonDeletedElementsMap">;
+
+/**
+ * Map of all excalidraw Scene elements, including deleted.
+ * Not a subset. Use this type when you need access to current Scene elements.
+ */
+export type SceneElementsMap = Map<
+ ExcalidrawElement["id"],
+ Ordered<ExcalidrawElement>
+> &
+ MakeBrand<"SceneElementsMap">;
+
+/**
+ * Map of all non-deleted Scene elements.
+ * Not a subset. Use this type when you need access to current Scene elements.
+ */
+export type NonDeletedSceneElementsMap = Map<
+ ExcalidrawElement["id"],
+ Ordered<NonDeletedExcalidrawElement>
+> &
+ MakeBrand<"NonDeletedSceneElementsMap">;
+
+export type ElementsMapOrArray =
+ | readonly ExcalidrawElement[]
+ | Readonly<ElementsMap>;
diff --git a/packages/excalidraw/element/utils.ts b/packages/excalidraw/element/utils.ts
new file mode 100644
index 0000000..d85cd78
--- /dev/null
+++ b/packages/excalidraw/element/utils.ts
@@ -0,0 +1,355 @@
+import { getDiamondPoints } from ".";
+import type { Curve, LineSegment } from "@excalidraw/math";
+import {
+ curve,
+ lineSegment,
+ pointFrom,
+ pointFromVector,
+ rectangle,
+ vectorFromPoint,
+ vectorNormalize,
+ vectorScale,
+ type GlobalPoint,
+} from "@excalidraw/math";
+import { getCornerRadius } from "../shapes";
+import type {
+ ExcalidrawDiamondElement,
+ ExcalidrawRectanguloidElement,
+} from "./types";
+
+/**
+ * Get the building components of a rectanguloid element in the form of
+ * line segments and curves.
+ *
+ * @param element Target rectanguloid element
+ * @param offset Optional offset to expand the rectanguloid shape
+ * @returns Tuple of line segments (0) and curves (1)
+ */
+export function deconstructRectanguloidElement(
+ element: ExcalidrawRectanguloidElement,
+ offset: number = 0,
+): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
+ const roundness = getCornerRadius(
+ Math.min(element.width, element.height),
+ element,
+ );
+
+ if (roundness <= 0) {
+ const r = rectangle(
+ pointFrom(element.x - offset, element.y - offset),
+ pointFrom(
+ element.x + element.width + offset,
+ element.y + element.height + offset,
+ ),
+ );
+
+ const top = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
+ pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
+ );
+ const right = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
+ pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
+ );
+ const bottom = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
+ pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
+ );
+ const left = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
+ pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
+ );
+ const sides = [top, right, bottom, left];
+
+ return [sides, []];
+ }
+
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ const r = rectangle(
+ pointFrom(element.x, element.y),
+ pointFrom(element.x + element.width, element.y + element.height),
+ );
+
+ const top = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
+ pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
+ );
+ const right = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
+ pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
+ );
+ const bottom = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
+ pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
+ );
+ const left = lineSegment<GlobalPoint>(
+ pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
+ pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
+ );
+
+ const offsets = [
+ vectorScale(
+ vectorNormalize(
+ vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
+ ),
+ offset,
+ ), // TOP LEFT
+ vectorScale(
+ vectorNormalize(
+ vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
+ ),
+ offset,
+ ), //TOP RIGHT
+ vectorScale(
+ vectorNormalize(
+ vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
+ ),
+ offset,
+ ), // BOTTOM RIGHT
+ vectorScale(
+ vectorNormalize(
+ vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
+ ),
+ offset,
+ ), // BOTTOM LEFT
+ ];
+
+ const corners = [
+ curve(
+ pointFromVector(offsets[0], left[1]),
+ pointFromVector(
+ offsets[0],
+ pointFrom<GlobalPoint>(
+ left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
+ left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
+ ),
+ ),
+ pointFromVector(
+ offsets[0],
+ pointFrom<GlobalPoint>(
+ top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
+ top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
+ ),
+ ),
+ pointFromVector(offsets[0], top[0]),
+ ), // TOP LEFT
+ curve(
+ pointFromVector(offsets[1], top[1]),
+ pointFromVector(
+ offsets[1],
+ pointFrom<GlobalPoint>(
+ top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
+ top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
+ ),
+ ),
+ pointFromVector(
+ offsets[1],
+ pointFrom<GlobalPoint>(
+ right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
+ right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
+ ),
+ ),
+ pointFromVector(offsets[1], right[0]),
+ ), // TOP RIGHT
+ curve(
+ pointFromVector(offsets[2], right[1]),
+ pointFromVector(
+ offsets[2],
+ pointFrom<GlobalPoint>(
+ right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
+ right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
+ ),
+ ),
+ pointFromVector(
+ offsets[2],
+ pointFrom<GlobalPoint>(
+ bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
+ bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
+ ),
+ ),
+ pointFromVector(offsets[2], bottom[1]),
+ ), // BOTTOM RIGHT
+ curve(
+ pointFromVector(offsets[3], bottom[0]),
+ pointFromVector(
+ offsets[3],
+ pointFrom<GlobalPoint>(
+ bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
+ bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
+ ),
+ ),
+ pointFromVector(
+ offsets[3],
+ pointFrom<GlobalPoint>(
+ left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
+ left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
+ ),
+ ),
+ pointFromVector(offsets[3], left[0]),
+ ), // BOTTOM LEFT
+ ];
+
+ const sides = [
+ lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
+ lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
+ lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
+ lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
+ ];
+
+ return [sides, corners];
+}
+
+/**
+ * Get the building components of a diamond element in the form of
+ * line segments and curves as a tuple, in this order.
+ *
+ * @param element The element to deconstruct
+ * @param offset An optional offset
+ * @returns Tuple of line segments (0) and curves (1)
+ */
+export function deconstructDiamondElement(
+ element: ExcalidrawDiamondElement,
+ offset: number = 0,
+): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
+ const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
+ getDiamondPoints(element);
+ const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
+ const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
+
+ if (element.roundness?.type == null) {
+ const [top, right, bottom, left]: GlobalPoint[] = [
+ pointFrom(element.x + topX, element.y + topY - offset),
+ pointFrom(element.x + rightX + offset, element.y + rightY),
+ pointFrom(element.x + bottomX, element.y + bottomY + offset),
+ pointFrom(element.x + leftX - offset, element.y + leftY),
+ ];
+
+ // Create the line segment parts of the diamond
+ // NOTE: Horizontal and vertical seems to be flipped here
+ const topRight = lineSegment<GlobalPoint>(
+ pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
+ pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
+ );
+ const bottomRight = lineSegment<GlobalPoint>(
+ pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
+ pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
+ );
+ const bottomLeft = lineSegment<GlobalPoint>(
+ pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
+ pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
+ );
+ const topLeft = lineSegment<GlobalPoint>(
+ pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
+ pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
+ );
+
+ return [[topRight, bottomRight, bottomLeft, topLeft], []];
+ }
+
+ const center = pointFrom<GlobalPoint>(
+ element.x + element.width / 2,
+ element.y + element.height / 2,
+ );
+
+ const [top, right, bottom, left]: GlobalPoint[] = [
+ pointFrom(element.x + topX, element.y + topY),
+ pointFrom(element.x + rightX, element.y + rightY),
+ pointFrom(element.x + bottomX, element.y + bottomY),
+ pointFrom(element.x + leftX, element.y + leftY),
+ ];
+
+ const offsets = [
+ vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
+ vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
+ vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
+ vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
+ ];
+
+ const corners = [
+ curve(
+ pointFromVector(
+ offsets[0],
+ pointFrom<GlobalPoint>(
+ right[0] - verticalRadius,
+ right[1] - horizontalRadius,
+ ),
+ ),
+ pointFromVector(offsets[0], right),
+ pointFromVector(offsets[0], right),
+ pointFromVector(
+ offsets[0],
+ pointFrom<GlobalPoint>(
+ right[0] - verticalRadius,
+ right[1] + horizontalRadius,
+ ),
+ ),
+ ), // RIGHT
+ curve(
+ pointFromVector(
+ offsets[1],
+ pointFrom<GlobalPoint>(
+ bottom[0] + verticalRadius,
+ bottom[1] - horizontalRadius,
+ ),
+ ),
+ pointFromVector(offsets[1], bottom),
+ pointFromVector(offsets[1], bottom),
+ pointFromVector(
+ offsets[1],
+ pointFrom<GlobalPoint>(
+ bottom[0] - verticalRadius,
+ bottom[1] - horizontalRadius,
+ ),
+ ),
+ ), // BOTTOM
+ curve(
+ pointFromVector(
+ offsets[2],
+ pointFrom<GlobalPoint>(
+ left[0] + verticalRadius,
+ left[1] + horizontalRadius,
+ ),
+ ),
+ pointFromVector(offsets[2], left),
+ pointFromVector(offsets[2], left),
+ pointFromVector(
+ offsets[2],
+ pointFrom<GlobalPoint>(
+ left[0] + verticalRadius,
+ left[1] - horizontalRadius,
+ ),
+ ),
+ ), // LEFT
+ curve(
+ pointFromVector(
+ offsets[3],
+ pointFrom<GlobalPoint>(
+ top[0] - verticalRadius,
+ top[1] + horizontalRadius,
+ ),
+ ),
+ pointFromVector(offsets[3], top),
+ pointFromVector(offsets[3], top),
+ pointFromVector(
+ offsets[3],
+ pointFrom<GlobalPoint>(
+ top[0] + verticalRadius,
+ top[1] + horizontalRadius,
+ ),
+ ),
+ ), // TOP
+ ];
+
+ const sides = [
+ lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
+ lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
+ lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
+ lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
+ ];
+
+ return [sides, corners];
+}