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:
parent
fef419f572
commit
91cafc92ce
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue