Skip to content

Commit

Permalink
feat: live pointer movement
Browse files Browse the repository at this point in the history
  • Loading branch information
dulapahv committed Dec 29, 2024
1 parent 28c6bf1 commit 0f56d6e
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 0 deletions.
2 changes: 2 additions & 0 deletions client/src/app/(room)/room/[roomId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"
>
<RemotePointers />
<div className="h-9" role="toolbar" aria-label="Editor Controls">
{monaco && editor && (
<MemoizedToolbar
Expand Down
149 changes: 149 additions & 0 deletions client/src/components/remote-pointers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { useCallback, useEffect, useState } from 'react';
import { MousePointer2 } from 'lucide-react';

import { PointerServiceMsg } from '@common/types/message';
import type { Pointer } from '@common/types/pointer';

import { userMap } from '@/lib/services/user-map';
import { getSocket } from '@/lib/socket';

interface RemotePointer {
id: string;
position: Pointer;
lastUpdate: number;
isVisible: boolean;
}

const POINTER_TIMEOUT = 2000; // Hide pointer after 2 seconds of inactivity
const FADE_DURATION = 300; // Duration of fade out animation in ms
const THROTTLE_MS = 16; // Approximately 60fps for smoother updates

const RemotePointers = () => {
const socket = getSocket();

const [pointers, setPointers] = useState<Map<string, RemotePointer>>(
new Map(),
);
const [lastEmit, setLastEmit] = useState<number>(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 (
<div
key={pointer.id}
className="pointer-events-none fixed z-[100] translate-x-[-50%] translate-y-[-50%] transform-gpu transition-all duration-100 ease-out will-change-[left,top,opacity]"
style={{
left: `${pointer.position[0]}px`,
top: `${pointer.position[1]}px`,
opacity: pointer.isVisible ? 1 : 0,
backfaceVisibility: 'hidden',
transition: `opacity ${FADE_DURATION}ms ease-out, left 100ms ease-out, top 100ms ease-out`,
}}
aria-hidden="true"
>
{/* Cursor */}
<div className="relative">
<MousePointer2
size={20}
className="absolute -left-[2px] -top-[2px] shadow-sm"
style={{
color: backgroundColor,
fill: 'currentColor',
}}
/>

{/* Name tag */}
<div
className="absolute left-4 top-4 flex h-5 max-w-[120px] items-center rounded px-1.5 shadow-sm"
style={{
backgroundColor,
}}
>
<span
className="truncate text-xs font-medium"
style={{ color }}
>
{username}
</span>
</div>
</div>
</div>
);
})}
</>
);
};

export { RemotePointers };
4 changes: 4 additions & 0 deletions common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ export enum StreamServiceMsg {
MIC_STATE = 'V',
SPEAKER_STATE = 'W',
}

export enum PointerServiceMsg {
POINTER = 'X',
}
7 changes: 7 additions & 0 deletions common/types/pointer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Pointer type
*
* Index 0: x-coordinate
* Index 1: y-coordinate
*/
export type Pointer = [number, number];
6 changes: 6 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
});
16 changes: 16 additions & 0 deletions server/src/service/pointer-service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};

1 comment on commit 0f56d6e

@vercel
Copy link

@vercel vercel bot commented on 0f56d6e Dec 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.