"use client"; import { PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import { Link2, X } from "lucide-react"; import { useState } from "react"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Select, SelectGroup, SelectGroupLabel, SelectItem, SelectPopup, SelectTrigger, SelectValue, } from "~/components/ui/select"; import { cn } from "~/lib/utils"; import { BROWSER_DEVICE_TOOLBAR_HEIGHT, resizeFreeformViewport } from "./browserViewportLayout"; import { commitViewportAndAspectRatio } from "./browserDeviceToolbarState"; const RESPONSIVE_VALUE = "responsive"; const SELECT_ITEMS = [ { value: RESPONSIVE_VALUE, label: "Responsive" }, ...PREVIEW_VIEWPORT_PRESETS.map((preset) => ({ value: preset.id, label: preset.label })), ]; function ScreenRotationIcon() { return ( ); } interface Props { readonly setting: Exclude; readonly width: number; readonly aspectRatio: number | null; readonly onAspectRatioChange: (aspectRatio: number | null) => void; readonly onChange: (setting: PreviewViewportSetting) => Promise; } export function BrowserDeviceToolbar({ setting, width, aspectRatio, onAspectRatioChange, onChange, }: Props) { const [pending, setPending] = useState(false); const [customSize, setCustomSize] = useState<{ readonly width: string; readonly height: string; } | null>(null); const presentedSize = customSize ?? { width: String(setting.width), height: String(setting.height), }; const selectedValue = setting._tag === "preset" && PREVIEW_VIEWPORT_PRESETS.some((preset) => preset.id === setting.presetId) ? setting.presetId : RESPONSIVE_VALUE; const customWidth = Number(presentedSize.width); const customHeight = Number(presentedSize.height); const customValid = Number.isInteger(customWidth) && Number.isInteger(customHeight) && customWidth >= PREVIEW_VIEWPORT_MIN_DIMENSION && customWidth <= PREVIEW_VIEWPORT_MAX_DIMENSION && customHeight >= PREVIEW_VIEWPORT_MIN_DIMENSION && customHeight <= PREVIEW_VIEWPORT_MAX_DIMENSION && customWidth * customHeight <= PREVIEW_VIEWPORT_MAX_AREA; const apply = (next: PreviewViewportSetting, nextAspectRatio = aspectRatio) => { setPending(true); void commitViewportAndAspectRatio(next, nextAspectRatio, onChange, onAspectRatioChange).then( () => { setPending(false); setCustomSize(null); }, () => setPending(false), ); }; const applyCustomSize = () => { if (!customValid || (customWidth === setting.width && customHeight === setting.height)) { setCustomSize(null); return; } apply({ _tag: "freeform", width: customWidth, height: customHeight }); }; const updateCustomDimension = (axis: "width" | "height", value: string) => { setCustomSize((current) => { const next = { width: axis === "width" ? value : (current?.width ?? String(setting.width)), height: axis === "height" ? value : (current?.height ?? String(setting.height)), }; const numeric = Number(value); if ( aspectRatio === null || !Number.isInteger(numeric) || numeric < PREVIEW_VIEWPORT_MIN_DIMENSION || numeric > PREVIEW_VIEWPORT_MAX_DIMENSION ) { return next; } const resized = resizeFreeformViewport( setting, axis === "width" ? { x: numeric - setting.width, y: 0 } : { x: 0, y: numeric - setting.height }, 1, axis === "width" ? "east" : "south", aspectRatio, ); return { width: String(resized.width), height: String(resized.height) }; }); }; const selectViewport = (value: string | null) => { if (!value) return; if (value === RESPONSIVE_VALUE) { if (setting._tag === "freeform") return; apply({ _tag: "freeform", width: setting.width, height: setting.height }); return; } const preset = PREVIEW_VIEWPORT_PRESETS.find((candidate) => candidate.id === value); if (!preset) return; apply( resolvePreviewViewport({ mode: "preset", preset: preset.id }), aspectRatio === null ? null : preset.width / preset.height, ); }; const rotate = () => { const hasCustomSize = customValid && (customWidth !== setting.width || customHeight !== setting.height); const source = hasCustomSize ? ({ _tag: "freeform", width: customWidth, height: customHeight } as const) : setting; apply( { ...source, width: source.height, height: source.width }, aspectRatio === null ? null : 1 / aspectRatio, ); }; const toggleAspectRatio = () => { onAspectRatioChange(aspectRatio === null ? customWidth / customHeight : null); }; return (
{ const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; const eventTarget = event.target; if ( (nextTarget instanceof HTMLElement && nextTarget.closest('[data-slot="select-positioner"]')) || (eventTarget instanceof HTMLElement && eventTarget.closest('[data-slot="select-positioner"]')) ) { return; } applyCustomSize(); }} > {width >= 560 ? ( Dimensions ) : null}
{ event.preventDefault(); applyCustomSize(); }} > setCustomSize( (current) => current ?? { width: String(setting.width), height: String(setting.height), }, ) } onChange={(event) => updateCustomDimension("width", event.target.value)} aria-label="Viewport width" aria-invalid={!customValid} className={cn( "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", width >= 360 ? "w-14" : "w-11", )} /> × setCustomSize( (current) => current ?? { width: String(setting.width), height: String(setting.height), }, ) } onChange={(event) => updateCustomDimension("height", event.target.value)} aria-label="Viewport height" aria-invalid={!customValid} className={cn( "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", width >= 360 ? "w-14" : "w-11", )} />
); }