feat(collections): add and remove from the inspector and the viewer
The shared inspector's footer ends with Collections… (folder icon) after Prompt history for a listed file, in the Images side panel and the viewer. Its dialog reads the file's membership, lists every collection as the concept's checkbox rows, takes an optional new name, and Save sends one request per change (new name, added, removed). A failure keeps the dialog with the membership read again. On a collection page a change to the open collection reloads the listing at once from the side panel and only when the viewer closes from the viewer. The inspector concept comparison draws the button into the concept after Prompt history (D139, #331).
This commit is contained in:
@@ -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.';
|
||||
@@ -204,7 +205,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 = () => {}}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -300,6 +306,14 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
});
|
||||
footer.append(history);
|
||||
}
|
||||
const member = collections ? revealTarget(record.identity) : null;
|
||||
if (member) {
|
||||
const button = createButton('');
|
||||
button.append(createWorkspaceIcon('folder'), ' Collections…');
|
||||
contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button,
|
||||
onChanged:onCollectionsChanged}));
|
||||
footer.append(button);
|
||||
}
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -279,7 +279,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
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 => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
@@ -295,7 +302,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), 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);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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, 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 = () => {},
|
||||
openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) {
|
||||
const local = new AbortController();
|
||||
const active = () => !scope.signal.aborted && !local.signal.aborted;
|
||||
let member = null, saving = false;
|
||||
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';
|
||||
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;
|
||||
}
|
||||
async function load() {
|
||||
member = null;
|
||||
paintSave();
|
||||
list.replaceChildren();
|
||||
showStatus(MEMBERSHIP_LOADING_TEXT, false);
|
||||
try {
|
||||
const ids = await collections.membership(target);
|
||||
if (!active()) return;
|
||||
member = new Set(ids);
|
||||
showStatus('', false);
|
||||
renderList();
|
||||
} catch {
|
||||
if (!active()) return;
|
||||
showStatus(MEMBERSHIP_FAILED_TEXT, true);
|
||||
}
|
||||
paintSave();
|
||||
}
|
||||
/** The requests Save sends, in order; null when the new name cannot be a collection name. */
|
||||
function plan() {
|
||||
const typed = input.value.trim();
|
||||
const name = typed ? normalizeCollectionName(typed) : '';
|
||||
if (name === null) return null;
|
||||
const steps = name ? [{ add: { name } }] : [];
|
||||
const boxes = [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId);
|
||||
for (const box of boxes) {
|
||||
const id = Number(box.dataset.collectionId);
|
||||
if (box.checked && !member.has(id)) steps.push({ add: { collectionId: id } });
|
||||
}
|
||||
for (const box of boxes) {
|
||||
const id = Number(box.dataset.collectionId);
|
||||
if (!box.checked && member.has(id)) steps.push({ remove: id });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
async function submit() {
|
||||
problem.textContent = '';
|
||||
problem.hidden = true;
|
||||
const steps = plan();
|
||||
if (steps?.length === 0) { closeDialog(); return; }
|
||||
saving = true;
|
||||
paintSave();
|
||||
const added = [], removed = [];
|
||||
let failed = steps === null;
|
||||
for (const step of steps ?? []) {
|
||||
try {
|
||||
if (step.add) added.push((await collections.add(step.add, [target])).collection.id);
|
||||
else 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 (added.length || removed.length) onChanged({ added, removed });
|
||||
if (!failed) {
|
||||
closeDialog();
|
||||
notify(COLLECTIONS_UPDATED_TOAST);
|
||||
return;
|
||||
}
|
||||
problem.textContent = UPDATE_FAILED_TEXT;
|
||||
problem.hidden = false;
|
||||
// A collection deleted meanwhile is re-read by the store; the list then shows what the server holds.
|
||||
await collections.settled();
|
||||
if (active()) await load();
|
||||
}
|
||||
showStatus('', false);
|
||||
openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: () => local.abort(),
|
||||
nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] });
|
||||
load();
|
||||
}
|
||||
@@ -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,adapter = rootedFileAdapter,
|
||||
collections = null,onCollectionsChanged = () => {}}) {
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user