485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
import {
|
|
History,
|
|
ImagePlus,
|
|
Loader2,
|
|
Sparkles,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { type ReactNode, useEffect, useState } from 'react';
|
|
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
|
|
export type CreativeImageInputReferenceImage = {
|
|
id: string;
|
|
label: string;
|
|
imageSrc: string;
|
|
};
|
|
|
|
export type CreativeImageInputPanelLabels = {
|
|
imageField: string;
|
|
uploadImage: string;
|
|
replaceImage: string;
|
|
emptyImageHint: string;
|
|
removeImage: string;
|
|
removeImageConfirmTitle: string;
|
|
removeImageConfirmBody: string;
|
|
promptReferenceUpload: string;
|
|
promptReferencePreviewAlt: string;
|
|
closePromptReferencePreview: string;
|
|
history?: string;
|
|
};
|
|
|
|
export type CreativeImageInputPanelProps = {
|
|
className?: string;
|
|
disabled?: boolean;
|
|
isSubmitting?: boolean;
|
|
mainImageMode?: 'edit' | 'preview';
|
|
canRemoveMainImage?: boolean;
|
|
canToggleAiRedraw?: boolean;
|
|
uploadedImageSrc: string;
|
|
uploadedImageAlt: string;
|
|
uploadedImageRefreshKey?: string | number | null;
|
|
mainImageMeta?: ReactNode;
|
|
mainImageInputId: string;
|
|
mainImageAccept?: string;
|
|
promptTextareaId: string;
|
|
prompt: string;
|
|
promptLabel: string;
|
|
promptAriaLabel?: string;
|
|
promptRows?: number;
|
|
aiRedraw: boolean;
|
|
promptReferenceImages: CreativeImageInputReferenceImage[];
|
|
promptReferenceLimit?: number;
|
|
imageModelPicker?: ReactNode;
|
|
error?: string | null;
|
|
inputError?: string | null;
|
|
submitLabel: string;
|
|
submitCostLabel?: string | null;
|
|
submitDisabled: boolean;
|
|
labels: CreativeImageInputPanelLabels;
|
|
onMainImageFileSelect: (file: File) => void;
|
|
onMainImageRemove: () => void;
|
|
onAiRedrawChange: (enabled: boolean) => void;
|
|
onPromptChange: (value: string) => void;
|
|
onPromptReferenceFilesSelect?: (files: File[]) => void;
|
|
onPromptReferenceRemove?: (referenceId: string) => void;
|
|
onHistoryClick?: () => void;
|
|
onSubmit: () => void;
|
|
};
|
|
|
|
const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
|
|
const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
|
|
|
|
export function CreativeImageInputPanel({
|
|
className = '',
|
|
disabled = false,
|
|
isSubmitting = false,
|
|
mainImageMode = 'edit',
|
|
canRemoveMainImage = true,
|
|
canToggleAiRedraw = true,
|
|
uploadedImageSrc,
|
|
uploadedImageAlt,
|
|
uploadedImageRefreshKey = null,
|
|
mainImageMeta = null,
|
|
mainImageInputId,
|
|
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
|
promptTextareaId,
|
|
prompt,
|
|
promptLabel,
|
|
promptAriaLabel,
|
|
promptRows = 2,
|
|
aiRedraw,
|
|
promptReferenceImages,
|
|
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
|
imageModelPicker = null,
|
|
error = null,
|
|
inputError = null,
|
|
submitLabel,
|
|
submitCostLabel = null,
|
|
submitDisabled,
|
|
labels,
|
|
onMainImageFileSelect,
|
|
onMainImageRemove,
|
|
onAiRedrawChange,
|
|
onPromptChange,
|
|
onPromptReferenceFilesSelect,
|
|
onPromptReferenceRemove,
|
|
onHistoryClick,
|
|
onSubmit,
|
|
}: CreativeImageInputPanelProps) {
|
|
const [previewReferenceImage, setPreviewReferenceImage] =
|
|
useState<CreativeImageInputReferenceImage | null>(null);
|
|
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
|
useState(false);
|
|
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
|
const promptReferenceUploadDisabled =
|
|
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
|
const canEditMainImage = mainImageMode === 'edit';
|
|
|
|
useEffect(() => {
|
|
if (uploadedImageSrc) {
|
|
setPreviewReferenceImage(null);
|
|
}
|
|
}, [uploadedImageSrc]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
previewReferenceImage &&
|
|
!promptReferenceImages.some(
|
|
(reference) => reference.id === previewReferenceImage.id,
|
|
)
|
|
) {
|
|
setPreviewReferenceImage(null);
|
|
}
|
|
}, [previewReferenceImage, promptReferenceImages]);
|
|
|
|
return (
|
|
<div
|
|
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
|
|
>
|
|
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
|
|
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
|
|
<div
|
|
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
|
showPrompt
|
|
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
|
: 'flex flex-col lg:grid lg:grid-cols-1'
|
|
}`}
|
|
>
|
|
<div
|
|
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
|
|
disabled ? 'opacity-55' : ''
|
|
}`}
|
|
>
|
|
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
|
|
{labels.imageField}
|
|
</div>
|
|
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
|
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
|
{canEditMainImage ? (
|
|
<>
|
|
<input
|
|
id={mainImageInputId}
|
|
type="file"
|
|
accept={mainImageAccept}
|
|
disabled={disabled}
|
|
aria-label={labels.uploadImage}
|
|
onChange={(event) => {
|
|
const file = event.currentTarget.files?.[0] ?? null;
|
|
event.currentTarget.value = '';
|
|
if (file) {
|
|
onMainImageFileSelect(file);
|
|
}
|
|
}}
|
|
className="sr-only"
|
|
/>
|
|
<label
|
|
htmlFor={mainImageInputId}
|
|
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
|
title={
|
|
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
|
}
|
|
>
|
|
<span className="sr-only">
|
|
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
|
</span>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
{uploadedImageSrc ? (
|
|
<ResolvedAssetImage
|
|
src={uploadedImageSrc}
|
|
refreshKey={uploadedImageRefreshKey}
|
|
alt={uploadedImageAlt}
|
|
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
|
|
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
|
|
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
|
|
</span>
|
|
</span>
|
|
)}
|
|
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
|
{canEditMainImage && onHistoryClick ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={onHistoryClick}
|
|
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] ${
|
|
disabled ? 'cursor-not-allowed opacity-55' : ''
|
|
}`}
|
|
aria-label={labels.history ?? '选择历史图片'}
|
|
title={labels.history ?? '选择历史图片'}
|
|
>
|
|
<History className="h-3.5 w-3.5" />
|
|
<span>历史</span>
|
|
</button>
|
|
) : null}
|
|
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
|
|
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
|
<span>AI重绘</span>
|
|
<input
|
|
role="switch"
|
|
type="checkbox"
|
|
checked={aiRedraw}
|
|
disabled={disabled}
|
|
onChange={(event) => onAiRedrawChange(event.target.checked)}
|
|
className="sr-only"
|
|
aria-label="AI重绘"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`relative h-5 w-9 rounded-full transition ${
|
|
aiRedraw ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
|
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
|
|
}`}
|
|
/>
|
|
</span>
|
|
</label>
|
|
) : null}
|
|
{canEditMainImage && uploadedImageSrc && canRemoveMainImage ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
|
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
|
|
aria-label={labels.removeImage}
|
|
title={labels.removeImage}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
) : canEditMainImage && !uploadedImageSrc ? (
|
|
<label
|
|
htmlFor={mainImageInputId}
|
|
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
|
disabled
|
|
? 'cursor-not-allowed opacity-55'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
>
|
|
{labels.emptyImageHint}
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
|
</div>
|
|
|
|
{showPrompt ? (
|
|
<div className="block shrink-0 lg:min-h-0">
|
|
<label
|
|
htmlFor={promptTextareaId}
|
|
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
|
|
>
|
|
{promptLabel}
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
id={promptTextareaId}
|
|
value={prompt}
|
|
disabled={disabled}
|
|
rows={promptRows}
|
|
placeholder=""
|
|
onChange={(event) => onPromptChange(event.target.value)}
|
|
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
|
|
aria-label={promptAriaLabel ?? promptLabel}
|
|
/>
|
|
{imageModelPicker}
|
|
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
|
|
<label
|
|
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${
|
|
promptReferenceUploadDisabled
|
|
? 'cursor-not-allowed opacity-55'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
aria-label={labels.promptReferenceUpload}
|
|
title={labels.promptReferenceUpload}
|
|
>
|
|
<ImagePlus className="h-4 w-4" />
|
|
<input
|
|
type="file"
|
|
accept={mainImageAccept}
|
|
multiple
|
|
aria-label={labels.promptReferenceUpload}
|
|
disabled={promptReferenceUploadDisabled}
|
|
onChange={(event) => {
|
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
event.currentTarget.value = '';
|
|
if (files.length > 0) {
|
|
onPromptReferenceFilesSelect(files);
|
|
}
|
|
}}
|
|
className="sr-only"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{promptReferenceImages.map((reference) => (
|
|
<div
|
|
key={reference.id}
|
|
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm"
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setPreviewReferenceImage(reference)}
|
|
className="block h-full w-full"
|
|
aria-label={`预览参考图 ${reference.label}`}
|
|
title={reference.label}
|
|
>
|
|
<ResolvedAssetImage
|
|
src={reference.imageSrc}
|
|
alt=""
|
|
aria-hidden="true"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</button>
|
|
{onPromptReferenceRemove ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onPromptReferenceRemove(reference.id)}
|
|
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-55"
|
|
aria-label={`移除参考图 ${reference.label}`}
|
|
title="移除参考图"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 shrink-0 space-y-3">
|
|
{inputError ? (
|
|
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
|
{inputError}
|
|
</div>
|
|
) : null}
|
|
{error ? (
|
|
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
|
<button
|
|
type="button"
|
|
disabled={disabled || submitDisabled}
|
|
onClick={onSubmit}
|
|
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
|
|
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
|
|
}`}
|
|
>
|
|
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
|
|
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
<Sparkles className="h-4 w-4" />
|
|
<span>{submitLabel}</span>
|
|
{submitCostLabel ? (
|
|
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
|
{submitCostLabel}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{previewReferenceImage ? (
|
|
<div
|
|
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
|
|
onClick={() => setPreviewReferenceImage(null)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="creative-image-reference-preview-title"
|
|
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
|
<div
|
|
id="creative-image-reference-preview-title"
|
|
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
|
>
|
|
{previewReferenceImage.label}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label={labels.closePromptReferencePreview}
|
|
onClick={() => setPreviewReferenceImage(null)}
|
|
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
|
<ResolvedAssetImage
|
|
src={previewReferenceImage.imageSrc}
|
|
alt={labels.promptReferencePreviewAlt}
|
|
className="h-full max-h-[72vh] w-full object-contain"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{isRemoveImageConfirmOpen ? (
|
|
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="creative-image-remove-confirm-title"
|
|
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
|
>
|
|
<div
|
|
id="creative-image-remove-confirm-title"
|
|
className="text-base font-black text-[var(--platform-text-strong)]"
|
|
>
|
|
{labels.removeImageConfirmTitle}
|
|
</div>
|
|
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
|
{labels.removeImageConfirmBody}
|
|
</div>
|
|
<div className="mt-5 grid grid-cols-2 gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsRemoveImageConfirmOpen(false)}
|
|
className="platform-button platform-button--secondary justify-center"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onMainImageRemove();
|
|
setIsRemoveImageConfirmOpen(false);
|
|
}}
|
|
className="platform-button platform-button--primary justify-center"
|
|
>
|
|
移除
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CreativeImageInputPanel;
|