New: Add From Clipboard to Set Image (#6637)

* add from clipboard to UI
* only trigger when input not focused
This commit is contained in:
Gykes 2026-03-03 17:01:31 -08:00 committed by GitHub
parent f7da37400b
commit fbf91b2526
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 93 additions and 33 deletions

View file

@ -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<IImageInput> = PatchComponent(
const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState("");
const intl = useIntl();
const Toast = useToast();
if (!isEditing) return <div />;
@ -58,6 +61,28 @@ export const ImageInput: React.FC<IImageInput> = 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<IImageInput> = PatchComponent(
<span>{intl.formatMessage({ id: "actions.from_url" })}</span>
</Button>
</div>
{window.isSecureContext && (
<div>
<Button className="minimal" onClick={onPasteClipboard}>
<Icon icon={faClipboard} className="fa-fw" />
<span>
{intl.formatMessage({ id: "actions.from_clipboard" })}
</span>
</Button>
</div>
)}
</>
</Popover.Content>
</Popover>

View file

@ -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}}}",

View file

@ -1,25 +1,18 @@
import React, { useCallback, useEffect } from "react";
const blobToDataURL = (blob: Blob): Promise<string> =>
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<string | null> => {
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<string>((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;