From b751b4a284afeafde8aa95f179634059ad11bc4a Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 1 Jul 2026 10:15:49 +0000 Subject: [PATCH] fix: Phase 0 - preparation Signed-off-by: Mark Tolmacs --- .../interaction/pointerInteractionMode.ts | 99 +++++++++ packages/excalidraw/interaction/trace.ts | 190 ++++++++++++++++++ .../tests/pointerInteractionMode.test.ts | 182 +++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 packages/excalidraw/interaction/pointerInteractionMode.ts create mode 100644 packages/excalidraw/interaction/trace.ts create mode 100644 packages/excalidraw/tests/pointerInteractionMode.test.ts diff --git a/packages/excalidraw/interaction/pointerInteractionMode.ts b/packages/excalidraw/interaction/pointerInteractionMode.ts new file mode 100644 index 0000000000..437e9f1e01 --- /dev/null +++ b/packages/excalidraw/interaction/pointerInteractionMode.ts @@ -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" }; +}; diff --git a/packages/excalidraw/interaction/trace.ts b/packages/excalidraw/interaction/trace.ts new file mode 100644 index 0000000000..8f6c7cf141 --- /dev/null +++ b/packages/excalidraw/interaction/trace.ts @@ -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>; + 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> => { + const result: Partial> = {}; + 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"; +}; diff --git a/packages/excalidraw/tests/pointerInteractionMode.test.ts b/packages/excalidraw/tests/pointerInteractionMode.test.ts new file mode 100644 index 0000000000..0bf86efab6 --- /dev/null +++ b/packages/excalidraw/tests/pointerInteractionMode.test.ts @@ -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[0]; +type Resize = Parameters[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, + resize: Partial = {}, +): 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" }); + }); + }); +});