9 changed files with 191 additions and 19 deletions
@ -1,3 +1,4 @@ |
|||
/** 自定义 Hooks 统一导出 */ |
|||
export { useImageDragTransfer, useWarmupImageDragUrls } from "./useImageDragTransfer"; |
|||
export type { ImageDragStartOptions, ImageDragTransferHandle } from "./useImageDragTransfer"; |
|||
export { useProbedImageDimensions } from "./useProbedImageDimensions"; |
|||
|
|||
@ -0,0 +1,46 @@ |
|||
import { useCallback, useEffect, useState } from "react"; |
|||
import { |
|||
getCachedImageSize, |
|||
normalizeImageProbeUrl, |
|||
probeImageSize, |
|||
type ProbedImageSize, |
|||
} from "@/utils/probeImageDimensions"; |
|||
|
|||
const PROBE_BATCH_SIZE = 8; |
|||
|
|||
/** 列表图片 URL 批量探测尺寸;探测完成后触发重渲染,但不改变瀑布流分列。 */ |
|||
export function useProbedImageDimensions(urls: readonly string[]) { |
|||
const [probeVersion, setProbeVersion] = useState(0); |
|||
|
|||
useEffect(() => { |
|||
const uniqueUrls = [...new Set(urls.map(normalizeImageProbeUrl).filter(Boolean))]; |
|||
const missingUrls = uniqueUrls.filter((url) => getCachedImageSize(url) == null); |
|||
if (missingUrls.length === 0) return; |
|||
|
|||
let cancelled = false; |
|||
void (async () => { |
|||
for (let index = 0; index < missingUrls.length; index += PROBE_BATCH_SIZE) { |
|||
if (cancelled) return; |
|||
const batch = missingUrls.slice(index, index + PROBE_BATCH_SIZE); |
|||
await Promise.all(batch.map((url) => probeImageSize(url))); |
|||
if (!cancelled) { |
|||
setProbeVersion((value) => value + 1); |
|||
} |
|||
} |
|||
})(); |
|||
|
|||
return () => { |
|||
cancelled = true; |
|||
}; |
|||
}, [urls]); |
|||
|
|||
const getSize = useCallback( |
|||
(rawUrl: unknown): ProbedImageSize | undefined => { |
|||
void probeVersion; |
|||
return getCachedImageSize(rawUrl); |
|||
}, |
|||
[probeVersion], |
|||
); |
|||
|
|||
return { getSize }; |
|||
} |
|||
@ -0,0 +1,108 @@ |
|||
import { resolveRecordImageDimensions, type RecordImageDimensions } from "./masonryLayout"; |
|||
|
|||
export type ProbedImageSize = { |
|||
width: number; |
|||
height: number; |
|||
}; |
|||
|
|||
const sizeCache = new Map<string, ProbedImageSize>(); |
|||
const pendingProbes = new Map<string, Promise<ProbedImageSize | null>>(); |
|||
|
|||
export function normalizeImageProbeUrl(rawUrl: unknown): string { |
|||
return typeof rawUrl === "string" ? rawUrl.trim() : ""; |
|||
} |
|||
|
|||
/** 读取已探测过的图片尺寸(跨组件、跨渲染复用)。 */ |
|||
export function getCachedImageSize(rawUrl: unknown): ProbedImageSize | undefined { |
|||
const url = normalizeImageProbeUrl(rawUrl); |
|||
if (!url) return undefined; |
|||
return sizeCache.get(url); |
|||
} |
|||
|
|||
/** 预加载图片并读取 naturalWidth / naturalHeight。 */ |
|||
export function probeImageSize(rawUrl: unknown): Promise<ProbedImageSize | null> { |
|||
const url = normalizeImageProbeUrl(rawUrl); |
|||
if (!url) return Promise.resolve(null); |
|||
|
|||
const cached = sizeCache.get(url); |
|||
if (cached) return Promise.resolve(cached); |
|||
|
|||
const pending = pendingProbes.get(url); |
|||
if (pending) return pending; |
|||
|
|||
const task = new Promise<ProbedImageSize | null>((resolve) => { |
|||
const img = new Image(); |
|||
const finish = (size: ProbedImageSize | null) => { |
|||
pendingProbes.delete(url); |
|||
if (size) sizeCache.set(url, size); |
|||
resolve(size); |
|||
}; |
|||
const onLoad = () => { |
|||
if (img.naturalWidth > 0 && img.naturalHeight > 0) { |
|||
finish({ width: img.naturalWidth, height: img.naturalHeight }); |
|||
return; |
|||
} |
|||
finish(null); |
|||
}; |
|||
const onError = () => finish(null); |
|||
img.addEventListener("load", onLoad, { once: true }); |
|||
img.addEventListener("error", onError, { once: true }); |
|||
img.decoding = "async"; |
|||
img.src = url; |
|||
}); |
|||
|
|||
pendingProbes.set(url, task); |
|||
return task; |
|||
} |
|||
|
|||
const PROBE_BATCH_SIZE = 8; |
|||
|
|||
/** 批量探测尚未缓存的图片尺寸。 */ |
|||
export async function probeImageSizes( |
|||
rawUrls: readonly unknown[], |
|||
options?: { batchSize?: number; signal?: AbortSignal }, |
|||
): Promise<void> { |
|||
const batchSize = Math.max(1, options?.batchSize ?? PROBE_BATCH_SIZE); |
|||
const uniqueUrls = [...new Set(rawUrls.map(normalizeImageProbeUrl).filter(Boolean))].filter( |
|||
(url) => !sizeCache.has(url), |
|||
); |
|||
if (uniqueUrls.length === 0) return; |
|||
|
|||
for (let index = 0; index < uniqueUrls.length; index += batchSize) { |
|||
if (options?.signal?.aborted) return; |
|||
const batch = uniqueUrls.slice(index, index + batchSize); |
|||
await Promise.all(batch.map((url) => probeImageSize(url))); |
|||
} |
|||
} |
|||
|
|||
/** 合并接口字段与前端探测结果,优先使用接口宽高。 */ |
|||
export function resolveMediaDimensionsWithProbe( |
|||
row: Record<string, unknown>, |
|||
getProbedSize: (url: string) => ProbedImageSize | undefined, |
|||
): RecordImageDimensions { |
|||
const fromApi = resolveRecordImageDimensions(row); |
|||
if (fromApi.aspectRatio) return fromApi; |
|||
|
|||
const url = normalizeImageProbeUrl(row.url); |
|||
if (!url) return {}; |
|||
|
|||
const probed = getProbedSize(url); |
|||
if (probed == null) return {}; |
|||
|
|||
return { |
|||
width: probed.width, |
|||
height: probed.height, |
|||
aspectRatio: `${probed.width} / ${probed.height}`, |
|||
}; |
|||
} |
|||
|
|||
/** 估算卡片高度权重:接口或探测尺寸优先,否则 fallback。 */ |
|||
export function estimateAspectWeightWithProbe( |
|||
row: Record<string, unknown>, |
|||
getProbedSize: (url: string) => ProbedImageSize | undefined, |
|||
fallback = 0.75, |
|||
): number { |
|||
const dims = resolveMediaDimensionsWithProbe(row, getProbedSize); |
|||
if (dims.width != null && dims.height != null) return dims.height / dims.width; |
|||
return fallback; |
|||
} |
|||
Loading…
Reference in new issue