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/tile-layout.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/tile-layout.ts')
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 335 |
1 files changed, 268 insertions, 67 deletions
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts index 4d81a0c..4b5c89c 100644 --- a/src/features/workspaces/logic/tile-layout.ts +++ b/src/features/workspaces/logic/tile-layout.ts @@ -2,13 +2,16 @@ * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero * DOM, zero Svelte, zero effects. * - * The PERSISTED data is orientation-independent: `{ version, tiles: [{ id, - * colSpan, rowSpan, order }] }` — the manual tile order + requested spans - * ONLY, never positions. Positions are re-derived by {@link pack} from the - * CURRENT geometry, so the same data renders in either geometry (wide desktop - * / tall mobile) with no orientation conversion step: a tile whose persisted - * span exceeds the geometry's cap simply renders clamped (the persisted span - * is untouched). + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). * * Terminology (distinct from the layout feature's nested split-tree "tile"): * a HOME TILE is one workspace's cell in the flat start-screen grid. @@ -55,19 +58,28 @@ export interface GridGeometry { readonly margin: number; } -/** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */ +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ export interface TileLayoutEntry { /** The workspace id (the slug). */ readonly id: string; readonly colSpan: number; readonly rowSpan: number; - /** Lower sorts earlier. Positions are NEVER persisted — only this order. */ + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; } /** The persisted document (localStorage via the injected `LocalStore` adapter). */ export interface TileLayoutData { - readonly version: 1; + readonly version: 1 | 2; readonly tiles: readonly TileLayoutEntry[]; } @@ -187,6 +199,37 @@ export function fillOrderKey(col: number, row: number, geometry: GridGeometry): return (row - 1) * geometry.flowCols + (col - 1); } +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + function cellsFree( occupied: ReadonlySet<string>, col: number, @@ -260,24 +303,180 @@ function firstFit( } /** - * THE order → coordinates transform: place the tiles (in their persisted - * order) into the first fitting slot in the geometry's fill order — greedy - * first-fit, so every fitting slot is used before the flow advances (no - * intentional holes). Requested spans clamp to the geometry (a 3×1 desktop - * tile in tall mode packs as 2×1 — the persisted span is untouched). Pure: - * entries + geometry in, a NEW placements array out (in pack order, which - * respects the persisted order). + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). */ export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { const occupied = new Set<string>(); - const placements: Placement[] = []; - for (const tile of sortTiles(tiles)) { + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { const { colSpan, rowSpan } = clampSpan(tile, geometry); const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); occupy(occupied, slot.col, slot.row, colSpan, rowSpan); - placements.push({ id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; } - return placements; + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out (stream order). + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + return placed.sort( + (a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry), + ); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); } /** @@ -309,28 +508,33 @@ function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { } /** - * Insert (or MOVE an already-present tile to) the first empty slot in fill - * order — the header-create placement. The new tile is 1×1, and inserting it - * before the first placement that starts AFTER the empty slot makes greedy - * packing drop it exactly into that slot. Idempotent: an id already in the - * layout is moved (spans preserved), not duplicated — this closes the race - * where the list-refresh sync appended the id first. Pure: entries in, a NEW - * entries array out (renumbered). + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). */ -export function insertAtFirstEmptySlot( +export function pinAtFirstEmpty( tiles: readonly TileLayoutEntry[], id: string, geometry: GridGeometry, ): TileLayoutEntry[] { const without = sortTiles(tiles).filter((tile) => tile.id !== id); const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); - const slotKey = fillOrderKey(slot.col, slot.row, geometry); - const placements = pack(without, geometry); - let index = placements.findIndex((p) => fillOrderKey(p.col, p.row, geometry) > slotKey); - if (index === -1) index = without.length; + const anchor = toUniversalCell(slot, geometry); const existing = tiles.find((tile) => tile.id === id); - const entry: TileLayoutEntry = existing ?? { id, colSpan: 1, rowSpan: 1, order: 0 }; - return renumber([...without.slice(0, index), entry, ...without.slice(index)]); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; } /** @@ -355,31 +559,10 @@ export function resizeTile( } /** - * Move one tile to a position in the manual ORDER (the drag-reorder op). The - * target index is in the CURRENT order-sorted array; the tile is removed and - * re-inserted at the index, so dragging forward lands after the hovered tile - * and dragging backward lands before it (the dragged tile takes the hovered - * slot). Positions re-derive from the new order — never stored. Pure. - */ -export function moveTile( - tiles: readonly TileLayoutEntry[], - id: string, - toIndex: number, -): TileLayoutEntry[] { - const ordered = sortTiles(tiles); - const from = ordered.findIndex((tile) => tile.id === id); - if (from === -1 || toIndex === from) return ordered; - const without = ordered.filter((tile) => tile.id !== id); - const index = Math.max(0, Math.min(Math.floor(toIndex), without.length)); - const moved = ordered[from]; - if (!moved) return ordered; - return renumber([...without.slice(0, index), moved, ...without.slice(index)]); -} - -/** - * Re-apply the recent-activity order (the header overflow "Sort" action): - * tiles sort by their workspace's `lastActivityAt` descending (unknown ids - * last, ties stable), then orders renumber. Pure. + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. */ export function sortByRecentActivity( tiles: readonly TileLayoutEntry[], @@ -388,7 +571,12 @@ export function sortByRecentActivity( const sorted = sortTiles(tiles).sort( (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), ); - return renumber(sorted); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); } /** @@ -429,14 +617,17 @@ export function mergeLayout( /** * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load - * is untyped at the boundary). Anything but `{ version: 1, tiles: [...] }` - * yields the empty layout; malformed entries coerce (missing spans → 1, - * garbage → dropped). Pure: unknown in, data out. + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. */ export function sanitizeLayout(raw: unknown): TileLayoutData { if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; const version = (raw as { version?: unknown }).version; - if (version !== 1) return EMPTY_LAYOUT; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; const rawTiles = (raw as { tiles?: unknown }).tiles; if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; const tiles: TileLayoutEntry[] = []; @@ -447,17 +638,21 @@ export function sanitizeLayout(raw: unknown): TileLayoutData { const id = (rawTile as { id?: unknown }).id; if (typeof id !== "string" || id === "" || seen.has(id)) continue; seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); tiles.push({ id, colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), }); } - return { version: 1, tiles }; + return { version: 2, tiles }; } -const EMPTY_LAYOUT: TileLayoutData = { version: 1, tiles: [] }; +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; function coerceSpan(value: unknown): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; @@ -468,3 +663,9 @@ function coerceOrder(value: unknown, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.floor(value); } + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} |
