Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/vocabulary.mjs
T
Vito Sansevero d4d99b7e18 feat(vocabulary): tag search, tag sort and an Untagged prompts entry (#444)
Tags & categories gains a server-side tag search and sort (keyset pages,
cursor scoped to the query) and an "Untagged prompts" row whose Browse opens
the Library with tag_mode=untagged. Refine this view gets Tag match, saved
Library views keep it, and the routes echo the applied query so a stale
server is an error, never an unfiltered list. Recorded as D149.

Refs #444
2026-10-28 17:07:50 -07:00

319 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Native Tags & categories screen (concept renderTags, app.js:719-729).
*
* Composition, labels and order follow the approved concept: Auto-tag images
* opens the Tag image dialog for the image selected in Images (aria-disabled
* without one, D115) and the Images cells count the files carrying each tag
* (#249 C2). The recorded deviations are bounded lists with Load more and (D149,
* #444) the Tag vocabulary's Untagged prompts row and its tag search and sort,
* drawn with the concept's `.operation` and toolbar tokens. Rows are real
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
* to the existing Library filters.
*/
import { createButton } from '../shared/toolkit/core.js';
import { TAG_SEARCH_MAX, TAG_SORTS, browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream,
untaggedBrowseFilters } from './vocabulary-state.mjs';
import { SEARCH_DELAY } from './toolbar.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { countOf } from './plural.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
import { pageHeading } from './heading.mjs';
import { browseImages, selectedImage } from './images.mjs';
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
import { showWorkspaceToast } from './toast.mjs';
/** D115: why Auto-tag images (and System › Auto-tagging's Preview tagging review) only explains without a selection. */
export const NO_SELECTION = 'Select an image in Images first.';
const NO_MATCH = 'No tags match this search.';
// The Untagged prompts row before the first tags page reports its count.
const UNTAGGED_PENDING = 'Positive prompts without a tag';
const COPY = {
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
categories: {empty: 'No categories yet.', error: 'Categories could not be loaded. Nothing was changed.', more: 'Load more categories'},
};
// Append the next page this far before the end of a list comes into view.
const LOAD_MARGIN = 250;
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** The concept's `.operation` row: h3 title, p description, one row action. */
function operation(title, description, label) {
const row = el('div', undefined, 'operation');
const copy = el('div');
copy.append(el('h3', title), el('p', description));
const actions = el('div', undefined, 'row-actions');
const button = createButton(label);
actions.append(button);
row.append(copy, actions);
return {row, button};
}
function tagRow(tag) {
const row = el('tr');
// The files carrying the tag (absent from an older server, which counts none).
const images = el('td', String(tag.image_count ?? 0));
const cell = el('td');
const actions = el('div', undefined, 'row-actions');
const browse = createButton('Browse');
browse.dataset.action = 'browse';
const rename = createButton('Rename / merge');
rename.dataset.action = 'rename';
actions.append(browse, rename);
cell.append(actions);
row.append(el('td', tag.name), el('td', String(tag.prompt_count)), images, cell);
return row;
}
function categoryRow(category) {
const {row, button} = operation(category.value, categoryRowText(category), 'Browse');
button.dataset.action = 'browse';
return row;
}
/**
* One bounded list: rows append as pages arrive, the end of the list loads the
* next page each time it scrolls into view, and `Load more` stays available while
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
* failed read never looks like zero counts.
*/
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query}) {
const copy = COPY[kind];
const empty = el('p', copy.empty, 'empty');
const continuation = el('div', undefined, 'vocabulary-continuation');
const status = el('p', '', 'vocabulary-status');
status.setAttribute('role', 'status');
const alert = el('p', copy.error, 'vocabulary-error');
alert.setAttribute('role', 'alert');
const retry = createButton('Retry');
const more = createButton(copy.more);
continuation.append(status, alert, retry, more);
after.after(empty, continuation);
const items = new WeakMap();
const listeners = new Set();
let rendered = 0, first = null;
function maybeLoad() {
const {state} = stream;
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
}
function render(state) {
// A reset starts again from page one; otherwise pages only append.
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
rowsHost.replaceChildren();
rendered = 0;
}
const appended = [];
for (const item of state.items.slice(rendered)) {
const row = renderRow(item);
items.set(row, item);
appended.push(row);
}
rowsHost.append(...appended);
rendered = state.items.length;
first = state.items[0] ?? null;
const failed = Boolean(state.error);
content.hidden = !rendered;
empty.hidden = Boolean(rendered || state.loading || failed || state.hasMore);
empty.textContent = state.query?.search ? NO_MATCH : copy.empty;
status.textContent = state.loading ? 'Loading…' : '';
status.hidden = !state.loading;
alert.hidden = !failed;
retry.hidden = !failed;
const hideMore = !state.hasMore || failed || !rendered;
// Keyboard users keep a focus target: Retry after a failed read, else the first new (or first) row.
if (hideMore && document.activeElement === more) {
(failed ? retry : appended[0]?.querySelector('button') ?? rowsHost.querySelector('button'))?.focus();
}
more.hidden = hideMore;
more.setAttribute('aria-disabled', String(state.loading));
continuation.hidden = !(state.loading || failed || state.hasMore);
for (const listener of listeners) listener(state);
}
const stream = createVocabularyStream({api, kind, onChange: render, query});
scope.listen(more, 'click', () => stream.loadMore());
scope.listen(retry, 'click', () => (stream.state.resetRequired ? stream.reset() : stream.loadMore()));
let observer = null;
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
}, {rootMargin: `${LOAD_MARGIN}px`});
observer.observe(continuation);
}
scope.cleanup(() => {
stream.dispose();
observer?.disconnect();
});
render(stream.state);
/** The rendered row whose item matches, if it is loaded. */
function rowFor(match) {
return [...rowsHost.children].find(row => {const item = items.get(row); return item !== undefined && match(item);}) ?? null;
}
/** Follow this list's state (a dialog reading further pages); returns the unsubscribe. */
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
return {stream, itemOf: row => items.get(row), rowFor, subscribe};
}
/**
* The tag search and sort (D149): the concept toolbar's `search.toolbar` > `label.search-field` (search icon and a
* search input) and a `select`, built here because the shared toolbar owns the page-level ids. A visually hidden
* status announces the result of a changed search or sort.
*/
function tagTools() {
const root = document.createElement('search');
root.className = 'toolbar vocabulary-tools';
const label = el('label', undefined, 'search-field');
label.htmlFor = 'tag-search';
const search = document.createElement('input');
search.id = 'tag-search';
search.type = 'search';
search.placeholder = 'Search tags…';
search.maxLength = TAG_SEARCH_MAX;
search.setAttribute('aria-label', 'Search tags');
label.append(createWorkspaceIcon('search'), search);
const sort = document.createElement('select');
sort.id = 'tag-sort';
sort.setAttribute('aria-label', 'Sort tags');
for (const [value, text] of TAG_SORTS) {
const option = el('option', text);
option.value = value;
sort.append(option);
}
const status = el('span', '', 'ca-visually-hidden');
status.setAttribute('role', 'status');
root.append(label, sort, status);
return {root, search, sort, read: () => ({search: search.value, sort: sort.value}),
announce: text => { if (status.textContent !== text) status.textContent = text; }};
}
/** Mount the screen into `main` below the shell's heading; `scope` owns every request, listener and observer. */
export function mountVocabulary(main, api, scope) {
main.classList.add('vocabulary-main');
scope.cleanup(() => main.classList.remove('vocabulary-main'));
const { description, actions } = pageHeading(main);
description.textContent = 'Organize once. Reuse everywhere.';
const create = createButton('+ Create tag');
const autoTag = createButton('Auto-tag images', 'ca-primary');
actions.append(create, autoTag);
// The concept's `tag-review` for `state.selectedImage` (app.js a1511); D115: none selected, so it only explains.
const selected = selectedImage();
if (!selected) autoTag.setAttribute('aria-disabled', 'true');
scope.listen(autoTag, 'click', () => {
if (!selected) { showWorkspaceToast(NO_SELECTION); return; }
openImageTagging({api, scope, image: selected, source: 'tags', trigger: autoTag});
});
const grid = el('div', undefined, 'two-col');
const tagSurface = el('section', undefined, 'surface');
const wrap = el('div', undefined, 'table-wrap');
const table = el('table');
const head = el('thead');
const headRow = el('tr');
for (const label of ['Tag', 'Prompts', 'Images', 'Actions']) {
const cell = el('th', label);
cell.scope = 'col';
headRow.append(cell);
}
head.append(headRow);
const body = el('tbody');
table.append(el('caption', 'Reusable tags'), head, body);
wrap.append(table);
// D149 (#444): the parents no tag's Browse returns, as a concept `.operation` row, then the tag search and sort.
const untagged = operation('Untagged prompts', UNTAGGED_PENDING, 'Browse');
const tools = tagTools();
tagSurface.append(el('h2', 'Tag vocabulary'),
el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), untagged.row, tools.root, wrap);
const categorySurface = el('section', undefined, 'surface');
const categoryRows = el('div', undefined, 'vocabulary-categories');
const manage = operation('Manage category', 'Rename or consolidate a category with a preview of affected recipes.', 'Review');
categorySurface.append(el('h2', 'Categories'),
el('p', 'Categories provide a broad home. Tags describe content; proposed collections group projects without changing either.'),
categoryRows, manage.row);
grid.append(tagSurface, categorySurface);
main.append(grid);
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read()});
let announcing = false;
tags.subscribe(state => {
if (state.untaggedCount !== null) untagged.row.querySelector('p').textContent = countOf(state.untaggedCount, 'positive prompt');
// A search or sort the person changed is announced once its first page lands (or fails, which the alert says).
// A reset first emits its empty initial state (no epoch yet), then the read: wait for the page or the failure.
if (!announcing || state.loading || (state.epoch === null && !state.error)) return;
announcing = false;
if (!state.error) tools.announce(state.total ? countOf(state.total, 'tag') : state.query.search ? NO_MATCH : COPY.tags.empty);
});
scope.listen(untagged.button, 'click', () => browseLibrary(untaggedBrowseFilters()));
const categories = mountList(scope, api, 'categories', {content: categoryRows, rowsHost: categoryRows, renderRow: categoryRow, after: categoryRows});
scope.listen(body, 'click', event => {
const button = event.target.closest('button[data-action="browse"]');
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) browseLibrary(browseFilters('tags', tag.name));
});
scope.listen(categoryRows, 'click', event => {
const button = event.target.closest('button[data-action="browse"]');
const category = button && categories.itemOf(button.closest('.operation'));
if (!category) return;
// MC10: a category only images hold opens Images filtered by it; the Library would be empty.
if (categoryBrowseTarget(category) === 'images') browseImages({category: category.value});
else browseLibrary(browseFilters('categories', category.value));
});
let settled = Promise.resolve();
/** Re-read both lists from page one: after any command, rows can move between pages. The tag search and sort stay. */
function reload() {
settled = Promise.all([tags.stream.reset(), categories.stream.reset()]).then(() => undefined);
return settled;
}
/** A changed tag search or sort reads the tags from page one; the categories are not affected. */
function searchTags() {
const next = tools.read(), current = tags.stream.state.query;
if (next.search.trim() === current.search && next.sort === current.sort) return;
announcing = true;
settled = tags.stream.reset(next).then(() => undefined);
}
scope.listen(tools.search, 'input', scope.debounce(searchTags, SEARCH_DELAY));
scope.listen(tools.sort, 'change', searchTags);
/** A guarded command for one dialog; any outcome that may have changed storage re-reads the lists and the Library shelf. */
function createCommand(onChange) {
return createVocabularyCommand({api, onChange, onInvalidate: event => {
invalidateLibraryAfterWrite({kind: 'vocabulary', receipt: event.receipt || {phase: event.phase}});
reload();
}});
}
const h1 = main.querySelector('h1');
const actionOf = (row, action) => row?.querySelector(`button[data-action="${action}"]`) ?? null;
const dialog = createVocabularyDialog({api, scope, createCommand,
epoch: () => tags.stream.state.epoch ?? categories.stream.state.epoch,
categories: {stream: categories.stream, subscribe: categories.subscribe},
reload,
// Rows are replaced on every reset, so a result's row exists only once the re-read has landed.
settled: () => settled,
heading: () => h1,
tagAction: id => actionOf(tags.rowFor(item => item.id === id), 'rename'),
categoryAction: value => actionOf(categories.rowFor(item => item.value === value), 'browse')});
scope.listen(create, 'click', () => dialog.createTag(create));
scope.listen(manage.button, 'click', () => dialog.manageCategories(manage.button));
scope.listen(body, 'click', event => {
const button = event.target.closest('button[data-action="rename"]');
const tag = button && tags.itemOf(button.closest('tr'));
if (tag) dialog.renameTag(tag, button);
});
// A saved image tag set changes the Images counts.
scope.cleanup(onImageTagsSaved(() => { if (!scope.signal.aborted) reload(); }));
reload();
return {autoTag, create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
}