summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic/drag-controller.ts
blob: a1877aa201f08fda45201341c83280ae32fb3725 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
 * Pure drag/press state machine for the home tile grid — zero DOM, zero
 * timers (the component schedules the long-press timer and dispatches its
 * firing as an event), zero Svelte.
 *
 * Pointer semantics:
 * - **Mouse**: press → move past the threshold = DRAG; release without
 *   moving = a TAP (open the workspace). Selection is right-click.
 * - **Touch**: press → (no movement past the threshold) long-press = LIFT
 *   (arm the drag + select the tile) → further moves drag. Moving past the
 *   threshold BEFORE the lift cancels the press — vertical scrolling wins,
 *   so the two gestures never fight. Release while lifted = DROP.
 *
 * Pure: `(phase, event, options) → { phase, emit }`. The component owns the
 * reactive `$state` and performs the emits (navigate / select / place).
 */

import type { Cell } from "./tile-layout";

/** Where the interaction currently stands. */
export type DragPhase =
  | { readonly kind: "idle" }
  /** Pointer is down; not yet decided between tap / drag / long-press. */
  | {
      readonly kind: "pressing";
      readonly id: string;
      readonly x: number;
      readonly y: number;
      readonly touch: boolean;
    }
  /** The tile is lifted and following the pointer (touch: after the long-press). */
  | {
      readonly kind: "dragging";
      readonly id: string;
      readonly x: number;
      readonly y: number;
      /** The grid CELL under the pointer (null = not over the canvas yet). */
      readonly targetCell: Cell | null;
      readonly touch: boolean;
    };

/** An action the component should perform as a result of a transition. */
export type DragEmit =
  | { readonly kind: "tap"; readonly id: string }
  | { readonly kind: "lift"; readonly id: string }
  | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null };

/** Events the component dispatches (DOM events + the long-press timer firing). */
export type DragEvent =
  | {
      readonly type: "pointer-down";
      readonly id: string;
      readonly x: number;
      readonly y: number;
      readonly touch: boolean;
    }
  | {
      readonly type: "pointer-move";
      readonly x: number;
      readonly y: number;
      readonly targetCell: Cell | null;
    }
  | { readonly type: "pointer-up" }
  | { readonly type: "long-press" }
  /** pointercancel / blur / the tile unmounting / Escape. */
  | { readonly type: "cancel" };

export interface DragOptions {
  /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */
  readonly dragThresholdPx: number;
}

export interface DragTransition {
  readonly phase: DragPhase;
  readonly emit?: DragEmit;
}

const IDLE: DragPhase = { kind: "idle" };

/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */
export function nextDragPhase(
  phase: DragPhase,
  event: DragEvent,
  options: DragOptions,
): DragTransition {
  switch (event.type) {
    case "pointer-down":
      // A press always restarts (a pointerdown while dragging is a new gesture).
      return {
        phase: {
          kind: "pressing",
          id: event.id,
          x: event.x,
          y: event.y,
          touch: event.touch,
        },
      };
    case "pointer-move": {
      if (phase.kind === "pressing") {
        const travelled = Math.hypot(event.x - phase.x, event.y - phase.y);
        if (travelled < options.dragThresholdPx) return { phase };
        // Touch: a big movement before the long-press lift is a SCROLL, not a
        // drag — abandon the press so the canvas scrolls natively.
        if (phase.touch) return { phase: IDLE };
        return {
          phase: {
            kind: "dragging",
            id: phase.id,
            x: event.x,
            y: event.y,
            targetCell: event.targetCell,
            touch: phase.touch,
          },
        };
      }
      if (phase.kind === "dragging") {
        return {
          phase: {
            ...phase,
            x: event.x,
            y: event.y,
            targetCell: event.targetCell,
          },
        };
      }
      return { phase };
    }
    case "pointer-up": {
      if (phase.kind === "pressing") {
        // No drag, no lift → a tap.
        return { phase: IDLE, emit: { kind: "tap", id: phase.id } };
      }
      if (phase.kind === "dragging") {
        return {
          phase: IDLE,
          emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell },
        };
      }
      return { phase };
    }
    case "long-press": {
      if (phase.kind === "pressing" && phase.touch) {
        // Touch lift: arm the drag at the press point (the pointer will move
        // from here) + tell the component to select the tile.
        return {
          phase: {
            kind: "dragging",
            id: phase.id,
            x: phase.x,
            y: phase.y,
            targetCell: null,
            touch: true,
          },
          emit: { kind: "lift", id: phase.id },
        };
      }
      return { phase };
    }
    case "cancel":
      return { phase: IDLE };
  }
}