diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9fe35d1..1b7873a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ /** 自定义 Hooks 统一导出 */ export { useImageDragTransfer, useWarmupImageDragUrls } from "./useImageDragTransfer"; export type { ImageDragStartOptions, ImageDragTransferHandle } from "./useImageDragTransfer"; +export { useProbedImageDimensions } from "./useProbedImageDimensions"; diff --git a/src/hooks/useProbedImageDimensions.ts b/src/hooks/useProbedImageDimensions.ts new file mode 100644 index 0000000..ddf4c47 --- /dev/null +++ b/src/hooks/useProbedImageDimensions.ts @@ -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 }; +} diff --git a/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.scss b/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.scss index 2c70dc7..9af5ac0 100644 --- a/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.scss +++ b/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.scss @@ -53,8 +53,8 @@ line-height: 0; } -.backgroundLibrary__cardMedia_placeholder { - aspect-ratio: 4 / 3; +.backgroundLibrary__cardMedia_pending { + min-height: 24px; } .backgroundLibrary__cardImage { diff --git a/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.tsx b/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.tsx index 6c76f37..7f78de8 100644 --- a/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.tsx +++ b/src/pages/Create/panels/InspirationLibraryPanel/BackgroundAssets/BackgroundAssets.tsx @@ -7,12 +7,13 @@ import type { AssetLibraryModel } from "../CharacterAssetsPanel/CharacterAssetsP import "./BackgroundAssets.scss"; import { useGlobalPromptImageStore } from "@/store/useMakeSameStore"; import { useImageDragTransfer, useWarmupImageDragUrls } from "@/hooks/useImageDragTransfer"; +import { useProbedImageDimensions } from "@/hooks/useProbedImageDimensions"; import { appendToShortestColumns, distributeToShortestColumns, - estimateRecordAspectWeight, + estimateAspectWeightWithProbe, resolveColumnCountFromWidth, - resolveRecordImageDimensions, + resolveMediaDimensionsWithProbe, } from "@/utils"; const gridRoot = "backgroundLibrary"; @@ -29,8 +30,8 @@ type MasonryLayoutCache = { columns: unknown[][]; }; -function itemWeight(row: unknown): number { - return estimateRecordAspectWeight(row as Record); +function itemWeight(row: unknown, getProbedSize: (url: string) => { width: number; height: number } | undefined): number { + return estimateAspectWeightWithProbe(row as Record, getProbedSize); } function responseDataList(res: unknown): unknown[] { @@ -204,6 +205,9 @@ export function BackgroundAssetsPanel({ [list], ); useWarmupImageDragUrls(warmupPreviewUrls, previewUrls); + const { getSize: getProbedSize } = useProbedImageDimensions(previewUrls); + const getProbedSizeRef = useRef(getProbedSize); + getProbedSizeRef.current = getProbedSize; loadingStateRef.current = { loading, loadingMore }; @@ -238,18 +242,19 @@ export function BackgroundAssetsPanel({ }, [loading, list.length]); const masonryColumns = useMemo(() => { + const getWeight = (row: unknown) => itemWeight(row, (url) => getProbedSizeRef.current(url)); const cache = masonryCacheRef.current; const canIncrementalAppend = cache != null && cache.columnCount === columnCount && list.length > cache.listLength; if (canIncrementalAppend) { const newItems = list.slice(cache.listLength); - const columns = appendToShortestColumns(cache.columns, newItems, itemWeight); + const columns = appendToShortestColumns(cache.columns, newItems, getWeight); masonryCacheRef.current = { columnCount, listLength: list.length, columns }; return columns; } - const columns = distributeToShortestColumns(list, columnCount, itemWeight); + const columns = distributeToShortestColumns(list, columnCount, getWeight); masonryCacheRef.current = { columnCount, listLength: list.length, columns }; return columns; }, [columnCount, list]); @@ -288,7 +293,7 @@ export function BackgroundAssetsPanel({
{column.map((row) => { const item = row as Record; - const imageDims = resolveRecordImageDimensions(item); + const imageDims = resolveMediaDimensionsWithProbe(item, getProbedSize); const mediaStyle: CSSProperties | undefined = imageDims.aspectRatio ? { aspectRatio: imageDims.aspectRatio } : undefined; @@ -302,7 +307,7 @@ export function BackgroundAssetsPanel({
`); - const domImage = pickLoadedImageFromDragTarget(evt); - if (domImage && trySetDragImageFromElement(evt.dataTransfer, domImage)) { - return true; - } - if (preferDomDragImage) { + const domImage = pickLoadedImageFromDragTarget(evt); + if (domImage && trySetDragImageFromElement(evt.dataTransfer, domImage)) { + return true; + } return true; } @@ -267,6 +266,11 @@ export function createImageDragTransferManager(options?: CreateImageDragTransfer return true; } + const domImage = pickLoadedImageFromDragTarget(evt); + if (domImage && trySetDragImageFromElement(evt.dataTransfer, domImage)) { + return true; + } + return true; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index f24dece..590f7da 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,6 +11,15 @@ export { resolveRecordImageDimensions, } from "./masonryLayout"; export type { RecordImageDimensions } from "./masonryLayout"; +export { + estimateAspectWeightWithProbe, + getCachedImageSize, + normalizeImageProbeUrl, + probeImageSize, + probeImageSizes, + resolveMediaDimensionsWithProbe, +} from "./probeImageDimensions"; +export type { ProbedImageSize } from "./probeImageDimensions"; /** 安全解析 JSON,失败返回 null */ export function safeParse(str: string, fallback: T): T { diff --git a/src/utils/probeImageDimensions.ts b/src/utils/probeImageDimensions.ts new file mode 100644 index 0000000..12e99e1 --- /dev/null +++ b/src/utils/probeImageDimensions.ts @@ -0,0 +1,108 @@ +import { resolveRecordImageDimensions, type RecordImageDimensions } from "./masonryLayout"; + +export type ProbedImageSize = { + width: number; + height: number; +}; + +const sizeCache = new Map(); +const pendingProbes = new Map>(); + +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 { + 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((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 { + 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, + 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, + 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; +} diff --git a/vite.config.mts b/vite.config.mts index 02683f0..6fa40ac 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -39,9 +39,9 @@ export default defineConfig(({ mode }) => { proxy: isDev ? { "/api_client": { - target: "http://192.168.77.111:8080/", + // target: "http://192.168.77.111:8080/", // target: "https://wwwtest.popi.art/", - // target: "https://www.popi.art/", + target: "https://www.popi.art/", secure: false, changeOrigin: true, },