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