import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import "./appearance-preview.css"; import { Monitor, Tablet, Smartphone, RotateCcw, type LucideIcon, } from "lucide-react"; import { Card, CardTitle } from "./card"; import { Button } from "./button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./tooltip"; export interface AppearancePreviewState { value: string; label: string; icon: LucideIcon; } const devices = [ { value: "desktop", label: "Desktop", width: 960, icon: Monitor }, { value: "tablet", label: "Tablet", width: 768, icon: Tablet }, { value: "mobile", label: "Mobile", width: 375, icon: Smartphone }, ]; export function PreviewAction({ label, icon: Icon, selected, onClick, }: { label: string; icon: LucideIcon; selected?: boolean; onClick: () => void; }) { return ( {label} ); } /** One bounded canvas, one fit-to-panel transform, and no decorative inner frame. */ export function AppearancePreview({ feature, states = [], children, note = "Sample content · Preview only · Changes are not saved", contentClassName = "", }: { feature: string; states?: AppearancePreviewState[]; children: (state: string) => ReactNode; note?: string; contentClassName?: string; }) { const [device, setDevice] = useState("desktop"); const [state, setState] = useState(states[0]?.value || "default"); const [revision, setRevision] = useState(0); const [size, setSize] = useState({ available: 0, height: 0 }); const canvas = useRef(null); const frame = useRef(null); const selectedDevice = devices.find((item) => item.value === device)!; const width = selectedDevice.width; const activeState = states.some((item) => item.value === state) ? state : states[0]?.value || "default"; const scale = size.available ? Math.min(1, Math.max(1, size.available - 32) / width) : 1; useLayoutEffect(() => { const measure = () => { // Unscaled layout dimensions prevent feedback loops after a fit-to-panel transform. const next = { available: canvas.current?.clientWidth || 0, height: frame.current?.offsetHeight || 0, }; setSize((previous) => previous.available === next.available && previous.height === next.height ? previous : next, ); }; measure(); const observer = new ResizeObserver(measure); if (canvas.current) observer.observe(canvas.current); if (frame.current) observer.observe(frame.current); return () => observer.disconnect(); }, []); return (
Live preview
{devices.map((item) => ( setDevice(item.value)} /> ))}
{states.length > 1 && (
{states.map((item) => ( setState(item.value)} /> ))}
)} { setState(states[0]?.value || "default"); setRevision((value) => value + 1); if (canvas.current) canvas.current.scrollTop = 0; }} />
{children(activeState)}
{note} {selectedDevice.label} · {width}px · {Math.round(scale * 100)}%
); }