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
319 lines
16 KiB
JavaScript
319 lines
16 KiB
JavaScript
/**
|
||
* 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};
|
||
}
|