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

1124 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <rstack-collab-overlay> — drop-in multiplayer presence for all rApps.
*
* Features:
* - "N online" badge (top-right pill with colored dots)
* - People panel dropdown (click badge to open)
* - Online/Offline mode toggle in panel
* - 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
* - Bridge API for canvas (external peer management via 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;
module?: string; // which rApp they're in
context?: string; // human-readable view label (e.g. "My Notebook > Note Title")
}
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;
#unsubPresence: (() => void) | null = null;
#unsubLeave: (() => void) | null = null;
#unsubConnect: (() => void) | null = null;
#unsubDisconnect: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
#badgeOnly = false;
#soloMode = false;
#panelOpen = false;
#connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting';
#externalPeers = false; // true for canvas — peers fed in via public API
#openActionsId: string | null = null; // which peer's actions dropdown is open
#spaceMembers: { did: string; displayName: string; role: string }[] = [];
#space: string | null = null;
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#moduleId = this.getAttribute('module-id');
this.#space = this.getAttribute('space');
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
// Canvas page — peers fed externally via bridge API, skip runtime connection
if (this.#moduleId === 'rspace') {
this.#externalPeers = true;
// Dispatch initial solo-mode event for canvas PresenceManager
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
// Canvas: load members immediately (no runtime wait)
this.#loadSpaceMembers();
}
// Resolve local identity
this.#resolveIdentity();
// Render initial (badge always visible)
this.#render();
this.#renderBadge();
// GC stale peers every 5s (all modes — prevents lingering ghost peers)
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
if (!this.#externalPeers) {
// 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);
// Try connecting to runtime
this.#tryConnect();
}
// Click-outside closes panel (listen on document, check composedPath for shadow DOM)
document.addEventListener('click', this.#onDocumentClick);
}
disconnectedCallback() {
document.removeEventListener('click', this.#onDocumentClick);
if (!this.#externalPeers) {
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#unsubPresence?.();
this.#unsubLeave?.();
this.#unsubConnect?.();
this.#unsubDisconnect?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
}
if (this.#gcInterval) clearInterval(this.#gcInterval);
this.#gcInterval = null;
if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
this.#runtimePollInterval = null;
}
// ── Public bridge API (for canvas CommunitySync) ──
updatePeer(peerId: string, username: string, color: string) {
const existing = this.#peers.get(peerId);
this.#peers.set(peerId, {
peerId,
username: username || existing?.username || 'Anonymous',
color: color || existing?.color || this.#colorForPeer(peerId),
cursor: existing?.cursor ?? null,
selection: existing?.selection ?? null,
lastSeen: Date.now(),
});
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
removePeer(peerId: string) {
this.#peers.delete(peerId);
if (this.#openActionsId === peerId) this.#openActionsId = null;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
setConnState(state: 'connected' | 'offline' | 'reconnecting' | 'connecting') {
this.#connState = state;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
clearPeers() {
this.#peers.clear();
this.#openActionsId = null;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
// ── Auto-discovery ──
#onDocSubscribe = (e: Event) => {
const { docId } = (e as CustomEvent).detail;
if (!this.#docId && docId) {
this.#docId = docId;
this.#connectToDoc();
}
};
// ── Runtime connection ──
#runtimePollInterval: ReturnType<typeof setInterval> | null = null;
#tryConnect() {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
this.#onRuntimeReady(runtime);
} else {
// Poll until runtime is ready (no timeout — WS connect can take 30s+)
let polls = 0;
this.#runtimePollInterval = setInterval(() => {
const rt = (window as any).__rspaceOfflineRuntime;
if (rt?.isInitialized) {
clearInterval(this.#runtimePollInterval!);
this.#runtimePollInterval = null;
this.#onRuntimeReady(rt);
}
// Slow down after 15s (30 polls × 500ms) to reduce overhead
polls++;
if (polls === 30 && this.#runtimePollInterval) {
clearInterval(this.#runtimePollInterval);
this.#runtimePollInterval = setInterval(() => {
const rt2 = (window as any).__rspaceOfflineRuntime;
if (rt2?.isInitialized) {
clearInterval(this.#runtimePollInterval!);
this.#runtimePollInterval = null;
this.#onRuntimeReady(rt2);
}
}, 2000);
}
}, 500);
}
}
#onRuntimeReady(runtime: any) {
this.#localPeerId = runtime.peerId;
// Assign a deterministic color from peer ID
this.#localColor = this.#colorForPeer(this.#localPeerId!);
// Set initial state from actual runtime connection status
this.#connState = runtime.isOnline ? 'connected' : 'offline';
// Track ongoing connection state changes
this.#unsubConnect = runtime.onConnect(() => this.setConnState('connected'));
this.#unsubDisconnect = runtime.onDisconnect(() => {
this.setConnState(navigator.onLine ? 'reconnecting' : 'offline');
});
// Load space members for offline display
this.#loadSpaceMembers();
if (this.#docId) {
this.#connectToDoc();
}
// Listen for space-wide presence broadcasts (module context from all rApps)
this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => {
if (!msg.username) return;
// Use peerId from server relay, or fall back to a hash of username
const pid = msg.peerId || `anon-${msg.username}`;
if (pid === this.#localPeerId) return; // ignore self
const existing = this.#peers.get(pid);
this.#peers.set(pid, {
peerId: pid,
username: msg.username || existing?.username || 'Anonymous',
color: msg.color || existing?.color || this.#colorForPeer(pid),
cursor: existing?.cursor ?? null,
selection: existing?.selection ?? null,
lastSeen: Date.now(),
module: msg.module || existing?.module,
context: msg.context || existing?.context,
});
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
});
// Listen for explicit leave signals (immediate cleanup, no GC wait)
this.#unsubLeave = runtime.onCustomMessage('presence-leave', (msg: any) => {
const pid = msg.peerId;
if (!pid || pid === this.#localPeerId) return;
if (this.#peers.has(pid)) {
this.#peers.delete(pid);
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
if (this.#panelOpen) this.#renderPanel();
}
});
}
async #loadSpaceMembers(retry = true) {
let members: Record<string, { role: string; displayName?: string }> = {};
// Try CRDT doc first (same pattern as rstack-space-settings)
const sync = (window as any).__communitySync;
if (sync?.doc?.members) {
members = sync.doc.members;
} else if (this.#space) {
// Fallback: fetch from API
const host = window.location.hostname;
const isSubdomain = host.split('.').length >= 3 && !host.startsWith('www.');
const metaUrl = isSubdomain ? '/rspace/api/meta' : `/${this.#space}/rspace/api/meta`;
try {
const token = localStorage.getItem('encryptid_token');
const res = await fetch(metaUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) {
const json = await res.json();
if (json.meta?.members) members = json.meta.members;
}
} catch { /* offline or error */ }
}
const dids = Object.keys(members);
if (!dids.length) {
// CRDT doc may not be synced yet — retry once after 5s
if (retry) setTimeout(() => this.#loadSpaceMembers(false), 5000);
return;
}
// Resolve display names via EncryptID
let resolved: Record<string, { username: string; displayName: string }> = {};
try {
const res = await fetch('/api/users/resolve-dids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dids }),
});
if (res.ok) resolved = await res.json();
} catch { /* offline */ }
this.#spaceMembers = dids.map(did => ({
did,
displayName: resolved[did]?.displayName || resolved[did]?.username || members[did].displayName || did.slice(0, 12),
role: members[did].role || 'member',
}));
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
#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?.claims?.username) this.#localUsername = session.claims.username;
else if (session?.username) this.#localUsername = session.username;
}
// Fallback: rstack-identity also stores username separately
if (this.#localUsername === 'Anonymous') {
const stored = localStorage.getItem('rspace-username');
if (stored) this.#localUsername = stored;
}
} catch { /* no session */ }
}
// ── Remote awareness handling ──
#handleRemoteAwareness(msg: AwarenessMessage) {
if (this.#soloMode) return; // suppress incoming awareness in solo mode
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) {
if (this.#soloMode) return; // suppress outgoing awareness in solo mode
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 real = (e.composedPath()[0] as HTMLElement);
const target = real?.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 real = (e.composedPath()[0] as HTMLElement);
const target = real?.closest?.('[data-collab-id]');
if (target) {
const collabId = target.getAttribute('data-collab-id');
if (collabId) this.#broadcastPresence(undefined, collabId);
}
};
// ── Click-outside closes panel ──
#onDocumentClick = (e: MouseEvent) => {
if (!this.#panelOpen) return;
// Check if click was inside our shadow DOM
const path = e.composedPath();
if (path.includes(this)) return;
this.#panelOpen = false;
this.#shadow.getElementById('people-panel')?.classList.remove('open');
};
// ── GC stale peers ──
#gcPeers() {
const now = Date.now();
const staleThreshold = this.#externalPeers ? 30000 : 15000;
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > staleThreshold) {
this.#peers.delete(id);
changed = true;
}
}
if (changed) {
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
if (this.#panelOpen) this.#renderPanel();
}
}
// ── 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 visible" id="badge"></div>
<div class="people-panel" id="people-panel">
<div class="people-panel-header">
<h3>People</h3>
<span class="panel-count" id="panel-count">1 online</span>
</div>
<div class="people-list" id="people-list"></div>
</div>
<div class="collab-cursors" id="cursors"></div>
`;
this.#shadow.getElementById('badge')?.addEventListener('click', (e) => {
e.stopPropagation();
this.#togglePanel();
});
}
// ── Panel toggle ──
#togglePanel() {
this.#panelOpen = !this.#panelOpen;
const panel = this.#shadow.getElementById('people-panel');
if (this.#panelOpen) {
panel?.classList.add('open');
this.#renderPanel();
} else {
panel?.classList.remove('open');
}
}
// ── Panel rendering ──
#renderPanel() {
const list = this.#shadow.getElementById('people-list');
const countEl = this.#shadow.getElementById('panel-count');
if (!list) return;
const onlineCount = this.#peers.size + 1;
if (countEl) countEl.textContent = this.#connState === 'connected' ? `${onlineCount} online` : '\u2014';
const fragments: string[] = [];
// Connection state notice
if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') {
const msg = this.#connState === 'offline'
? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.'
: this.#connState === 'connecting' ? 'Connecting to server\u2026' : 'Reconnecting to server\u2026';
fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
}
// Self row with Online/Offline toggle
const isSolo = this.#soloMode;
const selfModule = this.#moduleId || '';
fragments.push(`
<div class="people-row">
<span class="dot" style="background:${this.#localColor}"></span>
<div class="name-block">
<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></span>
${selfModule ? `<span class="peer-context">${this.#escHtml(selfModule)}</span>` : ''}
</div>
<span class="mode-toggle" title="Toggle your online presence">
<button class="mode-solo ${isSolo ? '' : 'active'}" data-action="share">Online</button>
<button class="mode-multi ${isSolo ? 'active' : ''}" data-action="solo">Offline</button>
</span>
</div>
`);
// Remote peer rows
const isCanvas = this.#moduleId === 'rspace';
for (const [pid, peer] of this.#peers) {
const ctxParts: string[] = [];
if (peer.module) ctxParts.push(peer.module);
if (peer.context) ctxParts.push(peer.context);
const ctxStr = ctxParts.join(' · ');
fragments.push(`
<div class="people-row">
<span class="dot" style="background:${peer.color}"></span>
<div class="name-block">
<span class="name">${this.#escHtml(peer.username)}</span>
${ctxStr ? `<span class="peer-context">${this.#escHtml(ctxStr)}</span>` : ''}
</div>
${isCanvas ? `<button class="actions-btn" data-pid="${this.#escHtml(pid)}">&gt;</button>` : ''}
</div>
`);
// Expanded actions for canvas
if (isCanvas && this.#openActionsId === pid) {
fragments.push(`
<div class="people-actions">
<button data-action="navigate" data-pid="${this.#escHtml(pid)}">Navigate to</button>
<button data-action="ping" data-pid="${this.#escHtml(pid)}">Ping to join you</button>
</div>
`);
}
}
// Offline space members (not currently online)
if (this.#spaceMembers.length > 0) {
// Build set of online usernames (case-insensitive) for cross-referencing
const onlineNames = new Set<string>();
onlineNames.add(this.#localUsername.toLowerCase());
for (const peer of this.#peers.values()) {
onlineNames.add(peer.username.toLowerCase());
}
const offlineMembers = this.#spaceMembers.filter(
m => !onlineNames.has(m.displayName.toLowerCase())
);
if (offlineMembers.length > 0) {
fragments.push(`<div class="section-divider">Offline</div>`);
for (const m of offlineMembers) {
fragments.push(`
<div class="people-row offline">
<span class="dot"></span>
<div class="name-block">
<span class="name">${this.#escHtml(m.displayName)}</span>
<span class="peer-context">${this.#escHtml(m.role)}</span>
</div>
</div>
`);
}
}
}
list.innerHTML = fragments.join('');
// Wire up event delegation on the list
list.onclick = (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const action = btn.dataset.action;
const pid = btn.dataset.pid;
if (action === 'solo') {
this.#setSoloMode(true);
} else if (action === 'share') {
this.#setSoloMode(false);
} else if (btn.classList.contains('actions-btn') && pid) {
this.#openActionsId = this.#openActionsId === pid ? null : pid;
this.#renderPanel();
} else if ((action === 'navigate' || action === 'ping') && pid) {
this.dispatchEvent(new CustomEvent('collab-peer-action', {
bubbles: true,
composed: true,
detail: { action, peerId: pid },
}));
}
};
}
// ── Solo mode ──
#setSoloMode(solo: boolean) {
this.#soloMode = solo;
localStorage.setItem('rspace_solo_mode', solo ? '1' : '0');
if (solo) {
// Clear remote peers and their visual artifacts (for non-canvas modules)
if (!this.#externalPeers) {
this.#peers.clear();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
}
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
// Notify canvas PresenceManager and any other listeners
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo } }));
}
#renderBadge() {
const badge = this.#shadow.getElementById('badge');
if (!badge) return;
if (this.#soloMode) {
badge.innerHTML = `<span class="count solo">\u{1F4F4} Offline</span>`;
badge.classList.add('visible', 'solo');
badge.title = 'Offline \u2014 your presence is hidden. Click to go online.';
return;
}
badge.classList.remove('solo');
if (this.#connState === 'offline') {
badge.innerHTML = `
<span class="dot" style="background:#f59e0b"></span>
<span class="count">Offline</span>
`;
badge.classList.add('visible');
badge.title = 'You\u2019re offline \u2014 changes saved locally.';
return;
}
if (this.#connState === 'reconnecting' || this.#connState === 'connecting') {
const label = this.#connState === 'connecting' ? 'Connecting\u2026' : 'Reconnecting\u2026';
badge.innerHTML = `
<span class="dot" style="background:#3b82f6"></span>
<span class="count">${label}</span>
`;
badge.classList.add('visible');
badge.title = label;
return;
}
const count = this.#peers.size + 1; // +1 for self
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">\u{1F465} ${count} online</span>
`;
badge.classList.add('visible');
badge.title = `${count} online \u2014 click to see people`;
}
#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('');
}
/** Find a data-collab-id element, searching into shadow roots. */
#findCollabEl(id: string): Element | null {
const sel = `[data-collab-id="${CSS.escape(id)}"]`;
const found = document.querySelector(sel);
if (found) return found;
// Walk into shadow roots (one level deep — rApp components)
for (const el of document.querySelectorAll('*')) {
if (el.shadowRoot) {
const inner = el.shadowRoot.querySelector(sel);
if (inner) return inner;
}
}
return null;
}
#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 = this.#findCollabEl(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: inline-flex;
align-items: center;
pointer-events: none;
position: relative;
}
.collab-badge {
display: none;
align-items: center;
gap: 4px;
padding: 4px 10px 4px 6px;
border-radius: 16px;
background: var(--rs-glass-bg, rgba(15, 23, 42, 0.85));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
font-size: 11px;
color: var(--rs-text-secondary, #94a3b8);
pointer-events: auto;
cursor: pointer;
user-select: none;
border: 1px solid var(--rs-glass-border, rgba(255,255,255,0.08));
transition: opacity 0.2s, border-color 0.2s;
white-space: nowrap;
}
.collab-badge:hover {
border-color: var(--rs-border-strong, rgba(255,255,255,0.2));
}
.collab-badge.visible {
display: inline-flex;
}
.collab-badge.solo {
opacity: 0.7;
}
.count.solo {
color: var(--rs-text-muted, #64748b);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.count {
margin-left: 2px;
font-weight: 500;
}
/* ── People panel dropdown ── */
.people-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 280px;
max-height: calc(100vh - 120px);
background: var(--rs-bg-surface, #1e1e1e);
border-radius: 12px;
box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.4)), 0 0 0 1px var(--rs-glass-border, rgba(255,255,255,0.08));
z-index: 10001;
overflow: hidden;
pointer-events: auto;
flex-direction: column;
}
.people-panel.open {
display: flex;
}
.people-panel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06));
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.people-panel-header h3 {
font-size: 14px;
color: var(--rs-text-primary, #0f172a);
margin: 0;
font-weight: 600;
}
.panel-count {
font-size: 12px;
color: var(--rs-text-secondary, #374151);
background: var(--rs-bg-surface-raised, #f0efe9);
padding: 2px 8px;
border-radius: 10px;
}
.people-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.conn-notice {
padding: 8px 16px;
font-size: 12px;
color: var(--rs-text-muted, #64748b);
background: var(--rs-bg-surface-raised, #f0efe9);
border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06));
}
.people-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.people-row:hover {
background: var(--rs-bg-hover, rgba(0,0,0,0.04));
}
.people-row .dot {
width: 10px;
height: 10px;
}
.people-row .name-block {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.people-row .name {
font-size: 13px;
color: var(--rs-text-primary, #0f172a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.peer-context {
font-size: 11px;
color: var(--rs-text-muted, #64748b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.you-tag {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
/* ── Offline members ── */
.section-divider {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #64748b);
padding: 8px 10px 4px;
margin-top: 4px;
}
.people-row.offline .dot {
background: #6b7280 !important;
}
.people-row.offline .name {
color: var(--rs-text-muted, #64748b);
}
/* ── Mode toggle (Online/Offline) ── */
.mode-toggle {
display: flex;
align-items: center;
gap: 0;
border: 1px solid var(--rs-border, rgba(0,0,0,0.1));
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.mode-toggle button {
padding: 3px 8px;
border: none;
background: none;
font-size: 11px;
color: var(--rs-text-muted, #64748b);
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.mode-toggle button.active {
background: var(--rs-accent, #14b8a6);
color: #fff;
}
.mode-toggle button:not(.active):hover {
background: var(--rs-bg-hover, rgba(0,0,0,0.04));
}
/* ── Peer action buttons (canvas only) ── */
.actions-btn {
padding: 2px 8px;
border: 1px solid var(--rs-border, rgba(0,0,0,0.1));
border-radius: 6px;
background: var(--rs-bg-surface-raised, #334155);
cursor: pointer;
font-size: 12px;
color: var(--rs-text-muted, #64748b);
transition: background 0.15s;
}
.actions-btn:hover {
background: var(--rs-bg-hover, rgba(0,0,0,0.04));
}
.people-actions {
padding: 4px 8px 8px 30px;
}
.people-actions button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
text-align: left;
font-size: 12px;
color: var(--rs-text-secondary, #374151);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.people-actions button:hover {
background: var(--rs-bg-hover, rgba(0,0,0,0.04));
}
/* ── Cursors ── */
.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;
}
/* ── Mobile ── */
@media (max-width: 640px) {
.count {
display: none;
}
.people-panel {
max-width: calc(100vw - 32px);
}
}
`;