summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 17:34:27 +0900
committerAdam Malczewski <[email protected]>2026-09-07 17:34:27 +0900
commit33d69a11e197d9aced4ce0ecff58898314f26482 (patch)
tree70f980dffa18c7796e64a8ebc43672755f4f9ec8 /src
parentc43250c16356be523201c2a685d527936aeea0bd (diff)
downloaddispatch-web-33d69a11e197d9aced4ce0ecff58898314f26482.tar.gz
dispatch-web-33d69a11e197d9aced4ce0ecff58898314f26482.zip
fix(workspaces): tile-grid drag bugs — stuck drop, grab-offset follow, empty-cell hover
Three drag-interaction bugs on the Win8 tile home, one shared root cause (capture loss from DOM reordering) plus two wiring defects: - DROP NEVER COMMITS (stuck tile): displacePlace returned the displacement preview SORTED BY STREAM ORDER, so crossing stream positions moved the dragged tile's DOM node in the keyed {#each} and the browser dropped its pointer capture — pointerup never landed, phase stayed "dragging", the follow transform never cleared. displacePlace now preserves the INPUT render order (coordinates change only); placeAtCell re-sorts to stream order itself, so the commit's order-renumbering semantics are unchanged. - FOLLOW TRANSFORM OFFSET: the follow translate was raw pointer displacement, so a grab away from the tile's top-left made the tile jump (its ORIGIN rode the pointer). gridPointerDown records the grab offset (pointer minus the tile's rect) and followStyle cancels the tile's true grid anchor (targetCell origin), so the GRAB POINT tracks the pointer. - CAN'T HOVER EMPTY CELLS: pointer handling moved from per-tile elements to the GRID CONTAINER, which captures the pointer and is never re-parented, never pointer-events:none, never unmounted — move/up stream to it over empty cells and off-canvas, and capture can never be lost to a DOM move. Tests updated: the four order-sensitive displacePlace assertions now expect input render order (all coordinates unchanged — the plan's stream examples still hold); HomeTileGrid drag tests still pass via event bubbling. Verification: svelte-check 0 errors/0 warnings; vitest 1689/1689; biome clean on touched files; vite build succeeds. Pointer capture is browser-only — human check at the page: (1) drag a tile across columns, release — it must drop, not stay stuck; (2) grab a tile by its corner/center — the grabbed point must stay under the cursor; (3) hover/drag over empty cells past the last tile — the ghost + drop must keep tracking.
Diffstat (limited to 'src')
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts17
-rw-r--r--src/features/workspaces/logic/tile-layout.ts19
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte79
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);