Skip to content

Commit 6dee626

Browse files
committed
feat: node copy & paste
1 parent 81b5c71 commit 6dee626

File tree

12 files changed

+744
-383
lines changed

12 files changed

+744
-383
lines changed

frontend/src/components/canvas-view.tsx

Lines changed: 71 additions & 383 deletions
Large diffs are not rendered by default.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Button } from "@/components/ui/button";
2+
import { Textarea } from "@/components/ui/textarea";
3+
import { MessageSquare, X } from "lucide-react";
4+
import { msg } from "@lingui/core/macro";
5+
import { useLingui } from "@lingui/react";
6+
import { Trans } from "@lingui/react/macro";
7+
8+
interface CanvasChatPanelProps {
9+
isOpen: boolean;
10+
onClose: () => void;
11+
message: string;
12+
onMessageChange: (message: string) => void;
13+
}
14+
15+
export function CanvasChatPanel({
16+
isOpen,
17+
onClose,
18+
message,
19+
onMessageChange,
20+
}: CanvasChatPanelProps) {
21+
const { _ } = useLingui();
22+
23+
if (!isOpen) return null;
24+
25+
return (
26+
<div className="w-96 bg-background border-l flex flex-col pt-14 shadow-xl">
27+
<div className="flex items-center justify-between border-b p-4">
28+
<h3 className="font-semibold">
29+
<Trans>AI Assistant</Trans>
30+
</h3>
31+
<Button variant="ghost" size="icon" onClick={onClose}>
32+
<X className="h-4 w-4" />
33+
</Button>
34+
</div>
35+
<div className="flex-1 overflow-y-auto p-4">
36+
<div className="text-sm text-muted-foreground">
37+
<Trans>This is the AI chat interface...</Trans>
38+
</div>
39+
</div>
40+
<div className="border-t p-4">
41+
<div className="flex gap-2">
42+
<Textarea
43+
value={message}
44+
onChange={(e) => onMessageChange(e.target.value)}
45+
placeholder={_(msg`Enter message...`)}
46+
className="min-h-15"
47+
/>
48+
<Button size="icon">
49+
<MessageSquare className="h-4 w-4" />
50+
</Button>
51+
</div>
52+
</div>
53+
</div>
54+
);
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Button } from "@/components/ui/button";
2+
import { Card } from "@/components/ui/card";
3+
import { FileText, Image, Video, Music } from "lucide-react";
4+
import { msg } from "@lingui/core/macro";
5+
import { useLingui } from "@lingui/react";
6+
import { type NodeType } from "../nodes";
7+
8+
interface CanvasLeftPanelProps {
9+
onAddNode: (type: NodeType) => void;
10+
}
11+
12+
export function CanvasLeftPanel({ onAddNode }: CanvasLeftPanelProps) {
13+
const { _ } = useLingui();
14+
15+
return (
16+
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
17+
<Card className="p-2 shadow-lg rounded-full">
18+
<div className="flex flex-col gap-2">
19+
<Button
20+
variant="ghost"
21+
size="icon"
22+
title={_(msg`Text Node`)}
23+
onClick={() => onAddNode("text")}
24+
>
25+
<FileText className="h-4 w-4" />
26+
</Button>
27+
<Button
28+
variant="ghost"
29+
size="icon"
30+
title={_(msg`Image Node`)}
31+
onClick={() => onAddNode("image")}
32+
>
33+
<Image className="h-4 w-4" />
34+
</Button>
35+
<Button
36+
variant="ghost"
37+
size="icon"
38+
title={_(msg`Video Node`)}
39+
onClick={() => onAddNode("video")}
40+
>
41+
<Video className="h-4 w-4" />
42+
</Button>
43+
<Button
44+
variant="ghost"
45+
size="icon"
46+
title={_(msg`Audio Node`)}
47+
onClick={() => onAddNode("audio")}
48+
>
49+
<Music className="h-4 w-4" />
50+
</Button>
51+
</div>
52+
</Card>
53+
</div>
54+
);
55+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Button } from "@/components/ui/button";
2+
import { Input } from "@/components/ui/input";
3+
import { ArrowLeft, MessageSquare, Download, Upload } from "lucide-react";
4+
import { msg } from "@lingui/core/macro";
5+
import { useLingui } from "@lingui/react";
6+
import { useSystemInfo } from "@/hooks/use-system-info";
7+
import { cn } from "@/lib/utils";
8+
9+
interface CanvasToolbarProps {
10+
name: string;
11+
onNameChange: (name: string) => void;
12+
onBack: () => void;
13+
onImport: () => void;
14+
onExport: () => void;
15+
onToggleChat: () => void;
16+
}
17+
18+
export function CanvasToolbar({
19+
name,
20+
onNameChange,
21+
onBack,
22+
onImport,
23+
onExport,
24+
onToggleChat,
25+
}: CanvasToolbarProps) {
26+
const { _ } = useLingui();
27+
const { systemInfo } = useSystemInfo();
28+
29+
return (
30+
<div
31+
className={cn(
32+
"absolute top-0 left-0 right-0 z-20 flex items-center gap-4 p-2 backdrop-blur-md bg-background/80 border-b border-border/50",
33+
systemInfo?.isMac && "pl-24"
34+
)}
35+
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
36+
>
37+
<Button variant="ghost" size="icon" onClick={onBack}>
38+
<ArrowLeft className="h-5 w-5" />
39+
</Button>
40+
<Input
41+
value={name}
42+
onChange={(e) => onNameChange(e.target.value)}
43+
className="max-w-sm border-none bg-transparent px-2 focus-visible:ring-0 focus-visible:ring-offset-0 font-semibold"
44+
placeholder={_(msg`Project name`)}
45+
/>
46+
<div className="ml-auto flex items-center gap-2">
47+
<Button
48+
variant="ghost"
49+
size="icon"
50+
title="Import JSON"
51+
onClick={onImport}
52+
>
53+
<Upload className="h-5 w-5" />
54+
</Button>
55+
<Button
56+
variant="ghost"
57+
size="icon"
58+
title="Copy JSON"
59+
onClick={onExport}
60+
>
61+
<Download className="h-5 w-5" />
62+
</Button>
63+
<Button variant="ghost" size="icon" onClick={onToggleChat}>
64+
<MessageSquare className="h-5 w-5" />
65+
</Button>
66+
</div>
67+
</div>
68+
);
69+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { CanvasToolbar } from "./canvas-toolbar";
2+
export { CanvasLeftPanel } from "./canvas-left-panel";
3+
export { CanvasChatPanel } from "./canvas-chat-panel";

frontend/src/hooks/canvas/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { useCanvasMouse } from "./use-canvas-mouse";
2+
export { useCanvasCopyPaste } from "./use-canvas-copy-paste";
3+
export { useCanvasDrag } from "./use-canvas-drag";
4+
export { useCanvasSelection } from "./use-canvas-selection";
5+
export { useCanvasImportExport } from "./use-canvas-import-export";
6+
export { useCanvasSave } from "./use-canvas-save";
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { useCallback, useRef, useEffect } from "react";
2+
import { type Node, type Edge } from "@xyflow/react";
3+
import { useLingui } from "@lingui/react";
4+
5+
interface UseCanvasCopyPasteProps {
6+
nodes: Node[];
7+
edges: Edge[];
8+
nodeIdCounter: number;
9+
setNodes: (nodes: Node[] | ((nodes: Node[]) => Node[])) => void;
10+
setEdges: (edges: Edge[] | ((edges: Edge[]) => Edge[])) => void;
11+
setNodeIdCounter: (counter: number | ((counter: number) => number)) => void;
12+
mousePositionRef: React.RefObject<{ x: number; y: number }>;
13+
}
14+
15+
export function useCanvasCopyPaste({
16+
nodes,
17+
edges,
18+
nodeIdCounter,
19+
setNodes,
20+
setEdges,
21+
setNodeIdCounter,
22+
mousePositionRef,
23+
}: UseCanvasCopyPasteProps) {
24+
const { _ } = useLingui();
25+
const copiedNodesRef = useRef<Node[]>([]);
26+
27+
// Copy selected nodes
28+
const copyNodes = useCallback(() => {
29+
const selectedNodesList = nodes.filter((n) => n.selected);
30+
if (selectedNodesList.length > 0) {
31+
copiedNodesRef.current = selectedNodesList;
32+
}
33+
}, [nodes]);
34+
35+
// Paste copied nodes
36+
const pasteNodes = useCallback(() => {
37+
if (copiedNodesRef.current.length === 0) return;
38+
39+
// Calculate the center of the copied nodes
40+
const copiedNodePositions = copiedNodesRef.current.map((n) => n.position);
41+
const minX = Math.min(...copiedNodePositions.map((p) => p.x));
42+
const minY = Math.min(...copiedNodePositions.map((p) => p.y));
43+
const maxX = Math.max(...copiedNodePositions.map((p) => p.x));
44+
const maxY = Math.max(...copiedNodePositions.map((p) => p.y));
45+
const centerX = (minX + maxX) / 2;
46+
const centerY = (minY + maxY) / 2;
47+
48+
// Use mouse position as paste target
49+
const targetX = mousePositionRef.current.x;
50+
const targetY = mousePositionRef.current.y;
51+
52+
const newNodes: Node[] = [];
53+
const oldToNewIdMap = new Map<string, string>();
54+
55+
// Create new nodes with updated IDs and positions
56+
copiedNodesRef.current.forEach((copiedNode) => {
57+
const newId = `node-${nodeIdCounter + newNodes.length}`;
58+
oldToNewIdMap.set(copiedNode.id, newId);
59+
60+
// Calculate relative position from center and apply to mouse position
61+
const offsetX = copiedNode.position.x - centerX;
62+
const offsetY = copiedNode.position.y - centerY;
63+
64+
const newNode: Node = {
65+
...copiedNode,
66+
id: newId,
67+
position: {
68+
x: targetX + offsetX,
69+
y: targetY + offsetY,
70+
},
71+
selected: false,
72+
data: {
73+
...copiedNode.data,
74+
label: copiedNode.data.label + " (Copy)",
75+
processing: undefined,
76+
error: undefined,
77+
runTrigger: undefined,
78+
},
79+
};
80+
newNodes.push(newNode);
81+
});
82+
83+
// Copy edges that connect the copied nodes
84+
const copiedNodeIds = new Set(copiedNodesRef.current.map((n) => n.id));
85+
const newEdges: Edge[] = edges
86+
.filter(
87+
(edge) =>
88+
copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target)
89+
)
90+
.map((edge) => ({
91+
...edge,
92+
id: `e${oldToNewIdMap.get(edge.source)}-${oldToNewIdMap.get(
93+
edge.target
94+
)}`,
95+
source: oldToNewIdMap.get(edge.source)!,
96+
target: oldToNewIdMap.get(edge.target)!,
97+
}));
98+
99+
setNodes((nds) => [...nds, ...newNodes]);
100+
setEdges((eds) => [...eds, ...newEdges]);
101+
setNodeIdCounter((c) => c + newNodes.length);
102+
}, [edges, nodeIdCounter, setNodes, setEdges, setNodeIdCounter, mousePositionRef]);
103+
104+
// Keyboard shortcuts
105+
useEffect(() => {
106+
const handleKeyDown = (event: KeyboardEvent) => {
107+
// Copy: Cmd+C (Mac) or Ctrl+C (Windows/Linux)
108+
if ((event.metaKey || event.ctrlKey) && event.key === "c") {
109+
// Don't interfere with text input copy
110+
const target = event.target as HTMLElement;
111+
if (
112+
target.tagName === "INPUT" ||
113+
target.tagName === "TEXTAREA" ||
114+
target.isContentEditable
115+
) {
116+
return;
117+
}
118+
event.preventDefault();
119+
copyNodes();
120+
}
121+
122+
// Paste: Cmd+V (Mac) or Ctrl+V (Windows/Linux)
123+
if ((event.metaKey || event.ctrlKey) && event.key === "v") {
124+
// Don't interfere with text input paste
125+
const target = event.target as HTMLElement;
126+
if (
127+
target.tagName === "INPUT" ||
128+
target.tagName === "TEXTAREA" ||
129+
target.isContentEditable
130+
) {
131+
return;
132+
}
133+
event.preventDefault();
134+
pasteNodes();
135+
}
136+
};
137+
138+
window.addEventListener("keydown", handleKeyDown);
139+
return () => window.removeEventListener("keydown", handleKeyDown);
140+
}, [copyNodes, pasteNodes]);
141+
142+
return { copyNodes, pasteNodes };
143+
}

0 commit comments

Comments
 (0)