// Remote cursors, remote selections and "follow" (spectate) mode.
const CURSOR_TTL = 20000;
export class Presence extends EventTarget {
constructor(app, client, sync) {
super();
this.app = app;
this.client = client;
this.sync = sync;
this.cursors = new Map(); // client id -> {x, y, graph, sel, vp, at}
this.following = null;
this.showCursors = true;
this.showNames = true;
this.lastSent = "";
this.pending = false;
client.on("cursor", (m) => this.onCursor(m));
let lastRoom = null;
sync.addEventListener("state", () => {
if (sync.targetKey !== lastRoom) {
lastRoom = sync.targetKey;
this.cursors.clear();
this.redraw();
}
});
client.on("leave", (m) => { this.cursors.delete(m.id); this.redraw(); });
client.on("presence", (users) => {
const ids = new Set(users.map((u) => u.id));
for (const id of this.cursors.keys()) if (!ids.has(id)) this.cursors.delete(id);
if (this.following && !ids.has(this.following)) this.follow(null);
});
window.addEventListener("pointermove", () => { this.pending = true; }, { passive: true });
// User takes the wheel: stop following.
window.addEventListener("pointerdown", (e) => {
if (this.following && e.target?.tagName === "CANVAS") this.follow(null);
}, true);
window.addEventListener("wheel", (e) => {
if (this.following && e.target?.tagName === "CANVAS") this.follow(null);
}, { capture: true, passive: true });
setInterval(() => this.sendState(), 70);
document.addEventListener("visibilitychange", () => {
client.send({ type: "idle", idle: document.hidden });
});
}
install() {
const canvas = this.app.canvas;
const previous = canvas.onDrawForeground;
canvas.onDrawForeground = (ctx, area) => {
previous?.call(canvas, ctx, area);
try { this.draw(ctx); } catch (e) { console.error("[RigShare] draw", e); }
};
}
currentGraphId() {
const g = this.app.canvas?.graph;
const root = this.app.rootGraph ?? this.app.graph;
return !g || g === root ? null : g.id ?? null;
}
viewport() {
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
const center = canvas.ds.convertCanvasToOffset([rect.width / 2, rect.height / 2]);
return { cx: Math.round(center[0]), cy: Math.round(center[1]), scale: +canvas.ds.scale.toFixed(3) };
}
sendState() {
if (!this.client.online || !this.app.canvas) return;
let msg;
if (!this.sync.live || document.hidden || this.sync.inAppMode) {
msg = { type: "cursor", x: null, y: null };
} else {
const [x, y] = this.app.canvas.graph_mouse ?? [0, 0];
const sel = Object.keys(this.app.canvas.selected_nodes ?? {});
msg = { type: "cursor", x: Math.round(x), y: Math.round(y), graph: this.currentGraphId(), sel, vp: this.viewport() };
}
const key = JSON.stringify(msg);
if (key === this.lastSent) return;
this.lastSent = key;
this.client.send(msg);
}
onCursor(m) {
if (m.room !== this.sync.targetKey) return;
this.cursors.set(m.id, { ...m, at: Date.now() });
if (m.id === this.following && m.vp && this.sync.live) this.applyViewport(m.vp, m.graph);
this.redraw();
}
async follow(id) {
this.following = id || null;
const user = id && this.userFor(id);
const room = user?.room && this.client.rooms.find((r) => r.key === user.room);
if (room && room.key !== this.sync.targetKey) {
try { await this.sync.openRoom(room); } catch (e) { console.error("[RigShare] follow", e); }
}
const c = id && this.cursors.get(id);
if (c?.vp) this.applyViewport(c.vp, c.graph);
this.dispatchEvent(new CustomEvent("follow", { detail: this.following }));
}
applyViewport(vp, graphId) {
if (graphId !== this.currentGraphId() || this.sync.inAppMode) return;
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
canvas.ds.scale = vp.scale;
canvas.ds.offset[0] = rect.width / 2 / vp.scale - vp.cx;
canvas.ds.offset[1] = rect.height / 2 / vp.scale - vp.cy;
canvas.setDirty(true, true);
}
redraw() {
this.app.canvas?.setDirty(true, false);
}
userFor(id) {
return this.client.users.find((u) => u.id === id);
}
draw(ctx) {
const visible = new Set();
if (!this.sync.live || !this.showCursors || this.sync.inAppMode) return this.pruneCursorEls(visible);
const canvas = this.app.canvas;
const graph = canvas.graph;
const scale = canvas.ds.scale;
const here = this.currentGraphId();
const now = Date.now();
for (const [id, c] of this.cursors) {
const user = this.userFor(id);
if (!user || (c.graph ?? null) !== here) continue;
const color = user.color;
// Selection outlines.
for (const nodeId of c.sel || []) {
const node = graph.getNodeById(isNaN(+nodeId) ? nodeId : +nodeId);
if (!node) continue;
const [x, y, w, h] = node.boundingRect ?? [node.pos[0], node.pos[1] - 30, node.size[0], node.size[1] + 30];
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2 / scale;
ctx.setLineDash([6 / scale, 4 / scale]);
ctx.strokeRect(x - 4 / scale, y - 4 / scale, w + 8 / scale, h + 8 / scale);
ctx.restore();
}
if (c.x == null || now - c.at > CURSOR_TTL) continue;
visible.add(id);
this.placeCursor(id, user, c);
}
this.pruneCursorEls(visible);
}
// Cursors live in a DOM layer above the canvas so they stay on top of
// DOM-rendered content (image previews, text widgets, Vue nodes).
overlay() {
if (!this.layer) {
this.layer = document.createElement("div");
this.layer.className = "rs-cursor-layer";
document.body.append(this.layer);
this.els = new Map();
}
return this.layer;
}
placeCursor(id, user, c) {
const layer = this.overlay();
let el = this.els.get(id);
if (!el) {
el = document.createElement("div");
el.className = "rs-cursor";
el.innerHTML = '';
layer.append(el);
this.els.set(id, el);
}
const canvas = this.app.canvas;
const rect = canvas.canvas.getBoundingClientRect();
const [x, y] = canvas.ds.convertOffsetToCanvas([c.x, c.y]);
const inside = x >= 0 && y >= 0 && x <= rect.width && y <= rect.height;
el.style.display = inside ? "" : "none";
el.style.transform = `translate(${rect.left + x}px, ${rect.top + y}px)`;
el.querySelector("path").setAttribute("fill", user.color);
const name = el.querySelector(".rs-cursor-name");
name.textContent = user.name;
name.style.background = user.color;
name.style.display = this.showNames ? "" : "none";
}
pruneCursorEls(visible) {
if (!this.els) return;
for (const [id, el] of this.els) {
if (!visible.has(id)) {
el.remove();
this.els.delete(id);
}
}
}
}