diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index 05cd59182..34e692081 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -1,5 +1,5 @@ import type { PreviewTabsState, PreviewTabState, ProjectId } from "@okcode/contracts"; -import { type FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { type FormEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ChevronLeftIcon, ChevronRightIcon, @@ -11,6 +11,8 @@ import { MonitorIcon, PlusIcon, RefreshCwIcon, + RotateCcwIcon, + RulerIcon, SmartphoneIcon, StarIcon, TabletIcon, @@ -20,23 +22,22 @@ import { import { validateHttpPreviewUrl } from "@okcode/shared/preview"; import { readDesktopPreviewBridge } from "~/desktopPreview"; -import { type BrowserPresetId, BROWSER_PRESETS, getBrowserPreset } from "~/lib/browserPresets"; +import { + type BrowserPresetId, + BROWSER_PRESETS, + DEFAULT_CUSTOM_VIEWPORT, + PRESET_CYCLE, + clampViewportDimension, + getBrowserPreset, +} from "~/lib/browserPresets"; import { cn } from "~/lib/utils"; +import { isMacPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { usePreviewStateStore } from "~/previewStateStore"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { - Menu, - MenuGroup, - MenuGroupLabel, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "./ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const EMPTY_TABS_STATE: PreviewTabsState = { tabs: [], @@ -75,9 +76,10 @@ const PRESET_ICONS: Record = { laptop: LaptopIcon, desktop: MonitorIcon, ultrawide: MonitorIcon, + custom: RulerIcon, }; -/** Sentinel value used by the radio group to represent "no preset" (responsive). */ +/** Sentinel value used by the toggle group to represent "no preset" (responsive). */ const RESPONSIVE_VALUE = "__responsive__"; function getActiveTab(state: PreviewTabsState): PreviewTabState | null { @@ -104,6 +106,37 @@ interface PreviewPanelProps { onClose: () => void; } +/** + * Resolve effective viewport dimensions for a preset, applying orientation and + * custom viewport overrides. + */ +function resolveViewportDimensions( + presetId: BrowserPresetId | null, + orientation: "portrait" | "landscape", + customViewport: { width: number; height: number }, +): { width: number; height: number } | null { + if (!presetId) return null; + + let w: number; + let h: number; + + if (presetId === "custom") { + w = customViewport.width; + h = customViewport.height; + } else { + const preset = getBrowserPreset(presetId); + if (!preset) return null; + w = preset.width; + h = preset.height; + } + + if (orientation === "landscape") { + return { width: Math.max(w, h), height: Math.min(w, h) }; + } + // portrait: ensure height >= width (natural for mobile/tablet), leave landscape-native presets as-is + return { width: w, height: h }; +} + export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps) { const previewBridge = readDesktopPreviewBridge(); const setProjectOpen = usePreviewStateStore((state) => state.setProjectOpen); @@ -111,7 +144,16 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps const toggleFavoriteUrl = usePreviewStateStore((state) => state.toggleFavoriteUrl); const presetId = usePreviewStateStore((state) => state.presetByProjectId[projectId] ?? null); const setProjectPreset = usePreviewStateStore((state) => state.setProjectPreset); - const activePreset = presetId ? getBrowserPreset(presetId) : null; + const orientation = usePreviewStateStore( + (state) => state.orientationByProjectId[projectId] ?? "portrait", + ); + const toggleProjectOrientation = usePreviewStateStore((state) => state.toggleProjectOrientation); + const customViewport = usePreviewStateStore( + (state) => state.customViewportByProjectId[projectId] ?? DEFAULT_CUSTOM_VIEWPORT, + ); + const setCustomViewport = usePreviewStateStore((state) => state.setCustomViewport); + + const effectiveDims = resolveViewportDimensions(presetId, orientation, customViewport); const PresetIcon = presetId ? PRESET_ICONS[presetId] : null; const [tabsState, setTabsState] = useState(EMPTY_TABS_STATE); @@ -119,6 +161,21 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps const [inputError, setInputError] = useState(null); const surfaceRef = useRef(null); + // Live surface dimensions for the badge + const [surfaceDims, setSurfaceDims] = useState<{ w: number; h: number } | null>(null); + const [dimsBadgeVisible, setDimsBadgeVisible] = useState(false); + const dimsFadeTimer = useRef | null>(null); + + // Custom viewport local input state + const [customWidthInput, setCustomWidthInput] = useState(String(customViewport.width)); + const [customHeightInput, setCustomHeightInput] = useState(String(customViewport.height)); + + // Sync custom input fields when store value changes externally + useEffect(() => { + setCustomWidthInput(String(customViewport.width)); + setCustomHeightInput(String(customViewport.height)); + }, [customViewport.width, customViewport.height]); + const activeTab = getActiveTab(tabsState); const showEmbeddedSurface = activeTab !== null && (activeTab.status === "loading" || activeTab.status === "ready"); @@ -257,6 +314,67 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps }; }, [previewBridge]); + // Live dimensions badge via ResizeObserver + useEffect(() => { + const el = surfaceRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setSurfaceDims({ w: Math.round(width), h: Math.round(height) }); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Show dimensions badge transiently when preset or orientation changes + useEffect(() => { + if (!presetId) { + setDimsBadgeVisible(false); + return; + } + setDimsBadgeVisible(true); + if (dimsFadeTimer.current) clearTimeout(dimsFadeTimer.current); + dimsFadeTimer.current = setTimeout(() => setDimsBadgeVisible(false), 2500); + return () => { + if (dimsFadeTimer.current) clearTimeout(dimsFadeTimer.current); + }; + }, [presetId, orientation, customViewport.width, customViewport.height]); + + // Keyboard shortcuts for cycling presets + const handlePresetShortcut = useCallback( + (event: KeyboardEvent) => { + const isMac = isMacPlatform(navigator.platform); + const mod = isMac ? event.metaKey : event.ctrlKey; + if (!mod || !event.shiftKey) return; + + if (event.key === "M" || event.key === "m") { + // Ctrl/Cmd+Shift+M: toggle mobile + event.preventDefault(); + setProjectPreset(projectId, presetId === "mobile" ? null : "mobile"); + return; + } + + if (event.key === "[" || event.key === "]") { + event.preventDefault(); + const currentIndex = PRESET_CYCLE.indexOf(presetId); + const idx = currentIndex === -1 ? 0 : currentIndex; + const delta = event.key === "]" ? 1 : -1; + const nextIndex = (idx + delta + PRESET_CYCLE.length) % PRESET_CYCLE.length; + const next = PRESET_CYCLE[nextIndex]; + setProjectPreset(projectId, next ?? null); + } + }, + [presetId, projectId, setProjectPreset], + ); + + useEffect(() => { + window.addEventListener("keydown", handlePresetShortcut); + return () => window.removeEventListener("keydown", handlePresetShortcut); + }, [handlePresetShortcut]); + const onSubmit = (event: FormEvent) => { event.preventDefault(); const validatedUrl = validateHttpPreviewUrl(inputUrl); @@ -302,6 +420,14 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps void api?.shell.openExternal(targetUrl); }; + const commitCustomViewport = () => { + const w = clampViewportDimension(Number(customWidthInput) || DEFAULT_CUSTOM_VIEWPORT.width); + const h = clampViewportDimension(Number(customHeightInput) || DEFAULT_CUSTOM_VIEWPORT.height); + setCustomViewport(projectId, { width: w, height: h }); + setCustomWidthInput(String(w)); + setCustomHeightInput(String(h)); + }; + const currentPageUrl = activeTab?.url ?? null; const isFavorite = currentPageUrl !== null && favoriteUrls.includes(currentPageUrl); @@ -366,58 +492,133 @@ export function PreviewPanel({ projectId, threadId, onClose }: PreviewPanelProps />
- - - {PresetIcon ? : } - - {activePreset ? activePreset.label : "Responsive"} - - - - - Viewport - { - setProjectPreset( - projectId, - value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId), - ); - }} - > - - - - Responsive + {/* ---- Viewport Preset Strip ---- */} +
+ {/* Responsive */} + + setProjectPreset(projectId, null)} + > + + + + Responsive + + + +
+ + {/* Device presets */} + {BROWSER_PRESETS.map((preset) => { + const Icon = PRESET_ICONS[preset.id]; + const isActive = presetId === preset.id; + return ( + + setProjectPreset(projectId, isActive ? null : preset.id)} + > + + + + {preset.label}{" "} + + {preset.width}×{preset.height} - - - {BROWSER_PRESETS.map((preset) => { - const Icon = PRESET_ICONS[preset.id]; - return ( - - - - {preset.label} - - {preset.width}×{preset.height} - - - - ); - })} - - - -
+ + + ); + })} + +
+ + {/* Custom viewport */} + + setProjectPreset(projectId, presetId === "custom" ? null : "custom")} + > + + + + Custom size + + + + {/* Orientation toggle */} + {presetId && ( + <> +
+ + toggleProjectOrientation(projectId)} + > + + + + {orientation === "portrait" ? "Landscape" : "Portrait"} + + + + )} +
+ + {/* Custom viewport dimension inputs */} + {presetId === "custom" && ( +
+ setCustomWidthInput(e.target.value)} + onBlur={commitCustomViewport} + onKeyDown={(e) => e.key === "Enter" && commitCustomViewport()} + aria-label="Viewport width" + className="h-5 w-14 text-center text-[10px] tabular-nums" + min={320} + max={3840} + /> + × + setCustomHeightInput(e.target.value)} + onBlur={commitCustomViewport} + onKeyDown={(e) => e.key === "Enter" && commitCustomViewport()} + aria-label="Viewport height" + className="h-5 w-14 text-center text-[10px] tabular-nums" + min={320} + max={2160} + /> +
+ )}
+ + {/* Preset info bar */} + {presetId && effectiveDims && ( +
+ {PresetIcon && } + + {presetId === "custom" ? "Custom" : (getBrowserPreset(presetId)?.label ?? presetId)} + {" \u2014 "} + {effectiveDims.width}×{effectiveDims.height} + {orientation === "landscape" ? " (landscape)" : ""} + +
+ )}
); } diff --git a/apps/web/src/lib/browserPresets.ts b/apps/web/src/lib/browserPresets.ts index 993873f8b..3865c6eaa 100644 --- a/apps/web/src/lib/browserPresets.ts +++ b/apps/web/src/lib/browserPresets.ts @@ -1,4 +1,4 @@ -export type BrowserPresetId = "mobile" | "tablet" | "laptop" | "desktop" | "ultrawide"; +export type BrowserPresetId = "mobile" | "tablet" | "laptop" | "desktop" | "ultrawide" | "custom"; export interface BrowserPreset { id: BrowserPresetId; @@ -15,6 +15,24 @@ export const BROWSER_PRESETS: readonly BrowserPreset[] = [ { id: "ultrawide", label: "Ultrawide", width: 2560, height: 1080 }, ] as const; +/** Ordered list of preset IDs for cycling (excludes "custom"). */ +export const PRESET_CYCLE: readonly (BrowserPresetId | null)[] = [ + null, // responsive + "mobile", + "tablet", + "laptop", + "desktop", + "ultrawide", +]; + export function getBrowserPreset(id: BrowserPresetId): BrowserPreset | undefined { return BROWSER_PRESETS.find((p) => p.id === id); } + +/** Default dimensions for custom viewports. */ +export const DEFAULT_CUSTOM_VIEWPORT = { width: 1024, height: 768 } as const; + +/** Clamp a viewport dimension to sane bounds. */ +export function clampViewportDimension(value: number): number { + return Math.max(320, Math.min(3840, Math.round(value))); +} diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 88e33f19a..0b23a6171 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -4,12 +4,20 @@ import { create } from "zustand"; import type { BrowserPresetId } from "./lib/browserPresets"; export type PreviewDock = "left" | "right" | "top" | "bottom"; +export type PreviewOrientation = "portrait" | "landscape"; + +export interface CustomViewport { + width: number; + height: number; +} interface PersistedPreviewUiState { openByProjectId: Record; dockByProjectId: Record; sizeByProjectId: Record; presetByProjectId: Record; + orientationByProjectId: Record; + customViewportByProjectId: Record; favoriteUrls: string[]; } @@ -20,6 +28,9 @@ interface PreviewStateStore extends PersistedPreviewUiState { toggleProjectLayout: (projectId: ProjectId) => void; setProjectSize: (projectId: ProjectId, size: number) => void; setProjectPreset: (projectId: ProjectId, preset: BrowserPresetId | null) => void; + setProjectOrientation: (projectId: ProjectId, orientation: PreviewOrientation) => void; + toggleProjectOrientation: (projectId: ProjectId) => void; + setCustomViewport: (projectId: ProjectId, viewport: CustomViewport) => void; addFavoriteUrl: (url: string) => void; removeFavoriteUrl: (url: string) => void; toggleFavoriteUrl: (url: string) => void; @@ -27,18 +38,43 @@ interface PreviewStateStore extends PersistedPreviewUiState { const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v4"; -const VALID_PRESETS = new Set(["mobile", "tablet", "laptop", "desktop", "ultrawide"]); +const VALID_PRESETS = new Set([ + "mobile", + "tablet", + "laptop", + "desktop", + "ultrawide", + "custom", +]); +const VALID_ORIENTATIONS = new Set(["portrait", "landscape"]); function isValidPresetId(value: unknown): value is BrowserPresetId { return typeof value === "string" && VALID_PRESETS.has(value); } +function isValidOrientation(value: unknown): value is PreviewOrientation { + return typeof value === "string" && VALID_ORIENTATIONS.has(value); +} + +function isValidCustomViewport(value: unknown): value is CustomViewport { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.width === "number" && + Number.isFinite(v.width) && + typeof v.height === "number" && + Number.isFinite(v.height) + ); +} + function createEmptyPersistedPreviewUiState(): PersistedPreviewUiState { return { openByProjectId: {}, dockByProjectId: {}, sizeByProjectId: {}, presetByProjectId: {}, + orientationByProjectId: {}, + customViewportByProjectId: {}, favoriteUrls: [], }; } @@ -105,6 +141,24 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState { ), ) : {}, + orientationByProjectId: + parsed.orientationByProjectId && typeof parsed.orientationByProjectId === "object" + ? Object.fromEntries( + Object.entries(parsed.orientationByProjectId).filter( + (entry): entry is [string, PreviewOrientation] => + typeof entry[0] === "string" && isValidOrientation(entry[1]), + ), + ) + : {}, + customViewportByProjectId: + parsed.customViewportByProjectId && typeof parsed.customViewportByProjectId === "object" + ? Object.fromEntries( + Object.entries(parsed.customViewportByProjectId).filter( + (entry): entry is [string, CustomViewport] => + typeof entry[0] === "string" && isValidCustomViewport(entry[1]), + ), + ) + : {}, favoriteUrls: Array.isArray(parsed.favoriteUrls) ? parsed.favoriteUrls.filter( (u): u is string => typeof u === "string" && u.trim().length > 0, @@ -129,6 +183,8 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void { dockByProjectId: state.dockByProjectId, sizeByProjectId: state.sizeByProjectId, presetByProjectId: state.presetByProjectId, + orientationByProjectId: state.orientationByProjectId, + customViewportByProjectId: state.customViewportByProjectId, favoriteUrls: state.favoriteUrls, } satisfies PersistedPreviewUiState), ); @@ -143,6 +199,8 @@ function snapshotState(state: PreviewStateStore): PersistedPreviewUiState { dockByProjectId: state.dockByProjectId, sizeByProjectId: state.sizeByProjectId, presetByProjectId: state.presetByProjectId, + orientationByProjectId: state.orientationByProjectId, + customViewportByProjectId: state.customViewportByProjectId, favoriteUrls: state.favoriteUrls, }; } @@ -226,6 +284,43 @@ export const usePreviewStateStore = create((set, get) => ({ }); }, + setProjectOrientation: (projectId, orientation) => { + set((state) => { + const nextOrientationByProjectId = { + ...state.orientationByProjectId, + [projectId]: orientation, + }; + persistPreviewUiState({ + ...snapshotState(state), + orientationByProjectId: nextOrientationByProjectId, + }); + return { orientationByProjectId: nextOrientationByProjectId }; + }); + }, + + toggleProjectOrientation: (projectId) => { + const current = get().orientationByProjectId[projectId] ?? "portrait"; + get().setProjectOrientation(projectId, current === "portrait" ? "landscape" : "portrait"); + }, + + setCustomViewport: (projectId, viewport) => { + const clamped: CustomViewport = { + width: Math.max(320, Math.min(3840, Math.round(viewport.width))), + height: Math.max(320, Math.min(2160, Math.round(viewport.height))), + }; + set((state) => { + const nextCustomViewportByProjectId = { + ...state.customViewportByProjectId, + [projectId]: clamped, + }; + persistPreviewUiState({ + ...snapshotState(state), + customViewportByProjectId: nextCustomViewportByProjectId, + }); + return { customViewportByProjectId: nextCustomViewportByProjectId }; + }); + }, + addFavoriteUrl: (url) => { const normalized = url.trim(); if (normalized.length === 0) return;