1"use client";
2
3import {
4 PREVIEW_VIEWPORT_MAX_AREA,
5 PREVIEW_VIEWPORT_MAX_DIMENSION,
6 PREVIEW_VIEWPORT_MIN_DIMENSION,
7 type PreviewViewportSetting,
8} from "@t3tools/contracts";
9import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport";
10import { Link2, Unlink2, X } from "lucide-react";
11import { useState } from "react";
12
13import { Button } from "~/components/ui/button";
14import { Input } from "~/components/ui/input";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
16import {
17 Select,
18 SelectGroup,
19 SelectGroupLabel,
20 SelectItem,
21 SelectPopup,
22 SelectTrigger,
23 SelectValue,
24} from "~/components/ui/select";
25import { cn } from "~/lib/utils";
26
27import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout";
28import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState";
29import { ScreenRotationIcon } from "./ScreenRotationIcon";
30
31const RESPONSIVE_VALUE = "responsive";
32const SELECT_ITEMS = [
33 { value: RESPONSIVE_VALUE, label: "Responsive" },
34 ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })),
35];
36
37interface Props {
38 readonly setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>;
39 readonly width: number;
40 readonly aspectRatio: number | null;
41 readonly onAspectRatioChange: (aspectRatio: number | null) => void;
42 readonly onChange: (setting: PreviewViewportSetting) => Promise<void>;
43}
44
45export function BrowserDeviceToolbar({
46 setting,
47 width,
48 aspectRatio,
49 onAspectRatioChange,
50 onChange,
51}: Props) {
52 const [pending, setPending] = useState(false);
53 const [customSize, setCustomSize] = useState<{
54 readonly width: string;
55 readonly height: string;
56 } | null>(null);
57 const presentedSize = customSize ?? {
58 width: String(setting.width),
59 height: String(setting.height),
60 };
61 const selectedValue =
62 setting._tag === "preset" &&
63 PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId)
64 ? setting.presetId
65 : RESPONSIVE_VALUE;
66 const customWidth = Number(presentedSize.width);
67 const customHeight = Number(presentedSize.height);
68 const customValid =
69 Number.isInteger(customWidth) &&
70 Number.isInteger(customHeight) &&
71 customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
72 customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION &&
73 customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION &&
74 customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION &&
75 customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA;
76
77 const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => {
78 setPending(true);
79 void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then(
80 () => {
81 setPending(false);
82 setCustomSize(null);
83 },
84 () => setPending(false),
85 );
86 };
87
88 const applyCustomSize = () => {
89 if (!customValid || (customWidth === setting.width && customHeight === setting.height)) {
90 setCustomSize(null);
91 return;
92 }
93 apply({ _tag: "freeform", width: customWidth, height: customHeight });
94 };
95
96 const updateCustomDimension = (axis: "width" | "height", value: string) => {
97 setCustomSize((current) => {
98 const next = {
99 width: axis === "width" ? value : (current?.width ?? String(setting.width)),
100 height: axis === "height" ? value : (current?.height ?? String(setting.height)),
101 };
102 const numeric = Number(value);
103 if (
104 aspectRatio === null ||
105 !Number.isInteger(numeric) ||
106 numeric < PREVIEW_VIEWPORT_MIN_DIMENSION ||
107 numeric > PREVIEW_VIEWPORT_MAX_DIMENSION
108 ) {
109 return next;
110 }
111 const resized = resizeFreeformViewport(
112 setting,
113 axis === "width"
114 ? { x: numeric - setting.width, y: 0 }
115 : { x: 0, y: numeric - setting.height },
116 1,
117 axis === "width" ? "east" : "south",
118 aspectRatio,
119 );
120 return { width: String(resized.width), height: String(resized.height) };
121 });
122 };
123
124 const selectViewport = (value: string | null) => {
125 if (!value) return;
126 if (value === RESPONSIVE_VALUE) {
127 if (setting._tag === "freeform") return;
128 apply({ _tag: "freeform", width: setting.width, height: setting.height });
129 return;
130 }
131 const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value);
132 if (!preset) return;
133 apply(
134 resolvePreviewViewport({ mode: "preset", preset: preset.id }),
135 aspectRatio === null ? null : preset.width / preset.height,
136 );
137 };
138
139 const rotate = () => {
140 const hasCustomSize =
141 customValid && (customWidth !== setting.width || customHeight !== setting.height);
142 const source = hasCustomSize
143 ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const)
144 : setting;
145 apply(
146 { ...source, width: source.height, height: source.width },
147 aspectRatio === null ? null : 1 / aspectRatio,
148 );
149 };
150
151 const toggleAspectRatio = () => {
152 onAspectRatioChange(aspectRatio === null ? customWidth / customHeight : null);
153 };
154
155 return (
156 <div
157 className="sticky left-0 top-0 z-50 flex items-center gap-0.5 overflow-x-auto border-b border-border/70 bg-background/95 px-1.5 shadow-xs backdrop-blur-md [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
158 style={{ width, height: BROWSER_DEVICE_TOOLBAR_HEIGHT }}
159 role="toolbar"
160 aria-label="Browser device toolbar"
161 data-browser-device-toolbar
162 onBlur={(event) => {
163 const nextTarget = event.relatedTarget;
164 if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
165 const eventTarget = event.target;
166 if (
167 (nextTarget instanceof HTMLElement &&
168 nextTarget.closest('[data-slot="select-positioner"]')) ||
169 (eventTarget instanceof HTMLElement &&
170 eventTarget.closest('[data-slot="select-positioner"]'))
171 ) {
172 return;
173 }
174 applyCustomSize();
175 }}
176 >
177 {width >= 560 ? (
178 <span className="mr-0.5 shrink-0 text-2xs font-medium text-muted-foreground">
179 Dimensions
180 </span>
181 ) : null}
182 <Select
183 modal={false}
184 value={selectedValue}
185 onValueChange={selectViewport}
186 items={SELECT_ITEMS}
187 disabled={pending}
188 >
189 <SelectTrigger
190 variant="ghost"
191 size="xs"
192 className={cn("shrink-0 justify-between", width >= 440 ? "w-36" : "w-24")}
193 aria-label="Browser device preset"
194 >
195 <SelectValue />
196 </SelectTrigger>
197 <SelectPopup align="start" alignItemWithTrigger={false}>
198 <SelectItem value={RESPONSIVE_VALUE}>Responsive</SelectItem>
199 <SelectGroup>
200 <SelectGroupLabel>Standard</SelectGroupLabel>
201 {PREVIEW_VIEWPORT_PRESETS.map((preset) => (
202 <SelectItem key={preset.id} value={preset.id}>
203 <span className="flex w-full items-center justify-between gap-5">
204 <span>{preset.label}</span>
205 <span className="text-xs tabular-nums text-muted-foreground">
206 {preset.detail}
207 </span>
208 </span>
209 </SelectItem>
210 ))}
211 </SelectGroup>
212 </SelectPopup>
213 </Select>
214
215 <form
216 className="m-0 flex min-w-0 shrink-0 items-center gap-0.5 border-0 p-0"
217 aria-label="Viewport dimensions"
218 onSubmit={(event) => {
219 event.preventDefault();
220 applyCustomSize();
221 }}
222 >
223 <Input
224 nativeInput
225 type="number"
226 inputMode="numeric"
227 size="compact"
228 font="mono"
229 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
230 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
231 value={presentedSize.width}
232 disabled={pending}
233 onFocus={() =>
234 setCustomSize(
235 (current) =>
236 current ?? {
237 width: String(setting.width),
238 height: String(setting.height),
239 },
240 )
241 }
242 onChange={(event) => updateCustomDimension("width", event.target.value)}
243 aria-label="Viewport width"
244 aria-invalid={!customValid}
245 className={width >= 360 ? "w-14" : "w-13"}
246 />
247 <span className="text-xs text-muted-foreground">×</span>
248 <Input
249 nativeInput
250 type="number"
251 inputMode="numeric"
252 size="compact"
253 font="mono"
254 min={PREVIEW_VIEWPORT_MIN_DIMENSION}
255 max={PREVIEW_VIEWPORT_MAX_DIMENSION}
256 value={presentedSize.height}
257 disabled={pending}
258 onFocus={() =>
259 setCustomSize(
260 (current) =>
261 current ?? {
262 width: String(setting.width),
263 height: String(setting.height),
264 },
265 )
266 }
267 onChange={(event) => updateCustomDimension("height", event.target.value)}
268 aria-label="Viewport height"
269 aria-invalid={!customValid}
270 className={width >= 360 ? "w-14" : "w-13"}
271 />
272 </form>
273
274 <Tooltip>
275 <TooltipTrigger
276 render={
277 <Button
278 variant={aspectRatio === null ? "ghost" : "secondary"}
279 size="icon-xs"
280 type="button"
281 aria-label={
282 aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"
283 }
284 aria-pressed={aspectRatio !== null}
285 disabled={pending || !customValid}
286 onPointerDown={(event) => event.preventDefault()}
287 onClick={toggleAspectRatio}
288 />
289 }
290 >
291 {aspectRatio === null ? (
292 <Unlink2 className={cn(aspectRatio !== null && "text-foreground")} />
293 ) : (
294 <Link2 className={cn(aspectRatio !== null && "text-foreground")} />
295 )}
296 </TooltipTrigger>
297 <TooltipPopup side="top">
298 {aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"}
299 </TooltipPopup>
300 </Tooltip>
301 <Button
302 variant="ghost"
303 size="icon-xs"
304 type="button"
305 aria-label="Rotate viewport"
306 disabled={pending}
307 onClick={rotate}
308 >
309 <ScreenRotationIcon />
310 </Button>
311 {/* Sticky backing so scrolled controls do not show through the close action. */}
312 <span className="sticky right-0 ml-auto flex bg-background/95">
313 <Button
314 variant="ghost"
315 size="icon-xs"
316 type="button"
317 aria-label="Close device toolbar"
318 disabled={pending}
319 onClick={() => {
320 apply({ _tag: "fill" }, null);
321 }}
322 >
323 <X />
324 </Button>
325 </span>
326 </div>
327 );
328}