From 2ede253b39831e38c4397dcafccada16d25fe785 Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 8 Jul 2026 01:54:56 +0800 Subject: [PATCH] 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) --- .../components/CanvasAgentComposer/index.tsx | 1 + .../ui/components/PromptComposer/index.tsx | 59 ++++++++ .../src/ui/pages/CanvasWorkspace/index.tsx | 143 +++++++++++------- frontend/src/ui/pages/HomePage/index.tsx | 1 + 4 files changed, 151 insertions(+), 53 deletions(-) diff --git a/frontend/src/ui/components/CanvasAgentComposer/index.tsx b/frontend/src/ui/components/CanvasAgentComposer/index.tsx index ce36c15..6e4c87c 100644 --- a/frontend/src/ui/components/CanvasAgentComposer/index.tsx +++ b/frontend/src/ui/components/CanvasAgentComposer/index.tsx @@ -188,6 +188,7 @@ export const CanvasAgentComposer = forwardRef {previewSlot} diff --git a/frontend/src/ui/components/PromptComposer/index.tsx b/frontend/src/ui/components/PromptComposer/index.tsx index 8309037..32bb5aa 100644 --- a/frontend/src/ui/components/PromptComposer/index.tsx +++ b/frontend/src/ui/components/PromptComposer/index.tsx @@ -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(); + 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("[data-annotation-id]"); return target instanceof Node ? target.parentElement?.closest("[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; 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) => { + 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(); diff --git a/frontend/src/ui/pages/CanvasWorkspace/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/index.tsx index bad59f7..a11e9cd 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/index.tsx @@ -600,7 +600,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { const [alignmentGuides, setAlignmentGuides] = useState([]); const [canvasFileDragging, setCanvasFileDragging] = useState(false); const [error, setError] = useState(""); - const [clipboardNode, setClipboardNode] = useState(null); const [hiddenNodeIds, setHiddenNodeIds] = useState>(() => new Set()); const [lockedNodeIds, setLockedNodeIds] = useState>(() => new Set()); const [toolbarVisibility, setToolbarVisibility] = useState(defaultToolbarVisibility); @@ -1837,20 +1836,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { }; const copyNode = (node: CanvasNode) => { - setClipboardNode(node); void writeCanvasNodeToSystemClipboard(node) .then(() => showCanvasToast(t("clipboardCopied"))) .catch(() => showCanvasToast(t("clipboardCopyFailed"), "error")); }; - const pasteNode = (sourceNode = clipboardNode) => { - if (!sourceNode) return; - const copy = cloneCanvasNode(sourceNode, cryptoFallbackId()); - setNodes((current) => [...current, copy]); - selectOnlyNode(copy.id); - setDirty(true); - }; - const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => { setNodes((current) => reorderNodeOp(current, nodeId, action)); setDirty(true); @@ -2037,6 +2027,74 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { setDirty(true); }; + const clipboardPasteAnchor = () => { + const pointer = lastCanvasPointerRef.current; + return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint(); + }; + + const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => { + const content = value.replace(/\u00a0/g, " "); + if (!content.trim()) return false; + + const currentViewport = viewportRef.current; + const zoom = Math.max(currentViewport.k, 0.01); + const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom }; + const node = fitTextNodeToContent({ + id: cryptoFallbackId(), + type: "text", + title: t("newTextNode"), + x: anchor.x - size.width / 2, + y: anchor.y - size.height / 2, + width: size.width, + height: size.height, + content, + tone: "ink", + status: "draft", + layerRole: "editable-text", + fontFamily: "Inter", + fontWeight: "Regular", + fontSize: defaultTextVisibleFontSize / zoom, + color: "#000000", + textAlign: "left", + opacity: 1, + strokeColor: "#000000", + strokeWidth: 0, + lineHeight: 0, + letterSpacing: 0, + textDecoration: "none", + textTransform: "none" + }); + setNodes((current) => { + const next = [...current, node]; + nodesRef.current = next; + return next; + }); + selectOnlyNode(node.id); + setDirty(true); + return true; + }; + + const pasteFromClipboardData = (dataTransfer: DataTransfer) => { + const anchor = clipboardPasteAnchor(); + const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image"); + if (imageFile) { + void uploadImagesToCanvas([imageFile], anchor); + return true; + } + return pasteClipboardText(dataTransfer.getData("text/plain"), anchor); + }; + + const pasteFromSystemClipboard = async () => { + const anchor = clipboardPasteAnchor(); + const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null); + if (imageFile) { + await uploadImagesToCanvas([imageFile], anchor); + return; + } + const text = await navigator.clipboard?.readText?.().catch(() => ""); + if (text) pasteClipboardText(text, anchor); + }; + const sendNodeToAgentComposer = (node: CanvasNode) => { if (isRealCanvasImageNode(node)) { insertCanvasImageReference(node, { focus: true }); @@ -2056,7 +2114,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { copyNode(node); removeNode(node.id); }, - paste: pasteNode, + paste: () => void pasteFromSystemClipboard(), duplicate: () => duplicateNode(node), bringForward: () => reorderNode(node.id, "forward"), sendBackward: () => reorderNode(node.id, "backward"), @@ -2143,33 +2201,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { useEffect(() => { const handlePaste = (event: ClipboardEvent) => { if (isEditableShortcutTarget(event.target)) return; - - const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : []; - const pasteImageFiles = () => { - const pointer = lastCanvasPointerRef.current; - const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint(); - void uploadImagesToCanvas(imageFiles, anchor); - }; - if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) { + if (!event.clipboardData) return; + if (pasteFromClipboardData(event.clipboardData)) { event.preventDefault(); - void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => { - if (useInternalNode) { - pasteNode(clipboardNode); - return; - } - pasteImageFiles(); - }); - return; } - if (imageFiles.length > 0) { - event.preventDefault(); - pasteImageFiles(); - return; - } - - if (!clipboardNode) return; - event.preventDefault(); - pasteNode(); }; window.addEventListener("paste", handlePaste); @@ -3108,7 +3143,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { width: size.width, height: size.height, content: previewUrl, - tone: "natural", + tone, status: "uploading" } }); @@ -3322,7 +3357,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) { }} selectionScale={viewport.k} viewportScale={viewport.k} - hasClipboard={Boolean(clipboardNode)} + hasClipboard contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null} onPointerDown={(event) => handleNodePointerDown(event, node)} onContextMenu={() => { @@ -5025,21 +5060,6 @@ function canvasBackgroundStorageKey(projectId: string) { return `fluxo:canvas-background:${projectId}`; } -function isTransparentClipboardNode(node: CanvasNode) { - return node.type === "image" && (node.layerRole === "background-removed" || node.tone === "transparent-image"); -} - -async function shouldUseInternalClipboardNode(node: CanvasNode, imageFiles: File[]) { - const imageFile = imageFiles[0]; - if (!imageFile) return false; - try { - const dimensions = await readImageDimensions(imageFile); - return dimensions.width === Math.max(1, Math.round(node.width)) && dimensions.height === Math.max(1, Math.round(node.height)); - } catch { - return false; - } -} - async function writeCanvasNodeToSystemClipboard(node: CanvasNode) { if (node.type === "text") { await writeClipboardText(node.content || node.title); @@ -5132,6 +5152,23 @@ function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = " }, []); } +function latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") { + return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null; +} + +async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") { + if (!navigator.clipboard?.read) return null; + const items = await navigator.clipboard.read(); + for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) { + const item = items[itemIndex]; + const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1); + if (!type) continue; + const blob = await item.getType(type); + return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`); + } + return null; +} + function dataTransferHasImage(dataTransfer: DataTransfer) { const files = dataTransferImageFiles(dataTransfer); if (files.length > 0) return true; diff --git a/frontend/src/ui/pages/HomePage/index.tsx b/frontend/src/ui/pages/HomePage/index.tsx index 1fdc555..ef1bd36 100644 --- a/frontend/src/ui/pages/HomePage/index.tsx +++ b/frontend/src/ui/pages/HomePage/index.tsx @@ -734,6 +734,7 @@ export function HomePage() { onTextChange={setPrompt} onReferencesChange={syncReferenceImages} onReferenceRemoved={handleReferenceRemoved} + onPasteFile={attachImage} onSubmit={() => createProject()} />