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:
|
||||
* - "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
|
||||
* - Hides on canvas page (rSpace has its own CommunitySync)
|
||||
* - Bridge API for canvas (external peer management via CommunitySync)
|
||||
*
|
||||
* Attributes:
|
||||
* module-id — current module identifier
|
||||
|
|
@ -45,8 +47,11 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
#lastCursor = { x: 0, y: 0 };
|
||||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||
#badgeOnly = false;
|
||||
#hidden = false; // true on canvas page
|
||||
#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();
|
||||
|
|
@ -58,21 +63,13 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
|
||||
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') {
|
||||
this.#hidden = true;
|
||||
// Still dispatch initial solo-mode event for canvas PresenceManager
|
||||
this.#externalPeers = true;
|
||||
// Dispatch initial solo-mode event for canvas PresenceManager
|
||||
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
|
||||
this.#resolveIdentity();
|
||||
|
||||
|
|
@ -80,23 +77,73 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
this.#render();
|
||||
this.#renderBadge();
|
||||
|
||||
// Try connecting to runtime
|
||||
this.#tryConnect();
|
||||
if (!this.#externalPeers) {
|
||||
// Explicit doc-id attribute (fallback)
|
||||
const explicitDocId = this.getAttribute('doc-id');
|
||||
if (explicitDocId) this.#docId = explicitDocId;
|
||||
|
||||
// GC stale peers every 5s
|
||||
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
||||
// 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() {
|
||||
if (this.#hidden) return;
|
||||
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
||||
this.#unsubAwareness?.();
|
||||
this.#stopMouseTracking();
|
||||
this.#stopFocusTracking();
|
||||
document.removeEventListener('click', this.#onDocumentClick);
|
||||
if (!this.#externalPeers) {
|
||||
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
|
||||
this.#unsubAwareness?.();
|
||||
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) => {
|
||||
|
|
@ -131,6 +178,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
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();
|
||||
|
|
@ -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 ──
|
||||
|
||||
#gcPeers() {
|
||||
|
|
@ -283,6 +342,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
this.#renderCursors();
|
||||
this.#renderFocusRings();
|
||||
}
|
||||
if (this.#panelOpen) this.#renderPanel();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,28 +362,136 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
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', () => this.#toggleSoloMode());
|
||||
this.#shadow.getElementById('badge')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.#togglePanel();
|
||||
});
|
||||
}
|
||||
|
||||
#toggleSoloMode() {
|
||||
this.#soloMode = !this.#soloMode;
|
||||
localStorage.setItem('rspace_solo_mode', this.#soloMode ? '1' : '0');
|
||||
// ── Panel toggle ──
|
||||
|
||||
if (this.#soloMode) {
|
||||
// Clear remote peers and their visual artifacts
|
||||
this.#peers.clear();
|
||||
if (!this.#badgeOnly) {
|
||||
this.#renderCursors();
|
||||
this.#renderFocusRings();
|
||||
#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;
|
||||
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();
|
||||
if (this.#panelOpen) this.#renderPanel();
|
||||
|
||||
// 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() {
|
||||
|
|
@ -338,6 +506,27 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
}
|
||||
|
||||
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())
|
||||
|
|
@ -351,7 +540,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
|||
<span class="count">\u{1F465} ${count} online</span>
|
||||
`;
|
||||
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() {
|
||||
|
|
@ -494,6 +683,172 @@ const OVERLAY_CSS = `
|
|||
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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
@ -524,4 +879,15 @@ const OVERLAY_CSS = `
|
|||
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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 ── */
|
||||
#mp-notify {
|
||||
position: fixed;
|
||||
|
|
@ -1241,21 +1046,6 @@
|
|||
|
||||
/* 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 {
|
||||
position: absolute;
|
||||
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>
|
||||
<rstack-app-switcher current="rspace"></rstack-app-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 class="rstack-header__center">
|
||||
<rstack-mi></rstack-mi>
|
||||
|
|
@ -1967,25 +1764,13 @@
|
|||
<rstack-comment-bell></rstack-comment-bell>
|
||||
<rstack-notification-bell></rstack-notification-bell>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
<rstack-space-settings></rstack-space-settings>
|
||||
<rstack-history-panel type="canvas"></rstack-history-panel>
|
||||
<div class="rstack-tab-row" data-theme="dark">
|
||||
<rstack-tab-bar space="" active="" view-mode="flat"></rstack-tab-bar>
|
||||
<div id="people-online-badge">
|
||||
<span class="dots" id="people-dots"></span>
|
||||
<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>
|
||||
<rstack-tab-bar space="" active="" view-mode="flat">
|
||||
<rstack-collab-overlay module-id="rspace" space=""></rstack-collab-overlay>
|
||||
</rstack-tab-bar>
|
||||
</div>
|
||||
<div id="community-info">
|
||||
<h2 id="community-name">Loading...</h2>
|
||||
|
|
@ -2738,6 +2523,8 @@
|
|||
|
||||
if (tabBar) {
|
||||
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
|
||||
function getModuleLabel(id) {
|
||||
|
|
@ -3081,8 +2868,7 @@
|
|||
document.getElementById("canvas-loading")?.remove();
|
||||
status.className = "offline";
|
||||
statusText.textContent = "Offline (cached)";
|
||||
connState = "offline";
|
||||
renderPeopleBadge();
|
||||
collabOverlay?.setConnState("offline");
|
||||
}
|
||||
} catch (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 localColor = peerIdToColor(peerId);
|
||||
const peoplePanel = document.getElementById("people-panel");
|
||||
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 collabOverlay = document.querySelector("rstack-collab-overlay");
|
||||
const pingToast = document.getElementById("ping-toast");
|
||||
const pingToastText = document.getElementById("ping-toast-text");
|
||||
const pingToastGo = document.getElementById("ping-toast-go");
|
||||
|
|
@ -3383,7 +3163,6 @@
|
|||
const mpNotifySwitch = document.getElementById("mp-notify-switch");
|
||||
let pingToastTimer = null;
|
||||
let mpNotifyTimer = null;
|
||||
let openActionsId = null; // which peer's actions dropdown is open
|
||||
|
||||
// ── Single / Multiplayer mode ──
|
||||
let isMultiplayer = localStorage.getItem("rspace-multiplayer") !== "false"; // default true
|
||||
|
|
@ -3391,12 +3170,14 @@
|
|||
function setMultiplayerMode(enabled) {
|
||||
isMultiplayer = enabled;
|
||||
localStorage.setItem("rspace-multiplayer", enabled ? "true" : "false");
|
||||
// Show/hide remote cursors
|
||||
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", () => {
|
||||
setMultiplayerMode(true);
|
||||
mpNotify.classList.remove("show");
|
||||
|
|
@ -3404,7 +3185,7 @@
|
|||
});
|
||||
|
||||
function showMpNotification(username) {
|
||||
if (isMultiplayer) return; // already in multiplayer
|
||||
if (isMultiplayer) return;
|
||||
mpNotifyText.textContent = `${username} is online — switch to multiplayer to collaborate`;
|
||||
mpNotify.classList.add("show");
|
||||
if (mpNotifyTimer) clearTimeout(mpNotifyTimer);
|
||||
|
|
@ -3417,122 +3198,20 @@
|
|||
// Announce ourselves
|
||||
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
|
||||
|
||||
function renderPeopleBadge() {
|
||||
const totalCount = onlinePeers.size + 1; // +1 for self
|
||||
peopleDots.innerHTML = "";
|
||||
|
||||
if (connState === "offline") {
|
||||
// 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 navigateToPeer(cursor) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
panX = rect.width / 2 - cursor.x * scale;
|
||||
panY = rect.height / 2 - cursor.y * scale;
|
||||
updateCanvasTransform();
|
||||
}
|
||||
|
||||
function renderPeoplePanel() {
|
||||
peopleList.innerHTML = "";
|
||||
// Show offline notice if disconnected
|
||||
if (connState === "offline" || connState === "reconnecting" || connState === "connecting") {
|
||||
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) {
|
||||
// Handle peer actions from collab overlay (navigate/ping)
|
||||
collabOverlay?.addEventListener("collab-peer-action", (e) => {
|
||||
const { action, peerId: pid } = e.detail;
|
||||
if (action === "navigate") {
|
||||
const peer = onlinePeers.get(pid);
|
||||
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
|
||||
} else if (action === "ping" && pid) {
|
||||
} else if (action === "ping") {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const vx = (rect.width / 2 - panX) / scale;
|
||||
const vy = (rect.height / 2 - panY) / scale;
|
||||
|
|
@ -3542,43 +3221,24 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Click-outside closes people panel
|
||||
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 ──
|
||||
// ── People Online event handlers (bridge to collab overlay) ──
|
||||
sync.addEventListener("peer-list", (e) => {
|
||||
onlinePeers.clear();
|
||||
collabOverlay?.clearPeers();
|
||||
const peers = e.detail.peers || [];
|
||||
for (const p of peers) {
|
||||
if (p.clientPeerId !== peerId) {
|
||||
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) => {
|
||||
const d = e.detail;
|
||||
if (d.peerId !== peerId) {
|
||||
onlinePeers.set(d.peerId, { username: d.username, color: d.color });
|
||||
renderPeopleBadge();
|
||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
||||
// In single-player mode, notify that someone joined
|
||||
collabOverlay?.updatePeer(d.peerId, d.username, d.color);
|
||||
if (!isMultiplayer) showMpNotification(d.username || "Someone");
|
||||
}
|
||||
});
|
||||
|
|
@ -3587,9 +3247,7 @@
|
|||
const leftId = e.detail.peerId;
|
||||
onlinePeers.delete(leftId);
|
||||
presence.removeUser(leftId);
|
||||
if (openActionsId === leftId) openActionsId = null;
|
||||
renderPeopleBadge();
|
||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
||||
collabOverlay?.removePeer(leftId);
|
||||
});
|
||||
|
||||
sync.addEventListener("ping-user", (e) => {
|
||||
|
|
@ -3597,7 +3255,6 @@
|
|||
pingToastText.textContent = `${d.fromUsername || "Someone"} wants you to join them`;
|
||||
pingToast.classList.add("show");
|
||||
if (pingToastTimer) clearTimeout(pingToastTimer);
|
||||
// Store viewport target for Go button
|
||||
pingToastGo.onclick = () => {
|
||||
if (d.viewport) navigateToPeer(d.viewport);
|
||||
pingToast.classList.remove("show");
|
||||
|
|
@ -3608,9 +3265,6 @@
|
|||
}, 8000);
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderPeopleBadge();
|
||||
|
||||
// Track if we're processing remote changes to avoid feedback loops
|
||||
let isProcessingRemote = false;
|
||||
|
||||
|
|
@ -3618,25 +3272,21 @@
|
|||
sync.addEventListener("connected", () => {
|
||||
status.className = "connected";
|
||||
statusText.textContent = "Connected";
|
||||
connState = "connected";
|
||||
renderPeopleBadge();
|
||||
collabOverlay?.setConnState("connected");
|
||||
});
|
||||
|
||||
sync.addEventListener("disconnected", () => {
|
||||
if (navigator.onLine) {
|
||||
status.className = "disconnected";
|
||||
statusText.textContent = "Reconnecting...";
|
||||
connState = "reconnecting";
|
||||
collabOverlay?.setConnState("reconnecting");
|
||||
} else {
|
||||
status.className = "offline";
|
||||
statusText.textContent = "Offline (changes saved locally)";
|
||||
connState = "offline";
|
||||
collabOverlay?.setConnState("offline");
|
||||
}
|
||||
// Clear online peers on disconnect (they'll re-announce on reconnect)
|
||||
onlinePeers.clear();
|
||||
openActionsId = null;
|
||||
renderPeopleBadge();
|
||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
||||
collabOverlay?.clearPeers();
|
||||
});
|
||||
|
||||
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...");
|
||||
status.className = "syncing";
|
||||
statusText.textContent = "Reconnecting...";
|
||||
connState = "reconnecting";
|
||||
renderPeopleBadge();
|
||||
collabOverlay?.setConnState("reconnecting");
|
||||
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");
|
||||
status.className = "offline";
|
||||
statusText.textContent = "Offline (changes saved locally)";
|
||||
connState = "offline";
|
||||
renderPeopleBadge();
|
||||
collabOverlay?.setConnState("offline");
|
||||
});
|
||||
|
||||
// Handle offline-loaded event
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ body.rstack-sidebar-open #toolbar {
|
|||
/* ── Mobile adjustments ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rapp-info-btn { display: none; }
|
||||
/* Switch header + tab row from fixed to sticky on mobile.
|
||||
This avoids needing a magic padding-top on #app and lets
|
||||
the header wrap naturally to two rows. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue