feat: create flowcharts from a generic element using elbow arrows (#8329)

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-08-09 03:43:15 +08:00 committed by GitHub
parent dd1370381d
commit 54491d13d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1431 additions and 19 deletions

View file

@ -162,6 +162,7 @@ import {
isMagicFrameElement,
isTextBindableContainer,
isElbowArrow,
isFlowchartNodeElement,
} from "../element/typeChecks";
import type {
ExcalidrawBindableElement,
@ -206,7 +207,10 @@ import {
isArrowKey,
KEYS,
} from "../keys";
import { isElementInViewport } from "../element/sizeHelpers";
import {
isElementCompletelyInViewport,
isElementInViewport,
} from "../element/sizeHelpers";
import {
distance2d,
getCornerRadius,
@ -430,6 +434,11 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid";
import { mutateElbowArrow } from "../element/routing";
import {
FlowChartCreator,
FlowChartNavigator,
getLinkDirectionFromKey,
} from "../element/flowchart";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -564,6 +573,9 @@ class App extends React.Component<AppProps, AppState> {
private elementsPendingErasure: ElementsPendingErasure = new Set();
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
hitLinkElement?: NonDeletedExcalidrawElement;
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
@ -1154,6 +1166,7 @@ class App extends React.Component<AppProps, AppState> {
el,
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
this.elementsPendingErasure,
null,
),
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
@ -1675,6 +1688,8 @@ class App extends React.Component<AppProps, AppState> {
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
}}
/>
<InteractiveCanvas
@ -3872,6 +3887,90 @@ class App extends React.Component<AppProps, AppState> {
});
}
if (event.key === KEYS.ESCAPE && this.flowChartCreator.isCreatingChart) {
this.flowChartCreator.clear();
this.triggerRender(true);
return;
}
const arrowKeyPressed = isArrowKey(event.key);
if (event[KEYS.CTRL_OR_CMD] && arrowKeyPressed && !event.shiftKey) {
event.preventDefault();
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElementsMap(),
this.state,
);
if (
selectedElements.length === 1 &&
isFlowchartNodeElement(selectedElements[0])
) {
this.flowChartCreator.createNodes(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
this.state,
getLinkDirectionFromKey(event.key),
);
}
return;
}
if (event.altKey) {
const selectedElements = getSelectedElements(
this.scene.getNonDeletedElementsMap(),
this.state,
);
if (selectedElements.length === 1 && arrowKeyPressed) {
event.preventDefault();
const nextId = this.flowChartNavigator.exploreByDirection(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
getLinkDirectionFromKey(event.key),
);
if (nextId) {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds(
{
[nextId]: true,
},
prevState,
),
}));
const nextNode = this.scene.getNonDeletedElementsMap().get(nextId);
if (
nextNode &&
!isElementCompletelyInViewport(
nextNode,
this.canvas.width / window.devicePixelRatio,
this.canvas.height / window.devicePixelRatio,
{
offsetLeft: this.state.offsetLeft,
offsetTop: this.state.offsetTop,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom,
},
this.scene.getNonDeletedElementsMap(),
)
) {
this.scrollToContent(nextNode, {
animate: true,
duration: 300,
});
}
}
return;
}
}
if (
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.P &&
@ -4238,6 +4337,58 @@ class App extends React.Component<AppProps, AppState> {
);
this.setState({ suggestedBindings: [] });
}
if (!event.altKey) {
if (this.flowChartNavigator.isExploring) {
this.flowChartNavigator.clear();
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
}
if (!event[KEYS.CTRL_OR_CMD]) {
if (this.flowChartCreator.isCreatingChart) {
if (this.flowChartCreator.pendingNodes?.length) {
this.scene.insertElements(this.flowChartCreator.pendingNodes);
}
const firstNode = this.flowChartCreator.pendingNodes?.[0];
if (firstNode) {
this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds(
{
[firstNode.id]: true,
},
prevState,
),
}));
if (
!isElementCompletelyInViewport(
firstNode,
this.canvas.width / window.devicePixelRatio,
this.canvas.height / window.devicePixelRatio,
{
offsetLeft: this.state.offsetLeft,
offsetTop: this.state.offsetTop,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom,
},
this.scene.getNonDeletedElementsMap(),
)
) {
this.scrollToContent(firstNode, {
animate: true,
duration: 300,
});
}
}
this.flowChartCreator.clear();
this.syncActionResult({ storeAction: StoreAction.CAPTURE });
}
}
});
// We purposely widen the `tool` type so this helper can be called with
@ -7122,7 +7273,6 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
this.setState((prevState) => {
const nextSelectedElementIds = {
...prevState.selectedElementIds,

View file

@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
className="HelpDialog__island--editor"
caption={t("helpDialog.editor")}
>
<Shortcut
label={t("helpDialog.createFlowchart")}
shortcuts={[getShortcutKey(`CtrlOrCmd+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("helpDialog.navigateFlowchart")}
shortcuts={[getShortcutKey(`Alt+Arrow Key`)]}
isOr={true}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[

View file

@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
box-sizing: border-box;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
left: 0;
top: 100%;

View file

@ -1,6 +1,7 @@
import { t } from "../i18n";
import type { AppClassProperties, Device, UIAppState } from "../types";
import {
isFlowchartNodeElement,
isImageElement,
isLinearElement,
isTextBindableContainer,
@ -10,6 +11,7 @@ import { getShortcutKey } from "../utils";
import { isEraserActive } from "../appState";
import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart";
interface HintViewerProps {
appState: UIAppState;
@ -18,7 +20,12 @@ interface HintViewerProps {
app: AppClassProperties;
}
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
const getHints = ({
appState,
isMobile,
device,
app,
}: HintViewerProps): null | string | string[] => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
@ -115,6 +122,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
!appState.selectedElementsAreBeingDragged &&
isTextBindableContainer(selectedElements[0])
) {
if (isFlowchartNodeElement(selectedElements[0])) {
if (
isNodeInFlowchart(
selectedElements[0],
app.scene.getNonDeletedElementsMap(),
)
) {
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
}
return t("hints.bindTextToElement");
}
}
@ -129,17 +149,24 @@ export const HintViewer = ({
device,
app,
}: HintViewerProps) => {
let hint = getHints({
const hints = getHints({
appState,
isMobile,
device,
app,
});
if (!hint) {
if (!hints) {
return null;
}
hint = getShortcutKey(hint);
const hint = Array.isArray(hints)
? hints
.map((hint) => {
return getShortcutKey(hint).replace(/\. ?$/, "");
})
.join(". ")
: getShortcutKey(hints);
return (
<div className="HintViewer">