feat(ui): add drag-and-drop image upload to composers

Add an ImageDropOverlay component and useImageDropTarget hook that let
users drop an image onto the home prompt card or canvas agent composer to
upload it as a reference, with a localized drop overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:02:12 +08:00
parent 58f11302fe
commit 0ef4b7c1ee
7 changed files with 342 additions and 2 deletions
+2
View File
@@ -58,6 +58,8 @@
"logout": "Log out",
"credits": "{count} Credits",
"attachReference": "Attach reference",
"dropImageToUpload": "Release to upload image",
"dropImageSubtext": "Supports JPG, PNG, SVG or WebP",
"webReference": "Web reference",
"modelSelection": "Model selection",
"webEnabled": "Web reference on",
+2
View File
@@ -58,6 +58,8 @@
"logout": "退出登录",
"credits": "{count} Credits",
"attachReference": "添加参考图",
"dropImageToUpload": "松开以上传图片",
"dropImageSubtext": "支持 JPG、PNG、SVG 或 WebP 格式",
"webReference": "联网参考",
"modelSelection": "模型选择",
"webEnabled": "联网参考已开启",
@@ -22,6 +22,7 @@
padding: 8px;
border: 1px solid #e1e3e8;
border-radius: 24px;
--image-drop-radius: 24px;
background: #fff;
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.1);
}
@@ -30,6 +31,7 @@
margin: 0;
border-color: #cbd2dc;
border-radius: 28px;
--image-drop-radius: 28px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 45px rgba(17, 24, 39, 0.14);
}
@@ -21,6 +21,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n/i18n";
import { ImageDropOverlay, useImageDropTarget } from "@/ui/components/ImageDropOverlay";
import { PromptComposer, type PromptComposerHandle, type UploadedReferenceImage, type CanvasAnnotation, type AnnotationPreviewRequest } from "@/ui/components/PromptComposer";
export type ComposerMode = "agent" | "image";
@@ -266,6 +267,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
const modeLabel = mode === "image" ? t("imageMode") : t("agentShort");
const ModeIcon = mode === "image" ? ImagePlus : Bot;
const canSubmit = hasComposerPromptText(text);
const imageDropTarget = useImageDropTarget({ onDropImage: onUploadFile });
const patchSettings = (patch: Partial<ComposerImageSettings>) => {
onSettingsChange({ ...settings, ...patch });
@@ -277,7 +279,11 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
};
const composerCard = (
<div id={variant === "panel" ? "agent-chat-footer" : undefined} className={`assistant-composer ${mode === "image" ? "image-mode" : "agent-mode-active"} ${variant === "floating" ? "floating-composer" : ""}`}>
<div
id={variant === "panel" ? "agent-chat-footer" : undefined}
className={`assistant-composer image-drop-target ${imageDropTarget.active ? "is-image-dragging" : ""} ${mode === "image" ? "image-mode" : "agent-mode-active"} ${variant === "floating" ? "floating-composer" : ""}`}
{...imageDropTarget.targetProps}
>
{mode === "image"}
<PromptComposer
ref={ref}
@@ -426,6 +432,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
)}
</button>
</div>
<ImageDropOverlay active={imageDropTarget.active} label={t("dropImageToUpload")} />
</div>
);
@@ -0,0 +1,174 @@
.image-drop-target {
position: relative;
isolation: isolate;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
--image-drop-radius: 8px;
}
.image-drop-target.is-image-dragging {
border-color: var(--border-strong) !important;
box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.04), var(--shadow-md) !important;
transform: translateY(-1px);
}
.image-drop-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: inherit;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border: 1px solid var(--border);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: scale(0.985);
transition: opacity 200ms cubic-bezier(0.215, 0.61, 0.355, 1),
transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1),
visibility 0s linear 240ms;
}
.image-drop-overlay[data-active="true"] {
opacity: 1;
visibility: visible;
transform: scale(1);
transition-delay: 0s;
}
.image-drop-overlay-inner {
position: absolute;
inset: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
border: 1.5px dashed var(--border-strong);
border-radius: calc(var(--image-drop-radius) - 6px);
background: radial-gradient(circle at center, rgba(246, 247, 249, 0.6) 0%, rgba(255, 255, 255, 0.25) 100%);
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.image-drop-overlay[data-active="true"] .image-drop-overlay-inner {
border-color: var(--muted);
background: radial-gradient(circle at center, rgba(238, 240, 243, 0.75) 0%, rgba(255, 255, 255, 0.4) 100%);
}
.image-drop-overlay-icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6px;
}
.image-drop-overlay-glow {
position: absolute;
width: 72px;
height: 72px;
border-radius: 50%;
background: radial-gradient(circle, rgba(17, 24, 39, 0.1) 0%, rgba(17, 24, 39, 0) 70%);
filter: blur(8px);
opacity: 0.6;
transform: scale(0.9);
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.4s ease;
}
.image-drop-overlay[data-active="true"] .image-drop-overlay-glow {
transform: scale(1.35);
opacity: 0.8;
background: radial-gradient(circle, rgba(17, 24, 39, 0.15) 0%, rgba(17, 24, 39, 0) 70%);
}
.image-drop-overlay-icon {
position: relative;
z-index: 1;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--text);
border: 1px solid var(--text);
box-shadow:
0 4px 12px rgba(17, 24, 39, 0.12),
0 12px 24px rgba(17, 24, 39, 0.16);
color: #ffffff;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.image-drop-overlay[data-active="true"] .image-drop-overlay-icon {
background: var(--black);
color: #ffffff;
border-color: var(--black);
transform: translateY(-4px) scale(1.06);
box-shadow:
0 8px 16px rgba(17, 24, 39, 0.15),
0 20px 40px rgba(17, 24, 39, 0.25);
animation: image-drop-icon-pulse 2s ease-in-out infinite 0.3s;
}
.image-drop-overlay strong {
font-size: 14px;
font-weight: 600;
line-height: 20px;
color: var(--text);
letter-spacing: -0.01em;
text-align: center;
transition: color 0.2s ease;
}
.image-drop-overlay[data-active="true"] strong {
color: var(--text);
}
.image-drop-overlay-subtext {
font-size: 12px;
font-weight: 400;
line-height: 16px;
color: var(--muted);
text-align: center;
transition: color 0.2s ease;
}
.image-drop-overlay[data-active="true"] .image-drop-overlay-subtext {
color: var(--muted);
}
@keyframes image-drop-icon-pulse {
0%, 100% {
transform: translateY(-4px) scale(1.06);
}
50% {
transform: translateY(-7px) scale(1.06);
box-shadow:
0 10px 20px rgba(17, 24, 39, 0.18),
0 24px 44px rgba(17, 24, 39, 0.28);
}
}
@media (prefers-reduced-motion: reduce) {
.image-drop-target,
.image-drop-overlay,
.image-drop-overlay-inner,
.image-drop-overlay-icon,
.image-drop-overlay-glow {
transition: none !important;
}
.image-drop-target.is-image-dragging {
transform: none !important;
}
.image-drop-overlay[data-active="true"] .image-drop-overlay-icon {
animation: none !important;
transform: none !important;
}
}
@@ -0,0 +1,147 @@
"use client";
import "./index.css";
import { useI18n } from "@/i18n/i18n";
import { ImageUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from "react";
type ImageDropOverlayProps = {
active: boolean;
label: string;
};
type UseImageDropTargetOptions = {
disabled?: boolean;
onDropImage: (file: File) => void | Promise<void>;
};
const imageFileNamePattern = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i;
function isImageFile(file: File) {
return file.type.startsWith("image/") || imageFileNamePattern.test(file.name);
}
function imageExtension(type: string) {
const subtype = type.split("/")[1]?.toLowerCase().split(";")[0];
if (!subtype) return "png";
if (subtype === "jpeg") return "jpg";
if (subtype === "svg+xml") return "svg";
return subtype.replace(/[^a-z0-9]+/g, "") || "png";
}
function ensureImageFileName(file: File) {
if (file.name.trim()) return file;
const type = file.type || "image/png";
return new File([file], `dropped-image-${Date.now()}.${imageExtension(type)}`, {
type,
lastModified: file.lastModified || Date.now()
});
}
function imageFileFromDataTransfer(dataTransfer: DataTransfer) {
const candidates = [
...Array.from(dataTransfer.files),
...Array.from(dataTransfer.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file))
];
const image = candidates.find(isImageFile);
return image ? ensureImageFileName(image) : null;
}
function dataTransferMayContainImage(dataTransfer: DataTransfer) {
if (imageFileFromDataTransfer(dataTransfer)) return true;
const fileItems = Array.from(dataTransfer.items).filter((item) => item.kind === "file");
if (fileItems.some((item) => item.type.startsWith("image/"))) return true;
return fileItems.some((item) => !item.type) && Array.from(dataTransfer.types).includes("Files");
}
export function useImageDropTarget({ disabled = false, onDropImage }: UseImageDropTargetOptions) {
const [active, setActive] = useState(false);
const dragDepthRef = useRef(0);
const reset = useCallback(() => {
dragDepthRef.current = 0;
setActive(false);
}, []);
useEffect(() => {
if (disabled) reset();
}, [disabled, reset]);
useEffect(() => {
if (!active) return;
window.addEventListener("dragend", reset);
window.addEventListener("drop", reset);
return () => {
window.removeEventListener("dragend", reset);
window.removeEventListener("drop", reset);
};
}, [active, reset]);
const onDragEnter = (event: ReactDragEvent<HTMLElement>) => {
if (disabled || !dataTransferMayContainImage(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
dragDepthRef.current += 1;
setActive(true);
};
const onDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (disabled || !dataTransferMayContainImage(event.dataTransfer)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setActive(true);
};
const onDragLeave = (event: ReactDragEvent<HTMLElement>) => {
if (!active) return;
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setActive(false);
};
const onDrop = (event: ReactDragEvent<HTMLElement>) => {
const image = imageFileFromDataTransfer(event.dataTransfer);
if (!image) {
reset();
return;
}
event.preventDefault();
event.stopPropagation();
reset();
void onDropImage(image);
};
return {
active,
targetProps: {
onDragEnter,
onDragOver,
onDragLeave,
onDrop
}
};
}
export function ImageDropOverlay({ active, label }: ImageDropOverlayProps) {
const { t } = useI18n();
return (
<div className="image-drop-overlay" data-active={active ? "true" : "false"} role="status" aria-live="polite" aria-hidden={!active}>
<div className="image-drop-overlay-inner">
<div className="image-drop-overlay-icon-wrapper">
<div className="image-drop-overlay-glow" />
<span className="image-drop-overlay-icon" aria-hidden="true">
<ImageUp size={24} strokeWidth={1.8} />
</span>
</div>
<strong>{label}</strong>
<span className="image-drop-overlay-subtext">{t("dropImageSubtext")}</span>
</div>
</div>
);
}
+7 -1
View File
@@ -16,6 +16,7 @@ import { designGateway } from "@/infrastructure/designGateway";
import { useAuth } from "@/ui/auth/AuthProvider";
import { BrandKitSelector } from "@/ui/components/BrandKitSelector";
import { useGlobalToast } from "@/ui/components/CanvasToast";
import { ImageDropOverlay, useImageDropTarget } from "@/ui/components/ImageDropOverlay";
import { DeleteProjectDialog } from "@/ui/components/DeleteProjectDialog";
import { ProjectCard } from "@/ui/components/ProjectCard";
import { PromptComposer, revokeReferenceImage, revokeReferenceImages, serializePromptTextWithReferences, type PromptComposerHandle, type UploadedReferenceImage } from "@/ui/components/PromptComposer";
@@ -813,6 +814,7 @@ export function HomePage() {
showError(toUserMessageText(err, "createError"));
}
};
const imageDropTarget = useImageDropTarget({ onDropImage: attachImage });
const selectPromptMode = (mode: HomePromptMode) => {
setActiveModeId(mode.id);
@@ -849,7 +851,10 @@ export function HomePage() {
<p>{t("heroSubtitle")}</p>
</div>
<div className={`prompt-card ${referenceImages.length > 0 ? "has-references" : ""}`}>
<div
className={`prompt-card image-drop-target ${imageDropTarget.active ? "is-image-dragging" : ""} ${referenceImages.length > 0 ? "has-references" : ""}`}
{...imageDropTarget.targetProps}
>
<PromptComposer
ref={promptComposerRef}
text={prompt}
@@ -928,6 +933,7 @@ export function HomePage() {
{loading ? <Loader2 className="spin" size={20} /> : <Send size={20} />}
</button>
</div>
<ImageDropOverlay active={imageDropTarget.active} label={t("dropImageToUpload")} />
</div>
<div className="quick-actions">