diff --git a/README.md b/README.md index ecd76e5..ccc7351 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,10 @@ 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 -category and tags come with it. Prepend and append STRING sockets remain visible +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. +Prepend and append STRING sockets remain visible when the fields are collapsed, so LoRA trigger outputs can connect directly. Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts support ComfyUI Classic and Nodes 2.0. diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png new file mode 100644 index 0000000..af3e81b Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-dark.png differ diff --git a/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png new file mode 100644 index 0000000..acf69de Binary files /dev/null and b/docs/screenshots/focused-toolkit/classic-start-focused-metadata-light.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png new file mode 100644 index 0000000..376fa8a Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-dark.png differ diff --git a/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png new file mode 100644 index 0000000..f74f6c0 Binary files /dev/null and b/docs/screenshots/focused-toolkit/nodes2-start-focused-metadata-light.png differ diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index e43476c..1f1bd48 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -29,6 +29,114 @@ test.beforeEach(async({page,browserName})=>{ }); test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);}); +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'); + return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}}); + }); + await page.getByText('Metadata · category / tags',{exact:true}).click(); + const tags = page.getByLabel('Tags',{exact:true}); + await tags.fill('portrait, bl'); + await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible(); + await tags.press('ArrowDown'); + await tags.press('Enter'); + await expect(tags).toHaveValue('portrait, blue eyes, '); + expect(await page.evaluate(()=>node.serialize().widgets_values[2])).toBe('portrait, blue eyes, '); + await tags.fill('bl'); + await page.getByRole('option',{name:'blue sky',exact:true}).click(); + await expect(tags).toHaveValue('blue sky, '); + await tags.fill('portrait, '); + await expect(page.getByRole('option',{name:'portrait',exact:true})).toHaveCount(0); + await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible(); + await tags.press('Escape'); + await expect(page.getByRole('listbox')).toBeHidden(); + await tags.fill('bl'); + await expect(page.getByRole('option',{name:'blue eyes',exact:true})).toBeVisible(); + await page.locator('.pm-focused').dispatchEvent('scroll'); + await expect(page.getByRole('listbox')).toBeHidden(); + await tags.fill('A brand new tag'); + await tags.press('Tab'); + await expect(tags).toHaveValue('A brand new tag'); +}); + +test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => { + let release; + let mode='delayed'; + await page.route('**/prompt_manager/tags/suggest?**', async route => { + const q = new URL(route.request().url()).searchParams.get('q'); + if(q === 'old') await new Promise(resolve=>{release=resolve;}); + if(mode === 'error') return route.fulfill({status:503,body:'Unavailable'}); + await route.fulfill({json:{success:true,suggestions:[{name:q+' result'}]}}).catch(()=>{}); + }); + await page.getByText('Metadata · category / tags',{exact:true}).click(); + const tags=page.getByLabel('Tags',{exact:true}); + await tags.fill('old'); + await expect.poll(()=>Boolean(release)).toBe(true); + await tags.fill('new'); + await expect(page.getByRole('option',{name:'new result',exact:true})).toBeVisible(); + release(); + await expect(page.getByRole('option',{name:'old result',exact:true})).toHaveCount(0); + mode='error'; + await tags.fill('unavailable'); + await expect(page.getByRole('listbox')).toBeHidden(); + await expect(tags).toHaveValue('unavailable'); + mode='ok'; + await tags.fill('retry'); + await expect(page.getByRole('option',{name:'retry result',exact:true})).toBeVisible(); + await page.evaluate(()=>node.onRemoved()); + await expect(page.getByRole('listbox')).toHaveCount(0); +}); + +test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => { + await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}})); + await page.getByText('Metadata · category / tags',{exact:true}).click(); + const category=page.getByLabel('Category',{exact:true}); + await category.fill('por'); + await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeVisible(); + await category.press('ArrowUp'); + await expect(page.getByRole('option',{name:'Portrait studio',exact:true})).toHaveAttribute('aria-selected','true'); + await category.press('Enter'); + await expect(category).toHaveValue('Portrait studio'); + expect(await page.evaluate(()=>node.serialize().widgets_values[1])).toBe('Portrait studio'); + await category.fill('por'); + await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible(); + await category.dispatchEvent('keydown',{key:'ArrowDown',isComposing:true}); + await expect(category).not.toHaveAttribute('aria-activedescendant',/.+/); + await page.getByLabel('Tags',{exact:true}).focus(); + await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden(); + await category.fill('New category'); + await expect(page.getByRole('listbox',{name:'Saved categories'})).toBeHidden(); + await expect(category).toHaveValue('New category'); +}); + +test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => { + await page.evaluate(async()=>{ + const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); + const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first'); + const second=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=second'); + window.completionFixture={scope:createScope(),selected:[],inserted:[]}; + const one=first.createSuggestionView(completionFixture.scope,null); + const two=second.createSuggestionView(completionFixture.scope,null,{label:'Other pack'}); + completionFixture.views=[one,two]; + one.hide(); + const items=[{name:'plain'},{name:'tag',type:'tag'},{name:'model',type:'lora'},{name:'nested',type:'embedding',fullPath:'folder/nested'},{name:'flat',type:'embedding',fullPath:'flat'}]; + one.render(items,index=>{completionFixture.selected.push(index);one.select(index);},item=>completionFixture.inserted.push(item.name)); + one.select(-1); + one.position({left:innerWidth-5,bottom:innerHeight,top:innerHeight-20}); + two.render([{name:'Other pack suggestion'}],()=>{},()=>{}); + two.position({left:0,bottom:5,top:0}); + }); + const lists=page.getByRole('listbox'); + const ids=await lists.evaluateAll(elements=>elements.map(element=>element.id)); + expect(new Set(ids).size).toBe(ids.length); + await page.getByRole('option',{name:'[LORA] model'}).hover(); + await expect(page.getByRole('option',{name:'[LORA] model'})).toHaveAttribute('aria-selected','true'); + await page.getByRole('option',{name:'[EMB] folder/nested'}).click(); + expect(await page.evaluate(()=>completionFixture.inserted)).toEqual(['nested']); + await page.evaluate(()=>completionFixture.scope.dispose()); + await expect(lists).toHaveCount(0); +}); + test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{ await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}})); await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft'); diff --git a/tests/comfyui/focused-node.spec.mjs b/tests/comfyui/focused-node.spec.mjs index 74f9203..5cf657d 100644 --- a/tests/comfyui/focused-node.spec.mjs +++ b/tests/comfyui/focused-node.spec.mjs @@ -103,8 +103,8 @@ test('Focused search preview respects metadata opt-out', async ({ page, request const id = await addFocused(page); const root = page.locator('.pm-focused'); await root.getByText('Metadata · category / tags', { exact: true }).click(); - await root.getByRole('textbox', { name: 'Category', exact: true }).fill('Keep category'); - await root.getByRole('textbox', { name: 'Tags', exact: true }).fill('keep-tag'); + await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category'); + await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag'); await root.getByRole('button', { name: 'Browse library', exact: true }).click(); await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text); await root.getByRole('button', { name: 'Search', exact: true }).click(); @@ -209,3 +209,72 @@ test('Focused Web UI button opens actual standalone pages', async ({ page }) => await expect(popup.locator('#addPromptBtn')).toBeVisible(); await popup.close(); }); + +test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => { + const category = `Host completion category ${testInfo.project.name}`; + const tag = `host-completion-${testInfo.project.name}`; + await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]); + const id = await addFocused(page); + const root = page.locator('.pm-focused'); + await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow'); + await root.getByText('Metadata · category / tags', { exact: true }).click(); + const categoryInput = root.getByRole('combobox', { name: 'Category', exact: true }); + const tagInput = root.getByRole('combobox', { name: 'Tags', exact: true }); + await categoryInput.fill(category.slice(0, -3)); + const categories = page.getByRole('listbox', { name: 'Saved categories', exact: true }); + await expect(categories.getByRole('option', { name: category, exact: true })).toBeVisible(); + await categoryInput.press('ArrowDown'); + await expect(categoryInput).toHaveAttribute('aria-activedescendant', /.+/); + await categoryInput.press('Enter'); + await expect(categoryInput).toHaveValue(category); + await expect(categoryInput).toHaveAttribute('aria-expanded', 'false'); + 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 tagInput.fill(`keep, ${tag.slice(0, -3)}`); + const tags = page.getByRole('listbox', { name: 'Saved tags', exact: true }); + const option = tags.getByRole('option', { name: tag, exact: true }); + await expect(option).toBeVisible(); + await expect(tags).toHaveAttribute('data-ca-theme', palette); + const bounds = await tags.boundingBox(); + const viewport = page.viewportSize(); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); + await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) }); + await option.click(); + await expect(tagInput).toHaveValue(`keep, ${tag}, `); + } + const expected = { text: 'Autocomplete workflow', category, tags: `keep, ${tag}, ` }; + await expect.poll(() => model(page, id)).toMatchObject(expected); + 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(categoryInput).toHaveValue(category); + await expect(tagInput).toHaveValue(expected.tags); + } + 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(expected); + await expect(categoryInput).toHaveValue(category); + await expect(tagInput).toHaveValue(expected.tags); + await categoryInput.fill(category.slice(0, -3)); + await expect(categories).toBeVisible(); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + app.graph.remove(app.graph.getNodeById(id)); + }, id); + await expect(page.getByRole('listbox', { name: 'Saved categories', includeHidden: true })).toHaveCount(0); + await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0); +}); diff --git a/tests/vendor/vendor-toolkit.test.mjs b/tests/vendor/vendor-toolkit.test.mjs index bfe777f..c03ce15 100644 --- a/tests/vendor/vendor-toolkit.test.mjs +++ b/tests/vendor/vendor-toolkit.test.mjs @@ -15,14 +15,14 @@ function fixture(t) { writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } })); writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } })); writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.1.0', license: 'MIT' })); - for (const file of ['ui/core.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file); + for (const file of ['ui/core.js', 'ui/suggestions.js', 'ui/controls.css', 'LICENSE', 'SOURCE.md']) writeFileSync(join(dependency, file), file); return { root, tooling, dependency, destination: join(root, 'web/js/shared/toolkit') }; } test('toolkit browser assets ship unchanged with deterministic locked provenance', t => { const f = fixture(t); syncToolkit({ root: f.root }); - for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source))); + for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) assert.deepEqual(readFileSync(join(f.destination, name)), readFileSync(join(f.dependency, source))); const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8'); assert.equal(JSON.parse(first).integrity, 'sha512-fixture'); syncToolkit({ root: f.root }); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 617b6f4..b44f3fd 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -124,5 +124,17 @@ "branches": 86, "functions": 98, "statements": 98 + }, + "metadata-autocomplete.js": { + "lines": 98, + "branches": 81, + "functions": 85, + "statements": 98 + }, + "shared/toolkit/suggestions.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 } } diff --git a/tools/vendor/package-lock.json b/tools/vendor/package-lock.json index b1a3324..6bd43be 100644 --- a/tools/vendor/package-lock.json +++ b/tools/vendor/package-lock.json @@ -8,7 +8,7 @@ "name": "promptmanager-vendor-tools", "version": "1.0.0", "devDependencies": { - "@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14", + "@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389", "@playwright/test": "1.64.1", "c8": "12.0.0", "viewerjs": "1.15.2" @@ -26,8 +26,8 @@ }, "node_modules/@comfyassets/toolkit": { "version": "0.1.0", - "resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14", - "integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==", + "resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389", + "integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==", "dev": true, "license": "MIT", "engines": { diff --git a/tools/vendor/package.json b/tools/vendor/package.json index 1769e78..9440944 100644 --- a/tools/vendor/package.json +++ b/tools/vendor/package.json @@ -16,7 +16,7 @@ "coverage:update": "node frontend-coverage.mjs --update-baseline" }, "devDependencies": { - "@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14", + "@comfyassets/toolkit": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389", "@playwright/test": "1.64.1", "c8": "12.0.0", "viewerjs": "1.15.2" diff --git a/tools/vendor/vendor-toolkit.mjs b/tools/vendor/vendor-toolkit.mjs index b48c866..83122fb 100644 --- a/tools/vendor/vendor-toolkit.mjs +++ b/tools/vendor/vendor-toolkit.mjs @@ -15,7 +15,7 @@ export function syncToolkit({ root = repositoryRoot, check = false } = {}) { if (locked?.resolved !== pin || !locked.integrity || locked.version !== info.version || info.name !== '@comfyassets/toolkit' || info.license !== 'MIT') throw new Error('Toolkit package identity or lock mismatch; run npm ci --ignore-scripts'); const output = new Map(); const files = {}; - for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) { + for (const [name, source] of Object.entries({ 'core.js': 'ui/core.js', 'suggestions.js': 'ui/suggestions.js', 'controls.css': 'ui/controls.css', LICENSE: 'LICENSE', 'SOURCE.md': 'SOURCE.md' })) { const content = readFileSync(join(installed, source)); output.set(name, content); files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') }; diff --git a/web/js/focused-node.js b/web/js/focused-node.js index 13c1262..d20bb68 100644 --- a/web/js/focused-node.js +++ b/web/js/focused-node.js @@ -1,5 +1,6 @@ import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js'; import {createPromptLibrary} from './prompt-library.js'; +import {attachMetadataAutocomplete} from './metadata-autocomplete.js'; const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text']; let nextEditorId = 0; @@ -57,11 +58,13 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.'; const controls = new Map([['text', main]]); const details = new Map(); + const completions = new Map(); element.append(toolbar, mainField, connected); function disclosure(key, label) { const section = document.createElement('details'); section.className = 'pm-editor-details'; const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); element.append(section); details.set(key,section); scope.listen(section, 'toggle', () => { + if (!section.open) completions.forEach(completion => completion.close()); if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot(); }); return section; @@ -76,6 +79,9 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { const field = createField(label,input,`${editorId}-${name}`); const note = document.createElement('span'); note.className = 'pm-editor-connection'; note.textContent = 'Supplied by an upstream connection.'; note.hidden = true; field.append(note); input._connectionNote = note; grid.append(field); + if (name === 'category' || name === 'tags') completions.set(name, attachMetadataAutocomplete(input, { + scope,api,settings:app.ui?.settings,multiple:name === 'tags',available:() => !isConnected(name) && input.getClientRects().length > 0, + })); } section.append(grid); } @@ -98,6 +104,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) { controls.forEach((input,name) => { input.value = value(name); if (name !== 'text') { + if (isConnected(name)) completions.get(name)?.close(); input.hidden = Boolean(isConnected(name)); input._connectionNote.hidden = !isConnected(name); } diff --git a/web/js/metadata-autocomplete.js b/web/js/metadata-autocomplete.js new file mode 100644 index 0000000..62430ef --- /dev/null +++ b/web/js/metadata-autocomplete.js @@ -0,0 +1,85 @@ +import {createScope} from './shared/toolkit/core.js'; +import {createSuggestionView} from './shared/toolkit/suggestions.js'; + +export function attachMetadataAutocomplete(input, {scope: owner, api, settings, multiple = false, available = () => true}) { + const scope = createScope(); + owner.cleanup(() => scope.dispose()); + const view = createSuggestionView(scope, settings, {label: multiple ? 'Saved tags' : 'Saved categories'}); + const attributes = ['role','aria-autocomplete','aria-controls','aria-expanded','aria-activedescendant','autocomplete']; + const original = new Map(attributes.map(name => [name,input.getAttribute(name)])); + input.setAttribute('role','combobox'); + input.setAttribute('aria-autocomplete','list'); + input.setAttribute('aria-controls',view.element.id); + input.setAttribute('aria-expanded','false'); + input.autocomplete = 'off'; + let timer, request, sequence = 0, items = [], selected = -1; + function close() { + clearTimeout(timer); request?.abort(); sequence++; + items = []; selected = -1; view.hide(); + input.setAttribute('aria-expanded','false'); + input.removeAttribute('aria-activedescendant'); + } + scope.cleanup(() => { + close(); + original.forEach((value,name) => value === null ? input.removeAttribute(name) : input.setAttribute(name,value)); + }); + function choose(index) { + selected = index; + input.setAttribute('aria-activedescendant',view.select(index)); + } + function insert(item) { + if (!available()) return close(); + const parts = input.value.split(','); + parts.pop(); + input.value = multiple ? [...parts.map(part => part.trim()).filter(Boolean),item.name].join(', ') + ', ' : item.name; + input.dispatchEvent(new Event('input',{bubbles:true})); + close(); input.focus(); + } + async function load() { + const current = ++sequence; + request = new AbortController(); + const parts = input.value.split(','); + const query = (multiple ? parts.pop() : input.value).trim(); + const committed = new Set(parts.map(part => part.trim().toLowerCase())); + const url = multiple ? `/prompt_manager/tags/suggest?${new URLSearchParams({q:query,limit:'20'})}` : '/prompt_manager/categories'; + try { + const response = await fetch(api.fileURL(url),{signal:request.signal}); + if (scope.signal.aborted || current !== sequence) return; + if (!response.ok) return close(); + const data = await response.json(); + if (scope.signal.aborted || current !== sequence || document.activeElement !== input || !available()) return; + if (!data.success) return close(); + items = (multiple ? data.suggestions : data.categories).map(item => ({name:typeof item === 'string' ? item : item.name})) + .filter(item => item.name.toLowerCase().startsWith(query.toLowerCase()) && (!multiple || !committed.has(item.name.toLowerCase()))).slice(0,15); + if (!items.length) return close(); + selected = -1; + view.render(items,choose,insert); view.position(input.getBoundingClientRect()); + input.setAttribute('aria-expanded','true'); + } catch { + if (current === sequence && !scope.signal.aborted) close(); + } + } + function schedule() { + close(); + if (available()) timer = setTimeout(load,150); + } + scope.listen(input,'input',schedule); + scope.listen(input,'focus',schedule); + scope.listen(input,'blur',close); + scope.listen(input,'keydown',event => { + if (event.isComposing) return; + if (event.key === 'Escape' || event.key === 'Tab') {close();return;} + if (!items.length) return; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); event.stopPropagation(); + choose(selected < 0 ? (event.key === 'ArrowDown' ? 0 : items.length - 1) : (selected + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length); + } else if (event.key === 'Enter' && selected >= 0) { + event.preventDefault(); event.stopPropagation(); insert(items[selected]); + } + }); + scope.listen(window,'resize',close); + document.addEventListener('scroll',event => { + if (!view.element.hidden && !view.element.contains(event.target)) close(); + },{capture:true,signal:scope.signal}); + return {close}; +} diff --git a/web/js/shared/toolkit/SOURCE.md b/web/js/shared/toolkit/SOURCE.md index 51f9ff3..7e0d840 100644 --- a/web/js/shared/toolkit/SOURCE.md +++ b/web/js/shared/toolkit/SOURCE.md @@ -21,3 +21,14 @@ range, touch-target and forced-colors rules (original lines 1–61, 97–99, 118 170–172 and closing brace 174). Feature-view selectors remain in KikoTools. The retained declarations are unchanged; the extracted stylesheet SHA-256 is `3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b`. + +## Suggestion view extraction + +`ui/suggestions.js` derives from KikoTools `web/ui/autocomplete-suggestions.js` +at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228` (SHA-256 `2aecc78f53b7be3d67b77337b4a340f948afcc4cb1196ccf3b70739ef1f0d002`). +The generic view preserves its scope-based cleanup, safe text rendering and +viewport positioning. Pack-specific classes and IDs are replaced by `ca-` names; +callers can supply a list label and omit type badges. Corresponding completion +styles are extracted from KikoTools' `web/ui/controls.css`. KikoTools is unchanged. +Keyboard handling, input ARIA state, matching and insertion remain in consumers. +The stylesheet hash above describes the initial extraction before this addition. diff --git a/web/js/shared/toolkit/controls.css b/web/js/shared/toolkit/controls.css index 0f74ae5..b773820 100644 --- a/web/js/shared/toolkit/controls.css +++ b/web/js/shared/toolkit/controls.css @@ -67,3 +67,13 @@ .ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; } .ca-tools .ca-switch input:checked::before { background: HighlightText; } } + +.ca-tools.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; } +.ca-tools.ca-completions[hidden] { display: none; } +.ca-tools.ca-completions .ca-completion-option { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; } +.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; } +.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; } +.ca-completion-directory { color: var(--ca-muted); } +.ca-tools.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; } +.ca-tools.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; } +@media (pointer: coarse) { .ca-tools.ca-completions .ca-completion-option { min-block-size: 40px; } } diff --git a/web/js/shared/toolkit/provenance.json b/web/js/shared/toolkit/provenance.json index b4bca9b..8a487b2 100644 --- a/web/js/shared/toolkit/provenance.json +++ b/web/js/shared/toolkit/provenance.json @@ -1,16 +1,20 @@ { "name": "@comfyassets/toolkit", "version": "0.1.0", - "resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14", - "integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==", + "resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389", + "integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==", "files": { "core.js": { "source": "ui/core.js", "sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838" }, + "suggestions.js": { + "source": "ui/suggestions.js", + "sha256": "979b7867bcf7c8d5bec2c6631cec4c15a5cbb5774c4eaa554989b954555b9a04" + }, "controls.css": { "source": "ui/controls.css", - "sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b" + "sha256": "9bb8662a5c911537c0577c5b21a861356550ec6235fcf2101d6585cc148efb8d" }, "LICENSE": { "source": "LICENSE", @@ -18,7 +22,7 @@ }, "SOURCE.md": { "source": "SOURCE.md", - "sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9" + "sha256": "713be4367ac20a587aa7740b985086dc86a866706584de4d56646c6ed0e574c9" } } } diff --git a/web/js/shared/toolkit/suggestions.js b/web/js/shared/toolkit/suggestions.js new file mode 100644 index 0000000..be06d40 --- /dev/null +++ b/web/js/shared/toolkit/suggestions.js @@ -0,0 +1,75 @@ +import { createScope, mountTheme } from './core.js'; + +let nextList = 0; + +export function suggestionPosition(field, popup, viewport) { + const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8)); + const below = field.bottom + 4; + const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4; + return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) }; +} + +export function createSuggestionView(scope, settings, { label = 'Suggestions' } = {}) { + const element = document.createElement('div'); + element.className = 'ca-tools ca-completions'; + do { element.id = `ca-completions-${++nextList}`; } + while (document.getElementById(element.id)); + element.role = 'listbox'; + element.setAttribute('aria-label', label); + element.hidden = true; + document.body.append(element); + mountTheme(element, settings, scope); + let optionsScope; + scope.cleanup(() => { optionsScope?.dispose(); element.remove(); }); + return { + element, + hide() { + optionsScope?.dispose(); + element.hidden = true; + element.replaceChildren(); + }, + render(suggestions, onSelect, onInsert) { + optionsScope?.dispose(); + optionsScope = createScope(); + element.replaceChildren(); + suggestions.forEach((item, index) => { + const option = document.createElement('div'); + option.className = 'ca-completion-option'; + option.id = `${element.id}-${index}`; + option.role = 'option'; + option.setAttribute('aria-selected', 'false'); + const badge = document.createElement('span'); + badge.className = 'ca-completion-type'; + badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]'; + const name = document.createElement('span'); + name.className = 'ca-completion-name'; + const path = item.type === 'embedding' ? item.fullPath : null; + if (path?.includes('/')) { + const directory = document.createElement('span'); + directory.className = 'ca-completion-directory'; + directory.textContent = path.slice(0, path.lastIndexOf('/') + 1); + name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1))); + } else name.textContent = item.name; + if (item.type) option.append(badge); + option.append(name); + optionsScope.listen(option, 'pointerdown', event => event.preventDefault()); + optionsScope.listen(option, 'pointermove', () => onSelect(index)); + optionsScope.listen(option, 'click', () => onInsert(item)); + element.append(option); + }); + element.hidden = false; + }, + select(index) { + const options = [...element.children]; + options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index))); + options[index]?.scrollIntoView({ block: 'nearest' }); + return options[index]?.id; + }, + position(field) { + const popup = element.getBoundingClientRect(); + const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight }); + element.style.left = `${left}px`; + element.style.top = `${top}px`; + }, + }; +}