Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/library-state.mjs
Vito 0a7b0238ca feat(library): concept prompt library cards, toolbar and caption (#263)
* feat(library): concept toolbar and caption

The Prompt library shelf uses the shared concept toolbar (search, category,
sort, Filters): sorts are Recently added / Name A-Z / Highest rated, then
Oldest first; the Tags filter moves to "Refine this view" (D14) and Refresh
library is removed (D15), with Retry kept and re-reading failed categories.
The caption is the concept's .section-caption, "N positive prompts · M
negative variants" over the filtered set from variant_total.

Library-embedded media cards keep the favorite star working: the Library
gallery read already reports favorite (one batched lookup per page, now
covered by a test), and star answers are delivered to every mounted embedded
gallery, so a star toggles, repaints and survives a reload. The checkbox and
selection bar stay Images-only.

* feat(library): concept prompt cards with real runs and last use

The Prompt library shelf renders the concept's article.prompt-card:
.card-art (up to two previews), .card-title with a title button and the
category chip, the positive prompt block, .card-meta ("− N negative
variants", "N runs", "N images"), .tags, and .card-footer with the rating
and last-use line and the positive send plus Explore history buttons.

D23: "N runs" is the parent's run_count; the footer reads Used today /
yesterday / N days ago / <Mon D> in the viewer's local calendar days, or
"Not used yet"; stars carry a visually hidden "Rated N out of 5" and an
unrated parent shows no stars. The positive is clamped to two lines while
copy and send use the full text. Long titles wrap inside the title row
and the category chip never breaks mid-word. Card actions are named after
the prompt ("Copy positive prompt: <title>") through a new title option on
the shared prompt blocks. The production .library-card CSS is removed and
the row estimates follow the clamped card height.

The shelf error now reads "Prompt library could not be loaded. Retry."
(Refresh is gone). TESTING.md records D23, the production-only shelf copy
(D40) and the concept search placeholder kept on the Library (D41).

* test(library): concept comparison for the shelf

library-concept.spec.mjs compares the Prompt library shelf with the
immutable concept on the concept-shaped fixture: heading, toolbar,
caption, grid and the six cards (art, title row, prompt block, meta,
tags, footer) at 1600x1100 and 390x844 in both themes and motion
settings, the 3-column grid at 1800x1100, and the behaviors in both
documents (title and Explore history open history, the footer send
carries the positive only, copy toasts "Prompt copied.", the "neon"
search and the Landscapes category). A mutation test proves the
comparison notices a footer padding change and a removed variants chip.

mapLibraryConcept applies only recorded deviations: D14, D16, D23 (title
min-width, singular variant chip, spoken rating, the two-line clamp the
concept's samples exceed at 390px), D35, D36, D40 and the new D42 (tag
chips in the store's alphabetical order). Each re-laid-out part is
checked against the concept page with that deviation applied at runtime.

Corrections the comparison demonstrated:
- the fixture creates the concept's prompts last to first, so Recently
  added lists them in the concept's order;
- the shelf no longer ends with the legacy "Current prompt library"
  link; Overview's legacy cards keep it, as with "Current gallery".

The shared inventory gains the Library section, and reflow/withoutItems
move into concept-inventory.mjs for both comparison specs.

* test(workspace): reach the legacy Library from Overview, not the shelf

The shelf no longer links the legacy Library (#249 E3). The shell
destinations test now expects no legacy link on Prompt library and
asserts Overview's "Open existing prompt library" card still links
/prompt_manager/admin and that the page loads.

* feat(library): Filters shows its active count; final review follow-ups

- D44: the shared toolbar's Filters button reads "Filters · N" while N
  "Refine this view" filters are active (Library: Tags; Images: Source,
  Folder, Media, Tags), so a Browse-set Tags filter is visible on the
  shelf. The text is the accessible name. Unit and browser tests cover
  Browse -> "Filters · 1" -> Clear -> "Filters" and the Images counts.
- Renumber the alphabetical tag chips deviation D42 -> D43 (D42 is #264's).
- The caption count is a role=status region written only when it changes
  and kept shown while the shelf reloads.
- usedLabel names the year for a use in another calendar year (D23).
- prompt-blocks: same `title` option API as #264 (`subject` suffix); the
  region-name helper's parameter no longer shadows it.
- Scope library.css .row-actions rules to the history panels; fix stale
  CSS and spec comments.
- Retry re-reading a failed category list and the no-op Apply/search
  paths are covered by a browser test.
- TESTING.md: D23 amendments (year, button vs link trade-off), D44, the
  legacy Library link wording.
2026-10-20 23:26:05 -07:00

109 lines
9.1 KiB
JavaScript

function validId(value) { return Number.isSafeInteger(value) && value > 0; }
function parent(value) {
return value && validId(value.id) && typeof value.text==='string';
}
function parameters(filters,limit) {
const query=new URLSearchParams({limit:String(limit)});
for(const key of ['text','category']) {
if(filters[key]!==undefined && typeof filters[key]!=='string') throw new TypeError('Invalid library filter');
if(filters[key]) query.set(key,filters[key]);
}
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
const sort=filters.sort || 'newest';
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
query.set('sort',sort);return query;
}
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
function validatePage(page,cursor) {
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
}
export function createLibraryStream({api,onChange=()=>{},restore}) {
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
let query=parameters({},40),request,disposed=false,seen=new Set();
if(restore) {
query=parameters(restore.filters,40);const saved=restore.state;
validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
}
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
async function loadMore() {
if(disposed || state.loading || !state.hasMore) return 0;
const current=request=new AbortController();const cursor=state.cursor;
emit({loading:true,error:null});const params=new URLSearchParams(query);if(cursor!==null) params.set('cursor',cursor);
try {
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
if(disposed || current!==request) return 0;
validatePage(page,cursor);const incoming=[];const added=new Set();
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
for(const value of added) seen.add(value);
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
}
return {get state(){return state;},loadMore,async reset(filters={}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
/**
* Saved roles for one selection. A pair outside the bounded variant page is
* passed separately as `selectedPair` (an exact-ID read) and must belong to the
* same positive; it never enters `detail.variants`.
*/
export function libraryPromptRoles(detail,pairId=null,selectedPair=null) {
let variant=pairId===null?null:detail.variants.find(item=>item.id===pairId);
if(pairId!==null && !variant && selectedPair?.id===pairId) {
if(selectedPair.positive_prompt_id!==detail.positive.id || selectedPair.positive_text!==detail.positive.text || typeof selectedPair.negative_text!=='string') throw new TypeError('Saved negative variant belongs to another prompt');
variant=selectedPair;
}
if(pairId!==null && !variant) throw new TypeError('Saved negative variant is unavailable');
return {positive:{status:'resolved',text:detail.positive.text,source:'library'},negative:variant?{status:'resolved',text:variant.negative_text,source:'library'}:{status:'unresolved',text:null,source:null,message:'Choose a saved negative variant to inspect or send it.'}};
}
const ARCHIVE_SCOPES=new Set(['active','archived','all']);
export function createLibraryDetail({api,onChange,archive='active'}) {
if(!ARCHIVE_SCOPES.has(archive)) throw new TypeError('Invalid archive scope');
let request,disposed=false,id;
let state=Object.freeze({detail:null,loading:false,error:null,loadingVariants:false,variantsError:null});
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
function validate(detail) {
if(!parent(detail.positive) || detail.positive.id!==id || !Array.isArray(detail.variants) || detail.variants.some(item=>!validId(item.id) || typeof item.negative_text!=='string' || typeof item.explicit_empty!=='boolean' || item.explicit_empty!==(item.negative_text==='')) || new Set(detail.variants.map(item=>item.id)).size!==detail.variants.length) throw new TypeError('Invalid prompt history');
const total=detail.variant_total ?? detail.variants.length,more=detail.variants_has_more ?? false,cursor=detail.variants_next_cursor ?? null;
if(!Number.isSafeInteger(total) || total<detail.variants.length || typeof more!=='boolean' || !(cursor===null || (typeof cursor==='string' && cursor.length>0)) || more!==(cursor!==null)) throw new TypeError('Invalid negative variant page');
return {...detail,variant_total:total,variants_has_more:more,variants_next_cursor:cursor};
}
async function fetch(append) {
if(disposed || (append && (!state.detail?.variants_has_more || state.loadingVariants || state.loading))) return 0;
request?.abort();const current=request=new AbortController();const previous=state.detail;const cursor=append?previous.variants_next_cursor:null;
emit(append?{loadingVariants:true,variantsError:null}:{detail:null,loading:true,error:null,loadingVariants:false,variantsError:null});
try {
if(!validId(id)) throw new TypeError('Invalid positive prompt');
const query=new URLSearchParams({variant_limit:'40'});if(archive!=='active') query.set('archive',archive);if(cursor) query.set('variant_cursor',cursor);
const raw=await api.get(`/prompt_manager/library/${id}?${query}`,{signal:current.signal});
if(disposed || request!==current) return 0;
const detail=validate(raw);
if(append) {
if(detail.positive.text!==previous.positive.text || (detail.variants_has_more && detail.variants_next_cursor===cursor)) throw new TypeError('Prompt history changed. Reload it before continuing.');
const variants=new Map(previous.variants.map(item=>[item.id,item]));for(const item of detail.variants) if(!variants.has(item.id)) variants.set(item.id,item);detail.variants=[...variants.values()];
}
request=null;emit({detail,loading:false,error:null,loadingVariants:false,variantsError:null});return detail.variants.length-(append?previous.variants.length:0);
} catch(error) {if(!disposed && request===current) {request=null;emit(append?{loadingVariants:false,variantsError:error}:{loading:false,error});}return 0;}
}
/** Same-parent reconciliation: a validated authoritative detail replaces the current one and supersedes any read. */
function accept(detail) {
if(disposed) throw new TypeError('Prompt history is disposed');
const next=validate(detail);
request?.abort();request=null;
emit({detail:next,loading:false,error:null,loadingVariants:false,variantsError:null});
}
return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},accept,dispose(){disposed=true;request?.abort();}};
}
export function libraryWindow(count,columns,top,height,focused=-1) {
if(count<=120) return Array.from({length:count},(_,index)=>index);
const rows=Math.ceil(count/columns),first=Math.max(0,Math.floor(top/578)-2),last=Math.min(rows-1,Math.ceil((top+height)/578)+2);
const keep=new Set();
for(let row=first;row<=last;row++) keep.add(row);
if(focused>=0 && focused<count) keep.add(Math.floor(focused/columns));
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
}