From 0ef4b7c1ee3af387631ca1e51d58793aa0aae748 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 11 Jul 2026 01:02:12 +0800 Subject: [PATCH] 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) --- frontend/src/i18n/locales/en-US.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + .../components/CanvasAgentComposer/index.css | 2 + .../components/CanvasAgentComposer/index.tsx | 9 +- .../ui/components/ImageDropOverlay/index.css | 174 ++++++++++++++++++ .../ui/components/ImageDropOverlay/index.tsx | 147 +++++++++++++++ frontend/src/ui/pages/HomePage/index.tsx | 8 +- 7 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 frontend/src/ui/components/ImageDropOverlay/index.css create mode 100644 frontend/src/ui/components/ImageDropOverlay/index.tsx diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 261e09d..de113eb 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 24c62b8..17de494 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -58,6 +58,8 @@ "logout": "退出登录", "credits": "{count} Credits", "attachReference": "添加参考图", + "dropImageToUpload": "松开以上传图片", + "dropImageSubtext": "支持 JPG、PNG、SVG 或 WebP 格式", "webReference": "联网参考", "modelSelection": "模型选择", "webEnabled": "联网参考已开启", diff --git a/frontend/src/ui/components/CanvasAgentComposer/index.css b/frontend/src/ui/components/CanvasAgentComposer/index.css index 10e45ad..4ae1925 100644 --- a/frontend/src/ui/components/CanvasAgentComposer/index.css +++ b/frontend/src/ui/components/CanvasAgentComposer/index.css @@ -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); } diff --git a/frontend/src/ui/components/CanvasAgentComposer/index.tsx b/frontend/src/ui/components/CanvasAgentComposer/index.tsx index 04c410a..78a47e2 100644 --- a/frontend/src/ui/components/CanvasAgentComposer/index.tsx +++ b/frontend/src/ui/components/CanvasAgentComposer/index.tsx @@ -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) => { onSettingsChange({ ...settings, ...patch }); @@ -277,7 +279,11 @@ export const CanvasAgentComposer = forwardRef +
{mode === "image"}
+ ); diff --git a/frontend/src/ui/components/ImageDropOverlay/index.css b/frontend/src/ui/components/ImageDropOverlay/index.css new file mode 100644 index 0000000..a0a6a7f --- /dev/null +++ b/frontend/src/ui/components/ImageDropOverlay/index.css @@ -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; + } +} diff --git a/frontend/src/ui/components/ImageDropOverlay/index.tsx b/frontend/src/ui/components/ImageDropOverlay/index.tsx new file mode 100644 index 0000000..89aeea4 --- /dev/null +++ b/frontend/src/ui/components/ImageDropOverlay/index.tsx @@ -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; +}; + + +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) => { + if (disabled || !dataTransferMayContainImage(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + dragDepthRef.current += 1; + setActive(true); + }; + + const onDragOver = (event: ReactDragEvent) => { + if (disabled || !dataTransferMayContainImage(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setActive(true); + }; + + const onDragLeave = (event: ReactDragEvent) => { + 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) => { + 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 ( +
+
+
+
+ +
+ {label} + {t("dropImageSubtext")} +
+
+ ); +} diff --git a/frontend/src/ui/pages/HomePage/index.tsx b/frontend/src/ui/pages/HomePage/index.tsx index e16d5f3..2f9cf19 100644 --- a/frontend/src/ui/pages/HomePage/index.tsx +++ b/frontend/src/ui/pages/HomePage/index.tsx @@ -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() {

{t("heroSubtitle")}

-
0 ? "has-references" : ""}`}> +
0 ? "has-references" : ""}`} + {...imageDropTarget.targetProps} + > : }
+