diff --git a/README.md b/README.md index ccc7351..8387d8d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-c **Prompt Manager (Focused)** adds an editor-first layout with collapsible composition and metadata fields. Open **Browse library** to search saved prompts, -preview a selection, then choose **Use prompt**; **Load metadata** controls whether +filter by saved category and comma-separated tags, preview a selection, then choose +**Use prompt**; **Load metadata** controls whether category and tags come with it. Metadata fields suggest saved categories and tags; choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the next tag; category remains a single value. New values can still be typed freely. diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png new file mode 100644 index 0000000..8745cc2 Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-dark.png differ diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png new file mode 100644 index 0000000..a0aa1fc Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-library-filters-light.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png new file mode 100644 index 0000000..7c9bb38 Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-dark.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png new file mode 100644 index 0000000..e1a3b97 Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-library-filters-light.png differ diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 1f1bd48..cf78a3c 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -29,6 +29,65 @@ test.beforeEach(async({page,browserName})=>{ }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);}); +test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => { + const requests=[]; + await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}})); + await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}})); + await page.route('**/prompt_manager/search?**',route=>{ + requests.push(new URL(route.request().url())); + return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}}); + }); + await page.getByLabel('Main prompt',{exact:true}).fill('My draft'); + await page.getByRole('button',{name:'Browse library',exact:true}).click(); + const category=page.getByLabel('Filter category',{exact:true}); + await category.fill('por'); + await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible(); + await category.press('ArrowDown'); + await category.press('Enter'); + expect(requests).toHaveLength(0); + const tags=page.getByLabel('Filter tags',{exact:true}); + await tags.fill('stu'); + await page.getByRole('option',{name:'studio',exact:true}).click(); + await expect(tags).toHaveValue('studio, '); + await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"'); + await page.getByRole('button',{name:'Search',exact:true}).click(); + await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait'); + expect(requests[0].searchParams.get('text')).toBe('portrait'); + expect(requests[0].searchParams.get('category')).toBe('Portrait'); + expect(requests[0].searchParams.get('tags')).toBe('studio,film grain'); + expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']); + await page.locator('.pm-editor-result').click(); + await category.fill('Landscape'); + await expect(page.locator('.pm-editor-selection button')).toBeDisabled(); + const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;}); + expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '}); + await expect(category).toHaveValue('Landscape'); + await expect(tags).toHaveValue('studio, '); + let recentRequest; + await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});}); + await page.getByRole('button',{name:'Recent',exact:true}).click(); + await expect.poll(()=>Boolean(recentRequest)).toBe(true); + expect(recentRequest.searchParams.has('category')).toBe(false); + expect(recentRequest.searchParams.has('tags')).toBe(false); + await page.getByRole('button',{name:'Hide library',exact:true}).click(); + await expect(page.getByRole('listbox')).toBeHidden(); +}); + +test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => { + const requests=[]; + await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});}); + await page.getByRole('button',{name:'Browse library',exact:true}).click(); + await page.getByLabel('Filter category',{exact:true}).fill('New category'); + await page.getByLabel('Filter category',{exact:true}).press('Enter'); + await expect.poll(()=>requests.length).toBe(1); + expect(requests[0].searchParams.get('category')).toBe('New category'); + await page.getByLabel('Filter category',{exact:true}).fill(''); + await page.getByLabel('Filter tags',{exact:true}).fill('custom, '); + await page.getByLabel('Filter tags',{exact:true}).press('Enter'); + await expect.poll(()=>requests.length).toBe(2); + expect(requests[1].searchParams.get('tags')).toBe('custom'); +}); + test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => { await page.route('**/prompt_manager/tags/suggest?**', route => { const q = new URL(route.request().url()).searchParams.get('q'); @@ -203,7 +262,7 @@ test('Focused reports empty queries, no results, and safely renders backend and await page.getByRole('button',{name:'Browse library',exact:true}).click(); const search=page.getByRole('button',{name:'Search',exact:true}); await search.click(); - await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.'); + await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.'); expect(requests).toBe(0); await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing'); await search.click(); @@ -220,7 +279,7 @@ test('Focused reports empty queries, no results, and safely renders backend and await page.getByRole('searchbox',{name:'Search saved prompts'}).fill(''); await search.click(); await expect(page.getByRole('alert')).toHaveCount(0); - await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text or tag:name.'); + await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.'); mode='empty'; await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing'); await search.click(); diff --git a/tests/comfyui/focused-node.spec.mjs b/tests/comfyui/focused-node.spec.mjs index 5cf657d..783a98b 100644 --- a/tests/comfyui/focused-node.spec.mjs +++ b/tests/comfyui/focused-node.spec.mjs @@ -278,3 +278,69 @@ test('Focused metadata completion uses saved categories and tags across renderer await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0); await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0); }); + +test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => { + const category = `Library category ${testInfo.project.name}`; + const tag = `library-filter-${testInfo.project.name}`; + const matchingText = `Matching library prompt ${testInfo.project.name}`; + await seed(request, matchingText, category, [tag]); + await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]); + await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]); + const id = await addFocused(page); + const root = page.locator('.pm-focused'); + await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft'); + await root.getByText('Metadata · category / tags', { exact: true }).click(); + await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category'); + await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag'); + await root.getByRole('button', { name: 'Browse library', exact: true }).click(); + const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true }); + const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true }); + await filterCategory.fill(category.slice(0, -3)); + await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible(); + await filterCategory.press('ArrowDown'); + await filterCategory.press('Enter'); + await expect(filterCategory).toHaveValue(category); + for (const palette of ['light', 'dark']) { + await page.evaluate(async palette => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette); + }, palette); + await filterTags.fill(tag.slice(0, -3)); + const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true }); + await expect(option).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) }); + await option.click(); + await expect(filterTags).toHaveValue(`${tag}, `); + } + const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' }; + await root.getByRole('button', { name: 'Search', exact: true }).click(); + const results = root.locator('.pm-editor-results').getByRole('button'); + await expect(results).toHaveCount(1); + await expect(results.first()).toContainText(matchingText); + await results.first().click(); + await expect.poll(() => model(page, id)).toMatchObject(draft); + for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.centerOnNode(app.graph._nodes[0]); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await expect(filterCategory).toHaveValue(category); + await expect(filterTags).toHaveValue(`${tag}, `); + await expect.poll(() => model(page, id)).toMatchObject(draft); + } + const queued = await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + const prompt = await app.graphToPrompt(); + await app.loadGraphData(app.graph.serialize()); + return prompt.output[String(id)].inputs; + }, id); + expect(queued).toMatchObject(draft); + await expect(filterCategory).toHaveValue(category); + await expect(filterTags).toHaveValue(`${tag}, `); + await root.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(results).toHaveCount(1); + await expect(results.first()).toContainText(matchingText); + await expect.poll(() => model(page, id)).toMatchObject(draft); +}); diff --git a/web/js/focused-node.js b/web/js/focused-node.js index d20bb68..5befb9b 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -112,7 +112,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' '); library.refreshAvailability(); } - const library = createPromptLibrary({scope,api,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onUse:(prompt,includeMetadata) => { + const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => { if (isConnected('text')) return; write('text',prompt.text); if (includeMetadata) { @@ -123,17 +123,18 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { }}); element.append(library.element,footer); function snapshot() { - return {libraryOpen:!library.element.hidden,...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))}; + return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))}; } function restore() { const state = thisNode.properties.focusedUI || {}; details.forEach((section,name) => {section.open = Boolean(state[name]);}); library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen))); - library.setQuery(value('search_text')); render(); + library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render(); } controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';})); scope.listen(browse,'click',() => { library.element.hidden = !library.element.hidden; + library.closeCompletions(); browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden)); this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus(); }); diff --git a/web/js/prompt-library.js b/web/js/prompt-library.js index cdcbeda..b305985 100644 --- a/web/js/prompt-library.js +++ b/web/js/prompt-library.js @@ -1,44 +1,63 @@ -import {createScope, createButton, createSwitch} from './shared/toolkit/core.js'; +import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js'; +import {attachMetadataAutocomplete} from './metadata-autocomplete.js'; let nextLibraryId = 0; -export function createPromptLibrary({scope, api, parseQuery, onUse, isConnected, onQuery}) { +export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters}) { const element = document.createElement('section'); element.className = 'pm-editor-library'; element.setAttribute('aria-label', 'Prompt library'); element.hidden = true; const heading = document.createElement('h3'); heading.textContent = 'Prompt library'; + const libraryId = `pm-library-${++nextLibraryId}`; const queryRow = document.createElement('div'); queryRow.className = 'pm-editor-query'; const query = document.createElement('input'); query.type = 'search'; query.placeholder = 'Search text or tag:portrait'; query.setAttribute('aria-label', 'Search saved prompts'); const search = createButton('Search', 'ca-primary'); const recent = createButton('Recent'); queryRow.append(query, search, recent); + const filters = document.createElement('div'); filters.className = 'pm-editor-fields'; + const category = document.createElement('input'); category.type = 'text'; + const tags = document.createElement('input'); tags.type = 'text'; + filters.append(createField('Filter category',category,`${libraryId}-category`),createField('Filter tags',tags,`${libraryId}-tags`)); + const hint = document.createElement('p'); hint.className = 'pm-editor-status'; hint.textContent = 'Search combines text, category and tags. Recent shows all recent prompts.'; + const completions = [category,tags].map((input,index) => attachMetadataAutocomplete(input, { + scope,api,settings,multiple:index === 1,available:() => !element.hidden && input.getClientRects().length > 0, + })); const status = document.createElement('p'); status.className = 'pm-editor-status'; status.setAttribute('role', 'status'); status.textContent = 'Search or browse recent prompts.'; const results = document.createElement('div'); results.className = 'pm-editor-results'; const preview = document.createElement('section'); preview.className = 'pm-editor-selection'; preview.hidden = true; const previewText = document.createElement('p'); previewText.className = 'pm-editor-preview'; const actions = document.createElement('div'); actions.className = 'pm-editor-row'; - const includeMetadata = createSwitch('Load metadata', '', `pm-load-metadata-${++nextLibraryId}`); + const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`); const use = createButton('Use prompt', 'ca-primary'); use.disabled = true; actions.append(includeMetadata, use); preview.append(previewText, actions); - element.append(heading, queryRow, status, results, preview); + element.append(heading, queryRow, filters, hint, status, results, preview); let selected = null; let request = null; let sequence = 0; let rows = createScope(); scope.cleanup(() => { sequence++; request?.abort(); rows.dispose(); }); function refreshAvailability() { use.disabled = !selected || isConnected(); } + function invalidate() { + sequence++; request?.abort(); selected = null; preview.hidden = true; refreshAvailability(); + rows.dispose(); rows = createScope(); results.replaceChildren(); + status.classList.remove('pm-editor-error'); status.setAttribute('role','status'); status.textContent = 'Search to refresh results.'; + } async function load(recentOnly = false) { request?.abort(); request = new AbortController(); const current = ++sequence; selected = null; preview.hidden = true; refreshAvailability(); rows.dispose(); rows = createScope(); results.replaceChildren(); - const {text, tags} = parseQuery(query.value); + const parsed = parseQuery(query.value); + const text = parsed.text; + const tagFilters = [...new Map([...parsed.tags,...tags.value.split(',')].map(tag => tag.trim()).filter(Boolean).map(tag => [tag.toLowerCase(),tag])).values()]; + const categoryFilter = category.value.trim(); status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status'); - if (!recentOnly && !text && !tags.length) { status.textContent = 'Enter search text or tag:name.'; return; } + if (!recentOnly && !text && !tagFilters.length && !categoryFilter) { status.textContent = 'Enter search text, a category or tags.'; return; } const params = new URLSearchParams(recentOnly ? {limit:'20', sort:'last_used_desc'} : {limit:'50'}); if (!recentOnly) { if (text) params.set('text', text); - if (tags.length) params.set('tags', tags.join(',')); + if (tagFilters.length) params.set('tags', tagFilters.join(',')); + if (categoryFilter) params.set('category', categoryFilter); } status.textContent = 'Loading prompts…'; try { @@ -69,11 +88,20 @@ export function createPromptLibrary({scope, api, parseQuery, onUse, isConnected, } scope.listen(search, 'click', () => load()); scope.listen(recent, 'click', () => load(true)); - scope.listen(query, 'input', () => onQuery(query.value)); - scope.listen(query, 'keydown', event => { if (event.key === 'Enter') { event.preventDefault(); load(); } }); + scope.listen(query, 'input', () => {invalidate();onQuery(query.value);}); + [query,category,tags].forEach(input => scope.listen(input, 'keydown', event => { + if (event.key === 'Enter' && !event.defaultPrevented && !event.isComposing) { event.preventDefault(); load(); } + })); + [category,tags].forEach(input => scope.listen(input,'input',() => { + invalidate(); status.textContent = 'Filters changed. Search to refresh results.'; onFilters(); + })); scope.listen(use, 'click', () => { if (!selected || isConnected()) return; onUse(selected, includeMetadata.querySelector('input').checked); }); - return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; }}; + return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; }, + filters:() => ({category:category.value,tags:tags.value}), + restoreFilters(state = {}) { invalidate();category.value = state.category || '';tags.value = state.tags || '';completions.forEach(completion => completion.close()); }, + closeCompletions() { completions.forEach(completion => completion.close()); }, + }; }