Compare commits

...
Author SHA1 Message Date
Mark Tolmacs 2201955938 feat: Phase 1 - resize refactor
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-07 15:31:39 +00:00
Mark Tolmacs b751b4a284 fix: Phase 0 - preparation
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-07-07 15:31:39 +00:00
8 changed files with 1058 additions and 43 deletions
@@ -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);
});
});
+60 -43
View File
@@ -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" };
};
+190
View File
@@ -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";
};
+58
View File
@@ -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);
});
});