fix: cursor world-coords, loading skeleton, WebSocket readyState guard

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 10:40:13 -08:00
parent fef419f572
commit 91cafc92ce
3 changed files with 63 additions and 8 deletions

View File

@ -42,6 +42,9 @@ export class PresenceManager extends EventTarget {
#fadeInterval: number | null = null; #fadeInterval: number | null = null;
#localPeerId: string; #localPeerId: string;
#localUsername: string; #localUsername: string;
#panX = 0;
#panY = 0;
#scale = 1;
constructor(container: HTMLElement, peerId: string, username?: string) { constructor(container: HTMLElement, peerId: string, username?: string) {
super(); super();
@ -68,6 +71,16 @@ export class PresenceManager extends EventTarget {
this.#localUsername = name; 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 * Update presence for a remote user
*/ */
@ -89,10 +102,12 @@ export class PresenceManager extends EventTarget {
user.lastUpdate = Date.now(); user.lastUpdate = Date.now();
} }
// Update cursor position // Update cursor position (world → screen conversion)
if (data.cursor) { if (data.cursor) {
user.element.style.left = `${data.cursor.x}px`; const screenX = data.cursor.x * this.#scale + this.#panX;
user.element.style.top = `${data.cursor.y}px`; 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"; 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() { #ensureCursorContainer() {
let cursorsEl = this.#container.querySelector(".presence-cursors") as HTMLElement; let cursorsEl = this.#container.querySelector(".presence-cursors") as HTMLElement;
if (!cursorsEl) { if (!cursorsEl) {

View File

@ -1865,6 +1865,8 @@ const server = Bun.serve<WSData>({
loadCommunity(communitySlug).then((doc) => { loadCommunity(communitySlug).then((doc) => {
if (!doc) return; if (!doc) return;
// Guard: peer may have disconnected during async load
if (ws.readyState !== WebSocket.OPEN) return;
if (mode === "json") { if (mode === "json") {
const docData = getDocumentData(communitySlug); const docData = getDocumentData(communitySlug);

View File

@ -1619,6 +1619,25 @@
transform: none; 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;
}
</style> </style>
<link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shell.css">
<style> <style>
@ -1952,6 +1971,10 @@
</div> </div>
<div id="canvas"><div id="canvas-content"></div></div> <div id="canvas"><div id="canvas-content"></div></div>
<div id="canvas-loading">
<div class="canvas-loading__spinner"></div>
<div class="canvas-loading__text">Loading canvas...</div>
</div>
<div id="select-rect"></div> <div id="select-rect"></div>
<script type="module"> <script type="module">
@ -2346,6 +2369,7 @@
await offlineStore.open(); await offlineStore.open();
const hadCache = await sync.initFromCache(); const hadCache = await sync.initFromCache();
if (hadCache) { if (hadCache) {
document.getElementById("canvas-loading")?.remove();
status.className = "offline"; status.className = "offline";
statusText.textContent = "Offline (cached)"; statusText.textContent = "Offline (cached)";
} }
@ -2402,10 +2426,10 @@
if (now - lastCursorUpdate < CURSOR_THROTTLE) return; if (now - lastCursorUpdate < CURSOR_THROTTLE) return;
lastCursorUpdate = now; lastCursorUpdate = now;
// Get cursor position relative to canvas // Convert screen → world coordinates (accounting for pan/zoom)
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left; const x = (e.clientX - rect.left - panX) / scale;
const y = e.clientY - rect.top; const y = (e.clientY - rect.top - panY) / scale;
// Send presence update via sync // Send presence update via sync
sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId)); sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId));
@ -2420,8 +2444,8 @@
lastCursorUpdate = now; lastCursorUpdate = now;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = e.touches[0].clientX - rect.left; const x = (e.touches[0].clientX - rect.left - panX) / scale;
const y = e.touches[0].clientY - rect.top; const y = (e.touches[0].clientY - rect.top - panY) / scale;
sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId)); sync.sendPresence(presence.getLocalPresence({ x, y }, selectedShapeId));
}, { passive: true }); }, { passive: true });
@ -2708,6 +2732,7 @@
}); });
sync.addEventListener("synced", (e) => { sync.addEventListener("synced", (e) => {
document.getElementById("canvas-loading")?.remove();
console.log("[Canvas] Initial sync complete:", e.detail.shapes); console.log("[Canvas] Initial sync complete:", e.detail.shapes);
}); });
@ -4159,6 +4184,8 @@
ghostEl.style.width = (defaults.width * scale) + "px"; ghostEl.style.width = (defaults.width * scale) + "px";
ghostEl.style.height = (defaults.height * scale) + "px"; ghostEl.style.height = (defaults.height * scale) + "px";
} }
// Update remote cursors to match new camera position
presence.setCamera(panX, panY, scale);
} }
document.getElementById("zoom-in").addEventListener("click", () => { document.getElementById("zoom-in").addEventListener("click", () => {