From 91cafc92ce1b6a2bc290f426b2fe8a34fc40bfb4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 10:40:13 -0800 Subject: [PATCH] fix: cursor world-coords, loading skeleton, WebSocket readyState guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert cursor positions to canvas world coordinates (screen→world on send, world→screen on render) so remote cursors appear at correct canvas locations regardless of pan/zoom - Add PresenceManager.setCamera() to reproject cursors on pan/zoom - Add loading spinner overlay dismissed on first cache hit or sync - Guard WebSocket open handler with readyState check after async load Co-Authored-By: Claude Opus 4.6 --- lib/presence.ts | 32 +++++++++++++++++++++++++++++--- server/index.ts | 2 ++ website/canvas.html | 37 ++++++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/presence.ts b/lib/presence.ts index db6771f..ff7b1cf 100644 --- a/lib/presence.ts +++ b/lib/presence.ts @@ -42,6 +42,9 @@ export class PresenceManager extends EventTarget { #fadeInterval: number | null = null; #localPeerId: string; #localUsername: string; + #panX = 0; + #panY = 0; + #scale = 1; constructor(container: HTMLElement, peerId: string, username?: string) { super(); @@ -68,6 +71,16 @@ export class PresenceManager extends EventTarget { this.#localUsername = name; } + /** + * Update camera transform so remote cursors render at correct screen positions + */ + setCamera(panX: number, panY: number, scale: number) { + this.#panX = panX; + this.#panY = panY; + this.#scale = scale; + this.#refreshCursors(); + } + /** * Update presence for a remote user */ @@ -89,10 +102,12 @@ export class PresenceManager extends EventTarget { user.lastUpdate = Date.now(); } - // Update cursor position + // Update cursor position (world → screen conversion) if (data.cursor) { - user.element.style.left = `${data.cursor.x}px`; - user.element.style.top = `${data.cursor.y}px`; + const screenX = data.cursor.x * this.#scale + this.#panX; + const screenY = data.cursor.y * this.#scale + this.#panY; + user.element.style.left = `${screenX}px`; + user.element.style.top = `${screenY}px`; user.element.style.opacity = "1"; } @@ -150,6 +165,17 @@ export class PresenceManager extends EventTarget { } } + #refreshCursors() { + for (const [, user] of this.#users) { + if (user.cursor) { + const screenX = user.cursor.x * this.#scale + this.#panX; + const screenY = user.cursor.y * this.#scale + this.#panY; + user.element.style.left = `${screenX}px`; + user.element.style.top = `${screenY}px`; + } + } + } + #ensureCursorContainer() { let cursorsEl = this.#container.querySelector(".presence-cursors") as HTMLElement; if (!cursorsEl) { diff --git a/server/index.ts b/server/index.ts index 1178fa2..2f3fb50 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1865,6 +1865,8 @@ const server = Bun.serve({ loadCommunity(communitySlug).then((doc) => { if (!doc) return; + // Guard: peer may have disconnected during async load + if (ws.readyState !== WebSocket.OPEN) return; if (mode === "json") { const docData = getDocumentData(communitySlug); diff --git a/website/canvas.html b/website/canvas.html index 919ba56..804f170 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1619,6 +1619,25 @@ transform: none; } } + + #canvas-loading { + position: fixed; inset: 0; z-index: 50; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 1rem; pointer-events: none; + } + .canvas-loading__spinner { + width: 36px; height: 36px; + border: 3px solid rgba(99,102,241,0.2); + border-top-color: #6366f1; + border-radius: 50%; + animation: canvas-loading-spin 0.8s linear infinite; + } + @keyframes canvas-loading-spin { to { transform: rotate(360deg); } } + .canvas-loading__text { + font-size: 0.85rem; color: #64748b; + font-family: system-ui, sans-serif; + }