Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/images.mjs
T
Vito a8aff3a39d feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283)
* feat(activity): image tagging jobs for bulk Add tags and Auto-tag

POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.

* feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.

* feat(activity): show image tagging jobs with their own outcomes

Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.

The selection bar's copy without a concept line is recorded as D117.

* fix(annotations): final review round for bulk image tagging

- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
  estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
  escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
  file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
  executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
  flight, and its primary reads "Save and next" when only the manual
  field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
  text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
  · M errors").
- Specs: the bulk spec waits for the answered Start before checking that
  the invalid press sent nothing; the Activity spec uses a real error row.
2026-10-22 18:55:24 -07:00

788 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
export { onFavoriteChange } from './images-favorites.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
const inner = el('div', undefined, 'detail-inner');
inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small'));
return inner;
}
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
/**
* Live regions re-announce replaced text even when it is the same, and a patched publish (a favorite) repeats the
* same status: write text and attributes only when they change.
*/
function setText(element, text) {
if (element.textContent !== text) element.textContent = text;
}
function setAttr(element, name, value) {
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
}
function selectOf(id, options, value) {
const select = el('select');
select.id = id;
select.replaceChildren(...options.map(([optionValue, label]) => {
const node = el('option', label);
node.value = optionValue;
return node;
}));
select.value = value;
return select;
}
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
function loadFailure(sourcesRetry, categoriesFailed) {
if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
}
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer
// counts every listed file across the output roots it walked.
const RESCAN_STARTED = 'Rescanning output folders…';
const RESCAN_FAILED = 'Rescan could not finish. Try again.';
function rescanFinished(result) {
const {total, roots} = result ?? {};
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
}
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
let lastVisit = null;
// The star's answers patch the saved visit while no Images page is mounted (D7).
setFavoriteVisitTarget((key, favorite) => {
if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
});
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
export function selectedImage() {
const key = lastVisit?.selection;
return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
}
/**
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit.
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, onSaveView = () => {}, activity = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; }
const { resolveUrl } = window.ApiClient;
const sender = createPromptSender({scope});
let detailInspector;
const inspection = createInspectionController({api});
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
const saved = embedded || !savedState ? null : imagesFilters(savedState);
const prior = embedded || saved || favorites ? null : lastVisit;
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
const visit = prior?.stale ? null : prior;
let density = prior?.density || 'comfortable';
let metrics = {...INITIAL_METRICS};
let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
let refine = null;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
favorites: favorites || saved?.favorites === true };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
// The first discovery settled (found or failed): only then may a pending saved scope be kept or dropped (#249 M3).
let discoverySettled = false;
scope.cleanup(() => {
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
// The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
const heading = embedded ? null : pageHeading(main);
let importButton = null, importDialog = null;
if (heading) {
heading.description.textContent = 'Your generations, their prompts, and the next idea.';
importButton = createButton('');
importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
heading.actions.prepend(importButton);
scope.cleanup(() => importButton.remove());
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan,
// D55: Scan for metadata reviews the session scan job over every discovered source.
onMetadataScan:activity ? trigger => openMetadataScan({scope, store:activity, sources:roots.length, trigger}) : null});
});
}
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
activeFilters:activeRefinements(filters),
onChange:value => {
if (recovering) return;
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
// A debounced search report can land after a select change already applied the same values; reloading
// then would refetch the first page and drop the reader's scroll position.
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
Object.assign(filters, next);
reset();
}, onFilters:openFilters, onSaveView:trigger => {
const value = toolbar.value();
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
}});
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
// The failed loads' retry hint (null once sources load) and whether the categories failed.
let sourcesRetry = null, categoriesFailed = false;
if (!embedded) main.append(optionsStatus);
// The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15).
const filterRow = el('div', undefined, 'filter-row');
const layouts = el('div', undefined, 'segmented');
layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout');
for (const mode of ['grid', 'list', 'masonry']) {
const button = createButton(mode[0].toUpperCase() + mode.slice(1));
button.prepend(createWorkspaceIcon(mode));
button.setAttribute('aria-pressed', String(mode === layout));
scope.listen(button, 'click', () => {
layout = mode;
for (const child of layouts.children) child.setAttribute('aria-pressed', String(child === button));
updateLayout();
});
layouts.append(button);
}
const sizeLabel = el('label', 'Thumbnail size', 'small');
sizeLabel.htmlFor = 'image-size';
const thumbnails = selectOf('image-size', [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], density);
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
filterRow.append(layouts, sizeLabel, thumbnails);
const scopeText = document.createTextNode('');
if (!embedded) {
const chip = el('span', undefined, 'chip');
chip.append(createWorkspaceIcon('folder'), scopeText);
const group = createButton('Group by positive prompt →', 'chip');
scope.listen(group, 'click', () => { window.location.hash = '#/library'; });
filterRow.append(chip, group);
}
main.append(filterRow);
const body = el('div', undefined, 'gallery-layout image-workspace');
const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
// The concept's caption replaces the old "All images" heading.
const caption = el('div', undefined, 'section-caption');
const count = el('span', 'Loading images…');
count.id = 'result-count';
const captionDay = el('span');
captionDay.hidden = true;
caption.append(count, captionDay);
const focusFallback = focusTarget || heading?.h1 || main;
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'infinite-continuation');
// The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it.
if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; }
const message = el('p', '', 'small');
const more = createButton('Load more images');
const retry = createButton('Retry');
continuation.append(message, more, retry);
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
const showsEmpty = !embedded || typeof emptyText === 'string';
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
empty.hidden = true;
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
if (!embedded) collection.append(caption);
collection.append(grid);
if (showsEmpty) collection.append(empty);
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
details.setAttribute('aria-label', 'Selected image details');
details.append(noSelection());
body.append(collection, details);
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)});
if (bar) main.append(bar.element);
main.append(body);
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
}});
scope.cleanup(() => mediaViewer.dispose());
function mediaElement(image, lazy = false) {
const video = image.is_video && !image.thumbnail_url;
const node = el(video ? 'video' : 'img');
node.src = resolveUrl(image.thumbnail_url || image.url);
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
else {
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
node.width = image.width; node.height = image.height;
}
}
return node;
}
function releaseVideo(video) {
video.pause(); video.removeAttribute('src'); video.load();
}
function inspect(image) {
detailInspector?.dispose();
selection = imageKey(image);
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
}
/** The concept's `.media-card.selected` (D19); as in the concept, Info carries no pressed state. */
function markSelected(card, selected) {
card.classList.toggle('selected', selected);
}
function previewImage(image) {
if (!image) return;
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
}
/** The concept media card (app.js mediaCard): the image opens the viewer, Info inspects (D7–D11, D19). */
function cardFor(image) {
const key = imageKey(image);
const card = el('article', undefined, 'media-card');
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
card.style.setProperty('--media-ratio', mediaRatio(image));
const art = el('div', undefined, 'media-art');
const open = createButton('', 'image-open');
open.setAttribute('aria-label', `Open ${image.filename}`);
open.append(mediaElement(image, true));
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
// wherever the listing reports favorites (Images and Library-embedded media).
const overlays = el('div', undefined, 'image-overlays');
if (bar) {
const label = el('label'), box = el('input');
box.type = 'checkbox'; box.checked = bar.isChecked(key);
box.setAttribute('aria-label', `Select ${image.filename}`);
label.append(box);
overlays.append(label);
}
if (typeof image.favorite === 'boolean') {
const star = createButton('', 'star-button');
star.setAttribute('aria-label', `Favorite ${image.filename}`);
paintStar(star, image.favorite, favoriteBusy(key));
overlays.append(star);
}
art.append(open, overlays);
const info = el('div', undefined, 'media-info');
const text = el('div');
const title = el('h3', cardTitle(image)); title.title = image.path;
text.append(title, el('p', cardSubtitle(image)));
const actions = el('div', undefined, 'image-card-actions');
const inspectButton = createButton('', 'icon-btn');
inspectButton.append(createWorkspaceIcon('info'));
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
actions.append(inspectButton, createTagButton(image));
info.append(text, actions);
card.append(art, info);
markSelected(card, key === selection);
return card;
}
function setFavorite(key, favorite) {
if (scope.signal.aborted) return;
stream.updateItem(key, {favorite});
const button = rendered.get(key)?.querySelector('.star-button');
if (button) paintStar(button, favorite, favoriteBusy(key));
}
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
receiveFavorites({scope, embedded, setFavorite});
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (!image) return;
const action = event.target.closest('.image-card-actions button');
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded})) inspect(image);
});
function renderVisible() {
if (!state || scope.signal.aborted) return;
const bounds = grid.getBoundingClientRect();
const scale = bounds.width / Number.parseFloat(getComputedStyle(grid).width) || 1;
const top = Math.max(0, -bounds.top / scale);
const viewportHeight = window.innerHeight / scale;
const active = document.activeElement?.closest('article[data-image-key]')?.dataset.imageKey;
const focused = active ? itemIndexes.get(active) ?? -1 : -1;
const indexes = state.items.length <= 120 ? state.items.map((_, index) => index) :
visibleImages(geometry.positions, top, viewportHeight, focused, geometry.maxHeight);
const keep = new Set(indexes.map(index => imageKey(state.items[index])));
for (const [key, card] of rendered) if (!keep.has(key)) {
for (const video of card.querySelectorAll('video')) releaseVideo(video);
card.remove(); rendered.delete(key);
}
for (const index of indexes) {
const image = state.items[index], key = imageKey(image);
let card = rendered.get(key);
if (!card) { card = cardFor(image); rendered.set(key, card); }
positionCard(card, index);
}
// Keep keyboard order aligned with the collection while preserving existing nodes.
let previous = null;
for (const index of indexes) {
const card = rendered.get(imageKey(state.items[index]));
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
previous = card;
}
// Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the
// concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row.
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale));
// A small epsilon: under zoom `rect / scale` carries float noise that must not relayout and scroll for nothing.
if (footerHeight > metrics.footerHeight + 0.01) {
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;
metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)};
updateGeometry();
for (const index of indexes) positionCard(rendered.get(imageKey(state.items[index])), index);
// A later mounted footer must not move the current visible card.
const correction = anchor === undefined ? 0 : geometry.positions[anchor].y - anchorTop;
if (top > 0 && correction) window.scrollBy(0, correction * scale);
scheduleVisible();
}
}
function positionCard(card, index) {
const image = state.items[index], position = geometry.positions[index];
card.dataset.index = index;
markSelected(card, imageKey(image) === selection);
card.setAttribute('aria-posinset', String(index + 1)); card.setAttribute('aria-setsize', String(state.total));
Object.assign(card.style, { left: `${position.x}px`, top: `${position.y}px`, width: `${position.width}px`, height: `${position.height}px` });
}
function focusImage(index, actions = false, {scroll = true} = {}) {
if (index < 0 || index >= state.items.length) return;
const image = state.items[index], key = imageKey(image);
let card = rendered.get(key);
if (!card) {
card = cardFor(image); rendered.set(key, card); positionCard(card, index);
grid.insertBefore(card, [...grid.children].find(child => Number(child.dataset.index) > index) || null);
}
card.querySelector(actions ? '.image-card-actions button' : '.image-open').focus({ preventScroll: true });
if (scroll) card.scrollIntoView({ block: 'nearest' });
cancelAnimationFrame(frame); frame = 0;
renderVisible();
}
scope.listen(grid, 'keydown', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const index = Number(card.dataset.index);
const columns = geometry.columns;
const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1,
ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key];
if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); }
else if (event.key === 'Tab') {
const backward = event.shiftKey && event.target.matches('.image-open');
const forward = !event.shiftKey && event.target.closest('.image-card-actions');
const adjacent = backward ? index - 1 : index + 1;
if ((backward || forward) && state.items[adjacent] && !rendered.has(imageKey(state.items[adjacent]))) {
event.preventDefault(); focusImage(adjacent, backward);
}
}
});
function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); }
function updateGeometry() {
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
grid.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
function updateLayout() {
if (!state || scope.signal.aborted) return;
cancelAnimationFrame(frame); frame = 0;
// The concept's grid classes (app.js updateImages); the concept stylesheet then owns the columns and gap (D3).
grid.className = ['image-grid', density === 'comfortable' ? '' : density,
layout === 'list' ? 'list-grid' : layout === 'masonry' ? 'masonry-grid' : ''].filter(Boolean).join(' ');
const width = Number.parseFloat(getComputedStyle(grid).width);
const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null;
galleryWidth = width;
metrics = {...INITIAL_METRICS, ...gridMetrics(grid)};
if (retained) {
metrics.footerHeight = retained.footerHeight;
metrics.listHeight = retained.listHeight;
}
grid.dataset.layout = layout;
grid.dataset.density = density;
updateGeometry();
renderVisible();
}
let resizeFrame = 0, observedWidth;
const resize = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
if (width === observedWidth) return;
observedWidth = width;
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
updateLayout();
});
});
resize.observe(grid);
scope.listen(window, 'scroll', scheduleVisible);
scope.listen(window, 'resize', () => {
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
if (next.loading && !next.loaded) return 'Loading images…';
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
if (embedded) return next.loaded ? 'You’re all caught up.' : 'No linked media available for this selection.';
// The concept's end of the collection (app.js updateImageContinuation).
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
}
function renderState(next, {patch, replaced = false} = {}) {
const previous = state;
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
const changed = !patch && state?.items !== next.items;
if (changed) {
// A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
if (!next.items.length || replaced) itemIndexes.clear();
for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
// The selected file left the refreshed result: the panel follows the collection, as after a reload; the
// selection bar keeps only the checked files still listed (I7).
if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
if (replaced) bar?.retain(key => itemIndexes.has(key));
state = next;
if (!embedded) {
setText(count, countLine(next));
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
if (changed) updateLayout();
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
if (!embedded && changed && !replaced && previous?.loaded && next.loaded > previous.loaded) {
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
setText(message, continuationText(next));
setAttr(message, 'role', next.error ? 'alert' : 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
scope.cleanup(() => {
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
function pressLoad() {
// A refresh re-reads the loaded range: Load more waits for it (and focus stays where it is, M3).
if (recovering || state?.loading || stream.refreshing) return;
focusAfterLoad = state?.loaded ?? 0;
stream.loadMore();
}
/** The concept focuses the first appended image; focus never stays on a control that just hid. */
function settleLoadFocus(next) {
const first = focusAfterLoad;
focusAfterLoad = null;
const active = document.activeElement;
// Only while focus is still on the pressed control: a viewer or another control the user moved to keeps it.
const stranded = !active || active === document.body || active === more || active === retry;
if (!stranded) return;
if (next.error) { retry.focus(); return; }
// Without scrolling: the visible cards keep their place (bounded-DOM anchor); the next arrow key reveals it.
if (next.loaded > first) focusImage(first, false, {scroll:false});
else if (more.hidden) {
if (next.loaded) focusImage(next.loaded - 1, false, {scroll:false});
else focusFallback.focus({preventScroll:true});
}
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', pressLoad);
function maybeLoad() {
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
}
function scheduleLoad() {
if (!scope.signal.aborted && !loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); });
}
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
}, { rootMargin: '250px' });
observer.observe(continuation);
}
function updateScope() {
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
toolbar?.setActiveFilters(activeRefinements(filters));
}
/** No selected file; `keepPanel` (recovering sources) leaves the panel's details shown. */
function dropSelection({keepPanel = false} = {}) {
for (const video of details.querySelectorAll('video')) releaseVideo(video);
if (!keepPanel) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(noSelection());
}
selection = null;
}
function reset() {
mediaViewer.stopSlideshow();
main.querySelector('#viewer-dialog[open]')?.close();
dropSelection({keepPanel:recovering});
focusAfterLoad = null;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
bar?.clear();
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
// then lists the view with every current value (#249 M3).
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
function settleSavedScope() {
if (!savedScopePending) return;
savedScopePending = false;
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
}
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
function filterByTag(tag) {
if (recovering || !tag.trim() || tag.includes(',')) return;
if (filters.tags.length === 1 && filters.tags[0] === tag) return;
filters.tags = [tag];
reset();
}
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
function openFilters(trigger) {
if (recovering) return;
refine = openRefineDialog({scope, trigger, fields:[
{key:'source', options:sourceOptions()},
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
{key:'media', options:MEDIA_OPTIONS},
{key:'tags'},
], values:{source:filters.root, folder:filters.subfolder, media:filters.media, tags:filters.tags.join(', ')},
onApply:applyRefinement, onClear:cleared => {
toolbar.setValue({category:cleared.category});
applyRefinement(cleared);
}});
}
function sourceOptions() {
return [['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])];
}
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
function folderOptions(source) {
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
return [['', 'All output folders'], ...(root?.subfolders ?? folders).map(path => [path, path])];
}
/** Apply the dialog's values (Clear adds `category`); unchanged filters keep the loaded page, as the toolbar does. */
function applyRefinement({source, folder, media, tags, category = filters.category}) {
if (recovering) return;
const next = {root:source, subfolder:folder, media, category,
tags:[...new Set(tags.split(',').map(tag => tag.trim()).filter(Boolean))]};
const same = Object.entries(next).every(([key, value]) => key === 'tags'
? value.length === filters.tags.length && value.every((tag, index) => tag === filters.tags[index])
: filters[key] === value);
if (same) return;
Object.assign(filters, next);
reset();
}
function setControlsDisabled(disabled) {
for (const control of [...toolbarControls, more, retry]) control.disabled = disabled;
if (importButton) importButton.disabled = disabled;
}
/**
* Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. A toast
* says it started and how it ended; meanwhile a reopened Import dialog shows Rescan as running.
*/
async function rescan() {
if (rescanning || recovering || scope.signal.aborted) return;
rescanning = true;
optionsRequest?.abort(); optionsRequest = null;
showWorkspaceToast(RESCAN_STARTED);
try {
const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
if (scope.signal.aborted) return;
const [found] = await Promise.all([loadOptions(), loadCategories()]);
if (scope.signal.aborted) return;
renderLoadStatus();
if (found) reset();
if (!scope.signal.aborted) announceRescan(rescanFinished(result));
} catch {
// A failed rescan leaves the collection and any discovery alert as they were.
if (!scope.signal.aborted) announceRescan(RESCAN_FAILED);
} finally {
rescanning = false;
importDialog?.setRescanBusy(false);
}
}
/**
* D42: the concept's toast stays fixed at the bottom, so a discovery alert the rescan leaves is scrolled clear of
* the toast's band (its `scroll-margin-block-end`) only when it is in that band; elsewhere the page stays put.
*/
function announceRescan(message) {
showWorkspaceToast(message);
if (optionsStatus.hidden) return;
const {top, bottom} = optionsStatus.getBoundingClientRect();
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
filters.root = 'all';filters.subfolder = '';
if (toolbar) filters.q = toolbar.value().q.trim();
roots = [];folders = [];
setControlsDisabled(true);
sourcesRetry = null;
renderLoadStatus();
reset();
const discovery = loadOptions(), request = optionsRequest;
const found = await discovery;
if (scope.signal.aborted || request !== optionsRequest) return;
renderLoadStatus();
if (!found) return;
recovering = false;
setControlsDisabled(false);
reset();
}
function renderLoadStatus() {
const text = loadFailure(sourcesRetry, categoriesFailed);
setText(optionsStatus, text);
optionsStatus.hidden = !text;
}
/** Discover the sources and folders; the caller renders the outcome with renderLoadStatus. */
async function loadOptions() {
optionsRequest?.abort();
const request = new AbortController(); optionsRequest = request;
try {
// Every source's folders (with ancestors), listed per source too, so the dialog can follow its Source.
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return false;
if (!Array.isArray(data?.roots) || !Array.isArray(data.subfolders)) throw new TypeError('Invalid image sources');
const paths = list => Array.isArray(list) ? list.filter(path => typeof path === 'string' && path) : null;
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
folders = paths(data.subfolders);
refine?.setOptions('source', sourceOptions());
updateScope();
sourcesRetry = null;
return true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return false;
sourcesRetry = recovering ? RETRY_REFRESH : RETRY_RESCAN;
return false;
}
}
/** The toolbar's categories are the Library's (the linked positive's category, D13). */
async function loadCategories() {
if (!toolbar) return;
categoryRequest?.abort();
const request = new AbortController(); categoryRequest = request;
try {
const data = await api.get('/prompt_manager/library/options', {signal:request.signal});
if (scope.signal.aborted || request !== categoryRequest) return;
if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories');
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]);
names.delete('');
toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)));
categoriesFailed = false;
} catch {
if (scope.signal.aborted || request !== categoryRequest) return;
categoriesFailed = true;
}
}
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
updateScope();
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
if (scope.signal.aborted) return;
discoverySettled = true;
renderLoadStatus();
if (savedScopePending) reset();
});
if (visit) {
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
if (selected) inspect(selected);
restoreFrame = requestAnimationFrame(() => {
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
});
if (visit.state.loading || visit.state.error) stream.loadMore();
} else if (savedScopePending) updateScope();
else reset();
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
function selectedPair() {
const {record, prompts, loading, error, refreshRequired} = inspection.state;
if (scope.signal.aborted || selection === null || loading || error || refreshRequired || record?.identity?.key !== selection) return null;
const roles = promptRoles(prompts);
return Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative') ? roles : null;
}
return {dispose, selectedPair, sender};
}