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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
|
import {
GridTableIcon,
LayoutThreeColumnIcon,
LayoutThreeRowIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { NumberField } from "@/components/ui/number-field";
import {
GRID_MIN,
GRID_MAX,
DEFAULT_GRID_CONFIG,
} from "@/constants/guide-constants";
import { usePreviewStore } from "@/stores/preview-store";
import { clampRound } from "@/utils/math";
import { cn } from "@/utils/ui";
import type { GuideDefinition } from "@/lib/guides/types";
function GridLines({
rows,
cols,
color,
}: {
rows: number;
cols: number;
color: string;
}) {
const verticals = Array.from(
{ length: cols - 1 },
(_, i) => ((i + 1) / cols) * 100,
);
const horizontals = Array.from(
{ length: rows - 1 },
(_, i) => ((i + 1) / rows) * 100,
);
return (
<>
{verticals.map((pct) => (
<div
key={`v-${pct}`}
className={cn("absolute top-0 bottom-0 w-px", color)}
style={{ left: `${pct}%` }}
/>
))}
{horizontals.map((pct) => (
<div
key={`h-${pct}`}
className={cn("absolute left-0 right-0 h-px", color)}
style={{ top: `${pct}%` }}
/>
))}
</>
);
}
function GridGuidePreview() {
return (
<div className="relative aspect-video w-full">
<GridLines rows={3} cols={4} color="bg-foreground/15" />
</div>
);
}
function GridGuideOverlay() {
const { rows, cols } = usePreviewStore((s) => s.gridConfig);
return (
<div className="absolute inset-0">
<GridLines rows={rows} cols={cols} color="bg-white/35" />
</div>
);
}
function GridGuideOptions() {
const rows = usePreviewStore((s) => s.gridConfig.rows);
const cols = usePreviewStore((s) => s.gridConfig.cols);
const setGridConfig = usePreviewStore((s) => s.setGridConfig);
const clampGridValue = (value: number) =>
clampRound({ value, min: GRID_MIN, max: GRID_MAX });
return (
<div className="flex gap-2">
<NumberField
icon={<HugeiconsIcon icon={LayoutThreeRowIcon} />}
value={rows}
min={GRID_MIN}
max={GRID_MAX}
isDefault={rows === DEFAULT_GRID_CONFIG.rows}
onReset={() => setGridConfig({ rows: DEFAULT_GRID_CONFIG.rows })}
onScrub={(value) => setGridConfig({ rows: clampGridValue(value) })}
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(parsed))
setGridConfig({ rows: clampGridValue(parsed) });
}}
className="flex-1"
/>
<NumberField
icon={<HugeiconsIcon icon={LayoutThreeColumnIcon} />}
value={cols}
min={GRID_MIN}
max={GRID_MAX}
isDefault={cols === DEFAULT_GRID_CONFIG.cols}
onReset={() => setGridConfig({ cols: DEFAULT_GRID_CONFIG.cols })}
onScrub={(value) => setGridConfig({ cols: clampGridValue(value) })}
onChange={(event) => {
const parsed = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(parsed))
setGridConfig({ cols: clampGridValue(parsed) });
}}
className="flex-1"
/>
</div>
);
}
export const gridGuide = {
id: "grid",
label: "Grid",
renderPreview: () => <GridGuidePreview />,
renderTriggerIcon: () => <HugeiconsIcon icon={GridTableIcon} />,
renderOverlay: () => <GridGuideOverlay />,
renderOptions: () => <GridGuideOptions />,
} as const satisfies GuideDefinition;
|