Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,871 changes: 926 additions & 4,945 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"react-virtualized-auto-sizer": "^1.0.7",
"react-window": "^1.8.9",
"recharts": "^2.15.4",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^2.6.0",
"web-vitals": "^4.2.4",
Expand Down
11 changes: 8 additions & 3 deletions src/components/code/AdvancedCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import type { CompletionSuggestion } from '@/utils/codeUtils';
// Configure Monaco Web Worker to use CDN to prevent main-thread blocking
loader.config({
paths: {
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs'
}
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs',
},
});

// Lazy-load Monaco using Next.js dynamic to avoid SSR issues and improve initial render
Expand Down Expand Up @@ -99,6 +99,7 @@ export const AdvancedCodeEditor: React.FC<AdvancedCodeEditorProps> = ({
output,
validationErrors,
collaborators,
isCollaborationConnected,
autoCompleteEnabled,
currentWord,
languages,
Expand Down Expand Up @@ -259,7 +260,11 @@ export const AdvancedCodeEditor: React.FC<AdvancedCodeEditorProps> = ({
onToggle={toggleAutoComplete}
onSelect={handleSuggestionSelect}
/>
<CollaborativeEditing collaborators={collaborators} roomId={roomId} />
<CollaborativeEditing
collaborators={collaborators}
roomId={roomId}
isConnected={isCollaborationConnected}
/>
</div>
</div>

Expand Down
47 changes: 20 additions & 27 deletions src/components/code/CollaborativeEditing.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,25 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Users, Wifi } from 'lucide-react';
import type { Collaborator } from '@/hooks/useCodeEditor';

interface CollaborativeEditingProps {
collaborators: Collaborator[];
roomId?: string;
isConnected?: boolean;
}

export const CollaborativeEditing: React.FC<CollaborativeEditingProps> = ({
collaborators,
roomId,
isConnected = false,
}) => {
const [isConnected, setIsConnected] = useState(false);
const [activeCount, setActiveCount] = useState(collaborators.length);

// Simulate a Socket.IO connection lifecycle
useEffect(() => {
if (!roomId) return;

// Simulate connection delay
const connectTimer = setTimeout(() => {
setIsConnected(true);
}, 800);

// Simulate occasional collaborator join/leave
const updateTimer = setInterval(() => {
setActiveCount((prev) => {
const delta = Math.random() > 0.5 ? 0 : Math.random() > 0.5 ? 1 : -1;
return Math.max(0, Math.min(collaborators.length + 2, prev + delta));
});
}, 8000);

return () => {
clearTimeout(connectTimer);
clearInterval(updateTimer);
setIsConnected(false);
};
}, [roomId, collaborators.length]);
const activeCount = collaborators.length;

const visibleCollaborators = collaborators.slice(0, 4);
const overflow = Math.max(0, activeCount - 4);
const liveCursorPreview = collaborators
.filter((user) => user.cursorLine && user.cursorColumn)
.slice(0, 2);

return (
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -107,6 +87,19 @@ export const CollaborativeEditing: React.FC<CollaborativeEditingProps> = ({
<Users className="w-3 h-3" />
{activeCount} live
</div>

{liveCursorPreview.length > 0 && (
<div className="hidden md:flex items-center gap-2 text-[11px] text-indigo-200/90">
{liveCursorPreview.map((user) => (
<span
key={`${user.id}-cursor`}
className="rounded-full px-2 py-0.5 border border-indigo-500/30 bg-indigo-950/40"
>
{user.name}: L{user.cursorLine}, C{user.cursorColumn}
</span>
))}
</div>
)}
</div>
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions src/features/collaboration/conflictResolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getOperationNetDelta } from './operations';
import type { TextOperation } from './types';

const transformIndexAgainstOperation = (index: number, against: TextOperation): number => {
if (against.type === 'insert') {
const insertLength = against.text?.length ?? 0;
if (against.index <= index) {
return index + insertLength;
}
return index;
}

const removedLength = against.length ?? 0;
const start = against.index;
const end = start + removedLength;

if (index <= start) {
return index;
}

if (index <= end) {
return start;
}

return index + getOperationNetDelta(against);
};

export const transformIncomingOperation = (
incomingOperation: TextOperation,
pendingLocalOperations: TextOperation[],
): TextOperation => {
const transformed: TextOperation = { ...incomingOperation };

for (const pending of pendingLocalOperations) {
if (pending.id === transformed.id) {
continue;
}

transformed.index = transformIndexAgainstOperation(transformed.index, pending);
}

return transformed;
};
3 changes: 3 additions & 0 deletions src/features/collaboration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './types';
export * from './operations';
export * from './conflictResolution';
91 changes: 91 additions & 0 deletions src/features/collaboration/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { TextOperation } from './types';

interface CreateOperationMeta {
roomId: string;
clientId: string;
baseVersion: number;
timestamp: number;
}

const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};

const generateOperationId = (clientId: string, timestamp: number): string => {
return `${clientId}-${timestamp}-${Math.random().toString(36).slice(2, 8)}`;
};

export const applyTextOperation = (content: string, operation: TextOperation): string => {
const safeIndex = clamp(operation.index, 0, content.length);

if (operation.type === 'insert') {
const insertion = operation.text ?? '';
return `${content.slice(0, safeIndex)}${insertion}${content.slice(safeIndex)}`;
}

const length = clamp(operation.length ?? 0, 0, content.length - safeIndex);

if (operation.type === 'delete') {
return `${content.slice(0, safeIndex)}${content.slice(safeIndex + length)}`;
}

const replacement = operation.text ?? '';
return `${content.slice(0, safeIndex)}${replacement}${content.slice(safeIndex + length)}`;
};

export const createTextOperationFromChange = (
previous: string,
next: string,
meta: CreateOperationMeta,
): TextOperation | null => {
if (previous === next) {
return null;
}

let prefixLength = 0;
const minLength = Math.min(previous.length, next.length);

while (prefixLength < minLength && previous[prefixLength] === next[prefixLength]) {
prefixLength += 1;
}

let previousSuffix = previous.length;
let nextSuffix = next.length;

while (
previousSuffix > prefixLength &&
nextSuffix > prefixLength &&
previous[previousSuffix - 1] === next[nextSuffix - 1]
) {
previousSuffix -= 1;
nextSuffix -= 1;
}

const removedLength = previousSuffix - prefixLength;
const insertedText = next.slice(prefixLength, nextSuffix);

let type: TextOperation['type'] = 'replace';
if (removedLength === 0) {
type = 'insert';
} else if (insertedText.length === 0) {
type = 'delete';
}

return {
id: generateOperationId(meta.clientId, meta.timestamp),
roomId: meta.roomId,
clientId: meta.clientId,
baseVersion: meta.baseVersion,
type,
index: prefixLength,
length: removedLength > 0 ? removedLength : undefined,
text: insertedText.length > 0 ? insertedText : undefined,
timestamp: meta.timestamp,
};
};

export const getOperationNetDelta = (operation: TextOperation): number => {
const insertedLength = operation.text?.length ?? 0;
const removedLength = operation.length ?? 0;
return insertedLength - removedLength;
};
132 changes: 132 additions & 0 deletions src/features/collaboration/server/webSocketServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Server as HttpServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import type { Socket } from 'socket.io';
import { applyTextOperation } from '../operations';
import type { CollaborationMessage, PresenceState, SyncState } from '../types';

interface RoomState {
content: string;
version: number;
presence: Map<string, PresenceState>;
}

const roomStore = new Map<string, RoomState>();
const socketMembership = new Map<string, Array<{ roomId: string; clientId: string }>>();

const getRoomState = (roomId: string): RoomState => {
const existing = roomStore.get(roomId);
if (existing) {
return existing;
}

const created: RoomState = {
content: '',
version: 0,
presence: new Map<string, PresenceState>(),
};

roomStore.set(roomId, created);
return created;
};

const buildSyncState = (roomId: string, roomState: RoomState): SyncState => {
return {
roomId,
content: roomState.content,
version: roomState.version,
presence: [...roomState.presence.values()],
};
};

const emitSync = (socket: Socket, roomId: string, roomState: RoomState): void => {
socket.emit('collaboration:message', {
type: 'sync',
state: buildSyncState(roomId, roomState),
} satisfies CollaborationMessage);
};

export const setupCollaborationWebSocketServer = (httpServer: HttpServer): SocketIOServer => {
const io = new SocketIOServer(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
path: '/api/collaboration/socket',
});

io.on('connection', (socket: Socket) => {
socket.on('collaboration:message', (message: CollaborationMessage) => {
if (message.type === 'join') {
const roomState = getRoomState(message.roomId);

socket.join(message.roomId);
roomState.presence.set(message.presence.clientId, message.presence);
socketMembership.set(socket.id, [
...(socketMembership.get(socket.id) ?? []),
{ roomId: message.roomId, clientId: message.presence.clientId },
]);

emitSync(socket, message.roomId, roomState);
socket.to(message.roomId).emit('collaboration:message', {
type: 'presence',
roomId: message.roomId,
presence: message.presence,
} satisfies CollaborationMessage);

return;
}

if (message.type === 'presence') {
const roomState = getRoomState(message.roomId);
roomState.presence.set(message.presence.clientId, message.presence);
socket.to(message.roomId).emit('collaboration:message', message);
return;
}

if (message.type === 'operation') {
const roomState = getRoomState(message.roomId);

if (message.operation.baseVersion > roomState.version) {
emitSync(socket, message.roomId, roomState);
return;
}

roomState.content = applyTextOperation(roomState.content, message.operation);
roomState.version += 1;

socket.emit('collaboration:message', {
type: 'ack',
roomId: message.roomId,
operationId: message.operation.id,
version: roomState.version,
} satisfies CollaborationMessage);

io.to(message.roomId).emit('collaboration:message', {
...message,
version: roomState.version,
} satisfies CollaborationMessage);
}
});

socket.on('disconnect', () => {
const memberships = socketMembership.get(socket.id) ?? [];

memberships.forEach(({ roomId, clientId }) => {
const roomState = roomStore.get(roomId);
if (!roomState) {
return;
}

roomState.presence.delete(clientId);

if (roomState.presence.size === 0) {
roomStore.delete(roomId);
}
});

socketMembership.delete(socket.id);
});
});

return io;
};
Loading
Loading