新增图片素材预览选择器

用 ThemedModal 网格选择器替换图片组件原生素材下拉。

支持预览占位、失效引用、清除绑定、键盘操作与只读状态。
This commit is contained in:
2026-09-03 13:49:44 +08:00
parent bc55e524ba
commit ed4e4af646
2 changed files with 170 additions and 18 deletions
@@ -0,0 +1,161 @@
import { Check, ImageIcon, X } from 'lucide-react';
import { useState } from 'react';
import { ThemedModal } from '../../../../../components/modal/ThemedModal';
import type { SpriteAsset } from '../../../../../features/ui-editor/types/SpriteAsset';
import { ComponentField } from './ComponentField';
export function ImageAssetSelector({
value,
sprites,
previewUrls,
readOnly,
onChange,
}: {
value: string | null;
sprites: Record<string, SpriteAsset>;
previewUrls: Record<string, string>;
readOnly: boolean;
onChange: (value: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const [failedPreviewIds, setFailedPreviewIds] = useState<Set<string>>(
() => new Set(),
);
const selectedSprite = value ? sprites[value] : undefined;
const selectedLabel = selectedSprite
? selectedSprite.metadata.name || value || '未绑定'
: value
? `素材不存在(${value}`
: '未绑定';
const buttonLabel = value ? '更换素材' : '选择素材';
function close() {
setOpen(false);
}
function select(nextValue: string | null) {
onChange(nextValue);
close();
}
function markPreviewFailed(id: string) {
setFailedPreviewIds((current) => {
if (current.has(id)) return current;
const next = new Set(current);
next.add(id);
return next;
});
}
return (
<>
<ComponentField label="目标素材">
<div className="mt-1 flex items-center gap-2">
<div
className={`min-w-0 flex-1 truncate rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 py-2 text-xs ${value && !selectedSprite ? 'text-red-600' : 'text-(--platform-text-strong)'}`}
title={selectedLabel}
>
{selectedLabel}
</div>
<button
type="button"
className="h-9 shrink-0 rounded-lg border border-orange-200 bg-orange-50 px-3 text-xs font-semibold text-orange-800 disabled:cursor-not-allowed disabled:opacity-40"
disabled={readOnly}
onClick={() => setOpen(true)}
>
{buttonLabel}
</button>
</div>
</ComponentField>
<ThemedModal
open={open}
onClose={close}
ariaLabel="选择图片素材"
panelClassName="flex max-h-[80vh] w-[720px] max-w-[calc(100vw-2rem)] flex-col rounded-2xl p-5"
>
<div className="flex items-center justify-between gap-3">
<h2 className="m-0 text-base font-semibold"></h2>
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-(--platform-text-soft) hover:bg-black/5"
aria-label="关闭素材选择器"
onClick={close}
>
<X size={16} aria-hidden="true" />
</button>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-3">
<button
type="button"
className={`flex min-h-36 flex-col items-center justify-center rounded-xl border border-dashed p-3 text-xs ${value === null ? 'border-orange-400 bg-orange-50 text-orange-800' : 'border-(--platform-subpanel-border) bg-white/45 text-(--platform-text-soft)'}`}
aria-pressed={value === null}
onClick={() => select(null)}
>
<X size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 text-[10px]"></span>
</button>
{value && !selectedSprite ? (
<div className="flex min-h-36 flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-3 text-center text-xs text-red-700">
<ImageIcon size={22} aria-hidden="true" />
<span className="mt-2 font-semibold"></span>
<span className="mt-1 break-all text-[10px]">{value}</span>
</div>
) : null}
{Object.entries(sprites).map(([id, sprite]) => {
const previewUrl = previewUrls[id];
const hasPreview =
Boolean(previewUrl) && !failedPreviewIds.has(id);
const selected = value === id;
return (
<button
key={id}
type="button"
className={`relative overflow-hidden rounded-xl border p-2 text-left transition ${selected ? 'border-orange-400 bg-orange-50 ring-2 ring-orange-200' : 'border-(--platform-subpanel-border) bg-white/45 hover:border-orange-200'}`}
aria-pressed={selected}
onClick={() => select(id)}
>
<div className="grid aspect-[4/3] place-items-center overflow-hidden rounded-lg bg-[linear-gradient(45deg,#eee_25%,transparent_25%),linear-gradient(-45deg,#eee_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#eee_75%),linear-gradient(-45deg,transparent_75%,#eee_75%)] bg-[length:14px_14px]">
{hasPreview ? (
<img
src={previewUrl}
alt={sprite.metadata.name || id}
className="size-full object-contain"
onError={() => markPreviewFailed(id)}
/>
) : (
<ImageIcon
size={24}
className="text-(--platform-text-soft)"
aria-hidden="true"
/>
)}
</div>
<span className="mt-2 block truncate text-xs font-semibold text-(--platform-text-strong)">
{sprite.metadata.name || id}
</span>
{selected ? (
<span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-orange-500 text-white">
<Check size={14} aria-hidden="true" />
</span>
) : null}
</button>
);
})}
</div>
{Object.keys(sprites).length === 0 ? (
<p className="m-0 py-10 text-center text-xs text-(--platform-text-soft)">
</p>
) : null}
</div>
</ThemedModal>
</>
);
}
@@ -4,10 +4,12 @@ import type { ImageType } from '../../../../../features/ui-editor/types/ImageTyp
import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState';
import type { ImageEditorProps } from './componentEditorTypes';
import { ComponentNumberInput, ComponentSelect } from './ComponentField';
import { ImageAssetSelector } from './ImageAssetSelector';
export function ImagePanel({
component,
sprites,
previewUrls,
readOnly,
onChange,
}: ImageEditorProps) {
@@ -17,24 +19,13 @@ export function ImagePanel({
return (
<div className="space-y-3">
<ComponentSelect
label="目标素材"
value={component.target_graphic ?? ''}
disabled={readOnly}
onChange={(event) =>
update({
...component,
target_graphic: event.target.value || null,
})
}
>
<option value=""></option>
{Object.entries(sprites).map(([id, sprite]) => (
<option key={id} value={id}>
{sprite.metadata.name || id}
</option>
))}
</ComponentSelect>
<ImageAssetSelector
value={component.target_graphic}
sprites={sprites}
previewUrls={previewUrls}
readOnly={readOnly}
onChange={(target_graphic) => update({ ...component, target_graphic })}
/>
<ComponentSelect
label="图片类型"