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} + > : }
+