Skip to content

Commit f513a5f

Browse files
authored
Add browser viewport presets to preview panel (#106)
- Persist per-thread viewport preset selection - Size embedded preview surface to match selected preset - Add responsive preset handling and state tests
1 parent 4d11c44 commit f513a5f

4 files changed

Lines changed: 164 additions & 2 deletions

File tree

apps/web/src/components/PreviewPanel.tsx

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,37 @@ import {
55
ChevronRightIcon,
66
ExternalLinkIcon,
77
GlobeIcon,
8+
LaptopIcon,
89
LoaderCircleIcon,
10+
MaximizeIcon,
11+
MonitorIcon,
912
PlusIcon,
1013
RefreshCwIcon,
14+
SmartphoneIcon,
1115
StarIcon,
16+
TabletIcon,
1217
WrenchIcon,
1318
XIcon,
1419
} from "lucide-react";
1520

1621
import { validateHttpPreviewUrl } from "@okcode/shared/preview";
1722
import { readDesktopPreviewBridge } from "~/desktopPreview";
23+
import { type BrowserPresetId, BROWSER_PRESETS, getBrowserPreset } from "~/lib/browserPresets";
1824
import { cn } from "~/lib/utils";
1925
import { readNativeApi } from "~/nativeApi";
2026
import { usePreviewStateStore } from "~/previewStateStore";
2127

2228
import { Button } from "./ui/button";
2329
import { Input } from "./ui/input";
30+
import {
31+
Menu,
32+
MenuGroupLabel,
33+
MenuPopup,
34+
MenuRadioGroup,
35+
MenuRadioItem,
36+
MenuSeparator,
37+
MenuTrigger,
38+
} from "./ui/menu";
2439

2540
const EMPTY_TABS_STATE: PreviewTabsState = {
2641
tabs: [],
@@ -38,6 +53,17 @@ const HIDDEN_PREVIEW_BOUNDS = {
3853
viewportHeight: 0,
3954
} as const;
4055

56+
const PRESET_ICONS: Record<BrowserPresetId, typeof SmartphoneIcon> = {
57+
mobile: SmartphoneIcon,
58+
tablet: TabletIcon,
59+
laptop: LaptopIcon,
60+
desktop: MonitorIcon,
61+
ultrawide: MonitorIcon,
62+
};
63+
64+
/** Sentinel value used by the radio group to represent "no preset" (responsive). */
65+
const RESPONSIVE_VALUE = "__responsive__";
66+
4167
function getActiveTab(state: PreviewTabsState): PreviewTabState | null {
4268
if (!state.activeTabId) return null;
4369
return state.tabs.find((t) => t.tabId === state.activeTabId) ?? null;
@@ -66,6 +92,10 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
6692
const setGlobalOpen = usePreviewStateStore((state) => state.setGlobalOpen);
6793
const favoriteUrls = usePreviewStateStore((state) => state.favoriteUrls);
6894
const toggleFavoriteUrl = usePreviewStateStore((state) => state.toggleFavoriteUrl);
95+
const presetId = usePreviewStateStore((state) => state.presetByThreadId[threadId] ?? null);
96+
const setThreadPreset = usePreviewStateStore((state) => state.setThreadPreset);
97+
const activePreset = presetId ? getBrowserPreset(presetId) : null;
98+
const PresetIcon = presetId ? PRESET_ICONS[presetId] : null;
6999

70100
const [tabsState, setTabsState] = useState<PreviewTabsState>(EMPTY_TABS_STATE);
71101
const [inputUrl, setInputUrl] = useState("");
@@ -299,6 +329,61 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
299329
</form>
300330
</div>
301331
<div className="flex items-center gap-1">
332+
<Menu>
333+
<MenuTrigger
334+
className={cn(
335+
"inline-flex h-6 cursor-default items-center gap-1 rounded-md px-1.5 text-[11px] transition-colors",
336+
presetId
337+
? "bg-accent/60 text-foreground"
338+
: "text-muted-foreground/55 hover:bg-accent/40 hover:text-foreground",
339+
)}
340+
aria-label="Viewport preset"
341+
>
342+
{PresetIcon ? (
343+
<PresetIcon className="size-3" />
344+
) : (
345+
<MaximizeIcon className="size-3" />
346+
)}
347+
<span className="max-sm:hidden">
348+
{activePreset ? activePreset.label : "Responsive"}
349+
</span>
350+
</MenuTrigger>
351+
<MenuPopup side="bottom" align="end" sideOffset={6}>
352+
<MenuGroupLabel>Viewport</MenuGroupLabel>
353+
<MenuRadioGroup
354+
value={presetId ?? RESPONSIVE_VALUE}
355+
onValueChange={(value) => {
356+
setThreadPreset(
357+
threadId,
358+
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
359+
);
360+
}}
361+
>
362+
<MenuRadioItem value={RESPONSIVE_VALUE}>
363+
<span className="flex items-center gap-2">
364+
<MaximizeIcon className="size-3.5 opacity-60" />
365+
Responsive
366+
</span>
367+
</MenuRadioItem>
368+
<MenuSeparator />
369+
{BROWSER_PRESETS.map((preset) => {
370+
const Icon = PRESET_ICONS[preset.id];
371+
return (
372+
<MenuRadioItem key={preset.id} value={preset.id}>
373+
<span className="flex items-center gap-2">
374+
<Icon className="size-3.5 opacity-60" />
375+
<span>{preset.label}</span>
376+
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
377+
{preset.width}&times;{preset.height}
378+
</span>
379+
</span>
380+
</MenuRadioItem>
381+
);
382+
})}
383+
</MenuRadioGroup>
384+
</MenuPopup>
385+
</Menu>
386+
302387
<Button
303388
type="button"
304389
size="icon-xs"
@@ -418,10 +503,28 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
418503
)}
419504

420505
{/* Content area */}
421-
<div className="flex min-h-0 flex-1 flex-col p-3">
506+
<div
507+
className={cn(
508+
"flex min-h-0 flex-1 p-3",
509+
activePreset ? "items-center justify-center" : "flex-col",
510+
)}
511+
>
422512
<div
423513
ref={surfaceRef}
424-
className="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-border/70 bg-card/20"
514+
className={cn(
515+
"relative overflow-hidden rounded-lg border border-border/70 bg-card/20",
516+
!activePreset && "min-h-0 flex-1",
517+
)}
518+
style={
519+
activePreset
520+
? {
521+
width: activePreset.width,
522+
height: activePreset.height,
523+
maxWidth: "100%",
524+
maxHeight: "100%",
525+
}
526+
: undefined
527+
}
425528
>
426529
{!showEmbeddedSurface ? (
427530
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground/70">

apps/web/src/lib/browserPresets.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type BrowserPresetId = "mobile" | "tablet" | "laptop" | "desktop" | "ultrawide";
2+
3+
export interface BrowserPreset {
4+
id: BrowserPresetId;
5+
label: string;
6+
width: number;
7+
height: number;
8+
}
9+
10+
export const BROWSER_PRESETS: readonly BrowserPreset[] = [
11+
{ id: "mobile", label: "Mobile", width: 390, height: 844 },
12+
{ id: "tablet", label: "Tablet", width: 768, height: 1024 },
13+
{ id: "laptop", label: "Laptop", width: 1366, height: 768 },
14+
{ id: "desktop", label: "Desktop", width: 1920, height: 1080 },
15+
{ id: "ultrawide", label: "Ultrawide", width: 2560, height: 1080 },
16+
] as const;
17+
18+
export function getBrowserPreset(id: BrowserPresetId): BrowserPreset | undefined {
19+
return BROWSER_PRESETS.find((p) => p.id === id);
20+
}

apps/web/src/previewStateStore.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe("previewStateStore", () => {
2929
globalOpen: false,
3030
dockByThreadId: {},
3131
sizeByThreadId: {},
32+
presetByThreadId: {},
3233
favoriteUrls: [],
3334
});
3435
storage.clear();

apps/web/src/previewStateStore.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { ThreadId } from "@okcode/contracts";
22
import { create } from "zustand";
33

4+
import type { BrowserPresetId } from "./lib/browserPresets";
5+
46
export type PreviewDock = "left" | "right" | "top" | "bottom";
57

68
interface PersistedPreviewUiState {
79
globalOpen: boolean;
810
dockByThreadId: Record<string, PreviewDock>;
911
sizeByThreadId: Record<string, number>;
12+
presetByThreadId: Record<string, BrowserPresetId>;
1013
favoriteUrls: string[];
1114
}
1215

@@ -16,18 +19,26 @@ interface PreviewStateStore extends PersistedPreviewUiState {
1619
setThreadDock: (threadId: ThreadId, dock: PreviewDock) => void;
1720
toggleThreadLayout: (threadId: ThreadId) => void;
1821
setThreadSize: (threadId: ThreadId, size: number) => void;
22+
setThreadPreset: (threadId: ThreadId, preset: BrowserPresetId | null) => void;
1923
addFavoriteUrl: (url: string) => void;
2024
removeFavoriteUrl: (url: string) => void;
2125
toggleFavoriteUrl: (url: string) => void;
2226
}
2327

2428
const PREVIEW_STATE_STORAGE_KEY = "okcode:desktop-preview:v3";
2529

30+
const VALID_PRESETS = new Set<string>(["mobile", "tablet", "laptop", "desktop", "ultrawide"]);
31+
32+
function isValidPresetId(value: unknown): value is BrowserPresetId {
33+
return typeof value === "string" && VALID_PRESETS.has(value);
34+
}
35+
2636
function createEmptyPersistedPreviewUiState(): PersistedPreviewUiState {
2737
return {
2838
globalOpen: false,
2939
dockByThreadId: {},
3040
sizeByThreadId: {},
41+
presetByThreadId: {},
3142
favoriteUrls: [],
3243
};
3344
}
@@ -77,6 +88,15 @@ function readPersistedPreviewUiState(): PersistedPreviewUiState {
7788
}),
7889
)
7990
: {},
91+
presetByThreadId:
92+
parsed.presetByThreadId && typeof parsed.presetByThreadId === "object"
93+
? Object.fromEntries(
94+
Object.entries(parsed.presetByThreadId).filter(
95+
(entry): entry is [string, BrowserPresetId] =>
96+
typeof entry[0] === "string" && isValidPresetId(entry[1]),
97+
),
98+
)
99+
: {},
80100
favoriteUrls: Array.isArray(parsed.favoriteUrls)
81101
? parsed.favoriteUrls.filter(
82102
(u): u is string => typeof u === "string" && u.trim().length > 0,
@@ -100,6 +120,7 @@ function persistPreviewUiState(state: PersistedPreviewUiState): void {
100120
globalOpen: state.globalOpen,
101121
dockByThreadId: state.dockByThreadId,
102122
sizeByThreadId: state.sizeByThreadId,
123+
presetByThreadId: state.presetByThreadId,
103124
favoriteUrls: state.favoriteUrls,
104125
} satisfies PersistedPreviewUiState),
105126
);
@@ -113,6 +134,7 @@ function snapshotState(state: PreviewStateStore): PersistedPreviewUiState {
113134
globalOpen: state.globalOpen,
114135
dockByThreadId: state.dockByThreadId,
115136
sizeByThreadId: state.sizeByThreadId,
137+
presetByThreadId: state.presetByThreadId,
116138
favoriteUrls: state.favoriteUrls,
117139
};
118140
}
@@ -180,6 +202,22 @@ export const usePreviewStateStore = create<PreviewStateStore>((set, get) => ({
180202
});
181203
},
182204

205+
setThreadPreset: (threadId, preset) => {
206+
set((state) => {
207+
const nextPresetByThreadId = { ...state.presetByThreadId };
208+
if (preset === null) {
209+
delete nextPresetByThreadId[threadId];
210+
} else {
211+
nextPresetByThreadId[threadId] = preset;
212+
}
213+
persistPreviewUiState({
214+
...snapshotState(state),
215+
presetByThreadId: nextPresetByThreadId,
216+
});
217+
return { presetByThreadId: nextPresetByThreadId };
218+
});
219+
},
220+
183221
addFavoriteUrl: (url) => {
184222
const normalized = url.trim();
185223
if (normalized.length === 0) return;

0 commit comments

Comments
 (0)