mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-20 00:16:54 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2201955938 | ||
|
|
b751b4a284 |
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
resizeFlip,
|
||||
isInverted,
|
||||
} from "@excalidraw/excalidraw/interaction/trace";
|
||||
|
||||
import { getCursorForResizingElement } from "../src/resizeTest";
|
||||
|
||||
import type { TransformHandleDirection } from "../src/transformHandles";
|
||||
|
||||
// Milestone 1 oracle validation, bypassing the DOM. The flip-condition logic
|
||||
// (resizeElements.ts `getNextMultipleWidthAndHeightFromPointer`, the
|
||||
// `flipConditionsMap`) is not exported — it's applied via computed inputs — so
|
||||
// we validate the trace `resizeFlip` oracle against an independent transcription
|
||||
// of that map. Agreement between the two differently-structured implementations
|
||||
// is the check that the oracle faithfully models the real branch.
|
||||
|
||||
// Transcribed verbatim from resizeElements.ts `flipConditionsMap`. Each entry is
|
||||
// [flipX, flipY] as a function of pointer vs anchor.
|
||||
const referenceFlip = (
|
||||
handle: TransformHandleDirection,
|
||||
p: { x: number; y: number },
|
||||
a: { x: number; y: number },
|
||||
): { x: boolean; y: boolean } => {
|
||||
const map: Record<TransformHandleDirection, [boolean, boolean]> = {
|
||||
ne: [p.x < a.x, p.y > a.y],
|
||||
se: [p.x < a.x, p.y < a.y],
|
||||
sw: [p.x > a.x, p.y < a.y],
|
||||
nw: [p.x > a.x, p.y > a.y],
|
||||
e: [p.x < a.x, false],
|
||||
w: [p.x > a.x, false],
|
||||
n: [false, p.y > a.y],
|
||||
s: [false, p.y < a.y],
|
||||
};
|
||||
const [x, y] = map[handle];
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const HANDLES: TransformHandleDirection[] = [
|
||||
"n",
|
||||
"s",
|
||||
"e",
|
||||
"w",
|
||||
"ne",
|
||||
"nw",
|
||||
"se",
|
||||
"sw",
|
||||
];
|
||||
|
||||
// pointer in every quadrant around the anchor, plus on-axis ties
|
||||
const anchor = { x: 0, y: 0 };
|
||||
const POINTERS = [
|
||||
{ x: 5, y: 5 },
|
||||
{ x: -5, y: 5 },
|
||||
{ x: 5, y: -5 },
|
||||
{ x: -5, y: -5 },
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 5, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
];
|
||||
|
||||
describe("resizeFlip oracle matches the source flipConditionsMap", () => {
|
||||
for (const handle of HANDLES) {
|
||||
it(`handle ${handle}`, () => {
|
||||
for (const p of POINTERS) {
|
||||
expect(resizeFlip(handle, p, anchor)).toEqual(
|
||||
referenceFlip(handle, p, anchor),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("only flips along axes the handle drives", () => {
|
||||
// side handles never flip on their fixed axis regardless of pointer
|
||||
expect(resizeFlip("e", { x: 100, y: -100 }, anchor).y).toBe(false);
|
||||
expect(resizeFlip("w", { x: -100, y: 100 }, anchor).y).toBe(false);
|
||||
expect(resizeFlip("n", { x: 100, y: 100 }, anchor).x).toBe(false);
|
||||
expect(resizeFlip("s", { x: -100, y: -100 }, anchor).x).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inversion predicate and cursor (resizeTest.ts)", () => {
|
||||
const cursor = (
|
||||
width: number,
|
||||
height: number,
|
||||
handle: "nw" | "se" | "ne" | "sw",
|
||||
) =>
|
||||
getCursorForResizingElement({
|
||||
element: { width, height, angle: 0 } as any,
|
||||
transformHandleType: handle,
|
||||
});
|
||||
|
||||
it("isInverted is true only when width/height have opposite signs", () => {
|
||||
expect(isInverted({ width: 100, height: 50 })).toBe(false);
|
||||
expect(isInverted({ width: -100, height: -50 })).toBe(false);
|
||||
expect(isInverted({ width: -100, height: 50 })).toBe(true);
|
||||
expect(isInverted({ width: 100, height: -50 })).toBe(true);
|
||||
});
|
||||
|
||||
// Exit-gate: both nw/se inverted-cursor cases must be exercised.
|
||||
it("nw/se cursor swaps under inversion, matches isInverted", () => {
|
||||
expect(cursor(100, 50, "nw")).toBe("nwse-resize");
|
||||
expect(cursor(100, 50, "se")).toBe("nwse-resize");
|
||||
// inverted → swapped diagonal
|
||||
expect(cursor(-100, 50, "nw")).toBe("nesw-resize");
|
||||
expect(cursor(-100, 50, "se")).toBe("nesw-resize");
|
||||
expect(isInverted({ width: -100, height: 50 })).toBe(true);
|
||||
});
|
||||
|
||||
it("ne/sw cursor swaps under inversion too", () => {
|
||||
expect(cursor(100, 50, "ne")).toBe("nesw-resize");
|
||||
expect(cursor(100, 50, "sw")).toBe("nesw-resize");
|
||||
expect(cursor(100, -50, "ne")).toBe("nwse-resize");
|
||||
expect(cursor(100, -50, "sw")).toBe("nwse-resize");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type Radians } from "@excalidraw/math";
|
||||
|
||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import type { EditorInterface } from "@excalidraw/common";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
import type { Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getOmitSidesForEditorInterface,
|
||||
DEFAULT_OMIT_SIDES,
|
||||
} from "../src/transformHandles";
|
||||
import { getTransformHandleTypeFromCoords } from "../src/resizeTest";
|
||||
|
||||
import type { TransformHandleType } from "../src/transformHandles";
|
||||
|
||||
// Fills the confirmed unit-test gap for handle-quadrant selection: which resize
|
||||
// handle a pointer position resolves to. Pure geometry, no App/DOM.
|
||||
|
||||
const zoom = { value: 1 } as Zoom;
|
||||
|
||||
const desktop: EditorInterface = {
|
||||
formFactor: "desktop",
|
||||
desktopUIMode: "full",
|
||||
userAgent: { isMobileDevice: false, platform: "other" },
|
||||
isTouchScreen: false,
|
||||
canFitSidebar: true,
|
||||
isLandscape: true,
|
||||
};
|
||||
|
||||
// Rectangle bounds, well clear of the origin so handle rects don't overlap.
|
||||
const bounds: Bounds = [100, 100, 300, 200];
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
|
||||
const handleCenters = () => {
|
||||
const handles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0 as Radians,
|
||||
zoom,
|
||||
"mouse",
|
||||
getOmitSidesForEditorInterface(desktop),
|
||||
);
|
||||
return Object.entries(handles).flatMap(([key, rect]) =>
|
||||
rect ? [[key as TransformHandleType, rect] as const] : [],
|
||||
);
|
||||
};
|
||||
|
||||
describe("getTransformHandleTypeFromCoords", () => {
|
||||
it("resolves a pointer at each handle's center to that handle", () => {
|
||||
for (const [key, [hx, hy, hw, hh]] of handleCenters()) {
|
||||
const found = getTransformHandleTypeFromCoords(
|
||||
bounds,
|
||||
hx + hw / 2,
|
||||
hy + hh / 2,
|
||||
zoom,
|
||||
"mouse",
|
||||
desktop,
|
||||
);
|
||||
expect(found).toBe(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false in the dead center (no handle, no side)", () => {
|
||||
expect(
|
||||
getTransformHandleTypeFromCoords(
|
||||
bounds,
|
||||
(x1 + x2) / 2,
|
||||
(y1 + y2) / 2,
|
||||
zoom,
|
||||
"mouse",
|
||||
desktop,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Side handles aren't rendered as rects (DEFAULT_OMIT_SIDES omits them); they
|
||||
// are detected as a band `SIDE_RESIZING_THRESHOLD` outside each edge.
|
||||
it("resolves side handles from the resize band outside each edge", () => {
|
||||
const at = (px: number, py: number) =>
|
||||
getTransformHandleTypeFromCoords(bounds, px, py, zoom, "mouse", desktop);
|
||||
|
||||
const spacing = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const midX = (x1 + x2) / 2;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
expect(at(midX, y1 - spacing)).toBe("n");
|
||||
expect(at(midX, y2 + spacing)).toBe("s");
|
||||
expect(at(x1 - spacing, midY)).toBe("w");
|
||||
expect(at(x2 + spacing, midY)).toBe("e");
|
||||
});
|
||||
|
||||
it("omits no corner handle for a desktop selection", () => {
|
||||
const keys = handleCenters().map(([key]) => key);
|
||||
expect(keys).toEqual(expect.arrayContaining(["nw", "ne", "sw", "se"]));
|
||||
// DEFAULT_OMIT_SIDES is what desktop uses (sanity check on the fixture)
|
||||
expect(getOmitSidesForEditorInterface(desktop)).toBe(DEFAULT_OMIT_SIDES);
|
||||
});
|
||||
});
|
||||
@@ -11494,49 +11494,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (resizingElement) {
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||
// update the store snapshot, so that invisible elements are not captured by the store
|
||||
this.updateScene({
|
||||
elements: this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== resizingElement.id),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
}
|
||||
|
||||
// handle frame membership for resizing frames and/or selected elements
|
||||
if (pointerDownState.resize.isResizing) {
|
||||
let nextElements = updateFrameMembershipOfSelectedElements(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
this,
|
||||
);
|
||||
|
||||
const selectedFrames = this.scene
|
||||
.getSelectedElements(this.state)
|
||||
.filter((element): element is ExcalidrawFrameLikeElement =>
|
||||
isFrameLikeElement(element),
|
||||
);
|
||||
|
||||
for (const frame of selectedFrames) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
frame,
|
||||
this.state,
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
}
|
||||
this.handleResizePointerUp(
|
||||
pointerDownState,
|
||||
resizingElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Code below handles selection when element(s) weren't
|
||||
// drag or added to selection on pointer down phase.
|
||||
@@ -12884,6 +12846,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Move-side of resize: recomputes and applies the transform for the active
|
||||
// handle on every pointer/key move. Finalization is in `handleResizePointerUp`.
|
||||
private maybeHandleResize = (
|
||||
pointerDownState: PointerDownState,
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
@@ -13015,6 +12979,59 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Resize finalization run on pointerUp: capture history, drop invisibly-small
|
||||
// elements from the store snapshot, and reconcile frame membership. The
|
||||
// move-side of resize lives in `maybeHandleResize`.
|
||||
private handleResizePointerUp = (
|
||||
pointerDownState: PointerDownState,
|
||||
resizingElement: NonDeletedExcalidrawElement | null,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
if (resizingElement) {
|
||||
this.store.scheduleCapture();
|
||||
}
|
||||
|
||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||
// update the store snapshot, so that invisible elements are not captured by the store
|
||||
this.updateScene({
|
||||
elements: this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== resizingElement.id),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
}
|
||||
|
||||
// handle frame membership for resizing frames and/or selected elements
|
||||
if (pointerDownState.resize.isResizing) {
|
||||
let nextElements = updateFrameMembershipOfSelectedElements(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
this,
|
||||
);
|
||||
|
||||
const selectedFrames = this.scene
|
||||
.getSelectedElements(this.state)
|
||||
.filter((element): element is ExcalidrawFrameLikeElement =>
|
||||
isFrameLikeElement(element),
|
||||
);
|
||||
|
||||
for (const frame of selectedFrames) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
frame,
|
||||
this.state,
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
}
|
||||
};
|
||||
|
||||
private getContextMenuItems = (
|
||||
type: "canvas" | "element",
|
||||
): ContextMenuItems => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import type { TransformHandleType } from "@excalidraw/element";
|
||||
|
||||
import type { AppState, PointerDownState } from "../types";
|
||||
|
||||
/**
|
||||
* The implicit "mode" a pointer interaction is in during a drag. In App.tsx
|
||||
* this is not stored anywhere — it is spread across which nullable AppState
|
||||
* fields happen to be populated (`resizingElement`, `newElement` + `multiElement`,
|
||||
* `selectedLinearElement`, `selectionElement`, `croppingElementId`) plus the
|
||||
* live `PointerDownState.resize` scratch. This discriminant names each of those
|
||||
* combinations so the eventual handler refactor has an explicit target.
|
||||
*/
|
||||
export type PointerInteractionMode =
|
||||
| { kind: "resize"; handleType: TransformHandleType }
|
||||
| { kind: "crop"; elementId: string }
|
||||
| { kind: "linearCreate" }
|
||||
// The transient where finishing multi-point linear creation feeds into
|
||||
// linear-edit mode — a genuine transition, not an accidental fall-through.
|
||||
| { kind: "linearCreateFinalizing" }
|
||||
| { kind: "linearPointDrag" }
|
||||
| { kind: "linearEdit" }
|
||||
| { kind: "boxSelect" }
|
||||
| { kind: "elementDrag" }
|
||||
| { kind: "genericCreate" }
|
||||
| { kind: "idle" };
|
||||
|
||||
type InteractionModeState = Pick<
|
||||
AppState,
|
||||
| "croppingElementId"
|
||||
| "newElement"
|
||||
| "multiElement"
|
||||
| "selectionElement"
|
||||
| "selectedLinearElement"
|
||||
| "selectedElementsAreBeingDragged"
|
||||
>;
|
||||
|
||||
type InteractionModeResize = Pick<
|
||||
PointerDownState["resize"],
|
||||
"isResizing" | "handleType"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Derives the current {@link PointerInteractionMode} from existing fields. Pure
|
||||
* and read-only — it mirrors the branch precedence in App.tsx's pointer handlers
|
||||
* without changing any control flow.
|
||||
*
|
||||
* The branch order is significant and matches the handlers: crop and resize
|
||||
* share the `resize.isResizing` gate (crop wins, mirroring `maybeHandleCrop`
|
||||
* running before `maybeHandleResize`); the four-field linear-finalizing case is
|
||||
* the most specific and precedes plain linear creation.
|
||||
*/
|
||||
export const getPointerInteractionMode = (
|
||||
state: InteractionModeState,
|
||||
resize: InteractionModeResize,
|
||||
): PointerInteractionMode => {
|
||||
if (resize.isResizing) {
|
||||
if (state.croppingElementId) {
|
||||
return { kind: "crop", elementId: state.croppingElementId };
|
||||
}
|
||||
if (resize.handleType) {
|
||||
return { kind: "resize", handleType: resize.handleType };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
state.newElement &&
|
||||
state.multiElement &&
|
||||
isLinearElement(state.newElement) &&
|
||||
state.selectedLinearElement
|
||||
) {
|
||||
return { kind: "linearCreateFinalizing" };
|
||||
}
|
||||
|
||||
if (state.multiElement) {
|
||||
return { kind: "linearCreate" };
|
||||
}
|
||||
|
||||
if (state.selectedLinearElement) {
|
||||
return state.selectedLinearElement.isDragging
|
||||
? { kind: "linearPointDrag" }
|
||||
: { kind: "linearEdit" };
|
||||
}
|
||||
|
||||
if (state.selectionElement) {
|
||||
return { kind: "boxSelect" };
|
||||
}
|
||||
|
||||
if (state.selectedElementsAreBeingDragged) {
|
||||
return { kind: "elementDrag" };
|
||||
}
|
||||
|
||||
if (state.newElement) {
|
||||
return { kind: "genericCreate" };
|
||||
}
|
||||
|
||||
return { kind: "idle" };
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
DRAGGING_THRESHOLD,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
DEFAULT_COLLISION_THRESHOLD,
|
||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
HEADING_RIGHT,
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
vectorToHeading,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { Vector } from "@excalidraw/math";
|
||||
|
||||
import type { PointerInteractionMode } from "./pointerInteractionMode";
|
||||
|
||||
/**
|
||||
* A behavior-preservation oracle for the App.tsx pointer-handler refactor.
|
||||
*
|
||||
* A trace records, per pointer event, only *derived symbolic* values — the
|
||||
* sign/quadrant/enum/threshold-comparison outcomes that actually decide which
|
||||
* handler branch runs. No raw coordinates and no user content: capturing raw
|
||||
* coordinates would not generalize and edges toward the privacy constraint,
|
||||
* while capturing nothing would miss the "same final position, different code
|
||||
* path" regressions that output-only assertions can't see.
|
||||
*
|
||||
* Predicate fields are omitted (not set to null) when their branch isn't
|
||||
* reached, so a refactor that skips a branch surfaces as a key-presence diff.
|
||||
*/
|
||||
|
||||
/** Which side of a resize anchor the pointer is on, per axis. */
|
||||
export type AnchorSide = "before" | "at" | "after";
|
||||
|
||||
/** The four corner regions binding-edge selection resolves to. */
|
||||
export type BindingEdge = "topLeft" | "bottomLeft" | "bottomRight" | "topRight";
|
||||
|
||||
/** Arrow heading enum, matching `vectorToHeading`. */
|
||||
export type HeadingDirection = "right" | "down" | "left" | "up";
|
||||
|
||||
/** Named distance thresholds a predicate can be compared against. */
|
||||
export const TRACE_THRESHOLDS = {
|
||||
DRAGGING_THRESHOLD,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
DEFAULT_COLLISION_THRESHOLD,
|
||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
||||
} as const;
|
||||
|
||||
export type TraceThresholdName = keyof typeof TRACE_THRESHOLDS;
|
||||
|
||||
export type PointerTracePredicates = {
|
||||
anchorCrossing?: { x: AnchorSide; y: AnchorSide };
|
||||
resizeFlip?: { x: boolean; y: boolean };
|
||||
inverted?: boolean;
|
||||
heading?: HeadingDirection;
|
||||
bindingEdge?: BindingEdge | null;
|
||||
/** key = threshold constant name; value = distance >= that threshold */
|
||||
distanceVsThreshold?: Partial<Record<TraceThresholdName, boolean>>;
|
||||
axisDominance?: "xDominant" | "yDominant" | "tie";
|
||||
};
|
||||
|
||||
export type PointerTrace = {
|
||||
step: number;
|
||||
eventType: "down" | "move" | "up";
|
||||
interactionMode: PointerInteractionMode;
|
||||
predicates: PointerTracePredicates;
|
||||
elementsDelta: {
|
||||
created: string[];
|
||||
deleted: string[];
|
||||
/** ids of elements whose `type` changed since the previous step */
|
||||
mutatedTypeChanges: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const side = (pointer: number, anchor: number): AnchorSide =>
|
||||
pointer < anchor ? "before" : pointer > anchor ? "after" : "at";
|
||||
|
||||
/** Symbolic position of the pointer relative to a resize anchor, per axis. */
|
||||
export const anchorCrossing = (
|
||||
pointer: { x: number; y: number },
|
||||
anchor: { x: number; y: number },
|
||||
): { x: AnchorSide; y: AnchorSide } => ({
|
||||
x: side(pointer.x, anchor.x),
|
||||
y: side(pointer.y, anchor.y),
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the resize flips the element per axis. Mirrors the `flipConditionsMap`
|
||||
* in resizeElements.ts (a handle only flips along axes it drives).
|
||||
*/
|
||||
export const resizeFlip = (
|
||||
handleDirection: string,
|
||||
pointer: { x: number; y: number },
|
||||
anchor: { x: number; y: number },
|
||||
): { x: boolean; y: boolean } => {
|
||||
const flipX =
|
||||
(handleDirection.includes("e") && pointer.x < anchor.x) ||
|
||||
(handleDirection.includes("w") && pointer.x > anchor.x);
|
||||
const flipY =
|
||||
(handleDirection.includes("s") && pointer.y < anchor.y) ||
|
||||
(handleDirection.includes("n") && pointer.y > anchor.y);
|
||||
return { x: flipX, y: flipY };
|
||||
};
|
||||
|
||||
/**
|
||||
* The cursor-inversion predicate from resizeTest.ts: an element whose width and
|
||||
* height have opposite sign has been flipped through its anchor.
|
||||
*/
|
||||
export const isInverted = (element: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): boolean => Math.sign(element.height) * Math.sign(element.width) === -1;
|
||||
|
||||
/** Maps a heading vector to its enum name, matching `vectorToHeading`. */
|
||||
export const headingDirection = (vec: Vector): HeadingDirection => {
|
||||
const heading = vectorToHeading(vec);
|
||||
if (heading === HEADING_RIGHT) {
|
||||
return "right";
|
||||
}
|
||||
if (heading === HEADING_DOWN) {
|
||||
return "down";
|
||||
}
|
||||
if (heading === HEADING_LEFT) {
|
||||
return "left";
|
||||
}
|
||||
return "up";
|
||||
};
|
||||
|
||||
/**
|
||||
* Which corner region a point falls into relative to a binding target's
|
||||
* non-rotated bounds, or `null` when it's over an edge (not a corner). Mirrors
|
||||
* the corner branches in binding.ts.
|
||||
*/
|
||||
export const bindingEdge = (
|
||||
point: { x: number; y: number },
|
||||
target: { x: number; y: number; width: number; height: number },
|
||||
): BindingEdge | null => {
|
||||
const left = point.x < target.x;
|
||||
const right = point.x > target.x + target.width;
|
||||
const above = point.y < target.y;
|
||||
const below = point.y > target.y + target.height;
|
||||
|
||||
if (left && above) {
|
||||
return "topLeft";
|
||||
}
|
||||
if (left && below) {
|
||||
return "bottomLeft";
|
||||
}
|
||||
if (right && below) {
|
||||
return "bottomRight";
|
||||
}
|
||||
if (right && above) {
|
||||
return "topRight";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** For each supplied threshold, whether the distance meets or exceeds it. */
|
||||
export const distanceVsThreshold = (
|
||||
distance: number,
|
||||
names: readonly TraceThresholdName[],
|
||||
): Partial<Record<TraceThresholdName, boolean>> => {
|
||||
const result: Partial<Record<TraceThresholdName, boolean>> = {};
|
||||
for (const name of names) {
|
||||
result[name] = distance >= TRACE_THRESHOLDS[name];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which axis dominates a drag offset. Mirrors the shift-lock decision in
|
||||
* App.tsx: the axis with the larger absolute delta wins (the other is locked).
|
||||
*/
|
||||
export const axisDominance = (
|
||||
dx: number,
|
||||
dy: number,
|
||||
): "xDominant" | "yDominant" | "tie" => {
|
||||
const absX = Math.abs(dx);
|
||||
const absY = Math.abs(dy);
|
||||
if (absX > absY) {
|
||||
return "xDominant";
|
||||
}
|
||||
if (absY > absX) {
|
||||
return "yDominant";
|
||||
}
|
||||
return "tie";
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { pointFrom, type Radians } from "@excalidraw/math";
|
||||
import { getBoundTextElementPosition } from "@excalidraw/element";
|
||||
import { getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
import { newLinearElement } from "@excalidraw/element";
|
||||
import { getTransformHandles } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||
import { createPasteEvent } from "../clipboard";
|
||||
import { Excalidraw } from "../index";
|
||||
import { resizeFlip } from "../interaction/trace";
|
||||
|
||||
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
|
||||
import * as blobModule from "../data/blob";
|
||||
@@ -918,3 +920,59 @@ describe("mutliple elements", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Milestone 1 trace oracle: assert the symbolic `resizeFlip` predicate agrees
|
||||
// with the real handler's outcome when flipping via a resize drag (as opposed
|
||||
// to the flip *actions* exercised above).
|
||||
describe("resize-drag flip predicate (trace)", () => {
|
||||
const resizeAndCheck = (
|
||||
handle: "nw" | "ne" | "sw" | "se" | "n" | "s" | "e" | "w",
|
||||
move: [number, number],
|
||||
expectedFlip: { x: boolean; y: boolean },
|
||||
) => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const rect = getTransformHandles(
|
||||
rectangle.get(),
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
{},
|
||||
)[handle]!;
|
||||
const start = { x: rect[0] + rect[2] / 2, y: rect[1] + rect[3] / 2 };
|
||||
const to = { x: start.x + move[0], y: start.y + move[1] };
|
||||
|
||||
const anchorMap: Record<string, { x: number; y: number }> = {
|
||||
ne: { x: 0, y: 100 },
|
||||
se: { x: 0, y: 0 },
|
||||
sw: { x: 200, y: 0 },
|
||||
nw: { x: 200, y: 100 },
|
||||
e: { x: 0, y: 50 },
|
||||
w: { x: 200, y: 50 },
|
||||
n: { x: 100, y: 100 },
|
||||
s: { x: 100, y: 0 },
|
||||
};
|
||||
const anchor = anchorMap[handle];
|
||||
|
||||
UI.resize(rectangle, handle, move);
|
||||
|
||||
expect(resizeFlip(handle, to, anchor)).toEqual(expectedFlip);
|
||||
};
|
||||
|
||||
it("horizontal flip via the 'e' handle predicts flip.x", () => {
|
||||
resizeAndCheck("e", [-245, 0], { x: true, y: false });
|
||||
});
|
||||
|
||||
it("vertical flip via the 'n' handle predicts flip.y", () => {
|
||||
resizeAndCheck("n", [0, 139], { x: false, y: true });
|
||||
});
|
||||
|
||||
it("plain resize (no crossing) predicts no flip", () => {
|
||||
resizeAndCheck("se", [40, 20], { x: false, y: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { getPointerInteractionMode } from "../interaction/pointerInteractionMode";
|
||||
|
||||
import type { PointerInteractionMode } from "../interaction/pointerInteractionMode";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
// Minimal stand-ins: getPointerInteractionMode only reads `type` off elements
|
||||
// (via isLinearElement) and `isDragging` off the linear editor.
|
||||
const linear = { type: "arrow" } as AppState["newElement"];
|
||||
const generic = { type: "rectangle" } as AppState["newElement"];
|
||||
const linearEditor = (isDragging: boolean) =>
|
||||
({ isDragging } as AppState["selectedLinearElement"]);
|
||||
|
||||
type State = Parameters<typeof getPointerInteractionMode>[0];
|
||||
type Resize = Parameters<typeof getPointerInteractionMode>[1];
|
||||
|
||||
const emptyState: State = {
|
||||
croppingElementId: null,
|
||||
newElement: null,
|
||||
multiElement: null,
|
||||
selectionElement: null,
|
||||
selectedLinearElement: null,
|
||||
selectedElementsAreBeingDragged: false,
|
||||
};
|
||||
|
||||
const noResize: Resize = { isResizing: false, handleType: false };
|
||||
|
||||
const mode = (
|
||||
state: Partial<State>,
|
||||
resize: Partial<Resize> = {},
|
||||
): PointerInteractionMode =>
|
||||
getPointerInteractionMode(
|
||||
{ ...emptyState, ...state },
|
||||
{ ...noResize, ...resize },
|
||||
);
|
||||
|
||||
describe("getPointerInteractionMode", () => {
|
||||
it("is idle when no interaction field is populated", () => {
|
||||
expect(mode({})).toEqual({ kind: "idle" });
|
||||
});
|
||||
|
||||
describe("resize / crop (share the isResizing gate)", () => {
|
||||
it("is resize when resizing with a handle and no crop target", () => {
|
||||
expect(mode({}, { isResizing: true, handleType: "se" })).toEqual({
|
||||
kind: "resize",
|
||||
handleType: "se",
|
||||
});
|
||||
});
|
||||
|
||||
it("is crop when a crop target is set — crop wins over resize", () => {
|
||||
expect(
|
||||
mode(
|
||||
{ croppingElementId: "img" },
|
||||
{ isResizing: true, handleType: "se" },
|
||||
),
|
||||
).toEqual({ kind: "crop", elementId: "img" });
|
||||
});
|
||||
|
||||
it("does not enter resize/crop unless isResizing is set", () => {
|
||||
expect(mode({ croppingElementId: "img" }, { handleType: "se" })).toEqual({
|
||||
kind: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enter resize without a handle", () => {
|
||||
expect(mode({}, { isResizing: true, handleType: false })).toEqual({
|
||||
kind: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
// App.tsx: a hit resize handle takes precedence over the linear-editor
|
||||
// fallback (8579 vs 8601).
|
||||
it("resize wins over an active linear editor", () => {
|
||||
expect(
|
||||
mode(
|
||||
{ selectedLinearElement: linearEditor(false) },
|
||||
{ isResizing: true, handleType: "nw" },
|
||||
),
|
||||
).toEqual({ kind: "resize", handleType: "nw" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("linear element", () => {
|
||||
it("is linearCreateFinalizing on the four-field transition", () => {
|
||||
expect(
|
||||
mode({
|
||||
newElement: linear,
|
||||
multiElement: linear as AppState["multiElement"],
|
||||
selectedLinearElement: linearEditor(false),
|
||||
}),
|
||||
).toEqual({ kind: "linearCreateFinalizing" });
|
||||
});
|
||||
|
||||
it("is linearCreate while a multi-point element is being created", () => {
|
||||
expect(
|
||||
mode({ multiElement: linear as AppState["multiElement"] }),
|
||||
).toEqual({ kind: "linearCreate" });
|
||||
});
|
||||
|
||||
// The finalizing case requires all four fields; missing the editor is
|
||||
// still plain creation.
|
||||
it("is linearCreate when the finalizing condition is only partial", () => {
|
||||
expect(
|
||||
mode({
|
||||
newElement: linear,
|
||||
multiElement: linear as AppState["multiElement"],
|
||||
}),
|
||||
).toEqual({ kind: "linearCreate" });
|
||||
});
|
||||
|
||||
it("is linearPointDrag when dragging an editor point", () => {
|
||||
expect(mode({ selectedLinearElement: linearEditor(true) })).toEqual({
|
||||
kind: "linearPointDrag",
|
||||
});
|
||||
});
|
||||
|
||||
it("is linearEdit when the editor is open but not dragging", () => {
|
||||
expect(mode({ selectedLinearElement: linearEditor(false) })).toEqual({
|
||||
kind: "linearEdit",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection / drag / generic creation", () => {
|
||||
it("is boxSelect when a selection element exists", () => {
|
||||
expect(
|
||||
mode({ selectionElement: {} as AppState["selectionElement"] }),
|
||||
).toEqual({ kind: "boxSelect" });
|
||||
});
|
||||
|
||||
it("is elementDrag when selected elements are being dragged", () => {
|
||||
expect(mode({ selectedElementsAreBeingDragged: true })).toEqual({
|
||||
kind: "elementDrag",
|
||||
});
|
||||
});
|
||||
|
||||
it("is genericCreate for a non-linear new element", () => {
|
||||
expect(mode({ newElement: generic })).toEqual({ kind: "genericCreate" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("precedence between overlapping fields", () => {
|
||||
it("linear editor wins over a stray selection element", () => {
|
||||
expect(
|
||||
mode({
|
||||
selectedLinearElement: linearEditor(false),
|
||||
selectionElement: {} as AppState["selectionElement"],
|
||||
}),
|
||||
).toEqual({ kind: "linearEdit" });
|
||||
});
|
||||
|
||||
it("box select wins over element drag", () => {
|
||||
expect(
|
||||
mode({
|
||||
selectionElement: {} as AppState["selectionElement"],
|
||||
selectedElementsAreBeingDragged: true,
|
||||
}),
|
||||
).toEqual({ kind: "boxSelect" });
|
||||
});
|
||||
|
||||
it("element drag wins over generic creation", () => {
|
||||
expect(
|
||||
mode({
|
||||
selectedElementsAreBeingDragged: true,
|
||||
newElement: generic,
|
||||
}),
|
||||
).toEqual({ kind: "elementDrag" });
|
||||
});
|
||||
|
||||
it("resize wins over every AppState-derived mode", () => {
|
||||
expect(
|
||||
mode(
|
||||
{
|
||||
newElement: generic,
|
||||
selectionElement: {} as AppState["selectionElement"],
|
||||
selectedElementsAreBeingDragged: true,
|
||||
},
|
||||
{ isResizing: true, handleType: "e" },
|
||||
),
|
||||
).toEqual({ kind: "resize", handleType: "e" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { arrayToMap, reseed } from "@excalidraw/common";
|
||||
import { getTransformHandles } from "@excalidraw/element";
|
||||
|
||||
import type { TransformHandleType } from "@excalidraw/element";
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { getPointerInteractionMode } from "../interaction/pointerInteractionMode";
|
||||
import { resizeFlip } from "../interaction/trace";
|
||||
|
||||
import { act, render } from "./test-utils";
|
||||
import { Pointer, UI } from "./helpers/ui";
|
||||
|
||||
import type { PointerInteractionMode } from "../interaction/pointerInteractionMode";
|
||||
|
||||
// Milestone 1 model-based oracle. A tiny, explicit resize state machine (no
|
||||
// dependency — the model doubles as documentation of the subsystem) generates
|
||||
// interaction paths. Each path is replayed through the *real* App.tsx pointer
|
||||
// handlers via the shared `Pointer`, and the per-step trace (interaction mode +
|
||||
// flip predicate) is asserted against the model. This is the behavior-
|
||||
// preservation gate the eventual handler refactor must keep green.
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
// --- the model -------------------------------------------------------------
|
||||
|
||||
type ResizeEvent =
|
||||
| { type: "down"; handle: TransformHandleType }
|
||||
| { type: "move"; to: Point }
|
||||
| { type: "up" };
|
||||
|
||||
type ModelState = {
|
||||
value: "idle" | "resizing";
|
||||
handle: TransformHandleType | null;
|
||||
flipped: { x: boolean; y: boolean };
|
||||
};
|
||||
|
||||
const IDLE: ModelState = {
|
||||
value: "idle",
|
||||
handle: null,
|
||||
flipped: { x: false, y: false },
|
||||
};
|
||||
|
||||
const step = (
|
||||
state: ModelState,
|
||||
event: ResizeEvent,
|
||||
anchor: Point,
|
||||
): ModelState => {
|
||||
switch (event.type) {
|
||||
case "down":
|
||||
return {
|
||||
value: "resizing",
|
||||
handle: event.handle,
|
||||
flipped: { x: false, y: false },
|
||||
};
|
||||
case "move":
|
||||
if (state.value !== "resizing" || state.handle === null) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
flipped: resizeFlip(state.handle, event.to, anchor),
|
||||
};
|
||||
case "up":
|
||||
return IDLE;
|
||||
}
|
||||
};
|
||||
|
||||
// The model's expected interaction mode for the current state.
|
||||
const expectedMode = (state: ModelState): PointerInteractionMode =>
|
||||
state.value === "resizing" && state.handle
|
||||
? { kind: "resize", handleType: state.handle }
|
||||
: { kind: "idle" };
|
||||
|
||||
// --- geometry --------------------------------------------------------------
|
||||
|
||||
// The resize anchor for each handle is the opposite corner/edge-midpoint of the
|
||||
// bounds (mirrors resizeElements.ts `anchorsMap`).
|
||||
const anchorFor = (
|
||||
handle: TransformHandleType,
|
||||
[minX, minY, maxX, maxY]: [number, number, number, number],
|
||||
): Point => {
|
||||
const w = maxX - minX;
|
||||
const hgt = maxY - minY;
|
||||
const map: Record<string, Point> = {
|
||||
ne: { x: minX, y: maxY },
|
||||
se: { x: minX, y: minY },
|
||||
sw: { x: maxX, y: minY },
|
||||
nw: { x: maxX, y: maxY },
|
||||
e: { x: minX, y: minY + hgt / 2 },
|
||||
w: { x: maxX, y: minY + hgt / 2 },
|
||||
n: { x: minX + w / 2, y: maxY },
|
||||
s: { x: minX + w / 2, y: minY },
|
||||
};
|
||||
return map[handle];
|
||||
};
|
||||
|
||||
const CORNERS: TransformHandleType[] = ["nw", "ne", "sw", "se"];
|
||||
const SIDES: TransformHandleType[] = ["n", "s", "e", "w"];
|
||||
const HANDLES = [...CORNERS, ...SIDES];
|
||||
|
||||
// --- path generation (all-transitions over handle × flip) ------------------
|
||||
|
||||
type Path = { handle: TransformHandleType; to: Point; anchor: Point };
|
||||
|
||||
const generatePaths = (
|
||||
bounds: [number, number, number, number],
|
||||
): Array<Path & { flip: "none" | "cross" }> => {
|
||||
const paths: Array<Path & { flip: "none" | "cross" }> = [];
|
||||
for (const handle of HANDLES) {
|
||||
const anchor = anchorFor(handle, bounds);
|
||||
const [minX, minY, maxX, maxY] = bounds;
|
||||
const start = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
|
||||
// handle position (approx — sign relative to anchor is what matters)
|
||||
const hpos: Point = {
|
||||
x: handle.includes("w") ? minX : handle.includes("e") ? maxX : start.x,
|
||||
y: handle.includes("n") ? minY : handle.includes("s") ? maxY : start.y,
|
||||
};
|
||||
// no flip: 20% toward the anchor (shrink, never crossing it)
|
||||
paths.push({
|
||||
handle,
|
||||
anchor,
|
||||
flip: "none",
|
||||
to: {
|
||||
x: hpos.x + 0.2 * (anchor.x - hpos.x),
|
||||
y: hpos.y + 0.2 * (anchor.y - hpos.y),
|
||||
},
|
||||
});
|
||||
// flip: reflect the handle across the anchor (crosses every driven axis)
|
||||
paths.push({
|
||||
handle,
|
||||
anchor,
|
||||
flip: "cross",
|
||||
to: { x: 2 * anchor.x - hpos.x, y: 2 * anchor.y - hpos.y },
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
};
|
||||
|
||||
// --- replay ----------------------------------------------------------------
|
||||
|
||||
const traceMode = (handle: TransformHandleType): PointerInteractionMode =>
|
||||
getPointerInteractionMode(h.state, {
|
||||
isResizing: h.state.isResizing,
|
||||
handleType: handle,
|
||||
});
|
||||
|
||||
const handleCenter = (
|
||||
element: ExcalidrawElement,
|
||||
handle: TransformHandleType,
|
||||
) => {
|
||||
const rect = getTransformHandles(
|
||||
element,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
{},
|
||||
)[handle];
|
||||
if (!rect) {
|
||||
throw new Error(`no ${handle} handle`);
|
||||
}
|
||||
return { x: rect[0] + rect[2] / 2, y: rect[1] + rect[3] / 2 };
|
||||
};
|
||||
|
||||
const centerOf = (element: ExcalidrawElement): Point => ({
|
||||
x: element.x + element.width / 2,
|
||||
y: element.y + element.height / 2,
|
||||
});
|
||||
|
||||
// Observe flip from the *result* geometry (independent of the oracle formula):
|
||||
// an axis flipped iff the element's center crossed the anchor on that axis.
|
||||
const observedFlip = (
|
||||
before: Point,
|
||||
after: Point,
|
||||
anchor: Point,
|
||||
): { x: boolean; y: boolean } => ({
|
||||
x: Math.sign(before.x - anchor.x) !== Math.sign(after.x - anchor.x),
|
||||
y: Math.sign(before.y - anchor.y) !== Math.sign(after.y - anchor.y),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
mouse.reset();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
});
|
||||
|
||||
describe("resize state machine — generated paths replayed on real handlers", () => {
|
||||
const bounds: [number, number, number, number] = [0, 0, 200, 100];
|
||||
|
||||
for (const path of generatePaths(bounds)) {
|
||||
it(`${path.handle} (${path.flip})`, () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
x: bounds[0],
|
||||
y: bounds[1],
|
||||
width: bounds[2] - bounds[0],
|
||||
height: bounds[3] - bounds[1],
|
||||
});
|
||||
|
||||
const before = centerOf(rectangle.get());
|
||||
|
||||
let model = IDLE;
|
||||
act(() => {
|
||||
h.setState({ selectedElementIds: { [rectangle.id]: true } });
|
||||
});
|
||||
|
||||
const start = handleCenter(rectangle.get(), path.handle);
|
||||
mouse.reset();
|
||||
mouse.downAt(start.x, start.y);
|
||||
model = step(model, { type: "down", handle: path.handle }, path.anchor);
|
||||
|
||||
mouse.moveTo(path.to.x, path.to.y);
|
||||
model = step(model, { type: "move", to: path.to }, path.anchor);
|
||||
// interaction mode reported by the real handler matches the model
|
||||
expect(traceMode(path.handle)).toEqual(expectedMode(model));
|
||||
expect(model.value).toBe("resizing");
|
||||
|
||||
mouse.upAt(path.to.x, path.to.y);
|
||||
model = step(model, { type: "up" }, path.anchor);
|
||||
|
||||
// back to idle
|
||||
expect(
|
||||
getPointerInteractionMode(h.state, {
|
||||
isResizing: false,
|
||||
handleType: false,
|
||||
}),
|
||||
).toEqual({ kind: "idle" });
|
||||
|
||||
// oracle flip prediction (captured in the model) matches the real
|
||||
// handler's resulting geometry
|
||||
const after = centerOf(rectangle.get());
|
||||
const observed = observedFlip(before, after, path.anchor);
|
||||
const predicted = resizeFlip(path.handle, path.to, path.anchor);
|
||||
expect(predicted).toEqual(observed);
|
||||
|
||||
if (path.flip === "cross") {
|
||||
// a crossing path must actually flip at least one driven axis
|
||||
expect(predicted.x || predicted.y).toBe(true);
|
||||
} else {
|
||||
expect(predicted).toEqual({ x: false, y: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("covers both nw and se corners in the generated set", () => {
|
||||
const handles = new Set(generatePaths(bounds).map((p) => p.handle));
|
||||
expect(handles.has("nw")).toBe(true);
|
||||
expect(handles.has("se")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user