Download web/js/files.js from FineEnvs/DesignGym: direct link, hf CLI and curl.
- Browser
- Download file 21.5 kB
-
https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/files.js
- Command line
-
hf download hf://spaces/FineEnvs/DesignGym/web/js/files.js
-
curl -L -o files.js https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/web/js/files.js
21.5 kB
| // The computer's file chooser, drawn inside the page. | |
| // | |
| // Real editors bring images in through the operating system: Insert > Pictures > | |
| // This Device, File > Place Embedded, File > Import, Upload files. A headless | |
| // browser has no native dialog to show, so the editor draws one, in the look of | |
| // macOS, Windows or GNOME (chosen per episode), holding the task's images as | |
| // files. Like a real file window it moves by its title bar, resizes from its | |
| // edges, and its files can be dragged straight onto the canvas. | |
| import { clamp, h } from "./util.js"; | |
| import { icon } from "./icons.js"; | |
| const OSES = ["mac", "windows", "linux"]; | |
| const FOLDERS = ["Downloads", "Desktop", "Pictures", "Documents"]; | |
| function hash(text) { | |
| let x = 2166136261; | |
| for (let i = 0; i < text.length; i++) x = Math.imul(x ^ text.charCodeAt(i), 16777619); | |
| return x >>> 0; | |
| } | |
| // Sidebar places, in each system's own order and wording. | |
| const PLACES = { | |
| mac: [ | |
| ["Favorites", [["recents", "Recents", "clock"], ["desktop", "Desktop", "monitor"], ["documents", "Documents", "file"], | |
| ["downloads", "Downloads", "download"], ["pictures", "Pictures", "image"]]], | |
| ["Locations", [["icloud", "iCloud Drive", "cloud"], ["disk", "Macintosh HD", "hard-drive"]]], | |
| ], | |
| windows: [ | |
| ["", [["home", "Home", "house"]]], | |
| ["", [["desktop", "Desktop", "monitor"], ["downloads", "Downloads", "download"], ["documents", "Documents", "file"], | |
| ["pictures", "Pictures", "image"]]], | |
| ["", [["disk", "This PC", "hard-drive"]]], | |
| ], | |
| linux: [ | |
| ["", [["recents", "Recent", "clock"], ["home", "Home", "house"], ["desktop", "Desktop", "monitor"], | |
| ["documents", "Documents", "file"], ["downloads", "Downloads", "download"], ["pictures", "Pictures", "image"]]], | |
| ["", [["disk", "Other Locations", "hard-drive"]]], | |
| ], | |
| }; | |
| const placeNames = (os) => Object.fromEntries(PLACES[os].flatMap(([, items]) => items).map(([id, name]) => [id, name])); | |
| /** Whether this episode starts with the task's images already uploaded (Canva's Uploads). */ | |
| export function preUploaded(app) { | |
| return hash(`${app.store?.episodeId || "episode"}:uploads`) % 2 === 0; | |
| } | |
| /** Which system, folder and view this episode's chooser uses (stable for the episode). */ | |
| export function chooserLook(app) { | |
| const key = app.params.fs || app.store?.episodeId || "episode"; | |
| const os = OSES.includes(app.params.os) ? app.params.os : OSES[hash(`${key}:os`) % OSES.length]; | |
| const r = hash(`${key}:files`); | |
| return { os, folder: FOLDERS[r % FOLDERS.length], view: (r >> 4) % 2 ? "list" : "grid", offset: ((r >> 8) % 60) - 30 }; | |
| } | |
| function fileFor(asset, os) { | |
| const r = hash(asset.id); | |
| const photo = asset.kind === "photo" || asset.kind === "background"; | |
| const ext = photo ? "jpg" : "png"; | |
| const bytes = photo ? 900_000 + (r % 3_600_000) : 30_000 + (r % 820_000); | |
| const mins = (r >> 5) % 600, hh = 8 + Math.floor(mins / 60), mm = String(mins % 60).padStart(2, "0"); | |
| const size = bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`; | |
| const ampm = hh >= 12 ? "PM" : "AM", h12 = ((hh + 11) % 12) + 1; | |
| const date = { mac: `Today at ${h12}:${mm} ${ampm}`, windows: `24/09/2026 ${String(hh).padStart(2, "0")}:${mm}`, linux: `${hh}:${mm}` }[os]; | |
| const kind = { mac: photo ? "JPEG image" : "PNG image", windows: photo ? "JPG File" : "PNG File", linux: photo ? "JPEG image" : "PNG image" }[os]; | |
| return { type: "file", id: asset.id, name: `${asset.id}.${ext}`, asset, size, date, kind }; | |
| } | |
| /** Open (or bring forward) the chooser. `onOpen(assets)` runs on Open; `onDrop(assets, point)` on a drop onto the canvas. */ | |
| export function openFileChooser(app, { title = "Open", action = "Open", multiple = true, onOpen, onDrop } = {}) { | |
| if (app.fileChooser?.el.isConnected) { | |
| app.fileChooser.retarget({ title, action, onOpen, onDrop }); | |
| app.fileChooser.focus(); | |
| return app.fileChooser; | |
| } | |
| const chooser = new FileChooser(app, { title, action, multiple, onOpen, onDrop }); | |
| app.fileChooser = chooser; | |
| return chooser; | |
| } | |
| class FileChooser { | |
| constructor(app, opts) { | |
| this.app = app; | |
| this.opts = opts; | |
| const look = chooserLook(app); | |
| this.os = look.os; | |
| this.names = placeNames(look.os); | |
| this.view = look.view; | |
| this.home = look.folder.toLowerCase(); | |
| this.place = this.home; | |
| this.history = [this.place]; | |
| this.hi = 0; | |
| this.selected = new Set(); | |
| this.anchor = null; | |
| this.query = ""; | |
| this.files = app.store.assets.map((a) => fileFor(a, this.os)); | |
| this.build(look); | |
| this.render(); | |
| this.focus(); | |
| } | |
| // ------------------------------------------------------------ contents | |
| entries() { | |
| const q = this.query.trim().toLowerCase(); | |
| let items = []; | |
| if (this.place === this.home || this.place === "recents" || (this.os === "windows" && this.place === "home")) items = this.files; | |
| else if (this.place === "home" || this.place === "disk") { | |
| items = FOLDERS.map((f) => ({ type: "folder", id: f.toLowerCase(), name: f })); | |
| } | |
| return q ? items.filter((e) => e.name.toLowerCase().includes(q)) : items; | |
| } | |
| go(place, push = true) { | |
| if (!this.names[place]) return; | |
| this.place = place; | |
| this.selected.clear(); | |
| this.query = ""; | |
| if (this.search) this.search.value = ""; | |
| if (push) { | |
| this.history = this.history.slice(0, this.hi + 1).concat(place); | |
| this.hi = this.history.length - 1; | |
| } | |
| this.render(); | |
| } | |
| // ------------------------------------------------------------ building | |
| build(look) { | |
| const os = this.os; | |
| const btn = (ic, title, onclick, cls = "") => h("button", { class: `fc-btn ${cls}`, type: "button", title, "aria-label": title, onclick, | |
| html: icon(ic, 15) }); | |
| this.backBtn = btn("chevron-left", "Back", () => { if (this.hi > 0) { this.hi--; this.go(this.history[this.hi], false); } }); | |
| this.fwdBtn = btn("chevron-right", "Forward", () => { if (this.hi < this.history.length - 1) { this.hi++; this.go(this.history[this.hi], false); } }); | |
| this.viewBtns = [["grid", "layout-grid", os === "mac" ? "as Icons" : "Large icons"], ["list", "list", os === "mac" ? "as List" : "Details"]] | |
| .map(([v, ic, title]) => { | |
| const b = btn(ic, title, () => { this.view = v; this.render(); }, "fc-view"); | |
| b.dataset.view = v; | |
| return b; | |
| }); | |
| this.search = h("input", { class: "fc-search", type: "search", spellcheck: "false", "aria-label": "Search" }); | |
| this.search.addEventListener("input", () => { this.query = this.search.value; this.render(); }); | |
| this.list = h("div", { class: "fc-files", role: "listbox", "aria-multiselectable": String(this.opts.multiple), tabindex: "0" }); | |
| this.list.addEventListener("pointerdown", (e) => { if (e.target === this.list) { this.selected.clear(); this.sync(); } }); | |
| this.side = h("nav", { class: "fc-side", "aria-label": "Places" }); | |
| for (const [section, items] of PLACES[os]) { | |
| const group = h("div", { class: "fc-side-group" }, section ? h("div", { class: "fc-side-title" }, section) : null); | |
| for (const [id, name, ic] of items) { | |
| group.append(h("button", { class: "fc-place", type: "button", dataset: { place: id }, onclick: () => this.go(id) }, | |
| h("span", { class: "fc-place-ic", html: icon(ic, 15) }), h("span", {}, name))); | |
| } | |
| this.side.append(group); | |
| } | |
| this.openBtn = h("button", { class: "fc-action fc-open", type: "button", dataset: { fc: "open" }, onclick: () => this.commit() }, this.opts.action); | |
| this.cancelBtn = h("button", { class: "fc-action fc-cancel", type: "button", dataset: { fc: "cancel" }, onclick: () => this.close() }, "Cancel"); | |
| const grow = () => h("span", { class: "grow" }); | |
| let top, foot; | |
| if (os === "mac") { | |
| this.search.placeholder = "Search"; | |
| this.pathSel = h("select", { class: "fc-path", "aria-label": "Folder" }); | |
| this.pathSel.addEventListener("change", () => this.go(this.pathSel.value)); | |
| const lights = h("div", { class: "fc-lights" }, | |
| h("button", { class: "fc-light close", type: "button", title: "Close", "aria-label": "Close", dataset: { fc: "close" }, onclick: () => this.close() }), | |
| h("span", { class: "fc-light off" }), h("span", { class: "fc-light off" })); | |
| top = h("div", { class: "fc-top fc-drag" }, lights, this.backBtn, this.fwdBtn, h("div", { class: "fc-seg" }, ...this.viewBtns), | |
| grow(), this.pathSel, grow(), h("div", { class: "fc-search-box" }, h("span", { html: icon("search", 13) }), this.search)); | |
| foot = h("div", { class: "fc-foot" }, grow(), this.cancelBtn, this.openBtn); | |
| } else if (os === "windows") { | |
| this.crumb = h("div", { class: "fc-crumb" }); | |
| this.nameInput = h("input", { class: "fc-name", spellcheck: "false", "aria-label": "File name" }); | |
| this.nameInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); this.commit(); } }); | |
| this.nameInput.addEventListener("input", () => { this.selected.clear(); this.sync(); }); | |
| const filter = h("select", { class: "fc-filter", "aria-label": "File type" }, h("option", {}, "All Pictures (*.png;*.jpg;*.jpeg)")); | |
| top = h("div", {}, | |
| h("div", { class: "fc-title fc-drag" }, h("span", { class: "fc-app", html: icon("app-window", 14) }), h("span", {}, this.opts.title), grow(), | |
| h("button", { class: "fc-winclose", type: "button", title: "Close", "aria-label": "Close", dataset: { fc: "close" }, onclick: () => this.close(), | |
| html: icon("x", 16) })), | |
| h("div", { class: "fc-nav" }, this.backBtn, this.fwdBtn, this.crumb, | |
| h("div", { class: "fc-search-box" }, this.search, h("span", { html: icon("search", 13) })), h("div", { class: "fc-seg" }, ...this.viewBtns))); | |
| foot = h("div", { class: "fc-foot" }, h("label", { class: "fc-name-label" }, "File name:", this.nameInput), filter, | |
| h("div", { class: "fc-foot-btns" }, this.openBtn, this.cancelBtn)); | |
| } else { | |
| this.crumb = h("div", { class: "fc-crumb" }); | |
| this.search.placeholder = "Search"; | |
| const filter = h("select", { class: "fc-filter", "aria-label": "File type" }, h("option", {}, "Image Files")); | |
| top = h("div", {}, | |
| h("div", { class: "fc-head fc-drag" }, this.cancelBtn, grow(), h("span", { class: "fc-head-title" }, this.opts.title === "Open" ? "Open File" : this.opts.title), | |
| grow(), this.openBtn), | |
| h("div", { class: "fc-nav" }, this.backBtn, this.fwdBtn, this.crumb, grow(), | |
| h("div", { class: "fc-search-box" }, h("span", { html: icon("search", 13) }), this.search), h("div", { class: "fc-seg" }, ...this.viewBtns))); | |
| foot = h("div", { class: "fc-foot" }, grow(), filter); | |
| } | |
| const handles = ["n", "s", "e", "w", "ne", "nw", "se", "sw"].map((d) => h("div", { class: `fc-rz fc-rz-${d}`, dataset: { dir: d } })); | |
| this.el = h("div", { class: `fc fc-${os}`, role: "dialog", "aria-label": this.opts.title, tabindex: "-1", dataset: { os } }, | |
| h("div", { class: "fc-frame" }, top, h("div", { class: "fc-main" }, this.side, this.list), foot), ...handles); | |
| const W = Math.min(820, innerWidth - 40), H = Math.min(520, innerHeight - 60); | |
| this.box = { x: Math.round((innerWidth - W) / 2 + look.offset), y: Math.round(Math.max(20, (innerHeight - H) / 2 - 30 + look.offset / 2)), w: W, h: H }; | |
| this.place_(); | |
| document.body.append(this.el); | |
| this.el.addEventListener("keydown", (e) => this.onKey(e)); | |
| for (const d of this.el.querySelectorAll(".fc-drag")) d.addEventListener("pointerdown", (e) => this.startMove(e)); | |
| for (const r of handles) r.addEventListener("pointerdown", (e) => this.startResize(e, r.dataset.dir)); | |
| } | |
| place_() { | |
| const b = this.box; | |
| Object.assign(this.el.style, { left: `${b.x}px`, top: `${b.y}px`, width: `${b.w}px`, height: `${b.h}px` }); | |
| } | |
| // ------------------------------------------------------------ window: move and resize | |
| startMove(e) { | |
| if (e.button !== 0 || e.target.closest("button, input, select")) return; | |
| e.preventDefault(); | |
| const x0 = e.clientX, y0 = e.clientY, b0 = { ...this.box }; | |
| const move = (ev) => { | |
| this.box.x = clamp(b0.x + ev.clientX - x0, 40 - b0.w, innerWidth - 60); | |
| this.box.y = clamp(b0.y + ev.clientY - y0, 0, innerHeight - 40); | |
| this.place_(); | |
| }; | |
| const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); }; | |
| addEventListener("pointermove", move); | |
| addEventListener("pointerup", up); | |
| } | |
| startResize(e, dir) { | |
| if (e.button !== 0) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const x0 = e.clientX, y0 = e.clientY, b0 = { ...this.box }; | |
| const MINW = 460, MINH = 300; | |
| const move = (ev) => { | |
| const dx = ev.clientX - x0, dy = ev.clientY - y0, b = { ...b0 }; | |
| if (dir.includes("e")) b.w = Math.max(MINW, b0.w + dx); | |
| if (dir.includes("s")) b.h = Math.max(MINH, b0.h + dy); | |
| if (dir.includes("w")) { b.w = Math.max(MINW, b0.w - dx); b.x = b0.x + b0.w - b.w; } | |
| if (dir.includes("n")) { b.h = Math.max(MINH, b0.h - dy); b.y = Math.max(0, b0.y + b0.h - b.h); } | |
| this.box = b; | |
| this.place_(); | |
| }; | |
| const up = () => { removeEventListener("pointermove", move); removeEventListener("pointerup", up); }; | |
| addEventListener("pointermove", move); | |
| addEventListener("pointerup", up); | |
| } | |
| // ------------------------------------------------------------ rendering | |
| render() { | |
| const entries = this.entries(); | |
| this.el.dataset.view = this.view; | |
| this.list.className = `fc-files fc-${this.view}`; | |
| this.list.textContent = ""; | |
| if (this.view === "list") { | |
| this.list.append(h("div", { class: "fc-row fc-row-head", "aria-hidden": "true" }, | |
| h("span", { class: "fc-col-name" }, "Name"), h("span", { class: "fc-col-date" }, this.os === "mac" ? "Date Modified" : "Modified"), | |
| h("span", { class: "fc-col-size" }, "Size"), h("span", { class: "fc-col-kind" }, this.os === "windows" ? "Type" : "Kind"))); | |
| } | |
| for (const e of entries) this.list.append(this.item(e)); | |
| if (!entries.length) this.list.append(h("div", { class: "fc-empty" }, this.query ? "No results" : this.os === "windows" ? "This folder is empty." : "No items")); | |
| for (const p of this.side.querySelectorAll(".fc-place")) p.classList.toggle("on", p.dataset.place === this.place); | |
| for (const b of this.viewBtns) b.classList.toggle("on", b.dataset.view === this.view); | |
| this.backBtn.disabled = this.hi <= 0; | |
| this.fwdBtn.disabled = this.hi >= this.history.length - 1; | |
| const name = this.names[this.place]; | |
| if (this.pathSel) { | |
| this.pathSel.textContent = ""; | |
| for (const [id, label] of Object.entries(this.names)) this.pathSel.append(h("option", { value: id, selected: id === this.place }, label)); | |
| } | |
| if (this.crumb) { | |
| this.crumb.textContent = ""; | |
| const trail = this.os === "windows" | |
| ? (this.place === "home" ? ["Home"] : this.place === "disk" ? ["This PC"] : ["This PC", name]) | |
| : (this.place === "home" ? ["user"] : this.place === "disk" ? ["Other Locations"] : ["user", name]); | |
| trail.forEach((t, i) => { | |
| if (i) this.crumb.append(h("span", { class: "fc-crumb-sep", html: icon("chevron-right", 12) })); | |
| const home = this.os === "linux" && t === "user"; | |
| this.crumb.append(h("span", { class: "fc-crumb-part" }, home ? h("span", { html: icon("house", 13) }) : null, t)); | |
| }); | |
| } | |
| if (this.search) this.search.placeholder = this.os === "windows" ? `Search ${name}` : "Search"; | |
| this.sync(); | |
| } | |
| // Selection changes restyle the items in place: rebuilding them under the pointer would swallow | |
| // the click (and so the double-click) that is still in progress. | |
| sync() { | |
| for (const el of this.list.querySelectorAll(".fc-item")) { | |
| const on = this.selected.has(el.dataset.file); | |
| el.classList.toggle("on", on); | |
| el.setAttribute("aria-selected", String(on)); | |
| } | |
| const files = [...this.selected].map((id) => this.files.find((f) => f.id === id)).filter(Boolean); | |
| this.openBtn.disabled = !files.length && !(this.nameInput && this.nameInput.value.trim()); | |
| if (this.nameInput && document.activeElement !== this.nameInput) { | |
| this.nameInput.value = files.length > 1 ? files.map((f) => `"${f.name}"`).join(" ") : files[0]?.name || ""; | |
| } | |
| } | |
| item(e) { | |
| const sel = this.selected.has(e.id); | |
| const thumb = e.type === "file" | |
| ? h("div", { class: "fc-thumb" }, h("img", { src: e.asset.url, alt: "", draggable: "false", loading: "lazy" })) | |
| : h("div", { class: "fc-thumb fc-folder", html: icon("folder", this.view === "grid" ? 44 : 16) }); | |
| const el = this.view === "grid" | |
| ? h("div", { class: `fc-item${sel ? " on" : ""}`, role: "option", "aria-selected": String(sel), dataset: { file: e.id, type: e.type }, title: e.name }, | |
| thumb, h("div", { class: "fc-item-name" }, e.name)) | |
| : h("div", { class: `fc-row fc-item${sel ? " on" : ""}`, role: "option", "aria-selected": String(sel), dataset: { file: e.id, type: e.type }, title: e.name }, | |
| h("span", { class: "fc-col-name" }, thumb, h("span", { class: "fc-item-name" }, e.name)), | |
| h("span", { class: "fc-col-date" }, e.type === "file" ? e.date : "--"), h("span", { class: "fc-col-size" }, e.type === "file" ? e.size : "--"), | |
| h("span", { class: "fc-col-kind" }, e.type === "file" ? e.kind : "Folder")); | |
| el.addEventListener("pointerdown", (ev) => this.onItemDown(ev, e)); | |
| el.addEventListener("dblclick", () => { | |
| if (e.type === "folder") this.go(e.id); | |
| else { this.selected = new Set([e.id]); this.commit(); } | |
| }); | |
| return el; | |
| } | |
| // ------------------------------------------------------------ selection, opening, dragging out | |
| onItemDown(ev, e) { | |
| if (ev.button !== 0) return; | |
| ev.stopPropagation(); | |
| const ids = this.entries().map((x) => x.id); | |
| const mod = ev.metaKey || ev.ctrlKey; | |
| if (ev.shiftKey && this.anchor && this.opts.multiple) { | |
| const [a, b] = [ids.indexOf(this.anchor), ids.indexOf(e.id)].sort((p, q) => p - q); | |
| this.selected = new Set(ids.slice(a, b + 1)); | |
| } else if (mod && this.opts.multiple) { | |
| if (this.selected.has(e.id)) this.selected.delete(e.id); else this.selected.add(e.id); | |
| this.anchor = e.id; | |
| } else if (!this.selected.has(e.id)) { | |
| this.selected = new Set([e.id]); | |
| this.anchor = e.id; | |
| } | |
| const wasOn = this.selected.has(e.id); | |
| this.sync(); | |
| if (e.type !== "file") return; | |
| const x0 = ev.clientX, y0 = ev.clientY; | |
| let ghost = null; | |
| const dragged = () => [...this.selected].map((id) => this.files.find((f) => f.id === id)).filter(Boolean); | |
| const move = (m) => { | |
| if (!ghost && Math.hypot(m.clientX - x0, m.clientY - y0) > 5) { | |
| const fs = dragged(); | |
| ghost = h("div", { class: "drag-ghost fc-ghost" }, h("img", { src: fs[0].asset.url, alt: "" }), | |
| fs.length > 1 ? h("span", { class: "fc-ghost-count" }, String(fs.length)) : null); | |
| document.body.append(ghost); | |
| } | |
| if (ghost) Object.assign(ghost.style, { left: `${m.clientX}px`, top: `${m.clientY}px` }); | |
| }; | |
| const up = async (u) => { | |
| removeEventListener("pointermove", move); | |
| removeEventListener("pointerup", up); | |
| if (!ghost) { | |
| if (wasOn && !ev.shiftKey && !mod && this.selected.size > 1) { this.selected = new Set([e.id]); this.sync(); } | |
| return; | |
| } | |
| ghost.remove(); | |
| if (this.el.contains(document.elementFromPoint(u.clientX, u.clientY))) return; // dropped back into the window | |
| const point = this.app.stage.dropPoint(u.clientX, u.clientY); | |
| if (point) await (this.opts.onDrop || ((assets, p) => this.app.placeAssets(assets, p)))(dragged().map((f) => f.asset), point); | |
| }; | |
| addEventListener("pointermove", move); | |
| addEventListener("pointerup", up); | |
| } | |
| async commit() { | |
| let files = [...this.selected].map((id) => this.files.find((f) => f.id === id)).filter(Boolean); | |
| if (!files.length && this.nameInput) { // typed into Windows' "File name" box | |
| const want = this.nameInput.value.split('"').map((s) => s.trim()).filter(Boolean); | |
| files = this.files.filter((f) => want.includes(f.name) || want.includes(f.id)); | |
| } | |
| const folder = [...this.selected].map((id) => this.entries().find((x) => x.id === id)).find((x) => x?.type === "folder"); | |
| if (!files.length && folder) { this.go(folder.id); return; } | |
| if (!files.length) return; | |
| this.close(); | |
| await (this.opts.onOpen || ((assets) => this.app.placeAssets(assets)))(files.map((f) => f.asset)); | |
| } | |
| onKey(e) { | |
| if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); this.close(); } | |
| else if (e.key === "Enter" && e.target !== this.nameInput) { e.preventDefault(); e.stopPropagation(); this.commit(); } | |
| else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a" && e.target.tagName !== "INPUT") { | |
| e.preventDefault(); e.stopPropagation(); | |
| this.selected = new Set(this.entries().filter((x) => x.type === "file").map((x) => x.id)); | |
| this.sync(); | |
| } | |
| e.stopPropagation(); // keys typed here are the chooser's, not the editor's shortcuts | |
| } | |
| retarget(opts) { Object.assign(this.opts, opts); this.openBtn.textContent = this.opts.action; } | |
| focus() { this.el.style.zIndex = "900"; this.el.focus({ preventScroll: true }); } | |
| close() { | |
| this.el.remove(); | |
| if (this.app.fileChooser === this) this.app.fileChooser = null; | |
| this.app.refreshAll?.(); | |
| } | |
| } | |