// 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?.(); } }