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