Files
Edouard Durand 51c16fa93e App mode support; hide ComfyUI's Workflows and Apps tabs
Live edits keep each person's graph/App view, cursors are off in the App
view, and .app.json workflows show as apps in Files, the Live list and Save.
ComfyUI's own file tabs duplicate Files, so they are hidden by default (admin
setting hide_comfy_file_tabs) and their shortcuts open Files.
2026-09-26 03:43:46 +00:00

203 lines
7.8 KiB
JavaScript

// 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 = '<svg width="16" height="22" viewBox="0 0 16 22"><path d="M1 1v17l4.5-4 3.5 7.5 3-1.5-3.5-7.2H14z" stroke="rgba(0,0,0,.6)" stroke-width="1.5" stroke-linejoin="round"/></svg><span class="rs-cursor-name"></span>';
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);
}
}
}
}