diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 9485dcb222..97df0b32ce 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -38,6 +38,7 @@ import { } from "../../excalidraw/tests/queries/dom"; import type { + ExcalidrawArrowElement, ExcalidrawElement, ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, @@ -328,11 +329,69 @@ describe("Test Linear Elements", () => { createTwoPointerLinearElement("arrow"); expect(h.state.selectedLinearElement?.isEditing).toBe(false); - mouse.doubleClick(); + mouse.doubleClickAt(midpoint[0], midpoint[1]); expect(h.state.selectedLinearElement?.isEditing).toBe(false); await getTextEditor(); }); + describe("Arrowhead toggle on endpoint dblclick", () => { + // the toggle replaces the element (immutable update), so always re-read + // the arrow from the scene instead of holding on to a stale reference + const getArrow = () => h.elements[0] as ExcalidrawArrowElement; + + it("should toggle end arrowhead between default and none", () => { + createTwoPointerLinearElement("arrow"); + expect(getArrow().endArrowhead).toBe(null); + + mouse.doubleClickAt(p2[0], p2[1]); + expect(getArrow().endArrowhead).toBe("arrow"); + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + + mouse.doubleClickAt(p2[0], p2[1]); + expect(getArrow().endArrowhead).toBe(null); + }); + + it("should toggle start arrowhead between default and none", () => { + createTwoPointerLinearElement("arrow"); + expect(getArrow().startArrowhead).toBe(null); + + mouse.doubleClickAt(p1[0], p1[1]); + expect(getArrow().startArrowhead).toBe("arrow"); + expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null); + + mouse.doubleClickAt(p1[0], p1[1]); + expect(getArrow().startArrowhead).toBe(null); + }); + + it("should restore the arrowhead removed by the previous toggle", () => { + const arrow = API.createElement({ + type: "arrow", + x: p1[0], + y: p1[1], + width: p2[0] - p1[0], + height: 0, + points: [pointFrom(0, 0), pointFrom(p2[0] - p1[0], p2[1] - p1[1])], + endArrowhead: "triangle", + }); + API.setElements([arrow]); + mouse.clickAt(midpoint[0], midpoint[1]); + + mouse.doubleClickAt(p2[0], p2[1]); + expect(getArrow().endArrowhead).toBe(null); + + mouse.doubleClickAt(p2[0], p2[1]); + expect(getArrow().endArrowhead).toBe("triangle"); + }); + + it("should not toggle arrowheads on dblclick outside endpoints", () => { + createTwoPointerLinearElement("arrow"); + + mouse.doubleClickAt(midpoint[0], midpoint[1]); + expect(getArrow().startArrowhead).toBe(null); + expect(getArrow().endArrowhead).toBe(null); + }); + }); + it("shouldn't create text element on double click in line editor (arrow)", async () => { createTwoPointerLinearElement("arrow"); const arrow = h.elements[0] as ExcalidrawLinearElement; @@ -1091,7 +1150,7 @@ describe("Test Linear Elements", () => { expect(h.elements.length).toBe(1); expect(h.elements[0].id).toBe(arrow.id); - mouse.doubleClickAt(arrow.x, arrow.y); + mouse.doubleClickAt(midpoint[0], midpoint[1]); expect(h.elements.length).toBe(2); const text = h.elements[1] as ExcalidrawTextElementWithContainer; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index abe11fa8c5..cd6a3425a0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -286,6 +286,7 @@ import type { ExcalidrawEmbeddableElement, Ordered, MagicGenerationData, + Arrowhead, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, @@ -741,6 +742,9 @@ class App extends React.Component { /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; private lastCompletedCanvasClicks: { x: number; y: number }[] = []; + /** arrowheads removed via endpoint double-click toggle, so a subsequent + * toggle can restore the original arrowhead (keyed by `elementId:side`) */ + private removedArrowheads = new Map(); /** previous frame pointer coords */ previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; @@ -6648,6 +6652,49 @@ class App extends React.Component { ); }; + /** + * Toggles the arrowhead at the given endpoint between no arrowhead and the + * arrowhead it had before the last toggle (falling back to the current + * default arrowhead). + */ + private toggleArrowheadAtEndpoint = ( + element: ExcalidrawArrowElement, + side: "start" | "end", + ) => { + const currentArrowhead = + side === "start" ? element.startArrowhead : element.endArrowhead; + + this.store.scheduleCapture(); + + let arrowheadUpdate: + | { startArrowhead: Arrowhead | null } + | { endArrowhead: Arrowhead | null }; + + if (currentArrowhead) { + this.removedArrowheads.set(`${element.id}:${side}`, currentArrowhead); + arrowheadUpdate = + side === "start" ? { startArrowhead: null } : { endArrowhead: null }; + } else { + const arrowhead = + this.removedArrowheads.get(`${element.id}:${side}`) ?? + (side === "start" + ? this.state.currentItemStartArrowhead + : this.state.currentItemEndArrowhead) ?? + "arrow"; + arrowheadUpdate = + side === "start" + ? { startArrowhead: arrowhead } + : { endArrowhead: arrowhead }; + } + + this.scene.mapElements((_element) => { + if (_element.id === element.id && isArrowElement(_element)) { + return newElementWith(_element, arrowheadUpdate); + } + return _element; + }); + }; + private handleCanvasDoubleClick = ( event: Pick< React.MouseEvent, @@ -6686,6 +6733,31 @@ class App extends React.Component { if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) { const selectedLinearElement: ExcalidrawLinearElement = selectedElements[0]; + + if ( + !event[KEYS.CTRL_OR_CMD] && + isArrowElement(selectedLinearElement) && + this.state.selectedLinearElement?.elementId === selectedLinearElement.id + ) { + const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( + selectedLinearElement, + this.scene.getNonDeletedElementsMap(), + this.state.zoom, + sceneX, + sceneY, + ); + if ( + clickedPointIndex === 0 || + clickedPointIndex === selectedLinearElement.points.length - 1 + ) { + this.toggleArrowheadAtEndpoint( + selectedLinearElement, + clickedPointIndex === 0 ? "start" : "end", + ); + return; + } + } + if ( ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) || isLineElement(selectedLinearElement)) && diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index 875ad4b73d..1ca64ab165 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -1,6 +1,7 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common"; import { + isArrowElement, isFlowchartNodeElement, isImageElement, isLinearElement, @@ -184,6 +185,16 @@ const getHints = ({ if (selectedElements.length === 1) { if (isLinearElement(selectedElements[0])) { + if ( + isArrowElement(selectedElements[0]) && + appState.selectedLinearElement?.elementId === + selectedElements[0].id && + (appState.selectedLinearElement.hoverPointIndex === 0 || + appState.selectedLinearElement.hoverPointIndex === + selectedElements[0].points.length - 1) + ) { + return t("hints.toggleArrowhead"); + } if (appState.selectedLinearElement?.isEditing) { return appState.selectedLinearElement.selectedPointsIndices ? t("hints.lineEditor_pointSelected", { diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index d8c045d6c4..987f769b20 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -373,6 +373,7 @@ "resize": "You can constrain proportions by holding {{shortcut_1}} while resizing,\nhold {{shortcut_2}} to resize from the center", "resizeImage": "You can resize freely by holding {{shortcut_1}},\nhold {{shortcut_2}} to resize from the center", "rotate": "You can constrain angles by holding {{shortcut}} while rotating", + "toggleArrowhead": "Double-click to toggle the arrowhead", "lineEditor_info": "Hold {{shortcut_1}} and Double-click or press {{shortcut_2}} to edit points", "lineEditor_line_info": "Double-click or press {{shortcut}} to edit points", "lineEditor_pointSelected": "Press {{shortcut_1}} to remove point(s),\n{{shortcut_2}} to duplicate, or drag to move",