Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/library-editor.mjs
T
Vito Sansevero fe0835eea3 fix(images): concept Load more focus, empty state and review rulings
Review of #249 C2:
- Load more and Retry focus the first appended image, as the concept
  does (app.js loadMoreImages). Focus moves without scrolling so the
  visible cards keep their place (bounded-DOM anchor), and only while
  focus is still on the pressed control: a viewer or a field the user
  moved to keeps it. When a last page adds nothing and the button hides,
  focus goes to the last image, never to the page; a failed page focuses
  Retry.
- The concept's empty state beside the grid: .empty "No matching
  images. Try another search or category."
- D17: the caption date shows only under Newest first (", YYYY" outside
  the current year) and is hidden under Oldest first and the name and
  rating sorts.
- en-US number grouping in the embedded count too.
- Refine "Folder" defaults to the concept's "All output folders". It is
  not narrowed by Source: the concept has no Source field, and the list
  holds every source's folders, so any pair can be chosen in one dialog.
- The shared toast moves to toast.mjs; library-editor re-exports it.
- The D32 overlay (extreme zoom) wraps the layout segmented control and
  the continuation row instead of pushing them out of the 390px column.
- The legacy gallery checks moved to Overview's "Open existing image
  gallery" card (D29).

Mapping additions: grid with no results -> .empty concept copy beside
the grid; Load more press -> focus the first new image (concept).
2026-10-20 02:27:52 -07:00

