1import {
2 PREVIEW_VIEWPORT_MAX_AREA,
3 PREVIEW_VIEWPORT_MAX_DIMENSION,
4 PREVIEW_VIEWPORT_MIN_DIMENSION,
5 type PreviewViewportSetting,
6 type PreviewViewportSize,
7} from "@t3tools/contracts";
8
9export interface BrowserViewportLayout {
10 readonly canvasWidth: number;
11 readonly canvasHeight: number;
12 readonly viewportX: number;
13 readonly viewportY: number;
14 /** Visible footprint inside the preview panel after fit-to-panel scaling. */
15 readonly viewportWidth: number;
16 readonly viewportHeight: number;
17 /** Presentation-only scale; the guest keeps its requested CSS viewport. */
18 readonly viewportScale: number;
19 readonly fillsPanel: boolean;
20}
21
22// Room for a compact size input (30px with its border) plus its 3px focus ring
23// on both sides, inside the toolbar's 1px bottom border. The bar scrolls
24// horizontally, which also clips anything that overflows vertically.
25export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 38;
26export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 16;
27
28export type BrowserViewportResizeDirection =
29 | "north"
30 | "northeast"
31 | "east"
32 | "southeast"
33 | "south"
34 | "southwest"
35 | "west"
36 | "northwest";
37
38export const browserViewportSettingKey = (setting: PreviewViewportSetting): string =>
39 setting._tag === "fill"
40 ? "fill"
41 : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`;
42
43const normalizeZoomFactor = (zoomFactor: number): number =>
44 Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
45
46export function resolveFittedBrowserViewport(
47 setting: PreviewViewportSetting,
48 sourceContent: {
49 readonly width: number;
50 readonly height: number;
51 readonly scale: number;
52 } | null,
53 zoomFactor = 1,
54): Exclude<PreviewViewportSetting, { readonly _tag: "fill" }> {
55 if (setting._tag !== "fill") return setting;
56 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
57 if (sourceContent) {
58 return {
59 _tag: "freeform",
60 width: Math.max(
61 1,
62 Math.round(sourceContent.width / sourceContent.scale / normalizedZoomFactor),
63 ),
64 height: Math.max(
65 1,
66 Math.round(sourceContent.height / sourceContent.scale / normalizedZoomFactor),
67 ),
68 };
69 }
70 return { _tag: "freeform", width: 1280, height: 800 };
71}
72
73export function resolveBrowserDeviceViewportArea(container: {
74 readonly width: number;
75 readonly height: number;
76}): PreviewViewportSize {
77 return {
78 width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2),
79 height: Math.max(
80 1,
81 container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
82 ),
83 };
84}
85
86export function resolveBrowserViewportLayout(
87 container: { readonly width: number; readonly height: number },
88 setting: PreviewViewportSetting,
89 zoomFactor = 1,
90): BrowserViewportLayout {
91 const containerWidth = Math.max(1, Math.round(container.width));
92 const containerHeight = Math.max(1, Math.round(container.height));
93 if (setting._tag === "fill") {
94 return {
95 canvasWidth: containerWidth,
96 canvasHeight: containerHeight,
97 viewportX: 0,
98 viewportY: 0,
99 viewportWidth: containerWidth,
100 viewportHeight: containerHeight,
101 viewportScale: 1,
102 fillsPanel: true,
103 };
104 }
105 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
106 const renderedWidth = setting.width * normalizedZoomFactor;
107 const renderedHeight = setting.height * normalizedZoomFactor;
108 const viewportScale = Math.min(
109 1,
110 containerWidth / renderedWidth,
111 containerHeight / renderedHeight,
112 );
113 const viewportWidth = renderedWidth * viewportScale;
114 const viewportHeight = renderedHeight * viewportScale;
115 return {
116 canvasWidth: containerWidth,
117 canvasHeight: containerHeight,
118 viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)),
119 viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)),
120 viewportWidth,
121 viewportHeight,
122 viewportScale,
123 fillsPanel: false,
124 };
125}
126
127export function resolveBrowserDeviceViewportLayout(
128 container: { readonly width: number; readonly height: number },
129 setting: Exclude<PreviewViewportSetting, { readonly _tag: "fill" }>,
130 zoomFactor = 1,
131): BrowserViewportLayout {
132 const layout = resolveBrowserViewportLayout(
133 resolveBrowserDeviceViewportArea(container),
134 setting,
135 zoomFactor,
136 );
137 return {
138 ...layout,
139 canvasWidth: Math.max(1, Math.round(container.width)),
140 canvasHeight: Math.max(1, Math.round(container.height)),
141 viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE,
142 viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT,
143 };
144}
145
146const clampViewportDimension = (value: number): number =>
147 Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value));
148
149const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number =>
150 aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0;
151
152function resizeAtAspectRatio(
153 desired: number,
154 aspectRatio: number,
155 primaryAxis: "width" | "height",
156): PreviewViewportSize {
157 if (primaryAxis === "width") {
158 const minimum = Math.ceil(
159 Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio),
160 );
161 const maximum = Math.floor(
162 Math.min(
163 PREVIEW_VIEWPORT_MAX_DIMENSION,
164 PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio,
165 Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio),
166 ),
167 );
168 let width = Math.min(maximum, Math.max(minimum, Math.round(desired)));
169 let height = Math.round(width / aspectRatio);
170 while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) {
171 width -= 1;
172 height = Math.round(width / aspectRatio);
173 }
174 return { width, height };
175 }
176
177 const minimum = Math.ceil(
178 Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio),
179 );
180 const maximum = Math.floor(
181 Math.min(
182 PREVIEW_VIEWPORT_MAX_DIMENSION,
183 PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio,
184 Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio),
185 ),
186 );
187 let height = Math.min(maximum, Math.max(minimum, Math.round(desired)));
188 let width = Math.round(height * aspectRatio);
189 while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) {
190 height -= 1;
191 width = Math.round(height * aspectRatio);
192 }
193 return { width, height };
194}
195
196export function resizeFreeformViewport(
197 start: PreviewViewportSize,
198 delta: { readonly x: number; readonly y: number },
199 zoomFactor = 1,
200 direction: BrowserViewportResizeDirection = "southeast",
201 aspectRatio?: number,
202): PreviewViewportSize {
203 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
204 const horizontalDelta = direction.includes("east")
205 ? delta.x
206 : direction.includes("west")
207 ? -delta.x
208 : 0;
209 const verticalDelta = direction.includes("south")
210 ? delta.y
211 : direction.includes("north")
212 ? -delta.y
213 : 0;
214 const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor;
215 const desiredHeight = start.height + verticalDelta / normalizedZoomFactor;
216 if (validAspectRatio(aspectRatio)) {
217 const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west";
218 const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south";
219 const primaryAxis =
220 controlsWidth && !controlsHeight
221 ? "width"
222 : controlsHeight && !controlsWidth
223 ? "height"
224 : Math.abs(desiredWidth - start.width) / start.width >=
225 Math.abs(desiredHeight - start.height) / start.height
226 ? "width"
227 : "height";
228 return resizeAtAspectRatio(
229 primaryAxis === "width" ? desiredWidth : desiredHeight,
230 aspectRatio,
231 primaryAxis,
232 );
233 }
234 let width = clampViewportDimension(Math.round(desiredWidth));
235 let height = clampViewportDimension(Math.round(desiredHeight));
236 if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height };
237 if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) {
238 width = Math.max(
239 PREVIEW_VIEWPORT_MIN_DIMENSION,
240 Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height),
241 );
242 } else {
243 height = Math.max(
244 PREVIEW_VIEWPORT_MIN_DIMENSION,
245 Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width),
246 );
247 }
248 return { width, height };
249}
250
251const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => {
252 const startEdge = start < available ? (available + start) / 2 : start;
253 const targetEdge = startEdge + pointerDelta;
254 return targetEdge <= available ? targetEdge * 2 - available : targetEdge;
255};
256
257const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => {
258 if (start > available) {
259 const distanceToFit = start - available;
260 return pointerDelta <= distanceToFit
261 ? start - pointerDelta
262 : available - (pointerDelta - distanceToFit) * 2;
263 }
264 const targetEdge = (available - start) / 2 + pointerDelta;
265 return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge;
266};
267
268export function resizeBrowserViewportFromRail(
269 start: PreviewViewportSize,
270 pointerDelta: { readonly x: number; readonly y: number },
271 available: PreviewViewportSize,
272 zoomFactor = 1,
273 direction: BrowserViewportResizeDirection = "southeast",
274 aspectRatio?: number,
275): PreviewViewportSize {
276 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
277 const startWidth = start.width * normalizedZoomFactor;
278 const startHeight = start.height * normalizedZoomFactor;
279 const desiredWidth = direction.includes("east")
280 ? resizeFromEndRail(startWidth, pointerDelta.x, available.width)
281 : direction.includes("west")
282 ? resizeFromStartRail(startWidth, pointerDelta.x, available.width)
283 : startWidth;
284 const desiredHeight = direction.includes("south")
285 ? resizeFromEndRail(startHeight, pointerDelta.y, available.height)
286 : direction.includes("north")
287 ? resizeFromStartRail(startHeight, pointerDelta.y, available.height)
288 : startHeight;
289 const widthDelta = desiredWidth - startWidth;
290 const heightDelta = desiredHeight - startHeight;
291 return resizeFreeformViewport(
292 start,
293 {
294 x: direction.includes("west") ? -widthDelta : widthDelta,
295 y: direction.includes("north") ? -heightDelta : heightDelta,
296 },
297 normalizedZoomFactor,
298 direction,
299 aspectRatio,
300 );
301}
302
303export function resolveResponsiveBrowserViewportSize(
304 container: { readonly width: number; readonly height: number },
305 zoomFactor = 1,
306): PreviewViewportSize {
307 const area = resolveBrowserDeviceViewportArea(container);
308 const normalizedZoomFactor = normalizeZoomFactor(zoomFactor);
309 return resizeFreeformViewport(
310 {
311 width: area.width / normalizedZoomFactor,
312 height: area.height / normalizedZoomFactor,
313 },
314 { x: 0, y: 0 },
315 );
316}