Merge remote-tracking branch 'origin/main' into feat/331-collections-pr2

#332 PR 3 (#428) hands a Tags & categories Browse to the next Images mount;
this branch mounts Images as a collection page too. Both imports are kept; a
collection page neither restores the last visit nor takes the Browse hand-off
(it is not the Images page), and the Images page keeps both the handed
category and the collection filter.
This commit is contained in:
Vito Sansevero
2026-10-28 12:53:30 -07:00
24 changed files with 1253 additions and 258 deletions
+14 -4
View File
@@ -20,8 +20,9 @@ const REPRESENTATIONS = {
limited: 'Record exceeds inspection limits; text is unavailable',
};
const TEXTLESS = new Set(['invalid', 'limited']);
// #332: the hidden prefix that names where a category chip's value came from.
// #332: the hidden prefix and the visible origin word that name where a category chip's value came from (D146).
const CATEGORY_PREFIX = {image: 'Image category: ', positive: 'Positive prompt category: ', pair: 'Recipe category: '};
const CATEGORY_ORIGIN = {image: 'this image', positive: 'prompt', pair: 'recipe'};
const REVEAL_URL = '/prompt_manager/images/reveal';
// D38 Show in folder: the route's answers, in the maintainer's words.
const REVEAL_OPENED = 'Opened the folder on this computer.';
@@ -193,6 +194,17 @@ export function inspectorTagChips(record, {filterable}) {
return chips;
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
* or " · recipe" (visible, never split), so the chip names where its value came from.
*/
export function categoryChip(library) {
const scope = CATEGORY_ORIGIN[library.category_scope] ? library.category_scope : 'positive';
const chip = prefixedChip(CATEGORY_PREFIX[scope], library.category);
chip.append(el('span', ` · ${CATEGORY_ORIGIN[scope]}`, 'chip-source'));
return chip;
}
function prefixedChip(prefix, text) {
const chip = el('span', undefined, 'chip');
chip.append(el('span', prefix, 'ca-visually-hidden'), text);
@@ -268,9 +280,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
if (badge) badges.append(el('span', badge, 'chip blue'));
// #332: an image's own category shows for any link status; an inherited one only when linked.
if (library?.category && (library.category_scope === 'image' || linked)) {
badges.append(prefixedChip(CATEGORY_PREFIX[library.category_scope] ?? CATEGORY_PREFIX.positive, library.category));
}
if (library?.category && (library.category_scope === 'image' || linked)) badges.append(categoryChip(library));
if (badges.childNodes.length) inner.append(badges);
const prompts = el('div'); inner.append(prompts);
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
+50 -30
View File
@@ -1,42 +1,62 @@
import { countOf, noun } from './plural.mjs';
import { countOf } from './plural.mjs';
/**
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
*/
export const CATEGORY_URL = '/prompt_manager/images/category';
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
export const CATEGORY_NONE_LINKED = "None of the selected images is linked to a saved prompt, so there is no prompt category to set.";
export const USE_PROMPT_CATEGORY = 'Use prompt category';
export const REMOVE_DIALOG = Object.freeze({title:'Use prompt category', action:'Remove'});
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
export const NONE_FOUND = 'None of the selected images could be found. Refresh the images and try again.';
export const NONE_HAD_OWN = 'None of the selected images had its own category.';
export const SOURCES_CHANGED = 'The sources changed. Refresh the images and try again.';
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
export const CATEGORY_MAX_LENGTH = 100;
// One request names at most 500 images (DC2), as "Add tags" does.
export const MAX_CATEGORY_IMAGES = 500;
function linkedPositive(image) {
const link = image?.linked;
if (link?.status !== 'linked') return null;
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
export function setOutcome(result, category) {
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
const missing = Number(result?.not_found) || 0;
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
}
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
export function categoryTargets(images) {
const ids = new Set();
let skipped = 0;
for (const image of images) {
const id = linkedPositive(image);
if (id === null) skipped += 1;
else ids.add(id);
/** The toast of a removal. */
export function clearOutcome(result) {
const cleared = Number(result?.cleared) || 0;
return cleared ? `Removed the image category from ${countOf(cleared, 'image')}.` : NONE_HAD_OWN;
}
/** The confirmation sentence before "Use prompt category" removes the own category of `count` images. */
export function removeQuestion(count) {
const which = count === 1 ? 'the 1 selected image' : `the ${count.toLocaleString()} selected images`;
return `Remove the image category from ${which}? Each one then shows its prompt's category, or none if it has no ` +
'linked prompt. This cannot be undone.';
}
/** The toast for a failed write: the coded answers the routes give, else the fixed failure. */
export function failureMessage(error) {
if (error?.code === 'files-not-found') return NONE_FOUND;
if (error?.code === 'source-changed') return SOURCES_CHANGED;
if (error?.code === 'too-many-images' && typeof error.message === 'string' && error.message) return error.message;
return CATEGORY_FAILED;
}
/** The toolbar's category list: the prompt and recipe categories plus the listed images' own ones, each once. */
export function mergeCategories(promptCategories, imageCategories) {
const merged = [];
const seen = new Set();
for (const value of [...(promptCategories ?? []), ...(imageCategories ?? [])]) {
if (typeof value !== 'string' || !value.trim() || seen.has(value)) continue;
seen.add(value);
merged.push(value);
}
return {promptIds:[...ids], skipped};
}
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
export function categoryOutcome(prompts, skipped) {
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
if (!skipped) return done;
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
}
/** The prompts the server reports changed; without a count, the number of prompts posted. */
export function updatedCount(result, posted) {
const count = result?.updated_count;
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
return merged;
}
+50 -26
View File
@@ -2,16 +2,16 @@ import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { MAX_FILTER_IMAGES, TOO_MANY_MATCHING, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
import { CATEGORY_DIALOG, CATEGORY_MAX_LENGTH, CATEGORY_URL, MAX_CATEGORY_IMAGES, REMOVE_DIALOG, USE_PROMPT_CATEGORY,
NONE_FOUND, clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs';
import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
const CHECKBOX_LABEL = '.image-overlays > label';
const CARD = 'article[data-image-key]';
const CATEGORY_URL = '/prompt_manager/bulk/category';
// D135: Export metadata reads every image's inspection in the browser, so it waits for loaded images.
export const EXPORT_LOADED_ONLY = 'Export metadata reads each image in the browser, so it works on loaded images only.';
@@ -29,8 +29,9 @@ function el(tag, text, className) {
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
*
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
* - `onCategorySet()` reloads the categories and the listing after a successful Set category or Use prompt category
* (the reload clears the selection through `clear`); focus then moves to `focusFallback` if it was on the hidden
* action. `getCategories()` gives the toolbar's categories, the field's suggestions.
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
* - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags
* leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays.
@@ -43,7 +44,7 @@ function el(tag, text, className) {
* matching", whose actions send that filter to the server.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, checked: initial = Object.freeze(new Set()) }) {
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
let exporting = false, categorizing = false;
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
@@ -261,42 +262,65 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
timer = setTimeout(release, 1000);
}
/**
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
* a toast says so and no dialog opens.
* Set category (app.js bulk-category → simpleInput, #332 D140): Apply sets each selected image's own category, linked
* or not; "Use prompt category" removes it after a confirmation that states the count. Up to 500 images (DC2): an
* explicit selection or "all N matching" (the listing filter, resolved by the server); more opens nothing.
*/
function openCategory(button) {
if (categorizing) return;
// All matching (D135): the listing's positive count says whether any match is linked; the server resolves.
if (allMatching && getListing().total > MAX_FILTER_IMAGES) { showWorkspaceToast(TOO_MANY_MATCHING); return; }
const targets = allMatching ? {filter: getFilter()} : categoryTargets(checkedImages());
const linked = allMatching ? getListing().positiveCount !== 0 : targets.promptIds.length > 0;
if (!linked) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
const count = allMatching ? getListing().total : checked.size;
const targets = allMatching ? {filter: getFilter()} : {items: bulkTargets(checkedImages())};
if (count > MAX_CATEGORY_IMAGES) { showWorkspaceToast(TOO_MANY); return; }
// Checked files the listing no longer holds (a refresh dropped them): nothing to send.
if (!allMatching && !targets.items?.length) { showWorkspaceToast(NONE_FOUND); return; }
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
secondary: { label: USE_PROMPT_CATEGORY, onClick: () => confirmRemove(button, targets, count) },
onApply: category => writeCategory(button, () => api.put(CATEGORY_URL, {...targets, category}),
result => setOutcome(result, category)) });
}
/** "Use prompt category": nothing is sent until Remove; Cancel leaves every category as it was. */
function confirmRemove(button, targets, count) {
const text = el('p', removeQuestion(count));
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel'), remove = createButton(REMOVE_DIALOG.action, 'danger');
actions.append(cancel, remove);
cancel.addEventListener('click', () => closeWorkspaceDialog());
remove.addEventListener('click', () => {
closeWorkspaceDialog();
writeCategory(button, () => api.del(CATEGORY_URL, {body: targets}), clearOutcome);
});
openContentDialog({ scope, trigger: button, title: REMOVE_DIALOG.title, nodes: [text, actions] });
cancel.focus();
}
/**
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
* not to save a visit that predates it). A failure keeps the selection, except a changed source, which reloads the
* listing as a success does (`onCategorySet`).
*/
async function applyCategory(button, { promptIds, skipped, filter }, category) {
async function writeCategory(button, send, outcome) {
categorizing = true;
const release = busy(button);
let result = null;
let result = null, failure = null;
try {
result = await api.post(CATEGORY_URL, filter ? { images_filter: filter, category } : { prompt_ids: promptIds, category });
} catch {
// Reported below; the selection stays as it was.
result = await send();
} catch (error) {
failure = error;
} finally {
categorizing = false;
release();
}
if (scope.signal.aborted) return;
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
if (filter && !result.prompt_count) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
showWorkspaceToast(filter ? categoryOutcome(updatedCount(result, result.prompt_count), Number(result.skipped) || 0)
: categoryOutcome(updatedCount(result, promptIds.length), skipped));
const focused = document.activeElement === button;
if (failure !== null || !result) {
showWorkspaceToast(failureMessage(failure));
// A changed source reloads the listing (clearing the selection); any other failure keeps it.
if (failure?.code !== 'source-changed') return;
onCategorySet();
if (focused) focusFallback.focus({ preventScroll: true });
return;
}
showWorkspaceToast(outcome(result));
onCategorySet();
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
if (focused) focusFallback.focus({ preventScroll: true });
+40 -15
View File
@@ -17,6 +17,7 @@ import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs';
import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs';
import { displayPreferences } from './preferences.mjs';
import { onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
@@ -84,6 +85,17 @@ setFavoriteVisitTarget((key, favorite) => {
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
// The filter state a Tags & categories Browse hands to the next Images mount (#332 MC10).
let browseState = null;
/**
* Open Images filtered by `category` only (every other filter at its default), as Browse opens the Library: Tags &
* categories' Browse of a category only images use (#332 MC10, D143).
*/
export function browseImages({category} = {}) {
if (typeof category !== 'string' || !category) throw new TypeError('browseImages needs a category');
browseState = {category};
window.location.hash = '#/images';
}
/** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */
export function forgetImagesVisit() {
lastVisit = null;
@@ -119,10 +131,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
const imagesPage = !embedded && !favorites && collectionId === null;
const handed = imagesPage && !savedState ? browseState : null;
if (imagesPage) browseState = null;
const saved = embedded || !savedState ? null : imagesFilters(savedState);
// The open collection: the route's, else a saved view's (D140); '' on every other page.
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
const prior = embedded || saved || favorites || openCollection ? null : lastVisit;
const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit;
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
const visit = prior?.stale ? null : prior;
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
@@ -134,11 +151,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
// The toolbar's current category list (loadCategories), also the Set category suggestions.
let categoryNames = [];
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
let refine = null;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
...(handed ? {category: handed.category} : {}),
// A collection and favorites are never both the filter (DC10).
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
filters.tags = [...filters.tags];
@@ -265,6 +285,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
getListing:() => ({total:state?.total ?? 0, hasMore:Boolean(state?.hasMore), positiveCount:state?.positiveCount ?? null}),
// A Source filter carries its root's scope, so a renumbered source is refused (source-changed), not acted on.
@@ -737,24 +759,27 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
return false;
}
}
/** The toolbar's categories are the Library's (the linked positive's category, D13). */
/**
* The toolbar's categories: the Library's (prompts and recipes) and the listed images' own ones (D141, #332), read
* in parallel. If only the image list fails, the prompt categories show and the categories alert is raised.
*/
async function loadCategories() {
if (!toolbar) return;
categoryRequest?.abort();
const request = new AbortController(); categoryRequest = request;
try {
const data = await api.get('/prompt_manager/library/options', {signal:request.signal});
if (scope.signal.aborted || request !== categoryRequest) return;
if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories');
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]);
names.delete('');
toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)));
categoriesFailed = false;
} catch {
if (scope.signal.aborted || request !== categoryRequest) return;
categoriesFailed = true;
}
const [library, images] = await Promise.allSettled([
api.get('/prompt_manager/library/options', {signal:request.signal}),
api.get(IMAGE_CATEGORIES_URL, {signal:request.signal}),
]);
if (scope.signal.aborted || request !== categoryRequest) return;
const valid = result => result.status === 'fulfilled' && Array.isArray(result.value?.categories);
if (!valid(library)) { categoriesFailed = true; return; }
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
const names = new Set(mergeCategories(library.value.categories, valid(images) ? images.value.categories : []));
if (filters.category) names.add(filters.category);
categoryNames = [...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0));
toolbar.setCategories(categoryNames);
categoriesFailed = !valid(images);
}
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
updateScope();
+17
View File
@@ -254,6 +254,23 @@ export function browseFilters(kind, value) {
: {text: '', category: value, tags: [], sort: 'newest'};
}
/**
* A category row's count (#332 D143): "N positive prompts" when no image holds it as its own category, "N positive
* prompts · M images" when both do, "M images" when only images do (stored rows, files on disk or not).
*/
export function categoryRowText(category) {
const prompts = count(category?.prompt_count) ? category.prompt_count : 0;
const images = count(category?.image_count) ? category.image_count : 0;
if (!images) return countOf(prompts, 'positive prompt');
if (!prompts) return countOf(images, 'image');
return `${countOf(prompts, 'positive prompt')} · ${countOf(images, 'image')}`;
}
/** Where a category's Browse opens: the Library while prompts hold it, else Images (MC10, its Library would be empty). */
export function categoryBrowseTarget(category) {
return count(category?.prompt_count) && category.prompt_count > 0 ? 'library' : 'images';
}
/** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */
export function readTagImpact(api, row, {signal} = {}) {
requireRow(row);
+7 -5
View File
@@ -9,12 +9,11 @@
* 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 { browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
import { pageHeading } from './heading.mjs';
import { selectedImage } from './images.mjs';
import { browseImages, selectedImage } from './images.mjs';
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
import { showWorkspaceToast } from './toast.mjs';
@@ -64,7 +63,7 @@ function tagRow(tag) {
}
function categoryRow(category) {
const {row, button} = operation(category.value, countOf(category.prompt_count, 'positive prompt'), 'Browse');
const {row, button} = operation(category.value, categoryRowText(category), 'Browse');
button.dataset.action = 'browse';
return row;
}
@@ -211,7 +210,10 @@ export function mountVocabulary(main, api, scope) {
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));
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();
+32 -4
View File
@@ -316,18 +316,29 @@ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy pr
area.select();
}
export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions';
/**
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
* reports the message (its native validity) until it is edited.
* reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a
* `<datalist>` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that
* closes the dialog and runs `onClick` (#332).
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
validate = null }) {
validate = null, suggestions = null, secondary = null }) {
requireScope(scope);
requireCallbacks({ onApply });
if (validate !== null) requireCallbacks({ validate });
if (suggestions !== null && (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string'))) {
throw new TypeError('The input dialog suggestions must be text');
}
if (secondary !== null && (typeof secondary?.label !== 'string' || !secondary.label)) {
throw new TypeError('The input dialog secondary action needs a label');
}
if (secondary !== null) requireCallbacks({ onClick: secondary.onClick });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
@@ -355,8 +366,25 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
onApply(entered);
});
const actions = el('div', undefined, 'dialog-actions');
actions.append(cancel, apply);
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
actions.append(cancel);
if (secondary !== null) {
// Between Cancel and the primary action; it closes the dialog first (#332 "Use prompt category").
const other = createButton(secondary.label);
other.addEventListener('click', () => { finish(); secondary.onClick(); });
actions.append(other);
}
actions.append(apply);
const nodes = [field(label, input, 'action-input')];
if (suggestions?.length) {
// Its own id: `datalist#categories` and `#vocabulary-category-options` belong to other pages' fields.
const list = el('datalist');
list.id = INPUT_SUGGESTIONS_ID;
for (const value of suggestions) { const option = el('option'); option.value = value; list.append(option); }
input.setAttribute('list', INPUT_SUGGESTIONS_ID);
nodes.push(list);
}
nodes.push(actions);
show(title, nodes, { scope, trigger, focusTarget: input });
}
/**
+4
View File
@@ -449,3 +449,7 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
outside 1–500 is highlighted as a refused setting is (D92). */
.toolbar #log-limit { inline-size: 6em; min-height: 36px; font-size: 11px; /* D109 */ }
.toolbar #log-limit[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D109 */ }
/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never
splits; at 390px the chip may wrap between the category and its origin. */
.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }