9 changed files with 191 additions and 19 deletions
@ -1,3 +1,4 @@ |
|||||
/** 自定义 Hooks 统一导出 */ |
/** 自定义 Hooks 统一导出 */ |
||||
export { useImageDragTransfer, useWarmupImageDragUrls } from "./useImageDragTransfer"; |
export { useImageDragTransfer, useWarmupImageDragUrls } from "./useImageDragTransfer"; |
||||
export type { ImageDragStartOptions, ImageDragTransferHandle } 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