mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
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:
parent
dd1370381d
commit
54491d13d4
21 changed files with 1431 additions and 19 deletions
|
@ -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,
|
||||
|
|
|
@ -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={[
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue