mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-08-30 16:55:16 +08:00
fix: Phase 0 - preparation
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
@@ -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";
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user