286 lines · 9.7 KB · TypeScript
RawDownload
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 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<PreviewViewportSetting, { readonly _tag: "fill" }>,  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 },  );}