aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/element/flowchart.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/element/flowchart.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/element/flowchart.ts')
-rw-r--r--packages/excalidraw/element/flowchart.ts715
1 files changed, 715 insertions, 0 deletions
diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts
new file mode 100644
index 0000000..09f006d
--- /dev/null
+++ b/packages/excalidraw/element/flowchart.ts
@@ -0,0 +1,715 @@
+import {
+ HEADING_DOWN,
+ HEADING_LEFT,
+ HEADING_RIGHT,
+ HEADING_UP,
+ compareHeading,
+ headingForPointFromElement,
+ type Heading,
+} from "./heading";
+import { bindLinearElement } from "./binding";
+import { LinearElementEditor } from "./linearElementEditor";
+import { newArrowElement, newElement } from "./newElement";
+import {
+ type ElementsMap,
+ type ExcalidrawBindableElement,
+ type ExcalidrawElement,
+ type ExcalidrawFlowchartNodeElement,
+ type NonDeletedSceneElementsMap,
+ type Ordered,
+ type OrderedExcalidrawElement,
+} from "./types";
+import { KEYS } from "../keys";
+import type { AppState, PendingExcalidrawElements } from "../types";
+import { mutateElement } from "./mutateElement";
+import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
+import {
+ isBindableElement,
+ isElbowArrow,
+ isFrameElement,
+ isFlowchartNodeElement,
+} from "./typeChecks";
+import { invariant, toBrandedType } from "../utils";
+import { pointFrom, type LocalPoint } from "@excalidraw/math";
+import { aabbForElement } from "../shapes";
+import { updateElbowArrowPoints } from "./elbowArrow";
+
+type LinkDirection = "up" | "right" | "down" | "left";
+
+const VERTICAL_OFFSET = 100;
+const HORIZONTAL_OFFSET = 100;
+
+export const getLinkDirectionFromKey = (key: string): LinkDirection => {
+ switch (key) {
+ case KEYS.ARROW_UP:
+ return "up";
+ case KEYS.ARROW_DOWN:
+ return "down";
+ case KEYS.ARROW_RIGHT:
+ return "right";
+ case KEYS.ARROW_LEFT:
+ return "left";
+ default:
+ return "right";
+ }
+};
+
+const getNodeRelatives = (
+ type: "predecessors" | "successors",
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ const items = [...elementsMap.values()].reduce(
+ (acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
+ let oppositeBinding;
+ if (
+ isElbowArrow(el) &&
+ // we want check existence of the opposite binding, in the direction
+ // we're interested in
+ (oppositeBinding =
+ el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
+ // similarly, we need to filter only arrows bound to target node
+ el[type === "predecessors" ? "endBinding" : "startBinding"]
+ ?.elementId === node.id
+ ) {
+ const relative = elementsMap.get(oppositeBinding.elementId);
+
+ if (!relative) {
+ return acc;
+ }
+
+ invariant(
+ isBindableElement(relative),
+ "not an ExcalidrawBindableElement",
+ );
+
+ const edgePoint = (
+ type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
+ ) as Readonly<LocalPoint>;
+
+ const heading = headingForPointFromElement(node, aabbForElement(node), [
+ edgePoint[0] + el.x,
+ edgePoint[1] + el.y,
+ ] as Readonly<LocalPoint>);
+
+ acc.push({
+ relative,
+ heading,
+ });
+ }
+ return acc;
+ },
+ [],
+ );
+
+ switch (direction) {
+ case "up":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_UP))
+ .map((item) => item.relative);
+ case "down":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_DOWN))
+ .map((item) => item.relative);
+ case "right":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_RIGHT))
+ .map((item) => item.relative);
+ case "left":
+ return items
+ .filter((item) => compareHeading(item.heading, HEADING_LEFT))
+ .map((item) => item.relative);
+ }
+};
+
+const getSuccessors = (
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ return getNodeRelatives("successors", node, elementsMap, direction);
+};
+
+export const getPredecessors = (
+ node: ExcalidrawBindableElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+) => {
+ return getNodeRelatives("predecessors", node, elementsMap, direction);
+};
+
+const getOffsets = (
+ element: ExcalidrawFlowchartNodeElement,
+ linkedNodes: ExcalidrawElement[],
+ direction: LinkDirection,
+) => {
+ const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
+
+ // check if vertical space or horizontal space is available first
+ if (direction === "up" || direction === "down") {
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ // check vertical space
+ const minX = element.x;
+ const maxX = element.x + element.width;
+
+ // vertical space is available
+ if (
+ linkedNodes.every(
+ (linkedNode) =>
+ linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
+ )
+ ) {
+ return {
+ x: 0,
+ y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
+ };
+ }
+ } else if (direction === "right" || direction === "left") {
+ const minY = element.y;
+ const maxY = element.y + element.height;
+
+ if (
+ linkedNodes.every(
+ (linkedNode) =>
+ linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
+ )
+ ) {
+ return {
+ x:
+ (HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
+ y: 0,
+ };
+ }
+ }
+
+ if (direction === "up" || direction === "down") {
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
+ const x =
+ linkedNodes.length === 0
+ ? 0
+ : (linkedNodes.length + 1) % 2 === 0
+ ? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
+ : (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
+
+ if (direction === "up") {
+ return {
+ x,
+ y: y * -1,
+ };
+ }
+
+ return {
+ x,
+ y,
+ };
+ }
+
+ const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
+ const x =
+ (linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
+ element.width;
+ const y =
+ linkedNodes.length === 0
+ ? 0
+ : (linkedNodes.length + 1) % 2 === 0
+ ? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
+ : (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
+
+ if (direction === "left") {
+ return {
+ x: x * -1,
+ y,
+ };
+ }
+ return {
+ x,
+ y,
+ };
+};
+
+const addNewNode = (
+ element: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+) => {
+ const successors = getSuccessors(element, elementsMap, direction);
+ const predeccessors = getPredecessors(element, elementsMap, direction);
+
+ const offsets = getOffsets(
+ element,
+ [...successors, ...predeccessors],
+ direction,
+ );
+
+ const nextNode = newElement({
+ type: element.type,
+ x: element.x + offsets.x,
+ y: element.y + offsets.y,
+ // TODO: extract this to a util
+ width: element.width,
+ height: element.height,
+ roundness: element.roundness,
+ roughness: element.roughness,
+ backgroundColor: element.backgroundColor,
+ strokeColor: element.strokeColor,
+ strokeWidth: element.strokeWidth,
+ opacity: element.opacity,
+ fillStyle: element.fillStyle,
+ strokeStyle: element.strokeStyle,
+ });
+
+ invariant(
+ isFlowchartNodeElement(nextNode),
+ "not an ExcalidrawFlowchartNodeElement",
+ );
+
+ const bindingArrow = createBindingArrow(
+ element,
+ nextNode,
+ elementsMap,
+ direction,
+ appState,
+ );
+
+ return {
+ nextNode,
+ bindingArrow,
+ };
+};
+
+export const addNewNodes = (
+ startNode: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+ numberOfNodes: number,
+) => {
+ // always start from 0 and distribute evenly
+ const newNodes: ExcalidrawElement[] = [];
+
+ for (let i = 0; i < numberOfNodes; i++) {
+ let nextX: number;
+ let nextY: number;
+ if (direction === "left" || direction === "right") {
+ const totalHeight =
+ VERTICAL_OFFSET * (numberOfNodes - 1) +
+ numberOfNodes * startNode.height;
+
+ const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
+
+ let offsetX = HORIZONTAL_OFFSET + startNode.width;
+ if (direction === "left") {
+ offsetX *= -1;
+ }
+ nextX = startNode.x + offsetX;
+ const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
+ nextY = startY + offsetY;
+ } else {
+ const totalWidth =
+ HORIZONTAL_OFFSET * (numberOfNodes - 1) +
+ numberOfNodes * startNode.width;
+ const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
+ let offsetY = VERTICAL_OFFSET + startNode.height;
+
+ if (direction === "up") {
+ offsetY *= -1;
+ }
+ nextY = startNode.y + offsetY;
+ const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
+ nextX = startX + offsetX;
+ }
+
+ const nextNode = newElement({
+ type: startNode.type,
+ x: nextX,
+ y: nextY,
+ // TODO: extract this to a util
+ width: startNode.width,
+ height: startNode.height,
+ roundness: startNode.roundness,
+ roughness: startNode.roughness,
+ backgroundColor: startNode.backgroundColor,
+ strokeColor: startNode.strokeColor,
+ strokeWidth: startNode.strokeWidth,
+ opacity: startNode.opacity,
+ fillStyle: startNode.fillStyle,
+ strokeStyle: startNode.strokeStyle,
+ });
+
+ invariant(
+ isFlowchartNodeElement(nextNode),
+ "not an ExcalidrawFlowchartNodeElement",
+ );
+
+ const bindingArrow = createBindingArrow(
+ startNode,
+ nextNode,
+ elementsMap,
+ direction,
+ appState,
+ );
+
+ newNodes.push(nextNode);
+ newNodes.push(bindingArrow);
+ }
+
+ return newNodes;
+};
+
+const createBindingArrow = (
+ startBindingElement: ExcalidrawFlowchartNodeElement,
+ endBindingElement: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+ appState: AppState,
+) => {
+ let startX: number;
+ let startY: number;
+
+ const PADDING = 6;
+
+ switch (direction) {
+ case "up": {
+ startX = startBindingElement.x + startBindingElement.width / 2;
+ startY = startBindingElement.y - PADDING;
+ break;
+ }
+ case "down": {
+ startX = startBindingElement.x + startBindingElement.width / 2;
+ startY = startBindingElement.y + startBindingElement.height + PADDING;
+ break;
+ }
+ case "right": {
+ startX = startBindingElement.x + startBindingElement.width + PADDING;
+ startY = startBindingElement.y + startBindingElement.height / 2;
+ break;
+ }
+ case "left": {
+ startX = startBindingElement.x - PADDING;
+ startY = startBindingElement.y + startBindingElement.height / 2;
+ break;
+ }
+ }
+
+ let endX: number;
+ let endY: number;
+
+ switch (direction) {
+ case "up": {
+ endX = endBindingElement.x + endBindingElement.width / 2 - startX;
+ endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
+ break;
+ }
+ case "down": {
+ endX = endBindingElement.x + endBindingElement.width / 2 - startX;
+ endY = endBindingElement.y - startY - PADDING;
+ break;
+ }
+ case "right": {
+ endX = endBindingElement.x - startX - PADDING;
+ endY = endBindingElement.y - startY + endBindingElement.height / 2;
+ break;
+ }
+ case "left": {
+ endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
+ endY = endBindingElement.y - startY + endBindingElement.height / 2;
+ break;
+ }
+ }
+
+ const bindingArrow = newArrowElement({
+ type: "arrow",
+ x: startX,
+ y: startY,
+ startArrowhead: null,
+ endArrowhead: appState.currentItemEndArrowhead,
+ strokeColor: startBindingElement.strokeColor,
+ strokeStyle: startBindingElement.strokeStyle,
+ strokeWidth: startBindingElement.strokeWidth,
+ opacity: startBindingElement.opacity,
+ roughness: startBindingElement.roughness,
+ points: [pointFrom(0, 0), pointFrom(endX, endY)],
+ elbowed: true,
+ });
+
+ bindLinearElement(
+ bindingArrow,
+ startBindingElement,
+ "start",
+ elementsMap as NonDeletedSceneElementsMap,
+ );
+ bindLinearElement(
+ bindingArrow,
+ endBindingElement,
+ "end",
+ elementsMap as NonDeletedSceneElementsMap,
+ );
+
+ const changedElements = new Map<string, OrderedExcalidrawElement>();
+ changedElements.set(
+ startBindingElement.id,
+ startBindingElement as OrderedExcalidrawElement,
+ );
+ changedElements.set(
+ endBindingElement.id,
+ endBindingElement as OrderedExcalidrawElement,
+ );
+ changedElements.set(
+ bindingArrow.id,
+ bindingArrow as OrderedExcalidrawElement,
+ );
+
+ LinearElementEditor.movePoints(bindingArrow, [
+ {
+ index: 1,
+ point: bindingArrow.points[1],
+ },
+ ]);
+
+ const update = updateElbowArrowPoints(
+ bindingArrow,
+ toBrandedType<NonDeletedSceneElementsMap>(
+ new Map([
+ ...elementsMap.entries(),
+ [startBindingElement.id, startBindingElement],
+ [endBindingElement.id, endBindingElement],
+ [bindingArrow.id, bindingArrow],
+ ] as [string, Ordered<ExcalidrawElement>][]),
+ ),
+ { points: bindingArrow.points },
+ );
+
+ return {
+ ...bindingArrow,
+ ...update,
+ };
+};
+
+export class FlowChartNavigator {
+ isExploring: boolean = false;
+ // nodes that are ONE link away (successor and predecessor both included)
+ private sameLevelNodes: ExcalidrawElement[] = [];
+ private sameLevelIndex: number = 0;
+ // set it to the opposite of the defalut creation direction
+ private direction: LinkDirection | null = null;
+ // for speedier navigation
+ private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
+
+ clear() {
+ this.isExploring = false;
+ this.sameLevelNodes = [];
+ this.sameLevelIndex = 0;
+ this.direction = null;
+ this.visitedNodes.clear();
+ }
+
+ exploreByDirection(
+ element: ExcalidrawElement,
+ elementsMap: ElementsMap,
+ direction: LinkDirection,
+ ): ExcalidrawElement["id"] | null {
+ if (!isBindableElement(element)) {
+ return null;
+ }
+
+ // clear if going at a different direction
+ if (direction !== this.direction) {
+ this.clear();
+ }
+
+ // add the current node to the visited
+ if (!this.visitedNodes.has(element.id)) {
+ this.visitedNodes.add(element.id);
+ }
+
+ /**
+ * CASE:
+ * - already started exploring, AND
+ * - there are multiple nodes at the same level, AND
+ * - still going at the same direction, AND
+ *
+ * RESULT:
+ * - loop through nodes at the same level
+ *
+ * WHY:
+ * - provides user the capability to loop through nodes at the same level
+ */
+ if (
+ this.isExploring &&
+ direction === this.direction &&
+ this.sameLevelNodes.length > 1
+ ) {
+ this.sameLevelIndex =
+ (this.sameLevelIndex + 1) % this.sameLevelNodes.length;
+
+ return this.sameLevelNodes[this.sameLevelIndex].id;
+ }
+
+ const nodes = [
+ ...getSuccessors(element, elementsMap, direction),
+ ...getPredecessors(element, elementsMap, direction),
+ ];
+
+ /**
+ * CASE:
+ * - just started exploring at the given direction
+ *
+ * RESULT:
+ * - go to the first node in the given direction
+ */
+ if (nodes.length > 0) {
+ this.sameLevelIndex = 0;
+ this.isExploring = true;
+ this.sameLevelNodes = nodes;
+ this.direction = direction;
+ this.visitedNodes.add(nodes[0].id);
+
+ return nodes[0].id;
+ }
+
+ /**
+ * CASE:
+ * - (just started exploring or still going at the same direction) OR
+ * - there're no nodes at the given direction
+ *
+ * RESULT:
+ * - go to some other unvisited linked node
+ *
+ * WHY:
+ * - provide a speedier navigation from a given node to some predecessor
+ * without the user having to change arrow key
+ */
+ if (direction === this.direction || !this.isExploring) {
+ if (!this.isExploring) {
+ // just started and no other nodes at the given direction
+ // so the current node is technically the first visited node
+ // (this is needed so that we don't get stuck between looping through )
+ this.visitedNodes.add(element.id);
+ }
+
+ const otherDirections: LinkDirection[] = [
+ "up",
+ "right",
+ "down",
+ "left",
+ ].filter((dir): dir is LinkDirection => dir !== direction);
+
+ const otherLinkedNodes = otherDirections
+ .map((dir) => [
+ ...getSuccessors(element, elementsMap, dir),
+ ...getPredecessors(element, elementsMap, dir),
+ ])
+ .flat()
+ .filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
+
+ for (const linkedNode of otherLinkedNodes) {
+ if (!this.visitedNodes.has(linkedNode.id)) {
+ this.visitedNodes.add(linkedNode.id);
+ this.isExploring = true;
+ this.direction = direction;
+ return linkedNode.id;
+ }
+ }
+ }
+
+ return null;
+ }
+}
+
+export class FlowChartCreator {
+ isCreatingChart: boolean = false;
+ private numberOfNodes: number = 0;
+ private direction: LinkDirection | null = "right";
+ pendingNodes: PendingExcalidrawElements | null = null;
+
+ createNodes(
+ startNode: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ direction: LinkDirection,
+ ) {
+ if (direction !== this.direction) {
+ const { nextNode, bindingArrow } = addNewNode(
+ startNode,
+ elementsMap,
+ appState,
+ direction,
+ );
+
+ this.numberOfNodes = 1;
+ this.isCreatingChart = true;
+ this.direction = direction;
+ this.pendingNodes = [nextNode, bindingArrow];
+ } else {
+ this.numberOfNodes += 1;
+ const newNodes = addNewNodes(
+ startNode,
+ elementsMap,
+ appState,
+ direction,
+ this.numberOfNodes,
+ );
+
+ this.isCreatingChart = true;
+ this.direction = direction;
+ this.pendingNodes = newNodes;
+ }
+
+ // add pending nodes to the same frame as the start node
+ // if every pending node is at least intersecting with the frame
+ if (startNode.frameId) {
+ const frame = elementsMap.get(startNode.frameId);
+
+ invariant(
+ frame && isFrameElement(frame),
+ "not an ExcalidrawFrameElement",
+ );
+
+ if (
+ frame &&
+ this.pendingNodes.every(
+ (node) =>
+ elementsAreInFrameBounds([node], frame, elementsMap) ||
+ elementOverlapsWithFrame(node, frame, elementsMap),
+ )
+ ) {
+ this.pendingNodes = this.pendingNodes.map((node) =>
+ mutateElement(
+ node,
+ {
+ frameId: startNode.frameId,
+ },
+ false,
+ ),
+ );
+ }
+ }
+ }
+
+ clear() {
+ this.isCreatingChart = false;
+ this.pendingNodes = null;
+ this.direction = null;
+ this.numberOfNodes = 0;
+ }
+}
+
+export const isNodeInFlowchart = (
+ element: ExcalidrawFlowchartNodeElement,
+ elementsMap: ElementsMap,
+) => {
+ for (const [, el] of elementsMap) {
+ if (
+ el.type === "arrow" &&
+ (el.startBinding?.elementId === element.id ||
+ el.endBinding?.elementId === element.id)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+};