Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5216689ce | ||
|
|
ebdb6056cc | ||
|
|
639ab79e4d | ||
|
|
640413afaf | ||
|
|
ea0908f78f | ||
|
|
a782c9830d | ||
|
|
aa125d82e3 | ||
|
|
af1a4cd852 | ||
|
|
704db4be8a | ||
|
|
50989a7bd6 | ||
|
|
68d501b161 | ||
|
|
1b12ad0262 | ||
|
|
c2a7ba523c | ||
|
|
fbaf02f827 | ||
|
|
ebccf02b0a | ||
|
|
0faf2bea50 | ||
|
|
b968730cc4 | ||
|
|
18685e0c83 | ||
|
|
ba30b8f94c | ||
|
|
f87f2c43d0 | ||
|
|
d0ffcccc23 | ||
|
|
6fdcbacfc1 | ||
|
|
bbc8c418de | ||
|
|
fd20968bc8 | ||
|
|
96c2a293a4 | ||
|
|
da136415da | ||
|
|
e47f05e6be | ||
|
|
3e2339fe96 | ||
|
|
52531a8be4 | ||
|
|
7ff785cb5a | ||
|
|
775e85b73a | ||
|
|
c1b5fd3fc0 | ||
|
|
e9d01c917b | ||
|
|
0d59de637e | ||
|
|
917aa838cb | ||
|
|
8e9750226d | ||
|
|
78993dcb4d | ||
|
|
c36ea7a0fb | ||
|
|
1153de26fc | ||
|
|
e14ec03945 | ||
|
|
990ebee79b | ||
|
|
d44cd198c4 | ||
|
|
7ae30212b9 | ||
|
|
94355a6311 | ||
|
|
4a7875c71d | ||
|
|
d9988f246a | ||
|
|
4186c9a053 | ||
|
|
07c16dca58 | ||
|
|
cd82cdcd1b | ||
|
|
a6927489a8 | ||
|
|
323f2617d8 | ||
|
|
53cfd8b035 | ||
|
|
f30db7713d | ||
|
|
567b06ac48 | ||
|
|
a543ecff6f | ||
|
|
4888427a1e | ||
|
|
9f55f0cc79 | ||
|
|
4022298446 | ||
|
|
6372bc7c63 | ||
|
|
d4d99b7e18 | ||
|
|
ebd43a8140 | ||
|
|
72df65113a |
+28
@@ -2928,6 +2928,34 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### Delete review and prompt-page polish (#512)
|
||||
|
||||
Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to
|
||||
find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI
|
||||
(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp
|
||||
on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice
|
||||
closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt…
|
||||
(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens
|
||||
right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit
|
||||
metadata while the page's review or write runs, and disabled on a gone prompt's page.
|
||||
|
||||
Also:
|
||||
- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only
|
||||
reviews included.
|
||||
- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice),
|
||||
and its close-only sentence reads "This prompt cannot be deleted or archived."
|
||||
- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative:
|
||||
hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its
|
||||
variant stays.
|
||||
- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer
|
||||
exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled.
|
||||
- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a
|
||||
remembered search the heading's count equals what the archived shelf lists. A regression test pins both.
|
||||
|
||||
Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512
|
||||
cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no
|
||||
restart.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
|
||||
@@ -421,7 +421,8 @@ export function measureInventory(scope) {
|
||||
description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})),
|
||||
actions:when(actions,()=>({...layout(actions),...edges(actions),
|
||||
fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null,
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
// D163: a closed menu's items are not heading actions.
|
||||
buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||
|
||||
@@ -26,7 +26,8 @@ const SECTIONS=['heading','history','noPageScroll'];
|
||||
*/
|
||||
export function mapHistoryConcept(reference) {
|
||||
const {history}=reference.oracle;
|
||||
return {...reference,history:{...history,
|
||||
// D163 (#512): the heading as laid out with the More menu button (the oracle's heading).
|
||||
return {...reference,heading:reference.oracle.heading,history:{...history,
|
||||
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
||||
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
||||
summary:history.summary,
|
||||
@@ -120,11 +121,14 @@ async function conceptAsRecorded(page) {
|
||||
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
||||
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
||||
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
|
||||
// D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button.
|
||||
const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata');
|
||||
const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton};
|
||||
},SHOW_ARCHIVED);
|
||||
await settle(page);
|
||||
return ()=>page.evaluate(()=>{
|
||||
const {timeline,order,texts,archived}=window.__historyOracle;
|
||||
const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove();
|
||||
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text;
|
||||
archived.remove();delete window.__historyOracle;
|
||||
|
||||
@@ -50,7 +50,8 @@ test('Delete… reviews the selection, deletes the unpaired, archives the paired
|
||||
await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts');
|
||||
const grid=review(page).locator('dl.meta-grid > div');
|
||||
await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']);
|
||||
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable',
|
||||
// #512: Cat portrait is also another prompt's negative in the fixture, so the review says so.
|
||||
await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair',"1 prompt used as another prompt's negative: hidden, restorable",
|
||||
'1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']);
|
||||
await expect(review(page).locator('p.small')).toHaveCount(3);
|
||||
await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/);
|
||||
@@ -81,7 +82,7 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur
|
||||
await openReview(page);
|
||||
await expect(review(page)).toHaveAccessibleName('Archive selected prompts');
|
||||
// The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants.
|
||||
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable');
|
||||
await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText("2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable");
|
||||
await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 2 prompts.');
|
||||
await expect(cards(page)).toHaveCount(before.length-2);
|
||||
@@ -300,7 +301,8 @@ test('the page\'s delete holds one review at a time, Escape returns focus, and a
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(page.locator('.library-archived-notice')).toBeVisible();
|
||||
await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click();
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.');
|
||||
// #512: the page's review names its one prompt.
|
||||
await expect(review(page).locator('p.small').last()).toHaveText('This prompt cannot be deleted or archived.');
|
||||
await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -459,3 +461,94 @@ test('a failed review keeps the selection, and a selection nothing can change op
|
||||
await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
expect(byText(await items(request),'Library extra 0000')).toBeDefined();
|
||||
});
|
||||
|
||||
// #512 (live test of #505/#507/#509): the prompt page's "⋯" More menu (D163), a gone prompt's page, single-prompt titles.
|
||||
const more=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'More actions for this prompt',exact:true});
|
||||
const moreMenu=page=>page.getByRole('menu',{name:'More actions for this prompt'});
|
||||
test('the prompt page\'s More menu opens Delete prompt…, by mouse and keyboard, and closes on Escape or outside (#512 D163)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
const heading=page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button');
|
||||
await expect(heading).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
|
||||
await expect(more(page)).toHaveAttribute('aria-haspopup','menu');await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
await expect(moreMenu(page)).toBeHidden();
|
||||
// Keyboard: Enter opens on the first item; Escape closes and returns focus.
|
||||
await more(page).focus();await page.keyboard.press('Enter');
|
||||
await expect(more(page)).toHaveAttribute('aria-expanded','true');
|
||||
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
|
||||
await expect(item).toBeFocused();await expect(item).toHaveClass(/\bdanger\b/);
|
||||
await page.keyboard.press('ArrowDown');await expect(item).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toBeFocused();await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
// ArrowUp opens on the last item; ArrowUp, Home and End move within it (one item here); Tab closes it.
|
||||
await page.keyboard.press('ArrowUp');await expect(item).toBeFocused();
|
||||
for(const key of ['ArrowUp','Home','End']) {await page.keyboard.press(key);await expect(item).toBeFocused();}
|
||||
await page.keyboard.press('Tab');await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toHaveAttribute('aria-expanded','false');
|
||||
// Space opens too; an outside press closes it.
|
||||
await more(page).focus();await page.keyboard.press(' ');await expect(item).toBeFocused();
|
||||
await page.locator('#workspace-main h1').click();await expect(moreMenu(page)).toBeHidden();
|
||||
// Choosing it opens the page's review, titled for this prompt; Cancel returns focus to ⋯.
|
||||
await more(page).click();await item.click();
|
||||
await expect(moreMenu(page)).toBeHidden();
|
||||
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
|
||||
await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked();
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await expect(review(page).locator('#modal-title')).toHaveText('Delete this prompt');
|
||||
await review(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
await expect(review(page)).toHaveCount(0);await expect(more(page)).toBeFocused();
|
||||
expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id);
|
||||
});
|
||||
|
||||
test('the More menu fits a 390px viewport and works by touch (#512 D163)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await more(page).click();
|
||||
const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true});
|
||||
await expect(item).toBeVisible();
|
||||
const box=await moreMenu(page).boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
|
||||
await item.click();
|
||||
await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click();
|
||||
await expect(toast(page)).toHaveText('Archived 1 prompt.');
|
||||
await expect(page.locator('.library-archived-notice')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a deleted prompt\'s page says it no longer exists, with the way back and no Retry (#512)',async({page,request})=>{
|
||||
const dual=byText(await items(request),'Dual role portrait');
|
||||
await deleteFromPage(page,dual.id);
|
||||
await expect(review(page)).toHaveAccessibleName('Archive this prompt');
|
||||
await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check();
|
||||
await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${dual.id}`);
|
||||
await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.');
|
||||
await expect(page.getByRole('button',{name:'Retry prompt history'})).toHaveCount(0);
|
||||
await expect(editMetadata(page)).toBeDisabled();await expect(more(page)).toBeDisabled();
|
||||
await page.getByRole('button',{name:'Open the Prompt library',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
});
|
||||
|
||||
test('the archived shelf\'s heading count and its list agree under a remembered search (#512 item 5)',async({page,request})=>{
|
||||
const all=await items(request);
|
||||
const dual=byText(all,'Dual role portrait'),dog=byText(all,'Dog landscape');
|
||||
await archiveByApi(request,[dual.id]);
|
||||
await page.goto(route);
|
||||
// A search that matches a shelf prompt but not the archived one.
|
||||
await page.getByRole('searchbox').fill('landscape');
|
||||
await expect(card(page,dog.id)).toHaveCount(1);await expect(card(page,dual.id)).toHaveCount(0);
|
||||
const matching=(await(await request.get('/prompt_manager/library?limit=100&shelf=archived&text=landscape')).json()).items.length;
|
||||
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
|
||||
// Leave and come back: the remembered search is shown and still counts the same.
|
||||
await page.goto('/prompt_manager/workspace#/overview');await page.goto(route);
|
||||
await expect(page.getByRole('searchbox')).toHaveValue('landscape');
|
||||
await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`);
|
||||
await showArchived(page).click();
|
||||
await expect(backToLibrary(page)).toBeVisible();
|
||||
if(matching) await expect(cards(page)).toHaveCount(matching);
|
||||
else await expect(page.locator('.library-status')).toHaveText('No archived prompts match.');
|
||||
await page.getByRole('searchbox').fill('');
|
||||
await expect(card(page,dual.id)).toHaveCount(1);
|
||||
await backToLibrary(page).click();
|
||||
await expect(showArchived(page)).toHaveText('Show archived prompts (1)');
|
||||
});
|
||||
|
||||
@@ -540,8 +540,9 @@ test('new from Images cancel preserves browse',async({page})=>{
|
||||
|
||||
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
||||
const actions=headingActions(page).locator('button:not([role="menuitem"])');
|
||||
// D163 (#512): the More menu button follows Send to ComfyUI.
|
||||
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']);
|
||||
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
||||
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
|
||||
@@ -42,8 +42,13 @@ test('the grid shows each row only when it applies, with protected titles in sel
|
||||
['Image links removed', '3 images will no longer show these prompts (files stay on disk)'],
|
||||
['Backup', 'Create and verify before deleting'],
|
||||
]);
|
||||
// #512: a row with nothing in it is left out.
|
||||
const archiveOnly = reviewOf(preview({archive: [2]}));
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']);
|
||||
assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']);
|
||||
const deleteOnly = reviewOf(preview({delete: [1]}));
|
||||
assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']);
|
||||
const keptOnly = reviewOf(preview({protected: [7]}));
|
||||
assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]);
|
||||
const off = reviewOf(preview({delete: [1], backup: 'off'}));
|
||||
assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)');
|
||||
});
|
||||
@@ -136,7 +141,7 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () =
|
||||
not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3});
|
||||
assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0,
|
||||
imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {},
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'});
|
||||
pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive', archiveOnly: []});
|
||||
});
|
||||
|
||||
test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => {
|
||||
@@ -291,3 +296,32 @@ test('the outcome names prompts restored since the review', async () => {
|
||||
assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' +
|
||||
'was not deleted. The Library was reloaded.');
|
||||
});
|
||||
|
||||
// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive.
|
||||
test('a review from a prompt\'s own page names that prompt', async () => {
|
||||
const {reviewOf, reviewTitle, blockedReason} = await source;
|
||||
const single = review => ({...review, single: true});
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt');
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt');
|
||||
const kept = single(reviewOf(preview({protected: [3]})));
|
||||
assert.equal(reviewTitle(kept), 'Delete this prompt');
|
||||
assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.');
|
||||
assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}),
|
||||
'archived'))), 'Delete this prompt');
|
||||
});
|
||||
|
||||
test('a negative used by another prompt is named as such when it is archived', async () => {
|
||||
const {reviewOf, reviewGrid, reviewSentences} = await source;
|
||||
const negative = reviewOf(preview({archive: [5], deletable: []}));
|
||||
assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]);
|
||||
assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " +
|
||||
"that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]);
|
||||
const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}}));
|
||||
assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]);
|
||||
// A paired prompt whose review digest is missing is not called another prompt's negative (review M1).
|
||||
const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}}));
|
||||
assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]);
|
||||
const sentences = reviewSentences(mixed);
|
||||
assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/);
|
||||
assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/);
|
||||
});
|
||||
|
||||
@@ -491,6 +491,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/more-menu.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/plural.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
@@ -26,6 +26,7 @@ export const COPY = Object.freeze({
|
||||
nothing: `${NOTHING} The Library was reloaded.`,
|
||||
none: 'None of the selected prompts can be deleted or archived.',
|
||||
noneArchived: 'None of the selected prompts can be deleted.',
|
||||
noneSingle: 'This prompt cannot be deleted or archived.',
|
||||
restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.',
|
||||
restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.',
|
||||
restoreFailed: 'The prompts could not be returned to the Library. Try again.',
|
||||
@@ -65,6 +66,10 @@ function pairedOf(preview) {
|
||||
pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs),
|
||||
variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) },
|
||||
pairedImageLinks: count(preview?.paired_image_links), choice: 'archive',
|
||||
// #512: archive ids the server says cannot be deleted (another prompt's negative); none from an older answer.
|
||||
// The server's own list, not the digest-checked one: a deletable prompt with a bad digest is still paired.
|
||||
archiveOnly: Array.isArray(preview?.deletable)
|
||||
? [...archive].filter(id => !ids(preview.deletable).includes(id)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +92,8 @@ function plan(review) {
|
||||
|
||||
export function reviewTitle(review) {
|
||||
const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length;
|
||||
// #512: a review opened from a prompt's own page names that prompt.
|
||||
if (review.single) return deletes || !archive.length ? 'Delete this prompt' : 'Archive this prompt';
|
||||
if (onArchivedShelf(review)) return 'Delete archived prompts';
|
||||
if (!archive.length) return 'Delete selected prompts';
|
||||
if (!deletes) return 'Archive selected prompts';
|
||||
@@ -112,16 +119,23 @@ export function archivedNoticeText(variantCount) {
|
||||
/** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */
|
||||
export function reviewGrid(review, titleOf = () => null) {
|
||||
if (permanent(review)) return permanentGrid(review, titleOf);
|
||||
const rows = [
|
||||
['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`],
|
||||
// The archive reason is the saved pair (a pair's positive or negative), with or without negative variants.
|
||||
['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`],
|
||||
];
|
||||
// #512: rows with nothing in them are left out.
|
||||
const rows = [];
|
||||
if (review.delete.length) rows.push(['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`]);
|
||||
if (review.archive.length) rows.push(['Archive', archiveValue(review)]);
|
||||
keptRows(rows, review, titleOf);
|
||||
if (review.delete.length) deleteRows(rows, review, review.imageLinks);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The archive reason is the saved pair (a pair's positive or negative); #512 names another prompt's negative. */
|
||||
function archiveValue(review) {
|
||||
const total = review.archive.length, negatives = review.archiveOnly.length;
|
||||
if (negatives === total) return `${countOf(total, 'prompt')} used as another prompt's negative: hidden, restorable`;
|
||||
const detail = negatives ? ` (${negatives.toLocaleString('en-US')} used as another prompt's negative)` : '';
|
||||
return `${countOf(total, 'prompt')} in a saved pair${detail}: hidden, restorable`;
|
||||
}
|
||||
|
||||
function keptRows(rows, review, titleOf) {
|
||||
if (review.protected.length) {
|
||||
const names = protectedNames(review, titleOf);
|
||||
@@ -163,12 +177,17 @@ function permanentGrid(review, titleOf) {
|
||||
/** The review's sentences, in order, each when it applies. */
|
||||
export function reviewSentences(review) {
|
||||
if (permanent(review)) return permanentSentences(review);
|
||||
const sentences = [];
|
||||
if (review.archive.length) {
|
||||
const sentences = [], negatives = review.archiveOnly.length;
|
||||
if (review.archive.length > negatives) {
|
||||
sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' +
|
||||
'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' +
|
||||
'and press Delete… or Restore.');
|
||||
}
|
||||
// #512: a negative selected without its positive stays the other prompt's variant.
|
||||
if (negatives) {
|
||||
sentences.push("Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant " +
|
||||
`stays.${negatives === review.archive.length ? ' To bring them back, press Show archived prompts, select them and press Restore.' : ''}`);
|
||||
}
|
||||
if (review.delete.length) sentences.push(removeSentence(review));
|
||||
if (review.protected.length) sentences.push(PROTECTED_SENTENCE);
|
||||
return sentences;
|
||||
@@ -210,8 +229,9 @@ export function primaryLabel(review) {
|
||||
|
||||
/** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */
|
||||
export function blockedReason(review) {
|
||||
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return COPY.noneArchived;
|
||||
if (!review.delete.length && !review.archive.length) return COPY.none;
|
||||
const none = review.single ? COPY.noneSingle : null;
|
||||
if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return none ?? COPY.noneArchived;
|
||||
if (!review.delete.length && !review.archive.length) return none ?? COPY.none;
|
||||
const deletes = review.delete.length + plan(review).paired.length;
|
||||
if (deletes && review.disk && review.disk.sufficient === false) {
|
||||
return typeof review.disk.message === 'string' && review.disk.message
|
||||
@@ -372,7 +392,8 @@ export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds,
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; }
|
||||
const review = { ...reviewOf(preview), epoch };
|
||||
// #512: the review names this one prompt.
|
||||
const review = { ...reviewOf(preview), epoch, single: true };
|
||||
openDeleteReview({ scope, trigger, review, titleOf, onDismiss,
|
||||
onStart: async chosen => {
|
||||
let result = null, error = null;
|
||||
|
||||
@@ -18,8 +18,11 @@ import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {mountLibrarySelection} from './library-selection.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs';
|
||||
import {createMoreMenu} from './more-menu.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// #512: what a prompt page says when its prompt no longer exists (404).
|
||||
const PROMPT_GONE='This prompt no longer exists. It may have been deleted.';
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null;
|
||||
// The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986).
|
||||
@@ -488,7 +491,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;
|
||||
// D163 (#512, maintainer 2026-10-29): a "⋯" More menu after Send to ComfyUI holds Delete prompt…, so delete is
|
||||
// findable on the page; Edit metadata keeps its own Delete prompt… (D161).
|
||||
const moreMenu=createMoreMenu({scope,label:'More actions for this prompt',items:[{label:'Delete prompt…',danger:true,onSelect:()=>deletePrompt(moreMenu.button)}]});
|
||||
actions.append(back,edit,send,moreMenu.element);
|
||||
let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null;
|
||||
scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();});
|
||||
// Every visit opens on the first negative, as the concept's navigate() does (app.js 1149: state.variant = 0);
|
||||
@@ -504,14 +511,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// can race the delete; aria-disabled keeps it focusable for the return.
|
||||
function holdEdit(held) {
|
||||
deleting=held;
|
||||
if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled');
|
||||
for(const button of [edit,moreMenu.button]) {
|
||||
if(held) button.setAttribute('aria-disabled','true');else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
}
|
||||
function deletePrompt() {
|
||||
function deletePrompt(trigger=edit) {
|
||||
const shown=detail;if(deleting || !shown?.positive) return;
|
||||
// One review at a time, as the selection bar holds itself (a second press during the preview starts nothing).
|
||||
holdEdit(true);
|
||||
reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});},
|
||||
reviewPromptDelete({api,scope,trigger,ids:[shown.positive.id],epoch:shown.library_epoch,
|
||||
titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(trigger.isConnected) trigger.focus({preventScroll:true});},
|
||||
onSettled:(outcome,result,failure)=>{
|
||||
holdEdit(false);
|
||||
if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}});
|
||||
@@ -571,6 +580,17 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;}
|
||||
contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true;
|
||||
blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren();
|
||||
// #512: a prompt that no longer exists (deleted, or a stale link) says so, with the way back; no Retry.
|
||||
// Edit metadata and ⋯ act on a loaded prompt only (review M2).
|
||||
const hadFocus=[edit,moreMenu.button].includes(document.activeElement);
|
||||
const loaded=!state.loading && !state.error;edit.disabled=!loaded;moreMenu.setDisabled(!loaded);
|
||||
if(state.error?.status===404) {
|
||||
const message=el('p',PROMPT_GONE,'library-status');message.setAttribute('role','status');body.append(message);
|
||||
const open=createButton('Open the Prompt library');contentScope.listen(open,'click',()=>{window.location.hash='#/library';});body.append(open);
|
||||
// A focused control that was just disabled hands focus to the way back (review M3).
|
||||
if(hadFocus) open.focus({preventScroll:true});
|
||||
return;
|
||||
}
|
||||
if(state.loading || state.error) {
|
||||
const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message);
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
|
||||
/**
|
||||
* A "⋯" (More) menu button for a page heading (D163, #512; the concept has no menu, so it uses the concept's button
|
||||
* and surface styles). The button carries `aria-haspopup="menu"` and `aria-expanded`; Enter, Space or ArrowDown opens
|
||||
* the menu on its first item, ArrowUp on its last; ArrowUp/ArrowDown/Home/End move between items; Escape closes it and
|
||||
* returns focus to the button; Tab, an outside pointer press or choosing an item closes it. A held button
|
||||
* (`aria-disabled`) or a disabled one opens nothing. Listeners live on `scope`.
|
||||
*
|
||||
* `items`: `[{ label, danger, onSelect }]`. Returns `{ element, button, setDisabled(disabled) }`.
|
||||
*/
|
||||
let menus = 0;
|
||||
export function createMoreMenu({ scope, label, items }) {
|
||||
const id = `more-menu-${++menus}`;
|
||||
const element = document.createElement('div');
|
||||
element.className = 'more-menu';
|
||||
const button = createButton('⋯');
|
||||
button.classList.add('more-menu-button');
|
||||
button.setAttribute('aria-label', label);
|
||||
button.setAttribute('aria-haspopup', 'menu');
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.setAttribute('aria-controls', id);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'more-menu-list';
|
||||
menu.id = id;
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', label);
|
||||
menu.hidden = true;
|
||||
const entries = items.map(item => {
|
||||
const entry = createButton(item.label, item.danger ? 'danger' : '');
|
||||
entry.setAttribute('role', 'menuitem');
|
||||
entry.tabIndex = -1;
|
||||
scope.listen(entry, 'click', () => { close(false); item.onSelect(); });
|
||||
return entry;
|
||||
});
|
||||
menu.append(...entries);
|
||||
element.append(button, menu);
|
||||
|
||||
const isOpen = () => !menu.hidden;
|
||||
const held = () => button.disabled || button.getAttribute('aria-disabled') === 'true';
|
||||
function open(index) {
|
||||
if (held()) return;
|
||||
menu.hidden = false;
|
||||
// Right-aligned under the button; where the heading wraps it to the left edge (390px), it opens rightwards.
|
||||
menu.style.left = menu.style.right = '';
|
||||
if (menu.getBoundingClientRect().left < 0) Object.assign(menu.style, { left: '0', right: 'auto' });
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
entries.at(index)?.focus();
|
||||
}
|
||||
function close(refocus) {
|
||||
if (!isOpen()) return;
|
||||
menu.hidden = true;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
if (refocus) button.focus();
|
||||
}
|
||||
function move(step) {
|
||||
const at = entries.indexOf(document.activeElement);
|
||||
entries[(at + step + entries.length) % entries.length].focus();
|
||||
}
|
||||
scope.listen(button, 'click', () => (isOpen() ? close(true) : open(0)));
|
||||
scope.listen(button, 'keydown', event => {
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
||||
event.preventDefault();
|
||||
open(event.key === 'ArrowDown' ? 0 : -1);
|
||||
});
|
||||
scope.listen(menu, 'keydown', event => {
|
||||
const keys = { ArrowDown: () => move(1), ArrowUp: () => move(-1), Home: () => entries[0].focus(),
|
||||
End: () => entries.at(-1).focus(), Escape: () => close(true) };
|
||||
if (event.key === 'Tab') { close(false); return; }
|
||||
if (!keys[event.key]) return;
|
||||
event.preventDefault();
|
||||
keys[event.key]();
|
||||
});
|
||||
// Focus leaving the menu (a screen reader's cursor, a script) closes it too.
|
||||
scope.listen(element, 'focusout', event => { if (event.relatedTarget && !element.contains(event.relatedTarget)) close(false); });
|
||||
// An outside press closes it; capture, so a press that a page control stops still counts.
|
||||
const outside = event => { if (isOpen() && !element.contains(event.target)) close(false); };
|
||||
document.addEventListener('pointerdown', outside, true);
|
||||
scope.cleanup(() => document.removeEventListener('pointerdown', outside, true));
|
||||
return {
|
||||
element,
|
||||
button,
|
||||
setDisabled(disabled) { button.disabled = disabled; if (disabled) close(false); },
|
||||
};
|
||||
}
|
||||
@@ -482,3 +482,14 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ }
|
||||
.tagging-modal .tag-action-notice > span { flex: 1 1 16em; }
|
||||
.tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ }
|
||||
|
||||
/* D163 (#512): the prompt page's "⋯" More menu. The concept has no menu; it uses the concept's button, surface, border
|
||||
and toast shadow, opens under its button, right-aligned so it stays inside a 390px viewport. */
|
||||
.more-menu { position: relative; display: inline-flex; }
|
||||
.more-menu-list {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; display: flex; flex-direction: column; gap: 4px;
|
||||
min-width: 160px; padding: 6px; background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 6px;
|
||||
box-shadow: 0 8px 35px #0004;
|
||||
}
|
||||
.more-menu-list[hidden] { display: none; }
|
||||
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user