1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
|
<script lang="ts">
import { onMount, untrack } from "svelte";
import type { GridGeometry, Span } from "../logic/tile-layout";
let {
geometry,
initial,
onPreview,
onCommit,
onCancel,
}: {
geometry: GridGeometry;
/** The tile's current (clamped) spans — the picker's starting cell. */
initial: Span;
/** Transiently ghost-preview the hovered footprint on the real grid. */
onPreview: (span: Span | null) => void;
onCommit: (colSpan: number, rowSpan: number) => void;
onCancel: () => void;
} = $props();
// The picker IS the span cap: wide = 4×2 small squares, tall = 2×2.
const cols = $derived(geometry.maxColSpan);
const rows = $derived(geometry.maxRowSpan);
// The currently hovered cell (c, r) — the highlighted rectangle spans
// 1..c × 1..r from the picker's top-left (insert-table style). The picker
// mounts fresh per resize session, so seeding from the prop is intended.
let hovered = $state({
colSpan: untrack(() => Math.min(initial.colSpan, cols)),
rowSpan: untrack(() => Math.min(initial.rowSpan, rows)),
});
function hover(colSpan: number, rowSpan: number): void {
hovered = { colSpan, rowSpan };
onPreview({ colSpan, rowSpan });
}
function commit(): void {
onCommit(hovered.colSpan, hovered.rowSpan);
}
function keydown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
return;
}
if (e.key === "Enter") {
e.preventDefault();
commit();
return;
}
let dc = 0;
let dr = 0;
if (e.key === "ArrowRight") dc = 1;
else if (e.key === "ArrowLeft") dc = -1;
else if (e.key === "ArrowDown") dr = 1;
else if (e.key === "ArrowUp") dr = -1;
else return;
e.preventDefault();
hover(
Math.max(1, Math.min(cols, hovered.colSpan + dc)),
Math.max(1, Math.min(rows, hovered.rowSpan + dr)),
);
}
let root = $state<HTMLDivElement | undefined>();
onMount(() => root?.focus());
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex -->
<div
bind:this={root}
class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none"
role="group"
aria-label="Resize tile"
tabindex="0"
onkeydown={keydown}
onmouseleave={() => onPreview(null)}
>
<p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p>
<div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}>
{#each Array.from({ length: rows }) as _, row (row)}
{#each Array.from({ length: cols }) as __, col (col)}
<button
type="button"
class={`size-6 border border-base-content/20 ${
col < hovered.colSpan && row < hovered.rowSpan
? "bg-primary/60"
: col === hovered.colSpan - 1 && row === hovered.rowSpan - 1
? "bg-primary/20"
: ""
}`}
aria-label={`Resize to ${col + 1} by ${row + 1}`}
onmouseenter={() => hover(col + 1, row + 1)}
onfocus={() => hover(col + 1, row + 1)}
onclick={() => {
hover(col + 1, row + 1);
commit();
}}
></button>
{/each}
{/each}
</div>
<p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p>
<p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p>
</div>
|