| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | PREVIEW_VIEWPORT_MAX_AREA, |
| 5 | PREVIEW_VIEWPORT_MAX_DIMENSION, |
| 6 | PREVIEW_VIEWPORT_MIN_DIMENSION, |
| 7 | type PreviewViewportSetting, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; |
| 10 | import { Link2, Unlink2, X } from "lucide-react"; |
| 11 | import { useState } from "react"; |
| 12 | |
| 13 | import { Button } from "~/components/ui/button"; |
| 14 | import { Input } from "~/components/ui/input"; |
| 15 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 16 | import { |
| 17 | Select, |
| 18 | SelectGroup, |
| 19 | SelectGroupLabel, |
| 20 | SelectItem, |
| 21 | SelectPopup, |
| 22 | SelectTrigger, |
| 23 | SelectValue, |
| 24 | } from "~/components/ui/select"; |
| 25 | import { cn } from "~/lib/utils"; |
| 26 | |
| 27 | import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; |
| 28 | import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; |
| 29 | import { ScreenRotationIcon } from "./ScreenRotationIcon"; |
| 30 | |
| 31 | const RESPONSIVE_VALUE = "responsive"; |
| 32 | const SELECT_ITEMS = [ |
| 33 | { value: RESPONSIVE_VALUE, label: "Responsive" }, |
| 34 | ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), |
| 35 | ]; |
| 36 | |
| 37 | interface 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 | |
| 45 | export 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 | } |