selector.ts raw

   1  /**
   2   * Responsive Image Variants - Variant Selector
   3   *
   4   * Implements the "next-larger" selection algorithm:
   5   * Pick the smallest variant >= target width for minimal client-side downscaling.
   6   */
   7  
   8  import { UploadedVariant } from './event'
   9  
  10  /**
  11   * Select the best variant for a given viewport width
  12   *
  13   * Selection rule: Pick the smallest variant >= target width.
  14   * This ensures the client only needs to downscale slightly (or not at all),
  15   * rather than upscaling which would cause blur.
  16   *
  17   * @param variants - Available variants
  18   * @param targetWidth - Desired display width in CSS pixels
  19   * @param pixelRatio - Device pixel ratio (default 1)
  20   * @returns The most appropriate variant, or undefined if none available
  21   */
  22  export function selectVariantForViewport(
  23    variants: UploadedVariant[],
  24    targetWidth: number,
  25    pixelRatio: number = 1
  26  ): UploadedVariant | undefined {
  27    if (variants.length === 0) return undefined
  28  
  29    const effectiveWidth = targetWidth * pixelRatio
  30  
  31    // Sort by width ascending
  32    const sorted = [...variants].sort((a, b) => a.width - b.width)
  33  
  34    // Find smallest variant >= target width (next-larger selection)
  35    for (const variant of sorted) {
  36      if (variant.width >= effectiveWidth) {
  37        return variant
  38      }
  39    }
  40  
  41    // If none large enough, return largest available
  42    return sorted[sorted.length - 1]
  43  }
  44  
  45  /**
  46   * Calculate the display dimensions for a variant at a target width
  47   *
  48   * Given a variant and a target display width, calculates the height
  49   * maintaining the original aspect ratio. Useful for placeholder sizing.
  50   *
  51   * @param variant - The variant to calculate dimensions for
  52   * @param displayWidth - The CSS pixel width it will be displayed at
  53   * @returns Object with width and height in CSS pixels
  54   */
  55  export function calculateDisplayDimensions(
  56    variant: UploadedVariant,
  57    displayWidth: number
  58  ): { width: number; height: number } {
  59    const aspectRatio = variant.height / variant.width
  60    return {
  61      width: displayWidth,
  62      height: Math.round(displayWidth * aspectRatio)
  63    }
  64  }
  65  
  66  /**
  67   * Pre-calculate placeholder dimensions from a binding event
  68   *
  69   * When loading an image, use this to determine the display dimensions
  70   * before the image loads, preventing layout shift.
  71   *
  72   * @param variants - All variants from the binding event
  73   * @param containerWidth - The container width in CSS pixels
  74   * @param pixelRatio - Device pixel ratio (default 1)
  75   * @returns Dimensions for the selected variant, or undefined if no variants
  76   */
  77  export function getPlaceholderDimensions(
  78    variants: UploadedVariant[],
  79    containerWidth: number,
  80    pixelRatio: number = 1
  81  ): { width: number; height: number; selectedVariant: UploadedVariant } | undefined {
  82    const selected = selectVariantForViewport(variants, containerWidth, pixelRatio)
  83    if (!selected) return undefined
  84  
  85    const dims = calculateDisplayDimensions(selected, containerWidth)
  86    return {
  87      ...dims,
  88      selectedVariant: selected
  89    }
  90  }
  91  
  92  /**
  93   * Check if a string is a valid 64-character hex hash
  94   */
  95  export function isValidBlobHash(str: string): boolean {
  96    return /^[a-f0-9]{64}$/i.test(str)
  97  }
  98  
  99  /**
 100   * Extract a blob hash from a Blossom URL
 101   *
 102   * Handles formats like:
 103   * - https://server.com/abc123...def456
 104   * - https://server.com/abc123...def456.jpg
 105   *
 106   * @param url - A Blossom blob URL
 107   * @returns The 64-character hash, or null if not found
 108   */
 109  export function extractHashFromUrl(url: string): string | null {
 110    try {
 111      const parsed = new URL(url)
 112      const pathname = parsed.pathname
 113  
 114      // Remove leading slash and any file extension
 115      const filename = pathname.split('/').pop() || ''
 116      const hash = filename.replace(/\.[^.]+$/, '')
 117  
 118      return isValidBlobHash(hash) ? hash.toLowerCase() : null
 119    } catch {
 120      return null
 121    }
 122  }
 123