diff --git a/app/ui/features/refsheet/hub.test.js b/app/ui/features/refsheet/hub.test.js index c2f95ea4..03302408 100644 --- a/app/ui/features/refsheet/hub.test.js +++ b/app/ui/features/refsheet/hub.test.js @@ -48,10 +48,17 @@ test("it is hidden when the server has no refsheet module, and the button opens assert.match(button.textContent, /Reference sheet/); button.click(); await new Promise((r) => setTimeout(r, 20)); - const picks = [...document.querySelectorAll("select")].filter((s) => /Picture for slot/.test(s.getAttribute("aria-label") || "")); - assert.equal(picks.length, 6); - assert.deepEqual([...picks[0].options].map((o) => o.textContent), ["Empty", "front.png", "Upload a picture…"]); // a video is not offered - picks[0].value = "a"; picks[0].dispatchEvent(new Event("change", { bubbles: true })); + const buttons = (text) => [...document.querySelectorAll("button")].filter((b) => b.textContent.includes(text)); + assert.equal(buttons("Choose picture…").length, 6); + assert.ok(document.body.textContent.includes("Rows") && document.body.textContent.includes("Columns") && document.body.textContent.includes("Slot format")); // every control has a visible label + buttons("Choose picture…")[0].click(); + await new Promise((r) => setTimeout(r, 20)); + const tiles = [...document.querySelectorAll("[role=option]")]; + assert.deepEqual(tiles.map((t) => t.textContent), ["front.png"]); // the bin's pictures only: a video is not offered + assert.ok(tiles[0].querySelector("img").getAttribute("src").endsWith("/a/thumb")); + tiles[0].click(); + await new Promise((r) => setTimeout(r, 20)); + assert.equal(buttons("Change: front.png").length, 1); const make = [...document.querySelectorAll("button")].find((b) => b.textContent === "Make sheet"); make.click(); await new Promise((r) => setTimeout(r, 20)); diff --git a/app/ui/features/refsheet/index.js b/app/ui/features/refsheet/index.js index b38a2573..6be2c7f6 100644 --- a/app/ui/features/refsheet/index.js +++ b/app/ui/features/refsheet/index.js @@ -4,7 +4,6 @@ import { composer as c } from "../../composer/composer.js"; import { learn } from "../../shell/bin.js"; import { DEFAULTS, FORMATS, MAX_SLOTS, problem, request, resize } from "./sheet.js"; -const UPLOAD = "__upload"; const thumb = (id) => `/fun/api/media/${encodeURIComponent(id)}/thumb`; export default { @@ -24,29 +23,37 @@ export default { const note = c.text.sm({ text: "" }); let make; - const choose = async (i, value) => { - if (value === UPLOAD) { - const file = await app.dom.pickFile("image/*"); - if (file) { - const got = await app.api.uploadMedia([file]); - if (got.problems.length) c.toast.warn({ text: got.problems[0] }); - if (got.media[0]) { await reload(); state.slots[i].picture = got.media[0].id; app.say("bin"); } - } - } else state.slots[i].picture = value; - drawGrid(); status(); + const upload = async (i) => { + const file = await app.dom.pickFile("image/*"); + if (!file) return; + const got = await app.api.uploadMedia([file]); + if (got.problems.length) c.toast.warn({ text: got.problems[0] }); + if (got.media[0]) { await reload(); state.slots[i].picture = got.media[0].id; app.say("bin"); } + }; + // The bin itself, as pictures with their names: one click chooses, Escape or the close button keeps what was there. + let picking = null; + const choose = (i) => { + if (picking) return; + const done = (fn) => { picking.close("done"); Promise.resolve(fn && fn()).then(() => { drawGrid(); status(); }); }; + const items = pictures.map((m) => ({ id: m.id, label: m.name, thumb: thumb(m.id) })); + const gallery = c.gallery.adaptive({ id: "refsheet-pick", items, selection: state.slots[i].picture ? [state.slots[i].picture] : [], empty: "The media bin has no pictures yet. Upload one.", + onActivate: (it) => done(() => { state.slots[i].picture = it.id; }) }); + picking = c.modal.stacked({ title: `Picture for slot ${i + 1}`, size: "lg", onClose: () => { picking = null; }, body: gallery, + actions: [c.button.md({ label: "Upload a picture…", onClick: () => done(() => upload(i)) }), + c.button.md({ label: "Leave empty", tone: "ghost", onClick: () => done(() => { state.slots[i].picture = ""; }) })] }); }; const cell = (slot, i) => { const named = pictures.find((m) => m.id === slot.picture); - const card = c.captioned.cell({ src: slot.picture ? thumb(slot.picture) : "", title: `Slot ${i + 1}`, caption: slot.label, label: `Slot ${i + 1}`, onChange: (text) => { slot.label = text; } }); - const pick = c.select.sm({ label: `Picture for slot ${i + 1}`, value: named ? named.id : "", onChange: (v) => choose(i, v), - options: [{ value: "", label: "Empty" }, ...pictures.map((m) => ({ value: m.id, label: m.name })), { value: UPLOAD, label: "Upload a picture…" }] }); + const card = c.captioned.cell({ src: slot.picture ? thumb(slot.picture) : "", title: `Slot ${i + 1}`, badge: slot.picture && !named ? "picture gone" : "", tone: "warn", caption: slot.label, label: `Slot ${i + 1}`, + onOpen: () => choose(i), onChange: (text) => { slot.label = text; } }); + const pick = c.button.sm({ label: named ? `Change: ${named.name}` : "Choose picture…", tone: "ghost", onClick: () => choose(i) }); return c.region.stack({ gap: "xs", children: [card, pick] }); }; const drawGrid = () => grid.set([c.grid.cards({ children: state.slots.map(cell) })]); const status = () => { note.setText(problem(state)); make && make.setDisabled(!!problem(state)); }; const regrid = () => { state.slots = resize(state.slots, state.rows, state.cols); drawGrid(); status(); }; - const num = (key, label, max) => c.number.md({ label, value: state[key], min: 1, max, step: 1, precision: 0, onChange: (v) => { state[key] = Math.max(1, Math.min(max, Math.round(v) || 1)); regrid(); } }); + const num = (key, label, max) => c.field.default({ label, hint: `1 to ${max}`, control: c.number.md({ label, value: state[key], min: 1, max, step: 1, precision: 0, onChange: (v) => { state[key] = Math.max(1, Math.min(max, Math.round(v) || 1)); regrid(); } }) }); make = c.button.md({ label: "Make sheet", tone: "primary", onClick: async () => { make.setBusy(true); try { @@ -58,11 +65,11 @@ export default { } catch (err) { c.toast.warn({ text: `Could not make the sheet: ${err.message}` }); } finally { make.setBusy(false); } } }); win = c.modal.generic({ title: "Reference sheet", subtitle: "Pictures in a grid on white, each labelled below. Each picture is cropped from the middle to fit its slot.", size: "xl", - onClose: () => { win = null; }, + onClose: () => { win = null; if (picking) picking.close("gone"); }, body: c.region.stack({ gap: "md", children: [ c.field.default({ label: "Title (top left corner, optional)", control: c.input.md({ value: state.title, label: "Title", onCommit: (v) => { state.title = v; } }) }), c.region.stack({ gap: "sm", children: [num("rows", "Rows", 12), num("cols", "Columns", 12), - c.select.md({ label: "Slot format", value: state.format, options: FORMATS.map((f) => ({ value: f, label: f })), onChange: (v) => { state.format = v; } })] }), + c.field.default({ label: "Slot format", hint: "width : height of every picture slot", control: c.select.md({ label: "Slot format", value: state.format, options: FORMATS.map((f) => ({ value: f, label: f })), onChange: (v) => { state.format = v; } }) })] }), note, grid] }), actions: [make] }); drawGrid(); status();