// Grids of things. // // The adaptive one is v4's media bin generalised: it sizes off its CONTAINER, // not the viewport, so a 280px panel lays out like a 280px panel whether the // window is 1200px or 3000px wide. // // Three of these show the SAME items in different shapes -- grid, list, icons // -- so they share one item shape and one selection behaviour. A bin that // forgot what was selected when the view changed would be three galleries, not // one bin in three views. import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { applyDensity, recallCols, rememberCols, normaliseCols } from "../internals/density.js"; import { wireDragReorder } from "../internals/dragReorder.js"; const EMPTY = "Nothing here yet"; /** One item's picture: a real thumbnail, or the glyph that stands in for one. */ function thumbOf(item, cls = "cx-cell-thumb", marks = true) { const thumb = el("div", { cls }); const glyph = () => el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } }); if (item.thumb) { // Decorative: the caption or the row's name carries it, so an alt would // just make a screen reader say everything twice. const img = el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } }); // A thumbnail that does not arrive falls back to the glyph rather than to // the browser's broken-image icon, which reads as a damaged FILE. In a bin // of fifty, one server hiccup would otherwise look like lost work. img.addEventListener("error", () => img.replaceWith(glyph())); thumb.append(img); } else { thumb.append(glyph()); } if (marks && item.badge) thumb.append(el("span", { cls: "cx-cell-badge", text: item.badge })); if (marks && item.duration) thumb.append(el("span", { cls: "cx-cell-duration", text: item.duration })); // `flags` [{text, title}]: what this item IS to the work (a reference, say): small and solid, in every view, readable at a glance. if ((item.flags || []).length) thumb.append(el("span", { cls: "cx-cell-flags", children: item.flags.map((f) => el("span", { cls: "cx-cell-flag", text: f.text, attrs: { title: f.title || f.text } })) })); return thumb; } /** * The part every view of a collection has: items, which are selected, and what * a click on one means. `cellFor` is the only difference between them. */ function collection({ items = [], empty = EMPTY, selection = [], onActivate, onContext, drag } = {}, host, cellFor) { const chosen = new Set(selection); let kept = new Map(); // id -> { cell, sig }: a tile is built once and kept while what it shows is unchanged const mark = (cell, on) => { cell.classList.toggle("cx-on", on); cell.setAttribute("aria-selected", String(on)); }; function draw() { if (!items.length) { kept = new Map(); host.replaceChildren(el("p", { cls: "cx-list-empty", text: empty })); return; } // Only a tile whose item changed is rebuilt. Rebuilding them all re-created every thumbnail and reset the // scroll position, and a selection change alone is just a class and an attribute. const next = new Map(); const cells = items.map((item) => { const sig = JSON.stringify(item), old = kept.get(item.id); let entry = old && old.sig === sig ? old : null; if (!entry) { const cell = cellFor(item, chosen.has(item.id)); cell.addEventListener("click", () => { if (onActivate) onActivate(item); }); if (onContext) { cell.addEventListener("contextmenu", (e) => { e.preventDefault(); onContext(item, e); }); } if (drag) { // { type, data(item) }: a tile that can be dropped elsewhere cell.draggable = true; cell.addEventListener("dragstart", (e) => { e.dataTransfer.setData(drag.type, JSON.stringify(drag.data(item))); e.dataTransfer.effectAllowed = "copy"; }); } entry = { cell, sig }; } mark(entry.cell, chosen.has(item.id)); next.set(item.id, entry); return entry.cell; }); kept = next; const now = [...host.children]; // a rebuilt tile swaps in place when every other tile stays where it is, so a tile with the keyboard focus keeps it if (now.length === cells.length && cells.every((cell, i) => cell === now[i] || !cell.parentNode)) cells.forEach((cell, i) => { if (cell === now[i]) return; const had = now[i] === document.activeElement; now[i].replaceWith(cell); if (had) cell.focus({ preventScroll: true }); }); else if (now.length !== cells.length || cells.some((cell, i) => cell !== now[i])) host.replaceChildren(...cells); } draw(); return { draw, get value() { return [...chosen]; }, setValue(next) { chosen.clear(); for (const v of next || []) chosen.add(v); draw(); }, setItems(next) { items = next || []; draw(); }, }; } const option = (on, extra = {}) => ({ type: "button", role: "option", "aria-selected": String(on), ...extra }); define("gallery", "adaptive", (props = {}) => { const { id = "gallery", cols } = props; const grid = el("div", { cls: "cx-gallery", attrs: { role: "listbox", "aria-multiselectable": "true" } }); applyDensity(grid, cols === undefined ? recallCols(id) : cols); const api = collection(props, grid, (item, on) => { const cell = el("button", { cls: ["cx-cell", on ? "cx-on" : null, "cx-focusable"], attrs: option(on) }); cell.append(thumbOf(item), el("span", { cls: "cx-cell-name", text: item.label })); return cell; }); const node = el("div", { cls: "cx-gallery-wrap", children: grid }); return { node, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, setCols(next) { const n = normaliseCols(next); applyDensity(grid, n); rememberCols(id, n); }, destroy: () => node.remove(), }; }); /** * The same items as tiles with no captions: as many as fit, the name on hover * and to a screen reader. For a bin holding dozens of results, where what is * being read is the picture and not its filename. */ define("gallery", "icons", (props = {}) => { const grid = el("div", { cls: ["cx-gallery", "cx-gallery-icons"], attrs: { role: "listbox", "aria-multiselectable": "true" } }); const api = collection(props, grid, (item, on) => { // title AND aria-label: without a caption the tile has no accessible name // at all, and a title alone is not one on a control. const cell = el("button", { cls: ["cx-cell", "cx-cell-icon", on ? "cx-on" : null, "cx-focusable"], attrs: option(on, { title: item.label, "aria-label": item.label }) }); // No badge or duration: 9px of text over a 48px tile covers the picture the // tile exists to show, and this view is chosen to see many pictures at once. cell.append(thumbOf(item, "cx-cell-thumb", false)); return cell; }); const node = el("div", { cls: "cx-gallery-wrap", children: grid }); return { node, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => node.remove(), }; }); /** The same items as rows: a small picture, the whole name, and what it is. */ define("gallery", "list", (props = {}) => { const body = el("div", { cls: "cx-media-list", attrs: { role: "listbox" } }); const api = collection(props, body, (item, on) => el("button", { cls: ["cx-media-row", on ? "cx-on" : null, "cx-focusable"], attrs: option(on), children: [ // No badge or duration over a 40px picture -- they would cover it. The // row has room to say both in words instead. thumbOf(item, "cx-media-row-thumb", false), el("span", { cls: "cx-media-row-name", text: item.label }), item.hint ? el("span", { cls: "cx-media-row-hint", text: item.hint }) : null, ].filter(Boolean), })); const node = el("div", { cls: "cx-gallery-wrap", children: body }); return { node, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => node.remove(), }; }); /** Rows of a name and what it is, nothing to look at: a list of projects. */ define("gallery", "rows", (props = {}) => { const body = el("div", { cls: "cx-pick-rows", attrs: { role: "listbox" } }); const api = collection(props, body, (item, on) => el("button", { cls: ["cx-pick-row", on ? "cx-on" : null, "cx-focusable"], attrs: option(on), children: [el("span", { cls: "cx-pick-name", text: item.label }), item.hint ? el("span", { cls: "cx-pick-hint", text: item.hint }) : null].filter(Boolean), })); return { node: body, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => body.remove() }; }); /** Large clickable cards: the wizard's "what are you making?" choices. */ define("gallery", "cards", ({ items = [], value, onActivate } = {}) => { let current = value; const node = el("div", { cls: "cx-cards", attrs: { role: "radiogroup" } }); const cards = items.map((item) => { const card = el("button", { cls: ["cx-card", "cx-focusable"], attrs: { type: "button", role: "radio", "aria-checked": String(item.id === current) }, children: [ item.icon ? el("span", { cls: "cx-card-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null, el("span", { cls: "cx-card-title", text: item.label }), item.hint ? el("span", { cls: "cx-hint", text: item.hint }) : null, ].filter(Boolean), }); card.classList.toggle("cx-on", item.id === current); card.addEventListener("click", () => { current = item.id; for (const { item: i, card: c } of cards) { c.classList.toggle("cx-on", i.id === current); c.setAttribute("aria-checked", String(i.id === current)); } if (onActivate) onActivate(item); }); node.append(card); return { item, card }; }); return { node, get value() { return current; }, destroy: () => node.remove() }; }); /** * A single scrolling row: filmstrips, segment strips, recent items. * * Selectable like its siblings, and through the same `collection` -- a strip is * the one shape whose whole job is "which of these am I on", and one that could * only be clicked THROUGH left the caller drawing its own current-item marker * over a row that did not have the concept. */ define("gallery", "strip", ({ onReorder, ...props } = {}) => { const node = el("div", { cls: "cx-strip", attrs: { role: "listbox", "aria-label": props.label } }); const api = collection(props, node, (item, on) => { const cell = el("button", { cls: ["cx-strip-cell", on ? "cx-on" : null, item.rating ? "cx-rated" : null, item.excluded ? "cx-excluded" : null, "cx-focusable"], attrs: option(on, { title: item.label, "aria-label": item.label, "data-rating": item.rating || undefined, // The item's OWN id, not just its position -- a // reorder tracked by index goes stale the moment // anything redraws the strip mid-drag (a scene // removed by keyboard while the mouse is still // held down, for one), since a redraw can happen on // its own input channel independent of the drag. "data-id": item.id, // Draggable whether or not `onReorder` is given: a // drag with nowhere to report to is inert, not wired // -- the delegated listeners below are the only thing // that acts on it, and they no-op without a handler. draggable: "true" }) }); // As wide as what it stands for is long, which is what turns a row of equal // boxes into something that reads as time. Bounded at both ends: one very // long clip beside short ones must not squeeze the rest to a sliver. if (item.weight) cell.style.flexGrow = String(Math.min(6, Math.max(1, Number(item.weight) || 1))); // The same fallback the grid has: a thumbnail that does not arrive shows the // glyph, not the browser's broken-image icon, which reads as a damaged file. cell.append(thumbOf(item, "cx-strip-face", false)); if (item.badge) cell.append(el("span", { cls: "cx-cell-badge", text: item.badge })); return cell; }); wireDragReorder(node, ".cx-strip-cell", onReorder); return { node, get value() { return api.value; }, setValue: api.setValue, setItems: api.setItems, destroy: () => node.remove(), }; }); /** * A scale over something laid out in a row: where each part begins. * * The marks are given rather than computed from an interval -- what a person * looks for on a timeline is where one scene becomes the next, and at 24 frames * a second a tick per second is a picket fence. */ define("ruler", "default", ({ marks = [], total = 1, label } = {}) => { const node = el("div", { cls: "cx-ruler", attrs: { role: "presentation", "aria-label": label } }); function set(next = [], span = 1) { node.replaceChildren(); for (const mark of next) { const tick = el("span", { cls: "cx-ruler-mark", text: mark.label, attrs: { title: mark.hint } }); // Percent, so the ruler tracks the row above it at any width. tick.style.insetInlineStart = `${Math.min(100, Math.max(0, (mark.at || 0) * 100))}%`; node.append(tick); } node.dataset.total = String(span); } set(marks, total); return { node, set, destroy: () => node.remove() }; }); /** * The result, big. One image or one video, or an empty state before there is * either -- because "nothing has been generated" and "the picture failed to * load" have to look different, and a bare with a broken src looks like * neither. */ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError, controls = true, loop = true } = {}) => { const stage = el("div", { cls: "cx-viewer-stage" }); let current = null; let currentKind = kind; let currentFile = null; let mediaEl = null; function show(next, nextKind = kind, file = null) { current = next || null; currentKind = nextKind; currentFile = current ? file : null; if (mediaEl && mediaEl.tagName === "VIDEO") { mediaEl.removeAttribute("src"); mediaEl.load(); } // a video left behind must give its connection back mediaEl = null; stage.replaceChildren(); if (!current) { stage.append(el("p", { cls: "cx-list-empty", text: empty })); return; } const media = nextKind === "video" ? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: controls ? "" : undefined, loop: loop ? "" : undefined, playsinline: "" } }) : el("img", { cls: "cx-viewer-media", attrs: { src: current, alt: "" } }); mediaEl = media; // A failed load is reported rather than left as a broken icon: the file is // produced by a run, so it not arriving is a fault worth naming. media.addEventListener("error", () => { stage.replaceChildren(el("p", { cls: "cx-list-empty", text: "This result could not be loaded." })); mediaEl = null; // Nothing is actually showing any more -- `file` saying otherwise is // what let "Save to bin" silently bin a dead reference from this exact // state, the one place the panel itself says there is nothing here. currentFile = null; if (onError) onError(current); }); stage.append(media); } show(src, kind); const node = el("div", { cls: "cx-viewer", children: stage }); return { node, get value() { return current; }, get kind() { return currentKind; }, // The file this came from -- {filename, subfolder, type} -- when the // caller had one to give. Null for anything shown by URL alone, which is // not enough to name a file back to the server with. get file() { return currentFile; }, /** The