rspace-online/shared/components/rstack-collab-overlay.ts

488 lines
12 KiB
TypeScript

/**
* <rstack-collab-overlay> — drop-in multiplayer presence for all rApps.
*
* Features:
* - "N online" badge (top-right pill with colored dots)
* - Remote cursors (SVG arrows with username labels, viewport-relative)
* - Focus highlighting (colored outline rings on data-collab-id elements)
* - Auto-discovery via rspace-doc-subscribe events from runtime
* - Hides on canvas page (rSpace has its own CommunitySync)
*
* Attributes:
* module-id — current module identifier
* space — current space slug
* doc-id — explicit doc ID (fallback if auto-discovery misses)
* mode — "badge-only" for iframe/proxy modules (no cursors)
*/
import type { AwarenessMessage } from '../local-first/sync';
// ── Peer color palette (same as canvas PresenceManager) ──
const PEER_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
];
interface PeerState {
peerId: string;
username: string;
color: string;
cursor: { x: number; y: number } | null;
selection: string | null;
lastSeen: number;
}
export class RStackCollabOverlay extends HTMLElement {
#shadow: ShadowRoot;
#peers = new Map<string, PeerState>();
#docId: string | null = null;
#moduleId: string | null = null;
#localPeerId: string | null = null;
#localColor = PEER_COLORS[0];
#localUsername = 'Anonymous';
#unsubAwareness: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
#badgeOnly = false;
#hidden = false; // true on canvas page
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#moduleId = this.getAttribute('module-id');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
// Hide on canvas page — it has its own CommunitySync + PresenceManager
if (this.#moduleId === 'rspace') {
this.#hidden = true;
return;
}
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
if (explicitDocId) this.#docId = explicitDocId;
// Listen for runtime doc subscriptions (auto-discovery)
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
// Resolve local identity
this.#resolveIdentity();
// Render initial (empty badge)
this.#render();
// Try connecting to runtime
this.#tryConnect();
// GC stale peers every 5s
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
}
disconnectedCallback() {
if (this.#hidden) return;
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
if (this.#gcInterval) clearInterval(this.#gcInterval);
this.#gcInterval = null;
}
// ── Auto-discovery ──
#onDocSubscribe = (e: Event) => {
const { docId } = (e as CustomEvent).detail;
if (!this.#docId && docId) {
this.#docId = docId;
this.#connectToDoc();
}
};
// ── Runtime connection ──
#tryConnect() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
this.#onRuntimeReady(runtime);
} else {
// Retry until runtime is ready
const check = setInterval(() => {
const rt = (window as any).__rspaceOfflineRuntime;
if (rt?.isInitialized) {
clearInterval(check);
this.#onRuntimeReady(rt);
}
}, 500);
// Give up after 15s
setTimeout(() => clearInterval(check), 15000);
}
}
#onRuntimeReady(runtime: any) {
this.#localPeerId = runtime.peerId;
// Assign a deterministic color from peer ID
this.#localColor = this.#colorForPeer(this.#localPeerId!);
if (this.#docId) {
this.#connectToDoc();
}
}
#connectToDoc() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
// Unsubscribe from previous
this.#unsubAwareness?.();
// Listen for remote awareness
this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => {
if (msg.peer === this.#localPeerId) return; // ignore self
this.#handleRemoteAwareness(msg);
});
// Start broadcasting local presence
if (!this.#badgeOnly) {
this.#startMouseTracking();
this.#startFocusTracking();
}
// Send initial presence (announce we're here)
this.#broadcastPresence();
}
// ── Identity ──
#resolveIdentity() {
try {
const raw = localStorage.getItem('encryptid_session');
if (raw) {
const session = JSON.parse(raw);
if (session?.username) this.#localUsername = session.username;
else if (session?.displayName) this.#localUsername = session.displayName;
}
} catch { /* no session */ }
}
// ── Remote awareness handling ──
#handleRemoteAwareness(msg: AwarenessMessage) {
const existing = this.#peers.get(msg.peer);
const peer: PeerState = {
peerId: msg.peer,
username: msg.username || existing?.username || 'Anonymous',
color: msg.color || existing?.color || this.#colorForPeer(msg.peer),
cursor: msg.cursor ?? existing?.cursor ?? null,
selection: msg.selection ?? existing?.selection ?? null,
lastSeen: Date.now(),
};
this.#peers.set(msg.peer, peer);
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
// ── Local broadcasting ──
#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime || !this.#docId) return;
runtime.sendAwareness(this.#docId, {
cursor,
selection,
username: this.#localUsername,
color: this.#localColor,
});
}
// ── Mouse tracking (15Hz throttle) ──
#mouseHandler = (e: MouseEvent) => {
this.#lastCursor = { x: e.clientX, y: e.clientY };
};
#startMouseTracking() {
document.addEventListener('mousemove', this.#mouseHandler, { passive: true });
// Broadcast at 15Hz
this.#mouseMoveTimer = setInterval(() => {
this.#broadcastPresence(this.#lastCursor, undefined);
}, 67); // ~15Hz
}
#stopMouseTracking() {
document.removeEventListener('mousemove', this.#mouseHandler);
if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer);
this.#mouseMoveTimer = null;
}
// ── Focus tracking on data-collab-id elements ──
#focusHandler = (e: FocusEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
#blurHandler = () => {
this.#broadcastPresence(undefined, '');
};
#startFocusTracking() {
document.addEventListener('focusin', this.#focusHandler, { passive: true });
document.addEventListener('click', this.#clickHandler, { passive: true });
document.addEventListener('focusout', this.#blurHandler, { passive: true });
}
#stopFocusTracking() {
document.removeEventListener('focusin', this.#focusHandler);
document.removeEventListener('click', this.#clickHandler);
document.removeEventListener('focusout', this.#blurHandler);
}
// Also track clicks on data-collab-id (many elements aren't focusable)
#clickHandler = (e: MouseEvent) => {
const target = (e.target as HTMLElement)?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
// ── GC stale peers ──
#gcPeers() {
const now = Date.now();
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > 15000) {
this.#peers.delete(id);
changed = true;
}
}
if (changed) {
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
}
// ── Color assignment ──
#colorForPeer(peerId: string): string {
let hash = 0;
for (let i = 0; i < peerId.length; i++) {
hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0;
}
return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length];
}
// ── Rendering ──
#render() {
this.#shadow.innerHTML = `
<style>${OVERLAY_CSS}</style>
<div class="collab-badge" id="badge"></div>
<div class="collab-cursors" id="cursors"></div>
`;
}
#renderBadge() {
const badge = this.#shadow.getElementById('badge');
if (!badge) return;
const count = this.#peers.size + 1; // +1 for self
if (count <= 1) {
badge.innerHTML = '';
badge.classList.remove('visible');
return;
}
const dots = Array.from(this.#peers.values())
.slice(0, 5) // show max 5 dots
.map(p => `<span class="dot" style="background:${p.color}"></span>`)
.join('');
badge.innerHTML = `
<span class="dot" style="background:${this.#localColor}"></span>
${dots}
<span class="count">${count} online</span>
`;
badge.classList.add('visible');
}
#renderCursors() {
const container = this.#shadow.getElementById('cursors');
if (!container) return;
const now = Date.now();
const fragments: string[] = [];
for (const peer of this.#peers.values()) {
if (!peer.cursor) continue;
const age = now - peer.lastSeen;
const opacity = age > 5000 ? 0.3 : 1;
fragments.push(`
<div class="cursor" style="left:${peer.cursor.x}px;top:${peer.cursor.y}px;opacity:${opacity}">
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
<path d="M1 1L6.5 18L8.5 11L15 9L1 1Z" fill="${peer.color}" stroke="white" stroke-width="1.5"/>
</svg>
<span class="cursor-label" style="background:${peer.color}">${this.#escHtml(peer.username)}</span>
</div>
`);
}
container.innerHTML = fragments.join('');
}
#renderFocusRings() {
// Remove all existing focus rings from the document
document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());
for (const peer of this.#peers.values()) {
if (!peer.selection) continue;
const target = document.querySelector(`[data-collab-id="${CSS.escape(peer.selection)}"]`);
if (!target) continue;
const rect = target.getBoundingClientRect();
const ring = document.createElement('div');
ring.className = 'rstack-collab-focus-ring';
ring.style.cssText = `
position: fixed;
left: ${rect.left - 3}px;
top: ${rect.top - 3}px;
width: ${rect.width + 6}px;
height: ${rect.height + 6}px;
border: 2px solid ${peer.color};
border-radius: 6px;
pointer-events: none;
z-index: 9998;
box-shadow: 0 0 0 1px ${peer.color}33;
transition: all 0.15s ease;
`;
// Username label on the ring
const label = document.createElement('span');
label.textContent = peer.username;
label.style.cssText = `
position: absolute;
top: -18px;
left: 4px;
font-size: 10px;
color: white;
background: ${peer.color};
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
`;
ring.appendChild(label);
document.body.appendChild(ring);
}
}
#escHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
static define() {
if (!customElements.get('rstack-collab-overlay')) {
customElements.define('rstack-collab-overlay', RStackCollabOverlay);
}
}
}
// ── Styles (inside shadow DOM) ──
const OVERLAY_CSS = `
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
z-index: 9999;
pointer-events: none;
}
.collab-badge {
position: fixed;
top: 8px;
right: 80px;
display: none;
align-items: center;
gap: 4px;
padding: 4px 10px 4px 6px;
border-radius: 16px;
background: var(--rs-bg-secondary, rgba(30, 30, 30, 0.85));
backdrop-filter: blur(8px);
font-size: 11px;
color: var(--rs-text-secondary, #ccc);
pointer-events: auto;
cursor: default;
user-select: none;
z-index: 10000;
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
}
.collab-badge.visible {
display: inline-flex;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.count {
margin-left: 2px;
font-weight: 500;
}
.collab-cursors {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.cursor {
position: fixed;
pointer-events: none;
transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease;
z-index: 9999;
}
.cursor-label {
position: absolute;
left: 14px;
top: 14px;
font-size: 10px;
color: white;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
line-height: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
`;