Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/toolbar.mjs
T
Vito 66e567cc95 fix(workspace): remove the 1-5 prompt rating from the workspace (#447) (#461)
The star is only for favorites (maintainer ruling 2026-10-28). The workspace
no longer shows or edits a prompt rating: no Rating field in the Library
editor, no stars on Library cards, no "Highest rated" sort on Images or the
Library, and no rating column or rated-images total in System > Statistics.

The editor never sends `rating`, so a workspace save keeps a stored rating
as it is. A saved view or Library visit with the retired rating sort opens on
Recently added. Data and API are unchanged; legacy pages keep their ratings.

Recorded as deviation D153 (amends D14, D23, D96); concept comparisons map
the concept through it.
2026-10-29 04:04:52 +00:00

129 lines
6.3 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.
/**
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
* `select#sort`, `Save view`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options
* first and a page's extra ones after them (D14); `Filters` reads "Filters · N" while N "Refine this view" filters are
* active (D44).
*/
import { createButton } from '../shared/toolkit/core.js';
import { createWorkspaceIcon } from './icons.mjs';
/** The search waits this long after the last keystroke before it reports a change. */
export const SEARCH_DELAY = 250;
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
// other label is one of the page's appended options.
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
const isText = value => typeof value === 'string';
/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */
function filtersLabel(count) {
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number');
return count ? `Filters · ${count}` : 'Filters';
}
/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
export function orderSorts(sorts) {
if (!Array.isArray(sorts) || sorts.length === 0 ||
sorts.some(pair => !Array.isArray(pair) || pair.length !== 2 || !isText(pair[0]) || !isText(pair[1]))) {
throw new TypeError('Sorts must be a non-empty list of [value, label] pairs');
}
const rank = ([, label]) => {
const index = CONCEPT_SORT_LABELS.indexOf(label);
return index === -1 ? CONCEPT_SORT_LABELS.length : index;
};
return sorts.map((pair, index) => ({ pair, index }))
.sort((a, b) => rank(a.pair) - rank(b.pair) || a.index - b.index)
.map(({ pair }) => [...pair]);
}
function option(value, label) {
const node = document.createElement('option');
node.value = value;
node.textContent = label;
return node;
}
function categoryOptions(categories) {
if (!Array.isArray(categories) || categories.some(value => !isText(value))) {
throw new TypeError('Categories must be a list of strings');
}
const values = [...new Set(categories.filter(Boolean))];
return [option('', 'All categories'), ...values.map(value => option(value, value))];
}
/**
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
* value after a select change or, debounced, after typing; `onSaveView(trigger)` opens the page's Save view dialog;
* `onFilters(trigger)` opens the page's "Refine this view"; `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
*/
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters, onSaveView }) {
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
throw new TypeError('The toolbar needs the page scope');
}
if (typeof onChange !== 'function' || typeof onFilters !== 'function' || typeof onSaveView !== 'function') {
throw new TypeError('The toolbar needs onChange, onFilters and onSaveView');
}
const root = document.createElement('search');
root.className = 'toolbar';
const label = document.createElement('label');
label.className = 'search-field';
label.htmlFor = 'search';
const search = document.createElement('input');
search.id = 'search';
search.type = 'search';
search.placeholder = 'Search prompts, tags, filenames…';
// The listing API rejects longer search text (py/api/images.py _workspace_filters).
search.maxLength = 500;
search.setAttribute('aria-label', 'Search workspace');
label.append(createWorkspaceIcon('search'), search);
const category = document.createElement('select');
category.id = 'category-filter';
category.setAttribute('aria-label', 'Filter by category');
category.replaceChildren(...categoryOptions(categories));
const sort = document.createElement('select');
sort.id = 'sort';
sort.setAttribute('aria-label', 'Sort results');
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
const saveView = createButton('Save view');
const filters = createButton(filtersLabel(activeFilters));
root.append(label, category, sort, saveView, filters);
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
function setValue(next) {
if (isText(next?.q)) search.value = next.q;
if (isText(next?.category)) category.value = next.category;
if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value;
}
function setCategories(next) {
const selected = category.value;
category.replaceChildren(...categoryOptions(next));
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
}
function setActiveFilters(count) {
const next = filtersLabel(count);
// Every listing reload reports the count; only a changed label rewrites the text node, because WebKit drops a
// click whose mousedown hit a text node that was replaced before mouseup.
if (filters.textContent !== next) filters.textContent = next;
}
setValue(value);
const report = () => onChange(read());
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
scope.listen(category, 'change', report);
scope.listen(sort, 'change', report);
scope.listen(saveView, 'click', () => onSaveView(saveView));
scope.listen(filters, 'click', () => onFilters(filters));
scope.cleanup(() => root.remove());
container.append(root);
return { element: root, value: read, setValue, setCategories, setActiveFilters };
}