Download web/js/store.js from FineEnvs/DesignGym: direct link, hf CLI and curl.
- Browser
- Download file 7.07 kB
-
https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/store.js
- Command line
-
hf download hf://spaces/FineEnvs/DesignGym/web/js/store.js
-
curl -L -o store.js https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/store.js
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(); | |
| }); | |
| } | |
| } | |