DesignGym / web /js /main.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
23df34a verified
Raw History Blame Contribute Delete
20 kB
// Editor bootstrap: builds the chosen skin around the canvas, connects to the
// episode, and wires keyboard, dialogs and the computer-use hooks.
//
// /editor/?episode=<id>&skin=figma|canva|photoshop|slides|powerpoint|inkscape&theme=light|dark
// &layout=swap mirror the panels (layout randomisation)
// &agent=1 opened by the computer-use driver
import { Emitter, h, shortcut } from "./util.js";
import { icon } from "./icons.js";
import { Store } from "./store.js";
import { Stage } from "./stage.js";
import { createCommands, eventChord } from "./commands.js";
import { SKINS } from "./skins.js";
import { openFileChooser } from "./files.js";
import { ensureFonts, fontCatalog } from "./fonts.js";
import { closePopover, contextMenuAt, iconButton, modal, popoverOpen, toast } from "./widgets.js";
// Display names are neutral on purpose: the layouts are inspired by well-known
// editors, but no product name or logo appears in the UI.
const SKIN_LABELS = { figma: "Studio layout", canva: "Template layout", photoshop: "Pixel layout" };
class App extends Emitter {
constructor() {
super();
const q = new URLSearchParams(location.search);
this.params = {
episode: q.get("episode"),
skin: SKINS[q.get("skin")] ? q.get("skin") : "figma",
theme: q.get("theme") || null,
layout: q.get("layout") || "default",
agent: q.get("agent") === "1",
os: q.get("os"),
fs: q.get("fs"),
};
// The editor never shows the target (no reference panel, no overlay), for agents or people:
// it comes with the task (and view_reference); the playground shows it beside the editor.
// Nor does it offer the HTML code editor: on the GUI surface, writing the design as
// code would skip the skill being trained. HTML is its own surface; people keep it here.
this.showCode = !this.params.agent;
this.skin = SKINS[this.params.skin];
this.fgColor = this.skin.name === "inkscape" ? this.skin.fill || "#000000" : "#000000";
document.documentElement.dataset.skin = this.skin.name;
const theme = this.params.theme || (this.skin.name === "photoshop" ? "dark" : "light");
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.layout = this.params.layout;
}
async start() {
await fontCatalog().catch(() => null);
if (!this.params.episode) { // no design browser (it would show every task's answer): episodes come from reset()
this.fatal("Open an episode's editor_url (from reset / start_task) or start one in the playground at /web/.");
return;
}
this.store = new Store(this.params.episode, { source: this.params.agent ? "gui-agent" : "editor" });
this.commands = createCommands(this);
this.build();
this.store.on("fatal", (msg) => this.fatal(msg));
await this.store.connect();
await ensureFonts([...(this.store.task.fonts_used || []), ...this.store.doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family)]);
document.title = `${this.store.task.title} · DesignGym`;
this.stage.fit();
this.stage.render();
this.refreshAll();
window.designEditor = {
ready: true,
store: this.store,
app: this,
whenIdle: (t) => this.store.whenIdle(t),
};
}
build() {
const root = document.getElementById("app");
root.textContent = "";
root.className = `app skin-${this.skin.name}`;
this.skinApi = this.skin.build(this, root);
this.stage = new Stage(this, this.skinApi.stageHost);
this.presence = h("div", { class: "presence", hidden: true }, h("span", { class: "dot" }), h("span", { class: "who" }, "Agent editing"));
this.skinApi.stageHost.append(this.presence);
let queued = false;
const refresh = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; this.refreshAll(); });
};
this.store.on("doc", refresh);
this.store.on("selection", () => { this.frameSelected = false; if (this.freeTransform && !this.store.selection.length) this.freeTransform = false; }); // selecting a layer leaves the frame
this.store.on("selection", refresh);
this.store.on("tool", refresh);
this.on("zoom", refresh);
this.store.on("toast", (m) => toast(m, "error"));
this.store.on("remote", (src) => this.showPresence(src));
this.store.on("done", (result) => { if (!this._submitting) this.showResult(result); });
this.store.on("connection", (ok) => document.documentElement.classList.toggle("offline", !ok));
document.addEventListener("keydown", (e) => this.onKey(e));
document.addEventListener("keyup", (e) => { if (e.code === "Space") { this.stage.space = false; this.stage.el.dataset.space = ""; } });
}
refreshAll() {
if (!this.store?.doc) return;
this.skinApi.refresh();
if (this.skin.frameLabel) this.stage.drawOverlay(); // the frame's selected state lives on the canvas
for (const p of this.skinApi.panels) if (!p.el || p.el.isConnected) p.refresh?.();
for (const b of document.querySelectorAll("[data-command]")) {
const c = this.commands[b.dataset.command];
if (c && b.tagName === "BUTTON" && !b.closest(".menu")) {
if (b.refresh) b.refresh();
else b.disabled = c.enabled ? !c.enabled() : false;
}
}
}
// ------------------------------------------------------------ keyboard
onKey(e) {
const t = e.target;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable);
if (typing) return;
if (document.querySelector(".modal-back")) return;
if (e.code === "Space" && !e.repeat) {
this.stage.space = true;
this.stage.el.dataset.space = "1";
e.preventDefault();
return;
}
const chord = eventChord(e);
if (chord === "Escape" && this.stage.placing) {
this.stage.stopPlacing();
return;
}
if ((chord === "Enter" || chord === "Escape") && this.freeTransform) { this.setFreeTransform(false); return; } // commit the transform
if (chord === "Escape") {
if (popoverOpen()) { closePopover(); return; } // Escape closes a picker or menu first, as in every editor
if (this.fileChooser) { this.fileChooser.close(); return; } // then the file window
if (this.frameSelected) { this.frameSelected = false; this.refreshAll(); return; }
if (this.store.tool !== "move") this.store.setTool("move");
else this.store.select([]);
return;
}
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key) && this.store.selection.length) {
e.preventDefault();
const d = e.shiftKey ? 10 : 1;
const dx = e.key === "ArrowLeft" ? -d : e.key === "ArrowRight" ? d : 0;
const dy = e.key === "ArrowUp" ? -d : e.key === "ArrowDown" ? d : 0;
this.store.updateMany(this.store.selected().filter((el) => !el.locked).map((el) => [el.id, { x: el.x + dx, y: el.y + dy }]));
return;
}
if (chord === "Enter" && this.store.selection.length === 1) {
const el = this.store.find(this.store.selection[0]);
if (el?.kind === "text") { e.preventDefault(); this.stage.startEdit(el.id); return; }
}
// Figma: with a layer selected, 1-9 set its opacity to 10-90% and 0 to 100%.
if (this.skin.name === "figma" && /^[0-9]$/.test(chord) && this.store.selection.length) {
e.preventDefault();
const v = chord === "0" ? 1 : Number(chord) / 10;
this.store.updateMany(this.store.selected().filter((el) => !el.locked).map((el) => [el.id, { opacity: v }]));
return;
}
const alias = { "Mod+Y": "redo", "Mod+Shift+Z": "redo", "Mod+=": "zoomIn", "Mod+-": "zoomOut", "Mod+0": "zoomFit" };
let cmd = alias[chord] ? this.commands[alias[chord]] : Object.values(this.commands).find((c) => c.key === chord);
if (!cmd && chord === "Delete") cmd = this.commands.delete;
if (cmd) {
e.preventDefault();
if (!cmd.enabled || cmd.enabled()) cmd.run();
}
}
// ------------------------------------------------------------ adding
defaultFill() { return this.usesFg() ? this.fgColor : this.skin.fill; }
usesFg() { return this.skin.name === "photoshop" || this.skin.name === "inkscape"; } // new objects take the current colour
setFg(c) { this.fgColor = c; this.refreshAll(); }
async addAsset(asset, point = null, box = null) {
const doc = this.store.doc;
const side = 0.4 * Math.min(doc.width, doc.height);
let w = asset.aspect >= 1 ? side * asset.aspect : side, hh = asset.aspect >= 1 ? side : side / asset.aspect;
if (w > doc.width) { w = doc.width; hh = w / asset.aspect; }
const [cx, cy] = point || [doc.width / 2, doc.height / 2];
const b = box || { x: cx - w / 2, y: cy - hh / 2, width: w, height: hh };
const r = await this.store.op("add_image", { asset_id: asset.id, x: Math.round(b.x), y: Math.round(b.y),
width: Math.round(b.width), height: Math.round(b.height) });
if (r?.element_id) this.store.select(r.element_id);
return r;
}
/** The file window, opened the way this editor opens it (Insert > Pictures, Place, Import, Upload). */
openFiles(opts = {}) {
return openFileChooser(this, {
onOpen: (assets) => this.placeAssets(assets),
onDrop: (assets, point) => this.placeAssets(assets, point, { dropped: true }),
...this.skinApi.fileOptions?.(), ...opts,
});
}
/** Put chosen or dropped images into the design: each editor's own way, else one after another at the centre. */
async placeAssets(assets, point = null, how = {}) {
if (this.skinApi.placeAssets) return this.skinApi.placeAssets(assets, point, how);
return this.placeAt(assets, point);
}
/** Default placement: at the drop point (or the centre), each further image a little down and right. */
async placeAt(assets, point = null) {
const ids = [];
for (const [i, a] of assets.entries()) {
const p = point ? [point[0] + 24 * i, point[1] + 24 * i] : null;
const r = await this.addAsset(a, p);
if (r?.element_id) ids.push(r.element_id);
}
if (ids.length) this.store.select(ids);
}
defaultFont() {
return this.store.task?.fonts_used?.[0] || "Montserrat";
}
async addTextAt(x, y) {
const size = Math.max(12, Math.round(this.store.doc.height / 20));
const r = await this.store.op("add_text", { text: "Text", x: Math.round(x), y: Math.round(y - size / 2),
font_family: this.defaultFont(), font_size: size, color: this.skin.name === "photoshop" ? this.fgColor : "#000000" });
if (r?.element_id) {
this.store.select(r.element_id);
await this.store.whenIdle(500);
this.stage.startEdit(r.element_id, { selectAll: true });
}
}
async addTextPreset(props) {
const r = await this.store.op("add_text", { font_family: this.defaultFont(), color: "#000000", ...props });
if (r?.element_id) this.store.select(r.element_id);
// Canva drops a text style onto the page already editing, its words selected: typing replaces them.
if (r?.element_id && this.skin.name === "canva") requestAnimationFrame(() => this.stage.startEdit(r.element_id, { selectAll: true }));
}
async addShapePreset(shape, radiusFrac = 0) {
const doc = this.store.doc;
const side = Math.round(Math.min(doc.width, doc.height) * 0.3);
const r = await this.store.op("add_shape", { shape, width: side, height: side, fill: this.defaultFill(), radius: Math.round(side * radiusFrac) });
if (r?.element_id) this.store.select(r.element_id);
}
// ------------------------------------------------------------ menus
floatingToolbar(rect) {
const C = this.commands;
const bar = h("div", { class: "floating-toolbar" },
iconButton("lock", "Lock", () => C.lock.run()),
iconButton("copy", "Duplicate", () => C.duplicate.run()),
iconButton("trash-2", "Delete", () => C.delete.run()),
iconButton("more-horizontal", "More", (e) => this.contextMenu(e.clientX, e.clientY, true)));
const top = Math.max(8, rect.y0 - 48);
Object.assign(bar.style, { left: `${(rect.x0 + rect.x1) / 2}px`, top: `${top}px` });
bar.addEventListener("pointerdown", (e) => e.stopPropagation());
return bar;
}
/** Photoshop: handles show with Show Transform Controls on, or during Free Transform (Mod+T). */
showHandles() { return this.skin.name !== "photoshop" || this.transformControls || this.freeTransform; }
setFreeTransform(on) { this.freeTransform = !!on; this.stage.drawOverlay(); this.refreshAll(); }
/** Figma: the frame itself is selected (its Fill is the design's background). */
selectFrame() {
this.store.select([]);
this.frameSelected = true;
this.refreshAll();
}
/** The colour of the canvas around the design (Figma's page colour); it is not part of the design. */
setCanvasColor(c) {
this.canvasColor = c;
this.stage.el.style.background = c;
this.refreshAll();
}
contextMenu(x, y, onElement) {
const C = this.commands;
const own = this.skinApi.contextItems?.(onElement);
if (own) { contextMenuAt(x, y, own); return; }
const items = onElement
? [{ cmd: C.copy }, { cmd: C.cut }, { cmd: C.paste }, { cmd: C.duplicate }, { cmd: C.delete }, "-",
{ cmd: C.bringFront }, { cmd: C.bringForward }, { cmd: C.sendBackward }, { cmd: C.sendBack }, "-",
{ cmd: C.flipH }, { cmd: C.flipV }, { cmd: C.lock }, { cmd: C.hide }]
: [{ cmd: C.paste }, { cmd: C.selectAll }, "-", { cmd: C.zoomFit }, { cmd: C.zoom100 },
...(this.showCode ? ["-", { cmd: C.html }] : [])];
contextMenuAt(x, y, items);
}
toast(m) { toast(m); }
showPresence(src) {
if (src === "editor" || src === "rollback" || src === "local") return;
this.presence.hidden = false;
this.presence.querySelector(".who").textContent = src === "agent" ? "Agent editing" : src === "gui-agent" ? "Agent (GUI) editing" : `${src} editing`;
clearTimeout(this._presenceT);
this._presenceT = setTimeout(() => { this.presence.hidden = true; }, 1600);
}
// ------------------------------------------------------------ dialogs
async openHtml() {
if (!this.showCode) return;
const res = await fetch(`/api/episodes/${this.store.episodeId}/html`);
const text = await res.text();
const area = h("textarea", { class: "code-area", spellcheck: "false", "aria-label": "HTML source" }, text);
const msg = h("div", { class: "code-msg" }, "Every direct child of #canvas is one element, back to front. Apply replaces the canvas.");
area.addEventListener("keydown", (e) => e.stopPropagation());
modal("Edit design as HTML", h("div", { class: "code-wrap" }, area, msg), {
cls: "modal-code",
actions: [
{ label: "Copy", run: async () => { await navigator.clipboard?.writeText(area.value).catch(() => null); toast("Copied"); return true; } },
{ label: "Cancel" },
{ label: "Apply", primary: true, run: async () => {
const r = await fetch(`/api/episodes/${this.store.episodeId}/html`, { method: "POST", headers: { "content-type": "text/plain" }, body: area.value });
const body = await r.json();
if (!r.ok) { msg.textContent = body.detail || "Rejected"; msg.classList.add("err"); return true; }
toast(body.warnings?.length ? `Applied with ${body.warnings.length} warning(s)` : "Applied");
return false;
} },
],
});
area.focus();
}
exportPng() {
const a = h("a", { href: `/api/episodes/${this.store.episodeId}/render.png?size=2048`, download: `${(this.store.task.title || "design").replace(/\W+/g, "-")}.png` });
document.body.append(a);
a.click();
a.remove();
}
confirmSubmit() {
if (this.store.done) { this.showResult(this.store.result); return; }
const t = this.store.task || {};
const name = `${(t.title || "design").replace(/[^\w\- ]+/g, "").trim() || "design"}.png`;
const row = (k, v) => h("div", { class: "export-row" }, h("span", { class: "export-k" }, k), h("span", { class: "export-v" }, v));
const body = h("div", { class: "export-body" },
row("File name", name), row("File type", "PNG"),
row("Size", t.canvas ? `${t.canvas.width} × ${t.canvas.height} px` : "Original"),
h("p", { class: "modal-text" }, "Exporting hands in the finished design. It can't be edited afterwards."));
modal("Export", body, {
cls: "modal-small modal-export",
actions: [
{ label: "Cancel" },
{ label: "Export", primary: true, run: async () => {
this._submitting = true;
try {
const result = await this.store.submit();
if (!this.params.agent) this.exportPng(); // people also get the file
this.showResult(result);
} catch (e) { toast(e.message, "error"); } finally { this._submitting = false; }
} },
],
});
}
showResult(r) {
if (!r) return;
const pct = (v) => `${Math.round(v * 1000) / 10}`;
const bar = (label, v) => h("div", { class: "score-row" }, h("span", { class: "score-label" }, label),
h("span", { class: "score-bar" }, h("span", { style: { width: `${Math.round(v * 100)}%` } })), h("span", { class: "score-val" }, pct(v)));
const rows = [bar("Structure", r.structure), bar("Visual", r.visual)];
if (r.mode === "description") rows.push(bar("Coverage", r.coverage));
rows.push(bar("Order", r.order), bar("Background", r.background));
const worst = [...r.elements].sort((a, b) => a.score - b.score).slice(0, 5).filter((e) => e.score < 0.95);
const body = h("div", { class: "result" },
h("div", { class: "result-top" }, h("div", { class: "result-score" }, pct(r.total), h("small", {}, "/ 100")),
h("div", { class: "result-sub" }, `Matched ${r.n_matched} of ${r.n_gt} target elements · ${r.n_submitted} on the canvas`)),
h("div", { class: "result-grid" },
h("figure", {}, h("img", { src: this.store.task.reference_url || "", alt: "Target", hidden: !this.store.task.reference_url }), h("figcaption", {}, this.store.task.reference_url ? "Target" : "")),
h("figure", {}, h("img", { src: `/api/episodes/${this.store.episodeId}/render.png?size=768&v=${this.store.version}`, alt: "Your design" }), h("figcaption", {}, "Your design"))),
h("div", { class: "score-rows" }, rows),
worst.length ? h("div", { class: "result-notes" }, h("div", { class: "panel-heading" }, "Biggest differences"),
worst.map((e) => h("div", { class: "note" }, h("b", {}, e.gt_id), ` ${pct(e.score)} · ${e.note || "position / size"}`))) : null);
modal("Design exported", body, { cls: "modal-result", actions: [{ label: "Close", primary: true }] });
}
showShortcuts() {
const rows = Object.values(this.commands).filter((c) => c.key && !(c === this.commands.html && !this.showCode)).map((c) =>
h("div", { class: "sc-row" }, h("span", {}, c.label), h("kbd", {}, shortcut(c.key))));
rows.push(h("div", { class: "sc-row" }, h("span", {}, "Nudge (Shift = 10px)"), h("kbd", {}, "← ↑ → ↓")),
h("div", { class: "sc-row" }, h("span", {}, "Pan"), h("kbd", {}, "Space + drag")),
h("div", { class: "sc-row" }, h("span", {}, "Zoom"), h("kbd", {}, `${shortcut("Mod")} + scroll`)),
h("div", { class: "sc-row" }, h("span", {}, "Edit text"), h("kbd", {}, "Double-click / Enter")));
modal("Keyboard shortcuts", h("div", { class: "sc-grid" }, rows), { cls: "modal-small" });
}
fatal(msg) {
const root = document.getElementById("app");
root.textContent = "";
root.append(h("div", { class: "fatal" }, h("div", { html: icon("triangle-alert", 32) }), h("h2", {}, "This episode is not available"),
h("p", {}, msg), h("a", { class: "btn btn-primary", href: "/web/" }, "Open the playground")));
}
}
const app = new App();
window.__app = app;
app.start().catch((e) => { console.error(e); app.fatal?.(String(e)); });