feat(ui): autocomplete saved Focused metadata with shared controls (#186)

This commit is contained in:
Vito Sansevero
2026-10-16 22:43:11 -07:00
parent cf0090d3f5
commit 5fa16d4aa3
18 changed files with 398 additions and 14 deletions
+4 -1
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+108
View File
@@ -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');
+71 -2
View File
@@ -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);
});
+2 -2
View File
@@ -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 });
+12
View File
@@ -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
}
}
+3 -3
View File
@@ -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": {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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') };
+7
View File
@@ -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);
}
+85
View File
@@ -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};
}
+11
View File
@@ -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.
+10
View File
@@ -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; } }
+8 -4
View File
@@ -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"
}
}
}
+75
View File
@@ -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`;
},
};
}