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/ui/HomeHeader.svelte | 2 +- src/features/workspaces/ui/HomeHeader.test.ts | 2 +- src/features/workspaces/ui/HomeTileGrid.svelte | 318 +++++++++++++++++------ src/features/workspaces/ui/HomeTileGrid.test.ts | 43 ++- src/features/workspaces/ui/WorkspacesHome.svelte | 2 +- 5 files changed, 278 insertions(+), 89 deletions(-) (limited to 'src/features/workspaces/ui') diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte index be630d2..fb0be0a 100644 --- a/src/features/workspaces/ui/HomeHeader.svelte +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -114,7 +114,7 @@ diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts index a0afc52..b4ef6c4 100644 --- a/src/features/workspaces/ui/HomeHeader.test.ts +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -36,7 +36,7 @@ describe("HomeHeader", () => { render(HomeHeader, { props: { ...p, withSort: true } }); await user.click(screen.getByRole("button", { name: "More actions" })); - await user.click(screen.getByRole("button", { name: "Sort by recent activity" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); expect(p.onSort).toHaveBeenCalledTimes(1); }); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte index ceae133..46b3c4e 100644 --- a/src/features/workspaces/ui/HomeTileGrid.svelte +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -1,25 +1,21 @@ + +
-
- {#each placements as placement (placement.id)} +
+ {#if ghostStyle && draggedPlacement} + + + {/if} + {#each rendered as placement (placement.id)} {@const ws = workspacesById.get(placement.id)} {#if ws} + {@const isDragged = draggedId === placement.id}
{/if}
- -{#if phase.kind === "dragging"} - -
- {workspacesById.get(phase.id)?.title ?? phase.id} -
-{/if} diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts index 3229d4e..d70c673 100644 --- a/src/features/workspaces/ui/HomeTileGrid.test.ts +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -41,7 +41,7 @@ function baseProps() { store: fakeStore(), onOpen: vi.fn(), onSelect: vi.fn(), - onReorder: vi.fn(), + onPlace: vi.fn(), onRenameEnd: vi.fn(), }; } @@ -98,32 +98,51 @@ describe("HomeTileGrid", () => { expect(props.onOpen).not.toHaveBeenCalled(); }); - it("dragging shows the transient ghost, and dropping reorders (when over a target)", async () => { + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { const props = baseProps(); const { container } = render(HomeTileGrid, { props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, }); const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); - // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op), - // but the GHOST must appear while dragging and disappear on drop. - Object.defineProperty(document, "elementFromPoint", { - configurable: true, - value: () => null, - }); + // The cell math resolves against the grid element's rect (jsdom: 0s). fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); - expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull(); + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); fireEvent.pointerUp(wrapper); - expect(container.querySelector(".pointer-events-none.fixed")).toBeNull(); - // No hit target → no reorder call. - expect(props.onReorder).not.toHaveBeenCalled(); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); // And no navigation either (a drag is not a tap). expect(props.onOpen).not.toHaveBeenCalled(); }); + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + it("a touch long-press lifts the tile and selects it (never opens)", async () => { const props = baseProps(); render(HomeTileGrid, { diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index cbf762f..7c0db85 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -276,7 +276,7 @@ {...(hasActive ? { hasActive } : {})} onOpen={openWorkspace} onSelect={select} - onReorder={(id, toIndex) => tiles.move(id, toIndex)} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} onRenameEnd={() => (renamingId = null)} /> {/if} -- cgit v1.2.3