import { PREVIEW_VIEWPORT_MAX_AREA, PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION, type PreviewViewportSetting, type PreviewViewportSize, } from "@t3tools/contracts"; export interface BrowserViewportLayout { readonly canvasWidth: number; readonly canvasHeight: number; readonly viewportX: number; readonly viewportY: number; /** Visible footprint inside the preview panel after fit-to-panel scaling. */ readonly viewportWidth: number; readonly viewportHeight: number; /** Presentation-only scale; the guest keeps its requested CSS viewport. */ readonly viewportScale: number; readonly fillsPanel: boolean; } export const BROWSER_DEVICE_TOOLBAR_HEIGHT = 32; export const BROWSER_VIEWPORT_RESIZE_RAIL_SIZE = 10; export type BrowserViewportResizeDirection = | "north" | "northeast" | "east" | "southeast" | "south" | "southwest" | "west" | "northwest"; export const browserViewportSettingKey = (setting: PreviewViewportSetting): string => setting._tag === "fill" ? "fill" : `${setting._tag}:${setting.width}:${setting.height}:${setting._tag === "preset" ? setting.presetId : ""}`; const normalizeZoomFactor = (zoomFactor: number): number => Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1; export function resolveFittedBrowserViewport( setting: PreviewViewportSetting, sourceContent: { readonly width: number; readonly height: number; readonly scale: number; } | null, zoomFactor = 1, ): Exclude { if (setting._tag !== "fill") return setting; const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); if (sourceContent) { return { _tag: "freeform", width: Math.max( 1, Math.round(sourceContent.width / sourceContent.scale / normalizedZoomFactor), ), height: Math.max( 1, Math.round(sourceContent.height / sourceContent.scale / normalizedZoomFactor), ), }; } return { _tag: "freeform", width: 1280, height: 800 }; } export function resolveBrowserDeviceViewportArea(container: { readonly width: number; readonly height: number; }): PreviewViewportSize { return { width: Math.max(1, container.width - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE * 2), height: Math.max( 1, container.height - BROWSER_DEVICE_TOOLBAR_HEIGHT - BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, ), }; } export function resolveBrowserViewportLayout( container: { readonly width: number; readonly height: number }, setting: PreviewViewportSetting, zoomFactor = 1, ): BrowserViewportLayout { const containerWidth = Math.max(1, Math.round(container.width)); const containerHeight = Math.max(1, Math.round(container.height)); if (setting._tag === "fill") { return { canvasWidth: containerWidth, canvasHeight: containerHeight, viewportX: 0, viewportY: 0, viewportWidth: containerWidth, viewportHeight: containerHeight, viewportScale: 1, fillsPanel: true, }; } const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); const renderedWidth = setting.width * normalizedZoomFactor; const renderedHeight = setting.height * normalizedZoomFactor; const viewportScale = Math.min( 1, containerWidth / renderedWidth, containerHeight / renderedHeight, ); const viewportWidth = renderedWidth * viewportScale; const viewportHeight = renderedHeight * viewportScale; return { canvasWidth: containerWidth, canvasHeight: containerHeight, viewportX: Math.max(0, Math.round((containerWidth - viewportWidth) / 2)), viewportY: Math.max(0, Math.round((containerHeight - viewportHeight) / 2)), viewportWidth, viewportHeight, viewportScale, fillsPanel: false, }; } export function resolveBrowserDeviceViewportLayout( container: { readonly width: number; readonly height: number }, setting: Exclude, zoomFactor = 1, ): BrowserViewportLayout { const layout = resolveBrowserViewportLayout( resolveBrowserDeviceViewportArea(container), setting, zoomFactor, ); return { ...layout, canvasWidth: Math.max(1, Math.round(container.width)), canvasHeight: Math.max(1, Math.round(container.height)), viewportX: layout.viewportX + BROWSER_VIEWPORT_RESIZE_RAIL_SIZE, viewportY: layout.viewportY + BROWSER_DEVICE_TOOLBAR_HEIGHT, }; } const clampViewportDimension = (value: number): number => Math.min(PREVIEW_VIEWPORT_MAX_DIMENSION, Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, value)); const validAspectRatio = (aspectRatio: number | undefined): aspectRatio is number => aspectRatio !== undefined && Number.isFinite(aspectRatio) && aspectRatio > 0; function resizeAtAspectRatio( desired: number, aspectRatio: number, primaryAxis: "width" | "height", ): PreviewViewportSize { if (primaryAxis === "width") { const minimum = Math.ceil( Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION * aspectRatio), ); const maximum = Math.floor( Math.min( PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MAX_DIMENSION * aspectRatio, Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA * aspectRatio), ), ); let width = Math.min(maximum, Math.max(minimum, Math.round(desired))); let height = Math.round(width / aspectRatio); while (width * height > PREVIEW_VIEWPORT_MAX_AREA && width > minimum) { width -= 1; height = Math.round(width / aspectRatio); } return { width, height }; } const minimum = Math.ceil( Math.max(PREVIEW_VIEWPORT_MIN_DIMENSION, PREVIEW_VIEWPORT_MIN_DIMENSION / aspectRatio), ); const maximum = Math.floor( Math.min( PREVIEW_VIEWPORT_MAX_DIMENSION, PREVIEW_VIEWPORT_MAX_DIMENSION / aspectRatio, Math.sqrt(PREVIEW_VIEWPORT_MAX_AREA / aspectRatio), ), ); let height = Math.min(maximum, Math.max(minimum, Math.round(desired))); let width = Math.round(height * aspectRatio); while (width * height > PREVIEW_VIEWPORT_MAX_AREA && height > minimum) { height -= 1; width = Math.round(height * aspectRatio); } return { width, height }; } export function resizeFreeformViewport( start: PreviewViewportSize, delta: { readonly x: number; readonly y: number }, zoomFactor = 1, direction: BrowserViewportResizeDirection = "southeast", aspectRatio?: number, ): PreviewViewportSize { const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); const horizontalDelta = direction.includes("east") ? delta.x : direction.includes("west") ? -delta.x : 0; const verticalDelta = direction.includes("south") ? delta.y : direction.includes("north") ? -delta.y : 0; const desiredWidth = start.width + horizontalDelta / normalizedZoomFactor; const desiredHeight = start.height + verticalDelta / normalizedZoomFactor; if (validAspectRatio(aspectRatio)) { const controlsWidth = horizontalDelta !== 0 || direction === "east" || direction === "west"; const controlsHeight = verticalDelta !== 0 || direction === "north" || direction === "south"; const primaryAxis = controlsWidth && !controlsHeight ? "width" : controlsHeight && !controlsWidth ? "height" : Math.abs(desiredWidth - start.width) / start.width >= Math.abs(desiredHeight - start.height) / start.height ? "width" : "height"; return resizeAtAspectRatio( primaryAxis === "width" ? desiredWidth : desiredHeight, aspectRatio, primaryAxis, ); } let width = clampViewportDimension(Math.round(desiredWidth)); let height = clampViewportDimension(Math.round(desiredHeight)); if (width * height <= PREVIEW_VIEWPORT_MAX_AREA) return { width, height }; if (Math.abs(horizontalDelta) >= Math.abs(verticalDelta)) { width = Math.max( PREVIEW_VIEWPORT_MIN_DIMENSION, Math.floor(PREVIEW_VIEWPORT_MAX_AREA / height), ); } else { height = Math.max( PREVIEW_VIEWPORT_MIN_DIMENSION, Math.floor(PREVIEW_VIEWPORT_MAX_AREA / width), ); } return { width, height }; } const resizeFromEndRail = (start: number, pointerDelta: number, available: number): number => { const startEdge = start < available ? (available + start) / 2 : start; const targetEdge = startEdge + pointerDelta; return targetEdge <= available ? targetEdge * 2 - available : targetEdge; }; const resizeFromStartRail = (start: number, pointerDelta: number, available: number): number => { if (start > available) { const distanceToFit = start - available; return pointerDelta <= distanceToFit ? start - pointerDelta : available - (pointerDelta - distanceToFit) * 2; } const targetEdge = (available - start) / 2 + pointerDelta; return targetEdge >= 0 ? available - targetEdge * 2 : available - targetEdge; }; export function resizeBrowserViewportFromRail( start: PreviewViewportSize, pointerDelta: { readonly x: number; readonly y: number }, available: PreviewViewportSize, zoomFactor = 1, direction: BrowserViewportResizeDirection = "southeast", aspectRatio?: number, ): PreviewViewportSize { const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); const startWidth = start.width * normalizedZoomFactor; const startHeight = start.height * normalizedZoomFactor; const desiredWidth = direction.includes("east") ? resizeFromEndRail(startWidth, pointerDelta.x, available.width) : direction.includes("west") ? resizeFromStartRail(startWidth, pointerDelta.x, available.width) : startWidth; const desiredHeight = direction.includes("south") ? resizeFromEndRail(startHeight, pointerDelta.y, available.height) : direction.includes("north") ? resizeFromStartRail(startHeight, pointerDelta.y, available.height) : startHeight; const widthDelta = desiredWidth - startWidth; const heightDelta = desiredHeight - startHeight; return resizeFreeformViewport( start, { x: direction.includes("west") ? -widthDelta : widthDelta, y: direction.includes("north") ? -heightDelta : heightDelta, }, normalizedZoomFactor, direction, aspectRatio, ); } export function resolveResponsiveBrowserViewportSize( container: { readonly width: number; readonly height: number }, zoomFactor = 1, ): PreviewViewportSize { const area = resolveBrowserDeviceViewportArea(container); const normalizedZoomFactor = normalizeZoomFactor(zoomFactor); return resizeFreeformViewport( { width: area.width / normalizedZoomFactor, height: area.height / normalizedZoomFactor, }, { x: 0, y: 0 }, ); }