feat: people online panel — live peer presence on canvas
- community-sync: re-announce on reconnect, handle peer-list/joined/left/ping events - presence: export peerIdToColor helper - server: track peer announcements, broadcast join/leave, relay ping-user - canvas: people online badge + expandable panel with avatar dots and ping button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4dd212ef7d
commit
5ee59f86d6
|
|
@ -216,6 +216,11 @@ export class CommunitySync extends EventTarget {
|
|||
|
||||
this.dispatchEvent(new CustomEvent("connected"));
|
||||
|
||||
// Re-announce presence on every (re)connect
|
||||
if (this.#announceData) {
|
||||
this.#send({ type: "announce", ...this.#announceData });
|
||||
}
|
||||
|
||||
if (this.#offlineStore) {
|
||||
this.#offlineStore.markSynced(this.#communitySlug);
|
||||
}
|
||||
|
|
@ -322,6 +327,22 @@ export class CommunitySync extends EventTarget {
|
|||
// Handle presence updates (cursors, selections)
|
||||
this.dispatchEvent(new CustomEvent("presence", { detail: msg }));
|
||||
break;
|
||||
|
||||
case "peer-list":
|
||||
this.dispatchEvent(new CustomEvent("peer-list", { detail: msg }));
|
||||
break;
|
||||
|
||||
case "peer-joined":
|
||||
this.dispatchEvent(new CustomEvent("peer-joined", { detail: msg }));
|
||||
break;
|
||||
|
||||
case "peer-left":
|
||||
this.dispatchEvent(new CustomEvent("peer-left", { detail: msg }));
|
||||
break;
|
||||
|
||||
case "ping-user":
|
||||
this.dispatchEvent(new CustomEvent("ping-user", { detail: msg }));
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[CommunitySync] Failed to handle message:", e);
|
||||
|
|
@ -394,6 +415,27 @@ export class CommunitySync extends EventTarget {
|
|||
});
|
||||
}
|
||||
|
||||
// ── People Online (announce / ping) ──
|
||||
|
||||
#announceData: { peerId: string; username: string; color: string } | null = null;
|
||||
|
||||
/**
|
||||
* Store announce payload and send immediately if already connected.
|
||||
*/
|
||||
setAnnounceData(data: { peerId: string; username: string; color: string }): void {
|
||||
this.#announceData = data;
|
||||
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||
this.#send({ type: "announce", ...data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server to relay a "come here" ping to a specific peer.
|
||||
*/
|
||||
sendPingUser(targetPeerId: string, viewport: { x: number; y: number }): void {
|
||||
this.#send({ type: "ping-user", targetPeerId, viewport });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a keep-alive ping to prevent WebSocket idle timeout
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ interface UserPresence extends PresenceData {
|
|||
}
|
||||
|
||||
// Generate consistent color from peer ID
|
||||
function peerIdToColor(peerId: string): string {
|
||||
export function peerIdToColor(peerId: string): string {
|
||||
const colors = [
|
||||
"#ef4444", // red
|
||||
"#f97316", // orange
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,9 @@ interface WSData {
|
|||
// Track connected clients per community
|
||||
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
|
||||
|
||||
// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color }
|
||||
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();
|
||||
|
||||
function generatePeerId(): string {
|
||||
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
|
@ -1536,6 +1539,55 @@ const server = Bun.serve<WSData>({
|
|||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "announce") {
|
||||
// Client announcing its identity for the people-online panel
|
||||
const { peerId: clientPeerId, username, color } = msg;
|
||||
if (!clientPeerId || !username) return;
|
||||
|
||||
let slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||||
if (!slugAnnouncements) {
|
||||
slugAnnouncements = new Map();
|
||||
peerAnnouncements.set(communitySlug, slugAnnouncements);
|
||||
}
|
||||
slugAnnouncements.set(peerId, { clientPeerId, username, color });
|
||||
|
||||
// Send full peer list back to the announcing client
|
||||
const peerList = Array.from(slugAnnouncements.values());
|
||||
ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));
|
||||
|
||||
// Broadcast peer-joined to all other clients
|
||||
const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
|
||||
const clients2 = communityClients.get(communitySlug);
|
||||
if (clients2) {
|
||||
for (const [cPeerId, client] of clients2) {
|
||||
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||||
client.send(joinedMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "ping-user") {
|
||||
// Relay a "come here" ping to a specific peer
|
||||
const { targetPeerId: targetClientPeerId, viewport } = msg;
|
||||
const slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||||
const senderInfo = slugAnnouncements?.get(peerId);
|
||||
if (!slugAnnouncements || !senderInfo) return;
|
||||
|
||||
// Find the server peerId that matches the target client peerId
|
||||
const clients3 = communityClients.get(communitySlug);
|
||||
if (clients3) {
|
||||
for (const [serverPid, _client] of clients3) {
|
||||
const ann = slugAnnouncements.get(serverPid);
|
||||
if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
|
||||
_client.send(JSON.stringify({
|
||||
type: "ping-user",
|
||||
fromPeerId: senderInfo.clientPeerId,
|
||||
fromUsername: senderInfo.username,
|
||||
viewport,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "update" && msg.id && msg.data) {
|
||||
if (ws.data.readOnly) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
|
||||
|
|
@ -1593,6 +1645,26 @@ const server = Bun.serve<WSData>({
|
|||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const { communitySlug, peerId } = ws.data;
|
||||
|
||||
// Broadcast peer-left before cleanup
|
||||
const slugAnnouncements = peerAnnouncements.get(communitySlug);
|
||||
if (slugAnnouncements) {
|
||||
const ann = slugAnnouncements.get(peerId);
|
||||
if (ann) {
|
||||
const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
|
||||
const clients = communityClients.get(communitySlug);
|
||||
if (clients) {
|
||||
for (const [cPeerId, client] of clients) {
|
||||
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
||||
client.send(leftMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
slugAnnouncements.delete(peerId);
|
||||
if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
|
||||
}
|
||||
}
|
||||
|
||||
const clients = communityClients.get(communitySlug);
|
||||
if (clients) {
|
||||
clients.delete(peerId);
|
||||
|
|
|
|||
|
|
@ -757,6 +757,278 @@
|
|||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── People Online badge ── */
|
||||
#people-online-badge {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 170px;
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
#people-online-badge:hover {
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
#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: fixed;
|
||||
top: 72px;
|
||||
right: 16px;
|
||||
width: 280px;
|
||||
max-height: calc(100vh - 120px);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
|
||||
z-index: 1001;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#people-panel.open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#people-panel-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#people-panel-header h3 {
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#people-panel-header .count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
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: #f1f5f9;
|
||||
}
|
||||
|
||||
.people-row .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.people-row .name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.people-row .name .you-tag {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.people-row .actions-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.people-row .actions-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.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: #334155;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.people-actions button:hover:not(:disabled) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.people-actions button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Ping toast ── */
|
||||
#ping-toast {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-60px);
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: white;
|
||||
z-index: 100001;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
#ping-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#ping-toast-go {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#ping-toast-go:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* ── People panel dark mode ── */
|
||||
body[data-theme="dark"] #people-online-badge {
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #people-panel {
|
||||
background: #1e293b;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #people-panel-header {
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #people-panel-header h3 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] #people-panel-header .count {
|
||||
color: #cbd5e1;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-row:hover {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-row .name {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-row .actions-btn {
|
||||
background: #334155;
|
||||
border-color: #475569;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-row .actions-btn:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-actions button {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-actions button:hover:not(:disabled) {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] .people-actions button:disabled {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
/* ── People panel mobile ── */
|
||||
@media (max-width: 640px) {
|
||||
#people-online-badge {
|
||||
right: 100px;
|
||||
bottom: 12px;
|
||||
}
|
||||
#people-panel {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
#canvas-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
|
@ -1268,6 +1540,24 @@
|
|||
<div id="memory-list"></div>
|
||||
</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 id="people-online-badge">
|
||||
<span class="dots" id="people-dots"></span>
|
||||
<span id="people-badge-text">1 online</span>
|
||||
</div>
|
||||
|
||||
<div id="ping-toast">
|
||||
<span id="ping-toast-text"></span>
|
||||
<button id="ping-toast-go">Go</button>
|
||||
</div>
|
||||
|
||||
<div id="shape-context-menu"></div>
|
||||
<div id="copy-toast"></div>
|
||||
|
||||
|
|
@ -1321,6 +1611,7 @@
|
|||
CommunitySync,
|
||||
PresenceManager,
|
||||
generatePeerId,
|
||||
peerIdToColor,
|
||||
OfflineStore,
|
||||
MiCanvasBridge,
|
||||
installSelectionTransforms
|
||||
|
|
@ -1682,21 +1973,26 @@
|
|||
|
||||
// Initialize offline store and CommunitySync
|
||||
const offlineStore = new OfflineStore();
|
||||
await offlineStore.open();
|
||||
const sync = new CommunitySync(communitySlug, offlineStore);
|
||||
window.__communitySync = sync;
|
||||
|
||||
// Try to load from cache immediately (shows content before WebSocket connects)
|
||||
const hadCache = await sync.initFromCache();
|
||||
if (hadCache) {
|
||||
status.className = "offline";
|
||||
statusText.textContent = "Offline (cached)";
|
||||
}
|
||||
|
||||
// Notify the shell tab bar that CommunitySync is ready
|
||||
document.dispatchEvent(new CustomEvent("community-sync-ready", {
|
||||
detail: { sync, communitySlug }
|
||||
}));
|
||||
// Non-blocking: open IndexedDB + load cache in background
|
||||
// UI handlers below register immediately regardless of this outcome
|
||||
(async () => {
|
||||
try {
|
||||
await offlineStore.open();
|
||||
const hadCache = await sync.initFromCache();
|
||||
if (hadCache) {
|
||||
status.className = "offline";
|
||||
statusText.textContent = "Offline (cached)";
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Canvas] Offline cache init failed:", e);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent("community-sync-ready", {
|
||||
detail: { sync, communitySlug }
|
||||
}));
|
||||
})();
|
||||
|
||||
// Initialize Presence for real-time cursors
|
||||
const peerId = generatePeerId();
|
||||
|
|
@ -1770,8 +2066,187 @@
|
|||
// Handle presence updates from other users
|
||||
sync.addEventListener("presence", (e) => {
|
||||
presence.updatePresence(e.detail);
|
||||
// Update last cursor for people panel "Navigate to"
|
||||
const pid = e.detail.peerId;
|
||||
if (pid && e.detail.cursor && onlinePeers.has(pid)) {
|
||||
onlinePeers.get(pid).lastCursor = e.detail.cursor;
|
||||
}
|
||||
});
|
||||
|
||||
// ── People Online panel ──
|
||||
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");
|
||||
const pingToast = document.getElementById("ping-toast");
|
||||
const pingToastText = document.getElementById("ping-toast-text");
|
||||
const pingToastGo = document.getElementById("ping-toast-go");
|
||||
let pingToastTimer = null;
|
||||
let openActionsId = null; // which peer's actions dropdown is open
|
||||
|
||||
// Announce ourselves
|
||||
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
|
||||
|
||||
function renderPeopleBadge() {
|
||||
const totalCount = onlinePeers.size + 1; // +1 for self
|
||||
peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`;
|
||||
peopleDots.innerHTML = "";
|
||||
// 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 = totalCount;
|
||||
}
|
||||
|
||||
function renderPeoplePanel() {
|
||||
peopleList.innerHTML = "";
|
||||
// Self row (no actions)
|
||||
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>`;
|
||||
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);
|
||||
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
|
||||
} else if (action === "ping" && pid) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const vx = (rect.width / 2 - panX) / scale;
|
||||
const vy = (rect.height / 2 - panY) / scale;
|
||||
sync.sendPingUser(pid, { x: vx, y: vy });
|
||||
const peer = onlinePeers.get(pid);
|
||||
showToast(`Pinged ${peer?.username || "user"}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Click-outside closes 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 ──
|
||||
sync.addEventListener("peer-list", (e) => {
|
||||
onlinePeers.clear();
|
||||
const peers = e.detail.peers || [];
|
||||
for (const p of peers) {
|
||||
if (p.clientPeerId !== peerId) {
|
||||
onlinePeers.set(p.clientPeerId, { username: p.username, color: 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();
|
||||
}
|
||||
});
|
||||
|
||||
sync.addEventListener("peer-left", (e) => {
|
||||
const leftId = e.detail.peerId;
|
||||
onlinePeers.delete(leftId);
|
||||
presence.removeUser(leftId);
|
||||
if (openActionsId === leftId) openActionsId = null;
|
||||
renderPeopleBadge();
|
||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
||||
});
|
||||
|
||||
sync.addEventListener("ping-user", (e) => {
|
||||
const d = e.detail;
|
||||
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");
|
||||
if (pingToastTimer) clearTimeout(pingToastTimer);
|
||||
};
|
||||
pingToastTimer = setTimeout(() => {
|
||||
pingToast.classList.remove("show");
|
||||
}, 8000);
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderPeopleBadge();
|
||||
|
||||
// Track if we're processing remote changes to avoid feedback loops
|
||||
let isProcessingRemote = false;
|
||||
|
||||
|
|
@ -1789,6 +2264,11 @@
|
|||
status.className = "offline";
|
||||
statusText.textContent = "Offline (changes saved locally)";
|
||||
}
|
||||
// Clear online peers on disconnect (they'll re-announce on reconnect)
|
||||
onlinePeers.clear();
|
||||
openActionsId = null;
|
||||
renderPeopleBadge();
|
||||
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
|
||||
});
|
||||
|
||||
sync.addEventListener("synced", (e) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue