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