Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
421 lines
19 KiB
JavaScript
421 lines
19 KiB
JavaScript
// 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, lookup } 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 <img> 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;
|
|
let look = null;
|
|
|
|
// How the picture is drawn (zoom, flip, blur, fade, fit): numbers and keywords from a feature, turned into style here.
|
|
function paintLook() {
|
|
if (!mediaEl) return;
|
|
const l = look || {};
|
|
mediaEl.style.transform = l.transform || "";
|
|
mediaEl.style.filter = l.filter || "";
|
|
mediaEl.style.objectFit = l.fit || "";
|
|
mediaEl.style.opacity = l.opacity == null ? "" : String(l.opacity);
|
|
}
|
|
|
|
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);
|
|
paintLook();
|
|
}
|
|
|
|
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 <video>/<img> element on screen, or null. */
|
|
get element() { return mediaEl; },
|
|
/** { transform, filter, fit: "contain" | "cover", opacity } -> how the picture is drawn now; null clears it. Kept across sources. */
|
|
setLook(next) { look = next || null; paintLook(); },
|
|
setValue: (next) => show(next, kind),
|
|
setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
|
|
/**
|
|
* The video frame (or the picture) on screen right now, as a PNG blob -- null if nothing is
|
|
* shown or there is nothing yet to draw (a video that has not loaded
|
|
* its first frame has no dimensions to capture at).
|
|
*/
|
|
captureFrame() {
|
|
const w = mediaEl && (mediaEl.videoWidth || mediaEl.naturalWidth), h = mediaEl && (mediaEl.videoHeight || mediaEl.naturalHeight);
|
|
if (!w) return Promise.resolve(null);
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
|
|
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
},
|
|
destroy: () => { show(null); node.remove(); },
|
|
};
|
|
});
|
|
|
|
/**
|
|
* A layer over its parent that shows one file (a picture, a clip with controls, a sound with controls) and a close button.
|
|
* `show({ src, kind })` opens it, `close()` shuts it and gives the connection back. The parent is whatever it is appended to.
|
|
*/
|
|
define("viewer", "cover", ({ onClose, label = "Close preview" } = {}) => {
|
|
const layer = el("div", { cls: "cx-viewer-cover" });
|
|
layer.hidden = true;
|
|
let media = null;
|
|
const release = () => {
|
|
if (media && media.tagName !== "IMG") { media.removeAttribute("src"); media.load(); } // give the connection back
|
|
media = null;
|
|
layer.replaceChildren(x.node);
|
|
};
|
|
const close = () => {
|
|
const was = !layer.hidden;
|
|
release(); layer.hidden = true;
|
|
if (was && onClose) onClose();
|
|
};
|
|
const x = lookup("iconButton", "sm")({ icon: "✕", label, onClick: close });
|
|
layer.append(x.node);
|
|
return {
|
|
node: layer,
|
|
get isOpen() { return !layer.hidden; },
|
|
show({ src, kind = "image" } = {}) {
|
|
release();
|
|
if (!src) { layer.hidden = true; return; }
|
|
const tag = kind === "image" ? "img" : kind === "audio" ? "audio" : "video";
|
|
media = el(tag, { cls: ["cx-viewer-cover-media", `cx-viewer-cover-${tag}`], attrs: { src, controls: tag === "img" ? undefined : "", autoplay: tag === "img" ? undefined : "", alt: tag === "img" ? "" : undefined } });
|
|
layer.replaceChildren(media, x.node);
|
|
layer.hidden = false;
|
|
},
|
|
close,
|
|
destroy: () => { release(); layer.remove(); },
|
|
};
|
|
});
|