rspace-online/lib/presence.ts

262 lines
6.5 KiB
TypeScript

/**
* PresenceManager - Shows other users' cursors and selections in real-time
*/
export interface PresenceData {
peerId: string;
cursor?: { x: number; y: number };
selection?: string; // Selected shape ID
username?: string;
color?: string;
}
interface UserPresence extends PresenceData {
lastUpdate: number;
element: HTMLElement;
}
// Generate consistent color from peer ID
function peerIdToColor(peerId: string): string {
const colors = [
"#ef4444", // red
"#f97316", // orange
"#eab308", // yellow
"#22c55e", // green
"#14b8a6", // teal
"#3b82f6", // blue
"#8b5cf6", // violet
"#ec4899", // pink
];
let hash = 0;
for (let i = 0; i < peerId.length; i++) {
hash = (hash << 5) - hash + peerId.charCodeAt(i);
hash = hash & hash;
}
return colors[Math.abs(hash) % colors.length];
}
export class PresenceManager extends EventTarget {
#container: HTMLElement;
#users = new Map<string, UserPresence>();
#fadeTimeout = 5000; // 5 seconds
#fadeInterval: number | null = null;
#localPeerId: string;
#localUsername: string;
constructor(container: HTMLElement, peerId: string, username?: string) {
super();
this.#container = container;
this.#localPeerId = peerId;
this.#localUsername = username || `User ${peerId.slice(0, 4)}`;
// Create cursor container
this.#ensureCursorContainer();
// Start fade check interval
this.#fadeInterval = window.setInterval(() => this.#checkFades(), 1000);
}
get localPeerId() {
return this.#localPeerId;
}
get localUsername() {
return this.#localUsername;
}
set localUsername(name: string) {
this.#localUsername = name;
}
/**
* Update presence for a remote user
*/
updatePresence(data: PresenceData) {
if (data.peerId === this.#localPeerId) return; // Ignore our own presence
let user = this.#users.get(data.peerId);
if (!user) {
// Create new cursor element
user = {
...data,
lastUpdate: Date.now(),
element: this.#createCursorElement(data),
};
this.#users.set(data.peerId, user);
} else {
// Update existing user
Object.assign(user, data);
user.lastUpdate = Date.now();
}
// Update cursor position
if (data.cursor) {
user.element.style.left = `${data.cursor.x}px`;
user.element.style.top = `${data.cursor.y}px`;
user.element.style.opacity = "1";
}
// Update username label
if (data.username) {
const label = user.element.querySelector(".cursor-label");
if (label) label.textContent = data.username;
}
// Update selection highlight
this.#updateSelectionHighlight(data.peerId, data.selection);
}
/**
* Get presence data for broadcasting
*/
getLocalPresence(cursor: { x: number; y: number }, selection?: string): PresenceData {
return {
peerId: this.#localPeerId,
cursor,
selection,
username: this.#localUsername,
color: peerIdToColor(this.#localPeerId),
};
}
/**
* Remove a user's presence
*/
removeUser(peerId: string) {
const user = this.#users.get(peerId);
if (user) {
user.element.remove();
this.#users.delete(peerId);
this.#clearSelectionHighlight(peerId);
}
}
/**
* Get all current users
*/
getUsers(): Map<string, UserPresence> {
return new Map(this.#users);
}
/**
* Clean up
*/
destroy() {
if (this.#fadeInterval !== null) {
clearInterval(this.#fadeInterval);
}
for (const [peerId] of this.#users) {
this.removeUser(peerId);
}
}
#ensureCursorContainer() {
let cursorsEl = this.#container.querySelector(".presence-cursors") as HTMLElement;
if (!cursorsEl) {
cursorsEl = document.createElement("div");
cursorsEl.className = "presence-cursors";
cursorsEl.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: visible;
`;
this.#container.appendChild(cursorsEl);
}
return cursorsEl;
}
#createCursorElement(data: PresenceData): HTMLElement {
const color = data.color || peerIdToColor(data.peerId);
const username = data.username || `User ${data.peerId.slice(0, 4)}`;
const el = document.createElement("div");
el.className = "presence-cursor";
el.dataset.peerId = data.peerId;
el.style.cssText = `
position: absolute;
pointer-events: none;
transition: left 0.1s, top 0.1s, opacity 0.3s;
z-index: 10000;
`;
el.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3));">
<path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35z" fill="${color}"/>
<path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35z" stroke="white" stroke-width="1.5"/>
</svg>
<span class="cursor-label" style="
position: absolute;
left: 20px;
top: 16px;
background: ${color};
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-weight: 500;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
">${username}</span>
`;
const container = this.#ensureCursorContainer();
container.appendChild(el);
return el;
}
#updateSelectionHighlight(peerId: string, shapeId?: string) {
// Remove previous selection
this.#clearSelectionHighlight(peerId);
if (!shapeId) return;
// Find and highlight the selected shape
const shape = document.getElementById(shapeId);
if (shape) {
const user = this.#users.get(peerId);
const color = user?.color || peerIdToColor(peerId);
shape.style.outline = `2px solid ${color}`;
shape.style.outlineOffset = "2px";
shape.dataset.selectedBy = peerId;
}
}
#clearSelectionHighlight(peerId: string) {
// Find any shape selected by this peer and remove highlight
const selected = document.querySelector(`[data-selected-by="${peerId}"]`) as HTMLElement;
if (selected) {
selected.style.outline = "";
selected.style.outlineOffset = "";
delete selected.dataset.selectedBy;
}
}
#checkFades() {
const now = Date.now();
for (const [peerId, user] of this.#users) {
const elapsed = now - user.lastUpdate;
if (elapsed > this.#fadeTimeout) {
// Fade out after timeout
user.element.style.opacity = "0.3";
}
if (elapsed > this.#fadeTimeout * 3) {
// Remove completely after 3x timeout
this.removeUser(peerId);
}
}
}
}
// Generate peer ID
export function generatePeerId(): string {
return crypto.randomUUID().slice(0, 8);
}