feat(ui): autocomplete saved Focused metadata with shared controls (#186)
This commit is contained in:
@@ -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 |
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Vendored
+1
-1
@@ -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"
|
||||
|
||||
Vendored
+1
-1
@@ -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') };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user