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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user