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(); 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 = 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; };