Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/index.mjs
T
Vito Sansevero 64f14a3e59 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.
2026-10-19 22:48:39 -07:00

171 lines
8.1 KiB
JavaScript

import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading } from './heading.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function legacyLink(route) {
const link = element('a', route.action, 'workspace-link');
link.href = resolveUrl(route.href);
return link;
}
function mountWorkspace() {
const scope = createScope();
const api = createApiClient();
const main = document.getElementById('workspace-main');
const primaryNav = document.getElementById('primary-nav');
const systemNav = document.getElementById('system-nav');
scope.listen(document.querySelector('.skip'), 'click', event => {
event.preventDefault();
main.focus();
});
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
const theme = mountWorkspaceTheme({ root: document.body, scope });
const preference = document.getElementById('theme');
preference.value = theme.preference;
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
// D6: ComfyUI ↗ opens the canvas; on Images with a resolved selection it sends that pair (D5: no Activity).
let images = null;
scope.cleanup(bindCanvasLink(document.querySelector('.topbar .canvas-link'), {
href: canvasHref(resolveUrl, window.location.pathname),
getSelectedPair: () => images?.selectedPair?.() ?? null,
sender: {open: (roles, trigger) => images?.sender.open(roles, trigger)},
}));
// The concept's nav(): icon, label text, then a `.nav-count` only for a loaded real count.
const navLinks = ids => ids.map(([id, icon]) => {
const route = routes.find(item => item.id === id);
const link = element('a');
link.append(createWorkspaceIcon(icon), document.createTextNode(route.label));
link.href = `#/${route.id}`;
link.dataset.route = route.id;
return link;
});
primaryNav.replaceChildren(...navLinks([['images', 'images'], ['library', 'library'], ['tags', 'tags'], ['inspect', 'upload']]));
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
const sidebar = document.querySelector('aside.sidebar');
let countsRequest;
function refreshCounts() {
countsRequest?.abort();
const request = countsRequest = new AbortController();
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (id, count) => {
if (request.signal.aborted) return;
const link = primaryNav.querySelector(`[data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
});
}
scope.cleanup(() => countsRequest?.abort());
scope.cleanup(onLibraryWrite(refreshCounts));
refreshCounts();
let viewScope;
let firstRender = true;
scope.cleanup(() => viewScope?.dispose());
function render(route) {
viewScope?.dispose();
images = null;
viewScope = createScope();
const view = viewScope;
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
}
document.title = `${routeTitle(route)} · Prompt Manager`;
// The concept's heading(); each view fills its description and actions.
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
const { description, h1 } = renderHeading(main, { title: route.label });
if (!firstRender) h1.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
if (route.id === 'images') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view);
return;
}
if (route.id === 'library') {
mountLibrary(main, api, view, {positiveId:route.positiveId});
return;
}
if (route.id === 'tags') {
mountVocabulary(main, api, view);
return;
}
if (isHandoff(route)) {
description.textContent = route.description;
const panel = element('section', undefined, 'surface handoff');
panel.append(element('h2', 'Your current tools are ready'),
element('p', 'This workspace is being introduced in stages. Continue in the existing tool with your saved data.'),
legacyLink(route));
main.append(panel);
return;
}
description.textContent = 'A home for your prompts, images and creative history.';
const stats = element('section', undefined, 'overview-stats');
stats.setAttribute('aria-label', 'Library statistics');
main.append(stats);
const tools = element('section', undefined, 'surface');
tools.append(element('h2', 'Continue creating'), element('p', 'Your existing tools remain available while the new workspaces take shape.'));
const cards = element('div', undefined, 'tool-grid');
for (const target of routes.filter(item => item.href)) {
const card = element('article', undefined, 'tool-card');
card.append(element('h3', target.label), element('p', target.description, 'small'), legacyLink(target));
cards.append(card);
}
tools.append(cards);
main.append(tools);
async function loadStats() {
const status = element('p', 'Loading library statistics…');
status.setAttribute('role', 'status');
stats.replaceChildren(status);
try {
const result = await api.get('/prompt_manager/stats', { signal: view.signal });
if (view.signal.aborted) return;
const values = summarizeStats(result.stats);
const list = element('dl', undefined, 'stat-grid');
for (const [label, count] of values) {
const stat = element('div', undefined, 'stat');
stat.append(element('dt', label), element('dd', count.toLocaleString()));
list.append(stat);
}
stats.replaceChildren(list);
if (values.every(([, count]) => count === 0)) {
stats.append(element('p', 'Your library is empty. Add a prompt in the prompt library or configure sources in System.', 'empty-state'));
}
} catch (error) {
if (view.signal.aborted) return;
const alert = element('p', 'Library statistics could not be loaded. Your saved data has not changed.');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
view.listen(retry, 'click', loadStats);
stats.replaceChildren(alert, retry);
}
}
loadStats();
}
scope.cleanup(mountRouter(window, render));
scope.listen(window, 'pagehide', () => scope.dispose());
return () => scope.dispose();
}
mountWorkspace();
window.addEventListener('pageshow', event => {
if (event.persisted) mountWorkspace();
});