File size: 7,072 Bytes
df84e45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | // 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();
});
}
}
|