Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2

This commit is contained in:
Vito Sansevero
2026-10-28 20:15:25 -07:00
89 changed files with 7062 additions and 329 deletions
+2 -1
View File
@@ -78,7 +78,8 @@ export function parseCollections(data) {
}
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
export const collectionNameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC');
const nameKey = collectionNameKey;
const ordered = list => [...list].sort((a, b) => {
const left = nameKey(a.name), right = nameKey(b.name);
return left < right ? -1 : left > right ? 1 : a.id - b.id;
+54 -16
View File
@@ -8,6 +8,7 @@ import { showWorkspaceToast } from './toast.mjs';
import { openCopyFallback } from './workspace-dialogs.mjs';
import { displayPreferences } from './preferences.mjs';
import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs';
import { openCollectionsDialog } from './inspector-collections.mjs';
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
@@ -175,24 +176,32 @@ function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
* The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
* tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
* an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
* image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
* is plain.
*/
export function inspectorTagChips(record, {filterable}) {
const shown = new Set(), chips = [];
const add = (names, filter) => {
const chips = [];
const add = (names, filter, source) => {
const shown = new Set();
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
shown.add(key); chips.push({name, filter});
shown.add(key); chips.push({name, filter, source});
}
};
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
const {library} = record;
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
return chips;
}
/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
export function tagSectionLabel(source, library) {
if (source === 'image') return 'Image tags';
return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
@@ -220,7 +229,12 @@ function prefixedChip(prefix, text) {
/** D124: the inspector's line while "Load metadata on selection" is off. */
export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) {
/**
* #331 D139: with the client store `collections`, a listed file's footer ends with "Collections…", which opens its
* collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes.
*/
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback,
collections = null, onCollectionsChanged = null}) {
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
const scope = childScope(parentScope);
let contentScope;
@@ -289,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
// D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
if (chips.length) {
const tags = el('div', undefined, 'tags');
for (const {name, filter} of chips) {
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
for (const source of ['image', 'linked']) {
const own = chips.filter(chip => chip.source === source);
if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
@@ -317,8 +328,35 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
});
footer.append(history);
}
const member = collections ? revealTarget(record.identity) : null;
if (member) {
const button = createButton('');
button.dataset.collections = '';
button.append(createWorkspaceIcon('folder'), ' Collections…');
// A re-render replaces the button: closing focuses the current one, else the inspector's heading.
const fallback = () => container.querySelector('button[data-collections]') ?? container.querySelector('h2');
contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button,
onChanged:onCollectionsChanged, focusFallback:fallback}));
footer.append(button);
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
* D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
* The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
*/
function tagSection(source, label, chips) {
const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
const tags = el('div', undefined, 'tags');
for (const {name: text, filter} of chips) {
if (!filter) {tags.append(el('span', text, 'chip')); continue;}
const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
}
section.append(heading, tags);
return section;
}
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
+24 -4
View File
@@ -34,9 +34,13 @@ const MAX_SUGGESTIONS = 100;
const MAX_INSTRUCTION_CHARS = 4000;
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
const REOBSERVE = new Set(Object.values(REOPEN));
const REOBSERVE = new Set(['storage-changed', 'file-changed', 'capture-expired']);
// #430: a changed source (the page's root scope no longer names its root) is never retried as is: the page re-scopes
// the target (`retarget`) or the dialog offers a refresh.
const REOPEN = Object.freeze({...Object.fromEntries([...REOBSERVE].map(code => [code, code])), 'source-changed': 'source-changed'});
const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
// States whose reason blocks Save (and Generate): the dialog shows it beside its actions (D147).
const BLOCKED = new Set(['observe-error', 'source-changed', 'unknown-outcome', ...REOBSERVE]);
// Generic failure envelopes whose fixed sentence the dialog words itself.
const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
@@ -269,6 +273,7 @@ function derive(state) {
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
blocked: BLOCKED.has(state.phase),
};
}
@@ -291,7 +296,7 @@ function validatePreset(preset) {
*/
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
const imageTarget = validateTarget(target);
let imageTarget = validateTarget(target);
requireValue(SOURCES.has(source), 'Unknown tagging source');
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
const chosen = validatePreset(preset);
@@ -364,6 +369,10 @@ export function createImageTaggingController({api, target, source = 'images', on
}
} catch (error) {
if (closed || serial !== openSerial) return;
if (isApiError(error) && error.code === 'source-changed') {
set({phase: 'source-changed', statusText: error.message});
return;
}
set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
return;
}
@@ -531,6 +540,16 @@ export function createImageTaggingController({api, target, source = 'images', on
return send(lastBody);
}
/**
* #430: the page found the file under a fresh `target` (its sources were renumbered): observe it there, keeping the
* typed draft. Refused (false) while a save is in flight or after close; a Generate for the old target is dropped.
*/
function retarget(next) {
if (closed || saving) return Promise.resolve(false);
imageTarget = validateTarget(next);
return open({keepDraft: observedOnce || Boolean(state.manual)}).then(() => true);
}
function cancel() {
if (closed) return;
set({closed: true});
@@ -556,7 +575,8 @@ export function createImageTaggingController({api, target, source = 'images', on
return {
get state() {return state;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
get target() {return imageTarget;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
};
}
+68 -3
View File
@@ -24,6 +24,9 @@ import { showWorkspaceToast } from './toast.mjs';
import { cardTitle } from './images-state.mjs';
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
// #430: while the page looks for a renumbered source's file, and the preview that a refused capture leaves.
const FINDING_SOURCE = 'The source of this image changed. Finding it in the refreshed image collection…';
const PREVIEW_UNAVAILABLE = 'Preview unavailable';
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
// The option inputs per controller key; the classifier keys only exist for WD14 models.
const OPTION_IDS = Object.freeze({
@@ -194,6 +197,13 @@ function previewOf(image, state) {
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
return node;
}
/** D147: the preview box a failed or refused capture leaves, saying so (the reason is beside the actions). */
function unavailablePreview() {
const node = el('div', PREVIEW_UNAVAILABLE, 'tag-image-preview tag-preview-empty');
node.setAttribute('role', 'img');
node.setAttribute('aria-label', PREVIEW_UNAVAILABLE);
return node;
}
/**
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
@@ -207,7 +217,8 @@ function previewOf(image, state) {
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
*/
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null, onSourceChanged = null,
onRefresh = null}) {
const target = imageTarget(image);
if (!target) throw new TypeError('The image has no tagging target');
const review = mode === 'review';
@@ -216,7 +227,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
let advancing = false;
const saved = ({filename: name, tags, toast}) => {
const event = {target, filename: name, tags, source};
// A retargeted dialog (#430) reports the file under its current identity.
const event = {target: {...controller.target}, filename: name, tags, source};
announceSaved(event);
if (toast) showWorkspaceToast(toast);
onSaved?.(event);
@@ -280,6 +292,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
: (last ? 'Save and finish' : 'Save and next'));
if (review) actions.append(cancel, skip, primary);
else actions.append(cancel, primary);
// D147: the reason a state blocks Save (and Generate), on its own row above the buttons; for a changed source the
// page is asked to find the file again (`onSourceChanged`), else Refresh is offered.
const notice = el('div', undefined, 'tag-action-notice');
const noticeText = el('span');
noticeText.setAttribute('role', 'status');
const refresh = createButton(onRefresh ? 'Refresh image collection' : 'Reload page');
notice.append(noticeText);
const tried = new Set();
let finding = false, current = image;
const nodes = [heading];
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
@@ -334,8 +355,45 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
closeWorkspaceDialog();
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
}
/** #430: ask the page once per target where a renumbered source's file is now; a found file is retargeted. */
function findSource() {
const {root_index: index, root_scope: held} = controller.target;
const attempt = `${index}:${held}`;
if (!onSourceChanged || tried.has(attempt)) return;
tried.add(attempt);
finding = true;
Promise.resolve().then(() => onSourceChanged({...current, ...controller.target})).catch(() => null).then(fresh => {
finding = false;
if (!open || completed) return;
const next = imageTarget(fresh);
if (next && controller.state.phase === 'source-changed') {
current = fresh;
controller.retarget(next);
} else render(controller.state);
});
}
function renderNotice(state) {
const changed = state.phase === 'source-changed';
const text = !state.blocked ? '' : changed && finding ? FINDING_SOURCE : state.statusText;
setText(noticeText, text);
const offer = changed && !finding;
if (offer !== refresh.isConnected) {
if (offer) notice.append(refresh);
else refresh.remove();
}
// Its own first row; Retry still leads the buttons after it (D116).
if (text && actions.firstElementChild !== notice) actions.prepend(notice);
else if (!text && notice.isConnected) notice.remove();
// The empty preview of a refused or failed capture says why it is empty.
if (state.blocked && !state.image && preview.tagName !== 'DIV') {
const next = unavailablePreview();
preview.replaceWith(next);
preview = next;
}
}
function render(state) {
if (!open || completed) return;
if (state.phase === 'source-changed' && !finding) findSource();
if (state.image && state.image !== shownImage) renderImage(state.image);
// The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
if (state.draftRevision !== shownDraft) {
@@ -344,7 +402,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
}
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
setText(count, state.countText);
setText(status, state.statusText);
// A blocking reason is shown once, beside the actions (D147).
setText(status, state.blocked ? '' : state.statusText);
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
setDisabled(primary, !state.canSave);
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
@@ -356,10 +415,16 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
retry.remove();
}
}
renderNotice(state);
if (state.phase === 'saved') complete(state);
}
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
refresh.addEventListener('click', () => {
closeWorkspaceDialog();
if (onRefresh) onRefresh();
else window.location.reload();
});
skip.addEventListener('click', () => {
if (!open || advancing || disabled(skip)) return;
advancing = true;
+8 -4
View File
@@ -4,7 +4,8 @@ import { countOf } from './plural.mjs';
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
* categories are set in the Prompt library editor, the Prompt library's selection bar (#437, library-selection.mjs)
* and on the legacy admin page's "Set Category".
*/
export const CATEGORY_URL = '/prompt_manager/images/category';
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
@@ -20,11 +21,14 @@ export const CATEGORY_MAX_LENGTH = 100;
// One request names at most 500 images (DC2), as "Add tags" does.
export const MAX_CATEGORY_IMAGES = 500;
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
export function setOutcome(result, category) {
/**
* The success toast of a set: unchanged items count as set; items that could not be found are named after. `noun` is
* what was set (the Prompt library's selection bar passes "prompt", #437).
*/
export function setOutcome(result, category, noun = 'image') {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const missing = Number(result?.not_found) || 0;
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
const done = `Category “${category}” set on ${countOf(updated + unchanged, noun)}.`;
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* The Images selection bar's collection actions (#331 D138, design §7.2–7.4):
*
* - **Add to collection** (the concept's `bulk-collection` simpleInput, app.js a1392–1398): "Add selected images to
* collection", "Collection name", Add, with the existing names offered as suggestions. A name that matches a
* collection ignoring case adds to it; a new name creates it. The selection stays and the listing is not reloaded
* (an add never changes it).
* - **Remove from collection** (beyond the concept, only while a collection filter is active): no confirmation; the
* listing then reloads through `onChanged`, keeping the checked files still listed.
*
* The routes take at most 500 explicit files. "All N matching" (#392, D135) is accepted only when every match is
* loaded and checked and there are at most 500 of them; otherwise the action toasts "Select at most 500 images." and
* sends nothing (ruling for #331 PR 3: the collection routes have no filter body).
*/
import { bulkTargets } from './images-tagging-bulk.mjs';
import { ADD_DIALOG_ACTION, ADD_DIALOG_LABEL, ADD_DIALOG_TITLE, ADD_FAILED_TOAST, FILES_NOT_FOUND_TOAST, MAX_COLLECTION_NAME_LENGTH,
REMOVE_FAILED_TOAST, TOO_MANY_TOAST, addedMessage, isCollectionGone, normalizeCollectionName, removedMessage,
writeFailure } from './collections.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
/**
* The files an action sends for the checked `images` (listing order): `{targets}`, or `{refusal}` with the toast.
* With `allMatching`, `total` is the listing's match count; only a fully loaded match is sent.
*/
export function collectionTargets(images, { allMatching = false, total = images.length } = {}) {
if (allMatching && total !== images.length) return { refusal: TOO_MANY_TOAST };
const targets = bulkTargets(images);
if (!targets) return { refusal: TOO_MANY_TOAST };
return targets.length ? { targets } : { refusal: FILES_NOT_FOUND_TOAST };
}
/**
* Add to collection over `images` (the checked files, taken now). `collections` is the client store; `openInput` is
* the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing.
*/
export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length,
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION,
maxLength: MAX_COLLECTION_NAME_LENGTH, suggestions: collections.collections.map(item => item.name),
onApply: async text => {
const name = normalizeCollectionName(text);
if (name === null) { notify(ADD_FAILED_TOAST); return; }
try {
const result = await collections.add({ name }, targets);
if (!scope.signal.aborted) notify(addedMessage(result));
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, ADD_FAILED_TOAST));
if (error?.code === 'source-changed') onSourceChanged?.();
}
} });
}
/**
* Remove the checked `images` from the open collection `collectionId`. Resolves once the toast is shown; a confirmed
* remove, or a collection found deleted meanwhile, calls `onChanged()` so the page lists it again.
*/
export async function removeFromCollection({ scope, collections, collectionId, images, allMatching = false, total = images.length,
notify = showWorkspaceToast, onChanged = null, onSourceChanged = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
try {
const result = await collections.remove(collectionId, targets);
if (scope.signal.aborted) return;
notify(removedMessage(result.collection.name, result.removed));
onChanged?.();
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, REMOVE_FAILED_TOAST));
if (isCollectionGone(error)) onChanged?.();
else if (error?.code === 'source-changed') onSourceChanged?.();
}
}
+38 -2
View File
@@ -8,6 +8,7 @@ import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './work
import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CHECKBOX_LABEL = '.image-overlays > label';
@@ -42,9 +43,14 @@ function el(tag, text, className) {
* `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and
* `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N
* matching", whose actions send that filter to the server.
*
* Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category;
* while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove
* calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) {
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null }) {
let checked = initial;
let exporting = false, categorizing = false;
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
@@ -75,7 +81,19 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
...matchingTarget() }));
bulk.append(addTags, autoTag);
}
bulk.append(setCategory, exportMetadata, clearButton);
bulk.append(setCategory);
if (collections) {
const addToCollection = createButton('Add to collection');
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
bulk.append(addToCollection);
}
if (collections && collection) {
const removeButton = createButton('Remove from collection');
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
bulk.append(removeButton);
}
bulk.append(exportMetadata, clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
@@ -143,6 +161,24 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
function matchingTarget() {
return allMatching ? { filter: getFilter(), count: getListing().total } : {};
}
/** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */
function collectionSelection() {
return { allMatching, total: allMatching ? getListing().total : checked.size };
}
/** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */
let removing = false;
async function removeChecked(button) {
if (removing) return;
removing = true;
const release = busy(button);
try {
await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(),
...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged });
} finally {
removing = false;
release();
}
}
/** Toggle one image on its own; it becomes the anchor. */
function toggle(key, on = !isChecked(key)) {
settleMatching();
@@ -0,0 +1,96 @@
/**
* Finding a file again after its source changed (#430).
*
* A listed file is addressed as `{root_index, root_scope, path}`. When the sources are renumbered every per-file
* action on it answers 409 `source-changed`. `rescopeImage` asks the server (POST /prompt_manager/images/rescope)
* for the file's current listing descriptor: the server maps the target only when its scope was issued for one of
* the current roots, so a removed or replaced source (or a changed key) answers null and the page offers Refresh.
*
* `createSourceRecovery` is the page's single flight: one rescope per held `(key, scope)` pair, its result shared
* by every caller (the inspector, the viewer and the Tag image dialog may all ask for the same file), and a pair
* already tried answers its first result, so a refusal can never loop. `apply(image, fresh, started)` is the page's
* part (refresh the collection, move the selection); `started` is the page generation when the rescope began, so a
* page whose filters changed meanwhile can leave itself alone. `reset()` (a manual Refresh) forgets every pair.
*/
import { imageKey } from './images-state.mjs';
export const RESCOPE_URL = '/prompt_manager/images/rescope';
// A page holds a handful of stale files at once; older pairs are forgotten first.
const MEMO_LIMIT = 64;
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
function validTarget(value) {
return value !== null && typeof value === 'object' && Number.isSafeInteger(value.root_index) && value.root_index >= 0 &&
hex64(value.root_scope) && typeof value.path === 'string' && value.path.length > 0;
}
/** The fresh descriptor of `image` (same path, current root and scope), else null. */
export async function rescopeImage(api, image, {signal} = {}) {
if (!validTarget(image)) return null;
let value;
try {
value = await api.post(RESCOPE_URL, {target: {root_index: image.root_index, root_scope: image.root_scope, path: image.path}},
{signal});
} catch (error) {
// 409 (the source is gone) or 404 (the file is gone): final, the caller offers Refresh. Anything else (no
// answer, a 5xx) is rethrown so the pair may be asked again.
if (error?.status === 404 || error?.status === 409) return null;
throw error;
}
const fresh = value?.success === true ? value.image : null;
if (!validTarget(fresh) || fresh.path !== image.path) return null;
return Object.freeze({...fresh});
}
export function createSourceRecovery({api, signal, apply, generation = () => 0}) {
// An embedded gallery's own page adapter may have no `post`: its rescope then simply finds nothing.
if (typeof apply !== 'function') throw new TypeError('createSourceRecovery needs apply');
const memo = new Map();
function recover(image) {
if (!validTarget(image)) return Promise.resolve(null);
const pair = `${imageKey(image)}|${image.root_scope}`;
if (memo.has(pair)) return memo.get(pair);
const started = generation();
const pending = rescopeImage(api, image, {signal})
.then(fresh => (fresh && !signal?.aborted ? apply(image, fresh, started) : null))
.then(result => result ?? null, () => {
if (memo.get(pair) === pending) memo.delete(pair);
return null;
});
memo.set(pair, pending);
if (memo.size > MEMO_LIMIT) memo.delete(memo.keys().next().value);
return pending;
}
return {recover, reset: () => memo.clear()};
}
/**
* The Images page's `apply` (#430). Keys are `root_index:path`, so after a renumbering nothing from the old list is
* kept by key (a checked or selected key could name a same-named file in another source): the viewer closes, the
* checks clear, a Source filter on the old index follows the file, and only the file itself stays selected while the
* collection lists again. An unchanged numbering re-reads the loaded range in place. `page` holds the page's hooks.
*/
export function recoveredSource(page) {
return async (image, fresh, started) => {
// The held scope still names its root: nothing is stale (a media error had another cause).
if (fresh.root_index === image.root_index && fresh.root_scope === image.root_scope) return null;
if (!page.current(started)) return fresh;
const selected = page.selected(image);
if (fresh.root_index === image.root_index) {
if (selected) page.inspect(fresh);
await page.stream.refresh().catch(() => {});
return fresh;
}
page.closeViewer();
page.clearChecks();
const {filters} = page;
if (filters.root === String(image.root_index)) filters.root = String(fresh.root_index);
else if (filters.root !== 'all') { filters.root = 'all'; filters.subfolder = ''; }
if (selected) page.inspect(fresh);
else page.dropSelection();
page.updateScope();
page.stream.reset(filters, {load:page.listsNow()});
page.rediscover();
return fresh;
};
}
+2 -2
View File
@@ -14,9 +14,9 @@ export const REFRESH_FAILED = 'Images could not be refreshed after the tag save.
/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
* `image-tag` does); false for any other control. */
export function openCardTagging({api, scope, image, action, embedded}) {
export function openCardTagging({api, scope, image, action, embedded, onSourceChanged = null, onRefresh = null}) {
if (!isTagButton(action)) return false;
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action, onSourceChanged, onRefresh});
return true;
}
+59 -8
View File
@@ -22,6 +22,7 @@ import { displayPreferences } from './preferences.mjs';
import { onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
import { createSourceRecovery, recoveredSource } from './images-source-recovery.mjs';
export { onFavoriteChange } from './images-favorites.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
@@ -68,6 +69,7 @@ const RETRY_RESCAN = 'Rescan from Import to retry.';
// The concept's empty collection (app.js updateImages).
const NO_MATCHES = 'No matching images. Try another search or category.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
const CURSOR_STALE = 'The image collection changed. Refresh image collection to continue.';
/** 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}`;
@@ -114,10 +116,12 @@ export function selectedImage() {
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
* deleted collection, D140) the page lists nothing and says so.
* deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the
* selection bar's Add to collection, and Remove from collection while a collection is open (D138).
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) {
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null,
collections = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
@@ -128,7 +132,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
const preferences = displayPreferences();
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
// The panel's listed file: a changed source is looked up once, the panel's Refresh stays the fallback (#430).
let selectedImage = null;
scope.cleanup(inspection.subscribe(next => {
detailInspector?.setState(next);
if (next.refreshRequired && next.code === 'source-changed' && selectedImage && imageKey(selectedImage) === selection) {
recovery.recover(selectedImage);
}
}));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
@@ -293,12 +304,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
getFilter:() => {
const filter = listingFilter(filters), scope = state?.items[0]?.root_scope;
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
}});
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
// #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the
// viewer reports only once it closes.
const inspectorCollections = embedded ? null : collections;
const collectionsChangedBy = ({added, removed}) => {
if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged();
};
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
}});
@@ -310,9 +331,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function inspect(image) {
detailInspector?.dispose();
selection = imageKey(image);
selectedImage = 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(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
@@ -350,7 +373,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
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);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
});
function renderVisible() {
@@ -498,6 +522,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
if (next.error?.code === 'cursor-stale') return CURSOR_STALE;
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`;
@@ -535,6 +560,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
setText(message, continuationText(next));
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
retry.hidden = !next.error || gone;
// A cursor the server can no longer verify is never resent: Retry becomes the collection refresh (#430).
setText(retry, next.error?.code === 'cursor-stale' ? 'Refresh image collection' : 'Retry');
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
@@ -579,7 +606,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : 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();
@@ -607,8 +634,18 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
details.replaceChildren(noSelection());
}
selection = null;
selectedImage = null;
}
// Bumped by every reset: a source recovery that began before it leaves the page alone (#430).
let generation = 0;
const recovery = createSourceRecovery({api, signal:scope.signal, generation:() => generation, apply:recoveredSource({
selected:image => selection === imageKey(image), inspect, dropSelection, stream, filters, updateScope,
closeViewer:() => { mediaViewer.stopSlideshow(); main.querySelector('#viewer-dialog[open]')?.close(); },
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
current:started => !scope.signal.aborted && started === generation,
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
function reset() {
generation += 1;
mediaViewer.stopSlideshow();
main.querySelector('#viewer-dialog[open]')?.close();
dropSelection({keepPanel:recovering});
@@ -710,8 +747,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
}
/**
* A confirmed collection change that may change this listing (a remove from the open collection): the loaded range
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
await stream.refresh();
} catch {
if (!scope.signal.aborted) reset();
}
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
recovery.reset();
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
filters.root = 'all';filters.subfolder = '';
+11 -4
View File
@@ -9,7 +9,7 @@ import { mountVocabulary } from './vocabulary.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { bindCanvasLink, canvasHref } from './topbar.mjs';
import { renderHeading, pageHeading } from './heading.mjs';
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { closeReviewDialog, closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK,
LIST_FAILED_TEXT } from './collections.mjs';
@@ -20,7 +20,7 @@ import { mountInspect } from './inspect.mjs';
import { mountReview } from './review.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { mountSystem } from './system.mjs';
import { onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { createRestoreReload, onRestoreFinished, onRestoreHandedBack, showRestoreReview } from './system-backups.mjs';
import { mountLibraryBanner } from './library-location.mjs';
import { detectJxlSupport } from './thumbnail-support.mjs';
@@ -184,13 +184,20 @@ function mountWorkspace() {
refreshCounts(NAV_COUNT_ROUTES);
// S9: a finished restore replaced the library. The cached visits are dropped, the counts and saved views read
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
scope.cleanup(onRestoreFinished(activity, () => {
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
// waits until the last one has closed, so the dialog keeps what was typed.
const restoreReload = createRestoreReload(() => {
forgetImagesVisit();
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);
savedViews.load();
collections.load();
if (currentKey !== 'activity') navigateTo(window.location.hash);
});
scope.cleanup(() => restoreReload.dispose());
scope.cleanup(onRestoreFinished(activity, () => {
closeReviewDialog();
restoreReload.request();
}));
// S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back:
// it opens again over the page shown now, with Restore (again) and Discard; leaving it any other way discards it.
@@ -403,7 +410,7 @@ function mountWorkspace() {
if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') {
mountNewPromptAction(main, api, view);
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
collection, onCollectionMissing, onSaveView:saveView('images', view), activity});
collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections});
return;
}
if (route.id === 'library') {
+1
View File
@@ -21,6 +21,7 @@ export class InspectionSourceChangedError extends Error {
super(SOURCE_CHANGED);
this.name = 'InspectionSourceChangedError';
this.refreshRequired = true;
this.code = 'source-changed';
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* The shared inspector's "Collections…" dialog (#331 D139, design §8), beyond the concept: one listed file's
* collections as a checklist in the sidebar's order, a New collection field, then Cancel and Save.
*
* - The file's membership is read when the dialog opens ("Loading collections…"); a failed read says "Collections
* could not be loaded." with Retry, and Save waits for a read that worked.
* - Each row is the concept's checkbox row (`label.tag-suggestion`, app.js a1128). With no collection the list says
* "No collections yet. Name one below."
* - Save sends one request per change, in order: the new name, each newly checked collection, each unchecked one.
* Nothing changed sends nothing and closes. All sent: the dialog closes with "Collections updated.". A failure keeps
* it open with "The collections could not be updated. Try again." and the membership read again; the changes that
* went through still reach `onChanged({added, removed})` (collection ids), as a full success does, but only once the
* dialog closes: a page that lists the file again (a collection page) would otherwise remove the dialog before its
* failure is read. Save waits for the membership read again.
* - A New collection name equal to a listed collection ignoring case is that collection's checkbox, checked. A name
* that cannot be a collection name is refused on the field; nothing is sent and the checkbox edits stay.
* - Focus starts on the field and moves to the first checkbox once they are listed; closing returns it to the
* trigger, or to `focusFallback()` when an inspector re-render removed the trigger.
*/
import { createButton } from '../shared/toolkit/core.js';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { COLLECTIONS_UPDATED_TOAST, MAX_COLLECTION_NAME_LENGTH, MEMBERSHIP_FAILED_TEXT, MEMBERSHIP_LOADING_TEXT,
NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, collectionNameKey, normalizeCollectionName } from './collections.mjs';
export const COLLECTIONS_DIALOG_TITLE = 'Collections';
export const NEW_COLLECTION_LABEL = 'New collection';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Open the dialog for `target` (`{root_index, root_scope, path}`) with the client store `collections`. `openDialog`
* and `closeDialog` are the dialog seams; `notify` the toast.
*/
export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null,
focusFallback = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) {
const local = new AbortController();
const active = () => !scope.signal.aborted && !local.signal.aborted;
let member = null, saving = false, listed = false;
// The confirmed changes, reported once when the dialog closes (review M2).
const changes = { added: [], removed: [] };
const status = el('p', '', 'small');
const retry = createButton('Retry');
const list = el('div');
list.setAttribute('role', 'group');
list.setAttribute('aria-label', COLLECTIONS_DIALOG_TITLE);
const input = el('input');
input.value = '';
input.maxLength = MAX_COLLECTION_NAME_LENGTH;
input.autocomplete = 'off';
input.addEventListener('input', () => input.setCustomValidity?.(''));
const problem = el('p', '', 'small');
problem.setAttribute('role', 'alert');
problem.hidden = true;
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel');
const save = createButton('Save', 'ca-primary');
actions.append(cancel, save);
const blocked = () => member === null || saving;
const paintSave = () => {
if (blocked()) save.setAttribute('aria-disabled', 'true');
else save.removeAttribute('aria-disabled');
};
retry.addEventListener('click', () => load());
cancel.addEventListener('click', () => { if (!saving) closeDialog(); });
save.addEventListener('click', () => { if (!blocked()) submit(); });
/** One checkbox row per collection, checked where the file is a member. */
function renderList() {
list.replaceChildren();
if (!collections.collections.length) {
list.append(el('p', NO_COLLECTIONS_TEXT, 'small'));
return;
}
for (const item of collections.collections) {
const row = el('label', undefined, 'tag-suggestion');
const box = el('input');
Object.assign(box, { type: 'checkbox', id: `collection-choice-${item.id}`, checked: member.has(item.id) });
box.dataset.collectionId = String(item.id);
row.htmlFor = box.id;
row.append(box, el('span', item.name));
list.append(row);
}
}
function showStatus(text, failed) {
status.textContent = text;
status.setAttribute('role', failed ? 'alert' : 'status');
status.hidden = !text;
retry.hidden = !failed;
}
/** Save waits while the membership is read (again). */
function loading() {
member = null;
paintSave();
list.replaceChildren();
showStatus(MEMBERSHIP_LOADING_TEXT, false);
}
async function load() {
loading();
try {
const ids = await collections.membership(target);
if (!active()) return;
member = new Set(ids);
showStatus('', false);
renderList();
// The first listing takes focus from the untouched field to the first checkbox (review L6).
if (!listed && !input.value) firstBox().focus();
listed = true;
} catch {
if (!active()) return;
showStatus(MEMBERSHIP_FAILED_TEXT, true);
}
paintSave();
}
const boxesOf = () => [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId);
const firstBox = () => boxesOf()[0] ?? input;
/**
* The requests Save sends, in order; null when the new name cannot be a collection name. A name equal to a listed
* collection's ignoring case is that collection's checkbox, checked (review L4).
*/
function plan() {
const typed = input.value.trim();
const name = typed ? normalizeCollectionName(typed) : '';
if (name === null) return null;
const named = name ? collections.collections.find(item => collectionNameKey(item.name) === collectionNameKey(name)) : null;
const steps = name && !named ? [{ add: { name } }] : [];
const boxes = boxesOf().map(box => ({ id: Number(box.dataset.collectionId), checked: box.checked }))
.map(box => (box.id === named?.id ? { ...box, checked: true } : box));
for (const box of boxes) if (box.checked && !member.has(box.id)) steps.push({ add: { collectionId: box.id } });
for (const box of boxes) if (!box.checked && member.has(box.id)) steps.push({ remove: box.id });
return steps;
}
async function submit() {
problem.textContent = '';
problem.hidden = true;
const steps = plan();
if (steps === null) {
// Review L5: refused on the field; nothing is sent and the checkbox edits stay.
input.setCustomValidity?.(UPDATE_FAILED_TEXT);
input.reportValidity?.();
input.focus();
return;
}
if (steps.length === 0) { closeDialog(); return; }
saving = true;
paintSave();
let failed = false;
for (const step of steps) {
try {
if (step.add) changes.added.push((await collections.add(step.add, [target])).collection.id);
else changes.removed.push((await collections.remove(step.remove, [target])).collection.id);
} catch {
failed = true;
break;
}
if (!active()) return;
if (step.add?.name) input.value = '';
}
if (!active()) return;
saving = false;
if (!failed) {
closeDialog();
notify(COLLECTIONS_UPDATED_TOAST);
return;
}
problem.textContent = UPDATE_FAILED_TEXT;
problem.hidden = false;
// Review M1: Save waits from here until the membership is read again. A collection deleted meanwhile is
// re-read by the store; the list then shows what the server holds.
loading();
await collections.settled();
if (active()) await load();
}
function closed() {
local.abort();
if (changes.added.length || changes.removed.length) onChanged?.({ added: [...changes.added], removed: [...changes.removed] });
// The shell focuses a trigger still on the page; one an inspector re-render replaced falls back (review L6).
if (trigger && !trigger.isConnected) focusFallback?.()?.focus();
}
showStatus('', false);
openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: closed, focusTarget: input,
nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] });
load();
}
+280
View File
@@ -0,0 +1,280 @@
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, setOutcome } from './images-category.mjs';
import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { countOf } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
/**
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
* category" (#437), the legacy admin page's bulk Add Tags and Set Category in the workspace. Built from the concept's
* Images parts only: each card's `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the actions in the
* Images bar's order, Clear). The selection is a set of
* prompt ids over the shelf's loaded items, so windowed-out cards keep their state; Shift+click on a checkbox applies
* the anchor's range (the Images helpers, D135). The dialog, the range helpers, the count and the toast text are the
* Images bar's own; one bar core shared with images-selection.mjs is a follow-up (D148).
*
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
* Add tags writes prompt tags only, never image tags or auto-tags.
*/
export const CATEGORY_URL = '/prompt_manager/bulk/category';
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
// The route's cap (`BULK_CATEGORY_MAX_PROMPTS`): more opens nothing.
export const MAX_CATEGORY_PROMPTS = 1000;
export const TOO_MANY_PROMPTS = 'Select at most 1,000 prompts.';
export const STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No category was changed.';
export const UNCONFIRMED = 'The category change could not be confirmed. The Library was reloaded.';
export const NONE_FOUND = 'None of the selected prompts could be found. The Library was reloaded.';
export const CONTROL_CHARACTERS = 'A category cannot contain control characters.';
export { CATEGORY_FAILED };
export const TAGS_URL = '/prompt_manager/bulk/tags';
export const TAG_OPTIONS_URL = '/prompt_manager/tags';
export const PROMPT_TAGS_DIALOG = Object.freeze({ title: 'Tag selected prompts', label: 'Tags, separated by commas', action: 'Apply' });
// What the editor's save accepts for one prompt (`validate_tags`): the route refuses more.
export const MAX_PROMPT_TAGS = 20;
export const MAX_TAG_LENGTH = 50;
export const TAG_PROBLEMS = Object.freeze({ none: NO_TAGS, many: 'Enter at most 20 tags.',
long: 'A tag can have at most 50 characters.', control: 'A tag cannot contain control characters.' });
export const TAGS_FAILED = 'Tags could not be added. Try again.';
export const TAGS_STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No tags were added.';
export const TAGS_UNCONFIRMED = 'Adding the tags could not be confirmed. The Library was reloaded.';
// Answers that may follow a committed write (the Library editor's unknown outcome): no status, or a gateway's.
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CARD = 'article[data-prompt-id]';
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* What a settled write means for the page: `toast` to show, whether the write may have `committed` (the page then
* invalidates its cached Library views) and whether the shelf must `reload` (which clears the selection).
*/
export function writeOutcome(result, failure, kind = 'category') {
const copy = WRITES[kind];
if (failure === null && result?.success) {
const found = ['updated', 'unchanged', 'over_limit'].reduce((sum, key) => sum + (Number(result[key]) || 0), 0);
if (!found) return { toast: NONE_FOUND, committed: false, reload: true };
return { toast: copy.success(result), committed: (Number(result.updated) || 0) > 0, reload: true };
}
if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true };
const status = failure?.status;
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
return { toast: copy.failed, committed: false, reload: false };
}
/** The Add tags toast (#442): what was added, then what already had them, was skipped or could not be found. */
export function tagsOutcome(result) {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const skipped = Number(result?.over_limit) || 0, missing = Number(result?.not_found) || 0;
const names = Array.isArray(result?.tags) ? result.tags.join(', ') : '';
const parts = [];
if (updated) parts.push(`Added “${names}” to ${countOf(updated, 'prompt')}.`);
if (unchanged) parts.push(`${countOf(unchanged, 'prompt')} already had ${updated ? 'them' : `“${names}”`}.`);
if (skipped) parts.push(`${countOf(skipped, 'prompt')} would pass ${MAX_PROMPT_TAGS} tags and ${skipped === 1 ? 'was' : 'were'} skipped.`);
if (missing) parts.push(`${missing.toLocaleString('en-US')} could not be found.`);
return parts.join(' ');
}
const WRITES = Object.freeze({
category: { success: result => setOutcome(result, result.category, 'prompt'), storage: STORAGE_CHANGED,
unconfirmed: UNCONFIRMED, failed: CATEGORY_FAILED },
tags: { success: tagsOutcome, storage: TAGS_STORAGE_CHANGED, unconfirmed: TAGS_UNCONFIRMED, failed: TAGS_FAILED },
});
/**
* "Select all N loaded" (#444 ruling, D151; the legacy page's select-all checkbox): offered while some loaded prompts
* are unchecked and no more are loaded than an action accepts. It sits where D135's "Select all N matching" sits on
* Images, but that offer appears once every loaded image is checked; this one reaches only the loaded prompts.
*/
export function offersLoaded(selected, keys) {
return selected.size > 0 && keys.length <= MAX_CATEGORY_PROMPTS && keys.some(key => !selected.has(key));
}
export const loadedOffer = count => `Select all ${count.toLocaleString('en-US')} loaded`;
/** The Add tags field's check before Apply: the parsed names, as `validate_tags` would judge them. */
export function tagsProblem(text) {
const tags = parseBulkTags(text);
if (!tags.length) return TAG_PROBLEMS.none;
if (tags.length > MAX_PROMPT_TAGS) return TAG_PROBLEMS.many;
if (tags.some(tag => tag.length > MAX_TAG_LENGTH)) return TAG_PROBLEMS.long;
if (tags.some(tag => /[\x00-\x1f]/.test(tag))) return TAG_PROBLEMS.control;
return null;
}
/** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */
export function categoryProblem(value) {
return /[\x00-\x1f]/.test(value) ? CONTROL_CHARACTERS : null;
}
/**
* Mount the selection on the shelf `grid`. `getItems()` is the shelf's loaded items in order, `getEpoch()` the
* storage epoch they were read under, `getCategories()` the Library's category list (the field's suggestions).
* `onCommitted(kind, receipt)` (`category` or `tags`) runs after a write that may have changed prompts, even once the page is gone (the page
* invalidates its cached Library views); `onReload()` then reloads the mounted shelf (which calls `clear`). Focus moves
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
const clearButton = createButton('Clear');
const selectLoaded = createButton('');
const actions = [addTags, setCategory, selectLoaded];
bulk.append(count, addTags, setCategory, clearButton);
scope.listen(selectLoaded, 'click', () => {
if (writing) return;
selectAll();
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
addTags.focus({ preventScroll: true });
});
scope.listen(addTags, 'click', () => openTags());
scope.listen(setCategory, 'click', () => openCategory());
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
scope.listen(element, 'keydown', event => {
if (event.key !== 'Escape' || !checked.size) return;
event.preventDefault();
clear();
focusFallback.focus({ preventScroll: true });
});
const ids = () => getItems().map(item => item.id);
function render() {
if (!checked.size) { bulk.remove(); return; }
const label = selectionLabel(checked.size);
if (count.textContent !== label) count.textContent = label;
const keys = ids();
if (offersLoaded(checked, keys)) {
const offer = loadedOffer(keys.length);
if (selectLoaded.textContent !== offer) selectLoaded.textContent = offer;
if (count.nextElementSibling !== selectLoaded) count.after(selectLoaded);
} else selectLoaded.remove();
if (bulk.parentNode !== element) element.append(bulk);
}
function paint() {
for (const box of grid.querySelectorAll(CHECKBOX)) {
const on = checked.has(Number(box.closest(CARD).dataset.promptId));
if (box.checked !== on) box.checked = on;
}
}
function update(next) { checked = next; paint(); render(); }
function clear() {
anchor = null;
if (checked.size) update(Object.freeze(new Set()));
}
/** Every loaded prompt, windowed-out cards included; the anchor stays. */
function selectAll() {
update(applyKeys(checked, ids(), true));
}
// The checkbox toggles natively; a Shift+click applies the anchor's range with the box's new state.
scope.listen(grid, 'click', event => {
const box = event.target.closest('.image-overlays > label');
if (box && event.shiftKey) shiftBox = Number(box.closest(CARD).dataset.promptId);
});
scope.listen(grid, 'change', event => {
if (!event.target.matches(CHECKBOX)) return;
const id = Number(event.target.closest(CARD).dataset.promptId), on = event.target.checked;
if (shiftBox === id) {
const keys = rangeKeys(ids(), anchor, id);
if (anchor === null || keys.length === 1) anchor = id;
update(applyKeys(checked, keys, on));
} else {
anchor = id;
update(toggleSelection(checked, id, on));
}
shiftBox = null;
});
/** The card's overlay: the concept's `.image-overlays > label > input`, checked from the selection. */
function overlayFor(item, name) {
const overlays = el('div', undefined, 'image-overlays'), label = el('label'), box = el('input');
box.type = 'checkbox';
box.checked = checked.has(item.id);
box.setAttribute('aria-label', `Select ${name}`);
label.append(box);
overlays.append(label);
return overlays;
}
/** The selected loaded prompts and the shelf epoch, or null after a toast says why nothing can be sent. */
function target(failed) {
if (writing) return null;
const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id));
if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return null; }
const epoch = getEpoch();
if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(failed); return null; }
return { prompt_ids: selected, expected_epoch: epoch };
}
function openCategory() {
const chosen = target(CATEGORY_FAILED);
if (!chosen) return;
openInputDialog({ scope, trigger: setCategory, title: PROMPT_CATEGORY_DIALOG.title, label: PROMPT_CATEGORY_DIALOG.label,
action: PROMPT_CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
validate: categoryProblem, onApply: category => write(CATEGORY_URL, { ...chosen, category }, 'category') });
}
/** Add tags (#442): the tag vocabulary as suggestions, the editor's list; a failed read opens without them. */
async function openTags() {
const chosen = target(TAGS_FAILED);
if (!chosen) return;
let suggestions = null;
try {
const result = await api.get(TAG_OPTIONS_URL, { signal: scope.signal, cache: 'no-store' });
if (Array.isArray(result?.tags)) suggestions = result.tags.filter(tag => typeof tag === 'string');
} catch {
// Suggestions are auxiliary; the field works without them.
}
if (scope.signal.aborted) return;
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
}
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
async function write(url, body, kind) {
writing = true;
for (const button of actions) button.setAttribute('aria-disabled', 'true');
let result = null, failure = null;
try {
result = await api.post(url, body);
} catch (error) {
failure = error;
} finally {
writing = false;
for (const button of actions) button.removeAttribute('aria-disabled');
}
const outcome = writeOutcome(result, failure, kind);
// A left page still invalidates what it cached, so a return never shows the old categories.
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
if (scope.signal.aborted) return;
const focused = element.contains(document.activeElement);
showWorkspaceToast(outcome.toast);
if (!outcome.reload) return;
onReload();
if (focused) focusFallback.focus({ preventScroll: true });
}
render();
return {
element,
overlayFor,
isChecked: id => checked.has(id),
hasSelection: () => checked.size > 0,
clear,
selectAll,
/** A loaded batch: the count and the offer follow it. */
listingChanged: () => { if (checked.size) render(); },
/** Whether a Set category write is still in flight. */
writing: () => writing,
};
}
+10 -5
View File
@@ -19,7 +19,7 @@ function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
@@ -27,7 +27,7 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true;
if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
state=Object.freeze({...saved,epoch:saved.epoch ?? null,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
async function loadMore() {
@@ -37,14 +37,19 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
try {
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
if(disposed || current!==request) return 0;
validatePage(page,cursor);const incoming=[];const added=new Set();
validatePage(page,cursor);
// #437: the storage epoch the shelf's items were read under, which a bulk write sends as its fence; a later
// page from a restored storage is refused (its items would belong to another database).
const epoch=typeof page.library_epoch==='string'?page.library_epoch:null;
if(state.epoch!==null && epoch!==state.epoch) throw new TypeError('Prompt storage changed while the library was loading');
const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
+24 -9
View File
@@ -15,6 +15,7 @@ import {pageHeading} from './heading.mjs';
import {mountToolbar} from './toolbar.mjs';
import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
let shelfVisit=null;
@@ -30,6 +31,8 @@ const metadataDrafts=new Map();
const archivedVisible=new Map();
// The concept's three sorts first, then the Library's appended Oldest first (D14).
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
const CARD_CONTROLS='button, input[type="checkbox"]';
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
function el(tag,text,className) {
@@ -171,6 +174,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
captionCount.setAttribute('role','status');
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
@@ -201,7 +209,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(exportButton,'click',exportLibrary);
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
@@ -246,7 +254,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
actions.append(send,explore);footer.append(facts,actions);
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
article.append(selection.overlayFor(item,name),art,content,footer);article.dataset.index=index;return {node:article,blocks};
}
function captureAnchor() {
// Mounted rows can already differ from estimates before their observer batch.
@@ -340,10 +348,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
if(changed) {pendingAnchor ||= anchor;schedule();}
}):null;
measurementObserver?.observe(grid);
function focusCard(index,last=false) {
/** Focus a card's first or `last` control (Tab across the window edge), or its title (`title`: Home/End/arrows). */
function focusCard(index,last=false,title=false) {
if(index<0 || index>=state.items.length) return;
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
const controls=card.querySelectorAll(CARD_CONTROLS);(title?card.querySelector('.card-title > button'):last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
}
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
scope.listen(grid,'click',event=>{
@@ -355,15 +364,19 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
scope.listen(grid,'keydown',event=>{
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
const index=Number(card.dataset.index);
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
// D151 (#444 ruling): Ctrl/Cmd+A selects every loaded prompt and Escape clears, as on Images (focus stays).
if((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase()==='a') {event.preventDefault();selection.selectAll();return;}
if(event.key==='Escape' && selection.hasSelection()) {event.preventDefault();selection.clear();return;}
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1),false,true);}
else if(event.key==='Tab') {
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
const controls=card.querySelectorAll(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1);
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
}
});
function render(next) {
if(items!==next.items) {
const batch=items!==next.items;
if(batch) {
pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true;
if(!items.length) {
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
@@ -372,6 +385,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
// A loaded batch: the selection bar's count and Select all N loaded follow it (D151).
if(batch) selection.listingChanged();
if(next.variantTotal!==null) {
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
@@ -392,10 +407,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
optionsRequest?.abort();const request=optionsRequest=new AbortController();
try {
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
categoryNames=[...data.categories];toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
}
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
const reset=()=>{restoreScrollY=null;selection.clear();toolbar.setActiveFilters(refinements());return stream.reset(filters);};
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
function applyToolbar(value) {
const next={text:value.q.trim(),category:value.category,sort:value.sort};
+1 -1
View File
@@ -4,7 +4,7 @@
// tests/test_local_inspect_parity.py compares every answer with the server's on the same records. Values follow
// Python's model (py-json.mjs): dict order, int versus float, code-point lengths, `str.strip()`.
//
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 adds candidate selection among several
// Task 8 ported execution evidence and graph tracing for one sampler; Task 9 added candidate selection among several
// samplers and detailer stages (local-resolver-stages.mjs) and A1111 `parameters` (local-parameters.mjs). No DOM.
import { PyValueError, isDict, isList, isInt, pyJsonLoads, pyLen, pySlice, pyStrip, pyStr, pyGet } from './py-json.mjs';
import {
+15 -4
View File
@@ -19,7 +19,12 @@ function element(tag, className, text) {
return node;
}
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
/**
* #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms
* reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open.
*/
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter,
collections = null,onCollectionsChanged = null}) {
const scope = createScope();
let active = null, retryVersion = 0;
const dispose = () => scope.dispose();
@@ -36,7 +41,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
active?.close(false);
const {items,selectedKey,trigger = document.activeElement} = options;
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
let originalDownload = null, inspectionState = inspectionController.state;
let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null;
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
if (!mounted) { initial = [state,selected]; return; }
update(state,selected);
@@ -99,7 +104,11 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
onLoad:() => inspectionController.load?.(),
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections,
onCollectionsChanged:({added,removed}) => {
collectionChanges ??= {added:[],removed:[]};
collectionChanges.added.push(...added);collectionChanges.removed.push(...removed);
}});
function closeViewer(restore = true) {
if (closed) return;
closed = true;active = null;local.dispose();
@@ -108,6 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
else onRestoreFocus?.(entry);
}
if (!scope.signal.aborted) onClose?.();
if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges);
}
active = {close:closeViewer,stop:() => session.stop()};
local.cleanup(() => {
@@ -148,7 +158,8 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
src = url.href;
}
const node = element(selected.is_video ? 'video' : 'img');
mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);});
// #430: the page may find the file under a renumbered source (`onMediaError`).
mediaLocal.listen(node,'error',() => {if (current()) {session.failed(key);onMediaError?.(selected);}});
if (selected.is_video) {
node.controls = true;node.playsInline = true;node.preload = 'metadata';
const preferences = readViewerPreferences();
+37
View File
@@ -326,6 +326,43 @@ export function onRestoreFinished(activity, onFinished) {
});
}
const OPEN_DIALOG = 'dialog[open]';
const DIALOG_MUTATIONS = Object.freeze({ subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
/**
* #415: the S9 reload after a finished restore never runs under an open dialog, which it would close with whatever
* was typed in it. `request()` runs `reload()` at once when no dialog is open; otherwise it runs once the last open
* dialog has closed (repeated requests meanwhile run it once). The dialog keeps its entries, and its next write is
* refused by the restored storage with its own note and Reload, as when this page never saw the restore. A
* MutationObserver sees every dialog close: removing `open` and removing the node are synchronous, while the dialog
* `close` event is a later task that never bubbles, and every dialog here is removed in the task that closes it.
* `dispose()` drops a waiting reload. Returns `{request, dispose}`.
*/
export function createRestoreReload(reload, { doc = globalThis.document, Observer = globalThis.MutationObserver } = {}) {
let observer = null;
const dialogOpen = () => doc.querySelector(OPEN_DIALOG) !== null;
const stop = () => {
observer?.disconnect();
observer = null;
};
return {
request() {
if (observer) return;
if (!dialogOpen()) {
reload();
return;
}
observer = new Observer(() => {
if (dialogOpen()) return;
stop();
reload();
});
observer.observe(doc.documentElement, DIALOG_MUTATIONS);
},
dispose: stop,
};
}
function surface(heading, ...nodes) {
const section = el('section', undefined, 'surface');
section.append(heading, ...nodes);
+16 -7
View File
@@ -112,13 +112,13 @@ function bindScope(scope) {
});
}
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null, review = false }) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
bindScope(scope);
// A swapped-out body is gone from the shell, so its owner releases it.
const replaced = session?.onClose;
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose, review };
replaced?.();
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
@@ -133,6 +133,14 @@ export function closeWorkspaceDialog() {
shell = null;
}
/**
* #415: a finished restore ends an open record-changing review (`openReviewDialog`) as a dismissal, since the plan it
* reviewed no longer describes storage and it holds nothing typed. Any other dialog stays open.
*/
export function closeReviewDialog() {
if (session?.review) closeWorkspaceDialog();
}
function fillSelect(select, key, options) {
if (!Array.isArray(options) || options.length === 0 ||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
@@ -324,8 +332,9 @@ export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions';
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
* reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a
* `<datalist>` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that
* closes the dialog and runs `onClick` (#332).
* `<datalist>` under the field (Set category's categories, #332; Add to collection's names, #331 D138); the field
* still takes any text. `secondary` (`{label, onClick}`) adds a button between Cancel and the action that closes the
* dialog and runs `onClick` (#332).
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
validate = null, suggestions = null, secondary = null }) {
@@ -392,13 +401,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
* another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false,
* × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it).
*/
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) {
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) {
requireScope(scope);
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function');
show(title, nodes, { scope, trigger, onClose, canClose });
show(title, nodes, { scope, trigger, onClose, canClose, focusTarget });
}
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +
@@ -497,7 +506,7 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
actions.append(cancel, start);
nodes.push(actions);
// Cancel, ×, Escape, a replacing dialog or the page leaving: everything but the primary is a dismissal.
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null });
show(title, nodes, { scope, trigger, onClose: onDismiss ? () => { if (!started) onDismiss(); } : null, review: true });
return {
setValue(term, text) {
const dd = values.get(term);
+14
View File
@@ -230,6 +230,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
.prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.file-inspector .tag-section .role-label { color: var(--ca-muted); }
.file-inspector .tag-section > .tags { margin-top: 0; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
@@ -264,6 +269,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
/* #437 D148: the card's selection checkbox is the concept's Images `.image-overlays`, placed over the art as on an image
card; `.media-art` carries the concept's `position: relative` there, the prompt card carries it here. */
.prompt-card { position: relative; /* D148 */ }
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's
@@ -453,3 +461,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never
splits; at 390px the chip may wrap between the category and its origin. */
.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }
/* D147 (#430): a state that blocks Save shows its reason on its own row above the sticky actions, with Refresh for a
changed source; the disabled actions look disabled; a refused capture's empty preview says so. */
.tagging-modal .dialog-actions [aria-disabled="true"], .tagging-modal .tag-model-panel [aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D147 */ }
.tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ }
.tagging-modal .tag-action-notice > span { flex: 1 1 16em; }
.tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ }