summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md4
-rw-r--r--src/features/workspaces/index.ts1
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts31
-rw-r--r--src/features/workspaces/logic/tile-layout.ts29
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte13
5 files changed, 76 insertions, 2 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 07240ab..a90a351 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -286,6 +286,10 @@ changes, NO contract changes — the wire types and every endpoint consumed are
elements to the GRID CONTAINER, which `setPointerCapture`s the pointer and never gets
re-parented/`pointer-events: none`/unmounted — so move/up stream to it over empty cells and off
the canvas, and capture can never be lost to a DOM move again.
+- **Drag append bound** (`dragAppendLimit` in `logic/tile-layout.ts` + `cellAt` in
+ `HomeTileGrid.svelte`): the drag target is clamped to at most 3 columns (wide) / rows (tall)
+ past the current content edge (span-aware, measured on the COMMITTED layout so it can't ratchet
+ mid-drag) — the canvas no longer grows endlessly when dragging far right / down.
- Verification: `typecheck` 0 errors/0 warnings, vitest 1689/1689 (incl. updated
`tile-layout.test.ts` order assertions — coordinates unchanged), biome clean on touched files,
`build` succeeds. Browser-only (pointer capture) — needs a human at the page to confirm the
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 243357e..a921617 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -24,6 +24,7 @@ export type {
export {
clampSpan,
displacePlace,
+ dragAppendLimit,
fillOrderKey,
firstEmptySlot,
geometryFor,
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts
index a5d58c0..b538373 100644
--- a/src/features/workspaces/logic/tile-layout.test.ts
+++ b/src/features/workspaces/logic/tile-layout.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
clampSpan,
displacePlace,
+ dragAppendLimit,
fillOrderKey,
firstEmptySlot,
type GridGeometry,
@@ -524,6 +525,36 @@ describe("placeAtCell", () => {
});
});
+describe("dragAppendLimit", () => {
+ it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => {
+ // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 },
+ ];
+ expect(dragAppendLimit(placements, WIDE)).toBe(7);
+ });
+
+ it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => {
+ // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 },
+ { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 },
+ ];
+ expect(dragAppendLimit(placements, TALL)).toBe(7);
+ });
+
+ it("an empty grid still allows the first tiles within the first 3 columns", () => {
+ expect(dragAppendLimit([], WIDE)).toBe(3);
+ expect(dragAppendLimit([], TALL)).toBe(3);
+ });
+
+ it("honors a custom extra", () => {
+ const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }];
+ expect(dragAppendLimit(placements, WIDE, 5)).toBe(6);
+ });
+});
+
describe("sortByRecentActivity", () => {
it("re-applies the recent-activity order (descending)", () => {
const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)];
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts
index c4720d3..6f7c281 100644
--- a/src/features/workspaces/logic/tile-layout.ts
+++ b/src/features/workspaces/logic/tile-layout.ts
@@ -489,6 +489,35 @@ export function placeAtCell(
}
/**
+ * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim
+ * at most `extra` columns (wide) / rows (tall) past the current content edge,
+ * so a drag can never create an endlessly growing canvas ("hard to place if
+ * it just grows"): wide bound = the right-most occupied column (span-aware) +
+ * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the
+ * first tiles may land within the first `extra` columns. The bound is measured
+ * on the COMMITTED placements — a drag always compares against the layout it
+ * started from, never the live preview, so the limit cannot ratchet mid-drag.
+ * Pure: placements + geometry in, the max allowed column (wide) / row (tall)
+ * out.
+ */
+export function dragAppendLimit(
+ placements: readonly Placement[],
+ geometry: GridGeometry,
+ extra = 3,
+): number {
+ let extent = 0;
+ for (const placement of placements) {
+ extent = Math.max(
+ extent,
+ geometry.mode === "wide"
+ ? placement.col + placement.colSpan - 1
+ : placement.row + placement.rowSpan - 1,
+ );
+ }
+ return extent + extra;
+}
+
+/**
* The first footprint-fitting EMPTY slot in fill order — where a NEW tile of
* the given span would go (the header's create flow). Pure: entries + span +
* geometry in, a slot placement out (always exists; may be a new column/row
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte
index 1ec31e5..adcdf08 100644
--- a/src/features/workspaces/ui/HomeTileGrid.svelte
+++ b/src/features/workspaces/ui/HomeTileGrid.svelte
@@ -6,6 +6,7 @@
import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
import {
displacePlace,
+ dragAppendLimit,
type Cell,
type GridGeometry,
type Placement,
@@ -203,6 +204,14 @@
const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
const pitchY = $derived(geometry.unit.height + geometry.unit.gap);
+ /**
+ * The drag target's clamp on the growth axis — at most 3 columns (wide) /
+ * rows (tall) past the current content edge. Measured on the COMMITTED
+ * placements (the drag preview never ratchets the limit mid-drag), so the
+ * canvas can't endlessly grow when dragging far right / down.
+ */
+ const appendLimit = $derived(dragAppendLimit(placements, geometry));
+
function cellAt(clientX: number, clientY: number): Cell | null {
const el = gridEl;
if (!el) return null;
@@ -213,8 +222,8 @@
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,
+ col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols),
+ row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit),
};
}