/** * 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(); #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 { 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 = ` ${username} `; 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); }