summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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);