feat(collab): unify header & tab-row — shared people-panel across all rApps
Canvas now uses the same rstack-collab-overlay component as all other rApps instead of its own custom #people-online-badge. Header restructured to match renderShell() layout (history/settings in dropdown-wraps on left). Bridge API (updatePeer/removePeer/setConnState/clearPeers) lets canvas feed CommunitySync peers into the shared component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e5466491c7
commit
8f1ad52557
|
|
@ -3,10 +3,12 @@
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - "N online" badge (top-right pill with colored dots)
|
* - "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)
|
* - Remote cursors (SVG arrows with username labels, viewport-relative)
|
||||||
* - Focus highlighting (colored outline rings on data-collab-id elements)
|
* - Focus highlighting (colored outline rings on data-collab-id elements)
|
||||||
* - Auto-discovery via rspace-doc-subscribe events from runtime
|
* - Auto-discovery via rspace-doc-subscribe events from runtime
|
||||||
* - Hides on canvas page (rSpace has its own CommunitySync)
|
* - Bridge API for canvas (external peer management via CommunitySync)
|
||||||
*
|
*
|
||||||
* Attributes:
|
* Attributes:
|
||||||
* module-id — current module identifier
|
* module-id — current module identifier
|
||||||
|
|
@ -45,8 +47,11 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
#lastCursor = { x: 0, y: 0 };
|
#lastCursor = { x: 0, y: 0 };
|
||||||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
#badgeOnly = false;
|
#badgeOnly = false;
|
||||||
#hidden = false; // true on canvas page
|
|
||||||
#soloMode = 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -58,21 +63,13 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
|
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
|
||||||
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
|
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
|
||||||
|
|
||||||
// Hide on canvas page — it has its own CommunitySync + PresenceManager
|
// Canvas page — peers fed externally via bridge API, skip runtime connection
|
||||||
if (this.#moduleId === 'rspace') {
|
if (this.#moduleId === 'rspace') {
|
||||||
this.#hidden = true;
|
this.#externalPeers = true;
|
||||||
// Still dispatch initial solo-mode event for canvas PresenceManager
|
// Dispatch initial solo-mode event for canvas PresenceManager
|
||||||
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
|
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
|
||||||
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
|
// Resolve local identity
|
||||||
this.#resolveIdentity();
|
this.#resolveIdentity();
|
||||||
|
|
||||||
|
|
@ -80,23 +77,73 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#render();
|
this.#render();
|
||||||
this.#renderBadge();
|
this.#renderBadge();
|
||||||
|
|
||||||
// Try connecting to runtime
|
if (!this.#externalPeers) {
|
||||||
this.#tryConnect();
|
// Explicit doc-id attribute (fallback)
|
||||||
|
const explicitDocId = this.getAttribute('doc-id');
|
||||||
|
if (explicitDocId) this.#docId = explicitDocId;
|
||||||
|
|
||||||
// GC stale peers every 5s
|
// Listen for runtime doc subscriptions (auto-discovery)
|
||||||
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
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() {
|
disconnectedCallback() {
|
||||||
if (this.#hidden) return;
|
document.removeEventListener('click', this.#onDocumentClick);
|
||||||
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
if (!this.#externalPeers) {
|
||||||
this.#unsubAwareness?.();
|
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
||||||
this.#stopMouseTracking();
|
this.#unsubAwareness?.();
|
||||||
this.#stopFocusTracking();
|
this.#stopMouseTracking();
|
||||||
|
this.#stopFocusTracking();
|
||||||
|
}
|
||||||
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
if (this.#gcInterval) clearInterval(this.#gcInterval);
|
||||||
this.#gcInterval = null;
|
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 ──
|
// ── Auto-discovery ──
|
||||||
|
|
||||||
#onDocSubscribe = (e: Event) => {
|
#onDocSubscribe = (e: Event) => {
|
||||||
|
|
@ -131,6 +178,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#localPeerId = runtime.peerId;
|
this.#localPeerId = runtime.peerId;
|
||||||
// Assign a deterministic color from peer ID
|
// Assign a deterministic color from peer ID
|
||||||
this.#localColor = this.#colorForPeer(this.#localPeerId!);
|
this.#localColor = this.#colorForPeer(this.#localPeerId!);
|
||||||
|
this.#connState = 'connected';
|
||||||
|
|
||||||
if (this.#docId) {
|
if (this.#docId) {
|
||||||
this.#connectToDoc();
|
this.#connectToDoc();
|
||||||
|
|
@ -266,6 +314,17 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 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 ──
|
// ── GC stale peers ──
|
||||||
|
|
||||||
#gcPeers() {
|
#gcPeers() {
|
||||||
|
|
@ -283,6 +342,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#renderCursors();
|
this.#renderCursors();
|
||||||
this.#renderFocusRings();
|
this.#renderFocusRings();
|
||||||
}
|
}
|
||||||
|
if (this.#panelOpen) this.#renderPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,28 +362,136 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#shadow.innerHTML = `
|
this.#shadow.innerHTML = `
|
||||||
<style>${OVERLAY_CSS}</style>
|
<style>${OVERLAY_CSS}</style>
|
||||||
<div class="collab-badge visible" id="badge"></div>
|
<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>
|
<div class="collab-cursors" id="cursors"></div>
|
||||||
`;
|
`;
|
||||||
this.#shadow.getElementById('badge')?.addEventListener('click', () => this.#toggleSoloMode());
|
this.#shadow.getElementById('badge')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.#togglePanel();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#toggleSoloMode() {
|
// ── Panel toggle ──
|
||||||
this.#soloMode = !this.#soloMode;
|
|
||||||
localStorage.setItem('rspace_solo_mode', this.#soloMode ? '1' : '0');
|
|
||||||
|
|
||||||
if (this.#soloMode) {
|
#togglePanel() {
|
||||||
// Clear remote peers and their visual artifacts
|
this.#panelOpen = !this.#panelOpen;
|
||||||
this.#peers.clear();
|
const panel = this.#shadow.getElementById('people-panel');
|
||||||
if (!this.#badgeOnly) {
|
if (this.#panelOpen) {
|
||||||
this.#renderCursors();
|
panel?.classList.add('open');
|
||||||
this.#renderFocusRings();
|
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;
|
||||||
|
fragments.push(`
|
||||||
|
<div class="people-row">
|
||||||
|
<span class="dot" style="background:${this.#localColor}"></span>
|
||||||
|
<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></span>
|
||||||
|
<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) {
|
||||||
|
fragments.push(`
|
||||||
|
<div class="people-row">
|
||||||
|
<span class="dot" style="background:${peer.color}"></span>
|
||||||
|
<span class="name">${this.#escHtml(peer.username)}</span>
|
||||||
|
${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();
|
this.#renderBadge();
|
||||||
|
if (this.#panelOpen) this.#renderPanel();
|
||||||
|
|
||||||
// Notify canvas PresenceManager and any other listeners
|
// Notify canvas PresenceManager and any other listeners
|
||||||
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
|
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#renderBadge() {
|
#renderBadge() {
|
||||||
|
|
@ -338,6 +506,27 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
badge.classList.remove('solo');
|
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 count = this.#peers.size + 1; // +1 for self
|
||||||
|
|
||||||
const dots = Array.from(this.#peers.values())
|
const dots = Array.from(this.#peers.values())
|
||||||
|
|
@ -351,7 +540,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
<span class="count">\u{1F465} ${count} online</span>
|
<span class="count">\u{1F465} ${count} online</span>
|
||||||
`;
|
`;
|
||||||
badge.classList.add('visible');
|
badge.classList.add('visible');
|
||||||
badge.title = 'Online \u2014 sharing your presence. Click to go offline.';
|
badge.title = `${count} online \u2014 click to see people`;
|
||||||
}
|
}
|
||||||
|
|
||||||
#renderCursors() {
|
#renderCursors() {
|
||||||
|
|
@ -494,6 +683,172 @@ const OVERLAY_CSS = `
|
||||||
font-weight: 500;
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--rs-text-primary, #fff);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.collab-cursors {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -524,4 +879,15 @@ const OVERLAY_CSS = `
|
||||||
line-height: 14px;
|
line-height: 14px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -959,201 +959,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── People Online badge ── */
|
|
||||||
#people-online-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rs-text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin: 0 8px 0 4px;
|
|
||||||
position: relative;
|
|
||||||
border-left: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.08));
|
|
||||||
padding-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-online-badge:hover {
|
|
||||||
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#people-dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 3px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-dots .dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── People Panel ── */
|
|
||||||
#people-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
width: 280px;
|
|
||||||
max-height: calc(100vh - 120px);
|
|
||||||
background: var(--rs-bg-surface);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--rs-shadow-lg);
|
|
||||||
z-index: 10001;
|
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-panel.open {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-panel-header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--rs-toolbar-panel-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-panel-header h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--rs-text-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-panel-header .count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rs-text-secondary);
|
|
||||||
background: var(--rs-bg-surface-raised);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#people-list {
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-row .dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-row .name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--rs-text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-row .name .you-tag {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mode toggle (single/multi player) ── */
|
|
||||||
.mode-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
border: 1px solid var(--rs-input-border);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-row .actions-btn {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border: 1px solid var(--rs-input-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--rs-bg-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--rs-text-muted);
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-row .actions-btn:hover {
|
|
||||||
background: var(--rs-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-actions button:hover:not(:disabled) {
|
|
||||||
background: var(--rs-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.people-actions button:disabled {
|
|
||||||
color: var(--rs-text-muted);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Multiplayer notification toast ── */
|
/* ── Multiplayer notification toast ── */
|
||||||
#mp-notify {
|
#mp-notify {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -1241,21 +1046,6 @@
|
||||||
|
|
||||||
/* Dark/light mode handled by CSS custom properties in theme.css */
|
/* Dark/light mode handled by CSS custom properties in theme.css */
|
||||||
|
|
||||||
/* ── People panel mobile ── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
#people-online-badge {
|
|
||||||
margin-left: 2px;
|
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
|
||||||
#people-badge-text {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
#people-panel {
|
|
||||||
max-width: calc(100vw - 32px);
|
|
||||||
right: -8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#canvas-content {
|
#canvas-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -1957,7 +1747,14 @@
|
||||||
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
|
||||||
<rstack-app-switcher current="rspace"></rstack-app-switcher>
|
<rstack-app-switcher current="rspace"></rstack-app-switcher>
|
||||||
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
<rstack-space-switcher current="" name=""></rstack-space-switcher>
|
||||||
<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
|
<div class="rstack-header__dropdown-wrap">
|
||||||
|
<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
|
||||||
|
<rstack-history-panel type="canvas"></rstack-history-panel>
|
||||||
|
</div>
|
||||||
|
<div class="rstack-header__dropdown-wrap">
|
||||||
|
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||||
|
<rstack-space-settings space="" module-id="rspace"></rstack-space-settings>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rstack-header__center">
|
<div class="rstack-header__center">
|
||||||
<rstack-mi></rstack-mi>
|
<rstack-mi></rstack-mi>
|
||||||
|
|
@ -1967,25 +1764,13 @@
|
||||||
<rstack-comment-bell></rstack-comment-bell>
|
<rstack-comment-bell></rstack-comment-bell>
|
||||||
<rstack-notification-bell></rstack-notification-bell>
|
<rstack-notification-bell></rstack-notification-bell>
|
||||||
<rstack-share-panel></rstack-share-panel>
|
<rstack-share-panel></rstack-share-panel>
|
||||||
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
||||||
<rstack-identity></rstack-identity>
|
<rstack-identity></rstack-identity>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<rstack-space-settings></rstack-space-settings>
|
|
||||||
<rstack-history-panel type="canvas"></rstack-history-panel>
|
|
||||||
<div class="rstack-tab-row" data-theme="dark">
|
<div class="rstack-tab-row" data-theme="dark">
|
||||||
<rstack-tab-bar space="" active="" view-mode="flat"></rstack-tab-bar>
|
<rstack-tab-bar space="" active="" view-mode="flat">
|
||||||
<div id="people-online-badge">
|
<rstack-collab-overlay module-id="rspace" space=""></rstack-collab-overlay>
|
||||||
<span class="dots" id="people-dots"></span>
|
</rstack-tab-bar>
|
||||||
<span id="people-badge-text">1 online</span>
|
|
||||||
</div>
|
|
||||||
<div id="people-panel">
|
|
||||||
<div id="people-panel-header">
|
|
||||||
<h3>People Online</h3>
|
|
||||||
<span class="count" id="people-count">0</span>
|
|
||||||
</div>
|
|
||||||
<div id="people-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="community-info">
|
<div id="community-info">
|
||||||
<h2 id="community-name">Loading...</h2>
|
<h2 id="community-name">Loading...</h2>
|
||||||
|
|
@ -2738,6 +2523,8 @@
|
||||||
|
|
||||||
if (tabBar) {
|
if (tabBar) {
|
||||||
tabBar.setAttribute("space", communitySlug);
|
tabBar.setAttribute("space", communitySlug);
|
||||||
|
document.querySelector("rstack-collab-overlay")?.setAttribute("space", communitySlug);
|
||||||
|
document.querySelector("rstack-space-settings")?.setAttribute("space", communitySlug);
|
||||||
|
|
||||||
// Helper: look up a module's display name
|
// Helper: look up a module's display name
|
||||||
function getModuleLabel(id) {
|
function getModuleLabel(id) {
|
||||||
|
|
@ -3081,8 +2868,7 @@
|
||||||
document.getElementById("canvas-loading")?.remove();
|
document.getElementById("canvas-loading")?.remove();
|
||||||
status.className = "offline";
|
status.className = "offline";
|
||||||
statusText.textContent = "Offline (cached)";
|
statusText.textContent = "Offline (cached)";
|
||||||
connState = "offline";
|
collabOverlay?.setConnState("offline");
|
||||||
renderPeopleBadge();
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[Canvas] Offline cache init failed:", e);
|
console.warn("[Canvas] Offline cache init failed:", e);
|
||||||
|
|
@ -3365,16 +3151,10 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── People Online panel ──
|
// ── People Online (bridged to rstack-collab-overlay) ──
|
||||||
const onlinePeers = new Map(); // clientPeerId → { username, color, lastCursor? }
|
const onlinePeers = new Map(); // clientPeerId → { username, color, lastCursor? }
|
||||||
const localColor = peerIdToColor(peerId);
|
const localColor = peerIdToColor(peerId);
|
||||||
const peoplePanel = document.getElementById("people-panel");
|
const collabOverlay = document.querySelector("rstack-collab-overlay");
|
||||||
const peopleBadge = document.getElementById("people-online-badge");
|
|
||||||
const peopleDots = document.getElementById("people-dots");
|
|
||||||
const peopleBadgeText = document.getElementById("people-badge-text");
|
|
||||||
const peopleCount = document.getElementById("people-count");
|
|
||||||
const peopleList = document.getElementById("people-list");
|
|
||||||
let connState = "connecting"; // "connected" | "offline" | "reconnecting" | "connecting"
|
|
||||||
const pingToast = document.getElementById("ping-toast");
|
const pingToast = document.getElementById("ping-toast");
|
||||||
const pingToastText = document.getElementById("ping-toast-text");
|
const pingToastText = document.getElementById("ping-toast-text");
|
||||||
const pingToastGo = document.getElementById("ping-toast-go");
|
const pingToastGo = document.getElementById("ping-toast-go");
|
||||||
|
|
@ -3383,7 +3163,6 @@
|
||||||
const mpNotifySwitch = document.getElementById("mp-notify-switch");
|
const mpNotifySwitch = document.getElementById("mp-notify-switch");
|
||||||
let pingToastTimer = null;
|
let pingToastTimer = null;
|
||||||
let mpNotifyTimer = null;
|
let mpNotifyTimer = null;
|
||||||
let openActionsId = null; // which peer's actions dropdown is open
|
|
||||||
|
|
||||||
// ── Single / Multiplayer mode ──
|
// ── Single / Multiplayer mode ──
|
||||||
let isMultiplayer = localStorage.getItem("rspace-multiplayer") !== "false"; // default true
|
let isMultiplayer = localStorage.getItem("rspace-multiplayer") !== "false"; // default true
|
||||||
|
|
@ -3391,12 +3170,14 @@
|
||||||
function setMultiplayerMode(enabled) {
|
function setMultiplayerMode(enabled) {
|
||||||
isMultiplayer = enabled;
|
isMultiplayer = enabled;
|
||||||
localStorage.setItem("rspace-multiplayer", enabled ? "true" : "false");
|
localStorage.setItem("rspace-multiplayer", enabled ? "true" : "false");
|
||||||
// Show/hide remote cursors
|
|
||||||
presence.setVisible(enabled);
|
presence.setVisible(enabled);
|
||||||
renderPeopleBadge();
|
|
||||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for solo-mode-change from the collab overlay
|
||||||
|
document.addEventListener("solo-mode-change", (e) => {
|
||||||
|
setMultiplayerMode(!e.detail.solo);
|
||||||
|
});
|
||||||
|
|
||||||
mpNotifySwitch.addEventListener("click", () => {
|
mpNotifySwitch.addEventListener("click", () => {
|
||||||
setMultiplayerMode(true);
|
setMultiplayerMode(true);
|
||||||
mpNotify.classList.remove("show");
|
mpNotify.classList.remove("show");
|
||||||
|
|
@ -3404,7 +3185,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function showMpNotification(username) {
|
function showMpNotification(username) {
|
||||||
if (isMultiplayer) return; // already in multiplayer
|
if (isMultiplayer) return;
|
||||||
mpNotifyText.textContent = `${username} is online — switch to multiplayer to collaborate`;
|
mpNotifyText.textContent = `${username} is online — switch to multiplayer to collaborate`;
|
||||||
mpNotify.classList.add("show");
|
mpNotify.classList.add("show");
|
||||||
if (mpNotifyTimer) clearTimeout(mpNotifyTimer);
|
if (mpNotifyTimer) clearTimeout(mpNotifyTimer);
|
||||||
|
|
@ -3417,122 +3198,20 @@
|
||||||
// Announce ourselves
|
// Announce ourselves
|
||||||
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
|
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
|
||||||
|
|
||||||
function renderPeopleBadge() {
|
function navigateToPeer(cursor) {
|
||||||
const totalCount = onlinePeers.size + 1; // +1 for self
|
const rect = canvas.getBoundingClientRect();
|
||||||
peopleDots.innerHTML = "";
|
panX = rect.width / 2 - cursor.x * scale;
|
||||||
|
panY = rect.height / 2 - cursor.y * scale;
|
||||||
if (connState === "offline") {
|
updateCanvasTransform();
|
||||||
// Actually disconnected from internet
|
|
||||||
peopleBadgeText.textContent = "Offline";
|
|
||||||
peopleBadge.title = "You\u2019re offline \u2014 your changes are saved locally and will resync when you reconnect to the internet.";
|
|
||||||
const selfDot = document.createElement("span");
|
|
||||||
selfDot.className = "dot";
|
|
||||||
selfDot.style.background = "#f59e0b";
|
|
||||||
peopleDots.appendChild(selfDot);
|
|
||||||
} else if (connState === "reconnecting" || connState === "connecting") {
|
|
||||||
peopleBadgeText.textContent = "Reconnecting\u2026";
|
|
||||||
peopleBadge.title = "Reconnecting to the server \u2014 your changes are saved locally and will resync automatically.";
|
|
||||||
const selfDot = document.createElement("span");
|
|
||||||
selfDot.className = "dot";
|
|
||||||
selfDot.style.background = "#3b82f6";
|
|
||||||
peopleDots.appendChild(selfDot);
|
|
||||||
} else {
|
|
||||||
// Connected
|
|
||||||
peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`;
|
|
||||||
peopleBadge.title = "";
|
|
||||||
// Self dot
|
|
||||||
const selfDot = document.createElement("span");
|
|
||||||
selfDot.className = "dot";
|
|
||||||
selfDot.style.background = localColor;
|
|
||||||
peopleDots.appendChild(selfDot);
|
|
||||||
// Remote dots (up to 4)
|
|
||||||
let dotCount = 0;
|
|
||||||
for (const [, peer] of onlinePeers) {
|
|
||||||
if (dotCount >= 4) break;
|
|
||||||
const dot = document.createElement("span");
|
|
||||||
dot.className = "dot";
|
|
||||||
dot.style.background = peer.color || "#94a3b8";
|
|
||||||
peopleDots.appendChild(dot);
|
|
||||||
dotCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
peopleCount.textContent = connState === "connected" ? totalCount : "\u2014";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPeoplePanel() {
|
// Handle peer actions from collab overlay (navigate/ping)
|
||||||
peopleList.innerHTML = "";
|
collabOverlay?.addEventListener("collab-peer-action", (e) => {
|
||||||
// Show offline notice if disconnected
|
const { action, peerId: pid } = e.detail;
|
||||||
if (connState === "offline" || connState === "reconnecting" || connState === "connecting") {
|
if (action === "navigate") {
|
||||||
const notice = document.createElement("div");
|
|
||||||
notice.style.cssText = "padding:8px 16px;font-size:12px;color:var(--rs-text-muted);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))";
|
|
||||||
notice.textContent = connState === "offline"
|
|
||||||
? "\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect."
|
|
||||||
: "Reconnecting to server\u2026";
|
|
||||||
peopleList.appendChild(notice);
|
|
||||||
}
|
|
||||||
// Self row with cursor visibility toggle
|
|
||||||
const selfRow = document.createElement("div");
|
|
||||||
selfRow.className = "people-row";
|
|
||||||
selfRow.innerHTML = `<span class="dot" style="background:${escapeHtml(localColor)}"></span>
|
|
||||||
<span class="name">${escapeHtml(storedUsername)} <span class="you-tag">(you)</span></span>
|
|
||||||
<span class="mode-toggle" title="Toggle cursor sharing with other users">
|
|
||||||
<button class="mode-solo ${isMultiplayer ? '' : 'active'}">Solo</button>
|
|
||||||
<button class="mode-multi ${isMultiplayer ? 'active' : ''}">Share</button>
|
|
||||||
</span>`;
|
|
||||||
selfRow.querySelector(".mode-solo").addEventListener("click", () => setMultiplayerMode(false));
|
|
||||||
selfRow.querySelector(".mode-multi").addEventListener("click", () => setMultiplayerMode(true));
|
|
||||||
peopleList.appendChild(selfRow);
|
|
||||||
// Remote peers
|
|
||||||
for (const [pid, peer] of onlinePeers) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "people-row";
|
|
||||||
row.innerHTML = `<span class="dot" style="background:${escapeHtml(peer.color || '#94a3b8')}"></span>
|
|
||||||
<span class="name">${escapeHtml(peer.username)}</span>
|
|
||||||
<button class="actions-btn" data-pid="${escapeHtml(pid)}">></button>`;
|
|
||||||
peopleList.appendChild(row);
|
|
||||||
// If this peer's actions are open, render them
|
|
||||||
if (openActionsId === pid) {
|
|
||||||
const actions = document.createElement("div");
|
|
||||||
actions.className = "people-actions";
|
|
||||||
const hasPos = !!peer.lastCursor;
|
|
||||||
actions.innerHTML = `
|
|
||||||
<button data-action="navigate" data-pid="${escapeHtml(pid)}" ${hasPos ? "" : "disabled"}>Navigate to${hasPos ? "" : " (no position)"}</button>
|
|
||||||
<button data-action="ping" data-pid="${escapeHtml(pid)}">Ping to join you</button>
|
|
||||||
<button disabled>Delegate to (coming soon)</button>
|
|
||||||
<button disabled>Add to space (coming soon)</button>`;
|
|
||||||
peopleList.appendChild(actions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Badge click toggles panel
|
|
||||||
peopleBadge.addEventListener("click", () => {
|
|
||||||
const isOpen = peoplePanel.classList.toggle("open");
|
|
||||||
if (isOpen) {
|
|
||||||
// Close memory panel if open (shared screen region)
|
|
||||||
const memPanel = document.getElementById("memory-panel");
|
|
||||||
if (memPanel) memPanel.classList.remove("open");
|
|
||||||
const memBtn = document.getElementById("toggle-memory");
|
|
||||||
if (memBtn) memBtn.classList.remove("active");
|
|
||||||
renderPeoplePanel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delegate clicks inside people-list
|
|
||||||
peopleList.addEventListener("click", (e) => {
|
|
||||||
const btn = e.target.closest("button");
|
|
||||||
if (!btn) return;
|
|
||||||
const pid = btn.dataset.pid;
|
|
||||||
if (btn.classList.contains("actions-btn")) {
|
|
||||||
openActionsId = openActionsId === pid ? null : pid;
|
|
||||||
renderPeoplePanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const action = btn.dataset.action;
|
|
||||||
if (action === "navigate" && pid) {
|
|
||||||
const peer = onlinePeers.get(pid);
|
const peer = onlinePeers.get(pid);
|
||||||
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
|
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
|
||||||
} else if (action === "ping" && pid) {
|
} else if (action === "ping") {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const vx = (rect.width / 2 - panX) / scale;
|
const vx = (rect.width / 2 - panX) / scale;
|
||||||
const vy = (rect.height / 2 - panY) / scale;
|
const vy = (rect.height / 2 - panY) / scale;
|
||||||
|
|
@ -3542,43 +3221,24 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click-outside closes people panel
|
// ── People Online event handlers (bridge to collab overlay) ──
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (peoplePanel.classList.contains("open") &&
|
|
||||||
!peoplePanel.contains(e.target) &&
|
|
||||||
!peopleBadge.contains(e.target)) {
|
|
||||||
peoplePanel.classList.remove("open");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function navigateToPeer(cursor) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
panX = rect.width / 2 - cursor.x * scale;
|
|
||||||
panY = rect.height / 2 - cursor.y * scale;
|
|
||||||
updateCanvasTransform();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── People Online event handlers ──
|
|
||||||
sync.addEventListener("peer-list", (e) => {
|
sync.addEventListener("peer-list", (e) => {
|
||||||
onlinePeers.clear();
|
onlinePeers.clear();
|
||||||
|
collabOverlay?.clearPeers();
|
||||||
const peers = e.detail.peers || [];
|
const peers = e.detail.peers || [];
|
||||||
for (const p of peers) {
|
for (const p of peers) {
|
||||||
if (p.clientPeerId !== peerId) {
|
if (p.clientPeerId !== peerId) {
|
||||||
onlinePeers.set(p.clientPeerId, { username: p.username, color: p.color });
|
onlinePeers.set(p.clientPeerId, { username: p.username, color: p.color });
|
||||||
|
collabOverlay?.updatePeer(p.clientPeerId, p.username, p.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderPeopleBadge();
|
|
||||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("peer-joined", (e) => {
|
sync.addEventListener("peer-joined", (e) => {
|
||||||
const d = e.detail;
|
const d = e.detail;
|
||||||
if (d.peerId !== peerId) {
|
if (d.peerId !== peerId) {
|
||||||
onlinePeers.set(d.peerId, { username: d.username, color: d.color });
|
onlinePeers.set(d.peerId, { username: d.username, color: d.color });
|
||||||
renderPeopleBadge();
|
collabOverlay?.updatePeer(d.peerId, d.username, d.color);
|
||||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
|
||||||
// In single-player mode, notify that someone joined
|
|
||||||
if (!isMultiplayer) showMpNotification(d.username || "Someone");
|
if (!isMultiplayer) showMpNotification(d.username || "Someone");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -3587,9 +3247,7 @@
|
||||||
const leftId = e.detail.peerId;
|
const leftId = e.detail.peerId;
|
||||||
onlinePeers.delete(leftId);
|
onlinePeers.delete(leftId);
|
||||||
presence.removeUser(leftId);
|
presence.removeUser(leftId);
|
||||||
if (openActionsId === leftId) openActionsId = null;
|
collabOverlay?.removePeer(leftId);
|
||||||
renderPeopleBadge();
|
|
||||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("ping-user", (e) => {
|
sync.addEventListener("ping-user", (e) => {
|
||||||
|
|
@ -3597,7 +3255,6 @@
|
||||||
pingToastText.textContent = `${d.fromUsername || "Someone"} wants you to join them`;
|
pingToastText.textContent = `${d.fromUsername || "Someone"} wants you to join them`;
|
||||||
pingToast.classList.add("show");
|
pingToast.classList.add("show");
|
||||||
if (pingToastTimer) clearTimeout(pingToastTimer);
|
if (pingToastTimer) clearTimeout(pingToastTimer);
|
||||||
// Store viewport target for Go button
|
|
||||||
pingToastGo.onclick = () => {
|
pingToastGo.onclick = () => {
|
||||||
if (d.viewport) navigateToPeer(d.viewport);
|
if (d.viewport) navigateToPeer(d.viewport);
|
||||||
pingToast.classList.remove("show");
|
pingToast.classList.remove("show");
|
||||||
|
|
@ -3608,9 +3265,6 @@
|
||||||
}, 8000);
|
}, 8000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial render
|
|
||||||
renderPeopleBadge();
|
|
||||||
|
|
||||||
// Track if we're processing remote changes to avoid feedback loops
|
// Track if we're processing remote changes to avoid feedback loops
|
||||||
let isProcessingRemote = false;
|
let isProcessingRemote = false;
|
||||||
|
|
||||||
|
|
@ -3618,25 +3272,21 @@
|
||||||
sync.addEventListener("connected", () => {
|
sync.addEventListener("connected", () => {
|
||||||
status.className = "connected";
|
status.className = "connected";
|
||||||
statusText.textContent = "Connected";
|
statusText.textContent = "Connected";
|
||||||
connState = "connected";
|
collabOverlay?.setConnState("connected");
|
||||||
renderPeopleBadge();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("disconnected", () => {
|
sync.addEventListener("disconnected", () => {
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
status.className = "disconnected";
|
status.className = "disconnected";
|
||||||
statusText.textContent = "Reconnecting...";
|
statusText.textContent = "Reconnecting...";
|
||||||
connState = "reconnecting";
|
collabOverlay?.setConnState("reconnecting");
|
||||||
} else {
|
} else {
|
||||||
status.className = "offline";
|
status.className = "offline";
|
||||||
statusText.textContent = "Offline (changes saved locally)";
|
statusText.textContent = "Offline (changes saved locally)";
|
||||||
connState = "offline";
|
collabOverlay?.setConnState("offline");
|
||||||
}
|
}
|
||||||
// Clear online peers on disconnect (they'll re-announce on reconnect)
|
|
||||||
onlinePeers.clear();
|
onlinePeers.clear();
|
||||||
openActionsId = null;
|
collabOverlay?.clearPeers();
|
||||||
renderPeopleBadge();
|
|
||||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sync.addEventListener("synced", (e) => {
|
sync.addEventListener("synced", (e) => {
|
||||||
|
|
@ -6993,8 +6643,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
console.log("[Canvas] Browser went online, reconnecting...");
|
console.log("[Canvas] Browser went online, reconnecting...");
|
||||||
status.className = "syncing";
|
status.className = "syncing";
|
||||||
statusText.textContent = "Reconnecting...";
|
statusText.textContent = "Reconnecting...";
|
||||||
connState = "reconnecting";
|
collabOverlay?.setConnState("reconnecting");
|
||||||
renderPeopleBadge();
|
|
||||||
sync.connect(wsUrl);
|
sync.connect(wsUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -7002,8 +6651,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
console.log("[Canvas] Browser went offline");
|
console.log("[Canvas] Browser went offline");
|
||||||
status.className = "offline";
|
status.className = "offline";
|
||||||
statusText.textContent = "Offline (changes saved locally)";
|
statusText.textContent = "Offline (changes saved locally)";
|
||||||
connState = "offline";
|
collabOverlay?.setConnState("offline");
|
||||||
renderPeopleBadge();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle offline-loaded event
|
// Handle offline-loaded event
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,7 @@ body.rstack-sidebar-open #toolbar {
|
||||||
/* ── Mobile adjustments ── */
|
/* ── Mobile adjustments ── */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.rapp-info-btn { display: none; }
|
||||||
/* Switch header + tab row from fixed to sticky on mobile.
|
/* Switch header + tab row from fixed to sticky on mobile.
|
||||||
This avoids needing a magic padding-top on #app and lets
|
This avoids needing a magic padding-top on #app and lets
|
||||||
the header wrap naturally to two rows. */
|
the header wrap naturally to two rows. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue