From 8307fe31aa6a96f30baf1c2a3c1ee8aae0eadbdb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:49:26 -0500 Subject: [PATCH] Add ability to paste in image dropzone (#20310) Primarily used in the face library, now users can use ctrl/meta-v to paste images from the clipboard in an image entry field --- web/public/locales/en/views/faceLibrary.json | 2 +- web/src/components/input/ImageEntry.tsx | 54 ++++++++++++++++++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json index 7bd60f95a..7f4c3a5b9 100644 --- a/web/public/locales/en/views/faceLibrary.json +++ b/web/public/locales/en/views/faceLibrary.json @@ -66,7 +66,7 @@ "selectImage": "Please select an image file." }, "dropActive": "Drop the image here…", - "dropInstructions": "Drag and drop an image here, or click to select", + "dropInstructions": "Drag and drop or paste an image here, or click to select", "maxSize": "Max size: {{size}}MB" }, "nofaces": "No faces available", diff --git a/web/src/components/input/ImageEntry.tsx b/web/src/components/input/ImageEntry.tsx index 47c8714ef..493d107f2 100644 --- a/web/src/components/input/ImageEntry.tsx +++ b/web/src/components/input/ImageEntry.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/form"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -30,6 +30,23 @@ export default function ImageEntry({ }: ImageEntryProps) { const { t } = useTranslation(["views/faceLibrary"]); const [preview, setPreview] = useState(null); + const dropzoneRef = useRef(null); + + // Auto focus the dropzone + useEffect(() => { + if (dropzoneRef.current && !preview) { + dropzoneRef.current.focus(); + } + }, [preview]); + + // Clean up preview URL on unmount or preview change + useEffect(() => { + return () => { + if (preview) { + URL.revokeObjectURL(preview); + } + }; + }, [preview]); const formSchema = z.object({ file: z @@ -52,9 +69,6 @@ export default function ImageEntry({ // Create preview const objectUrl = URL.createObjectURL(file); setPreview(objectUrl); - - // Clean up preview URL when component unmounts - return () => URL.revokeObjectURL(objectUrl); } }, [form], @@ -68,6 +82,31 @@ export default function ImageEntry({ multiple: false, }); + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + event.preventDefault(); + const clipboardItems = Array.from(event.clipboardData.items); + for (const item of clipboardItems) { + if (item.type.startsWith("image/")) { + const blob = item.getAsFile(); + if (blob && blob.size <= maxSize) { + const mimeType = blob.type.split("/")[1]; + const extension = `.${mimeType}`; + if (accept["image/*"].includes(extension)) { + const fileName = blob.name || `pasted-image.${mimeType}`; + const file = new File([blob], fileName, { type: blob.type }); + form.setValue("file", file, { shouldValidate: true }); + const objectUrl = URL.createObjectURL(file); + setPreview(objectUrl); + return; // Take the first valid image + } + } + } + } + }, + [form, maxSize, accept], + ); + const onSubmit = useCallback( (data: z.infer) => { if (!data.file) return; @@ -90,7 +129,12 @@ export default function ImageEntry({ render={() => ( -
+
{!preview ? (