Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/vocabulary.mjs
Vito cbf46a7573 feat(vocabulary): native Tags & categories screen (#245)
* feat(vocabulary): add workspace vocabulary state

* feat(vocabulary): render the native Tags & categories screen

* feat(vocabulary): add guarded vocabulary dialogs

* fix(vocabulary): recover unconfirmed deletes and list every category in Manage

* refactor(vocabulary): use the shared plural helper

* test(vocabulary): compare the native screen with the approved concept

* fix(vocabulary): wrap long tag names so row actions stay visible on narrow screens

* docs(vocabulary): document the native vocabulary screen and gates

Also aligns the shell link focus-ring offset with the concept (3px) and makes
the chrome comparison poll the document title.

* fix(vocabulary): refresh lists when a write lands after its dialog closes

A rename, delete, create or category apply that commits after its dialog
was disposed (Chromium force-closing the modal on a repeated Escape, or
Back while the write is in flight) returned before onInvalidate, so the
lists were never re-read and a cached Library shelf kept showing the old
names. Like the recipe editor, a disposed command now still reports any
outcome that may have changed storage (receipt, conflict, restore,
unconfirmed) without rendering; only a failed write stays silent.

* fix(vocabulary): give the Manage categories datalist its own id

The proposed-name datalist reused the recipe editor's `categories` id. It was safe only because routes dispose each other's dialogs; it is now `vocabulary-category-options`.

* docs(vocabulary): correct vocabulary testing notes

Name the Tag column and the Create tag heading action, say that a failed Load more moves focus to Retry, place bulk actions on the admin page, describe late writes after a closed dialog, Browse's exact filter and the full deviation list, and use a test port that is not the live concept server's.

* test(vocabulary): raise the vocabulary dialog branch floor

coverage:update after the late-write and datalist fixes: workspace/vocabulary-dialog.mjs branches 87 -> 88. No floor is lowered.

* fix(vocabulary): keep 390px row actions visible regardless of font metrics

The one-column Tags table reserved a fixed 6em for the tag name. With
CI's stock DejaVu Sans fallback (about 9% wider than the pinned image's)
Tag + Prompts + Images + stacked actions needed 351px of a 336px wrap,
so Browse and Rename / merge were pushed out of view on every row.

At 700px and below the Tag column now has no fixed minimum and the
actions ask for no more than their stacked width, so all remaining space
goes to the tag name: short names stay on one line wherever the
concept's own table fits, and a wider font wraps the name instead of
clipping an action. A new test widens the metrics with letter spacing
and fails without this change in every engine.

* ci: run browser jobs in the pinned Playwright image

Concept comparisons measure geometry, which follows the fonts a browser
falls back to. The concept names Inter, which neither environment ships:
the pinned image falls back to WenQuanYi Zen Hei, a stock ubuntu-latest
runner to DejaVu Sans. Under DejaVu the concept's own Tags table needs
more than a 390px viewport (its page scrolls sideways and, under touch
emulation, the widened layout viewport resizes its dialogs), so the
390px comparisons failed in CI while passing locally.

Both browser jobs now run in mcr.microsoft.com/playwright:v1.64.1-noble,
the image local runs use, instead of installing browsers on the runner.
A tooling test keeps the tag equal to @playwright/test in the lockfile.

* fix(vocabulary): ignore a category preview edited while it is pending

Editing the Category or proposed name while a slow Preview was in flight
was a no-op in the saving phase, so the late response still became an
applicable preview: the form showed the new values while Apply would
submit the old source and target.

An edit during a pending preview now marks it stale. Its late result
returns the dialog to idle (Preview again) instead of showing an impact
or offering Apply; a plain failure of a stale preview is not shown
either, while a restore or conflict still is, since it describes
storage. The inputs stay editable during the request, as in the recipe
editor, which disables only its actions while saving.

* ci: use the image's Python and Node in containerized browser jobs

Inside mcr.microsoft.com/playwright:v1.64.1-noble, actions/setup-python
failed before any test ran: `spawn /__t/Python/3.12.15/x64/bin/pip
ENOENT`. The toolcache builds come from the runner, and their entry
points name host interpreter paths that do not exist in the container.

The jobs now use the image's Python 3.12 and Node 24. The image has no
ensurepip and the job runs as uid 1001 without root, so a checksum-pinned
pip wheel bootstraps a user-space venv for requirements-dev.txt; a step
checks that the image Node is 24. The tooling test forbids setup-python
and setup-node in these jobs and requires the pinned bootstrap.
2026-10-19 12:05:27 -07:00

239 lines
11 KiB
JavaScript

/**
* Native Tags & categories screen (concept renderTags, app.js:719-729).
*
* Composition, labels and order follow the approved concept; the recorded
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
* "—" / "Not available yet", and bounded lists with Load more. 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 { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
import { countOf } from './plural.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
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');
const images = el('td');
const dash = el('span', '—');
dash.setAttribute('aria-hidden', 'true');
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
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, countOf(category.prompt_count, 'positive prompt'), '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}) {
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);
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});
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};
}
/** 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 heading = main.querySelector('.workspace-heading');
heading.querySelector('.workspace-heading-copy').append(el('p', 'Organize once. Reuse everywhere.', 'intro'));
const actions = el('div', undefined, 'heading-actions');
const create = createButton('+ Create tag');
actions.append(create);
heading.append(actions);
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);
tagSurface.append(el('h2', 'Tag vocabulary'),
el('p', 'Category/tag autocomplete uses this same vocabulary across nodes and dialogs.'), 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});
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) browseLibrary(browseFilters('categories', category.value));
});
let settled = Promise.resolve();
/** Re-read both lists from page one: after any command, rows can move between pages. */
function reload() {
settled = Promise.all([tags.stream.reset(), categories.stream.reset()]).then(() => undefined);
return settled;
}
/** 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);
});
reload();
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
}