fix: Phase 0 - preparation

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-07-01 10:15:49 +00:00
parent b2a729c400
commit b751b4a284
3 changed files with 471 additions and 0 deletions
@@ -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";
};
@@ -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" });
});
});
});