From c43250c16356be523201c2a685d527936aeea0bd Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 7 Sep 2026 17:08:52 +0900 Subject: feat(workspaces): pinned tiles with displacement reflow + FLIP drag preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/workspaces/logic/drag-controller.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) (limited to 'src/features/workspaces/logic/drag-controller.ts') 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 }, -- cgit v1.2.3