Skip to content

Commit 2f792a3

Browse files
authored
feat: implement Collaborative Canvas for Architecture Diagramming Closes #479 (#544)
1 parent 06dda19 commit 2f792a3

4 files changed

Lines changed: 312 additions & 0 deletions

File tree

frontend/src/components/CollaborativeCanvas.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import ReactFlow, {
1818
NodeChange,
1919
} from 'reactflow';
2020
import 'reactflow/dist/style.css';
21+
import { nodeTypes } from './whiteboard/Web3Nodes';
2122

2223
interface CollaborativeCanvasProps {
2324
roomId: string;
@@ -164,6 +165,22 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
164165
});
165166
};
166167

168+
const handleAddWeb3Node = (type: 'wallet' | 'contract' | 'actor') => {
169+
addNode({
170+
id: `node-${Date.now()}`,
171+
type: type,
172+
position: {
173+
x: 150 + Math.random() * 200,
174+
y: 150 + Math.random() * 200,
175+
},
176+
data: {
177+
label: type.charAt(0).toUpperCase() + type.slice(1),
178+
...(type === 'wallet' ? { address: '0x1234...abcd' } : {}),
179+
...(type === 'contract' ? { network: 'Ethereum' } : {}),
180+
},
181+
});
182+
};
183+
167184
const onNodesChange = useCallback(
168185
(changes: NodeChange[]) => {
169186
changes.forEach((change) => {
@@ -244,6 +261,26 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
244261
>
245262
Add Circle
246263
</button>
264+
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
265+
<button
266+
onClick={() => handleAddWeb3Node('wallet')}
267+
className="rounded-md bg-purple-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-400"
268+
>
269+
Add Wallet
270+
</button>
271+
<button
272+
onClick={() => handleAddWeb3Node('contract')}
273+
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-400"
274+
>
275+
Add Contract
276+
</button>
277+
<button
278+
onClick={() => handleAddWeb3Node('actor')}
279+
className="rounded-md bg-green-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-green-400"
280+
>
281+
Add Actor
282+
</button>
283+
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
247284
<button
248285
onClick={handleExportImage}
249286
disabled={isExporting}
@@ -286,6 +323,7 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
286323
<ReactFlow
287324
nodes={defaultNodes}
288325
edges={defaultEdges}
326+
nodeTypes={nodeTypes}
289327
onNodesChange={onNodesChange}
290328
onEdgesChange={onEdgesChange}
291329
onConnect={onConnect}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Handle, Position, NodeProps } from 'reactflow';
2+
import React from 'react';
3+
4+
export function WalletNode({ data }: NodeProps) {
5+
return (
6+
<div className="rounded-xl border-2 border-purple-500 bg-purple-100 p-4 shadow-md w-48">
7+
<Handle type="target" position={Position.Top} className="w-3 h-3" />
8+
<div className="flex flex-col items-center">
9+
<div className="text-2xl mb-2">👛</div>
10+
<div className="font-bold text-purple-900">{data.label}</div>
11+
<div className="text-xs text-purple-700 mt-1">{data.address || '0x...'}</div>
12+
</div>
13+
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
14+
</div>
15+
);
16+
}
17+
18+
export function ContractNode({ data }: NodeProps) {
19+
return (
20+
<div className="rounded-xl border-2 border-blue-500 bg-blue-100 p-4 shadow-md w-48">
21+
<Handle type="target" position={Position.Top} className="w-3 h-3" />
22+
<div className="flex flex-col items-center">
23+
<div className="text-2xl mb-2">📄</div>
24+
<div className="font-bold text-blue-900">{data.label}</div>
25+
<div className="text-xs text-blue-700 mt-1">{data.network || 'Ethereum'}</div>
26+
</div>
27+
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
28+
</div>
29+
);
30+
}
31+
32+
export function ActorNode({ data }: NodeProps) {
33+
return (
34+
<div className="rounded-full border-2 border-green-500 bg-green-100 p-4 shadow-md w-32 h-32 flex flex-col justify-center items-center">
35+
<Handle type="target" position={Position.Top} className="w-3 h-3" />
36+
<div className="text-3xl mb-1">👤</div>
37+
<div className="font-bold text-green-900 text-sm text-center">{data.label}</div>
38+
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
39+
</div>
40+
);
41+
}
42+
43+
export const nodeTypes = {
44+
wallet: WalletNode,
45+
contract: ContractNode,
46+
actor: ActorNode,
47+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { CollaborativeCanvas } from '../CollaborativeCanvas';
5+
import * as canvasHooks from '@/hooks/useCanvasCollaboration';
6+
7+
// Mock html2canvas and jspdf
8+
vi.mock('html2canvas', () => ({
9+
default: vi.fn().mockResolvedValue({
10+
toDataURL: vi.fn().mockReturnValue('data:image/png;base64,mock'),
11+
width: 800,
12+
height: 600,
13+
}),
14+
}));
15+
16+
vi.mock('jspdf', () => {
17+
return {
18+
default: vi.fn().mockImplementation(() => ({
19+
internal: {
20+
pageSize: {
21+
getWidth: () => 210,
22+
getHeight: () => 297,
23+
},
24+
},
25+
addImage: vi.fn(),
26+
save: vi.fn(),
27+
})),
28+
};
29+
});
30+
31+
// Mock hooks
32+
vi.mock('@/hooks/useCanvasCollaboration', () => {
33+
return {
34+
useCanvasCollaboration: vi.fn(),
35+
useSharedCanvas: vi.fn(),
36+
useAwareness: vi.fn(),
37+
};
38+
});
39+
40+
// Mock ResizeObserver for React Flow
41+
class ResizeObserverMock {
42+
observe() {}
43+
unobserve() {}
44+
disconnect() {}
45+
}
46+
global.ResizeObserver = ResizeObserverMock;
47+
48+
describe('CollaborativeCanvas', () => {
49+
const mockAddNode = vi.fn();
50+
51+
beforeEach(() => {
52+
vi.clearAllMocks();
53+
54+
vi.mocked(canvasHooks.useCanvasCollaboration).mockReturnValue({
55+
doc: {} as any,
56+
awareness: {},
57+
isConnected: true,
58+
});
59+
60+
vi.mocked(canvasHooks.useSharedCanvas).mockReturnValue({
61+
nodes: [],
62+
edges: [],
63+
addNode: mockAddNode,
64+
updateNode: vi.fn(),
65+
deleteNode: vi.fn(),
66+
addEdge: vi.fn(),
67+
deleteEdge: vi.fn(),
68+
});
69+
70+
vi.mocked(canvasHooks.useAwareness).mockReturnValue([
71+
{ clientId: 2, name: 'Remote User', color: '#ff0000' }
72+
]);
73+
});
74+
75+
it('renders correctly', () => {
76+
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);
77+
78+
expect(screen.getByText('Canvas: test-room')).toBeInTheDocument();
79+
expect(screen.getByText(/1 collaborator/)).toBeInTheDocument();
80+
});
81+
82+
it('adds standard shape nodes', () => {
83+
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);
84+
85+
fireEvent.click(screen.getByText('Add Rectangle'));
86+
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
87+
type: 'default',
88+
data: { label: 'Rectangle' }
89+
}));
90+
});
91+
92+
it('adds web3 specific nodes', () => {
93+
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);
94+
95+
fireEvent.click(screen.getByText('Add Wallet'));
96+
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
97+
type: 'wallet',
98+
data: expect.objectContaining({ label: 'Wallet', address: '0x1234...abcd' })
99+
}));
100+
101+
fireEvent.click(screen.getByText('Add Contract'));
102+
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
103+
type: 'contract',
104+
data: expect.objectContaining({ label: 'Contract', network: 'Ethereum' })
105+
}));
106+
107+
fireEvent.click(screen.getByText('Add Actor'));
108+
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
109+
type: 'actor',
110+
data: expect.objectContaining({ label: 'Actor' })
111+
}));
112+
});
113+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import * as Y from 'yjs';
4+
import { useCanvasCollaboration, useSharedCanvas, useAwareness } from '../useCanvasCollaboration';
5+
6+
vi.mock('y-websocket', () => {
7+
return {
8+
WebsocketProvider: vi.fn().mockImplementation(() => ({
9+
awareness: {
10+
setLocalState: vi.fn(),
11+
on: vi.fn(),
12+
off: vi.fn(),
13+
getStates: vi.fn().mockReturnValue(new Map()),
14+
clientID: 1,
15+
},
16+
on: vi.fn((event, cb) => {
17+
if (event === 'status') {
18+
setTimeout(() => cb({ status: 'connected' }), 10);
19+
}
20+
}),
21+
disconnect: vi.fn(),
22+
})),
23+
};
24+
});
25+
26+
describe('useCanvasCollaboration hooks', () => {
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
describe('useCanvasCollaboration', () => {
32+
it('initializes connection and doc', async () => {
33+
const { result } = renderHook(() => useCanvasCollaboration('room-1', 'user-1'));
34+
35+
expect(result.current.doc).toBeInstanceOf(Y.Doc);
36+
expect(result.current.awareness).toBeDefined();
37+
38+
// Initially disconnected
39+
expect(result.current.isConnected).toBe(false);
40+
41+
// Wait for mock status event
42+
await new Promise((resolve) => setTimeout(resolve, 20));
43+
expect(result.current.isConnected).toBe(true);
44+
});
45+
});
46+
47+
describe('useSharedCanvas', () => {
48+
it('manages nodes and edges', () => {
49+
const doc = new Y.Doc();
50+
const nodesArray = doc.getArray('nodes');
51+
const edgesArray = doc.getArray('edges');
52+
53+
const { result } = renderHook(() => useSharedCanvas(doc));
54+
55+
expect(result.current.nodes).toEqual([]);
56+
expect(result.current.edges).toEqual([]);
57+
58+
act(() => {
59+
result.current.addNode({
60+
id: 'n1',
61+
type: 'wallet',
62+
position: { x: 0, y: 0 },
63+
data: { label: 'Wallet' }
64+
});
65+
});
66+
67+
expect(result.current.nodes).toHaveLength(1);
68+
expect(result.current.nodes[0].id).toBe('n1');
69+
expect(nodesArray.length).toBe(1);
70+
71+
act(() => {
72+
result.current.updateNode('n1', { position: { x: 10, y: 10 } });
73+
});
74+
75+
expect(result.current.nodes[0].position).toEqual({ x: 10, y: 10 });
76+
77+
act(() => {
78+
result.current.addEdge({ id: 'e1', source: 'n1', target: 'n2' });
79+
});
80+
81+
expect(result.current.edges).toHaveLength(1);
82+
expect(result.current.edges[0].id).toBe('e1');
83+
84+
act(() => {
85+
result.current.deleteNode('n1');
86+
result.current.deleteEdge('e1');
87+
});
88+
89+
expect(result.current.nodes).toHaveLength(0);
90+
expect(result.current.edges).toHaveLength(0);
91+
});
92+
});
93+
94+
describe('useAwareness', () => {
95+
it('tracks remote users', () => {
96+
const mockAwareness = {
97+
getStates: vi.fn().mockReturnValue(new Map([
98+
[1, { user: { id: 'u1', name: 'User 1' } }],
99+
[2, { user: { id: 'u2', name: 'User 2' } }]
100+
])),
101+
on: vi.fn(),
102+
off: vi.fn(),
103+
clientID: 1, // local client is 1
104+
};
105+
106+
const { result } = renderHook(() => useAwareness(mockAwareness));
107+
108+
// Should only include client 2 (remote user)
109+
expect(result.current).toHaveLength(1);
110+
expect(result.current[0].clientId).toBe(2);
111+
expect(result.current[0].id).toBe('u2');
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)