Skip to content
Source
Sign inSign up
Open History to see the last commit

BrowserDeviceToolbar.tsx

341 lines11.1 KB

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