Reference sheet: labelled fields, and a picture picker that shows the bin as thumbnails
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
34db2b331d
commit
1faeb30000
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user