f7126f9556
新增 HostBridge file.captureImage 契约与 H5 facade Expo 移动壳通过系统相机拍摄图片并复用图片导入校验 通用图片输入面板按宿主能力展示拍摄入口并转换为现有 File 回调 补充移动壳、HostBridge、图片面板测试和原生壳文档
762 lines
30 KiB
TypeScript
762 lines
30 KiB
TypeScript
import {
|
|
Camera,
|
|
History,
|
|
ImagePlus,
|
|
Loader2,
|
|
Sparkles,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
canUseNativeHostCapability,
|
|
captureHostImageFile,
|
|
type HostFileImportImageResult,
|
|
importHostImageFile,
|
|
subscribeHostImageDrop,
|
|
} from '../../services/host-bridge/hostBridge';
|
|
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
import { PlatformActionButton } from './PlatformActionButton';
|
|
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
|
import { PlatformIconBadge } from './PlatformIconBadge';
|
|
import { PlatformIconButton } from './PlatformIconButton';
|
|
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
|
|
import { PlatformPillBadge } from './PlatformPillBadge';
|
|
import { PlatformPillSwitch } from './PlatformPillSwitch';
|
|
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
|
import { PlatformTextField } from './PlatformTextField';
|
|
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
|
|
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
|
|
|
export type CreativeImageInputReferenceImage = {
|
|
id: string;
|
|
label: string;
|
|
imageSrc: string;
|
|
assetObjectId?: string | null;
|
|
};
|
|
|
|
export type CreativeImageInputPanelLabels = {
|
|
imageField: string;
|
|
uploadImage: string;
|
|
replaceImage: string;
|
|
emptyImageHint: string;
|
|
removeImage: string;
|
|
removeImageConfirmTitle: string;
|
|
removeImageConfirmBody: string;
|
|
promptReferenceUpload: string;
|
|
promptReferencePreviewAlt: string;
|
|
closePromptReferencePreview: string;
|
|
previewMainImage?: string;
|
|
closeMainImagePreview?: string;
|
|
history?: string;
|
|
};
|
|
|
|
export type CreativeImageInputPanelProps = {
|
|
className?: string;
|
|
fillHeight?: boolean;
|
|
disabled?: boolean;
|
|
isSubmitting?: boolean;
|
|
mainImageMode?: 'edit' | 'preview';
|
|
mainImageClickMode?: 'upload' | 'preview';
|
|
canUploadMainImage?: boolean;
|
|
canUseImageHistory?: boolean;
|
|
canRemoveMainImage?: boolean;
|
|
canToggleAiRedraw?: boolean;
|
|
canUploadPromptReferences?: boolean;
|
|
uploadedImageSrc: string;
|
|
uploadedImageAlt: string;
|
|
uploadedImageRefreshKey?: string | number | null;
|
|
mainImagePreviewZIndexClassName?: string;
|
|
mainImageMeta?: ReactNode;
|
|
mainImageInputId: string;
|
|
mainImageAccept?: string;
|
|
promptTextareaId: string;
|
|
prompt: string;
|
|
promptLabel: string;
|
|
promptAriaLabel?: string;
|
|
promptRows?: number;
|
|
aiRedraw: boolean;
|
|
promptReferenceImages: CreativeImageInputReferenceImage[];
|
|
promptReferenceLimit?: number;
|
|
imageLimitHint?: string | null;
|
|
imageModelPicker?: ReactNode;
|
|
error?: string | null;
|
|
inputError?: string | null;
|
|
showSubmitButton?: boolean;
|
|
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;
|
|
|
|
function hostImageImportResultToFile(result: HostFileImportImageResult) {
|
|
return puzzleReferenceImageDataUrlToFile(
|
|
`data:${result.mimeType};base64,${result.base64Data}`,
|
|
result.fileName,
|
|
);
|
|
}
|
|
|
|
export function CreativeImageInputPanel({
|
|
className = '',
|
|
fillHeight = true,
|
|
disabled = false,
|
|
isSubmitting = false,
|
|
mainImageMode = 'edit',
|
|
mainImageClickMode = 'preview',
|
|
canUploadMainImage = true,
|
|
canUseImageHistory = true,
|
|
canRemoveMainImage = true,
|
|
canToggleAiRedraw = true,
|
|
canUploadPromptReferences,
|
|
uploadedImageSrc,
|
|
uploadedImageAlt,
|
|
uploadedImageRefreshKey = null,
|
|
mainImagePreviewZIndexClassName = 'z-[82]',
|
|
mainImageMeta = null,
|
|
mainImageInputId,
|
|
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
|
promptTextareaId,
|
|
prompt,
|
|
promptLabel,
|
|
promptAriaLabel,
|
|
promptRows = 2,
|
|
aiRedraw,
|
|
promptReferenceImages,
|
|
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
|
|
imageLimitHint = null,
|
|
imageModelPicker = null,
|
|
error = null,
|
|
inputError = null,
|
|
showSubmitButton = true,
|
|
submitLabel,
|
|
submitCostLabel = null,
|
|
submitDisabled,
|
|
labels,
|
|
onMainImageFileSelect,
|
|
onMainImageRemove,
|
|
onAiRedrawChange,
|
|
onPromptChange,
|
|
onPromptReferenceFilesSelect,
|
|
onPromptReferenceRemove,
|
|
onHistoryClick,
|
|
onSubmit,
|
|
}: CreativeImageInputPanelProps) {
|
|
const mainImageCardRef = useRef<HTMLDivElement | null>(null);
|
|
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
|
const promptReferenceInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [previewReferenceImage, setPreviewReferenceImage] =
|
|
useState<CreativeImageInputReferenceImage | null>(null);
|
|
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
|
|
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
|
useState(false);
|
|
const showPrompt =
|
|
mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
|
const shouldShowPromptReferences =
|
|
canUploadPromptReferences ?? !uploadedImageSrc;
|
|
const promptReferenceUploadDisabled =
|
|
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
|
const canEditMainImage = mainImageMode === 'edit';
|
|
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
|
|
const shouldShowHistoryButton =
|
|
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
|
|
const shouldPreviewMainImage =
|
|
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
|
|
const shouldShowMainImageUploadButton =
|
|
isMainImageUploadEnabled && shouldPreviewMainImage;
|
|
const canImportHostImage = canUseNativeHostCapability('file.importImage');
|
|
const canCaptureHostImage = canUseNativeHostCapability('file.captureImage');
|
|
const canReceiveHostImageDrop =
|
|
canUseNativeHostCapability('file.imageDropped');
|
|
const promptReferenceInputId = `${mainImageInputId}-prompt-reference`;
|
|
|
|
useEffect(() => {
|
|
if (uploadedImageSrc) {
|
|
setPreviewReferenceImage(null);
|
|
} else {
|
|
setIsMainImagePreviewOpen(false);
|
|
}
|
|
}, [uploadedImageSrc]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
previewReferenceImage &&
|
|
!promptReferenceImages.some(
|
|
(reference) => reference.id === previewReferenceImage.id,
|
|
)
|
|
) {
|
|
setPreviewReferenceImage(null);
|
|
}
|
|
}, [previewReferenceImage, promptReferenceImages]);
|
|
|
|
useEffect(() => {
|
|
if (!canReceiveHostImageDrop || disabled || !isMainImageUploadEnabled) {
|
|
return undefined;
|
|
}
|
|
|
|
return subscribeHostImageDrop((payload) => {
|
|
const card = mainImageCardRef.current;
|
|
const position = payload.position;
|
|
if (!card || !position) {
|
|
return;
|
|
}
|
|
|
|
const bounds = card.getBoundingClientRect();
|
|
const isInsideCard =
|
|
position.x >= bounds.left &&
|
|
position.x <= bounds.right &&
|
|
position.y >= bounds.top &&
|
|
position.y <= bounds.bottom;
|
|
if (!isInsideCard) {
|
|
return;
|
|
}
|
|
|
|
const topElement =
|
|
typeof document.elementFromPoint === 'function'
|
|
? document.elementFromPoint(position.x, position.y)
|
|
: null;
|
|
// 中文注释:桌面拖入是窗口级事件;只让坐标命中的最上层主图槽位消费,避免多个创作面板同时接收同一张图。
|
|
if (topElement && !card.contains(topElement)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
onMainImageFileSelect(hostImageImportResultToFile(payload));
|
|
} catch {
|
|
// 中文注释:宿主已校验图片类型和体积;这里仅兜住浏览器 File 构造异常,保持当前表单状态。
|
|
}
|
|
});
|
|
}, [
|
|
canReceiveHostImageDrop,
|
|
disabled,
|
|
isMainImageUploadEnabled,
|
|
onMainImageFileSelect,
|
|
]);
|
|
|
|
const bodyClassName = fillHeight
|
|
? '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'
|
|
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
|
|
const sectionClassName = fillHeight
|
|
? 'creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible'
|
|
: 'creative-image-input-panel__section puzzle-creation-form-section flex flex-none flex-col overflow-visible';
|
|
const gridSizeClassName = fillHeight ? 'min-h-0 flex-1' : 'flex-none';
|
|
const imageFieldClassName = fillHeight
|
|
? 'creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col'
|
|
: 'creative-image-input-panel__image-field puzzle-image-field flex min-w-0 flex-none flex-col';
|
|
const imageFrameClassName = fillHeight
|
|
? 'creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center'
|
|
: 'creative-image-input-panel__image-frame puzzle-image-card-frame flex flex-none items-center justify-center';
|
|
const imageCardClassName = fillHeight
|
|
? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] 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 sm:min-h-[18rem] lg:h-auto lg:w-full'
|
|
: 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] 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 sm:min-h-[18rem]';
|
|
|
|
const importHostImageAsFile = async () => {
|
|
const result = await importHostImageFile();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
return hostImageImportResultToFile(result);
|
|
};
|
|
|
|
const captureHostImageAsFile = async () => {
|
|
const result = await captureHostImageFile();
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
return hostImageImportResultToFile(result);
|
|
};
|
|
|
|
const handleMainImageUploadClick = () => {
|
|
if (disabled || !isMainImageUploadEnabled) {
|
|
return;
|
|
}
|
|
if (!canImportHostImage) {
|
|
mainImageInputRef.current?.click();
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const file = await importHostImageAsFile();
|
|
if (file) {
|
|
onMainImageFileSelect(file);
|
|
}
|
|
} catch {
|
|
// 中文注释:宿主导入失败时不再弹浏览器文件框,避免权限失败后重复打扰用户。
|
|
}
|
|
})();
|
|
};
|
|
|
|
const handleMainImageCaptureClick = () => {
|
|
if (disabled || !isMainImageUploadEnabled || !canCaptureHostImage) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const file = await captureHostImageAsFile();
|
|
if (file) {
|
|
onMainImageFileSelect(file);
|
|
}
|
|
} catch {
|
|
// 中文注释:相机拍摄失败或用户取消时保持当前表单状态,不回落到相册或文件框。
|
|
}
|
|
})();
|
|
};
|
|
|
|
const handlePromptReferenceUploadClick = () => {
|
|
if (
|
|
promptReferenceUploadDisabled ||
|
|
!shouldShowPromptReferences ||
|
|
!onPromptReferenceFilesSelect
|
|
) {
|
|
return;
|
|
}
|
|
if (!canImportHostImage) {
|
|
promptReferenceInputRef.current?.click();
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const file = await importHostImageAsFile();
|
|
if (file) {
|
|
onPromptReferenceFilesSelect([file]);
|
|
}
|
|
} catch {
|
|
// 中文注释:宿主导入失败时保持当前表单状态,由外层错误通道继续承接后续重试。
|
|
}
|
|
})();
|
|
};
|
|
|
|
const handlePromptReferenceCaptureClick = () => {
|
|
if (
|
|
promptReferenceUploadDisabled ||
|
|
!shouldShowPromptReferences ||
|
|
!onPromptReferenceFilesSelect ||
|
|
!canCaptureHostImage
|
|
) {
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
const file = await captureHostImageAsFile();
|
|
if (file) {
|
|
onPromptReferenceFilesSelect([file]);
|
|
}
|
|
} catch {
|
|
// 中文注释:相机拍摄失败不打断当前创作输入,用户可继续选择上传或重试拍摄。
|
|
}
|
|
})();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`creative-image-input-panel flex min-h-0 flex-col ${
|
|
fillHeight ? 'flex-1' : 'flex-none'
|
|
} ${className}`}
|
|
>
|
|
<div className={bodyClassName}>
|
|
<section className={sectionClassName}>
|
|
<div
|
|
className={`creative-image-input-panel__grid puzzle-creation-form-grid ${gridSizeClassName} 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={`${imageFieldClassName} ${
|
|
disabled ? 'opacity-55' : ''
|
|
}`}
|
|
>
|
|
<PlatformFieldLabel variant="form" className="shrink-0">
|
|
{labels.imageField}
|
|
</PlatformFieldLabel>
|
|
<div className={imageFrameClassName}>
|
|
<div ref={mainImageCardRef} className={imageCardClassName}>
|
|
{isMainImageUploadEnabled ? (
|
|
<input
|
|
ref={mainImageInputRef}
|
|
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"
|
|
/>
|
|
) : null}
|
|
{shouldPreviewMainImage ? (
|
|
<button
|
|
type="button"
|
|
className="absolute inset-0 z-[2] cursor-zoom-in"
|
|
aria-label={labels.previewMainImage ?? uploadedImageAlt}
|
|
title={labels.previewMainImage ?? uploadedImageAlt}
|
|
onClick={() => setIsMainImagePreviewOpen(true)}
|
|
/>
|
|
) : isMainImageUploadEnabled ? (
|
|
<button
|
|
type="button"
|
|
className={`absolute inset-0 z-0 border-0 bg-transparent p-0 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
|
disabled={disabled}
|
|
aria-label={
|
|
uploadedImageSrc
|
|
? labels.replaceImage
|
|
: labels.uploadImage
|
|
}
|
|
title={
|
|
uploadedImageSrc
|
|
? labels.replaceImage
|
|
: labels.uploadImage
|
|
}
|
|
onClick={handleMainImageUploadClick}
|
|
>
|
|
<span className="sr-only">
|
|
{uploadedImageSrc
|
|
? labels.replaceImage
|
|
: labels.uploadImage}
|
|
</span>
|
|
</button>
|
|
) : 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))]">
|
|
<PlatformIconBadge
|
|
icon={<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />}
|
|
size="xl"
|
|
tone="soft"
|
|
className="border border-[var(--platform-subpanel-border)] bg-white/92 sm:h-20 sm:w-20"
|
|
/>
|
|
</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%)]" />
|
|
{shouldShowMainImageUploadButton ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label={labels.replaceImage}
|
|
title={labels.replaceImage}
|
|
disabled={disabled}
|
|
onClick={handleMainImageUploadClick}
|
|
icon={<ImagePlus className="h-4 w-4" />}
|
|
className="absolute bottom-3 right-3 z-10 h-10 w-10"
|
|
/>
|
|
) : null}
|
|
{shouldShowHistoryButton ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label={labels.history ?? '选择历史图片'}
|
|
title={labels.history ?? '选择历史图片'}
|
|
disabled={disabled}
|
|
onClick={onHistoryClick}
|
|
icon={<History className="h-3.5 w-3.5" />}
|
|
className="absolute right-3 top-3 z-10 gap-1.5 px-3 py-2 text-[11px] font-black"
|
|
>
|
|
<span>历史</span>
|
|
</PlatformIconButton>
|
|
) : null}
|
|
{isMainImageUploadEnabled && canCaptureHostImage ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label="拍摄图片"
|
|
title="拍摄图片"
|
|
disabled={disabled}
|
|
onClick={handleMainImageCaptureClick}
|
|
icon={<Camera className="h-3.5 w-3.5" />}
|
|
className={`absolute top-3 z-10 h-10 w-10 ${
|
|
shouldShowHistoryButton ? 'right-[4.75rem]' : 'right-3'
|
|
}`}
|
|
/>
|
|
) : null}
|
|
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
|
|
<PlatformPillSwitch
|
|
label="AI重绘"
|
|
aria-label="AI重绘"
|
|
checked={aiRedraw}
|
|
disabled={disabled}
|
|
onChange={(event) =>
|
|
onAiRedrawChange(event.target.checked)
|
|
}
|
|
className="absolute bottom-3 left-3 z-10"
|
|
/>
|
|
) : null}
|
|
{canEditMainImage &&
|
|
uploadedImageSrc &&
|
|
canRemoveMainImage ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label={labels.removeImage}
|
|
title={labels.removeImage}
|
|
disabled={disabled}
|
|
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
className="absolute left-3 top-3 z-10 h-10 w-10"
|
|
/>
|
|
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={handleMainImageUploadClick}
|
|
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap border-0 bg-transparent p-0 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}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{mainImageMeta ? (
|
|
<div className="mt-3 shrink-0">{mainImageMeta}</div>
|
|
) : null}
|
|
{imageLimitHint ? (
|
|
<div className="mt-2 shrink-0 text-center text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
|
{imageLimitHint}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{showPrompt ? (
|
|
<div className="block shrink-0 lg:min-h-0">
|
|
<label htmlFor={promptTextareaId} className="mb-2 block">
|
|
<PlatformFieldLabel variant="form" className="mb-0">
|
|
{promptLabel}
|
|
</PlatformFieldLabel>
|
|
</label>
|
|
<div className="relative">
|
|
<PlatformTextField
|
|
variant="textarea"
|
|
id={promptTextareaId}
|
|
value={prompt}
|
|
disabled={disabled}
|
|
rows={promptRows}
|
|
placeholder=""
|
|
onChange={(event) => onPromptChange(event.target.value)}
|
|
size="lg"
|
|
density="roomy"
|
|
className="h-[6rem] min-h-[6rem] rounded-[1.15rem] pb-14 font-normal 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}
|
|
{shouldShowPromptReferences &&
|
|
onPromptReferenceFilesSelect ? (
|
|
<>
|
|
<input
|
|
ref={promptReferenceInputRef}
|
|
id={promptReferenceInputId}
|
|
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"
|
|
/>
|
|
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
|
{canCaptureHostImage ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label="拍摄图片"
|
|
title="拍摄图片"
|
|
disabled={promptReferenceUploadDisabled}
|
|
onClick={handlePromptReferenceCaptureClick}
|
|
icon={<Camera className="h-3.5 w-3.5" />}
|
|
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
|
|
/>
|
|
) : null}
|
|
{canImportHostImage ? (
|
|
<PlatformIconButton
|
|
variant="surfaceFloating"
|
|
label={labels.promptReferenceUpload}
|
|
title={labels.promptReferenceUpload}
|
|
disabled={promptReferenceUploadDisabled}
|
|
onClick={handlePromptReferenceUploadClick}
|
|
icon={<ImagePlus className="h-4 w-4" />}
|
|
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
|
|
/>
|
|
) : (
|
|
<PlatformIconButton
|
|
asChild="label"
|
|
htmlFor={promptReferenceInputId}
|
|
variant="surfaceFloating"
|
|
label={labels.promptReferenceUpload}
|
|
title={labels.promptReferenceUpload}
|
|
icon={<ImagePlus className="h-4 w-4" />}
|
|
className={`h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
|
|
promptReferenceUploadDisabled
|
|
? 'cursor-not-allowed opacity-55'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{shouldShowPromptReferences &&
|
|
promptReferenceImages.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{promptReferenceImages.map((reference) => (
|
|
<PlatformUploadPreviewCard
|
|
key={reference.id}
|
|
imageSrc={reference.imageSrc}
|
|
imageAlt=""
|
|
previewLabel={`预览参考图 ${reference.label}`}
|
|
removeLabel={`移除参考图 ${reference.label}`}
|
|
onPreview={() => setPreviewReferenceImage(reference)}
|
|
onRemove={
|
|
onPromptReferenceRemove
|
|
? () => onPromptReferenceRemove(reference.id)
|
|
: undefined
|
|
}
|
|
disabled={disabled}
|
|
resolveAsset
|
|
className="h-12 w-12 rounded-[0.75rem] bg-white/90 shadow-sm"
|
|
previewButtonProps={{ title: reference.label }}
|
|
removeButtonProps={{
|
|
title: '移除参考图',
|
|
className:
|
|
'right-0.5 top-0.5 bg-white/94 text-[var(--platform-text-strong)] shadow-sm hover:bg-white hover:text-[var(--platform-accent)]',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 shrink-0 space-y-3">
|
|
{inputError ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="profile"
|
|
size="md"
|
|
className="rounded-2xl"
|
|
>
|
|
{inputError}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
{error ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="profile"
|
|
size="md"
|
|
className="rounded-2xl"
|
|
>
|
|
{error}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{showSubmitButton ? (
|
|
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
|
<PlatformActionButton
|
|
disabled={disabled || submitDisabled}
|
|
onClick={onSubmit}
|
|
className={`min-h-10 px-4 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 ? (
|
|
<PlatformPillBadge
|
|
tone="lightOverlay"
|
|
size="xs"
|
|
className="px-2 font-bold"
|
|
>
|
|
{submitCostLabel}
|
|
</PlatformPillBadge>
|
|
) : null}
|
|
</span>
|
|
</PlatformActionButton>
|
|
</div>
|
|
) : null}
|
|
|
|
<PlatformImagePreviewModal
|
|
open={Boolean(previewReferenceImage)}
|
|
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
|
|
imageSrc={previewReferenceImage?.imageSrc ?? null}
|
|
imageAlt={labels.promptReferencePreviewAlt}
|
|
closeLabel={labels.closePromptReferencePreview}
|
|
zIndexClassName="z-[80]"
|
|
onClose={() => setPreviewReferenceImage(null)}
|
|
/>
|
|
|
|
<PlatformImagePreviewModal
|
|
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
|
|
title={labels.previewMainImage ?? uploadedImageAlt}
|
|
imageSrc={uploadedImageSrc}
|
|
imageAlt={uploadedImageAlt}
|
|
refreshKey={uploadedImageRefreshKey}
|
|
closeLabel={
|
|
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
|
}
|
|
zIndexClassName={mainImagePreviewZIndexClassName}
|
|
onClose={() => setIsMainImagePreviewOpen(false)}
|
|
/>
|
|
|
|
<UnifiedConfirmDialog
|
|
open={isRemoveImageConfirmOpen}
|
|
title={labels.removeImageConfirmTitle}
|
|
description={labels.removeImageConfirmBody}
|
|
onClose={() => setIsRemoveImageConfirmOpen(false)}
|
|
confirmLabel="移除"
|
|
cancelLabel="取消"
|
|
showCancel
|
|
onConfirm={() => {
|
|
onMainImageRemove();
|
|
setIsRemoveImageConfirmOpen(false);
|
|
}}
|
|
size="sm"
|
|
zIndexClassName="z-[80]"
|
|
overlayClassName="px-4 py-6"
|
|
panelClassName="platform-remap-surface max-w-xs rounded-[1.35rem] shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CreativeImageInputPanel;
|