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()} />