Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/images.mjs
T
Vito 9d398f9862 feat(workspace): concept shell — sidebar, topbar, heading and footer on the approved mock's CSS (#256)
* build(workspace): vendor the approved concept stylesheet with a drift check

* refactor(workspace): adopt the concept stylesheet as the workspace base

* feat(workspace): concept sidebar with real navigation counts

* feat(workspace): concept topbar with Theme and ComfyUI link

* refactor(workspace): one concept heading and footer for every route

* fix(workspace): keep the canvas link on the proxy mount

A resolver failure no longer stops the shell from mounting: the ComfyUI link falls back to the root read from the page's own mount path (never another origin). Tests cover a non-standard /ComfyBackendDirect mount, including that no request escapes it.

* test(workspace): concept fixture and shell comparison

* test(workspace): concept fixture carries the concept inspector facts

* fix(workspace): match the concept brand name and compare shell hover states

* test(workspace): shell comparison mutation check and docs

Add mutate() to the shared concept inventory and a shell mutation check: a
nav-row min-height rule and a removed .breadcrumbs each make the comparison
fail with the changed path. Harden the resolver-failure guard test with an
injection count, make the overlay lint fail when workspace.css is missing,
mark the library.css .row-actions leftovers for PR E, and document the
concept CSS base, the overlay lint, the comparison method and the recorded
shell deviations in TESTING.md.

* fix(workspace): focus the page title on a route change

A sidebar or history navigation now focuses #page-title, as the concept
does on every hashchange (app.js 1168), so a screen reader announces the
page heading instead of the main region. The first render leaves focus
alone and the skip link still focuses main. The heading module's comment
now describes what the shell does.

* fix(workspace): give the Overview nav entry its own home glyph

Overview reused the concept's Activity pulse byte for byte, so once the
Activity row lands two entries would share one icon. Overview now has an
outline home glyph. The shell comparison takes it from the D28 ruling
instead of the concept's activity path, and the icon test checks that
every approved glyph is distinct.

* test(workspace): accept only #249 decision markers and state the lint scope

The overlay lint accepted any D-number, so another issue's ruling such
as "D5 (#245)" could mark a concept override. A marker now has to be a
D-number with no other issue cited; the vocabulary rule that cited #245
reads "#245 decision 5". TESTING.md now states that the lint covers
workspace.css only, lists the unmarked overrides still in images.css,
and says PRs C and D run the lint over each page sheet they keep.

* perf(workspace): re-read only Library and Tags counts after a Library write

A confirmed Library or vocabulary write cannot change the Images count,
but it re-read all three, including the Images output listing. It now
re-reads only the Library and Tags counts. Each count has its own read,
so a write never cancels an Images read that is still in flight.
2026-10-19 23:04:40 -07:00

