From fbf91b25262dcbb39a8cdcc566ae39d5c8d757e6 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:01:31 -0800 Subject: [PATCH] New: Add From Clipboard to Set Image (#6637) * add from clipboard to UI * only trigger when input not focused --- ui/v2.5/src/components/Shared/ImageInput.tsx | 37 ++++++++- ui/v2.5/src/locales/en-GB.json | 4 + ui/v2.5/src/utils/image.tsx | 85 ++++++++++++-------- 3 files changed, 93 insertions(+), 33 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ImageInput.tsx b/ui/v2.5/src/components/Shared/ImageInput.tsx index 7675da41f..57b8f06f8 100644 --- a/ui/v2.5/src/components/Shared/ImageInput.tsx +++ b/ui/v2.5/src/components/Shared/ImageInput.tsx @@ -10,8 +10,10 @@ import { import { useIntl } from "react-intl"; import { ModalComponent } from "./Modal"; import { Icon } from "./Icon"; -import { faFile, faLink } from "@fortawesome/free-solid-svg-icons"; +import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons"; import { PatchComponent } from "src/patch"; +import ImageUtils from "src/utils/image"; +import { useToast } from "src/hooks/Toast"; interface IImageInput { isEditing: boolean; @@ -39,6 +41,7 @@ export const ImageInput: React.FC = PatchComponent( const [isShowDialog, setIsShowDialog] = useState(false); const [url, setURL] = useState(""); const intl = useIntl(); + const Toast = useToast(); if (!isEditing) return
; @@ -58,6 +61,28 @@ export const ImageInput: React.FC = PatchComponent( ); } + async function onPasteClipboard() { + try { + const data = await ImageUtils.readClipboardImage(); + if (data && onImageURL) { + onImageURL(data); + Toast.success( + intl.formatMessage({ id: "toast.clipboard_image_pasted" }) + ); + } else { + Toast.error(intl.formatMessage({ id: "toast.clipboard_no_image" })); + } + } catch (e) { + if (e instanceof DOMException && e.name === "NotAllowedError") { + Toast.error( + intl.formatMessage({ id: "toast.clipboard_access_denied" }) + ); + } else { + Toast.error(e); + } + } + } + function showDialog() { setURL(""); setIsShowDialog(true); @@ -127,6 +152,16 @@ export const ImageInput: React.FC = PatchComponent( {intl.formatMessage({ id: "actions.from_url" })}
+ {window.isSecureContext && ( +
+ +
+ )} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 56b11f3f9..8a0ad96da 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -56,6 +56,7 @@ "export_all": "Export all…", "find": "Find", "finish": "Finish", + "from_clipboard": "From clipboard", "from_file": "From file…", "from_url": "From URL…", "full_export": "Full export", @@ -1636,6 +1637,9 @@ "toast": { "added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "Added generation job to queue", + "clipboard_access_denied": "Clipboard access denied. Check your browser permissions", + "clipboard_image_pasted": "Image pasted from clipboard", + "clipboard_no_image": "No image found in clipboard", "created_entity": "Created {entity}", "default_filter_set": "Default filter set", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", diff --git a/ui/v2.5/src/utils/image.tsx b/ui/v2.5/src/utils/image.tsx index b31387e83..73b833f80 100644 --- a/ui/v2.5/src/utils/image.tsx +++ b/ui/v2.5/src/utils/image.tsx @@ -1,25 +1,18 @@ import React, { useCallback, useEffect } from "react"; +const blobToDataURL = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const readImage = (file: File, onLoadEnd: (imageData: string) => void) => { - const reader: FileReader = new FileReader(); - reader.onloadend = () => { - // only proceed if no error encountered - if (!reader.error) { - onLoadEnd(reader.result as string); - } - }; - reader.readAsDataURL(file); -}; - -const pasteImage = ( - event: ClipboardEvent, - onLoadEnd: (imageData: string) => void -) => { - const files = event?.clipboardData?.files; - if (!files?.length) return; - - const file = files[0]; - readImage(file, onLoadEnd); + // only proceed if no error encountered + blobToDataURL(file) + .then(onLoadEnd) + .catch(() => {}); }; const onImageChange = ( @@ -30,6 +23,46 @@ const onImageChange = ( if (file) readImage(file, onLoadEnd); }; +const imageToDataURL = async (url: string) => { + const response = await fetch(url); + const blob = await response.blob(); + return blobToDataURL(blob); +}; + +// uses event.clipboardData which works in all contexts including insecure HTTP +const pasteImage = ( + event: ClipboardEvent, + onLoadEnd: (imageData: string) => void +) => { + const files = event?.clipboardData?.files; + if (!files?.length) return; + + if (document.activeElement instanceof HTMLInputElement) { + // don't interfere with pasting text into inputs + return; + } + + const file = Array.from(files).find((f) => f.type.startsWith("image/")); + if (file) readImage(file, onLoadEnd); +}; + +// uses Clipboard API which requires secure context (HTTPS or localhost) +const readClipboardImage = async (): Promise => { + if (!window.isSecureContext) { + return null; + } + + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find((t) => t.startsWith("image/")); + if (imageType) { + const blob = await item.getType(imageType); + return blobToDataURL(blob); + } + } + return null; +}; + const usePasteImage = ( onLoadEnd: (imageData: string) => void, isActive: boolean = true @@ -53,23 +86,11 @@ const usePasteImage = ( return false; }; -const imageToDataURL = async (url: string) => { - const response = await fetch(url); - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => { - resolve(reader.result as string); - }; - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -}; - const ImageUtils = { onImageChange, usePasteImage, imageToDataURL, + readClipboardImage, }; export default ImageUtils;