From 0f56d6ec22be966cb2df39d5657a4d0ce73608d2 Mon Sep 17 00:00:00 2001 From: Dulapah Vibulsanti Date: Sun, 29 Dec 2024 14:54:58 +0000 Subject: [PATCH] feat: live pointer movement --- client/src/app/(room)/room/[roomId]/page.tsx | 2 + client/src/components/remote-pointers.tsx | 149 +++++++++++++++++++ common/types/message.ts | 4 + common/types/pointer.ts | 7 + server/src/index.ts | 6 + server/src/service/pointer-service.ts | 16 ++ 6 files changed, 184 insertions(+) create mode 100644 client/src/components/remote-pointers.tsx create mode 100644 common/types/pointer.ts create mode 100644 server/src/service/pointer-service.ts diff --git a/client/src/app/(room)/room/[roomId]/page.tsx b/client/src/app/(room)/room/[roomId]/page.tsx index eb28190..a8a4546 100644 --- a/client/src/app/(room)/room/[roomId]/page.tsx +++ b/client/src/app/(room)/room/[roomId]/page.tsx @@ -25,6 +25,7 @@ import { FollowUser } from '@/components/follow-user'; import { LeaveButton } from '@/components/leave-button'; import { MarkdownEditor } from '@/components/markdown-editor'; import { MonacoEditor } from '@/components/monaco'; +import { RemotePointers } from '@/components/remote-pointers'; import { RunButton } from '@/components/run-button'; import { Sandpack } from '@/components/sandpack'; import { SettingsButton } from '@/components/settings-button'; @@ -267,6 +268,7 @@ export default function Room(props: { className="flex h-full min-w-[500px] flex-col overflow-clip" aria-label="Code Editor Workspace" > +
{monaco && editor && ( { + const socket = getSocket(); + + const [pointers, setPointers] = useState>( + new Map(), + ); + const [lastEmit, setLastEmit] = useState(0); + + // Handle sending pointer updates + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const now = Date.now(); + if (now - lastEmit < THROTTLE_MS) return; + + const pointer: Pointer = [event.clientX, event.clientY]; + socket.emit(PointerServiceMsg.POINTER, pointer); + setLastEmit(now); + }, + [socket, lastEmit], + ); + + useEffect(() => { + const handlePointerUpdate = (userId: string, newPosition: Pointer) => { + setPointers((prev) => { + const updated = new Map(prev); + updated.set(userId, { + id: userId, + position: newPosition, + lastUpdate: Date.now(), + isVisible: true, + }); + return updated; + }); + }; + + const cleanup = setInterval(() => { + setPointers((prev) => { + const now = Date.now(); + const updated = new Map(prev); + let hasChanges = false; + + for (const [id, pointer] of updated.entries()) { + const timeSinceUpdate = now - pointer.lastUpdate; + + // Start fade out animation + if (timeSinceUpdate > POINTER_TIMEOUT && pointer.isVisible) { + updated.set(id, { ...pointer, isVisible: false }); + hasChanges = true; + + // Remove pointer after fade animation completes + setTimeout(() => { + setPointers((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + }, FADE_DURATION); + } + } + + return hasChanges ? updated : prev; + }); + }, 1000); + + window.addEventListener('pointermove', handlePointerMove); + socket.on(PointerServiceMsg.POINTER, handlePointerUpdate); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + socket.off(PointerServiceMsg.POINTER, handlePointerUpdate); + clearInterval(cleanup); + }; + }, [socket, handlePointerMove]); + + return ( + <> + {Array.from(pointers.values()).map((pointer) => { + const username = userMap.get(pointer.id); + if (!username) return null; + + const { backgroundColor, color } = userMap.getColors(pointer.id); + + return ( + + ); + })} + + ); +}; + +export { RemotePointers }; diff --git a/common/types/message.ts b/common/types/message.ts index 11dfb51..524dad0 100644 --- a/common/types/message.ts +++ b/common/types/message.ts @@ -32,3 +32,7 @@ export enum StreamServiceMsg { MIC_STATE = 'V', SPEAKER_STATE = 'W', } + +export enum PointerServiceMsg { + POINTER = 'X', +} diff --git a/common/types/pointer.ts b/common/types/pointer.ts new file mode 100644 index 0000000..cb507de --- /dev/null +++ b/common/types/pointer.ts @@ -0,0 +1,7 @@ +/** + * Pointer type + * + * Index 0: x-coordinate + * Index 1: y-coordinate + */ +export type Pointer = [number, number]; diff --git a/server/src/index.ts b/server/src/index.ts index c13e01f..cf08622 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,14 +4,17 @@ import { App } from 'uWebSockets.js'; import { CodeServiceMsg, + PointerServiceMsg, RoomServiceMsg, ScrollServiceMsg, StreamServiceMsg, } from '../../common/types/message'; import type { Cursor, EditOp } from '../../common/types/operation'; +import type { Pointer } from '../../common/types/pointer'; import type { Scroll } from '../../common/types/scroll'; import type { ExecutionResult } from '../../common/types/terminal'; import * as codeService from './service/code-service'; +import * as pointerService from './service/pointer-service'; import * as roomService from './service/room-service'; import * as scrollService from './service/scroll-service'; import * as userService from './service/user-service'; @@ -134,5 +137,8 @@ io.on('connection', (socket) => { socket.on(StreamServiceMsg.SPEAKER_STATE, (speakersOn: boolean) => webRTCService.handleSpeakerState(socket, speakersOn), ); + socket.on(PointerServiceMsg.POINTER, (pointer: Pointer) => + pointerService.updatePointer(socket, pointer), + ); socket.on('disconnecting', () => roomService.leave(socket, io)); }); diff --git a/server/src/service/pointer-service.ts b/server/src/service/pointer-service.ts new file mode 100644 index 0000000..b512671 --- /dev/null +++ b/server/src/service/pointer-service.ts @@ -0,0 +1,16 @@ +import type { Socket } from 'socket.io'; + +import { PointerServiceMsg } from '../../../common/types/message'; +import type { Pointer } from '../../../common/types/pointer'; +import { getUserRoom } from '../service/room-service'; +import { getCustomId } from '../service/user-service'; + +export const updatePointer = (socket: Socket, pointer: Pointer) => { + const roomID = getUserRoom(socket); + if (!roomID) return; + + const customId = getCustomId(socket.id); + if (customId) { + socket.to(roomID).emit(PointerServiceMsg.POINTER, customId, pointer); + } +};