Browse Source

feat: create flowcharts from a generic element using elbow arrows (#8329)

Co-authored-by: Mark Tolmacs <[email protected]>
Co-authored-by: dwelle <[email protected]>
Ryan Di 1 year ago
parent
commit
54491d13d4

+ 7 - 5
packages/excalidraw/actions/actionHistory.tsx

@@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
 import { t } from "../i18n";
 import type { History } from "../history";
 import { HistoryChangedEvent } from "../history";
-import type { AppState } from "../types";
+import type { AppClassProperties, AppState } from "../types";
 import { KEYS } from "../keys";
 import { arrayToMap } from "../utils";
 import { isWindows } from "../constants";
@@ -13,7 +13,8 @@ import type { Store } from "../store";
 import { StoreAction } from "../store";
 import { useEmitter } from "../hooks/useEmitter";
 
-const writeData = (
+const executeHistoryAction = (
+  app: AppClassProperties,
   appState: Readonly<AppState>,
   updater: () => [SceneElementsMap, AppState] | void,
 ): ActionResult => {
@@ -23,7 +24,8 @@ const writeData = (
     !appState.editingElement &&
     !appState.newElement &&
     !appState.selectedElementsAreBeingDragged &&
-    !appState.selectionElement
+    !appState.selectionElement &&
+    !app.flowChartCreator.isCreatingChart
   ) {
     const result = updater();
 
@@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
   trackEvent: { category: "history" },
   viewMode: false,
   perform: (elements, appState, value, app) =>
-    writeData(appState, () =>
+    executeHistoryAction(app, appState, () =>
       history.undo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,
@@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
   trackEvent: { category: "history" },
   viewMode: false,
   perform: (elements, appState, _, app) =>
-    writeData(appState, () =>
+    executeHistoryAction(app, appState, () =>
       history.redo(
         arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
         appState,

+ 152 - 2
packages/excalidraw/components/App.tsx

@@ -162,6 +162,7 @@ import {
   isMagicFrameElement,
   isTextBindableContainer,
   isElbowArrow,
+  isFlowchartNodeElement,
 } from "../element/typeChecks";
 import type {
   ExcalidrawBindableElement,
@@ -206,7 +207,10 @@ import {
   isArrowKey,
   KEYS,
 } from "../keys";
-import { isElementInViewport } from "../element/sizeHelpers";
+import {
+  isElementCompletelyInViewport,
+  isElementInViewport,
+} from "../element/sizeHelpers";
 import {
   distance2d,
   getCornerRadius,
@@ -430,6 +434,11 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
 import { getVisibleSceneBounds } from "../element/bounds";
 import { isMaybeMermaidDefinition } from "../mermaid";
 import { mutateElbowArrow } from "../element/routing";
+import {
+  FlowChartCreator,
+  FlowChartNavigator,
+  getLinkDirectionFromKey,
+} from "../element/flowchart";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -564,6 +573,9 @@ class App extends React.Component<AppProps, AppState> {
 
   private elementsPendingErasure: ElementsPendingErasure = new Set();
 
+  public flowChartCreator: FlowChartCreator = new FlowChartCreator();
+  private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
+
   hitLinkElement?: NonDeletedExcalidrawElement;
   lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
   lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
@@ -1154,6 +1166,7 @@ class App extends React.Component<AppProps, AppState> {
                   el,
                   getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
                   this.elementsPendingErasure,
+                  null,
                 ),
                 ["--embeddable-radius" as string]: `${getCornerRadius(
                   Math.min(el.width, el.height),
@@ -1675,6 +1688,8 @@ class App extends React.Component<AppProps, AppState> {
                               this.state.viewBackgroundColor,
                             embedsValidationStatus: this.embedsValidationStatus,
                             elementsPendingErasure: this.elementsPendingErasure,
+                            pendingFlowchartNodes:
+                              this.flowChartCreator.pendingNodes,
                           }}
                         />
                         <InteractiveCanvas
@@ -3872,6 +3887,90 @@ class App extends React.Component<AppProps, AppState> {
         });
       }
 
+      if (event.key === KEYS.ESCAPE && this.flowChartCreator.isCreatingChart) {
+        this.flowChartCreator.clear();
+        this.triggerRender(true);
+        return;
+      }
+
+      const arrowKeyPressed = isArrowKey(event.key);
+
+      if (event[KEYS.CTRL_OR_CMD] && arrowKeyPressed && !event.shiftKey) {
+        event.preventDefault();
+
+        const selectedElements = getSelectedElements(
+          this.scene.getNonDeletedElementsMap(),
+          this.state,
+        );
+
+        if (
+          selectedElements.length === 1 &&
+          isFlowchartNodeElement(selectedElements[0])
+        ) {
+          this.flowChartCreator.createNodes(
+            selectedElements[0],
+            this.scene.getNonDeletedElementsMap(),
+            this.state,
+            getLinkDirectionFromKey(event.key),
+          );
+        }
+
+        return;
+      }
+
+      if (event.altKey) {
+        const selectedElements = getSelectedElements(
+          this.scene.getNonDeletedElementsMap(),
+          this.state,
+        );
+
+        if (selectedElements.length === 1 && arrowKeyPressed) {
+          event.preventDefault();
+
+          const nextId = this.flowChartNavigator.exploreByDirection(
+            selectedElements[0],
+            this.scene.getNonDeletedElementsMap(),
+            getLinkDirectionFromKey(event.key),
+          );
+
+          if (nextId) {
+            this.setState((prevState) => ({
+              selectedElementIds: makeNextSelectedElementIds(
+                {
+                  [nextId]: true,
+                },
+                prevState,
+              ),
+            }));
+
+            const nextNode = this.scene.getNonDeletedElementsMap().get(nextId);
+
+            if (
+              nextNode &&
+              !isElementCompletelyInViewport(
+                nextNode,
+                this.canvas.width / window.devicePixelRatio,
+                this.canvas.height / window.devicePixelRatio,
+                {
+                  offsetLeft: this.state.offsetLeft,
+                  offsetTop: this.state.offsetTop,
+                  scrollX: this.state.scrollX,
+                  scrollY: this.state.scrollY,
+                  zoom: this.state.zoom,
+                },
+                this.scene.getNonDeletedElementsMap(),
+              )
+            ) {
+              this.scrollToContent(nextNode, {
+                animate: true,
+                duration: 300,
+              });
+            }
+          }
+          return;
+        }
+      }
+
       if (
         event[KEYS.CTRL_OR_CMD] &&
         event.key === KEYS.P &&
@@ -4238,6 +4337,58 @@ class App extends React.Component<AppProps, AppState> {
       );
       this.setState({ suggestedBindings: [] });
     }
+
+    if (!event.altKey) {
+      if (this.flowChartNavigator.isExploring) {
+        this.flowChartNavigator.clear();
+        this.syncActionResult({ storeAction: StoreAction.CAPTURE });
+      }
+    }
+
+    if (!event[KEYS.CTRL_OR_CMD]) {
+      if (this.flowChartCreator.isCreatingChart) {
+        if (this.flowChartCreator.pendingNodes?.length) {
+          this.scene.insertElements(this.flowChartCreator.pendingNodes);
+        }
+
+        const firstNode = this.flowChartCreator.pendingNodes?.[0];
+
+        if (firstNode) {
+          this.setState((prevState) => ({
+            selectedElementIds: makeNextSelectedElementIds(
+              {
+                [firstNode.id]: true,
+              },
+              prevState,
+            ),
+          }));
+
+          if (
+            !isElementCompletelyInViewport(
+              firstNode,
+              this.canvas.width / window.devicePixelRatio,
+              this.canvas.height / window.devicePixelRatio,
+              {
+                offsetLeft: this.state.offsetLeft,
+                offsetTop: this.state.offsetTop,
+                scrollX: this.state.scrollX,
+                scrollY: this.state.scrollY,
+                zoom: this.state.zoom,
+              },
+              this.scene.getNonDeletedElementsMap(),
+            )
+          ) {
+            this.scrollToContent(firstNode, {
+              animate: true,
+              duration: 300,
+            });
+          }
+        }
+
+        this.flowChartCreator.clear();
+        this.syncActionResult({ storeAction: StoreAction.CAPTURE });
+      }
+    }
   });
 
   // We purposely widen the `tool` type so this helper can be called with
@@ -7122,7 +7273,6 @@ class App extends React.Component<AppProps, AppState> {
               locked: false,
               frameId: topLayerFrame ? topLayerFrame.id : null,
             });
-
       this.setState((prevState) => {
         const nextSelectedElementIds = {
           ...prevState.selectedElementIds,

+ 10 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
             className="HelpDialog__island--editor"
             caption={t("helpDialog.editor")}
           >
+            <Shortcut
+              label={t("helpDialog.createFlowchart")}
+              shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
+              isOr={true}
+            />
+            <Shortcut
+              label={t("helpDialog.navigateFlowchart")}
+              shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
+              isOr={true}
+            />
             <Shortcut
               label={t("labels.moveCanvas")}
               shortcuts={[

+ 1 - 0
packages/excalidraw/components/HintViewer.scss

@@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
     box-sizing: border-box;
     position: absolute;
     display: flex;
+    flex-direction: column;
     justify-content: center;
     left: 0;
     top: 100%;

+ 31 - 4
packages/excalidraw/components/HintViewer.tsx

@@ -1,6 +1,7 @@
 import { t } from "../i18n";
 import type { AppClassProperties, Device, UIAppState } from "../types";
 import {
+  isFlowchartNodeElement,
   isImageElement,
   isLinearElement,
   isTextBindableContainer,
@@ -10,6 +11,7 @@ import { getShortcutKey } from "../utils";
 import { isEraserActive } from "../appState";
 
 import "./HintViewer.scss";
+import { isNodeInFlowchart } from "../element/flowchart";
 
 interface HintViewerProps {
   appState: UIAppState;
@@ -18,7 +20,12 @@ interface HintViewerProps {
   app: AppClassProperties;
 }
 
-const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
+const getHints = ({
+  appState,
+  isMobile,
+  device,
+  app,
+}: HintViewerProps): null | string | string[] => {
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
@@ -115,6 +122,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
         !appState.selectedElementsAreBeingDragged &&
         isTextBindableContainer(selectedElements[0])
       ) {
+        if (isFlowchartNodeElement(selectedElements[0])) {
+          if (
+            isNodeInFlowchart(
+              selectedElements[0],
+              app.scene.getNonDeletedElementsMap(),
+            )
+          ) {
+            return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
+          }
+
+          return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
+        }
+
         return t("hints.bindTextToElement");
       }
     }
@@ -129,17 +149,24 @@ export const HintViewer = ({
   device,
   app,
 }: HintViewerProps) => {
-  let hint = getHints({
+  const hints = getHints({
     appState,
     isMobile,
     device,
     app,
   });
-  if (!hint) {
+
+  if (!hints) {
     return null;
   }
 
-  hint = getShortcutKey(hint);
+  const hint = Array.isArray(hints)
+    ? hints
+        .map((hint) => {
+          return getShortcutKey(hint).replace(/\. ?$/, "");
+        })
+        .join(". ")
+    : getShortcutKey(hints);
 
   return (
     <div className="HintViewer">

+ 2 - 1
packages/excalidraw/data/restore.ts

@@ -51,6 +51,7 @@ import { normalizeLink } from "./url";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getSizeFromPoints } from "../points";
 import { getLineHeight } from "../fonts";
+import { normalizeFixedPoint } from "../element/binding";
 
 type RestoredAppState = Omit<
   AppState,
@@ -106,7 +107,7 @@ const repairBinding = (
     ...binding,
     focus: binding.focus || 0,
     fixedPoint: isElbowArrow(element)
-      ? binding.fixedPoint ?? ([0, 0] as [number, number])
+      ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
       : null,
   };
 };

+ 18 - 4
packages/excalidraw/element/binding.ts

@@ -1000,7 +1000,7 @@ const updateBoundPoint = (
 
   if (isElbowArrow(linearElement)) {
     const fixedPoint =
-      binding.fixedPoint ??
+      normalizeFixedPoint(binding.fixedPoint) ??
       calculateFixedPointForElbowArrowBinding(
         linearElement,
         bindableElement,
@@ -1113,12 +1113,12 @@ export const calculateFixedPointForElbowArrowBinding = (
   ) as Point;
 
   return {
-    fixedPoint: [
+    fixedPoint: normalizeFixedPoint([
       (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
         hoveredElement.width,
       (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
         hoveredElement.height,
-    ] as [number, number],
+    ]),
   };
 };
 
@@ -2171,7 +2171,8 @@ export const getGlobalFixedPointForBindableElement = (
   fixedPointRatio: [number, number],
   element: ExcalidrawBindableElement,
 ) => {
-  const [fixedX, fixedY] = fixedPointRatio;
+  const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
+
   return rotatePoint(
     [element.x + element.width * fixedX, element.y + element.height * fixedY],
     getCenterForElement(element),
@@ -2225,3 +2226,16 @@ export const getArrowLocalFixedPoints = (
     LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
   ];
 };
+
+export const normalizeFixedPoint = <T extends FixedPoint | null>(
+  fixedPoint: T,
+): T extends null ? null : FixedPoint => {
+  // Do not allow a precise 0.5 for fixed point ratio
+  // to avoid jumping arrow heading due to floating point imprecision
+  if (fixedPoint && (fixedPoint[0] === 0.5 || fixedPoint[1] === 0.5)) {
+    return fixedPoint.map((ratio) =>
+      ratio === 0.5 ? 0.5001 : ratio,
+    ) as T extends null ? null : FixedPoint;
+  }
+  return fixedPoint as any as T extends null ? null : FixedPoint;
+};

+ 404 - 0
packages/excalidraw/element/flowchart.test.tsx

@@ -0,0 +1,404 @@
+import ReactDOM from "react-dom";
+import { render } 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";
+
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+
+beforeEach(async () => {
+  localStorage.clear();
+  reseed(7);
+  mouse.reset();
+
+  await render(<Excalidraw handleKeyboardGlobally={true} />);
+  h.state.width = 1000;
+  h.state.height = 1000;
+
+  // The bounds of hand-drawn linear elements may change after flipping, so
+  // removing this style for testing
+  UI.clickTool("arrow");
+  UI.clickByTitle("Architect");
+  UI.clickTool("selection");
+});
+
+describe("flow chart creation", () => {
+  beforeEach(() => {
+    API.clearSelection();
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 200,
+      height: 100,
+    });
+
+    h.elements = [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,
+    });
+
+    h.elements = [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,
+    });
+
+    h.elements = [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,
+    });
+
+    h.elements = [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,
+    );
+  });
+});

+ 698 - 0
packages/excalidraw/element/flowchart.ts

@@ -0,0 +1,698 @@
+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 { aabbForElement } from "../math";
+import type {
+  ElementsMap,
+  ExcalidrawBindableElement,
+  ExcalidrawElement,
+  ExcalidrawFlowchartNodeElement,
+  NonDeletedSceneElementsMap,
+  OrderedExcalidrawElement,
+} from "./types";
+import { KEYS } from "../keys";
+import type { AppState, PendingExcalidrawElements, Point } from "../types";
+import { mutateElement } from "./mutateElement";
+import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
+import {
+  isBindableElement,
+  isElbowArrow,
+  isFrameElement,
+  isFlowchartNodeElement,
+} from "./typeChecks";
+import { invariant } from "../utils";
+
+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: Point =
+          type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
+
+        const heading = headingForPointFromElement(node, aabbForElement(node), [
+          edgePoint[0] + el.x,
+          edgePoint[1] + el.y,
+        ]);
+
+        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,
+  });
+
+  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,
+    });
+
+    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: appState.currentItemStartArrowhead,
+    endArrowhead: appState.currentItemEndArrowhead,
+    strokeColor: appState.currentItemStrokeColor,
+    strokeStyle: appState.currentItemStrokeStyle,
+    strokeWidth: appState.currentItemStrokeWidth,
+    points: [
+      [0, 0],
+      [endX, endY],
+    ],
+    elbowed: true,
+  });
+
+  bindLinearElement(
+    bindingArrow,
+    startBindingElement,
+    "start",
+    elementsMap as NonDeletedSceneElementsMap,
+  );
+  bindLinearElement(
+    bindingArrow,
+    endBindingElement,
+    "end",
+    elementsMap as NonDeletedSceneElementsMap,
+  );
+
+  const changedElements = new Map<string, OrderedExcalidrawElement>();
+  changedElements.set(
+    startBindingElement.id,
+    startBindingElement as OrderedExcalidrawElement,
+  );
+  changedElements.set(
+    endBindingElement.id,
+    endBindingElement as OrderedExcalidrawElement,
+  );
+  changedElements.set(
+    bindingArrow.id,
+    bindingArrow as OrderedExcalidrawElement,
+  );
+
+  LinearElementEditor.movePoints(
+    bindingArrow,
+    [
+      {
+        index: 1,
+        point: bindingArrow.points[1],
+      },
+    ],
+    elementsMap as NonDeletedSceneElementsMap,
+    undefined,
+    {
+      changedElements,
+    },
+  );
+
+  return bindingArrow;
+};
+
+export class FlowChartNavigator {
+  isExploring: boolean = false;
+  // nodes that are ONE link away (successor and predecessor both included)
+  private sameLevelNodes: ExcalidrawElement[] = [];
+  private sameLevelIndex: number = 0;
+  // set it to the opposite of the defalut creation direction
+  private direction: LinkDirection | null = null;
+  // for speedier navigation
+  private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
+
+  clear() {
+    this.isExploring = false;
+    this.sameLevelNodes = [];
+    this.sameLevelIndex = 0;
+    this.direction = null;
+    this.visitedNodes.clear();
+  }
+
+  exploreByDirection(
+    element: ExcalidrawElement,
+    elementsMap: ElementsMap,
+    direction: LinkDirection,
+  ): ExcalidrawElement["id"] | null {
+    if (!isBindableElement(element)) {
+      return null;
+    }
+
+    // clear if going at a different direction
+    if (direction !== this.direction) {
+      this.clear();
+    }
+
+    // add the current node to the visited
+    if (!this.visitedNodes.has(element.id)) {
+      this.visitedNodes.add(element.id);
+    }
+
+    /**
+     * CASE:
+     * - already started exploring, AND
+     * - there are multiple nodes at the same level, AND
+     * - still going at the same direction, AND
+     *
+     * RESULT:
+     * - loop through nodes at the same level
+     *
+     * WHY:
+     * - provides user the capability to loop through nodes at the same level
+     */
+    if (
+      this.isExploring &&
+      direction === this.direction &&
+      this.sameLevelNodes.length > 1
+    ) {
+      this.sameLevelIndex =
+        (this.sameLevelIndex + 1) % this.sameLevelNodes.length;
+
+      return this.sameLevelNodes[this.sameLevelIndex].id;
+    }
+
+    const nodes = [
+      ...getSuccessors(element, elementsMap, direction),
+      ...getPredecessors(element, elementsMap, direction),
+    ];
+
+    /**
+     * CASE:
+     * - just started exploring at the given direction
+     *
+     * RESULT:
+     * - go to the first node in the given direction
+     */
+    if (nodes.length > 0) {
+      this.sameLevelIndex = 0;
+      this.isExploring = true;
+      this.sameLevelNodes = nodes;
+      this.direction = direction;
+      this.visitedNodes.add(nodes[0].id);
+
+      return nodes[0].id;
+    }
+
+    /**
+     * CASE:
+     * - (just started exploring or still going at the same direction) OR
+     * - there're no nodes at the given direction
+     *
+     * RESULT:
+     * - go to some other unvisited linked node
+     *
+     * WHY:
+     * - provide a speedier navigation from a given node to some predecessor
+     *   without the user having to change arrow key
+     */
+    if (direction === this.direction || !this.isExploring) {
+      if (!this.isExploring) {
+        // just started and no other nodes at the given direction
+        // so the current node is technically the first visited node
+        // (this is needed so that we don't get stuck between looping through )
+        this.visitedNodes.add(element.id);
+      }
+
+      const otherDirections: LinkDirection[] = [
+        "up",
+        "right",
+        "down",
+        "left",
+      ].filter((dir): dir is LinkDirection => dir !== direction);
+
+      const otherLinkedNodes = otherDirections
+        .map((dir) => [
+          ...getSuccessors(element, elementsMap, dir),
+          ...getPredecessors(element, elementsMap, dir),
+        ])
+        .flat()
+        .filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
+
+      for (const linkedNode of otherLinkedNodes) {
+        if (!this.visitedNodes.has(linkedNode.id)) {
+          this.visitedNodes.add(linkedNode.id);
+          this.isExploring = true;
+          this.direction = direction;
+          return linkedNode.id;
+        }
+      }
+    }
+
+    return null;
+  }
+}
+
+export class FlowChartCreator {
+  isCreatingChart: boolean = false;
+  private numberOfNodes: number = 0;
+  private direction: LinkDirection | null = "right";
+  pendingNodes: PendingExcalidrawElements | null = null;
+
+  createNodes(
+    startNode: ExcalidrawFlowchartNodeElement,
+    elementsMap: ElementsMap,
+    appState: AppState,
+    direction: LinkDirection,
+  ) {
+    if (direction !== this.direction) {
+      const { nextNode, bindingArrow } = addNewNode(
+        startNode,
+        elementsMap,
+        appState,
+        direction,
+      );
+
+      this.numberOfNodes = 1;
+      this.isCreatingChart = true;
+      this.direction = direction;
+      this.pendingNodes = [nextNode, bindingArrow];
+    } else {
+      this.numberOfNodes += 1;
+      const newNodes = addNewNodes(
+        startNode,
+        elementsMap,
+        appState,
+        direction,
+        this.numberOfNodes,
+      );
+
+      this.isCreatingChart = true;
+      this.direction = direction;
+      this.pendingNodes = newNodes;
+    }
+
+    // add pending nodes to the same frame as the start node
+    // if every pending node is at least intersecting with the frame
+    if (startNode.frameId) {
+      const frame = elementsMap.get(startNode.frameId);
+
+      invariant(
+        frame && isFrameElement(frame),
+        "not an ExcalidrawFrameElement",
+      );
+
+      if (
+        frame &&
+        this.pendingNodes.every(
+          (node) =>
+            elementsAreInFrameBounds([node], frame, elementsMap) ||
+            elementOverlapsWithFrame(node, frame, elementsMap),
+        )
+      ) {
+        this.pendingNodes = this.pendingNodes.map((node) =>
+          mutateElement(
+            node,
+            {
+              frameId: startNode.frameId,
+            },
+            false,
+          ),
+        );
+      }
+    }
+  }
+
+  clear() {
+    this.isCreatingChart = false;
+    this.pendingNodes = null;
+    this.direction = null;
+    this.numberOfNodes = 0;
+  }
+}
+
+export const isNodeInFlowchart = (
+  element: ExcalidrawFlowchartNodeElement,
+  elementsMap: ElementsMap,
+) => {
+  for (const [, el] of elementsMap) {
+    if (
+      el.type === "arrow" &&
+      (el.startBinding?.elementId === element.id ||
+        el.endBinding?.elementId === element.id)
+    ) {
+      return true;
+    }
+  }
+
+  return false;
+};

+ 10 - 2
packages/excalidraw/element/linearElementEditor.ts

@@ -44,7 +44,7 @@ import {
   getHoveredElementForBinding,
   isBindingEnabled,
 } from "./binding";
-import { tupleToCoors } from "../utils";
+import { toBrandedType, tupleToCoors } from "../utils";
 import {
   isBindingElement,
   isElbowArrow,
@@ -1447,9 +1447,17 @@ export class LinearElementEditor {
             : null;
       }
 
+      console.warn("movePoints", options?.changedElements);
+
+      const mergedElementsMap = options?.changedElements
+        ? toBrandedType<SceneElementsMap>(
+            new Map([...elementsMap, ...options.changedElements]),
+          )
+        : elementsMap;
+
       mutateElbowArrow(
         element,
-        elementsMap,
+        mergedElementsMap,
         nextPoints,
         [offsetX, offsetY],
         bindings,

+ 37 - 0
packages/excalidraw/element/sizeHelpers.ts

@@ -55,6 +55,43 @@ export const isElementInViewport = (
   );
 };
 
+export const isElementCompletelyInViewport = (
+  element: ExcalidrawElement,
+  width: number,
+  height: number,
+  viewTransformations: {
+    zoom: Zoom;
+    offsetLeft: number;
+    offsetTop: number;
+    scrollX: number;
+    scrollY: number;
+  },
+  elementsMap: ElementsMap,
+) => {
+  const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
+  const topLeftSceneCoords = viewportCoordsToSceneCoords(
+    {
+      clientX: viewTransformations.offsetLeft,
+      clientY: viewTransformations.offsetTop,
+    },
+    viewTransformations,
+  );
+  const bottomRightSceneCoords = viewportCoordsToSceneCoords(
+    {
+      clientX: viewTransformations.offsetLeft + width,
+      clientY: viewTransformations.offsetTop + height,
+    },
+    viewTransformations,
+  );
+
+  return (
+    x1 >= topLeftSceneCoords.x &&
+    y1 >= topLeftSceneCoords.y &&
+    x2 <= bottomRightSceneCoords.x &&
+    y2 <= bottomRightSceneCoords.y
+  );
+};
+
 /**
  * Makes a perfect shape or diagonal/horizontal/vertical line
  */

+ 11 - 0
packages/excalidraw/element/typeChecks.ts

@@ -24,6 +24,7 @@ import type {
   ExcalidrawElbowArrowElement,
   PointBinding,
   FixedPointBinding,
+  ExcalidrawFlowchartNodeElement,
 } from "./types";
 
 export const isInitializedImageElement = (
@@ -219,6 +220,16 @@ export const isExcalidrawElement = (
   }
 };
 
+export const isFlowchartNodeElement = (
+  element: ExcalidrawElement,
+): element is ExcalidrawFlowchartNodeElement => {
+  return (
+    element.type === "rectangle" ||
+    element.type === "ellipse" ||
+    element.type === "diamond"
+  );
+};
+
 export const hasBoundTextElement = (
   element: ExcalidrawElement | null,
 ): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {

+ 5 - 0
packages/excalidraw/element/types.ts

@@ -160,6 +160,11 @@ export type ExcalidrawGenericElement =
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement;
 
+export type ExcalidrawFlowchartNodeElement =
+  | ExcalidrawRectangleElement
+  | ExcalidrawDiamondElement
+  | ExcalidrawEllipseElement;
+
 /**
  * ExcalidrawElement should be JSON serializable and (eventually) contain
  * no computed data. The list of all ExcalidrawElements should be shareable

+ 3 - 0
packages/excalidraw/locales/en.json

@@ -316,6 +316,7 @@
     "placeImage": "Click to place the image, or click and drag to set its size manually",
     "publishLibrary": "Publish your own library",
     "bindTextToElement": "Press enter to add text",
+    "createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
     "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
     "eraserRevert": "Hold Alt to revert the elements marked for deletion",
     "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
@@ -366,6 +367,8 @@
     "click": "click",
     "deepSelect": "Deep select",
     "deepBoxSelect": "Deep select within box, and prevent dragging",
+    "createFlowchart": "Create a flowchart from a generic element",
+    "navigateFlowchart": "Navigate a flowchart",
     "curvedArrow": "Curved arrow",
     "curvedLine": "Curved line",
     "documentation": "Documentation",

+ 4 - 0
packages/excalidraw/renderer/renderElement.ts

@@ -35,6 +35,7 @@ import type {
   Zoom,
   InteractiveCanvasAppState,
   ElementsPendingErasure,
+  PendingExcalidrawElements,
 } from "../types";
 import { getDefaultAppState } from "../appState";
 import {
@@ -104,6 +105,7 @@ export const getRenderOpacity = (
   element: ExcalidrawElement,
   containingFrame: ExcalidrawFrameLikeElement | null,
   elementsPendingErasure: ElementsPendingErasure,
+  pendingNodes: Readonly<PendingExcalidrawElements> | null,
 ) => {
   // multiplying frame opacity with element opacity to combine them
   // (e.g. frame 50% and element 50% opacity should result in 25% opacity)
@@ -113,6 +115,7 @@ export const getRenderOpacity = (
   // (so that erasing always results in lower opacity than original)
   if (
     elementsPendingErasure.has(element.id) ||
+    (pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
     (containingFrame && elementsPendingErasure.has(containingFrame.id))
   ) {
     opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
@@ -672,6 +675,7 @@ export const renderElement = (
     element,
     getContainingFrame(element, elementsMap),
     renderConfig.elementsPendingErasure,
+    renderConfig.pendingFlowchartNodes,
   );
 
   switch (element.type) {

+ 17 - 0
packages/excalidraw/renderer/staticScene.ts

@@ -370,6 +370,23 @@ const _renderStaticScene = ({
         console.error(error);
       }
     });
+
+  // render pending nodes for flowcharts
+  renderConfig.pendingFlowchartNodes?.forEach((element) => {
+    try {
+      renderElement(
+        element,
+        elementsMap,
+        allElementsMap,
+        rc,
+        context,
+        renderConfig,
+        appState,
+      );
+    } catch (error) {
+      console.error(error);
+    }
+  });
 };
 
 /** throttled to animation framerate */

+ 9 - 1
packages/excalidraw/scene/Scene.ts

@@ -377,6 +377,10 @@ class Scene {
   }
 
   insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
+    if (!elements.length) {
+      return;
+    }
+
     if (!Number.isFinite(index) || index < 0) {
       throw new Error(
         "insertElementAtIndex can only be called with index >= 0",
@@ -403,7 +407,11 @@ class Scene {
   };
 
   insertElements = (elements: ExcalidrawElement[]) => {
-    const index = elements[0].frameId
+    if (!elements.length) {
+      return;
+    }
+
+    const index = elements[0]?.frameId
       ? this.getElementIndex(elements[0].frameId)
       : this.elements.length;
 

+ 1 - 0
packages/excalidraw/scene/export.ts

@@ -242,6 +242,7 @@ export const exportToCanvas = async (
       // empty disables embeddable rendering
       embedsValidationStatus: new Map(),
       elementsPendingErasure: new Set(),
+      pendingFlowchartNodes: null,
     },
   });
 

+ 2 - 0
packages/excalidraw/scene/types.ts

@@ -16,6 +16,7 @@ import type {
   SocketId,
   UserIdleState,
   Device,
+  PendingExcalidrawElements,
 } from "../types";
 import type { MakeBrand } from "../utility-types";
 
@@ -33,6 +34,7 @@ export type StaticCanvasRenderConfig = {
   isExporting: boolean;
   embedsValidationStatus: EmbedsValidationStatus;
   elementsPendingErasure: ElementsPendingErasure;
+  pendingFlowchartNodes: PendingExcalidrawElements | null;
 };
 
 export type SVGRenderConfig = {

+ 3 - 0
packages/excalidraw/types.ts

@@ -648,6 +648,7 @@ export type AppClassProperties = {
   onMagicframeToolSelect: App["onMagicframeToolSelect"];
   getName: App["getName"];
   dismissLinearEditor: App["dismissLinearEditor"];
+  flowChartCreator: App["flowChartCreator"];
 };
 
 export type PointerDownState = Readonly<{
@@ -828,3 +829,5 @@ export type EmbedsValidationStatus = Map<
 >;
 
 export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
+
+export type PendingExcalidrawElements = ExcalidrawElement[];

+ 6 - 0
packages/excalidraw/utils.ts

@@ -929,6 +929,12 @@ export const assertNever = (
   throw new Error(message);
 };
 
+export function invariant(condition: any, message: string): asserts condition {
+  if (!condition) {
+    throw new Error(message);
+  }
+}
+
 /**
  * Memoizes on values of `opts` object (strict equality).
  */