mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-10 02:49:57 +02:00

Fix binding Remove unneeded params Unfinished simple arrow avoidance Fix newly created jumping arrow when gets outside Do not apply the jumping logic to elbow arrows for new elements Existing arrows now jump out Type updates to support fixed binding for simple arrows Fix crash for elbow arrws in mutateElement() Refactored simple arrow creation Updating tests No confirm threshold when inside biding range Fix multi-point arrow grid off Make elbow arrows respect grids Unbind arrow if bound and moved at shaft of arrow key Fix binding test Fix drag unbind when the bound element is in the selection Do not move mid point for simple arrows bound on both ends Add test for mobing mid points for simple arrows when bound on the same element on both ends Fix linear editor bug when both midpoint and endpoint is moved Fix all point multipoint arrow highlight and binding Arrow dragging gets a little drag to avoid accidental unbinding Fixed point binding for simple arrows when the arrow doesn't point to the element Fix binding disabled use-case triggering arrow editor Timed binding mode change for simple arrows Apply fixes Remove code to unbind on drag Update simple arrow fixed point when arrow is dragged or moved by arrow keys Binding highlight fixes Change bind mode timeout logic Fix tests Add Alt bindMode switch No dragging of arrows when bound, similar to elbow Fix timeout not taking effect immediately Bumop z-index for arrows when dragged Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Only transparent bindables allow binding fallthrough Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix lint Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point click array creation interaction with fixed point binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Restrict new behavior to arrows only Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Allow binding inside images Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix already existing fixed binding retention Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Refactor and implement fixed point binding for unfilled elements Restore drag Removed point binding Binding code refactor Added centered focus point Binding & focus point debug Add invariants to check binding integrity in elements Binding fixes Small refactors Completely rewritten binding Include point updates after binding update Fix point updates when endpoint dragged and opposite endpoint orbits centered focus point only for new arrows Make z-index arrow reorder on bind Turn off inside binding mode after leaving a shape Remove invariants from debug feat: expose `applyTo` options, don't commit empty text element (#9744) * Expose applyTo options, skip re-draw for empty text * Don't commit empty text elements test: added test file for distribute (#9754) z-index update Bind mode on precise binding Fix binding to inside element Fix initial arrow not following cursor (white dot) Fix elbow arrow Fix z-index so it works on hover Fix fixed angle orbiting Move point click arrow creation over to common strategy Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Add binding strategy for drag arrow creation Fix elbow arrow Fix point handles Snap to center Fix transparent shape binding Internal arrow creation fix Fix point binding Fix selection bug Fix new arrow focus point Images now always bind inside Flashing arrow creation on binding band Add watchState debug method to window.h Fix debug canvas crash Remove non-needed bind mode Fix restore No keyboard movement when bound Add actionFinalize when arrow in edit mode Add drag to the Stats panel when bound arrow is moved Further simplify curve tracking Add typing to action register() Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix point at finalize Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix type errors Signed-off-by: Mark Tolmacs <mark@lazycat.hu> New arrow binding rules Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix cyclical dep Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fix jiggly arrows Fix jiggly arrow x2 Long inside-other binding Click-click binding Fix arrows Performance [PERF] Replace in-place Jacobian derivation with analytical version Different approach to inside binding Signed-off-by: Mark Tolmacs <mark@lazycat.hu> Fixes Fix inconsistent arrow start jump out Change how images are bound to on new arrow creation Lower timeout Small insurance fix
829 lines
24 KiB
TypeScript
829 lines
24 KiB
TypeScript
import { KEYS, arrayToMap } from "@excalidraw/common";
|
|
|
|
import { pointFrom } from "@excalidraw/math";
|
|
|
|
import { actionWrapTextInContainer } from "@excalidraw/excalidraw/actions/actionBoundText";
|
|
|
|
import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
|
|
|
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
|
|
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
|
|
|
import { getTransformHandles } from "../src/transformHandles";
|
|
import {
|
|
getTextEditor,
|
|
TEXT_EDITOR_SELECTOR,
|
|
} from "../../excalidraw/tests/queries/dom";
|
|
|
|
import type { ExcalidrawLinearElement, FixedPointBinding } from "../src/types";
|
|
|
|
const { h } = window;
|
|
|
|
const mouse = new Pointer("mouse");
|
|
|
|
describe("element binding", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
});
|
|
|
|
it("should create valid binding if duplicate start/end points", async () => {
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
x: 0,
|
|
y: 0,
|
|
width: 50,
|
|
height: 50,
|
|
});
|
|
const arrow = API.createElement({
|
|
type: "arrow",
|
|
x: 100,
|
|
y: 0,
|
|
width: 100,
|
|
height: 1,
|
|
points: [
|
|
pointFrom(0, 0),
|
|
pointFrom(0, 0),
|
|
pointFrom(100, 0),
|
|
pointFrom(100, 0),
|
|
],
|
|
});
|
|
API.setElements([rect, arrow]);
|
|
expect(arrow.startBinding).toBe(null);
|
|
|
|
// select arrow
|
|
mouse.clickAt(150, 0);
|
|
|
|
// move arrow start to potential binding position
|
|
mouse.downAt(100, 0);
|
|
mouse.moveTo(55, 0);
|
|
mouse.up(0, 0);
|
|
|
|
// Point selection is evaluated like the points are rendered,
|
|
// from right to left. So clicking on the first point should move the joint,
|
|
// not the start point.
|
|
expect(arrow.startBinding).toBe(null);
|
|
|
|
// Now that the start point is free, move it into overlapping position
|
|
mouse.downAt(100, 0);
|
|
mouse.moveTo(55, 0);
|
|
mouse.up(0, 0);
|
|
|
|
expect(API.getSelectedElements()).toEqual([arrow]);
|
|
|
|
expect(arrow.startBinding).toEqual({
|
|
elementId: rect.id,
|
|
focus: 0,
|
|
gap: 0,
|
|
fixedPoint: expect.arrayContaining([1.1, 0]),
|
|
});
|
|
|
|
// Move the end point to the overlapping binding position
|
|
mouse.downAt(200, 0);
|
|
mouse.moveTo(55, 0);
|
|
mouse.up(0, 0);
|
|
|
|
// Both the start and the end points should be bound
|
|
expect(arrow.startBinding).toEqual({
|
|
elementId: rect.id,
|
|
focus: 0,
|
|
gap: 0,
|
|
fixedPoint: expect.arrayContaining([1.1, 0]),
|
|
});
|
|
expect(arrow.endBinding).toEqual({
|
|
elementId: rect.id,
|
|
focus: 0,
|
|
gap: 0,
|
|
fixedPoint: expect.arrayContaining([1.1, 0]),
|
|
});
|
|
});
|
|
|
|
//@TODO fix the test with rotation
|
|
it.skip("rotation of arrow should rebind both ends", () => {
|
|
const rectLeft = UI.createElement("rectangle", {
|
|
x: 0,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const rectRight = UI.createElement("rectangle", {
|
|
x: 400,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 210,
|
|
y: 250,
|
|
width: 180,
|
|
height: 1,
|
|
});
|
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
|
|
|
const rotation = getTransformHandles(
|
|
arrow,
|
|
h.state.zoom,
|
|
arrayToMap(h.elements),
|
|
"mouse",
|
|
).rotation!;
|
|
const rotationHandleX = rotation[0] + rotation[2] / 2;
|
|
const rotationHandleY = rotation[1] + rotation[3] / 2;
|
|
mouse.down(rotationHandleX, rotationHandleY);
|
|
mouse.move(300, 400);
|
|
mouse.up();
|
|
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
|
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
|
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
|
|
});
|
|
|
|
// TODO fix & reenable once we rewrite tests to work with concurrency
|
|
it.skip(
|
|
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
|
"editing by clicking on element A should end up selecting A",
|
|
async () => {
|
|
UI.createElement("rectangle", {
|
|
y: 0,
|
|
size: 100,
|
|
});
|
|
// Create arrow bound to rectangle
|
|
UI.clickTool("arrow");
|
|
mouse.down(50, -100);
|
|
mouse.up(0, 80);
|
|
|
|
// Edit arrow with multi-point
|
|
mouse.doubleClick();
|
|
// move arrow head
|
|
mouse.down();
|
|
mouse.up(0, 10);
|
|
expect(API.getSelectedElement().type).toBe("arrow");
|
|
|
|
// NOTE this mouse down/up + await needs to be done in order to repro
|
|
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
|
mouse.reset();
|
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
|
mouse.down(0, 0);
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
mouse.up();
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
},
|
|
);
|
|
|
|
it("should unbind arrow when moving it with keyboard", () => {
|
|
const rectangle = UI.createElement("rectangle", {
|
|
x: 75,
|
|
y: 0,
|
|
size: 100,
|
|
});
|
|
|
|
// Creates arrow 1px away from bidding with rectangle
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 0,
|
|
y: 0,
|
|
size: 49,
|
|
});
|
|
|
|
expect(arrow.endBinding).toBe(null);
|
|
|
|
mouse.downAt(49, 49);
|
|
mouse.moveTo(51, 0);
|
|
mouse.up(0, 0);
|
|
|
|
// Test sticky connection
|
|
expect(API.getSelectedElement().type).toBe("arrow");
|
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
|
|
|
// Sever connection
|
|
expect(API.getSelectedElement().type).toBe("arrow");
|
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
|
expect(arrow.endBinding).not.toBe(null);
|
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
|
expect(arrow.endBinding).not.toBe(null);
|
|
});
|
|
|
|
it("should unbind on bound element deletion", () => {
|
|
const rectangle = UI.createElement("rectangle", {
|
|
x: 60,
|
|
y: 0,
|
|
size: 100,
|
|
});
|
|
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 0,
|
|
y: 0,
|
|
size: 50,
|
|
});
|
|
|
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
|
|
|
mouse.select(rectangle);
|
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
|
Keyboard.keyDown(KEYS.DELETE);
|
|
expect(arrow.endBinding).toBe(null);
|
|
});
|
|
|
|
it("should unbind on text element deletion by submitting empty text", async () => {
|
|
const text = API.createElement({
|
|
type: "text",
|
|
text: "ola",
|
|
x: 60,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
API.setElements([text]);
|
|
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 0,
|
|
y: 0,
|
|
size: 50,
|
|
});
|
|
|
|
expect(arrow.endBinding?.elementId).toBe(text.id);
|
|
|
|
// edit text element and submit
|
|
// -------------------------------------------------------------------------
|
|
|
|
UI.clickTool("text");
|
|
|
|
mouse.clickAt(text.x + 50, text.y + 50);
|
|
|
|
const editor = await getTextEditor();
|
|
|
|
fireEvent.change(editor, { target: { value: "" } });
|
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
|
|
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
|
expect(arrow.endBinding).toBe(null);
|
|
});
|
|
|
|
it("should keep binding on text update", async () => {
|
|
const text = API.createElement({
|
|
type: "text",
|
|
text: "ola",
|
|
x: 60,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
API.setElements([text]);
|
|
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 0,
|
|
y: 0,
|
|
size: 50,
|
|
});
|
|
|
|
expect(arrow.endBinding?.elementId).toBe(text.id);
|
|
|
|
// delete text element by submitting empty text
|
|
// -------------------------------------------------------------------------
|
|
|
|
UI.clickTool("text");
|
|
|
|
mouse.clickAt(text.x + 50, text.y + 50);
|
|
const editor = await getTextEditor();
|
|
|
|
expect(editor).not.toBe(null);
|
|
|
|
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
|
|
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
|
expect(arrow.endBinding?.elementId).toBe(text.id);
|
|
});
|
|
|
|
it("should update binding when text containerized", async () => {
|
|
const rectangle1 = API.createElement({
|
|
type: "rectangle",
|
|
id: "rectangle1",
|
|
width: 100,
|
|
height: 100,
|
|
boundElements: [
|
|
{ id: "arrow1", type: "arrow" },
|
|
{ id: "arrow2", type: "arrow" },
|
|
],
|
|
});
|
|
|
|
const arrow1 = API.createElement({
|
|
type: "arrow",
|
|
id: "arrow1",
|
|
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
|
startBinding: {
|
|
elementId: "rectangle1",
|
|
fixedPoint: [0.5, 1],
|
|
mode: "orbit",
|
|
},
|
|
endBinding: {
|
|
elementId: "text1",
|
|
fixedPoint: [1, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
});
|
|
|
|
const arrow2 = API.createElement({
|
|
type: "arrow",
|
|
id: "arrow2",
|
|
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
|
|
startBinding: {
|
|
elementId: "text1",
|
|
fixedPoint: [0.5, 1],
|
|
mode: "orbit",
|
|
},
|
|
endBinding: {
|
|
elementId: "rectangle1",
|
|
fixedPoint: [1, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
});
|
|
|
|
const text1 = API.createElement({
|
|
type: "text",
|
|
id: "text1",
|
|
text: "ola",
|
|
boundElements: [
|
|
{ id: "arrow1", type: "arrow" },
|
|
{ id: "arrow2", type: "arrow" },
|
|
],
|
|
});
|
|
|
|
API.setElements([rectangle1, arrow1, arrow2, text1]);
|
|
|
|
API.setSelectedElements([text1]);
|
|
|
|
expect(h.state.selectedElementIds[text1.id]).toBe(true);
|
|
|
|
API.executeAction(actionWrapTextInContainer);
|
|
|
|
// new text container will be placed before the text element
|
|
const container = h.elements.at(-2)!;
|
|
|
|
expect(container.type).toBe("rectangle");
|
|
expect(container.id).not.toBe(rectangle1.id);
|
|
|
|
expect(container).toEqual(
|
|
expect.objectContaining({
|
|
boundElements: expect.arrayContaining([
|
|
{
|
|
type: "text",
|
|
id: text1.id,
|
|
},
|
|
{
|
|
type: "arrow",
|
|
id: arrow1.id,
|
|
},
|
|
{
|
|
type: "arrow",
|
|
id: arrow2.id,
|
|
},
|
|
]),
|
|
}),
|
|
);
|
|
|
|
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
|
|
expect(arrow1.endBinding?.elementId).toBe(container.id);
|
|
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
|
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
|
});
|
|
|
|
// #6459
|
|
it("should unbind arrow only from the latest element", () => {
|
|
const rectLeft = UI.createElement("rectangle", {
|
|
x: 0,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const rectRight = UI.createElement("rectangle", {
|
|
x: 400,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 210,
|
|
y: 250,
|
|
width: 180,
|
|
height: 1,
|
|
});
|
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
|
|
|
// Drag arrow off of bound rectangle range
|
|
const handles = getTransformHandles(
|
|
arrow,
|
|
h.state.zoom,
|
|
arrayToMap(h.elements),
|
|
"mouse",
|
|
).se!;
|
|
|
|
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
|
const elX = handles[0] + handles[2] / 2;
|
|
const elY = handles[1] + handles[3] / 2;
|
|
mouse.downAt(elX, elY);
|
|
mouse.moveTo(300, 400);
|
|
mouse.up();
|
|
|
|
expect(arrow.startBinding).not.toBe(null);
|
|
expect(arrow.endBinding).toBe(null);
|
|
});
|
|
|
|
it("should not unbind when duplicating via selection group", () => {
|
|
const rectLeft = UI.createElement("rectangle", {
|
|
x: 0,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const rectRight = UI.createElement("rectangle", {
|
|
x: 400,
|
|
y: 200,
|
|
width: 200,
|
|
height: 500,
|
|
});
|
|
const arrow = UI.createElement("arrow", {
|
|
x: 210,
|
|
y: 250,
|
|
width: 177,
|
|
height: 1,
|
|
});
|
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
|
|
|
mouse.downAt(-100, -100);
|
|
mouse.moveTo(650, 750);
|
|
mouse.up(0, 0);
|
|
|
|
expect(API.getSelectedElements().length).toBe(3);
|
|
|
|
mouse.moveTo(5, 5);
|
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
mouse.downAt(5, 5);
|
|
mouse.moveTo(1000, 1000);
|
|
mouse.up(0, 0);
|
|
|
|
expect(window.h.elements.length).toBe(6);
|
|
window.h.elements.forEach((element) => {
|
|
if (isLinearElement(element)) {
|
|
expect(element.startBinding).not.toBe(null);
|
|
expect(element.endBinding).not.toBe(null);
|
|
} else {
|
|
expect(element.boundElements).not.toBe(null);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Fixed-point arrow binding", () => {
|
|
beforeEach(async () => {
|
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
});
|
|
|
|
it("should create fixed-point binding when both arrow endpoint is inside rectangle", () => {
|
|
// Create a filled solid rectangle
|
|
UI.clickTool("rectangle");
|
|
mouse.downAt(100, 100);
|
|
mouse.moveTo(200, 200);
|
|
mouse.up();
|
|
|
|
const rect = API.getSelectedElement();
|
|
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
|
|
|
|
// Draw arrow with endpoint inside the filled rectangle, since only
|
|
// filled bindables bind inside the shape
|
|
UI.clickTool("arrow");
|
|
mouse.downAt(110, 110);
|
|
mouse.moveTo(160, 160);
|
|
mouse.up();
|
|
|
|
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
|
|
expect(arrow.x).toBe(110);
|
|
expect(arrow.y).toBe(110);
|
|
|
|
// Should bind to the rectangle since endpoint is inside
|
|
expect(arrow.startBinding?.elementId).toBe(rect.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
|
|
|
const startBinding = arrow.startBinding as FixedPointBinding;
|
|
expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
|
|
expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
|
|
expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
|
|
expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
|
|
|
|
const endBinding = arrow.endBinding as FixedPointBinding;
|
|
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
|
|
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
|
|
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
|
|
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
|
|
|
|
mouse.reset();
|
|
|
|
// Move the bindable
|
|
mouse.downAt(130, 110);
|
|
mouse.moveTo(280, 110);
|
|
mouse.up();
|
|
|
|
// Check if the arrow moved
|
|
expect(arrow.x).toBe(260);
|
|
expect(arrow.y).toBe(110);
|
|
});
|
|
|
|
it("should create fixed-point binding when one of the arrow endpoint is inside rectangle", () => {
|
|
// Create a filled solid rectangle
|
|
UI.clickTool("rectangle");
|
|
mouse.downAt(100, 100);
|
|
mouse.moveTo(200, 200);
|
|
mouse.up();
|
|
|
|
const rect = API.getSelectedElement();
|
|
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
|
|
|
|
// Draw arrow with endpoint inside the filled rectangle, since only
|
|
// filled bindables bind inside the shape
|
|
UI.clickTool("arrow");
|
|
mouse.downAt(10, 10);
|
|
mouse.moveTo(160, 160);
|
|
mouse.up();
|
|
|
|
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
|
|
expect(arrow.x).toBe(10);
|
|
expect(arrow.y).toBe(10);
|
|
expect(arrow.width).toBe(150);
|
|
expect(arrow.height).toBe(150);
|
|
|
|
// Should bind to the rectangle since endpoint is inside
|
|
expect(arrow.startBinding).toBe(null);
|
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
|
|
|
const endBinding = arrow.endBinding as FixedPointBinding;
|
|
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
|
|
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
|
|
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
|
|
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
|
|
|
|
mouse.reset();
|
|
|
|
// Move the bindable
|
|
mouse.downAt(130, 110);
|
|
mouse.moveTo(280, 110);
|
|
mouse.up();
|
|
|
|
// Check if the arrow moved
|
|
expect(arrow.x).toBe(10);
|
|
expect(arrow.y).toBe(10);
|
|
expect(arrow.width).toBe(300);
|
|
expect(arrow.height).toBe(150);
|
|
});
|
|
|
|
it("should maintain relative position when arrow start point is dragged outside and rectangle is moved", () => {
|
|
// Create a filled solid rectangle
|
|
UI.clickTool("rectangle");
|
|
mouse.downAt(100, 100);
|
|
mouse.moveTo(200, 200);
|
|
mouse.up();
|
|
|
|
const rect = API.getSelectedElement();
|
|
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
|
|
|
|
// Draw arrow with both endpoints inside the filled rectangle, creating same-element binding
|
|
UI.clickTool("arrow");
|
|
mouse.downAt(120, 120);
|
|
mouse.moveTo(180, 180);
|
|
mouse.up();
|
|
|
|
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
|
|
|
|
// Both ends should be bound to the same rectangle
|
|
expect(arrow.startBinding?.elementId).toBe(rect.id);
|
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
|
|
|
mouse.reset();
|
|
|
|
// Select the arrow and drag the start point outside the rectangle
|
|
mouse.downAt(120, 120);
|
|
mouse.moveTo(50, 50); // Move start point outside rectangle
|
|
mouse.up();
|
|
|
|
mouse.reset();
|
|
|
|
// Move the rectangle by dragging it
|
|
mouse.downAt(150, 110);
|
|
mouse.moveTo(300, 300);
|
|
mouse.up();
|
|
|
|
expect(arrow.x).toBe(50);
|
|
expect(arrow.y).toBe(50);
|
|
expect(arrow.width).toBeCloseTo(280, 0);
|
|
expect(arrow.height).toBeCloseTo(320, 0);
|
|
});
|
|
|
|
it("should move inner points when arrow is bound to same element on both ends", () => {
|
|
// Create one rectangle as binding target
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
x: 50,
|
|
y: 50,
|
|
width: 200,
|
|
height: 100,
|
|
fillStyle: "solid",
|
|
backgroundColor: "#a5d8ff",
|
|
});
|
|
|
|
// Create a non-elbowed arrow with inner points bound to the same element on both ends
|
|
const arrow = API.createElement({
|
|
type: "arrow",
|
|
x: 100,
|
|
y: 75,
|
|
width: 100,
|
|
height: 50,
|
|
points: [
|
|
pointFrom(0, 0), // start point
|
|
pointFrom(25, -25), // first inner point
|
|
pointFrom(75, 25), // second inner point
|
|
pointFrom(100, 0), // end point
|
|
],
|
|
startBinding: {
|
|
elementId: rect.id,
|
|
fixedPoint: [0.25, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
endBinding: {
|
|
elementId: rect.id,
|
|
fixedPoint: [0.75, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
});
|
|
|
|
API.setElements([rect, arrow]);
|
|
|
|
// Store original inner point positions (local coordinates)
|
|
const originalInnerPoint1 = [...arrow.points[1]];
|
|
const originalInnerPoint2 = [...arrow.points[2]];
|
|
|
|
// Move the rectangle
|
|
mouse.reset();
|
|
mouse.downAt(150, 100); // Click on the rectangle
|
|
mouse.moveTo(300, 200); // Move it down and to the right
|
|
mouse.up();
|
|
|
|
// Verify that inner points moved with the arrow (same local coordinates)
|
|
// When both ends are bound to the same element, inner points should maintain
|
|
// their local coordinates relative to the arrow's origin
|
|
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
|
|
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
|
|
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
|
|
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
|
|
});
|
|
|
|
it("should NOT move inner points when arrow is bound to different elements", () => {
|
|
// Create two rectangles as binding targets
|
|
const rectLeft = API.createElement({
|
|
type: "rectangle",
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
const rectRight = API.createElement({
|
|
type: "rectangle",
|
|
x: 300,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
// Create a non-elbowed arrow with inner points bound to different elements
|
|
const arrow = API.createElement({
|
|
type: "arrow",
|
|
x: 100,
|
|
y: 50,
|
|
width: 200,
|
|
height: 0,
|
|
points: [
|
|
pointFrom(0, 0), // start point
|
|
pointFrom(50, -20), // first inner point
|
|
pointFrom(150, 20), // second inner point
|
|
pointFrom(200, 0), // end point
|
|
],
|
|
startBinding: {
|
|
elementId: rectLeft.id,
|
|
fixedPoint: [0.5, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
endBinding: {
|
|
elementId: rectRight.id,
|
|
fixedPoint: [0.5, 0.5],
|
|
mode: "orbit",
|
|
},
|
|
});
|
|
|
|
API.setElements([rectLeft, rectRight, arrow]);
|
|
|
|
// Store original inner point positions
|
|
const originalInnerPoint1 = [...arrow.points[1]];
|
|
const originalInnerPoint2 = [...arrow.points[2]];
|
|
|
|
// Move the right rectangle down by 50 pixels
|
|
mouse.reset();
|
|
mouse.downAt(350, 50); // Click on the right rectangle
|
|
mouse.moveTo(350, 100); // Move it down
|
|
mouse.up();
|
|
|
|
// Verify that inner points did NOT move when bound to different elements
|
|
// The arrow should NOT translate inner points proportionally when only one end moves
|
|
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
|
|
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
|
|
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
|
|
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
|
|
});
|
|
});
|
|
|
|
describe("line segment extension binding", () => {
|
|
beforeEach(async () => {
|
|
mouse.reset();
|
|
|
|
await act(() => {
|
|
return setLanguage(defaultLang);
|
|
});
|
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
});
|
|
|
|
it("should use point binding when extended segment intersects element", () => {
|
|
// Create a rectangle that will be intersected by the extended arrow segment
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
x: 100,
|
|
y: 100,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
API.setElements([rect]);
|
|
|
|
// Draw an arrow that points at the rectangle (extended segment will intersect)
|
|
UI.clickTool("arrow");
|
|
mouse.downAt(0, 0); // Start point
|
|
mouse.moveTo(120, 95); // End point - arrow direction points toward rectangle
|
|
mouse.up();
|
|
|
|
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
|
|
|
|
// Should create a normal point binding since the extended line segment
|
|
// from the last arrow segment intersects the rectangle
|
|
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
|
expect(arrow.endBinding).toHaveProperty("focus");
|
|
expect(arrow.endBinding).toHaveProperty("gap");
|
|
});
|
|
|
|
it("should use fixed point binding when extended segment misses element", () => {
|
|
// Create a rectangle positioned so the extended arrow segment will miss it
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
x: 100,
|
|
y: 100,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
|
|
API.setElements([rect]);
|
|
|
|
// Draw an arrow that doesn't point at the rectangle (extended segment will miss)
|
|
UI.clickTool("arrow");
|
|
mouse.reset();
|
|
mouse.downAt(125, 93); // Start point
|
|
mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle
|
|
mouse.up();
|
|
|
|
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
|
|
|
|
// Should create a fixed point binding since the extended line segment
|
|
// from the last arrow segment misses the rectangle
|
|
expect(arrow.startBinding?.elementId).toBe(rect.id);
|
|
expect(arrow.startBinding).toHaveProperty("fixedPoint");
|
|
expect(
|
|
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
|
|
).toBeGreaterThanOrEqual(0);
|
|
expect(
|
|
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
|
|
).toBeLessThanOrEqual(1);
|
|
expect(
|
|
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
|
|
).toBeLessThanOrEqual(0.5);
|
|
expect(
|
|
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
|
|
).toBeLessThanOrEqual(1);
|
|
expect(arrow.endBinding).toBe(null);
|
|
});
|
|
});
|