262 lines
6.5 KiB
TypeScript
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);
|
|
}
|