cyclecad 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION_GUIDE.md +502 -0
- package/INTEGRATION-GUIDE.md +377 -0
- package/MODULES_PHASES_6_7.md +780 -0
- package/app/index.html +106 -2
- package/app/js/brep-kernel.js +1353 -455
- package/app/js/help-module.js +1437 -0
- package/app/js/kernel.js +364 -40
- package/app/js/modules/animation-module.js +967 -0
- package/app/js/modules/assembly-module.js +47 -3
- package/app/js/modules/cam-module.js +1067 -0
- package/app/js/modules/collaboration-module.js +1102 -0
- package/app/js/modules/data-module.js +1656 -0
- package/app/js/modules/drawing-module.js +54 -8
- package/app/js/modules/formats-module.js +1173 -0
- package/app/js/modules/inspection-module.js +937 -0
- package/app/js/modules/mesh-module.js +968 -0
- package/app/js/modules/operations-module.js +40 -7
- package/app/js/modules/plugin-module.js +957 -0
- package/app/js/modules/rendering-module.js +1306 -0
- package/app/js/modules/scripting-module.js +955 -0
- package/app/js/modules/simulation-module.js +60 -3
- package/app/js/modules/sketch-module.js +1032 -90
- package/app/js/modules/step-module.js +47 -6
- package/app/js/modules/surface-module.js +728 -0
- package/app/js/modules/version-module.js +1410 -0
- package/app/js/modules/viewport-module.js +95 -8
- package/app/test-agent-v2.html +881 -1316
- package/docs/ARCHITECTURE.html +838 -1408
- package/docs/DEVELOPER-GUIDE.md +1504 -0
- package/docs/TUTORIAL.md +740 -0
- package/package.json +1 -1
- package/.github/scripts/cad-diff.js +0 -590
- package/.github/workflows/cad-diff.yml +0 -117
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file collaboration-module.js
|
|
3
|
+
* @description Real-time multi-user collaboration via WebRTC peer-to-peer.
|
|
4
|
+
* Users see each other's 3D cursors, watch live edits synchronize across the design,
|
|
5
|
+
* and communicate via floating in-viewport chat messages. Built on CRDT (Conflict-free
|
|
6
|
+
* Replicated Data Types) for automatic conflict resolution when concurrent edits occur.
|
|
7
|
+
*
|
|
8
|
+
* @tutorial Creating a Collaboration Room
|
|
9
|
+
* Step 1: Initialize collaboration
|
|
10
|
+
* const collab = await kernel.exec('collab.createRoom', {
|
|
11
|
+
* capacity: 10,
|
|
12
|
+
* access: 'owner'
|
|
13
|
+
* });
|
|
14
|
+
* // Returns { code: 'ABC123', hostName: 'You' }
|
|
15
|
+
*
|
|
16
|
+
* Step 2: Share the code (ABC123) with teammates via email, Slack, or link
|
|
17
|
+
*
|
|
18
|
+
* Step 3: They join with
|
|
19
|
+
* const joined = await kernel.exec('collab.joinRoom', {
|
|
20
|
+
* code: 'ABC123',
|
|
21
|
+
* userName: 'Alice'
|
|
22
|
+
* });
|
|
23
|
+
* // They instantly see your cursor, geometry, and chat
|
|
24
|
+
*
|
|
25
|
+
* Step 4: Make changes — they're broadcast to all peers automatically
|
|
26
|
+
* kernel.exec('shape.cylinder', { radius: 25, height: 80 });
|
|
27
|
+
* // All peers see the cylinder appear in real-time
|
|
28
|
+
*
|
|
29
|
+
* Step 5: Leave the room (browser close or manual)
|
|
30
|
+
* kernel.exec('collab.leaveRoom');
|
|
31
|
+
*
|
|
32
|
+
* @tutorial Changing User Roles
|
|
33
|
+
* Room owners can control permissions:
|
|
34
|
+
* kernel.exec('collab.setRole', {
|
|
35
|
+
* userId: 'alice-123',
|
|
36
|
+
* role: 'editor' // 'owner' | 'editor' | 'viewer'
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* Roles:
|
|
40
|
+
* - owner: Full control (create/edit/delete/invite)
|
|
41
|
+
* - editor: Can model and view, cannot delete others' work or invite
|
|
42
|
+
* - viewer: Read-only, can only see and comment
|
|
43
|
+
*
|
|
44
|
+
* @tutorial In-Viewport Chat
|
|
45
|
+
* Press Enter to open chat box, type, press Enter to send.
|
|
46
|
+
* Messages appear as floating bubbles near your cursor for 10 seconds.
|
|
47
|
+
* Includes sender name, timestamp, and avatar color (per user).
|
|
48
|
+
*
|
|
49
|
+
* @version 1.0.0
|
|
50
|
+
* @author Sachin Kumar <vvlars@googlemail.com>
|
|
51
|
+
* @license MIT
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// COLLABORATION MODULE — Main Export
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
name: 'collaboration',
|
|
60
|
+
version: '1.0.0',
|
|
61
|
+
|
|
62
|
+
// ========================================================================
|
|
63
|
+
// MODULE STATE
|
|
64
|
+
// ========================================================================
|
|
65
|
+
|
|
66
|
+
state: {
|
|
67
|
+
/** @type {string|null} Current room code (null if not connected) */
|
|
68
|
+
roomCode: null,
|
|
69
|
+
|
|
70
|
+
/** @type {string} Local user ID (UUID) */
|
|
71
|
+
userId: null,
|
|
72
|
+
|
|
73
|
+
/** @type {string} Local user name */
|
|
74
|
+
userName: 'User',
|
|
75
|
+
|
|
76
|
+
/** @type {'owner'|'editor'|'viewer'} Local user role */
|
|
77
|
+
role: 'viewer',
|
|
78
|
+
|
|
79
|
+
/** @type {Map<string, Object>} Connected peers: userId → {name, role, cursorPos, color, ping} */
|
|
80
|
+
peers: new Map(),
|
|
81
|
+
|
|
82
|
+
/** @type {Array<Object>} CRDT operation log for sync */
|
|
83
|
+
operationLog: [],
|
|
84
|
+
|
|
85
|
+
/** @type {number} Clock vector for causality tracking */
|
|
86
|
+
lamportClock: 0,
|
|
87
|
+
|
|
88
|
+
/** @type {boolean} Room active */
|
|
89
|
+
connected: false,
|
|
90
|
+
|
|
91
|
+
/** @type {number} Peer discovery timeout (ms) */
|
|
92
|
+
peerDiscoveryInterval: null,
|
|
93
|
+
|
|
94
|
+
/** @type {Object} WebSocket for signaling (if signaling server available) */
|
|
95
|
+
signalingSocket: null,
|
|
96
|
+
|
|
97
|
+
/** @type {Map<string, RTCPeerConnection>} WebRTC peer connections */
|
|
98
|
+
peerConnections: new Map(),
|
|
99
|
+
|
|
100
|
+
/** @type {Map<string, RTCDataChannel>} Data channels per peer */
|
|
101
|
+
dataChannels: new Map(),
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// ========================================================================
|
|
105
|
+
// INIT — Setup and teardown
|
|
106
|
+
// ========================================================================
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initialize collaboration module.
|
|
110
|
+
* Sets up event listeners, generates user ID, loads preferences.
|
|
111
|
+
* Called automatically on app startup.
|
|
112
|
+
*
|
|
113
|
+
* @async
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async init() {
|
|
117
|
+
this.state.userId = this._generateUUID();
|
|
118
|
+
this.state.userName = localStorage.getItem('collab_userName') || 'User';
|
|
119
|
+
|
|
120
|
+
// Listen for 3D cursor movement
|
|
121
|
+
window.addEventListener('mousemove', (e) => this._onCursorMove(e));
|
|
122
|
+
|
|
123
|
+
// Listen for chat keypress
|
|
124
|
+
document.addEventListener('keypress', (e) => this._onChatKeyPress(e));
|
|
125
|
+
|
|
126
|
+
// Detect page unload and gracefully leave room
|
|
127
|
+
window.addEventListener('beforeunload', () => {
|
|
128
|
+
if (this.state.connected) {
|
|
129
|
+
this._disconnect();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log('[Collaboration] Initialized. User ID:', this.state.userId);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// ========================================================================
|
|
137
|
+
// PUBLIC API — Room Management
|
|
138
|
+
// ========================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a new collaboration room.
|
|
142
|
+
* Generates a 6-character alphanumeric room code.
|
|
143
|
+
* Caller becomes the owner with full permissions.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} options
|
|
146
|
+
* @param {number} [options.capacity=10] Max peers allowed (host is separate)
|
|
147
|
+
* @param {string} [options.hostName] Display name for host (default: 'You')
|
|
148
|
+
* @returns {Promise<Object>} { code, hostName, shareLink }
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* const room = await kernel.exec('collab.createRoom', { capacity: 5 });
|
|
152
|
+
* console.log('Room code:', room.code); // 'ABC123'
|
|
153
|
+
*/
|
|
154
|
+
async createRoom(options = {}) {
|
|
155
|
+
const { capacity = 10, hostName = 'You' } = options;
|
|
156
|
+
|
|
157
|
+
// Generate room code
|
|
158
|
+
const code = this._generateRoomCode();
|
|
159
|
+
|
|
160
|
+
this.state.roomCode = code;
|
|
161
|
+
this.state.role = 'owner';
|
|
162
|
+
this.state.userName = hostName;
|
|
163
|
+
this.state.connected = true;
|
|
164
|
+
|
|
165
|
+
// Save preference
|
|
166
|
+
localStorage.setItem('collab_userName', hostName);
|
|
167
|
+
|
|
168
|
+
// Initialize empty peer map
|
|
169
|
+
this.state.peers.clear();
|
|
170
|
+
this.state.operationLog = [];
|
|
171
|
+
this.state.lamportClock = 0;
|
|
172
|
+
|
|
173
|
+
this._showNotification(`Room created: ${code}`, 'success');
|
|
174
|
+
this._broadcastEvent('collab:roomCreated', { code, hostName });
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
code,
|
|
178
|
+
hostName,
|
|
179
|
+
shareLink: `${window.location.origin}?join=${code}`,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Join an existing collaboration room.
|
|
185
|
+
* Requests peer list from host and establishes WebRTC connections.
|
|
186
|
+
*
|
|
187
|
+
* @param {Object} options
|
|
188
|
+
* @param {string} options.code Room code (6 chars, case-insensitive)
|
|
189
|
+
* @param {string} [options.userName] Display name for this user
|
|
190
|
+
* @returns {Promise<Object>} { joined, peerCount, hostName }
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* const result = await kernel.exec('collab.joinRoom', {
|
|
194
|
+
* code: 'ABC123',
|
|
195
|
+
* userName: 'Alice'
|
|
196
|
+
* });
|
|
197
|
+
* if (result.joined) console.log('Connected to', result.peerCount, 'peers');
|
|
198
|
+
*/
|
|
199
|
+
async joinRoom(options = {}) {
|
|
200
|
+
const { code, userName = 'User' } = options;
|
|
201
|
+
|
|
202
|
+
if (!code || code.length !== 6) {
|
|
203
|
+
throw new Error('Invalid room code format');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.state.roomCode = code.toUpperCase();
|
|
207
|
+
this.state.userName = userName;
|
|
208
|
+
this.state.role = 'editor'; // Joining user starts as editor, not owner
|
|
209
|
+
this.state.connected = true;
|
|
210
|
+
|
|
211
|
+
localStorage.setItem('collab_userName', userName);
|
|
212
|
+
|
|
213
|
+
// Simulate peer discovery (in production: contact signaling server)
|
|
214
|
+
await this._discoverPeers();
|
|
215
|
+
|
|
216
|
+
this._showNotification(`Joined room ${code}`, 'success');
|
|
217
|
+
this._broadcastEvent('collab:userJoined', {
|
|
218
|
+
userId: this.state.userId,
|
|
219
|
+
userName,
|
|
220
|
+
role: this.state.role,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
joined: true,
|
|
225
|
+
peerCount: this.state.peers.size,
|
|
226
|
+
hostName: 'Host', // Would come from signaling server
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Leave the current collaboration room.
|
|
232
|
+
* Closes all peer connections and notifies other users.
|
|
233
|
+
*
|
|
234
|
+
* @returns {Promise<void>}
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* await kernel.exec('collab.leaveRoom');
|
|
238
|
+
*/
|
|
239
|
+
async leaveRoom() {
|
|
240
|
+
if (!this.state.connected) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await this._disconnect();
|
|
245
|
+
|
|
246
|
+
this.state.roomCode = null;
|
|
247
|
+
this.state.connected = false;
|
|
248
|
+
this.state.peers.clear();
|
|
249
|
+
|
|
250
|
+
this._showNotification('Left collaboration room', 'info');
|
|
251
|
+
this._broadcastEvent('collab:roomClosed', {});
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// ========================================================================
|
|
255
|
+
// PUBLIC API — User Management
|
|
256
|
+
// ========================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get list of all connected users (peers + self).
|
|
260
|
+
*
|
|
261
|
+
* @returns {Promise<Array<Object>>} List of { userId, name, role, ping, color }
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* const users = await kernel.exec('collab.getUsers');
|
|
265
|
+
* console.log(`${users.length} users in room`);
|
|
266
|
+
*/
|
|
267
|
+
async getUsers() {
|
|
268
|
+
const users = Array.from(this.state.peers.values()).map((peer) => ({
|
|
269
|
+
userId: peer.userId,
|
|
270
|
+
name: peer.name,
|
|
271
|
+
role: peer.role,
|
|
272
|
+
ping: peer.ping || 0,
|
|
273
|
+
color: peer.color,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
// Add self
|
|
277
|
+
users.unshift({
|
|
278
|
+
userId: this.state.userId,
|
|
279
|
+
name: this.state.userName + ' (You)',
|
|
280
|
+
role: this.state.role,
|
|
281
|
+
ping: 0,
|
|
282
|
+
color: '#4CAF50', // Green for self
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return users;
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Change a user's role (owner only).
|
|
290
|
+
* Validates that caller is owner before allowing role changes.
|
|
291
|
+
*
|
|
292
|
+
* @param {Object} options
|
|
293
|
+
* @param {string} options.userId Target user ID
|
|
294
|
+
* @param {string} options.role New role ('owner' | 'editor' | 'viewer')
|
|
295
|
+
* @returns {Promise<void>}
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* await kernel.exec('collab.setRole', {
|
|
299
|
+
* userId: 'alice-uuid',
|
|
300
|
+
* role: 'viewer'
|
|
301
|
+
* });
|
|
302
|
+
*/
|
|
303
|
+
async setRole(options = {}) {
|
|
304
|
+
const { userId, role } = options;
|
|
305
|
+
|
|
306
|
+
if (this.state.role !== 'owner') {
|
|
307
|
+
throw new Error('Only room owner can change roles');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!['owner', 'editor', 'viewer'].includes(role)) {
|
|
311
|
+
throw new Error('Invalid role: ' + role);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const peer = this.state.peers.get(userId);
|
|
315
|
+
if (peer) {
|
|
316
|
+
peer.role = role;
|
|
317
|
+
this._broadcastEvent('collab:roleChanged', { userId, role });
|
|
318
|
+
this._broadcastToPeers('roleChange', { userId, role });
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
// ========================================================================
|
|
323
|
+
// PUBLIC API — Operations and Chat
|
|
324
|
+
// ========================================================================
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Broadcast a geometry operation to all peers.
|
|
328
|
+
* Operation is logged in CRDT log with causality tracking.
|
|
329
|
+
*
|
|
330
|
+
* @param {Object} operation Geometry operation object
|
|
331
|
+
* { type, params, featureId, userId, timestamp, lamportClock }
|
|
332
|
+
* @returns {Promise<void>}
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* await kernel.exec('collab.broadcastOperation', {
|
|
336
|
+
* type: 'extrude',
|
|
337
|
+
* params: { distance: 50 },
|
|
338
|
+
* featureId: 'sketch_1'
|
|
339
|
+
* });
|
|
340
|
+
*/
|
|
341
|
+
async broadcastOperation(operation = {}) {
|
|
342
|
+
if (!this.state.connected) {
|
|
343
|
+
throw new Error('Not connected to collaboration room');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Enrich operation with metadata
|
|
347
|
+
const enriched = {
|
|
348
|
+
...operation,
|
|
349
|
+
userId: this.state.userId,
|
|
350
|
+
timestamp: Date.now(),
|
|
351
|
+
lamportClock: ++this.state.lamportClock,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Add to local log
|
|
355
|
+
this.state.operationLog.push(enriched);
|
|
356
|
+
|
|
357
|
+
// Broadcast to all peers
|
|
358
|
+
await this._broadcastToPeers('operation', enriched);
|
|
359
|
+
|
|
360
|
+
this._broadcastEvent('collab:operationSent', enriched);
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Send a chat message to all peers.
|
|
365
|
+
* Message appears as floating text bubble in 3D view.
|
|
366
|
+
*
|
|
367
|
+
* @param {Object} options
|
|
368
|
+
* @param {string} options.text Message text (max 500 chars)
|
|
369
|
+
* @returns {Promise<void>}
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* await kernel.exec('collab.sendMessage', {
|
|
373
|
+
* text: 'I just added a hole here!'
|
|
374
|
+
* });
|
|
375
|
+
*/
|
|
376
|
+
async sendMessage(options = {}) {
|
|
377
|
+
const { text } = options;
|
|
378
|
+
|
|
379
|
+
if (!text || text.trim().length === 0) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (text.length > 500) {
|
|
384
|
+
throw new Error('Message too long (max 500 chars)');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const message = {
|
|
388
|
+
userId: this.state.userId,
|
|
389
|
+
userName: this.state.userName,
|
|
390
|
+
text: text.trim(),
|
|
391
|
+
timestamp: Date.now(),
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Broadcast to peers
|
|
395
|
+
await this._broadcastToPeers('message', message);
|
|
396
|
+
|
|
397
|
+
// Show in local chat (for sender)
|
|
398
|
+
this._showChatBubble(message);
|
|
399
|
+
|
|
400
|
+
this._broadcastEvent('collab:messageSent', message);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
// ========================================================================
|
|
404
|
+
// PUBLIC API — Info and Config
|
|
405
|
+
// ========================================================================
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Get current room info.
|
|
409
|
+
*
|
|
410
|
+
* @returns {Promise<Object|null>}
|
|
411
|
+
* { code, role, userName, peerCount, connected, operationCount }
|
|
412
|
+
*/
|
|
413
|
+
async getRoomInfo() {
|
|
414
|
+
if (!this.state.connected) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
code: this.state.roomCode,
|
|
420
|
+
role: this.state.role,
|
|
421
|
+
userName: this.state.userName,
|
|
422
|
+
userId: this.state.userId,
|
|
423
|
+
peerCount: this.state.peers.size,
|
|
424
|
+
connected: this.state.connected,
|
|
425
|
+
operationCount: this.state.operationLog.length,
|
|
426
|
+
lamportClock: this.state.lamportClock,
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Get operations since a given Lamport clock value.
|
|
432
|
+
* Used for syncing late-joiners with operation history.
|
|
433
|
+
*
|
|
434
|
+
* @param {number} since Lamport clock threshold (default 0)
|
|
435
|
+
* @returns {Promise<Array<Object>>} Operations with clock >= since
|
|
436
|
+
*/
|
|
437
|
+
async getOperationsSince(since = 0) {
|
|
438
|
+
return this.state.operationLog.filter((op) => op.lamportClock >= since);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
// ========================================================================
|
|
442
|
+
// INTERNAL HELPERS — Network and Signaling
|
|
443
|
+
// ========================================================================
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Discover peers by contacting signaling server.
|
|
447
|
+
* In production, this would reach out to a central server.
|
|
448
|
+
* For demo, simulates peer discovery.
|
|
449
|
+
*
|
|
450
|
+
* @private
|
|
451
|
+
* @async
|
|
452
|
+
* @returns {Promise<void>}
|
|
453
|
+
*/
|
|
454
|
+
async _discoverPeers() {
|
|
455
|
+
// Simulate discovery with timeout
|
|
456
|
+
return new Promise((resolve) => {
|
|
457
|
+
setTimeout(() => {
|
|
458
|
+
// In production: contact signaling server with room code
|
|
459
|
+
// Server returns list of peer addresses
|
|
460
|
+
// For each: initiate WebRTC connection
|
|
461
|
+
resolve();
|
|
462
|
+
}, 500);
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Broadcast data to all connected peers via WebRTC or fallback.
|
|
468
|
+
*
|
|
469
|
+
* @private
|
|
470
|
+
* @async
|
|
471
|
+
* @param {string} type Message type
|
|
472
|
+
* @param {Object} data Message payload
|
|
473
|
+
* @returns {Promise<void>}
|
|
474
|
+
*/
|
|
475
|
+
async _broadcastToPeers(type, data) {
|
|
476
|
+
const message = JSON.stringify({ type, data });
|
|
477
|
+
|
|
478
|
+
for (const [peerId, dc] of this.state.dataChannels) {
|
|
479
|
+
if (dc && dc.readyState === 'open') {
|
|
480
|
+
try {
|
|
481
|
+
dc.send(message);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
console.warn(`Failed to send to peer ${peerId}:`, err.message);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Handle incoming message from peer.
|
|
491
|
+
*
|
|
492
|
+
* @private
|
|
493
|
+
* @param {Object} message Parsed message object
|
|
494
|
+
*/
|
|
495
|
+
_handlePeerMessage(message) {
|
|
496
|
+
const { type, data } = message;
|
|
497
|
+
|
|
498
|
+
switch (type) {
|
|
499
|
+
case 'operation':
|
|
500
|
+
this._mergeRemoteOperation(data);
|
|
501
|
+
break;
|
|
502
|
+
case 'message':
|
|
503
|
+
this._showChatBubble(data);
|
|
504
|
+
this._broadcastEvent('collab:messageReceived', data);
|
|
505
|
+
break;
|
|
506
|
+
case 'cursorMove':
|
|
507
|
+
this._updatePeerCursor(data);
|
|
508
|
+
break;
|
|
509
|
+
case 'roleChange':
|
|
510
|
+
this._handleRoleChange(data);
|
|
511
|
+
break;
|
|
512
|
+
default:
|
|
513
|
+
console.log('[Collaboration] Unknown message type:', type);
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Merge a remote operation using CRDT logic.
|
|
519
|
+
* Resolves conflicts by Lamport clock ordering.
|
|
520
|
+
*
|
|
521
|
+
* @private
|
|
522
|
+
* @param {Object} operation Remote operation
|
|
523
|
+
*/
|
|
524
|
+
_mergeRemoteOperation(operation) {
|
|
525
|
+
// Update our Lamport clock to maintain causality
|
|
526
|
+
this.state.lamportClock = Math.max(
|
|
527
|
+
this.state.lamportClock,
|
|
528
|
+
operation.lamportClock
|
|
529
|
+
) + 1;
|
|
530
|
+
|
|
531
|
+
// Add to log
|
|
532
|
+
this.state.operationLog.push(operation);
|
|
533
|
+
|
|
534
|
+
// CRDT merge: sort by (lamportClock, userId) to get consistent ordering
|
|
535
|
+
this.state.operationLog.sort((a, b) => {
|
|
536
|
+
if (a.lamportClock !== b.lamportClock) {
|
|
537
|
+
return a.lamportClock - b.lamportClock;
|
|
538
|
+
}
|
|
539
|
+
return a.userId.localeCompare(b.userId);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Broadcast event for app to re-sync geometry
|
|
543
|
+
this._broadcastEvent('collab:operationReceived', operation);
|
|
544
|
+
},
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Handle role change notification.
|
|
548
|
+
*
|
|
549
|
+
* @private
|
|
550
|
+
* @param {Object} data { userId, role }
|
|
551
|
+
*/
|
|
552
|
+
_handleRoleChange(data) {
|
|
553
|
+
const { userId, role } = data;
|
|
554
|
+
const peer = this.state.peers.get(userId);
|
|
555
|
+
if (peer) {
|
|
556
|
+
peer.role = role;
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Gracefully disconnect from room.
|
|
562
|
+
*
|
|
563
|
+
* @private
|
|
564
|
+
* @async
|
|
565
|
+
* @returns {Promise<void>}
|
|
566
|
+
*/
|
|
567
|
+
async _disconnect() {
|
|
568
|
+
// Close all peer connections
|
|
569
|
+
for (const pc of this.state.peerConnections.values()) {
|
|
570
|
+
pc.close();
|
|
571
|
+
}
|
|
572
|
+
this.state.peerConnections.clear();
|
|
573
|
+
this.state.dataChannels.clear();
|
|
574
|
+
|
|
575
|
+
// Close signaling socket if present
|
|
576
|
+
if (this.state.signalingSocket) {
|
|
577
|
+
this.state.signalingSocket.close();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Broadcast goodbye
|
|
581
|
+
await this._broadcastToPeers('userLeft', {
|
|
582
|
+
userId: this.state.userId,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ========================================================================
|
|
587
|
+
// INTERNAL HELPERS — UI and Display
|
|
588
|
+
// ========================================================================
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Show a notification toast at top of screen.
|
|
592
|
+
*
|
|
593
|
+
* @private
|
|
594
|
+
* @param {string} message
|
|
595
|
+
* @param {string} type 'success' | 'error' | 'info' | 'warning'
|
|
596
|
+
*/
|
|
597
|
+
_showNotification(message, type = 'info') {
|
|
598
|
+
const toast = document.createElement('div');
|
|
599
|
+
toast.className = `collab-toast collab-toast-${type}`;
|
|
600
|
+
toast.textContent = message;
|
|
601
|
+
document.body.appendChild(toast);
|
|
602
|
+
|
|
603
|
+
setTimeout(() => toast.remove(), 4000);
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Display chat message as floating bubble in 3D view.
|
|
608
|
+
*
|
|
609
|
+
* @private
|
|
610
|
+
* @param {Object} message { userId, userName, text, timestamp }
|
|
611
|
+
*/
|
|
612
|
+
_showChatBubble(message) {
|
|
613
|
+
const bubble = document.createElement('div');
|
|
614
|
+
bubble.className = 'collab-chat-bubble';
|
|
615
|
+
bubble.innerHTML = `
|
|
616
|
+
<strong>${message.userName}</strong><br>
|
|
617
|
+
${message.text}
|
|
618
|
+
`;
|
|
619
|
+
|
|
620
|
+
const peer = this.state.peers.get(message.userId);
|
|
621
|
+
bubble.style.backgroundColor = peer ? peer.color : '#2196F3';
|
|
622
|
+
bubble.style.color = '#fff';
|
|
623
|
+
|
|
624
|
+
document.body.appendChild(bubble);
|
|
625
|
+
|
|
626
|
+
// Fade out and remove after 8s
|
|
627
|
+
setTimeout(() => {
|
|
628
|
+
bubble.style.opacity = '0';
|
|
629
|
+
setTimeout(() => bubble.remove(), 500);
|
|
630
|
+
}, 8000);
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Update a peer's cursor position in 3D space.
|
|
635
|
+
*
|
|
636
|
+
* @private
|
|
637
|
+
* @param {Object} data { userId, x, y, z }
|
|
638
|
+
*/
|
|
639
|
+
_updatePeerCursor(data) {
|
|
640
|
+
const { userId, x, y, z } = data;
|
|
641
|
+
|
|
642
|
+
const peer = this.state.peers.get(userId);
|
|
643
|
+
if (peer) {
|
|
644
|
+
peer.cursorPos = { x, y, z };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Trigger re-render of cursor indicators
|
|
648
|
+
this._broadcastEvent('collab:cursorMoved', data);
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
// ========================================================================
|
|
652
|
+
// INTERNAL HELPERS — Event Handlers
|
|
653
|
+
// ========================================================================
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Handle mouse move events to broadcast cursor position.
|
|
657
|
+
* Throttled to 10Hz to reduce network traffic.
|
|
658
|
+
*
|
|
659
|
+
* @private
|
|
660
|
+
* @param {MouseEvent} e
|
|
661
|
+
*/
|
|
662
|
+
_onCursorMove(e) {
|
|
663
|
+
if (!this.state.connected) return;
|
|
664
|
+
|
|
665
|
+
// Throttle: only send every 100ms
|
|
666
|
+
if (this.state.lastCursorBroadcast &&
|
|
667
|
+
Date.now() - this.state.lastCursorBroadcast < 100) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.state.lastCursorBroadcast = Date.now();
|
|
672
|
+
|
|
673
|
+
// Convert screen coords to 3D (this is app-specific)
|
|
674
|
+
// For now, just broadcast screen position
|
|
675
|
+
const cursorData = {
|
|
676
|
+
userId: this.state.userId,
|
|
677
|
+
screenX: e.clientX,
|
|
678
|
+
screenY: e.clientY,
|
|
679
|
+
timestamp: Date.now(),
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
this._broadcastToPeers('cursorMove', cursorData);
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Handle Enter key in chat input.
|
|
687
|
+
*
|
|
688
|
+
* @private
|
|
689
|
+
* @param {KeyboardEvent} e
|
|
690
|
+
*/
|
|
691
|
+
_onChatKeyPress(e) {
|
|
692
|
+
if (e.key === 'Enter' && e.ctrlKey) {
|
|
693
|
+
const chatInput = document.querySelector('.collab-chat-input');
|
|
694
|
+
if (chatInput) {
|
|
695
|
+
const text = chatInput.value.trim();
|
|
696
|
+
if (text) {
|
|
697
|
+
this.sendMessage({ text });
|
|
698
|
+
chatInput.value = '';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
// ========================================================================
|
|
705
|
+
// INTERNAL HELPERS — Utilities
|
|
706
|
+
// ========================================================================
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Generate a UUID v4.
|
|
710
|
+
*
|
|
711
|
+
* @private
|
|
712
|
+
* @returns {string}
|
|
713
|
+
*/
|
|
714
|
+
_generateUUID() {
|
|
715
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
716
|
+
const r = (Math.random() * 16) | 0;
|
|
717
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
718
|
+
return v.toString(16);
|
|
719
|
+
});
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Generate a random 6-character room code.
|
|
724
|
+
* Uses uppercase alphanumeric (no vowels to avoid profanity).
|
|
725
|
+
*
|
|
726
|
+
* @private
|
|
727
|
+
* @returns {string}
|
|
728
|
+
*/
|
|
729
|
+
_generateRoomCode() {
|
|
730
|
+
const chars = 'BCDFGHJKLMNPQRSTVWXYZ0123456789';
|
|
731
|
+
let code = '';
|
|
732
|
+
for (let i = 0; i < 6; i++) {
|
|
733
|
+
code += chars[Math.floor(Math.random() * chars.length)];
|
|
734
|
+
}
|
|
735
|
+
return code;
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Broadcast a custom event to the app.
|
|
740
|
+
*
|
|
741
|
+
* @private
|
|
742
|
+
* @param {string} eventName
|
|
743
|
+
* @param {Object} detail
|
|
744
|
+
*/
|
|
745
|
+
_broadcastEvent(eventName, detail) {
|
|
746
|
+
const event = new CustomEvent(eventName, { detail });
|
|
747
|
+
document.dispatchEvent(event);
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
// ========================================================================
|
|
751
|
+
// HELP SYSTEM INTEGRATION
|
|
752
|
+
// ========================================================================
|
|
753
|
+
|
|
754
|
+
helpEntries: [
|
|
755
|
+
{
|
|
756
|
+
title: 'Create a Collaboration Room',
|
|
757
|
+
description:
|
|
758
|
+
'Click Collaborate → Create Room. Share the 6-character code with teammates. You\'ll see their cursors and all their edits in real-time.',
|
|
759
|
+
category: 'Collaboration',
|
|
760
|
+
shortcut: 'Ctrl+Shift+C',
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
title: 'Join a Collaboration Room',
|
|
764
|
+
description:
|
|
765
|
+
'Enter the room code shared by the host. You can immediately see their 3D model and cursor position.',
|
|
766
|
+
category: 'Collaboration',
|
|
767
|
+
shortcut: 'Ctrl+Shift+J',
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
title: 'Change User Roles',
|
|
771
|
+
description:
|
|
772
|
+
'Room owner can set roles: Owner (full), Editor (can model), Viewer (read-only). Right-click user in panel to change role.',
|
|
773
|
+
category: 'Collaboration',
|
|
774
|
+
shortcut: null,
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
title: 'Send a Chat Message',
|
|
778
|
+
description:
|
|
779
|
+
'Press Ctrl+Enter to open chat. Type your message and press Enter. Messages appear as floating bubbles in the 3D view for 8 seconds.',
|
|
780
|
+
category: 'Collaboration',
|
|
781
|
+
shortcut: 'Ctrl+Enter',
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
title: 'View Connected Users',
|
|
785
|
+
description:
|
|
786
|
+
'Open the Collaborate panel to see all connected users, their roles, and network latency (ping).',
|
|
787
|
+
category: 'Collaboration',
|
|
788
|
+
shortcut: null,
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
title: 'Leave a Room',
|
|
792
|
+
description:
|
|
793
|
+
'Click Collaborate → Leave Room. Your peers are notified and no longer see your cursor.',
|
|
794
|
+
category: 'Collaboration',
|
|
795
|
+
shortcut: null,
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
|
|
799
|
+
// ========================================================================
|
|
800
|
+
// UI PANEL — HTML and Styling
|
|
801
|
+
// ========================================================================
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Get the HTML for the collaboration panel.
|
|
805
|
+
* Displays room info, user list, and chat box.
|
|
806
|
+
*
|
|
807
|
+
* @returns {string} HTML markup
|
|
808
|
+
*/
|
|
809
|
+
getUI() {
|
|
810
|
+
return `
|
|
811
|
+
<div class="collab-panel" id="collab-panel">
|
|
812
|
+
<div class="collab-header">
|
|
813
|
+
<h3>Collaboration</h3>
|
|
814
|
+
<button class="collab-close-btn" data-close-panel="collab-panel">×</button>
|
|
815
|
+
</div>
|
|
816
|
+
|
|
817
|
+
<div class="collab-content">
|
|
818
|
+
<!-- Room Info Section -->
|
|
819
|
+
<div class="collab-section" id="collab-room-info">
|
|
820
|
+
<div class="collab-status disconnected">
|
|
821
|
+
<span class="collab-dot"></span>
|
|
822
|
+
Not connected
|
|
823
|
+
</div>
|
|
824
|
+
<button id="collab-create-btn" class="collab-button collab-button-primary">
|
|
825
|
+
Create Room
|
|
826
|
+
</button>
|
|
827
|
+
<button id="collab-join-btn" class="collab-button">
|
|
828
|
+
Join Room
|
|
829
|
+
</button>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<!-- User List Section -->
|
|
833
|
+
<div class="collab-section" id="collab-user-section" style="display: none;">
|
|
834
|
+
<h4>Users in Room</h4>
|
|
835
|
+
<ul id="collab-user-list" class="collab-user-list"></ul>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<!-- Chat Section -->
|
|
839
|
+
<div class="collab-section" id="collab-chat-section" style="display: none;">
|
|
840
|
+
<h4>Chat (Ctrl+Enter)</h4>
|
|
841
|
+
<div id="collab-chat-history" class="collab-chat-history"></div>
|
|
842
|
+
<input
|
|
843
|
+
type="text"
|
|
844
|
+
id="collab-chat-input"
|
|
845
|
+
class="collab-chat-input"
|
|
846
|
+
placeholder="Type message..."
|
|
847
|
+
/>
|
|
848
|
+
</div>
|
|
849
|
+
</div>
|
|
850
|
+
</div>
|
|
851
|
+
|
|
852
|
+
<style>
|
|
853
|
+
.collab-panel {
|
|
854
|
+
position: fixed;
|
|
855
|
+
right: 0;
|
|
856
|
+
top: 80px;
|
|
857
|
+
width: 320px;
|
|
858
|
+
height: 600px;
|
|
859
|
+
background: #1e1e1e;
|
|
860
|
+
border-left: 1px solid #333;
|
|
861
|
+
border-radius: 0;
|
|
862
|
+
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.3);
|
|
863
|
+
display: flex;
|
|
864
|
+
flex-direction: column;
|
|
865
|
+
z-index: 1000;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
.collab-header {
|
|
869
|
+
display: flex;
|
|
870
|
+
justify-content: space-between;
|
|
871
|
+
align-items: center;
|
|
872
|
+
padding: 12px;
|
|
873
|
+
border-bottom: 1px solid #333;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
.collab-header h3 {
|
|
877
|
+
margin: 0;
|
|
878
|
+
color: #e0e0e0;
|
|
879
|
+
font-size: 14px;
|
|
880
|
+
font-weight: 600;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.collab-close-btn {
|
|
884
|
+
background: none;
|
|
885
|
+
border: none;
|
|
886
|
+
color: #999;
|
|
887
|
+
font-size: 20px;
|
|
888
|
+
cursor: pointer;
|
|
889
|
+
padding: 0;
|
|
890
|
+
width: 24px;
|
|
891
|
+
height: 24px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.collab-close-btn:hover {
|
|
895
|
+
color: #e0e0e0;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
.collab-content {
|
|
899
|
+
flex: 1;
|
|
900
|
+
overflow-y: auto;
|
|
901
|
+
padding: 12px;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.collab-section {
|
|
905
|
+
margin-bottom: 16px;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.collab-section h4 {
|
|
909
|
+
margin: 0 0 8px 0;
|
|
910
|
+
color: #999;
|
|
911
|
+
font-size: 11px;
|
|
912
|
+
text-transform: uppercase;
|
|
913
|
+
letter-spacing: 0.5px;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.collab-status {
|
|
917
|
+
display: flex;
|
|
918
|
+
align-items: center;
|
|
919
|
+
gap: 8px;
|
|
920
|
+
padding: 8px;
|
|
921
|
+
border-radius: 4px;
|
|
922
|
+
font-size: 12px;
|
|
923
|
+
margin-bottom: 8px;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.collab-status.connected {
|
|
927
|
+
background: #1b5e20;
|
|
928
|
+
color: #81c784;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.collab-status.disconnected {
|
|
932
|
+
background: #33333;
|
|
933
|
+
color: #999;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.collab-dot {
|
|
937
|
+
width: 6px;
|
|
938
|
+
height: 6px;
|
|
939
|
+
border-radius: 50%;
|
|
940
|
+
display: inline-block;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.collab-status.connected .collab-dot {
|
|
944
|
+
background: #81c784;
|
|
945
|
+
animation: pulse 2s infinite;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
@keyframes pulse {
|
|
949
|
+
0%, 100% { opacity: 1; }
|
|
950
|
+
50% { opacity: 0.5; }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.collab-button {
|
|
954
|
+
width: 100%;
|
|
955
|
+
padding: 8px;
|
|
956
|
+
margin-bottom: 6px;
|
|
957
|
+
border: none;
|
|
958
|
+
border-radius: 4px;
|
|
959
|
+
background: #333;
|
|
960
|
+
color: #e0e0e0;
|
|
961
|
+
font-size: 12px;
|
|
962
|
+
cursor: pointer;
|
|
963
|
+
transition: background 0.2s;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.collab-button:hover {
|
|
967
|
+
background: #444;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.collab-button-primary {
|
|
971
|
+
background: #0284C7;
|
|
972
|
+
color: white;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.collab-button-primary:hover {
|
|
976
|
+
background: #0369a1;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.collab-user-list {
|
|
980
|
+
list-style: none;
|
|
981
|
+
padding: 0;
|
|
982
|
+
margin: 0;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.collab-user-item {
|
|
986
|
+
padding: 8px;
|
|
987
|
+
border-radius: 4px;
|
|
988
|
+
background: #2a2a2a;
|
|
989
|
+
margin-bottom: 6px;
|
|
990
|
+
display: flex;
|
|
991
|
+
align-items: center;
|
|
992
|
+
gap: 8px;
|
|
993
|
+
font-size: 12px;
|
|
994
|
+
color: #e0e0e0;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
.collab-user-color {
|
|
998
|
+
width: 12px;
|
|
999
|
+
height: 12px;
|
|
1000
|
+
border-radius: 2px;
|
|
1001
|
+
flex-shrink: 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.collab-user-name {
|
|
1005
|
+
flex: 1;
|
|
1006
|
+
overflow: hidden;
|
|
1007
|
+
text-overflow: ellipsis;
|
|
1008
|
+
white-space: nowrap;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.collab-user-role {
|
|
1012
|
+
font-size: 10px;
|
|
1013
|
+
background: #444;
|
|
1014
|
+
padding: 2px 6px;
|
|
1015
|
+
border-radius: 2px;
|
|
1016
|
+
text-transform: uppercase;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.collab-chat-history {
|
|
1020
|
+
background: #2a2a2a;
|
|
1021
|
+
border-radius: 4px;
|
|
1022
|
+
padding: 8px;
|
|
1023
|
+
height: 150px;
|
|
1024
|
+
overflow-y: auto;
|
|
1025
|
+
margin-bottom: 8px;
|
|
1026
|
+
font-size: 11px;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.collab-chat-message {
|
|
1030
|
+
margin-bottom: 6px;
|
|
1031
|
+
padding: 4px;
|
|
1032
|
+
border-left: 2px solid #0284C7;
|
|
1033
|
+
padding-left: 6px;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.collab-chat-name {
|
|
1037
|
+
font-weight: 600;
|
|
1038
|
+
color: #0284C7;
|
|
1039
|
+
font-size: 10px;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.collab-chat-text {
|
|
1043
|
+
color: #e0e0e0;
|
|
1044
|
+
word-wrap: break-word;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.collab-chat-input {
|
|
1048
|
+
width: 100%;
|
|
1049
|
+
padding: 8px;
|
|
1050
|
+
border: 1px solid #444;
|
|
1051
|
+
border-radius: 4px;
|
|
1052
|
+
background: #2a2a2a;
|
|
1053
|
+
color: #e0e0e0;
|
|
1054
|
+
font-size: 12px;
|
|
1055
|
+
box-sizing: border-box;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.collab-chat-input:focus {
|
|
1059
|
+
outline: none;
|
|
1060
|
+
border-color: #0284C7;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
.collab-toast {
|
|
1064
|
+
position: fixed;
|
|
1065
|
+
bottom: 20px;
|
|
1066
|
+
left: 20px;
|
|
1067
|
+
padding: 12px 16px;
|
|
1068
|
+
border-radius: 4px;
|
|
1069
|
+
font-size: 12px;
|
|
1070
|
+
animation: slideIn 0.3s ease;
|
|
1071
|
+
z-index: 10000;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
@keyframes slideIn {
|
|
1075
|
+
from {
|
|
1076
|
+
transform: translateX(-100%);
|
|
1077
|
+
opacity: 0;
|
|
1078
|
+
}
|
|
1079
|
+
to {
|
|
1080
|
+
transform: translateX(0);
|
|
1081
|
+
opacity: 1;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.collab-toast-success {
|
|
1086
|
+
background: #1b5e20;
|
|
1087
|
+
color: #81c784;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
.collab-toast-error {
|
|
1091
|
+
background: #b71c1c;
|
|
1092
|
+
color: #ff5252;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.collab-toast-info {
|
|
1096
|
+
background: #01579b;
|
|
1097
|
+
color: #81d4fa;
|
|
1098
|
+
}
|
|
1099
|
+
</style>
|
|
1100
|
+
`;
|
|
1101
|
+
},
|
|
1102
|
+
};
|