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
|
@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { History } from "../history";
|
import type { History } from "../history";
|
||||||
import { HistoryChangedEvent } from "../history";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import type { AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
|
@ -13,7 +13,8 @@ import type { Store } from "../store";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const executeHistoryAction = (
|
||||||
|
app: AppClassProperties,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
updater: () => [SceneElementsMap, AppState] | void,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
|
@ -23,7 +24,8 @@ const writeData = (
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
!appState.newElement &&
|
!appState.newElement &&
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
!appState.selectionElement
|
!appState.selectionElement &&
|
||||||
|
!app.flowChartCreator.isCreatingChart
|
||||||
) {
|
) {
|
||||||
const result = updater();
|
const result = updater();
|
||||||
|
|
||||||
|
@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) =>
|
perform: (elements, appState, value, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.undo(
|
history.undo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
|
@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, _, app) =>
|
||||||
writeData(appState, () =>
|
executeHistoryAction(app, appState, () =>
|
||||||
history.redo(
|
history.redo(
|
||||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
appState,
|
appState,
|
||||||
|
|
|
@ -162,6 +162,7 @@ import {
|
||||||
isMagicFrameElement,
|
isMagicFrameElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isFlowchartNodeElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
|
@ -206,7 +207,10 @@ import {
|
||||||
isArrowKey,
|
isArrowKey,
|
||||||
KEYS,
|
KEYS,
|
||||||
} from "../keys";
|
} from "../keys";
|
||||||
import { isElementInViewport } from "../element/sizeHelpers";
|
import {
|
||||||
|
isElementCompletelyInViewport,
|
||||||
|
isElementInViewport,
|
||||||
|
} from "../element/sizeHelpers";
|
||||||
import {
|
import {
|
||||||
distance2d,
|
distance2d,
|
||||||
getCornerRadius,
|
getCornerRadius,
|
||||||
|
@ -430,6 +434,11 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||||
import { getVisibleSceneBounds } from "../element/bounds";
|
import { getVisibleSceneBounds } from "../element/bounds";
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
|
import {
|
||||||
|
FlowChartCreator,
|
||||||
|
FlowChartNavigator,
|
||||||
|
getLinkDirectionFromKey,
|
||||||
|
} from "../element/flowchart";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
|
@ -564,6 +573,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
private elementsPendingErasure: ElementsPendingErasure = new Set();
|
||||||
|
|
||||||
|
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
|
||||||
|
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
|
||||||
|
|
||||||
hitLinkElement?: NonDeletedExcalidrawElement;
|
hitLinkElement?: NonDeletedExcalidrawElement;
|
||||||
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
|
||||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||||
|
@ -1154,6 +1166,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
el,
|
el,
|
||||||
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
|
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
|
||||||
this.elementsPendingErasure,
|
this.elementsPendingErasure,
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
["--embeddable-radius" as string]: `${getCornerRadius(
|
["--embeddable-radius" as string]: `${getCornerRadius(
|
||||||
Math.min(el.width, el.height),
|
Math.min(el.width, el.height),
|
||||||
|
@ -1675,6 +1688,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.state.viewBackgroundColor,
|
this.state.viewBackgroundColor,
|
||||||
embedsValidationStatus: this.embedsValidationStatus,
|
embedsValidationStatus: this.embedsValidationStatus,
|
||||||
elementsPendingErasure: this.elementsPendingErasure,
|
elementsPendingErasure: this.elementsPendingErasure,
|
||||||
|
pendingFlowchartNodes:
|
||||||
|
this.flowChartCreator.pendingNodes,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<InteractiveCanvas
|
<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 (
|
if (
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key === KEYS.P &&
|
event.key === KEYS.P &&
|
||||||
|
@ -4238,6 +4337,58 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
);
|
);
|
||||||
this.setState({ suggestedBindings: [] });
|
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
|
// 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,
|
locked: false,
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
const nextSelectedElementIds = {
|
const nextSelectedElementIds = {
|
||||||
...prevState.selectedElementIds,
|
...prevState.selectedElementIds,
|
||||||
|
|
|
@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||||
className="HelpDialog__island--editor"
|
className="HelpDialog__island--editor"
|
||||||
caption={t("helpDialog.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
|
<Shortcut
|
||||||
label={t("labels.moveCanvas")}
|
label={t("labels.moveCanvas")}
|
||||||
shortcuts={[
|
shortcuts={[
|
||||||
|
|
|
@ -9,6 +9,7 @@ $wide-viewport-width: 1000px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { AppClassProperties, Device, UIAppState } from "../types";
|
import type { AppClassProperties, Device, UIAppState } from "../types";
|
||||||
import {
|
import {
|
||||||
|
isFlowchartNodeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
|
@ -10,6 +11,7 @@ import { getShortcutKey } from "../utils";
|
||||||
import { isEraserActive } from "../appState";
|
import { isEraserActive } from "../appState";
|
||||||
|
|
||||||
import "./HintViewer.scss";
|
import "./HintViewer.scss";
|
||||||
|
import { isNodeInFlowchart } from "../element/flowchart";
|
||||||
|
|
||||||
interface HintViewerProps {
|
interface HintViewerProps {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
|
@ -18,7 +20,12 @@ interface HintViewerProps {
|
||||||
app: AppClassProperties;
|
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 { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||||
const multiMode = appState.multiElement !== null;
|
const multiMode = appState.multiElement !== null;
|
||||||
|
|
||||||
|
@ -115,6 +122,19 @@ const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||||
!appState.selectedElementsAreBeingDragged &&
|
!appState.selectedElementsAreBeingDragged &&
|
||||||
isTextBindableContainer(selectedElements[0])
|
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");
|
return t("hints.bindTextToElement");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -129,17 +149,24 @@ export const HintViewer = ({
|
||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
}: HintViewerProps) => {
|
}: HintViewerProps) => {
|
||||||
let hint = getHints({
|
const hints = getHints({
|
||||||
appState,
|
appState,
|
||||||
isMobile,
|
isMobile,
|
||||||
device,
|
device,
|
||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
if (!hint) {
|
|
||||||
|
if (!hints) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
hint = getShortcutKey(hint);
|
const hint = Array.isArray(hints)
|
||||||
|
? hints
|
||||||
|
.map((hint) => {
|
||||||
|
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||||
|
})
|
||||||
|
.join(". ")
|
||||||
|
: getShortcutKey(hints);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="HintViewer">
|
<div className="HintViewer">
|
||||||
|
|
|
@ -51,6 +51,7 @@ import { normalizeLink } from "./url";
|
||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
|
import { normalizeFixedPoint } from "../element/binding";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
|
@ -106,7 +107,7 @@ const repairBinding = (
|
||||||
...binding,
|
...binding,
|
||||||
focus: binding.focus || 0,
|
focus: binding.focus || 0,
|
||||||
fixedPoint: isElbowArrow(element)
|
fixedPoint: isElbowArrow(element)
|
||||||
? binding.fixedPoint ?? ([0, 0] as [number, number])
|
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1000,7 +1000,7 @@ const updateBoundPoint = (
|
||||||
|
|
||||||
if (isElbowArrow(linearElement)) {
|
if (isElbowArrow(linearElement)) {
|
||||||
const fixedPoint =
|
const fixedPoint =
|
||||||
binding.fixedPoint ??
|
normalizeFixedPoint(binding.fixedPoint) ??
|
||||||
calculateFixedPointForElbowArrowBinding(
|
calculateFixedPointForElbowArrowBinding(
|
||||||
linearElement,
|
linearElement,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
@ -1113,12 +1113,12 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||||
) as Point;
|
) as Point;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fixedPoint: [
|
fixedPoint: normalizeFixedPoint([
|
||||||
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
|
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
|
||||||
hoveredElement.width,
|
hoveredElement.width,
|
||||||
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
|
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
|
||||||
hoveredElement.height,
|
hoveredElement.height,
|
||||||
] as [number, number],
|
]),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2171,7 +2171,8 @@ export const getGlobalFixedPointForBindableElement = (
|
||||||
fixedPointRatio: [number, number],
|
fixedPointRatio: [number, number],
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
) => {
|
) => {
|
||||||
const [fixedX, fixedY] = fixedPointRatio;
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
return rotatePoint(
|
return rotatePoint(
|
||||||
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
||||||
getCenterForElement(element),
|
getCenterForElement(element),
|
||||||
|
@ -2225,3 +2226,16 @@ export const getArrowLocalFixedPoints = (
|
||||||
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
|
LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const normalizeFixedPoint = <T extends FixedPoint | null>(
|
||||||
|
fixedPoint: T,
|
||||||
|
): T extends null ? null : FixedPoint => {
|
||||||
|
// Do not allow a precise 0.5 for fixed point ratio
|
||||||
|
// to avoid jumping arrow heading due to floating point imprecision
|
||||||
|
if (fixedPoint && (fixedPoint[0] === 0.5 || fixedPoint[1] === 0.5)) {
|
||||||
|
return fixedPoint.map((ratio) =>
|
||||||
|
ratio === 0.5 ? 0.5001 : ratio,
|
||||||
|
) as T extends null ? null : FixedPoint;
|
||||||
|
}
|
||||||
|
return fixedPoint as any as T extends null ? null : FixedPoint;
|
||||||
|
};
|
||||||
|
|
404
packages/excalidraw/element/flowchart.test.tsx
Normal file
404
packages/excalidraw/element/flowchart.test.tsx
Normal file
|
@ -0,0 +1,404 @@
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
import { render } from "../tests/test-utils";
|
||||||
|
import { reseed } from "../random";
|
||||||
|
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import { API } from "../tests/helpers/api";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
reseed(7);
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
h.state.width = 1000;
|
||||||
|
h.state.height = 1000;
|
||||||
|
|
||||||
|
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||||
|
// removing this style for testing
|
||||||
|
UI.clickTool("arrow");
|
||||||
|
UI.clickByTitle("Architect");
|
||||||
|
UI.clickTool("selection");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flow chart creation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [rectangle];
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// multiple at once
|
||||||
|
it("create multiple successor nodes at once", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when directions are changed, only the last same directions will apply", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(7);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when escaped, no nodes will be created", () => {
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
Keyboard.keyPress(KEYS.ESCAPE);
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create nodes one at a time", () => {
|
||||||
|
const initialNode = h.elements[0];
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||||
|
|
||||||
|
const firstChildNode = h.elements.filter(
|
||||||
|
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||||
|
)[0];
|
||||||
|
expect(firstChildNode).not.toBe(null);
|
||||||
|
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
API.setSelectedElements([initialNode]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||||
|
|
||||||
|
const secondChildNode = h.elements.filter(
|
||||||
|
(el) =>
|
||||||
|
el.type === "rectangle" &&
|
||||||
|
el.id !== initialNode.id &&
|
||||||
|
el.id !== firstChildNode.id,
|
||||||
|
)[0];
|
||||||
|
expect(secondChildNode).not.toBe(null);
|
||||||
|
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
API.setSelectedElements([initialNode]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.length).toBe(7);
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||||
|
|
||||||
|
const thirdChildNode = h.elements.filter(
|
||||||
|
(el) =>
|
||||||
|
el.type === "rectangle" &&
|
||||||
|
el.id !== initialNode.id &&
|
||||||
|
el.id !== firstChildNode.id &&
|
||||||
|
el.id !== secondChildNode.id,
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
expect(thirdChildNode).not.toBe(null);
|
||||||
|
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||||
|
|
||||||
|
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||||
|
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flow chart navigation", () => {
|
||||||
|
it("single node at each level", () => {
|
||||||
|
/**
|
||||||
|
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [rectangle];
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||||
|
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||||
|
|
||||||
|
// all the way to the left, gets us to the first node
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
|
||||||
|
// all the way to the right, gets us to the last node
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
expect(rightMostNode);
|
||||||
|
expect(rightMostNode.type).toBe("rectangle");
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple nodes at each level", () => {
|
||||||
|
/**
|
||||||
|
* from the perspective of the first node, there're four layers, and
|
||||||
|
* there are four nodes at the second layer
|
||||||
|
*
|
||||||
|
* -> ▨
|
||||||
|
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||||
|
* -> ▨
|
||||||
|
* -> ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [rectangle];
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
const secondNode = h.elements[1];
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
// because of same level cycling,
|
||||||
|
// going right five times should take us back to the second node again
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||||
|
|
||||||
|
// from the second node, going right three times should take us to the rightmost node
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("take the most obvious link when possible", () => {
|
||||||
|
/**
|
||||||
|
* ▨ → ▨ ▨ → ▨
|
||||||
|
* ↓ ↑
|
||||||
|
* ▨ → ▨
|
||||||
|
*/
|
||||||
|
|
||||||
|
API.clearSelection();
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
h.elements = [rectangle];
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||||
|
|
||||||
|
// last node should be the one that's selected
|
||||||
|
const rightMostNode = h.elements[h.elements.length - 2];
|
||||||
|
expect(rightMostNode.type).toBe("rectangle");
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
|
||||||
|
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||||
|
|
||||||
|
// going any direction takes us to the predecessor as well
|
||||||
|
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||||
|
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||||
|
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
API.setSelectedElements([rightMostNode]);
|
||||||
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||||
|
});
|
||||||
|
Keyboard.keyUp(KEYS.ALT);
|
||||||
|
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||||
|
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
698
packages/excalidraw/element/flowchart.ts
Normal file
698
packages/excalidraw/element/flowchart.ts
Normal file
|
@ -0,0 +1,698 @@
|
||||||
|
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;
|
||||||
|
};
|
|
@ -44,7 +44,7 @@ import {
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { tupleToCoors } from "../utils";
|
import { toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
@ -1447,9 +1447,17 @@ export class LinearElementEditor {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.warn("movePoints", options?.changedElements);
|
||||||
|
|
||||||
|
const mergedElementsMap = options?.changedElements
|
||||||
|
? toBrandedType<SceneElementsMap>(
|
||||||
|
new Map([...elementsMap, ...options.changedElements]),
|
||||||
|
)
|
||||||
|
: elementsMap;
|
||||||
|
|
||||||
mutateElbowArrow(
|
mutateElbowArrow(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
mergedElementsMap,
|
||||||
nextPoints,
|
nextPoints,
|
||||||
[offsetX, offsetY],
|
[offsetX, offsetY],
|
||||||
bindings,
|
bindings,
|
||||||
|
|
|
@ -55,6 +55,43 @@ export const isElementInViewport = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isElementCompletelyInViewport = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
viewTransformations: {
|
||||||
|
zoom: Zoom;
|
||||||
|
offsetLeft: number;
|
||||||
|
offsetTop: number;
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
},
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
|
||||||
|
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||||
|
{
|
||||||
|
clientX: viewTransformations.offsetLeft,
|
||||||
|
clientY: viewTransformations.offsetTop,
|
||||||
|
},
|
||||||
|
viewTransformations,
|
||||||
|
);
|
||||||
|
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||||
|
{
|
||||||
|
clientX: viewTransformations.offsetLeft + width,
|
||||||
|
clientY: viewTransformations.offsetTop + height,
|
||||||
|
},
|
||||||
|
viewTransformations,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
x1 >= topLeftSceneCoords.x &&
|
||||||
|
y1 >= topLeftSceneCoords.y &&
|
||||||
|
x2 <= bottomRightSceneCoords.x &&
|
||||||
|
y2 <= bottomRightSceneCoords.y
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
|
ExcalidrawFlowchartNodeElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
|
@ -219,6 +220,16 @@ export const isExcalidrawElement = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFlowchartNodeElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawFlowchartNodeElement => {
|
||||||
|
return (
|
||||||
|
element.type === "rectangle" ||
|
||||||
|
element.type === "ellipse" ||
|
||||||
|
element.type === "diamond"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const hasBoundTextElement = (
|
export const hasBoundTextElement = (
|
||||||
element: ExcalidrawElement | null,
|
element: ExcalidrawElement | null,
|
||||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||||
|
|
|
@ -160,6 +160,11 @@ export type ExcalidrawGenericElement =
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement;
|
| ExcalidrawEllipseElement;
|
||||||
|
|
||||||
|
export type ExcalidrawFlowchartNodeElement =
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawDiamondElement
|
||||||
|
| ExcalidrawEllipseElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||||
|
|
|
@ -316,6 +316,7 @@
|
||||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||||
"publishLibrary": "Publish your own library",
|
"publishLibrary": "Publish your own library",
|
||||||
"bindTextToElement": "Press enter to add text",
|
"bindTextToElement": "Press enter to add text",
|
||||||
|
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
|
||||||
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
|
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
|
||||||
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
|
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
|
||||||
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
|
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
|
||||||
|
@ -366,6 +367,8 @@
|
||||||
"click": "click",
|
"click": "click",
|
||||||
"deepSelect": "Deep select",
|
"deepSelect": "Deep select",
|
||||||
"deepBoxSelect": "Deep select within box, and prevent dragging",
|
"deepBoxSelect": "Deep select within box, and prevent dragging",
|
||||||
|
"createFlowchart": "Create a flowchart from a generic element",
|
||||||
|
"navigateFlowchart": "Navigate a flowchart",
|
||||||
"curvedArrow": "Curved arrow",
|
"curvedArrow": "Curved arrow",
|
||||||
"curvedLine": "Curved line",
|
"curvedLine": "Curved line",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
|
|
|
@ -35,6 +35,7 @@ import type {
|
||||||
Zoom,
|
Zoom,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
ElementsPendingErasure,
|
ElementsPendingErasure,
|
||||||
|
PendingExcalidrawElements,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
|
@ -104,6 +105,7 @@ export const getRenderOpacity = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
containingFrame: ExcalidrawFrameLikeElement | null,
|
containingFrame: ExcalidrawFrameLikeElement | null,
|
||||||
elementsPendingErasure: ElementsPendingErasure,
|
elementsPendingErasure: ElementsPendingErasure,
|
||||||
|
pendingNodes: Readonly<PendingExcalidrawElements> | null,
|
||||||
) => {
|
) => {
|
||||||
// multiplying frame opacity with element opacity to combine them
|
// multiplying frame opacity with element opacity to combine them
|
||||||
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
|
||||||
|
@ -113,6 +115,7 @@ export const getRenderOpacity = (
|
||||||
// (so that erasing always results in lower opacity than original)
|
// (so that erasing always results in lower opacity than original)
|
||||||
if (
|
if (
|
||||||
elementsPendingErasure.has(element.id) ||
|
elementsPendingErasure.has(element.id) ||
|
||||||
|
(pendingNodes && pendingNodes.some((node) => node.id === element.id)) ||
|
||||||
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
(containingFrame && elementsPendingErasure.has(containingFrame.id))
|
||||||
) {
|
) {
|
||||||
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100;
|
||||||
|
@ -672,6 +675,7 @@ export const renderElement = (
|
||||||
element,
|
element,
|
||||||
getContainingFrame(element, elementsMap),
|
getContainingFrame(element, elementsMap),
|
||||||
renderConfig.elementsPendingErasure,
|
renderConfig.elementsPendingErasure,
|
||||||
|
renderConfig.pendingFlowchartNodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
|
|
|
@ -370,6 +370,23 @@ const _renderStaticScene = ({
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// render pending nodes for flowcharts
|
||||||
|
renderConfig.pendingFlowchartNodes?.forEach((element) => {
|
||||||
|
try {
|
||||||
|
renderElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
allElementsMap,
|
||||||
|
rc,
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** throttled to animation framerate */
|
/** throttled to animation framerate */
|
||||||
|
|
|
@ -377,6 +377,10 @@ class Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||||
|
if (!elements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(index) || index < 0) {
|
if (!Number.isFinite(index) || index < 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"insertElementAtIndex can only be called with index >= 0",
|
"insertElementAtIndex can only be called with index >= 0",
|
||||||
|
@ -403,7 +407,11 @@ class Scene {
|
||||||
};
|
};
|
||||||
|
|
||||||
insertElements = (elements: ExcalidrawElement[]) => {
|
insertElements = (elements: ExcalidrawElement[]) => {
|
||||||
const index = elements[0].frameId
|
if (!elements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = elements[0]?.frameId
|
||||||
? this.getElementIndex(elements[0].frameId)
|
? this.getElementIndex(elements[0].frameId)
|
||||||
: this.elements.length;
|
: this.elements.length;
|
||||||
|
|
||||||
|
|
|
@ -242,6 +242,7 @@ export const exportToCanvas = async (
|
||||||
// empty disables embeddable rendering
|
// empty disables embeddable rendering
|
||||||
embedsValidationStatus: new Map(),
|
embedsValidationStatus: new Map(),
|
||||||
elementsPendingErasure: new Set(),
|
elementsPendingErasure: new Set(),
|
||||||
|
pendingFlowchartNodes: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
Device,
|
Device,
|
||||||
|
PendingExcalidrawElements,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { MakeBrand } from "../utility-types";
|
import type { MakeBrand } from "../utility-types";
|
||||||
|
|
||||||
|
@ -33,6 +34,7 @@ export type StaticCanvasRenderConfig = {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
elementsPendingErasure: ElementsPendingErasure;
|
elementsPendingErasure: ElementsPendingErasure;
|
||||||
|
pendingFlowchartNodes: PendingExcalidrawElements | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SVGRenderConfig = {
|
export type SVGRenderConfig = {
|
||||||
|
|
|
@ -648,6 +648,7 @@ export type AppClassProperties = {
|
||||||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||||||
getName: App["getName"];
|
getName: App["getName"];
|
||||||
dismissLinearEditor: App["dismissLinearEditor"];
|
dismissLinearEditor: App["dismissLinearEditor"];
|
||||||
|
flowChartCreator: App["flowChartCreator"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
|
@ -828,3 +829,5 @@ export type EmbedsValidationStatus = Map<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||||||
|
|
||||||
|
export type PendingExcalidrawElements = ExcalidrawElement[];
|
||||||
|
|
|
@ -929,6 +929,12 @@ export const assertNever = (
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function invariant(condition: any, message: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Memoizes on values of `opts` object (strict equality).
|
* Memoizes on values of `opts` object (strict equality).
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue