963 lines
26 KiB
TypeScript
963 lines
26 KiB
TypeScript
/**
|
|
* <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)
|
|
* - Solo/Share 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;
|
|
#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
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#moduleId = this.getAttribute('module-id');
|
|
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 } }));
|
|
}
|
|
|
|
// Resolve local identity
|
|
this.#resolveIdentity();
|
|
|
|
// Render initial (badge always visible)
|
|
this.#render();
|
|
this.#renderBadge();
|
|
|
|
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();
|
|
|
|
// GC stale peers every 5s
|
|
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
|
}
|
|
|
|
// 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.#stopMouseTracking();
|
|
this.#stopFocusTracking();
|
|
}
|
|
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
|
this.#gcInterval = 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 ──
|
|
|
|
#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!);
|
|
this.#connState = 'connected';
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
#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) {
|
|
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();
|
|
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();
|
|
}
|
|
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 Online</h3>
|
|
<span class="panel-count" id="panel-count">1</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 count = this.#peers.size + 1;
|
|
if (countEl) countEl.textContent = this.#connState === 'connected' ? String(count) : '\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.'
|
|
: 'Reconnecting to server\u2026';
|
|
fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
|
|
}
|
|
|
|
// Self row with Solo/Share 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 cursor sharing with other users">
|
|
<button class="mode-solo ${isSolo ? 'active' : ''}" data-action="solo">Solo</button>
|
|
<button class="mode-multi ${isSolo ? '' : 'active'}" data-action="share">Share</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)}">></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>
|
|
`);
|
|
}
|
|
}
|
|
|
|
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') {
|
|
badge.innerHTML = `
|
|
<span class="dot" style="background:#3b82f6"></span>
|
|
<span class="count">Reconnecting\u2026</span>
|
|
`;
|
|
badge.classList.add('visible');
|
|
badge.title = 'Reconnecting to server\u2026';
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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-bg-secondary, rgba(30, 30, 30, 0.85));
|
|
-webkit-backdrop-filter: blur(8px);
|
|
backdrop-filter: blur(8px);
|
|
font-size: 11px;
|
|
color: var(--rs-text-secondary, #ccc);
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
border: 1px solid var(--rs-border, rgba(255,255,255,0.08));
|
|
transition: opacity 0.2s, border-color 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.collab-badge:hover {
|
|
border-color: 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, #888);
|
|
}
|
|
|
|
.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: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px 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(255,255,255,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, #fff);
|
|
margin: 0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.panel-count {
|
|
font-size: 12px;
|
|
color: var(--rs-text-secondary, #ccc);
|
|
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06));
|
|
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, #888);
|
|
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.04));
|
|
border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,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(255,255,255,0.08));
|
|
}
|
|
|
|
.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, #fff);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.peer-context {
|
|
font-size: 11px;
|
|
color: var(--rs-text-muted, #888);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.you-tag {
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
font-weight: normal;
|
|
}
|
|
|
|
/* ── Mode toggle (Solo/Share) ── */
|
|
|
|
.mode-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
border: 1px solid var(--rs-border, rgba(255,255,255,0.12));
|
|
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, #888);
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.mode-toggle button.active {
|
|
background: var(--rs-accent, #3b82f6);
|
|
color: #fff;
|
|
}
|
|
|
|
.mode-toggle button:not(.active):hover {
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
}
|
|
|
|
/* ── Peer action buttons (canvas only) ── */
|
|
|
|
.actions-btn {
|
|
padding: 2px 8px;
|
|
border: 1px solid var(--rs-border, rgba(255,255,255,0.12));
|
|
border-radius: 6px;
|
|
background: var(--rs-bg-surface, #1e1e1e);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
color: var(--rs-text-muted, #888);
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.actions-btn:hover {
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
}
|
|
|
|
.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, #ccc);
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.people-actions button:hover {
|
|
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
}
|
|
|
|
/* ── 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);
|
|
}
|
|
}
|
|
`;
|