You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/element/flowchart.ts

699 lines
18 KiB
TypeScript

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;
};