434 lines
25 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, createField, createSelect } from '../shared/toolkit/core.js';
import { createImageStream, imageKey } from './images-state.mjs';
import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.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';
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
function formatBytes(value) {
return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
let lastVisit = null;
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
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 visit = embedded ? null : lastVisit;
let density = visit?.density || 'comfortable';
let metrics = workspaceImageMetrics(window.innerWidth, density);
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
scope.cleanup(() => {
if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
const description = embedded ? null : pageHeading(main)?.description;
if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.';
const filtersBar = el('div', undefined, 'image-filters');
const search = el('input');
search.value = filters.q;
search.type = 'search'; search.placeholder = 'Find an image by filename or folder…'; search.maxLength = 256;
const source = createSelect([['all', 'All sources']], '');
const folder = createSelect([['', 'All folders']], '');
const media = createSelect([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']], '');
const sort = createSelect([['newest', 'Newest first'], ['oldest', 'Oldest first'], ['name', 'Filename A–Z'], ['name-desc', 'Filename Z–A']], '');
media.value = filters.media; sort.value = filters.sort;
filtersBar.append(createField('Search filenames', search, 'image-search'), createField('Source', source, 'image-source'),
createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort'));
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
if (!embedded) main.append(filtersBar, optionsStatus);
const controls = el('div', undefined, 'image-view-controls');
const toolbar = el('div', undefined, 'image-toolbar');
const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1;
toolbar.append(collectionHeading);
const count = el('p', 'Loading images…', 'muted');
const layouts = el('div', undefined, 'image-layouts');
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 refresh = createButton('Refresh');
scope.listen(refresh, 'click', async () => {
if (refresh.disabled) return;
refresh.disabled = true;
optionsRequest?.abort(); optionsRequest = null;
optionsStatus.hidden = true;
try {
await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
if (scope.signal.aborted) return;
if (await loadOptions() && !scope.signal.aborted) reset();
} catch {
if (scope.signal.aborted) return;
optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
optionsStatus.hidden = false;
} finally {
refresh.disabled = recovering;
}
});
const currentGallery = el('a', 'Current gallery', 'workspace-link');
currentGallery.href = resolveUrl('/prompt_manager/gallery');
const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], '');
thumbnails.value = density;
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density'));
if (!embedded) {
const group = el('a', 'Group by positive prompt', 'workspace-link');
group.href = '#/library'; group.prepend(createWorkspaceIcon('branch'));
controls.append(group, refresh);
}
main.append(controls);
toolbar.append(count);
const body = el('div', undefined, 'image-workspace');
const collection = el('section', undefined, 'image-collection');
collection.setAttribute('aria-label', 'Images');
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'image-continuation');
const message = el('p');
const more = createButton('Load more images');
const retry = createButton('Retry');
const end = el('p', '', 'muted');
continuation.append(message, more, retry, end);
collection.append(toolbar, grid, continuation);
if (!embedded) collection.append(currentGallery);
const details = el('section', undefined, 'image-details panel');
details.setAttribute('aria-label', 'Image details');
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
body.append(collection, details);
main.append(body);
const mediaViewer = createMediaViewer({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 collectionHeading.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 { node.alt = image.filename; node.decoding = 'async'; if (lazy) node.loading = 'lazy'; }
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, inline:true,
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) {
card.classList.toggle('selected', key === selection);
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
}
}
function previewImage(image) {
if (!image) return;
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
}
function cardFor(image) {
const key = imageKey(image);
const card = el('article', undefined, 'image-card');
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
const open = createButton('');
open.className = 'image-open'; open.setAttribute('aria-label', `Open ${image.filename}`);
open.title = 'Show image details'; open.setAttribute('aria-pressed', String(key === selection));
const img = mediaElement(image, true);
open.append(img);
const footer = el('div', undefined, 'image-card-footer');
const title = el('strong', image.filename); title.title = image.path;
const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : '';
const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase();
const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`);
const preview = createButton('', 'ca-icon-button');
preview.append(createWorkspaceIcon('images'));
preview.setAttribute('aria-label', `Preview ${image.filename}`);
footer.append(title, sub, preview); card.append(open, footer);
return card;
}
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 (event.target.closest('.image-card-footer button')) previewImage(image);
else if (event.target.closest('button')) 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.
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight));
if (footerHeight > metrics.footerHeight) {
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;
card.classList.toggle('selected', imageKey(image) === selection);
card.querySelector('.image-open').setAttribute('aria-pressed', String(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, preview = false) {
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(preview ? '.image-card-footer button' : '.image-open').focus({ preventScroll: true });
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 = layout === 'list' ? 1 : metrics.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-footer');
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, 240, metrics);
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;
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 = workspaceImageMetrics(window.innerWidth, density, galleryWidth);
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 renderState(next) {
const changed = state?.items !== next.items;
if (changed) {
if (!next.items.length) itemIndexes.clear();
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
state = next;
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`;
if (changed) updateLayout();
message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
scope.cleanup(() => {
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); });
scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); });
function maybeLoad() {
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[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 reset() {
mediaViewer.stopSlideshow();
main.querySelector('.image-preview[open]')?.close();
if (!recovering) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
}
selection = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
stream.reset(filters, {load:!recovering});
}
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 = '';filters.q = search.value.trim();
source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', ''));
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true;
optionsStatus.hidden = true;
reset();
const discovery = loadOptions(), request = optionsRequest;
if (!await discovery || scope.signal.aborted || request !== optionsRequest) return;
recovering = false;
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false;
reset();
}
async function loadOptions() {
optionsRequest?.abort();
const request = new AbortController(); optionsRequest = request;
try {
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
if (filters.root !== 'all') params.set('root', filters.root);
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return false;
source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
source.value = filters.root;
folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
folder.value = filters.subfolder;
optionsStatus.hidden = true;
return true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return false;
optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.';
optionsStatus.hidden = false;
return false;
}
}
scope.cleanup(() => optionsRequest?.abort());
scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200));
for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
scope.listen(input, 'change', () => {
if (recovering) return;
filters[key] = input.value;
if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
reset();
});
}
if (!embedded) loadOptions();
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 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};
}