mirror of
https://github.com/stashapp/stash.git
synced 2026-05-08 12:32:29 +02:00
New: Add From Clipboard to Set Image (#6637)
* add from clipboard to UI * only trigger when input not focused
This commit is contained in:
parent
f7da37400b
commit
fbf91b2526
3 changed files with 93 additions and 33 deletions
|
|
@ -10,8 +10,10 @@ import {
|
||||||
import { useIntl } from "react-intl";
|
import { useIntl } from "react-intl";
|
||||||
import { ModalComponent } from "./Modal";
|
import { ModalComponent } from "./Modal";
|
||||||
import { Icon } from "./Icon";
|
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 { PatchComponent } from "src/patch";
|
||||||
|
import ImageUtils from "src/utils/image";
|
||||||
|
import { useToast } from "src/hooks/Toast";
|
||||||
|
|
||||||
interface IImageInput {
|
interface IImageInput {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
|
|
@ -39,6 +41,7 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
||||||
const [isShowDialog, setIsShowDialog] = useState(false);
|
const [isShowDialog, setIsShowDialog] = useState(false);
|
||||||
const [url, setURL] = useState("");
|
const [url, setURL] = useState("");
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const Toast = useToast();
|
||||||
|
|
||||||
if (!isEditing) return <div />;
|
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() {
|
function showDialog() {
|
||||||
setURL("");
|
setURL("");
|
||||||
setIsShowDialog(true);
|
setIsShowDialog(true);
|
||||||
|
|
@ -127,6 +152,16 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
|
||||||
<span>{intl.formatMessage({ id: "actions.from_url" })}</span>
|
<span>{intl.formatMessage({ id: "actions.from_url" })}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.Content>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
"export_all": "Export all…",
|
"export_all": "Export all…",
|
||||||
"find": "Find",
|
"find": "Find",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
|
"from_clipboard": "From clipboard",
|
||||||
"from_file": "From file…",
|
"from_file": "From file…",
|
||||||
"from_url": "From URL…",
|
"from_url": "From URL…",
|
||||||
"full_export": "Full export",
|
"full_export": "Full export",
|
||||||
|
|
@ -1636,6 +1637,9 @@
|
||||||
"toast": {
|
"toast": {
|
||||||
"added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
"added_entity": "Added {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||||
"added_generation_job_to_queue": "Added generation job to queue",
|
"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}",
|
"created_entity": "Created {entity}",
|
||||||
"default_filter_set": "Default filter set",
|
"default_filter_set": "Default filter set",
|
||||||
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,18 @@
|
||||||
import React, { useCallback, useEffect } from "react";
|
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 readImage = (file: File, onLoadEnd: (imageData: string) => void) => {
|
||||||
const reader: FileReader = new FileReader();
|
// only proceed if no error encountered
|
||||||
reader.onloadend = () => {
|
blobToDataURL(file)
|
||||||
// only proceed if no error encountered
|
.then(onLoadEnd)
|
||||||
if (!reader.error) {
|
.catch(() => {});
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onImageChange = (
|
const onImageChange = (
|
||||||
|
|
@ -30,6 +23,46 @@ const onImageChange = (
|
||||||
if (file) readImage(file, onLoadEnd);
|
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 = (
|
const usePasteImage = (
|
||||||
onLoadEnd: (imageData: string) => void,
|
onLoadEnd: (imageData: string) => void,
|
||||||
isActive: boolean = true
|
isActive: boolean = true
|
||||||
|
|
@ -53,23 +86,11 @@ const usePasteImage = (
|
||||||
return false;
|
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 = {
|
const ImageUtils = {
|
||||||
onImageChange,
|
onImageChange,
|
||||||
usePasteImage,
|
usePasteImage,
|
||||||
imageToDataURL,
|
imageToDataURL,
|
||||||
|
readClipboardImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageUtils;
|
export default ImageUtils;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue