(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 = (
+ 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;
+
+const embeddedLinkCache = new Map();
+
+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 =
+ /^`,
+ ),
+ 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(
+ `
`,
+ ),
+ 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(`
+
+
+ `),
+ 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 | 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();
+ 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;
+
+ const heading = headingForPointFromElement(node, aabbForElement(node), [
+ edgePoint[0] + el.x,
+ edgePoint[1] + el.y,
+ ] as Readonly);
+
+ 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();
+ 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(
+ new Map([
+ ...elementsMap.entries(),
+ [startBindingElement.id, startBindingElement],
+ [endBindingElement.id, endBindingElement],
+ [bindingArrow.id, bindingArrow],
+ ] as [string, Ordered][]),
+ ),
+ { 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 = 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 = (
+ 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: P,
+ o: P,
+) => vectorToHeading(vectorFromPoint
(p, o));
+
+export const headingForPointIsHorizontal =
(
+ p: P,
+ o: P,
+) => headingIsHorizontal(headingForPoint
(p, o));
+
+export const compareHeading = (a: Heading, b: Heading) =>
+ a[0] === b[0] && a[1] === b[1];
+
+export const headingIsHorizontal = (a: Heading) =>
+ compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
+
+export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
+
+// 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,
+ aabb: Readonly,
+ p: Readonly,
+): 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([top, right, midPoint] as Triangle, p)
+ ) {
+ return headingForDiamond(top, right);
+ } else if (
+ triangleIncludesPoint(
+ [right, bottom, midPoint] as Triangle,
+ p,
+ )
+ ) {
+ return headingForDiamond(right, bottom);
+ } else if (
+ triangleIncludesPoint(
+ [bottom, left, midPoint] as Triangle,
+ 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(
+ [topLeft, topRight, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_UP
+ : triangleIncludesPoint(
+ [topRight, bottomRight, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_RIGHT
+ : triangleIncludesPoint(
+ [bottomRight, bottomLeft, midPoint] as Triangle,
+ p,
+ )
+ ? HEADING_DOWN
+ : HEADING_LEFT;
+};
+
+export const flipHeading = (h: Heading): Heading =>
+ [
+ h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
+ h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
+ ] as Heading;
diff --git a/packages/excalidraw/element/image.ts b/packages/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((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();
+ const erroredFiles = new Map();
+
+ 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[]),
+ );
+
+ 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 = (
+ elements: readonly T[],
+) =>
+ elements.filter((element) => !element.isDeleted) as readonly NonDeleted[];
+
+export const isNonDeletedElement = (
+ element: T,
+): element is NonDeleted => !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) {
+ 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(
+ id: InstanceType["elementId"],
+ elementsMap: ElementsMap,
+ ): T | null {
+ const element = elementsMap.get(id);
+ if (element) {
+ return element as NonDeleted;
+ }
+ return null;
+ }
+
+ static handleBoxSelection(
+ event: PointerEvent,
+ appState: AppState,
+ setState: React.Component["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,
+ 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,
+ "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,
+ 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,
+ 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(
+ element: NonDeleted,
+ 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,
+ 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,
+ app: AppClassProperties,
+ store: Store,
+ scenePointer: { x: number; y: number },
+ linearElementEditor: LinearElementEditor,
+ scene: Scene,
+ ): {
+ didAddPoint: boolean;
+ hitElement: NonDeleted | null;
+ linearElementEditor: LinearElementEditor | null;
+ } {
+ const appState = app.state;
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+
+ const ret: ReturnType = {
+ didAddPoint: false,
+ hitElement: null,
+ 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(
+ 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,
+ 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,
+ 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,
+ 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,
+
+ 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,
+ 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,
+ 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,
+ 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) {
+ 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,
+ 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,
+ 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,
+ 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(0, 0);
+ const [offsetX, offsetY] = pointFrom(
+ 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(
+ 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,
+ 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,
+ 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,
+ );
+ fixedSegments[index] = {
+ index,
+ start: pointFrom