summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic/drag-controller.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
committerAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
commitc43250c16356be523201c2a685d527936aeea0bd (patch)
treee3f6eeff9e2a6fec2f094df8591df0b038b5c5cd /src/features/workspaces/logic/drag-controller.ts
parent6ae43c119ad51e99d1f4a4ede21b6ba4eb44c3b1 (diff)
downloaddispatch-web-c43250c16356be523201c2a685d527936aeea0bd.tar.gz
dispatch-web-c43250c16356be523201c2a685d527936aeea0bd.zip
feat(workspaces): pinned tiles with displacement reflow + FLIP drag preview
Tiles can now be dropped onto specific cells; empty cells are real estate that persists (notes/pinned-tiles-plan.md). Data (v2): each tile carries an optional pinned anchor in a UNIVERSAL 4-row frame (tall folds its 2 columns onto frame column 1; a wide col>1 pin reads as unpinned in tall). v1 documents migrate in memory to all-unpinned (render-identical) and write back as v2 on the next change. sanitizeLayout drops garbage anchors. Packing: pinned-first (first-wins on overlap, one-ply), then unpinned tiles first-fit into the REMAINING cells — holes between pins count and never compact. displacePlace: the user-specified semantics — the dragged tile leaves a gap at its origin (nothing slides backward), anchors at the drop cell, and the contiguous filled run pushes FORWARD until the first empty cell absorbs it (X_X + Y@1 → YXX; _XZ_ + Y@2 → _YXZ). placeAtCell commits the displacement and pins every tile; pinAtFirstEmpty pins the create-flow tile at the first truly-empty slot; sortByRecentActivity now also clears pins (an explicit arrangement reset — the menu item says so). Drag UX: pointer → cell hit-math (empty cells are drop targets); a faint dot marks every cell center during a drag (CSS radial-gradient overlay, one extra pitch of end padding for the append slot); the dragged tile element itself follows the pointer (lifted via transform, pointer-events none); a dashed tone-colored ghost marks the landing footprint. The preview recomputes from the COMMITTED layout on every hover-cell change (as if the user moved straight from the original to there) while GSAP Flip animates from wherever the tiles currently are (re-entrant — mid-flight positions captured). Escape/pointercancel discards the preview and flies the tile home; a drop settles it onto its new cell. Tests: displacement (both user examples + internal moves + spans + tall stream), pinned pack (anchors, gaps kept, first-wins, universal-frame mapping, deleted-leaves-holes), v1→v2 migration + anchor sanitization, store placeAtCell/no-op write guard, Escape-cancels-drag component test. 1689 green.
Diffstat (limited to 'src/features/workspaces/logic/drag-controller.ts')
-rw-r--r--src/features/workspaces/logic/drag-controller.ts19
1 files changed, 11 insertions, 8 deletions
diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts
index 03dcade..a1877aa 100644
--- a/src/features/workspaces/logic/drag-controller.ts
+++ b/src/features/workspaces/logic/drag-controller.ts
@@ -12,9 +12,11 @@
* 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 / reorder).
+ * 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" }
@@ -32,7 +34,8 @@ export type DragPhase =
readonly id: string;
readonly x: number;
readonly y: number;
- readonly targetIndex: number;
+ /** The grid CELL under the pointer (null = not over the canvas yet). */
+ readonly targetCell: Cell | null;
readonly touch: boolean;
};
@@ -40,7 +43,7 @@ export type DragPhase =
export type DragEmit =
| { readonly kind: "tap"; readonly id: string }
| { readonly kind: "lift"; readonly id: string }
- | { readonly kind: "drop"; readonly id: string; readonly targetIndex: number };
+ | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null };
/** Events the component dispatches (DOM events + the long-press timer firing). */
export type DragEvent =
@@ -55,7 +58,7 @@ export type DragEvent =
readonly type: "pointer-move";
readonly x: number;
readonly y: number;
- readonly targetIndex: number;
+ readonly targetCell: Cell | null;
}
| { readonly type: "pointer-up" }
| { readonly type: "long-press" }
@@ -105,7 +108,7 @@ export function nextDragPhase(
id: phase.id,
x: event.x,
y: event.y,
- targetIndex: event.targetIndex,
+ targetCell: event.targetCell,
touch: phase.touch,
},
};
@@ -116,7 +119,7 @@ export function nextDragPhase(
...phase,
x: event.x,
y: event.y,
- targetIndex: event.targetIndex,
+ targetCell: event.targetCell,
},
};
}
@@ -130,7 +133,7 @@ export function nextDragPhase(
if (phase.kind === "dragging") {
return {
phase: IDLE,
- emit: { kind: "drop", id: phase.id, targetIndex: phase.targetIndex },
+ emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell },
};
}
return { phase };
@@ -145,7 +148,7 @@ export function nextDragPhase(
id: phase.id,
x: phase.x,
y: phase.y,
- targetIndex: -1,
+ targetCell: null,
touch: true,
},
emit: { kind: "lift", id: phase.id },