summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/HomeTileGrid.svelte
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/ui/HomeTileGrid.svelte
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/ui/HomeTileGrid.svelte')
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte318
1 files changed, 244 insertions, 74 deletions
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 @@
<script lang="ts">
import type { WorkspaceEntry } from "@dispatch/wire";
+ import { gsap } from "gsap";
+ import { Flip } from "gsap/Flip";
+ import { tick } from "svelte";
import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
- import type { GridGeometry, Placement, Span } from "../logic/tile-layout";
+ import {
+ displacePlace,
+ type Cell,
+ type GridGeometry,
+ type Placement,
+ type Span,
+ } from "../logic/tile-layout";
import { tileTone, type TileTone } from "../logic/view-model";
import type { WorkspaceStore } from "../store.svelte";
import WorkspaceTile from "./WorkspaceTile.svelte";
- /**
- * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
- * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
- * tone's SATURATED color with the tone's `-content` text, like Win8's
- * colored tiles on the dark Start canvas.
- */
- const TONE_CLASSES: Record<TileTone, string> = {
- primary: "bg-primary text-primary-content border-primary",
- secondary: "bg-secondary text-secondary-content border-secondary",
- accent: "bg-accent text-accent-content border-accent",
- info: "bg-info text-info-content border-info",
- success: "bg-success text-success-content border-success",
- warning: "bg-warning text-warning-content border-warning",
- };
+ gsap.registerPlugin(Flip);
let {
geometry,
@@ -32,11 +28,11 @@
hasActive,
onOpen,
onSelect,
- onReorder,
+ onPlace,
onRenameEnd,
}: {
geometry: GridGeometry;
- /** Packed placements (in pack order — the manual tile order). */
+ /** Packed placements of the COMMITTED layout (the drag preview derives). */
placements: readonly Placement[];
workspacesById: ReadonlyMap<string, WorkspaceEntry>;
/** Forwarded to tiles for the inline rename (semantics preserved). */
@@ -49,8 +45,8 @@
hasActive?: (workspaceId: string) => boolean;
onOpen: (id: string) => void;
onSelect: (id: string) => void;
- /** The drag dropped `id` at `toIndex` in the manual order (-1 = nowhere). */
- onReorder: (id: string, toIndex: number) => void;
+ /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */
+ onPlace: (id: string, cell: Cell | null) => void;
onRenameEnd: () => void;
} = $props();
@@ -59,6 +55,10 @@
const LONG_PRESS_MS = 500;
let phase = $state<DragPhase>({ kind: "idle" });
let longPressTimer: ReturnType<typeof setTimeout> | undefined;
+ /** Pointer position where the current press began (the follow transform base). */
+ let pressOrigin = $state<{ x: number; y: number } | null>(null);
+ /** The tile mid-settle after a drop (excluded from FLIP until it lands). */
+ let settling = $state<string | null>(null);
function armLongPress(): void {
disarmLongPress();
@@ -76,25 +76,64 @@
}
function dispatch(event: DragEvent): void {
+ const wasDragging = phase.kind === "dragging" ? phase.id : null;
const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX });
phase = transition.phase;
const emit = transition.emit;
if (emit) {
if (emit.kind === "tap") onOpen(emit.id);
else if (emit.kind === "lift") onSelect(emit.id);
- else if (emit.kind === "drop" && emit.targetIndex >= 0) onReorder(emit.id, emit.targetIndex);
+ else if (emit.kind === "drop") drop(emit.id, emit.targetCell);
+ }
+ if (phase.kind === "idle") {
+ // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above;
+ // a CANCEL mid-drag flies the tile back to its committed slot.
+ if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging);
+ pressOrigin = null;
}
// Keep the long-press armed exactly while a TOUCH press is undecided.
if (phase.kind === "pressing" && phase.touch) armLongPress();
else disarmLongPress();
}
- /** The hovered tile's index in the manual order (-1 = not over a tile). */
- function hoveredIndex(x: number, y: number): number {
- const hit = document.elementFromPoint(x, y)?.closest("[data-tile-id]");
- const id = hit?.getAttribute("data-tile-id");
- if (id === null || id === undefined) return -1;
- return placements.findIndex((placement) => placement.id === id);
+ /** The tile being dragged (or settling after a drop) follows no FLIP. */
+ const excludedFromFlip = $derived(
+ new Set(
+ [phase.kind === "dragging" ? phase.id : null, settling].filter(
+ (id): id is string => id !== null,
+ ),
+ ),
+ );
+
+ /** Commit the drop, then settle the tile from the pointer onto its new cell. */
+ function drop(id: string, cell: Cell | null): void {
+ if (cell !== null) onPlace(id, cell);
+ flyBack(id);
+ }
+
+ /**
+ * Animate the tile's follow transform to identity at its (possibly new)
+ * committed slot — the drop settle AND the cancel/Escape fly-back. The tile
+ * is excluded from Flip until the animation completes so the two never
+ * fight over the same transform.
+ */
+ function flyBack(id: string): void {
+ const el = tileEls.get(id);
+ if (!el) return;
+ settling = id;
+ void tick().then(() => {
+ gsap.to(el, {
+ x: 0,
+ y: 0,
+ scale: 1,
+ duration: 0.2,
+ ease: "power2.out",
+ onComplete: () => {
+ gsap.set(el, { clearProps: "transform" });
+ settling = null;
+ },
+ });
+ });
}
/** Interactive targets inside a tile (the rename input) own their gestures. */
@@ -107,6 +146,7 @@
if (e.button !== 0 || insideInteractive(e.target)) return;
// Capture so fast drags keep the pointer events on this element.
(e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
+ pressOrigin = { x: e.clientX, y: e.clientY };
dispatch({
type: "pointer-down",
id,
@@ -122,7 +162,7 @@
type: "pointer-move",
x: e.clientX,
y: e.clientY,
- targetIndex: hoveredIndex(e.clientX, e.clientY),
+ targetCell: cellAt(e.clientX, e.clientY),
});
}
@@ -131,9 +171,72 @@
dispatch({ type: "pointer-up" });
}
- // ── Tile element registry (for the FLIP reflow animation) ─────────────────
+ // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the
+ // selection — the committed layout is untouched and the tile flies home.
+ function windowKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape" || phase.kind === "idle") return;
+ e.stopPropagation();
+ dispatch({ type: "cancel" });
+ }
+
+ // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ─────
+ let gridEl = $state<HTMLDivElement | undefined>();
+
+ const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
+ const pitchY = $derived(geometry.unit.height + geometry.unit.gap);
+
+ function cellAt(clientX: number, clientY: number): Cell | null {
+ const el = gridEl;
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns.
+ const cellW = wide ? pitchX : rect.width / geometry.flowCols;
+ const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1);
+ const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1);
+ return {
+ col: wide ? col : Math.min(col, geometry.flowCols),
+ row: wide ? Math.min(row, geometry.flowRows) : row,
+ };
+ }
+
+ // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─
+ /** The dragged tile's id while a drag is in flight (else null). */
+ const draggedId = $derived<string | null>(
+ phase.kind === "dragging" ? phase.id : null,
+ );
+ const draggedPlacement = $derived(
+ draggedId !== null
+ ? (placements.find((placement) => placement.id === draggedId) ?? null)
+ : null,
+ );
+
+ /** The pointer's hovered cell while dragging (else null). */
+ const hoveredCell = $derived<Cell | null>(
+ phase.kind === "dragging" ? phase.targetCell : null,
+ );
+
+ /** The landing cell: where the pointer is, else the tile's committed slot. */
+ const targetCell = $derived<Cell | null>(
+ hoveredCell ??
+ (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null),
+ );
+
+ /**
+ * What to render: while dragging, the displacement of the COMMITTED layout
+ * at the target cell — recomputed from committed on EVERY hover change (as
+ * if the user moved straight from the original to here); the FLIP below
+ * animates from wherever the tiles currently are. Otherwise the committed
+ * placements.
+ */
+ const rendered = $derived(
+ draggedPlacement && targetCell && draggedId !== null
+ ? displacePlace(placements, draggedId, targetCell, geometry)
+ : placements,
+ );
+
+ // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ───
let tileEls = new Map<string, HTMLElement>();
- let lastRects = new Map<string, { x: number; y: number }>();
function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
tileEls.set(id, el);
@@ -144,32 +247,31 @@
};
}
- // FLIP: measure after a placements/geometry change and play the inverted
- // delta — reordering edits the ORDER, the reflow is always derived.
+ let flipState: ReturnType<typeof Flip.getState> | null = null;
+
+ $effect.pre(() => {
+ // Track the layout inputs; the DOM read below is intentionally untracked.
+ void rendered
+ .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`)
+ .join("|");
+ void geometry.mode;
+ flipState = Flip.getState(
+ [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el),
+ );
+ });
+
$effect(() => {
- // Track the layout inputs (the DOM reads below are untracked).
- const ids = new Set(placements.map((placement) => placement.id));
+ void rendered;
void geometry.mode;
- const rects = new Map<string, { x: number; y: number }>();
- for (const [id, el] of tileEls) {
- if (!ids.has(id)) continue;
- const rect = el.getBoundingClientRect();
- rects.set(id, { x: rect.left, y: rect.top });
- }
- for (const [id, prev] of lastRects) {
- const next = rects.get(id);
- const el = tileEls.get(id);
- if (!next || !el) continue;
- const dx = prev.x - next.x;
- const dy = prev.y - next.y;
- if ((dx !== 0 || dy !== 0) && typeof el.animate === "function") {
- el.animate(
- [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
- { duration: 200, easing: "ease-out" },
- );
- }
- }
- lastRects = rects;
+ const state = flipState;
+ flipState = null;
+ if (!state) return;
+ Flip.from(state, {
+ duration: 0.25,
+ ease: "power2.inOut",
+ nested: true,
+ onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }),
+ });
});
// ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ────
@@ -209,15 +311,34 @@
}
// ── Grid + tile styles (the geometry decides; no breakpoint classes) ──────
- const gridStyle = $derived(
- geometry.mode === "wide"
- ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
+ const dragging = $derived(phase.kind === "dragging");
+
+ const gridStyle = $derived.by(() => {
+ const base =
+ geometry.mode === "wide"
+ ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` +
` gap: ${geometry.unit.gap}px; width: max-content;`
- : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
+ : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` +
- ` gap: ${geometry.unit.gap}px;`,
- );
+ ` gap: ${geometry.unit.gap}px;`;
+ if (!dragging) return base;
+ // The drop-target dots: one faint dot at each unit cell's CENTER, painted
+ // as a background whose tile is the cell pitch. One extra pitch of padding
+ // past the content end gives the append-slot a dot too.
+ const dotSize =
+ geometry.mode === "wide" ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`;
+ const dotX = geometry.mode === "wide" ? geometry.unit.width / 2 : 0;
+ const dot =
+ ` background-image: radial-gradient(circle 2.5px,` +
+ ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` +
+ ` background-size: ${dotSize} ${pitchY}px;` +
+ ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` +
+ (geometry.mode === "wide"
+ ? ` padding-right: ${pitchX}px;`
+ : ` padding-bottom: ${pitchY}px;`);
+ return base + dot;
+ });
function spanFor(placement: Placement): Span {
if (preview && preview.id === placement.id) {
@@ -230,8 +351,48 @@
const span = spanFor(placement);
return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`;
}
+
+ /** The dragged tile's follow transform (it keeps its committed slot). */
+ function followStyle(placement: Placement): string {
+ if (draggedId !== placement.id || !pressOrigin || phase.kind !== "dragging") return "";
+ const dx = phase.x - pressOrigin.x;
+ const dy = phase.y - pressOrigin.y;
+ return `transform: translate(${dx}px, ${dy}px) scale(1.05);`;
+ }
+
+ // ── The drop ghost: a dashed footprint at the target anchor ───────────────
+ const ghostStyle = $derived.by(() => {
+ if (!dragging || !targetCell || !draggedPlacement) return null;
+ const span = spanFor(draggedPlacement);
+ const wide = geometry.mode === "wide";
+ const h = span.rowSpan * pitchY - geometry.unit.gap;
+ const x = wide
+ ? `${(targetCell.col - 1) * pitchX}px`
+ : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`;
+ const width = wide
+ ? `${span.colSpan * pitchX - geometry.unit.gap}px`
+ : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`;
+ return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`;
+ });
+
+ /**
+ * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
+ * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
+ * tone's SATURATED color with the tone's `-content` text, like Win8's
+ * colored tiles on the dark Start canvas.
+ */
+ const TONE_CLASSES: Record<TileTone, string> = {
+ primary: "bg-primary text-primary-content border-primary",
+ secondary: "bg-secondary text-secondary-content border-secondary",
+ accent: "bg-accent text-accent-content border-accent",
+ info: "bg-info text-info-content border-info",
+ success: "bg-success text-success-content border-success",
+ warning: "bg-warning text-warning-content border-warning",
+ };
</script>
+<svelte:window onkeydowncapture={windowKeydown} />
+
<div class="relative min-h-0 flex-1">
<div
bind:this={scrollerEl}
@@ -242,21 +403,40 @@
onscroll={updateScroll}
onwheel={handleWheel}
>
- <div class="grid" style={gridStyle}>
- {#each placements as placement (placement.id)}
+ <div bind:this={gridEl} class="relative grid" style={gridStyle}>
+ {#if ghostStyle && draggedPlacement}
+ <!-- The landing footprint (the dragged tile's tone, dashed). -->
+ <div
+ aria-hidden="true"
+ class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70"
+ class:border-primary={tileTone(draggedPlacement.id) === "primary"}
+ class:border-secondary={tileTone(draggedPlacement.id) === "secondary"}
+ class:border-accent={tileTone(draggedPlacement.id) === "accent"}
+ class:border-info={tileTone(draggedPlacement.id) === "info"}
+ class:border-success={tileTone(draggedPlacement.id) === "success"}
+ class:border-warning={tileTone(draggedPlacement.id) === "warning"}
+ style={ghostStyle}
+ ></div>
+ {/if}
+ {#each rendered as placement (placement.id)}
{@const ws = workspacesById.get(placement.id)}
{#if ws}
+ {@const isDragged = draggedId === placement.id}
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div
data-tile-id={placement.id}
- class={`flex select-none flex-col overflow-hidden rounded-box border shadow-md transition-[transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:shadow-xl ${
+ class={`flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${
TONE_CLASSES[tileTone(placement.id)]
} ${
selectedId === placement.id
? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
: "border-transparent"
- } ${phase.kind === "dragging" && phase.id === placement.id ? "opacity-40" : ""}`}
- style={`${wrapperStyle(placement)} scroll-snap-align: start;`}
+ } ${
+ isDragged
+ ? "pointer-events-none relative z-40 shadow-2xl"
+ : "shadow-md hover:-translate-y-0.5 hover:shadow-xl"
+ }`}
+ style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`}
role="button"
tabindex="0"
aria-label={`Open workspace ${ws.title}`}
@@ -316,13 +496,3 @@
</div>
{/if}
</div>
-
-{#if phase.kind === "dragging"}
- <!-- The drag ghost follows the pointer (transient; never a data change). -->
- <div
- class="pointer-events-none fixed z-50 rounded-box border border-base-content/25 bg-base-100/95 px-3 py-2 text-sm font-bold shadow-2xl"
- style={`left: ${phase.x + 10}px; top: ${phase.y + 10}px;`}
- >
- {workspacesById.get(phase.id)?.title ?? phase.id}
- </div>
-{/if}