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