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.
699 lines
18 KiB
TypeScript
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;
|
|
};
|