199 lines
5.1 KiB
TypeScript
199 lines
5.1 KiB
TypeScript
import { isArrowKey, KEYS } from "@excalidraw/common";
|
|
|
|
import {
|
|
makeNextSelectedElementIds,
|
|
CaptureUpdateAction,
|
|
FlowChartCreator,
|
|
FlowChartNavigator,
|
|
getSelectedElements,
|
|
isFlowchartNodeElement,
|
|
type LinkDirection,
|
|
} from "@excalidraw/element";
|
|
|
|
import type {
|
|
ExcalidrawElement,
|
|
NonDeletedExcalidrawElement,
|
|
} from "@excalidraw/element/types";
|
|
|
|
import type React from "react";
|
|
import type App from "./App";
|
|
import type { PendingExcalidrawElements } from "../types";
|
|
|
|
type FlowchartOperation =
|
|
| { type: "none" }
|
|
| { type: "canceled" }
|
|
| { type: "creating"; pending: PendingExcalidrawElements }
|
|
| { type: "navigating"; nodeId: ExcalidrawElement["id"] | null }
|
|
| { type: "committed"; nodes: PendingExcalidrawElements }
|
|
| { type: "navigationEnded" };
|
|
|
|
/**
|
|
* Captures the App state management for the flowchart functionality.
|
|
*/
|
|
export class AppFlowchart {
|
|
private creator = new FlowChartCreator();
|
|
private navigator = new FlowChartNavigator();
|
|
|
|
constructor(private app: App) {}
|
|
|
|
get pendingNodes() {
|
|
return this.creator.pendingNodes;
|
|
}
|
|
|
|
get isCreatingChart() {
|
|
return this.creator.isCreatingChart;
|
|
}
|
|
|
|
/** ends any in-progress flowchart creation/navigation session */
|
|
clear = () => {
|
|
this.creator.clear();
|
|
this.navigator.clear();
|
|
};
|
|
|
|
handleKeyEvent = (event: React.KeyboardEvent | KeyboardEvent): boolean => {
|
|
const operation = this.resolveKeyboardEventToOperation(event);
|
|
|
|
switch (operation.type) {
|
|
case "none":
|
|
return false;
|
|
case "canceled":
|
|
this.app.triggerRender(true);
|
|
return true;
|
|
case "creating":
|
|
event.preventDefault();
|
|
if (operation.pending.length) {
|
|
this.app.revealIfHidden(operation.pending);
|
|
}
|
|
return true;
|
|
case "navigating": {
|
|
event.preventDefault();
|
|
const node =
|
|
operation.nodeId &&
|
|
this.app.scene.getNonDeletedElementsMap().get(operation.nodeId);
|
|
if (node) {
|
|
this.selectAndReveal(node);
|
|
}
|
|
return true;
|
|
}
|
|
case "committed": {
|
|
if (operation.nodes.length) {
|
|
this.app.insertNewElements(operation.nodes);
|
|
}
|
|
|
|
const firstNode = operation.nodes[0];
|
|
if (firstNode) {
|
|
this.selectAndReveal(firstNode);
|
|
}
|
|
|
|
this.captureUpdate();
|
|
return true;
|
|
}
|
|
case "navigationEnded":
|
|
this.captureUpdate();
|
|
return true;
|
|
}
|
|
};
|
|
|
|
private resolveKeyboardEventToOperation(
|
|
event: React.KeyboardEvent | KeyboardEvent,
|
|
): FlowchartOperation {
|
|
const { creator, navigator, app } = this;
|
|
|
|
if (event.type === "keydown") {
|
|
if (event.key === KEYS.ESCAPE && creator.isCreatingChart) {
|
|
creator.clear();
|
|
return { type: "canceled" };
|
|
}
|
|
|
|
if (!isArrowKey(event.key)) {
|
|
return { type: "none" };
|
|
}
|
|
|
|
if (event[KEYS.CTRL_OR_CMD] && !event.shiftKey) {
|
|
const selectedElements = getSelectedElements(
|
|
app.scene.getNonDeletedElementsMap(),
|
|
app.state,
|
|
);
|
|
|
|
if (
|
|
selectedElements.length === 1 &&
|
|
isFlowchartNodeElement(selectedElements[0])
|
|
) {
|
|
creator.createNodes(
|
|
selectedElements[0],
|
|
app.state,
|
|
AppFlowchart.getLinkDirectionFromKey(event.key),
|
|
app.scene,
|
|
);
|
|
}
|
|
|
|
return { type: "creating", pending: creator.pendingNodes ?? [] };
|
|
}
|
|
|
|
if (event.altKey) {
|
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
|
const selectedElements = getSelectedElements(elementsMap, app.state);
|
|
|
|
if (selectedElements.length === 1) {
|
|
return {
|
|
type: "navigating",
|
|
nodeId: navigator.exploreByDirection(
|
|
selectedElements[0],
|
|
elementsMap,
|
|
AppFlowchart.getLinkDirectionFromKey(event.key),
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
return { type: "none" };
|
|
}
|
|
|
|
// keyup: releasing a modifier finalizes the workflow it was driving;
|
|
// both can finalize on the same event
|
|
const navigationEnded = !event.altKey && navigator.isExploring;
|
|
if (navigationEnded) {
|
|
navigator.clear();
|
|
}
|
|
|
|
if (!event[KEYS.CTRL_OR_CMD] && creator.isCreatingChart) {
|
|
const nodes = creator.pendingNodes ?? [];
|
|
creator.clear();
|
|
return { type: "committed", nodes };
|
|
}
|
|
|
|
return navigationEnded ? { type: "navigationEnded" } : { type: "none" };
|
|
}
|
|
|
|
private selectAndReveal(node: NonDeletedExcalidrawElement) {
|
|
this.app.setState((prevState) => ({
|
|
selectedElementIds: makeNextSelectedElementIds(
|
|
{ [node.id]: true },
|
|
prevState,
|
|
),
|
|
}));
|
|
this.app.revealIfHidden([node]);
|
|
}
|
|
|
|
private captureUpdate() {
|
|
this.app.syncActionResult({
|
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
});
|
|
}
|
|
|
|
private static 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";
|
|
}
|
|
}
|
|
}
|