diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 17:40:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 17:40:29 +0900 |
| commit | 46b5f9d30f149c3e80f483c91f563c0a6fc75b25 (patch) | |
| tree | d6554c2634b624ead9f87881ff506506f88a7cbe /src/features/workspaces/logic | |
| parent | 828d4e4fb822ec2925c8a625bc5bf326c3425964 (diff) | |
| download | dispatch-web-46b5f9d30f149c3e80f483c91f563c0a6fc75b25.tar.gz dispatch-web-46b5f9d30f149c3e80f483c91f563c0a6fc75b25.zip | |
feat(workspaces): bound the drag append area — 3 columns past the content edge
Dragging a tile far right grew the wide canvas endlessly (the target cell
resolved from the pointer with no upper bound), making precise placement
impossible. Add a pure `dragAppendLimit(placements, geometry, extra = 3)`:
the drag target may aim at most 3 columns (wide) / rows (tall) past the
current span-aware content edge. The bound is measured on the COMMITTED
layout — never the live preview — so it can't ratchet mid-drag; it steps
forward only when a drop commits new content. cellAt applies it on the
growth axis (wide: col, tall: row); the fixed cross-axis clamps are
unchanged. Exported via the unit index.
Verification: svelte-check 0 errors/0 warnings; vitest 1693/1693 (4 new
dragAppendLimit tests); biome clean on touched files; build ok.
Diffstat (limited to 'src/features/workspaces/logic')
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.test.ts | 31 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 29 |
2 files changed, 60 insertions, 0 deletions
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 |