374 lines
20 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.
/**
* Native recipe editor dialogs: New prompt, Edit recipe metadata, Create text
* revision, Add a negative variant, and the pair archive confirmation.
*
* Markup, labels, order and native datalists follow the approved concept. All
* reads, writes, receipts and recovery live in library-editor-state.mjs; this
* module only renders `controller.state` and forwards user actions.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createLibraryEditorController } from './library-editor-state.mjs';
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
const SUBMITTABLE = new Set(['ready', 'write-error']);
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
const NO_PAIR = 'Archive needs a selected saved negative variant.';
const ALREADY_ARCHIVED = 'This negative variant is already archived.';
const REVIEW = 'Loaded the latest saved values. Review your entries, then save.';
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
const REFRESH = ['Reload Library', 'refresh'];
// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline:
// every exit (Reload Library, Cancel, × or Escape) drops the draft.
const DISCARDS = 'Leaving this dialog discards your entries.';
// The toast lives in the shell module; re-exported for the pages that already import it from here.
export { showWorkspaceToast } from './toast.mjs';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Inline state copy and its one recovery action; `archive` selects the pair-archive
* wording. After a restore only New prompt may re-read: every other mode names an
* owner by its old numeric ID, so it offers only the Library refresh exit, which
* discards the draft.
*/
function statusOf(state, archive, mode = null) {
const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.';
const message = state.error?.message;
switch (state.phase) {
case 'initializing': return {text: 'Loading the latest saved values…'};
case 'initialization-error': return {text: `The latest saved values could not be loaded. ${nothing}`, action: ['Retry', 'retryInitialization']};
case 'saving': return {text: archive ? 'Archiving… The dialog stays open until the server answers.' : 'Saving… The dialog stays open until the server answers.'};
case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`};
case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.'
: 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']};
case 'storage-changed': return mode === 'new' ? {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: ['Reload Library state', 'reload']}
: {text: `Prompt storage was restored since this dialog opened. ${nothing}${archive ? '' : ` ${DISCARDS}`}`, action: REFRESH};
case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.'
: mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'
: 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']};
case 'saved-refresh-error':
// A refresh that found restored storage cannot recover by re-reading; only reloading the Library view can.
if (state.error?.code === 'storage-changed') {
return {text: `${archive ? 'Archived' : 'Saved'}; Library refresh failed because prompt storage was restored. Reload the Library to see current data.`, action: REFRESH};
}
return {text: archive ? 'Archived; Library refresh failed.' : 'Saved; Library refresh failed.', action: ['Retry refresh', 'retryReconciliation']};
case 'ready': return message ? {text: message} : state.reviewRequired ? {text: archive ? REVIEW_ARCHIVE : REVIEW} : null;
default: return null;
}
}
/** One persistent inline status in the concept's .warning-note style, so live updates are announced. */
function statusLine() {
const note = el('p', '', 'warning-note');
note.id = 'library-editor-status';
note.setAttribute('role', 'status');
note.hidden = true;
return {note, set(text) {
if (note.textContent !== (text || '')) note.textContent = text || '';
note.hidden = !text;
}};
}
/** Re-parent only on a real change: moving a focused button would drop its focus. */
function setChildren(parent, nodes) {
const current = [...parent.children];
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
}
/** Saving or re-reading disables or removes the focused action; focus would drop to the body. */
const SETTLING = new Set(['saving', 'initializing']);
const restoredStorage = state => state.phase === 'storage-changed' || (state.phase === 'saved-refresh-error' && state.error?.code === 'storage-changed');
/**
* The action that held focus when a write or re-read began gets it back once that
* settles; when it is gone or disabled (Save after an unconfirmed write), focus moves
* to the first usable fallback, the recovery action first, and never to the body.
*/
function restoreFocus(button, state, fallbacks) {
if (!button || SETTLING.has(state.phase)) return button;
if (state.phase !== 'complete' && (!document.activeElement || document.activeElement === document.body)) {
[button, ...fallbacks].find(node => node?.isConnected && !node.disabled)?.focus();
}
return null;
}
/** Remember a focused dialog action while the phase settles. */
function settlingFocus(actions, state, current) {
return SETTLING.has(state.phase) && actions.contains(document.activeElement) ? document.activeElement : current;
}
function field(label, control, id) {
const wrapper = el('div', undefined, 'field');
const caption = el('label', label);
caption.htmlFor = id;
control.id = id;
wrapper.append(caption, control);
return wrapper;
}
function datalist(id, values) {
const list = el('datalist');
list.id = id;
list.append(...values.map(value => Object.assign(document.createElement('option'), {value})));
return list;
}
/**
* `onReconciled(result, {outcome, draft})` receives the reconciled result with the
* controller's outcome and submitted draft. `onRefreshRequired()` is the explicit
* exit when prompt storage was restored; the dialog closes first.
*/
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
onRefreshRequired = () => window.location.reload()}) {
let session = null, disposed = false;
const dialog = el('dialog', undefined, 'library-editor');
dialog.setAttribute('aria-labelledby', 'library-editor-title');
const header = el('div', undefined, 'dialog-header');
const title = el('h2');
title.id = 'library-editor-title';
const close = createButton('×');
close.setAttribute('aria-label', 'Close dialog');
const body = el('div');
header.append(title, close);
dialog.append(header, body);
const busy = () => session?.controller.state.phase === 'saving';
close.addEventListener('click', () => finish());
dialog.addEventListener('cancel', event => {
event.preventDefault();
if (!busy()) finish();
});
// A browser may still force-close a modal; treat that as a dismissal without claiming rollback.
dialog.addEventListener('close', () => {if (!dialog.open) finish();});
function finish() {
if (!session) return;
const {controller, trigger, views} = session;
session = null;
views.abort();
controller.dispose();
if (dialog.open) dialog.close();
dialog.remove();
if (trigger?.isConnected) trigger.focus();
}
function show(heading) {
title.textContent = heading;
close.disabled = false;
if (!dialog.isConnected) document.body.append(dialog);
if (!dialog.open) dialog.showModal();
close.focus();
}
/** The one recovery action for a phase, created once per view and reused across renders. */
function recoveryButton(cache, action, signal) {
if (!action) return null;
const [label, method] = action;
if (!cache.has(label)) {
const control = createButton(label);
control.addEventListener('click', () => {
const {controller} = session;
if (method === 'refresh') {finish(); onRefreshRequired(); return;}
if (method === 'reload') controller.reloadOwner({keepDraft: true});
else controller[method]();
}, {signal});
cache.set(label, control);
}
return cache.get(label);
}
function controllerFor(mode, positiveId, pairId) {
const controller = createLibraryEditorController({api, mode, positiveId, pairId, onCommitted,
onReconciled: result => onReconciled(result, {outcome: controller.state.outcome, draft: controller.state.draft}),
onChange: state => session?.render(state)});
return controller;
}
function editorView({mode, positiveId, pairId, draft}) {
const views = new AbortController();
const controller = controllerFor(mode, positiveId, pairId);
const negativeOnly = mode === 'negative';
const readOnly = mode === 'metadata';
const form = el('form');
const controls = {};
const textarea = (name, required = false) => {
const node = el('textarea');
node.required = required;
node.readOnly = readOnly && (name === 'positive_text' || name === 'negative_text');
return controls[name] = node;
};
const input = (name, required = false) => {
const node = el('input');
node.required = required;
return controls[name] = node;
};
if (!negativeOnly) {
form.append(field('Title', input('title', true), FIELD_IDS.title),
field('Positive prompt', textarea('positive_text', true), FIELD_IDS.positive_text));
}
form.append(field(negativeOnly ? 'Negative prompt' : 'Negative prompt (optional)', textarea('negative_text'), FIELD_IDS.negative_text));
const categories = datalist('categories', []), tags = datalist('tag-options', []);
if (!negativeOnly) {
const columns = el('div', undefined, 'two-col');
const category = input('category'), tagInput = input('tags');
category.setAttribute('list', 'categories');
tagInput.setAttribute('list', 'tag-options');
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
const rating = controls.rating = el('select');
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
save.type = 'submit';
form.append(line.note, actions);
for (const [name, node] of Object.entries(controls)) {
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
}, {signal: views.signal});
}
form.addEventListener('submit', event => {
event.preventDefault();
controller.submit();
}, {signal: views.signal});
cancel.addEventListener('click', () => finish(), {signal: views.signal});
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
if (!negativeOnly) loadVocabulary(tags, views.signal);
let categoryKey = null, refocus = null;
function render(state) {
for (const [name, node] of Object.entries(controls)) {
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
if (node.value !== value) node.value = value;
}
const key = state.categories.join('\n');
if (key !== categoryKey) {
categoryKey = key;
categories.replaceChildren(...datalist('', state.categories).children);
}
const saving = state.phase === 'saving';
refocus = settlingFocus(actions, state, refocus);
save.disabled = !SUBMITTABLE.has(state.phase);
cancel.disabled = close.disabled = saving;
// After a restore the numeric IDs may name another prompt; only the Library reload exit stays.
revise.disabled = saving || restoredStorage(state);
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
const status = statusOf(state, false, mode);
line.set(status?.text ?? pairProblem);
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
else archive.removeAttribute('aria-describedby');
const retry = recoveryButton(recovery, status?.action, views.signal);
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
refocus = restoreFocus(refocus, state, [retry, save]);
if (state.phase === 'complete') finish();
}
body.replaceChildren(form);
return {controller, views, render, start() {
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
render(controller.state);
controller.initialize();
}};
}
async function loadVocabulary(list, signal) {
try {
const result = await api.get('/prompt_manager/tags', {signal, cache: 'no-store'});
if (signal.aborted || !Array.isArray(result.tags)) return;
list.replaceChildren(...datalist('', result.tags.filter(tag => typeof tag === 'string').map(tag => `${tag},`)).children);
} catch {
// Tag suggestions are auxiliary vocabulary; the field stays fully usable without them.
}
}
/** Swap the open Edit dialog to the pair archive confirmation, reusing its controller. */
function archiveView() {
const {controller, trigger} = session;
// An Edit save's failure or validation message is not about the pair; the confirmation starts clean.
const inherited = controller.state.error;
session.views.abort();
const views = new AbortController();
const block = el('section', undefined, 'prompt-block prompt-negative');
block.setAttribute('aria-label', 'Negative prompt');
const blockHeading = el('div', undefined, 'prompt-block-heading');
blockHeading.append(el('h3', 'Negative prompt'));
const negative = el('div');
block.append(blockHeading, negative);
const links = el('p', undefined, 'small');
const restore = el('p', 'You can restore it from Show archived in Negative variants.', 'small');
const line = statusLine(), recovery = new Map();
let refocus = null;
const actions = el('div', undefined, 'dialog-actions');
const reread = createButton('Retry');
reread.addEventListener('click', () => controller.refreshPair(), {signal: views.signal});
const cancel = createButton('Cancel'), confirm = createButton('Archive', 'danger');
cancel.addEventListener('click', () => finish(), {signal: views.signal});
confirm.addEventListener('click', () => controller.setArchive(true), {signal: views.signal});
let shownNegative = null;
function render(state) {
const {pair} = state;
if (pair && pair.negative_text !== shownNegative) {
shownNegative = pair.negative_text;
negative.replaceChildren(shownNegative === '' ? el('p', 'Empty negative prompt', 'prompt-empty') : el('pre', shownNegative, 'prompt-text'));
}
// Counts come only from the controller's fresh exact-ID pair read.
const counted = state.pairRead === 'fresh' && Boolean(pair), readFailed = state.pairRead === 'error';
if (counted) {
const images = pair.image_count, runs = pair.run_count;
links.textContent = `${images} image${images === 1 ? '' : 's'} and ${runs} recorded run${runs === 1 ? '' : 's'} stay linked.`;
}
const status = state.error && state.error === inherited ? null : statusOf(state, true);
line.set(status?.text ?? (readFailed ? 'Linked images and recorded runs could not be read. Nothing was archived.'
: counted ? null : 'Checking linked images and recorded runs…'));
const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null);
links.hidden = !counted;
const saving = state.phase === 'saving';
refocus = settlingFocus(actions, state, refocus);
confirm.disabled = !counted || !SUBMITTABLE.has(state.phase);
cancel.disabled = close.disabled = saving;
setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]);
refocus = restoreFocus(refocus, state, [retry, confirm]);
if (state.phase === 'complete') finish();
}
body.replaceChildren(block, links, restore, line.note, actions);
session = {controller, trigger, views, render};
show('Archive negative variant');
controller.refreshPair();
render(controller.state);
}
function open({mode, positiveId = null, pairId = null, trigger = null, draft = null}) {
if (disposed) return;
const previous = session;
if (previous) {
// Swap the same open dialog's content, as the concept does; the old controller stops here.
previous.views.abort();
previous.controller.dispose();
}
const view = editorView({mode, positiveId, pairId, draft});
session = {controller: view.controller, trigger: trigger ?? previous?.trigger ?? null, views: view.views, render: view.render};
show(TITLES[mode]);
view.start();
}
function dispose() {
if (disposed) return;
finish();
disposed = true;
}
if (parentScope.signal.aborted) dispose();
else parentScope.signal.addEventListener('abort', dispose, {once: true});
return {open, dispose};
}