feat: flip arrowheads if only arrow(s) selected (#8525)

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
David Luzar 2024-09-19 15:46:36 +02:00 committed by GitHub
parent f3f0ab7c83
commit 8ca4cf3260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 222 additions and 68 deletions

View file

@ -3,87 +3,209 @@ import { Excalidraw } from "../index";
import { render } from "../tests/test-utils"; import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { point } from "../../math"; import { point } from "../../math";
import { actionFlipHorizontal } from "./actionFlip"; import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window; const { h } = window;
const testElements = [ describe("flipping re-centers selection", () => {
API.createElement({ it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
type: "rectangle", const elements = [
id: "rec1", API.createElement({
x: 1046, type: "rectangle",
y: 541, id: "rec1",
width: 100, x: 100,
height: 100, y: 100,
boundElements: [ width: 100,
{ height: 100,
id: "arr", boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow", type: "arrow",
},
],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 1169,
y: 777,
width: 102,
height: 115,
boundElements: [
{
id: "arr", id: "arr",
type: "arrow", x: 149.9,
}, y: 95,
], width: 156,
}), height: 239.9,
API.createElement({ startBinding: {
type: "arrow", elementId: "rec1",
id: "arrow", focus: 0,
x: 1103.0717787616313, gap: 5,
y: 536.8531862198708, fixedPoint: [0.49, -0.05],
width: 159.68539325842903, },
height: 333.0396003698186, endBinding: {
startBinding: { elementId: "rec2",
elementId: "rec1", focus: 0,
focus: 0.1366906474820229, gap: 5,
gap: 5.000000000000057, fixedPoint: [-0.05, 0.49],
fixedPoint: [0.5683453237410123, -0.05014327585315258], },
}, startArrowhead: null,
endBinding: { endArrowhead: "arrow",
elementId: "rec2", points: [
focus: 0.0014925373134265828, point(0, 0),
gap: 5, point(0, -35),
fixedPoint: [-0.04862325174825108, 0.4992537313432874], point(-90.9, -35),
}, point(-90.9, 204.9),
points: [ point(65.1, 204.9),
point(0, 0), ],
point(0, -35), elbowed: true,
point(-97.80898876404626, -35), }),
point(-97.80898876404626, 298.0396003698186), ];
point(61.87640449438277, 298.0396003698186), await render(<Excalidraw initialData={{ elements }} />);
],
elbowed: true,
}),
];
describe("flipping action", () => { API.setSelectedElements(elements);
it("flip re-centers the selection even after multiple flip actions", async () => {
await render(<Excalidraw initialData={{ elements: testElements }} />);
API.setSelectedElements(testElements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3); expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1"); const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(1113.78, 0); expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(541, 0); expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2"); const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(988.72, 0); expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(777, 0); expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
}); });
}); });

View file

@ -2,6 +2,7 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
@ -19,9 +20,13 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { isElbowArrow, isLinearElement } from "../element/typeChecks"; import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { mutateElement } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@ -112,6 +117,21 @@ const flipElements = (
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } = const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements); getCommonBoundingBox(selectedElements);

View file

@ -129,6 +129,10 @@ export class API {
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
}; };
static getElement = <T extends ExcalidrawElement>(element: T): T => {
return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
}
static createElement = < static createElement = <
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle", T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
>({ >({
@ -186,6 +190,12 @@ export class API {
endBinding?: T extends "arrow" endBinding?: T extends "arrow"
? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"] ? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"]
: never; : never;
startArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
: never;
endArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
: never;
elbowed?: boolean; elbowed?: boolean;
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
@ -342,6 +352,8 @@ export class API {
if (element.type === "arrow") { if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null; element.startBinding = rest.startBinding ?? null;
element.endBinding = rest.endBinding ?? null; element.endBinding = rest.endBinding ?? null;
element.startArrowhead = rest.startArrowhead ?? null;
element.endArrowhead = rest.endArrowhead ?? null;
} }
if (id) { if (id) {
element.id = id; element.id = id;