DesignGym / web /js /store.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
7.07 kB
// The editor's model: the episode's document, kept in sync with the server.
//
// The server is the source of truth. Every edit is sent as an operation over
// the episode's WebSocket; the server validates it, applies it, and pushes the
// new document to every open editor (and records it in the trajectory). For
// responsiveness, property updates are applied locally the moment they are
// sent and then replaced by the server's copy; drags never touch the store at
// all until the pointer is released.
import { fontsLoading } from "./fonts.js";
import { Emitter } from "./util.js";
export class Store extends Emitter {
constructor(episodeId, { source = "editor" } = {}) {
super();
this.episodeId = episodeId;
this.source = source;
this.doc = null;
this.task = null;
this.assets = [];
this.assetById = new Map();
this.version = -1;
this.done = false;
this.result = null;
this.canUndo = false;
this.canRedo = false;
this.selection = [];
this.tool = "move";
this.connected = false;
this._pending = new Map();
this._seq = 0;
this._ws = null;
this._busy = 0; // gestures in progress (drag, text edit)
this._backoff = 500;
}
// ---------------------------------------------------------------- sync
connect() {
return new Promise((resolve) => {
const proto = location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${location.host}/api/episodes/${this.episodeId}/ws?source=${this.source}`;
const ws = new WebSocket(url);
this._ws = ws;
let first = true;
ws.onopen = () => {
this.connected = true;
this._backoff = 500;
this.emit("connection", true);
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "state") {
this._applyState(msg);
if (first) { first = false; resolve(); }
} else if (msg.type === "ack" || msg.type === "pong") {
const p = this._pending.get(msg.id);
if (p) {
this._pending.delete(msg.id);
msg.ok === false ? p.reject(new Error(msg.error || "rejected")) : p.resolve(msg.result);
}
if (!this._pending.size) this.emit("idle");
} else if (msg.type === "error") {
this.emit("fatal", msg.message);
}
};
ws.onclose = () => {
this.connected = false;
this.emit("connection", false);
for (const p of this._pending.values()) p.reject(new Error("disconnected"));
this._pending.clear();
setTimeout(() => this.connect(), this._backoff);
this._backoff = Math.min(8000, this._backoff * 2);
};
});
}
_applyState(msg) {
if (msg.version < this.version) return;
const firstLoad = this.doc === null;
this.version = msg.version;
this.doc = msg.doc;
this.task = msg.task;
if (firstLoad || this.assets.length !== msg.assets.length) {
this.assets = msg.assets;
this.assetById = new Map(msg.assets.map((a) => [a.id, a]));
}
this.canUndo = msg.can_undo;
this.canRedo = msg.can_redo;
const wasDone = this.done;
this.done = msg.done;
this.result = msg.result;
this.stepCount = msg.step_count;
this.maxSteps = msg.max_steps;
const ids = new Set(this.doc.elements.map((e) => e.id));
const kept = this.selection.filter((id) => ids.has(id));
if (kept.length !== this.selection.length) {
this.selection = kept;
this.emit("selection", this.selection);
}
this.emit("doc", { source: msg.source, firstLoad });
if (msg.source && msg.source !== this.source && !firstLoad) this.emit("remote", msg.source);
if (!wasDone && this.done) this.emit("done", this.result);
}
_send(payload) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("not connected"));
}
const id = ++this._seq;
const promise = new Promise((resolve, reject) => this._pending.set(id, { resolve, reject }));
this._ws.send(JSON.stringify({ ...payload, id }));
return promise;
}
// ---------------------------------------------------------------- edits
async op(name, args = {}) {
if (this.done) {
this.emit("toast", "This episode is finished.");
return null;
}
if (name === "update_element") this._optimistic(args);
try {
return await this._send({ type: "op", op: name, args });
} catch (e) {
this.emit("toast", e.message);
this.emit("doc", { source: "rollback" });
throw e;
}
}
_optimistic(args) {
const el = this.find(args.element_id);
if (!el) return;
const { element_id, ...props } = args;
Object.assign(el, props);
this.emit("doc", { source: "local" });
}
update(id, props) {
return this.op("update_element", { element_id: id, ...props });
}
async updateMany(changes) {
// Sequential so the undo history gets one entry per element in order.
for (const [id, props] of changes) await this.update(id, props);
}
undo() { return this._send({ type: "undo" }).catch((e) => this.emit("toast", e.message)); }
redo() { return this._send({ type: "redo" }).catch((e) => this.emit("toast", e.message)); }
submit() { return this._send({ type: "submit" }); }
// ---------------------------------------------------------------- queries
find(id) { return this.doc?.elements.find((e) => e.id === id) || null; }
selected() { return this.selection.map((id) => this.find(id)).filter(Boolean); }
index(id) { return this.doc.elements.findIndex((e) => e.id === id); }
select(ids, { add = false, toggle = false } = {}) {
ids = Array.isArray(ids) ? ids : ids ? [ids] : [];
let next;
if (toggle) {
const set = new Set(this.selection);
for (const id of ids) set.has(id) ? set.delete(id) : set.add(id);
next = [...set];
} else if (add) {
next = [...new Set([...this.selection, ...ids])];
} else next = ids;
const same = next.length === this.selection.length && next.every((id, i) => id === this.selection[i]);
if (same) return;
this.selection = next;
this.emit("selection", next);
}
setTool(tool) {
if (this.tool === tool) return;
this.tool = tool;
this.emit("tool", tool);
}
// ---------------------------------------------------------------- gestures
busy(on) {
this._busy = Math.max(0, this._busy + (on ? 1 : -1));
if (!this._busy && !this._pending.size) this.emit("idle");
}
// Resolves once no operation is in flight and no gesture is running. The
// computer-use driver awaits this before every screenshot.
whenIdle(timeout = 2000) {
return new Promise((resolve) => {
const start = performance.now();
const check = () => {
if ((!this._pending.size && !this._busy && !fontsLoading()) || performance.now() - start > timeout) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve(true)));
} else setTimeout(check, 16);
};
check();
});
}
}