diff options
Diffstat (limited to 'src/features/workspaces')
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.test.ts | 17 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 19 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.svelte | 79 |
3 files changed, 88 insertions, 27 deletions
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts index 81156e7..a5d58c0 100644 --- a/src/features/workspaces/logic/tile-layout.test.ts +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -350,11 +350,12 @@ describe("displacePlace", () => { { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, ]; const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); - // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array + // keeps the INPUT render order (only coordinates change). expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ - ["Y", 1, 1], ["X", 1, 2], ["X2", 1, 3], + ["Y", 1, 1], ]); }); @@ -366,10 +367,11 @@ describe("displacePlace", () => { { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, ]; const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + // Same coordinates, input render order: X, Z, then the dragged Y. expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ - ["Y", 1, 2], ["X", 1, 3], ["Z", 1, 4], + ["Y", 1, 2], ]); }); @@ -380,9 +382,10 @@ describe("displacePlace", () => { ]; // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + // The array keeps the input render order (a first) — only a's coords move. expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ - ["b", 1, 2], ["a", 1, 3], + ["b", 1, 2], ]); }); @@ -432,12 +435,12 @@ describe("displacePlace", () => { { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, ]; const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); - // d is pushed past the column's end → column 2 row 1. Stream order: - // b(1) c(2) a(3) d(4). + // d is pushed past the column's end → column 2 row 1. The array keeps the + // INPUT render order (a,b,c,d) — only coordinates change. expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 4], ["b", 1, 2], ["c", 1, 3], - ["a", 1, 4], ["d", 2, 1], ]); }); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts index 4b5c89c..c4720d3 100644 --- a/src/features/workspaces/logic/tile-layout.ts +++ b/src/features/workspaces/logic/tile-layout.ts @@ -413,7 +413,9 @@ function firstFitFrom( * 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). + * in, a NEW placements array out — in the INPUT array order (only coordinates + * change). A keyed `{#each}` over the result therefore never moves DOM nodes; + * moving a captured element drops its pointer capture — the "stuck tile" bug. */ export function displacePlace( placements: readonly Placement[], @@ -447,9 +449,12 @@ export function displacePlace( 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), - ); + // Return in the INPUT render order (coordinates change only): the live drag + // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes + // — a moved captured element silently loses pointer capture (the stuck-tile + // bug). The commit path re-sorts to stream order itself (`placeAtCell`). + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => byId.get(placement.id) ?? placement); } /** @@ -468,7 +473,11 @@ export function placeAtCell( // 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); + // displacePlace preserves the input RENDER order for the live preview; the + // commit pins + renumbers in STREAM order, so re-sort before pinning. + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry) + .slice() + .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry)); const result: TileLayoutEntry[] = []; for (const placement of displaced) { const entry = tiles.find((tile) => tile.id === placement.id); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte index 46b3c4e..f50d902 100644 --- a/src/features/workspaces/ui/HomeTileGrid.svelte +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -57,6 +57,8 @@ 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); + /** Where within the pressed tile the pointer grabbed (its follow-transform base). */ + let grabOffset = $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); @@ -90,6 +92,7 @@ // a CANCEL mid-drag flies the tile back to its committed slot. if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); pressOrigin = null; + grabOffset = null; } // Keep the long-press armed exactly while a TOUCH press is undecided. if (phase.kind === "pressing" && phase.touch) armLongPress(); @@ -142,10 +145,25 @@ return el?.closest("input, select, textarea, button, a") !== null; } - function tilePointerDown(e: PointerEvent, id: string): void { - 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); + /** + * Drag gestures live on the GRID CONTAINER, not the tiles: the container is + * never re-parented, never toggles `pointer-events`, and never unmounts, so + * pointer capture set here survives the whole gesture. (A captured element + * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an + * uncaptured pointer can't stream events over empty cells — the empty-cell + * bug.) Capture also keeps move/up flowing when the pointer leaves the grid. + */ + function gridPointerDown(e: PointerEvent): void { + if (e.button !== 0) return; + const tileEl = (e.target as Element | null)?.closest("[data-tile-id]"); + if (!tileEl || insideInteractive(e.target)) return; + const id = tileEl.getAttribute("data-tile-id"); + if (id === null) return; + gridEl?.setPointerCapture?.(e.pointerId); + // Where within the tile the pointer grabbed — the follow transform keeps + // THIS point under the pointer (not the tile's top-left origin). + const rect = tileEl.getBoundingClientRect(); + grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; pressOrigin = { x: e.clientX, y: e.clientY }; dispatch({ type: "pointer-down", @@ -156,8 +174,8 @@ }); } - function tilePointerMove(e: PointerEvent): void { - if (phase.kind === "idle" || insideInteractive(e.target)) return; + function gridPointerMove(e: PointerEvent): void { + if (phase.kind === "idle") return; dispatch({ type: "pointer-move", x: e.clientX, @@ -166,7 +184,7 @@ }); } - function tilePointerUp(): void { + function gridPointerUp(): void { if (phase.kind === "idle") return; dispatch({ type: "pointer-up" }); } @@ -352,11 +370,37 @@ return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; } + /** + * The landing cell's top-left in CLIENT coordinates — where the CSS grid + * actually places a tile anchored at `targetCell` (the true column pitch: + * fixed in wide; the fluid column width PLUS the gap in tall). The follow + * transform cancels exactly this, so the grab point tracks the pointer. + */ + const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => { + const el = gridEl; + if (!el || !targetCell) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols; + return { + x: rect.left + (targetCell.col - 1) * colPitch, + y: rect.top + (targetCell.row - 1) * pitchY, + }; + }); + /** 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; + if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") { + return ""; + } + const origin = targetCellOrigin; + if (!origin) return ""; + // Pointer minus the tile's GRID anchor minus the grab offset — the grab + // point the user pressed tracks the pointer. A raw pointer-displacement + // translate would ride the tile's origin instead and jump by the grab + // offset (a center grab snaps the tile's top-left under the pointer). + const dx = phase.x - origin.x - grabOffset.x; + const dy = phase.y - origin.y - grabOffset.y; return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; } @@ -403,7 +447,16 @@ onscroll={updateScroll} onwheel={handleWheel} > - <div bind:this={gridEl} class="relative grid" style={gridStyle}> + <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) --> + <div + bind:this={gridEl} + class="relative grid" + style={gridStyle} + onpointerdown={gridPointerDown} + onpointermove={gridPointerMove} + onpointerup={gridPointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + > {#if ghostStyle && draggedPlacement} <!-- The landing footprint (the dragged tile's tone, dashed). --> <div @@ -441,10 +494,6 @@ tabindex="0" aria-label={`Open workspace ${ws.title}`} use:trackTile={placement.id} - onpointerdown={(e) => tilePointerDown(e, placement.id)} - onpointermove={tilePointerMove} - onpointerup={tilePointerUp} - onpointercancel={() => dispatch({ type: "cancel" })} oncontextmenu={(e) => { e.preventDefault(); onSelect(placement.id); |
