feat(editor): dblclick to toggle arrowhead (#11615)

This commit is contained in:
David Luzar
2026-07-07 14:22:21 +02:00
committed by GitHub
parent aaa14e9df6
commit b2a729c400
4 changed files with 145 additions and 2 deletions
@@ -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;
+72
View File
@@ -286,6 +286,7 @@ import type {
ExcalidrawEmbeddableElement,
Ordered,
MagicGenerationData,
Arrowhead,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
@@ -741,6 +742,9 @@ class App extends React.Component<AppProps, AppState> {
/** 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<string, Arrowhead>();
/** 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<AppProps, AppState> {
);
};
/**
* 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<HTMLCanvasElement>,
@@ -6686,6 +6733,31 @@ class App extends React.Component<AppProps, AppState> {
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)) &&
@@ -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", {
+1
View File
@@ -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",