diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 17:08:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 17:08:52 +0900 |
| commit | c43250c16356be523201c2a685d527936aeea0bd (patch) | |
| tree | e3f6eeff9e2a6fec2f094df8591df0b038b5c5cd /src/features/workspaces/logic/drag-controller.test.ts | |
| parent | 6ae43c119ad51e99d1f4a4ede21b6ba4eb44c3b1 (diff) | |
| download | dispatch-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.test.ts')
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.test.ts | 39 |
1 files changed, 23 insertions, 16 deletions
diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts index d6dd760..459063a 100644 --- a/src/features/workspaces/logic/drag-controller.test.ts +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -26,7 +26,7 @@ describe("nextDragPhase — mouse", () => { it("movement past the threshold starts a drag", () => { const transition = nextDragPhase( press(), - { type: "pointer-move", x: 20, y: 12, targetIndex: 3 }, + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ @@ -34,7 +34,7 @@ describe("nextDragPhase — mouse", () => { id: "t1", x: 20, y: 12, - targetIndex: 3, + targetCell: { col: 4, row: 1 }, touch: false, }); expect(transition.emit).toBeUndefined(); @@ -43,7 +43,7 @@ describe("nextDragPhase — mouse", () => { it("movement within the threshold stays pressing", () => { const transition = nextDragPhase( press(), - { type: "pointer-move", x: 12, y: 14, targetIndex: 0 }, + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); @@ -58,16 +58,16 @@ describe("nextDragPhase — mouse", () => { it("release while dragging emits a drop with the last tracked index", () => { let transition: DragTransition = nextDragPhase( press(), - { type: "pointer-move", x: 30, y: 30, targetIndex: 2 }, + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, OPTS, ); transition = nextDragPhase( transition.phase, - { type: "pointer-move", x: 31, y: 31, targetIndex: 5 }, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, OPTS, ); transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); - expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 }); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); expect(transition.phase).toEqual({ kind: "idle" }); }); }); @@ -81,7 +81,7 @@ describe("nextDragPhase — touch", () => { id: "t1", x: 10, y: 10, - targetIndex: -1, + targetCell: null, touch: true, }); }); @@ -89,7 +89,7 @@ describe("nextDragPhase — touch", () => { it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { const transition = nextDragPhase( press(true), - { type: "pointer-move", x: 10, y: 80, targetIndex: 0 }, + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ kind: "idle" }); @@ -99,7 +99,7 @@ describe("nextDragPhase — touch", () => { it("movement within the threshold before the lift keeps pressing", () => { const transition = nextDragPhase( press(true), - { type: "pointer-move", x: 13, y: 12, targetIndex: 0 }, + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, OPTS, ); expect(transition.phase?.kind).toBe("pressing"); @@ -109,7 +109,7 @@ describe("nextDragPhase — touch", () => { let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); transition = nextDragPhase( transition.phase, - { type: "pointer-move", x: 10, y: 90, targetIndex: 4 }, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, OPTS, ); expect(transition.phase).toEqual({ @@ -117,7 +117,7 @@ describe("nextDragPhase — touch", () => { id: "t1", x: 10, y: 90, - targetIndex: 4, + targetCell: { col: 5, row: 1 }, touch: true, }); }); @@ -131,7 +131,7 @@ describe("nextDragPhase — touch", () => { it("release while lifted (no movement) drops at the lifted tile's own slot", () => { let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); - expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: -1 }); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); }); }); @@ -140,7 +140,11 @@ describe("nextDragPhase — cancellations", () => { for (const phase of [ { kind: "idle" as const }, press(), - nextDragPhase(press(), { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, OPTS).phase, + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, ]) { const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); expect(transition.phase).toEqual({ kind: "idle" }); @@ -153,8 +157,11 @@ describe("nextDragPhase — cancellations", () => { kind: "idle", }); expect( - nextDragPhase({ kind: "idle" }, { type: "pointer-move", x: 1, y: 1, targetIndex: 0 }, OPTS) - .phase, + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, ).toEqual({ kind: "idle" }); expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ kind: "idle", @@ -164,7 +171,7 @@ describe("nextDragPhase — cancellations", () => { it("a second pointer-down restarts the gesture with the new tile", () => { const dragging = nextDragPhase( press(), - { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, OPTS, ).phase; const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); |
