Files
upstream-excalidraw/packages/excalidraw/components/App.flowchart.ts
2026-09-16 05:31:04 +05:00

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