diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index 2f322b7ad2..d860c4143b 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -4,7 +4,7 @@ import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; import type { History } from "../history"; import { HistoryChangedEvent } from "../history"; -import type { AppState } from "../types"; +import type { AppClassProperties, AppState } from "../types"; import { KEYS } from "../keys"; import { arrayToMap } from "../utils"; import { isWindows } from "../constants"; @@ -13,7 +13,8 @@ import type { Store } from "../store"; import { StoreAction } from "../store"; import { useEmitter } from "../hooks/useEmitter"; -const writeData = ( +const executeHistoryAction = ( + app: AppClassProperties, appState: Readonly, updater: () => [SceneElementsMap, AppState] | void, ): ActionResult => { @@ -23,7 +24,8 @@ const writeData = ( !appState.editingElement && !appState.newElement && !appState.selectedElementsAreBeingDragged && - !appState.selectionElement + !appState.selectionElement && + !app.flowChartCreator.isCreatingChart ) { const result = updater(); @@ -53,7 +55,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({ trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState, value, app) => - writeData(appState, () => + executeHistoryAction(app, appState, () => history.undo( arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` appState, @@ -94,7 +96,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({ trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState, _, app) => - writeData(appState, () => + executeHistoryAction(app, appState, () => history.redo( arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` appState, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 324931d3c8..41608ea121 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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(null!); const AppPropsContext = React.createContext(null!); @@ -564,6 +573,9 @@ class App extends React.Component { private elementsPendingErasure: ElementsPendingErasure = new Set(); + public flowChartCreator: FlowChartCreator = new FlowChartCreator(); + private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -1154,6 +1166,7 @@ class App extends React.Component { 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 { this.state.viewBackgroundColor, embedsValidationStatus: this.embedsValidationStatus, elementsPendingErasure: this.elementsPendingErasure, + pendingFlowchartNodes: + this.flowChartCreator.pendingNodes, }} /> { }); } + 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 { ); 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 { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => { const nextSelectedElementIds = { ...prevState.selectedElementIds, diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 58c1eea463..5892e6ac18 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -304,6 +304,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { className="HelpDialog__island--editor" caption={t("helpDialog.editor")} > + + { +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 (
diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 0e1e82ccee..dd1c55da1b 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -51,6 +51,7 @@ import { normalizeLink } from "./url"; import { syncInvalidIndices } from "../fractionalIndex"; import { getSizeFromPoints } from "../points"; import { getLineHeight } from "../fonts"; +import { normalizeFixedPoint } from "../element/binding"; type RestoredAppState = Omit< AppState, @@ -106,7 +107,7 @@ const repairBinding = ( ...binding, focus: binding.focus || 0, fixedPoint: isElbowArrow(element) - ? binding.fixedPoint ?? ([0, 0] as [number, number]) + ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) : null, }; }; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index b750bffa4f..c367bf752b 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -1000,7 +1000,7 @@ const updateBoundPoint = ( if (isElbowArrow(linearElement)) { const fixedPoint = - binding.fixedPoint ?? + normalizeFixedPoint(binding.fixedPoint) ?? calculateFixedPointForElbowArrowBinding( linearElement, bindableElement, @@ -1113,12 +1113,12 @@ export const calculateFixedPointForElbowArrowBinding = ( ) as Point; return { - fixedPoint: [ + fixedPoint: normalizeFixedPoint([ (nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) / hoveredElement.width, (nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) / hoveredElement.height, - ] as [number, number], + ]), }; }; @@ -2171,7 +2171,8 @@ export const getGlobalFixedPointForBindableElement = ( fixedPointRatio: [number, number], element: ExcalidrawBindableElement, ) => { - const [fixedX, fixedY] = fixedPointRatio; + const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); + return rotatePoint( [element.x + element.width * fixedX, element.y + element.height * fixedY], getCenterForElement(element), @@ -2225,3 +2226,16 @@ export const getArrowLocalFixedPoints = ( LinearElementEditor.pointFromAbsoluteCoords(arrow, endPoint, elementsMap), ]; }; + +export const normalizeFixedPoint = ( + 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; +}; diff --git a/packages/excalidraw/element/flowchart.test.tsx b/packages/excalidraw/element/flowchart.test.tsx new file mode 100644 index 0000000000..629fb12970 --- /dev/null +++ b/packages/excalidraw/element/flowchart.test.tsx @@ -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(); + 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, + ); + }); +}); diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts new file mode 100644 index 0000000000..83850be824 --- /dev/null +++ b/packages/excalidraw/element/flowchart.ts @@ -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(); + 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 = 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; +}; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index bab2074b7b..c071b68f93 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -44,7 +44,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { tupleToCoors } from "../utils"; +import { toBrandedType, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -1447,9 +1447,17 @@ export class LinearElementEditor { : null; } + console.warn("movePoints", options?.changedElements); + + const mergedElementsMap = options?.changedElements + ? toBrandedType( + new Map([...elementsMap, ...options.changedElements]), + ) + : elementsMap; + mutateElbowArrow( element, - elementsMap, + mergedElementsMap, nextPoints, [offsetX, offsetY], bindings, diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index 382cd5c3be..9ffc682f67 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -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 */ diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 17eaaad54e..821ba2d368 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -24,6 +24,7 @@ import type { ExcalidrawElbowArrowElement, PointBinding, FixedPointBinding, + ExcalidrawFlowchartNodeElement, } from "./types"; 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 = ( element: ExcalidrawElement | null, ): element is MarkNonNullable => { diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 21ec0e95e2..33372764b7 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -160,6 +160,11 @@ export type ExcalidrawGenericElement = | ExcalidrawDiamondElement | ExcalidrawEllipseElement; +export type ExcalidrawFlowchartNodeElement = + | ExcalidrawRectangleElement + | ExcalidrawDiamondElement + | ExcalidrawEllipseElement; + /** * ExcalidrawElement should be JSON serializable and (eventually) contain * no computed data. The list of all ExcalidrawElements should be shareable diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index afb213df1f..9444baf805 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -316,6 +316,7 @@ "placeImage": "Click to place the image, or click and drag to set its size manually", "publishLibrary": "Publish your own library", "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", "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.", @@ -366,6 +367,8 @@ "click": "click", "deepSelect": "Deep select", "deepBoxSelect": "Deep select within box, and prevent dragging", + "createFlowchart": "Create a flowchart from a generic element", + "navigateFlowchart": "Navigate a flowchart", "curvedArrow": "Curved arrow", "curvedLine": "Curved line", "documentation": "Documentation", diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 946f5fbf40..4e6b11ac8d 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -35,6 +35,7 @@ import type { Zoom, InteractiveCanvasAppState, ElementsPendingErasure, + PendingExcalidrawElements, } from "../types"; import { getDefaultAppState } from "../appState"; import { @@ -104,6 +105,7 @@ export const getRenderOpacity = ( element: ExcalidrawElement, containingFrame: ExcalidrawFrameLikeElement | null, elementsPendingErasure: ElementsPendingErasure, + pendingNodes: Readonly | null, ) => { // multiplying frame opacity with element opacity to combine them // (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) if ( elementsPendingErasure.has(element.id) || + (pendingNodes && pendingNodes.some((node) => node.id === element.id)) || (containingFrame && elementsPendingErasure.has(containingFrame.id)) ) { opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; @@ -672,6 +675,7 @@ export const renderElement = ( element, getContainingFrame(element, elementsMap), renderConfig.elementsPendingErasure, + renderConfig.pendingFlowchartNodes, ); switch (element.type) { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index ff90e5e855..b2b8becd28 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -370,6 +370,23 @@ const _renderStaticScene = ({ 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 */ diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 813b3cbf53..cac23f96dc 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -377,6 +377,10 @@ class Scene { } insertElementsAtIndex(elements: ExcalidrawElement[], index: number) { + if (!elements.length) { + return; + } + if (!Number.isFinite(index) || index < 0) { throw new Error( "insertElementAtIndex can only be called with index >= 0", @@ -403,7 +407,11 @@ class Scene { }; insertElements = (elements: ExcalidrawElement[]) => { - const index = elements[0].frameId + if (!elements.length) { + return; + } + + const index = elements[0]?.frameId ? this.getElementIndex(elements[0].frameId) : this.elements.length; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 1f693e6449..61479e55ea 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -242,6 +242,7 @@ export const exportToCanvas = async ( // empty disables embeddable rendering embedsValidationStatus: new Map(), elementsPendingErasure: new Set(), + pendingFlowchartNodes: null, }, }); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 6f478b3106..0ead974b86 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -16,6 +16,7 @@ import type { SocketId, UserIdleState, Device, + PendingExcalidrawElements, } from "../types"; import type { MakeBrand } from "../utility-types"; @@ -33,6 +34,7 @@ export type StaticCanvasRenderConfig = { isExporting: boolean; embedsValidationStatus: EmbedsValidationStatus; elementsPendingErasure: ElementsPendingErasure; + pendingFlowchartNodes: PendingExcalidrawElements | null; }; export type SVGRenderConfig = { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index ce280a9718..0cff81641c 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -648,6 +648,7 @@ export type AppClassProperties = { onMagicframeToolSelect: App["onMagicframeToolSelect"]; getName: App["getName"]; dismissLinearEditor: App["dismissLinearEditor"]; + flowChartCreator: App["flowChartCreator"]; }; export type PointerDownState = Readonly<{ @@ -828,3 +829,5 @@ export type EmbedsValidationStatus = Map< >; export type ElementsPendingErasure = Set; + +export type PendingExcalidrawElements = ExcalidrawElement[]; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index c8e8c743df..3216eba5c2 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -929,6 +929,12 @@ export const assertNever = ( 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). */