Add ability to copy/paste images, files, and text from web, local, or otherwise (#2326)

This commit is contained in:
Timothy Carambat 2024-09-19 14:44:49 -05:00 committed by GitHub
parent 4fa3d6d333
commit 84c1f6e0ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 97 additions and 8 deletions

View File

@ -9,6 +9,7 @@ import useUser from "@/hooks/useUser";
export const DndUploaderContext = createContext(); export const DndUploaderContext = createContext();
export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE"; export const REMOVE_ATTACHMENT_EVENT = "ATTACHMENT_REMOVE";
export const CLEAR_ATTACHMENTS_EVENT = "ATTACHMENT_CLEAR"; export const CLEAR_ATTACHMENTS_EVENT = "ATTACHMENT_CLEAR";
export const PASTE_ATTACHMENT_EVENT = "ATTACHMENT_PASTED";
/** /**
* File Attachment for automatic upload on the chat container page. * File Attachment for automatic upload on the chat container page.
@ -36,10 +37,15 @@ export function DnDFileUploaderProvider({ workspace, children }) {
useEffect(() => { useEffect(() => {
window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); window.addEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove);
window.addEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); window.addEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments);
window.addEventListener(PASTE_ATTACHMENT_EVENT, handlePastedAttachment);
return () => { return () => {
window.removeEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove); window.removeEventListener(REMOVE_ATTACHMENT_EVENT, handleRemove);
window.removeEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments); window.removeEventListener(CLEAR_ATTACHMENTS_EVENT, resetAttachments);
window.removeEventListener(
PASTE_ATTACHMENT_EVENT,
handlePastedAttachment
);
}; };
}, []); }, []);
@ -86,6 +92,39 @@ export function DnDFileUploaderProvider({ workspace, children }) {
); );
} }
/**
* Handle pasted attachments.
* @param {CustomEvent<{files: File[]}>} event
*/
async function handlePastedAttachment(event) {
const { files = [] } = event.detail;
if (!files.length) return;
const newAccepted = [];
for (const file of files) {
if (file.type.startsWith("image/")) {
newAccepted.push({
uid: v4(),
file,
contentString: await toBase64(file),
status: "success",
error: null,
type: "attachment",
});
} else {
newAccepted.push({
uid: v4(),
file,
contentString: null,
status: "in_progress",
error: null,
type: "upload",
});
}
}
setFiles((prev) => [...prev, ...newAccepted]);
embedEligibleAttachments(newAccepted);
}
/** /**
* Handle dropped files. * Handle dropped files.
* @param {Attachment[]} acceptedFiles * @param {Attachment[]} acceptedFiles
@ -119,8 +158,15 @@ export function DnDFileUploaderProvider({ workspace, children }) {
} }
setFiles((prev) => [...prev, ...newAccepted]); setFiles((prev) => [...prev, ...newAccepted]);
embedEligibleAttachments(newAccepted);
}
for (const attachment of newAccepted) { /**
* Embeds attachments that are eligible for embedding - basically files that are not images.
* @param {Attachment[]} newAttachments
*/
function embedEligibleAttachments(newAttachments = []) {
for (const attachment of newAttachments) {
// Images/attachments are chat specific. // Images/attachments are chat specific.
if (attachment.type === "attachment") continue; if (attachment.type === "attachment") continue;
@ -200,7 +246,7 @@ export default function DnDFileUploaderWrapper({ children }) {
/** /**
* Convert image types into Base64 strings for requests. * Convert image types into Base64 strings for requests.
* @param {File} file * @param {File} file
* @returns {string} * @returns {Promise<string>}
*/ */
async function toBase64(file) { async function toBase64(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -33,7 +33,8 @@ export default function AttachmentManager({ attachments }) {
* @param {{attachment: import("../../DnDWrapper").Attachment}} * @param {{attachment: import("../../DnDWrapper").Attachment}}
*/ */
function AttachmentItem({ attachment }) { function AttachmentItem({ attachment }) {
const { uid, file, status, error, document, type } = attachment; const { uid, file, status, error, document, type, contentString } =
attachment;
const { iconBgColor, Icon } = displayFromFile(file); const { iconBgColor, Icon } = displayFromFile(file);
function removeFileFromQueue() { function removeFileFromQueue() {
@ -127,11 +128,18 @@ function AttachmentItem({ attachment }) {
/> />
</button> </button>
</div> </div>
<div {contentString ? (
className={`${iconBgColor} rounded-lg flex items-center justify-center flex-shrink-0 p-1`} <img
> src={contentString}
<Icon size={30} className="text-white" /> className={`${iconBgColor} w-[30px] h-[30px] rounded-lg flex items-center justify-center`}
</div> />
) : (
<div
className={`${iconBgColor} rounded-lg flex items-center justify-center flex-shrink-0 p-1`}
>
<Icon size={30} className="text-white" />
</div>
)}
<div className="flex flex-col w-[130px]"> <div className="flex flex-col w-[130px]">
<p className="text-white text-xs font-medium truncate"> <p className="text-white text-xs font-medium truncate">
{file.name} {file.name}

View File

@ -15,6 +15,7 @@ import SpeechToText from "./SpeechToText";
import { Tooltip } from "react-tooltip"; import { Tooltip } from "react-tooltip";
import AttachmentManager from "./Attachments"; import AttachmentManager from "./Attachments";
import AttachItem from "./AttachItem"; import AttachItem from "./AttachItem";
import { PASTE_ATTACHMENT_EVENT } from "../DnDWrapper";
export const PROMPT_INPUT_EVENT = "set_prompt_input"; export const PROMPT_INPUT_EVENT = "set_prompt_input";
export default function PromptInput({ export default function PromptInput({
@ -91,6 +92,39 @@ export default function PromptInput({
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
}; };
const handlePasteEvent = (e) => {
e.preventDefault();
if (e.clipboardData.items.length === 0) return false;
// paste any clipboard items that are images.
for (const item of e.clipboardData.items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
window.dispatchEvent(
new CustomEvent(PASTE_ATTACHMENT_EVENT, {
detail: { files: [file] },
})
);
continue;
}
// handle files specifically that are not images as uploads
if (item.kind === "file") {
const file = item.getAsFile();
window.dispatchEvent(
new CustomEvent(PASTE_ATTACHMENT_EVENT, {
detail: { files: [file] },
})
);
continue;
}
}
const pasteText = e.clipboardData.getData("text/plain");
if (pasteText) setPromptInput(pasteText.trim());
return;
};
const watchForSlash = debounce(checkForSlash, 300); const watchForSlash = debounce(checkForSlash, 300);
const watchForAt = debounce(checkForAt, 300); const watchForAt = debounce(checkForAt, 300);
@ -125,6 +159,7 @@ export default function PromptInput({
setPromptInput(e.target.value); setPromptInput(e.target.value);
}} }}
onKeyDown={captureEnter} onKeyDown={captureEnter}
onPaste={handlePasteEvent}
required={true} required={true}
disabled={inputDisabled} disabled={inputDisabled}
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}