feat(clipboard): paste images and text from the system clipboard
Replace the internal single-node clipboard with real clipboard paste: the prompt composer uploads pasted image files as references, and the canvas pastes clipboard images as nodes and clipboard text as text nodes anchored at the pointer, reading from the async clipboard API on the context-menu paste action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
|
||||
onReferenceRemoved={onReferenceRemoved}
|
||||
onAnnotationsChange={onAnnotationsChange}
|
||||
onAnnotationPreviewChange={onAnnotationPreviewChange}
|
||||
onPasteFile={onUploadFile}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{previewSlot}
|
||||
|
||||
@@ -311,6 +311,45 @@ function pastedReferenceImage(reference: PromptImageReference, index: number): U
|
||||
};
|
||||
}
|
||||
|
||||
function isImageFile(file: File | null): file is File {
|
||||
return Boolean(file && (file.type.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)));
|
||||
}
|
||||
|
||||
function imageExtension(type: string) {
|
||||
const subtype = type.split("/")[1]?.toLowerCase().split(";")[0];
|
||||
if (!subtype) return "png";
|
||||
if (subtype === "jpeg") return "jpg";
|
||||
if (subtype === "svg+xml") return "svg";
|
||||
return subtype.replace(/[^a-z0-9]+/g, "") || "png";
|
||||
}
|
||||
|
||||
function namedClipboardImageFile(file: File, index: number) {
|
||||
if (file.name.trim()) return file;
|
||||
return new File([file], `pasted-image-${index + 1}.${imageExtension(file.type)}`, {
|
||||
type: file.type || "image/png",
|
||||
lastModified: file.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function latestClipboardImageFile(dataTransfer: DataTransfer) {
|
||||
const seen = new Set<string>();
|
||||
const files = [
|
||||
...Array.from(dataTransfer.files),
|
||||
...Array.from(dataTransfer.items)
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
]
|
||||
.filter(isImageFile)
|
||||
.filter((file) => {
|
||||
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map(namedClipboardImageFile);
|
||||
return files.at(-1) ?? null;
|
||||
}
|
||||
|
||||
function closestAnnotationChip(target: EventTarget | Node | null) {
|
||||
if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]");
|
||||
return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null;
|
||||
@@ -549,6 +588,7 @@ export const PromptComposer = forwardRef<
|
||||
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
|
||||
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
|
||||
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
|
||||
onPasteFile?: (file: File | null) => void | Promise<void>;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
>(function PromptComposer(
|
||||
@@ -565,6 +605,7 @@ export const PromptComposer = forwardRef<
|
||||
onReferenceRemoved,
|
||||
onAnnotationsChange,
|
||||
onAnnotationPreviewChange,
|
||||
onPasteFile,
|
||||
onSubmit
|
||||
},
|
||||
ref
|
||||
@@ -849,7 +890,25 @@ export const PromptComposer = forwardRef<
|
||||
onAnnotationPreviewChange?.(null);
|
||||
};
|
||||
|
||||
const uploadPastedImageFile = async (file: File) => {
|
||||
if (!onPasteFile) return;
|
||||
await onPasteFile(file);
|
||||
};
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const pastedImage = latestClipboardImageFile(event.clipboardData);
|
||||
if (pastedImage) {
|
||||
event.preventDefault();
|
||||
const editor = editorRef.current;
|
||||
const selection = window.getSelection();
|
||||
if (editor && selection?.rangeCount) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (rangeBelongsToEditor(range, editor)) savedRangeRef.current = range.cloneRange();
|
||||
}
|
||||
void uploadPastedImageFile(pastedImage);
|
||||
return;
|
||||
}
|
||||
|
||||
const pastedText = event.clipboardData.getData("text/plain");
|
||||
if (!pastedText) return;
|
||||
event.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user