Compare commits

...
40 changed files with 1484 additions and 57 deletions
+12
View File
@@ -150,6 +150,18 @@ node identifier and sockets. The reusable architecture is inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma); no implementation or assets
were copied. See the [toolkit maintenance notes](tools/vendor/README.md#shared-comfyassets-browser-controls).
**Prompt Manager (Focused)** adds an editor-first layout with collapsible
composition and metadata fields. Open **Browse library** to search saved prompts,
filter by saved category and comma-separated tags, preview a selection, then choose
**Use prompt**; **Load metadata** controls whether
category and tags come with it. Metadata fields suggest saved categories and tags;
choose with the mouse or arrow keys and Enter. Tag selections append `, ` for the
next tag; category remains a single value. New values can still be typed freely.
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.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+6 -3
View File
@@ -3,12 +3,13 @@ ComfyUI_PromptManager: A comprehensive ComfyUI custom node that extends text enc
with persistent prompt storage, advanced search capabilities, and an automatic image
gallery system.
This module provides two main node types:
This module provides prompt node types:
- PromptManager: CLIP encoding node that outputs CONDITIONING
- PromptManagerFocused: Editor-first layout using the same encoding contract
- PromptManagerText: Text-only node that outputs STRING with prepend/append
functionality
Both nodes share the same database backend for persistent prompt storage and include
The nodes share the same database backend for persistent prompt storage and include
an automatic image gallery system that monitors ComfyUI output directories and links
generated images to their source prompts.
@@ -38,18 +39,20 @@ def get_version():
return "unknown"
from .prompt_manager import PromptManager # noqa: E402
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
from .prompt_manager_text import PromptManagerText # noqa: E402
from .prompt_search_list import PromptSearchList # noqa: E402
NODE_CLASS_MAPPINGS = {
"PromptManager": PromptManager,
"PromptManagerFocused": PromptManagerFocused,
"PromptManagerText": PromptManagerText,
"PromptSearchList": PromptSearchList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerFocused": "Prompt Manager (Focused)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

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: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+15
View File
@@ -281,3 +281,18 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
combined = f"{text}|{prepend_text}|{append_text}|{category}|{tags}"
return hashlib.sha256(combined.encode()).hexdigest()
class PromptManagerFocused(PromptManager):
"""The editor-first UI uses the existing encoding and storage contract."""
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
inputs = super().INPUT_TYPES()
for name in ("prepend_text", "append_text"):
kind, options = inputs["optional"][name]
inputs["optional"][name] = (
kind,
{**options, "default": "", "forceInput": True},
)
return inputs
+411
View File
@@ -0,0 +1,411 @@
import {readFileSync} from 'node:fs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
test.beforeEach(async({page,browserName})=>{
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
await startCoverage(page,browserName);
page.focusedErrors=[];page.on('pageerror',error=>page.focusedErrors.push(error.message));
await page.route('**/scripts/app.js',route=>route.fulfill({contentType:'text/javascript',body:'export const app=window.fixtureApp;'}));
await page.route('**/scripts/api.js',route=>route.fulfill({contentType:'text/javascript',body:'export const api={fileURL:path=>path,queuePrompt:async(...args)=>args};'}));
await page.route('**/extensions/promptmanager/prompt_manager.js',route=>route.fulfill({contentType:'text/javascript',body:readFileSync(new URL('../../web/comfy/prompt_manager.js',import.meta.url),'utf8')}));
await page.goto('/prompt_manager/test');
await page.evaluate(async()=>{
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
}
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
await expect(page.locator('.pm-focused')).toBeVisible();
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
await page.route('**/prompt_manager/search?**',route=>{
requests.push(new URL(route.request().url()));
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
});
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const category=page.getByLabel('Filter category',{exact:true});
await category.fill('por');
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
await category.press('ArrowDown');
await category.press('Enter');
expect(requests).toHaveLength(0);
const tags=page.getByLabel('Filter tags',{exact:true});
await tags.fill('stu');
await page.getByRole('option',{name:'studio',exact:true}).click();
await expect(tags).toHaveValue('studio, ');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('portrait tag:studio tag:"film grain"');
await page.getByRole('button',{name:'Search',exact:true}).click();
await expect(page.locator('.pm-editor-result')).toContainText('Filtered portrait');
expect(requests[0].searchParams.get('text')).toBe('portrait');
expect(requests[0].searchParams.get('category')).toBe('Portrait');
expect(requests[0].searchParams.get('tags')).toBe('studio,film grain');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['My draft','','']);
await page.locator('.pm-editor-result').click();
await category.fill('Landscape');
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
await expect(category).toHaveValue('Landscape');
await expect(tags).toHaveValue('studio, ');
let recentRequest;
await page.route('**/prompt_manager/recent?**',route=>{recentRequest=new URL(route.request().url());return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
expect(recentRequest.searchParams.has('category')).toBe(false);
expect(recentRequest.searchParams.has('tags')).toBe(false);
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('listbox')).toBeHidden();
});
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
const requests=[];
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByLabel('Filter category',{exact:true}).fill('New category');
await page.getByLabel('Filter category',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(1);
expect(requests[0].searchParams.get('category')).toBe('New category');
await page.getByLabel('Filter category',{exact:true}).fill('');
await page.getByLabel('Filter tags',{exact:true}).fill('custom, ');
await page.getByLabel('Filter tags',{exact:true}).press('Enter');
await expect.poll(()=>requests.length).toBe(2);
expect(requests[1].searchParams.get('tags')).toBe('custom');
});
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
await page.route('**/prompt_manager/tags/suggest?**', route => {
const q = new URL(route.request().url()).searchParams.get('q');
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');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('button',{name:/Library portrait/}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
await page.getByRole('switch',{name:'Load metadata'}).uncheck();
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Library portrait');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill('trigger');
await page.getByLabel('Append',{exact:true}).fill('detailed');
await page.getByText('Composed text preview',{exact:true}).click();
await expect(page.locator('[data-composed-preview]')).toHaveText('trigger Library portrait detailed');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
});
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
await page.getByText('Metadata · category / tags',{exact:true}).click();
await page.getByLabel('Category',{exact:true}).fill('Saved category');
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
requested = new URL(route.request().url());
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
});
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
await query.fill('fox tag:"film grain"');
await query.press('Enter');
await page.getByRole('button',{name:/Selected fox/}).click();
expect(requested.searchParams.get('text')).toBe('fox');
expect(requested.searchParams.get('tags')).toBe('film grain');
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Preserved draft');
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await page.getByText('Metadata · category / tags',{exact:true}).click();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Animals');
await expect(page.getByLabel('Tags',{exact:true})).toHaveValue('film grain, fox');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
});
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
let requests=0;
let mode='empty';
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/search?**', async route => {
requests++;
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
});
await page.getByRole('button',{name:'Browse library',exact:true}).click();
const search=page.getByRole('button',{name:'Search',exact:true});
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
expect(requests).toBe(0);
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
mode='error';
await search.click();
await expect(page.getByRole('alert')).toHaveText(`Could not load prompts: ${malicious}`);
await expect(page.locator('.pm-editor-library img')).toHaveCount(0);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
mode='http';
await search.click();
await expect(page.getByRole('alert')).toContainText('HTTP 503');
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
mode='empty';
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('missing');
await search.click();
await expect(page.getByRole('alert')).toHaveCount(0);
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
});
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
const malicious='<img src=x onerror="window.focusedInjected=true">';
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.locator('.pm-editor-result').click();
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
await expect(page.locator('.pm-editor-library img, .pm-editor-library svg, .pm-editor-library script')).toHaveCount(0);
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(malicious);
expect(await page.evaluate(()=>window.focusedInjected)).toBeUndefined();
});
async function deferLibraryRequests(page) {
await page.evaluate(()=>{
const originalFetch=window.fetch;
window.libraryRequests=[];
window.fetch=(url,options)=>{
if(!/\/prompt_manager\/(search|recent)\?/.test(String(url))) return originalFetch(url,options);
return new Promise(resolve=>window.libraryRequests.push({url:String(url),signal:options.signal,resolve}));
};
window.completeLibraryRequest=(index,text)=>libraryRequests[index].resolve(new Response(JSON.stringify({success:true,results:[{id:index,text}]}),{status:200,headers:{'Content-Type':'application/json'}}));
});
}
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
await deferLibraryRequests(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
await page.getByRole('button',{name:'Search',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[0].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(1,'Latest recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.evaluate(()=>completeLibraryRequest(0,'Stale search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest recent');
await page.getByRole('button',{name:'Recent',exact:true}).click();
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('latest search');
await page.getByRole('button',{name:'Search',exact:true}).click();
expect(await page.evaluate(()=>libraryRequests[2].signal.aborted)).toBe(true);
await page.evaluate(()=>completeLibraryRequest(3,'Latest search'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
await page.evaluate(()=>completeLibraryRequest(2,'Stale recent'));
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
});
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
await deferLibraryRequests(page);
await page.getByRole('button',{name:'Browse library',exact:true}).click();
await page.getByRole('button',{name:'Recent',exact:true}).click();
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
const removed=await page.evaluate(()=>{
node.onRemoved();
document.body.style.setProperty('--bg-color','#ffffff');
fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));
return {requestAborted:libraryRequests[0].signal.aborted,scopeAborted:node._focusedScope.signal.aborted};
});
expect(removed).toEqual({requestAborted:true,scopeAborted:true});
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
await page.getByRole('button',{name:'Hide library',exact:true}).click();
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
});
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
await page.setViewportSize({width:1000,height:1000});
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
const prepend=page.getByLabel('Prepend',{exact:true});
const append=page.getByLabel('Append',{exact:true});
let first=await prepend.boundingBox(),second=await append.boundingBox();
expect(Math.abs(first.y-second.y)).toBeLessThan(2);
expect(second.x).toBeGreaterThan(first.x+first.width);
await page.evaluate(()=>{node._focusedView.element.style.width='300px';});
await expect.poll(async()=>{const a=await prepend.boundingBox(),b=await append.boundingBox();return b.y>a.y+a.height;}).toBe(true);
expect(await page.locator('.pm-focused').evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
const dark=await prepend.evaluate(element=>getComputedStyle(element).backgroundColor);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#ffffff'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','light');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).not.toBe(dark);
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222222'));
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
});
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
await page.getByText('Composition · prepend / append',{exact:true}).click();
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
await page.getByLabel('Append',{exact:true}).fill(' detail ');
await page.getByText('Composed text preview',{exact:true}).click();
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
});
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
});
test('Focused expands its main editor when the node gets taller',async({page})=>{
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
expect(editor.height).toBeGreaterThan(400);
});
test('Focused respects the existing Web UI display preference',async({page})=>{
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
await page.evaluate(()=>{
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
window.open=(...args)=>{window.openArgs=args;return {};};
});
await page.evaluate(()=>node._focusedSettingsReady);
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
});
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
await page.evaluate(()=>{window.open=()=>null;});
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
await expect(page.locator('.pm-focused')).toBeVisible();
});
+346
View File
@@ -0,0 +1,346 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
async function addFocused(page) {
return page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptManagerFocused');
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
app.graph.add(node);
node.setSize([680, 1050]);
app.canvas.ds.scale = .8;
app.canvas.centerOnNode(node);
return node.id;
});
}
async function model(page, id) {
return page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return Object.fromEntries(app.graph.getNodeById(id).widgets.map(widget => [widget.name, widget.value]));
}, id);
}
async function seed(request, text, category = 'Focused host tests', tags = ['focused-host']) {
const response = await request.post('/prompt_manager/save', { data: { text, category, tags } });
expect(response.ok()).toBe(true);
expect((await response.json()).success).toBe(true);
}
async function browse(page, text) {
const root = page.locator('.pm-focused');
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
await root.getByRole('button', { name: 'Recent', exact: true }).click();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
return root;
}
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
const definitions = await (await request.get('/object_info')).json();
const focused = definitions.PromptManagerFocused;
expect(focused).toBeDefined();
expect(focused.display_name).toBe('Prompt Manager (Focused)');
expect(focused.output).toEqual(definitions.PromptManager.output);
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
for (const name of ['prepend_text', 'append_text']) {
expect(focused.input.optional[name][0]).toBe('STRING');
expect(focused.input.optional[name][1]).toMatchObject({ forceInput: true, default: '' });
}
});
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
const text = `Focused selected ${testInfo.project.name}`;
await seed(request, text);
const id = await addFocused(page);
const root = page.locator('.pm-focused');
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
await expect(root).toBeVisible();
expect(await editor.evaluate(async (element, id) => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl === element;
}, id)).toBe(true);
await editor.fill('Original editing text');
await browse(page, text);
await expect(editor).toHaveValue('Original editing text');
expect((await model(page, id)).text).toBe('Original editing text');
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host' });
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('LoRA trigger');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('high detail');
await root.getByText('Composed text preview', { exact: true }).click();
await expect(root).toContainText(`LoRA trigger ${text} high detail`);
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(root).toHaveCount(1);
await expect(editor).toHaveValue(text);
}
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({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
await expect(editor).toHaveValue(text);
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(root).toHaveCount(0);
});
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
const text = `Focused search ${testInfo.project.name}`;
await seed(request, text, 'Incoming category', ['incoming-tag']);
const id = await addFocused(page);
const root = page.locator('.pm-focused');
await root.getByText('Metadata · category / tags', { exact: true }).click();
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();
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
await root.getByRole('switch', { name: 'Load metadata', exact: true }).uncheck();
await root.getByRole('button', { name: 'Use prompt', exact: true }).click();
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
});
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
const text = `Connected library ${testInfo.project.name}`;
await seed(request, text);
const id = await addFocused(page);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
await root.getByText('Composition · prepend / append', { exact: true }).click();
await root.getByRole('textbox', { name: 'Prepend', exact: true }).fill('Local prepend');
await root.getByRole('textbox', { name: 'Append', exact: true }).fill('Local append');
await root.getByText('Composition · prepend / append', { exact: true }).click();
const links = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const { convertToInput } = await import('/extensions/core/widgetInputs.js');
const target = app.graph.getNodeById(id);
convertToInput(target, target.widgets.find(widget => widget.name === 'text'));
const result = {};
for (const name of ['text', 'prepend_text', 'append_text']) {
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [target.pos[0] - 360, target.pos[1] + Object.keys(result).length * 150];
source.widgets.find(widget => widget.name === 'value').value = `Connected ${name}`;
const slot = target.inputs.findIndex(input => input.name === name);
if (slot < 0) throw new Error(`Missing ${name} input socket`);
source.connect(0, target, slot);
result[name] = [String(source.id), 0];
}
app.canvas.centerOnNode(target);
return result;
}, id);
await browse(page, text);
await expect(root.getByRole('textbox', { name: 'Main prompt', exact: true })).toBeHidden();
await expect(root.getByRole('button', { name: 'Use prompt', exact: true })).toBeDisabled();
await root.getByText('Composition · prepend / append', { exact: true }).click();
for (const name of ['Prepend', 'Append']) await expect(root.getByRole('textbox', { name, exact: true })).toBeHidden();
const guardedValues = await model(page, id);
await root.locator('textarea, input').evaluateAll(inputs => {
for (const input of inputs) {
if (!input.hidden && !input.closest('[hidden]')) continue;
input.value = 'Attempt to replace upstream text';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
expect(await model(page, id)).toMatchObject(guardedValues);
for (let reload = 0; reload < 2; reload++) {
const inputs = await page.evaluate(async ({ id, reload }) => {
const { app } = await import('/scripts/app.js');
if (reload) await app.loadGraphData(app.graph.serialize());
return (await app.graphToPrompt()).output[String(id)].inputs;
}, { id, reload });
expect(inputs).toMatchObject(links);
}
await expect(root.getByRole('button', { name: 'Use prompt', exact: true, includeHidden: true })).toBeDisabled();
const disconnected = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
for (const name of ['text', 'prepend_text', 'append_text']) node.disconnectInput(node.inputs.findIndex(input => input.name === name));
return (await app.graphToPrompt()).output[String(id)].inputs;
}, id);
expect(disconnected).toMatchObject({ text: 'Local main', prepend_text: 'Local prepend', append_text: 'Local append' });
for (const [name, value] of [['Main prompt', 'Local main'], ['Prepend', 'Local prepend'], ['Append', 'Local append']]) {
const input = page.getByRole('textbox', { name, exact: true });
await expect(input).toHaveCount(1);
await expect(input).toBeEditable();
await expect(input).toHaveValue(value);
}
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
});
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await addFocused(page);
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);
const root = page.locator('.pm-focused');
await expect(root).toHaveAttribute('data-ca-theme', palette);
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
}
});
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
await addFocused(page);
const popupPromise = page.waitForEvent('popup');
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
await expect(popup.locator('body')).toContainText('Prompt');
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
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);
});
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
const category = `Library category ${testInfo.project.name}`;
const tag = `library-filter-${testInfo.project.name}`;
const matchingText = `Matching library prompt ${testInfo.project.name}`;
await seed(request, matchingText, category, [tag]);
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
const id = await addFocused(page);
const root = page.locator('.pm-focused');
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
await root.getByText('Metadata · category / tags', { exact: true }).click();
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
await filterCategory.fill(category.slice(0, -3));
await expect(page.getByRole('listbox', { name: 'Saved categories', exact: true }).getByRole('option', { name: category, exact: true })).toBeVisible();
await filterCategory.press('ArrowDown');
await filterCategory.press('Enter');
await expect(filterCategory).toHaveValue(category);
for (const palette of ['light', 'dark']) {
await page.evaluate(async palette => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', palette);
}, palette);
await filterTags.fill(tag.slice(0, -3));
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
await expect(option).toBeVisible();
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
await option.click();
await expect(filterTags).toHaveValue(`${tag}, `);
}
const draft = { text: 'Keep my draft', category: 'Draft category', tags: 'draft-tag' };
await root.getByRole('button', { name: 'Search', exact: true }).click();
const results = root.locator('.pm-editor-results').getByRole('button');
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await results.first().click();
await expect.poll(() => model(page, id)).toMatchObject(draft);
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await expect.poll(() => model(page, id)).toMatchObject(draft);
}
const queued = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const prompt = await app.graphToPrompt();
await app.loadGraphData(app.graph.serialize());
return prompt.output[String(id)].inputs;
}, id);
expect(queued).toMatchObject(draft);
await expect(filterCategory).toHaveValue(category);
await expect(filterTags).toHaveValue(`${tag}, `);
await root.getByRole('button', { name: 'Search', exact: true }).click();
await expect(results).toHaveCount(1);
await expect(results.first()).toContainText(matchingText);
await expect.poll(() => model(page, id)).toMatchObject(draft);
});
+43
View File
@@ -0,0 +1,43 @@
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
export function installHostHooks(test, expectedTypes = ['PromptManager', 'PromptManagerText', 'PromptSearchList']) {
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async expectedTypes => {
const { app } = await import('/scripts/app.js');
return !!app.graph && expectedTypes.every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
}, expectedTypes);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
}
+2 -38
View File
@@ -1,43 +1,7 @@
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import runtime from '../../tools/vendor/comfyui-runtime.json' with { type: 'json' };
import { installHostHooks } from './host-fixture.mjs';
test.beforeEach(async ({ page, context, request }, testInfo) => {
const response = await request.get('/system_stats');
expect(response.ok()).toBe(true);
const info = (await response.json()).system;
expect(info.comfyui_version).toBe(runtime.serverVersion);
expect(info.pytorch_version).toBe(`${runtime.torchVersion}+cpu`);
expect(info.required_frontend_version).toBe(runtime.frontendVersion);
expect(info.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe(runtime.frontendVersion);
await testInfo.attach('host-versions.json', { body: JSON.stringify(info, null, 2), contentType: 'application/json' });
page.hostErrors = [];
page.on('pageerror', error => page.hostErrors.push(error.message));
await context.route('**/*', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api\//, '/');
if (path === '/settings' && request.method() === 'GET') {
const response = await route.fetch();
return route.fulfill({ json: { ...await response.json(),
'Comfy.VueNodes.Enabled': testInfo.project.metadata.initialRenderer,
'Comfy.TutorialCompleted': true, 'Comfy.Workflow.Persist': false,
'Comfy.Notification.ShowVersionUpdates': false } });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && !path.startsWith('/prompt_manager/')) return route.fulfill({ json: {} });
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && ['PromptManager', 'PromptManagerText', 'PromptSearchList'].every(name => globalThis.LiteGraph?.registered_node_types?.[name]);
});
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(testInfo.project.metadata.initialRenderer);
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); app.graph.clear(); });
});
test.afterEach(async ({ page }) => {
expect(page.hostErrors).toEqual([]);
});
installHostHooks(test);
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} selects real database prompts and preserves text across renderers and reload`, async ({ page, request }, testInfo) => {
+34
View File
@@ -327,3 +327,37 @@ test("missing or empty metadata yields placeholders", () => {
assert.equal(r.checkpoint, "Unknown");
assert.equal(extractGenerationParams(null).seed, "Unknown");
});
test("Focused API metadata composes linked positive text and preserves negative role", () => {
const prompt = {
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
2: { class_type: "PromptManagerFocused", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
3: { class_type: "PromptManagerFocused", inputs: { text: "blurry", prepend_text: "avoid" } },
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
};
const result = extractGenerationParams({ prompt });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry");
});
test("Focused workflow-only metadata recovers serialized fields and connected composition", () => {
const workflow = {
nodes: [
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
{ id: 2, type: "PromptManagerFocused", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
{ id: 3, type: "PromptManagerFocused", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
],
links: [
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
],
};
const result = extractGenerationParams({ workflow });
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
assert.equal(result.negativePrompt, "avoid blurry watermark");
assert.equal(result.checkpoint, "model.safetensors");
});
+61
View File
@@ -0,0 +1,61 @@
"""Focused shares encoding and tracking while exposing composition sockets."""
import logging
import unittest
from unittest import mock
from prompt_manager import PromptManager, PromptManagerFocused
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
class TestFocusedNode(unittest.TestCase):
def test_focused_keeps_the_execution_contract_and_visible_string_inputs(self):
self.assertTrue(issubclass(PromptManagerFocused, PromptManager))
self.assertEqual(PromptManagerFocused.RETURN_TYPES, PromptManager.RETURN_TYPES)
self.assertEqual(PromptManagerFocused.FUNCTION, PromptManager.FUNCTION)
focused = PromptManagerFocused.INPUT_TYPES()
for name in ("prepend_text", "append_text"):
self.assertTrue(focused["optional"][name][1]["forceInput"])
self.assertNotIn(
"forceInput", PromptManager.INPUT_TYPES()["optional"][name][1]
)
self.assertEqual(focused["hidden"]["unique_id"], "UNIQUE_ID")
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
node = PromptManagerFocused.__new__(PromptManagerFocused)
node.logger = logging.getLogger("test.focused")
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = mock.Mock(return_value=42)
clip = mock.Mock()
graph = {"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}}}
conditioning, text = node.encode_prompt(
clip,
"cat",
prepend_text="trigger",
append_text="detailed",
prompt=graph,
unique_id="1",
)
self.assertEqual(text, "trigger cat detailed")
clip.tokenize.assert_called_once_with(text)
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
tracked = node._track_prompt_execution.call_args.kwargs
self.assertEqual(tracked["text"], "cat")
self.assertEqual(tracked["prompt_graph"], graph)
def test_focused_is_traced_as_positive_and_not_as_the_negative_run(self):
graph = {
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["4", 0],
"positive": ["1", 0],
"negative": ["2", 0],
},
},
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
+6 -1
View File
@@ -135,7 +135,12 @@ class TestPackageWiring(PackageTestCase):
def test_nodes_are_registered(self):
self.assertEqual(
sorted(self.package.NODE_CLASS_MAPPINGS),
["PromptManager", "PromptManagerText", "PromptSearchList"],
[
"PromptManager",
"PromptManagerFocused",
"PromptManagerText",
"PromptSearchList",
],
)
def test_db_factory_returns_the_shared_database(self):
+13
View File
@@ -54,6 +54,19 @@ class TestPositivePromptNodes(unittest.TestCase):
}
self.assertEqual(positive_prompt_nodes(graph), ["134"])
def test_focused_nodes_preserve_positive_roles_and_samplerless_fallback(self):
graph = {
"1": {"class_type": "PromptManagerFocused", "inputs": {"text": "cat"}},
"2": {"class_type": "PromptManagerFocused", "inputs": {"text": "blur"}},
"5": ksampler("1", "2"),
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
self.assertEqual(run_prompt_nodes(graph), ["1"])
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
self.assertEqual(run_prompt_nodes(graph), [])
del graph["5"]
self.assertEqual(run_prompt_nodes(graph), ["2"])
def test_negative_only_prompt_manager_is_never_positive(self):
graph = {
"176": pm("blurry"),
+10
View File
@@ -126,6 +126,16 @@ class TestQueueHook(DbTestCase):
self.assertEqual(self._runs(positive), 1)
self.assertEqual(self._runs(negative), 0)
def test_focused_queued_runs_count_only_positive_even_when_cached(self):
positive, negative = self._save("a cat"), self._save("blurry")
graph = workflow()
for node_id in ("134", "176"):
graph[node_id]["class_type"] = "PromptManagerFocused"
self._queue(graph)
self._queue(graph)
self.assertEqual(self._runs(positive), 2)
self.assertEqual(self._runs(negative), 0)
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
positive = self._save("a cat")
self._queue(workflow())
+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 });
+24
View File
@@ -112,5 +112,29 @@
"branches": 96,
"functions": 98,
"statements": 98
},
"focused-node.js": {
"lines": 95,
"branches": 73,
"functions": 87,
"statements": 95
},
"prompt-library.js": {
"lines": 98,
"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') };
+4 -2
View File
@@ -11,7 +11,9 @@ link-tracing rules of web/js/comfy-metadata.js.
import re
from typing import Any, Dict, List, Optional
PROMPT_MANAGER_TYPES = frozenset({"PromptManager", "PromptManagerText"})
PROMPT_MANAGER_TYPES = frozenset(
{"PromptManager", "PromptManagerText", "PromptManagerFocused"}
)
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
MAX_VISITS = 500
@@ -92,7 +94,7 @@ def has_sampler(graph: Any) -> bool:
def positive_prompt_nodes(graph: Any) -> List[str]:
"""Ids of PromptManager/PromptManagerText nodes that feed a positive input.
"""Ids of PromptManager family nodes that feed a positive input.
Ordered by discovery, each id once. Malformed graphs yield an empty list.
"""
+8 -2
View File
@@ -3,6 +3,8 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
const { installFocusedNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']);
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -197,7 +199,7 @@ function syncPromptManagerWidgets() {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if (node.type === "PromptManager" || node.type === "PromptManagerText") {
if (promptManagerTypes.has(node.type)) {
if (node.widgets && node.widgets.length > 0) {
if (!node.widgets_values) {
node.widgets_values = [];
@@ -257,7 +259,7 @@ app.registerExtension({
if (graph && output) {
const nodes = graph._nodes || graph.nodes || [];
for (const node of nodes) {
if ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) {
if (promptManagerTypes.has(node.type) && output[node.id]) {
const outputNode = output[node.id];
// Skip if text input is connected to another node (value is a link reference array)
@@ -290,6 +292,10 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'PromptManagerFocused') {
installFocusedNode(nodeType, app, api, parseSearchQuery);
return;
}
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
// Store original methods
const onNodeCreated = nodeType.prototype.onNodeCreated;
+1
View File
@@ -235,6 +235,7 @@
CLIPTextEncode: ["text"],
PromptManager: PROMPT_MANAGER_WIDGETS,
PromptManagerText: PROMPT_MANAGER_WIDGETS,
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
CheckpointLoader: ["config_name", "ckpt_name"],
UNETLoader: ["unet_name", "weight_dtype"],
};
+158
View File
@@ -0,0 +1,158 @@
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;
function loadStyles() {
const href = new URL('./prompt-editor.css', import.meta.url).href;
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
}
export function installFocusedNode(nodeType, app, api, parseQuery) {
const prototype = nodeType.prototype;
const created = prototype.onNodeCreated;
const removed = prototype.onRemoved;
const configure = prototype.configure;
const serialize = prototype.serialize;
const connections = prototype.onConnectionsChange;
prototype.onNodeCreated = function (...args) {
created?.apply(this, args);
const scope = createScope(); this._focusedScope = scope;
const editorId = `pm-focused-${++nextEditorId}`;
this.properties ||= {};
const widgets = new Map();
for (const name of names) {
let widget = this.widgets?.find(item => item.name === name);
if (!widget) widget = this.addWidget('text', name, '', () => {}, {});
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
widgets.set(name, widget);
}
const element = document.createElement('div'); element.className = 'ca-tools pm-focused';
element.setAttribute('aria-label','Focused prompt editor');
loadStyles(); mountTheme(element, app.ui?.settings, scope);
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
const web = createButton('Web UI ↗'); toolbar.append(title, browse, web);
let webDisplayMode = 'popup';
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
.then(response => response.ok ? response.json() : null)
.then(data => {
const mode = data?.settings?.webui_display_mode;
if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode;
}).catch(() => {});
const textWidget = widgets.get('text');
const nativeInput = textWidget.inputEl;
const inputDescriptor = Object.getOwnPropertyDescriptor(textWidget,'inputEl');
const main = document.createElement('textarea'); main.rows = 6; main.spellcheck = nativeInput?.spellcheck ?? false;
// ComfyUI owns widget.element; inputEl exposes our visible editor to extensions.
Object.defineProperty(textWidget,'inputEl',{configurable:true,writable:true,value:main});
scope.cleanup(() => {
if (inputDescriptor) Object.defineProperty(textWidget,'inputEl',inputDescriptor);
else delete textWidget.inputEl;
});
const mainField = createField('Main prompt', main, `${editorId}-text`);
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;
}
const thisNode = this;
const composition = disclosure('composition','Composition · prepend / append');
const metadata = disclosure('metadata','Metadata · category / tags');
for (const [section, fields] of [[composition,[['prepend_text','Prepend'],['append_text','Append']]],[metadata,[['category','Category'],['tags','Tags']]]]) {
const grid = document.createElement('div'); grid.className = 'pm-editor-fields';
for (const [name,label] of fields) {
const input = document.createElement('input'); input.type = 'text'; input.setAttribute('aria-label',label); controls.set(name,input);
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);
}
const composed = disclosure('preview','Composed text preview');
const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview);
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = 'Main prompt is saved when this node runs.';
function isConnected(name) { return thisNode.inputs?.some(input => input.name === name && input.link != null); }
function value(name) { return String(widgets.get(name)?.value ?? ''); }
function write(name, text) {
if (isConnected(name)) return;
const widget = widgets.get(name); widget.value = text;
if (widget.inputEl) widget.inputEl.value = text;
const index = thisNode.widgets.indexOf(widget);
if (thisNode.widgets_values) thisNode.widgets_values[index] = text;
widget.callback?.(text, app.canvas, thisNode);
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
}
function render() {
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
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);
}
});
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
library.refreshAvailability();
}
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => {
if (isConnected('text')) return;
write('text',prompt.text);
if (includeMetadata) {
write('category',prompt.category || '');
write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || '');
}
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
}});
element.append(library.element,footer);
function snapshot() {
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
}
function restore() {
const state = thisNode.properties.focusedUI || {};
details.forEach((section,name) => {section.open = Boolean(state[name]);});
library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
}
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';}));
scope.listen(browse,'click',() => {
library.element.hidden = !library.element.hidden;
library.closeCompletions();
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus();
});
scope.listen(web,'click',() => {
const url = api.fileURL('/prompt_manager/web');
const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes');
footer.setAttribute('role',opened ? 'status' : 'alert');
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
});
this._focusedView = {element,render,restore,snapshot};
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
this.size = [550,650]; this.setSize(this.size); restore();
};
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
prototype.serialize = function (...args) {
if (this._focusedView) this.properties.focusedUI = this._focusedView.snapshot();
return serialize?.apply(this,args);
};
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); };
}
+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};
}
+27
View File
@@ -0,0 +1,27 @@
.pm-focused { inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
.pm-focused [hidden] { display: none !important; }
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
.pm-focused > .ca-field { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
.pm-editor-fields { display: grid; grid-template-columns: minmax(0,1fr); gap: 10px; padding-block: 10px; }
.pm-editor-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
.pm-editor-fields input { inline-size: 100%; }
.pm-editor-preview { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 10px; border-radius: 5px; background: var(--ca-field); }
.pm-editor-status, .pm-editor-connection { color: var(--ca-muted); font-size: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-editor-error { color: var(--ca-error-text); background: var(--ca-error-bg); padding: 8px; border-radius: 5px; }
.pm-editor-library { display: grid; gap: 10px; border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; }
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
.pm-editor-results { display: grid; gap: 6px; max-block-size: 300px; overflow: auto; }
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
.pm-editor-result > span:first-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
.pm-editor-selection { display: grid; gap: 10px; }
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
+107
View File
@@ -0,0 +1,107 @@
import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js';
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
let nextLibraryId = 0;
export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters}) {
const element = document.createElement('section');
element.className = 'pm-editor-library';
element.setAttribute('aria-label', 'Prompt library');
element.hidden = true;
const heading = document.createElement('h3'); heading.textContent = 'Prompt library';
const libraryId = `pm-library-${++nextLibraryId}`;
const queryRow = document.createElement('div'); queryRow.className = 'pm-editor-query';
const query = document.createElement('input'); query.type = 'search'; query.placeholder = 'Search text or tag:portrait'; query.setAttribute('aria-label', 'Search saved prompts');
const search = createButton('Search', 'ca-primary');
const recent = createButton('Recent');
queryRow.append(query, search, recent);
const filters = document.createElement('div'); filters.className = 'pm-editor-fields';
const category = document.createElement('input'); category.type = 'text';
const tags = document.createElement('input'); tags.type = 'text';
filters.append(createField('Filter category',category,`${libraryId}-category`),createField('Filter tags',tags,`${libraryId}-tags`));
const hint = document.createElement('p'); hint.className = 'pm-editor-status'; hint.textContent = 'Search combines text, category and tags. Recent shows all recent prompts.';
const completions = [category,tags].map((input,index) => attachMetadataAutocomplete(input, {
scope,api,settings,multiple:index === 1,available:() => !element.hidden && input.getClientRects().length > 0,
}));
const status = document.createElement('p'); status.className = 'pm-editor-status'; status.setAttribute('role', 'status'); status.textContent = 'Search or browse recent prompts.';
const results = document.createElement('div'); results.className = 'pm-editor-results';
const preview = document.createElement('section'); preview.className = 'pm-editor-selection'; preview.hidden = true;
const previewText = document.createElement('p'); previewText.className = 'pm-editor-preview';
const actions = document.createElement('div'); actions.className = 'pm-editor-row';
const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`);
const use = createButton('Use prompt', 'ca-primary'); use.disabled = true;
actions.append(includeMetadata, use); preview.append(previewText, actions);
element.append(heading, queryRow, filters, hint, status, results, preview);
let selected = null;
let request = null;
let sequence = 0;
let rows = createScope();
scope.cleanup(() => { sequence++; request?.abort(); rows.dispose(); });
function refreshAvailability() { use.disabled = !selected || isConnected(); }
function invalidate() {
sequence++; request?.abort(); selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
status.classList.remove('pm-editor-error'); status.setAttribute('role','status'); status.textContent = 'Search to refresh results.';
}
async function load(recentOnly = false) {
request?.abort(); request = new AbortController();
const current = ++sequence;
selected = null; preview.hidden = true; refreshAvailability();
rows.dispose(); rows = createScope(); results.replaceChildren();
const parsed = parseQuery(query.value);
const text = parsed.text;
const tagFilters = [...new Map([...parsed.tags,...tags.value.split(',')].map(tag => tag.trim()).filter(Boolean).map(tag => [tag.toLowerCase(),tag])).values()];
const categoryFilter = category.value.trim();
status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status');
if (!recentOnly && !text && !tagFilters.length && !categoryFilter) { status.textContent = 'Enter search text, a category or tags.'; return; }
const params = new URLSearchParams(recentOnly ? {limit:'20', sort:'last_used_desc'} : {limit:'50'});
if (!recentOnly) {
if (text) params.set('text', text);
if (tagFilters.length) params.set('tags', tagFilters.join(','));
if (categoryFilter) params.set('category', categoryFilter);
}
status.textContent = 'Loading prompts…';
try {
const response = await fetch(api.fileURL(`/prompt_manager/${recentOnly ? 'recent' : 'search'}?${params}`), {signal:request.signal});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (scope.signal.aborted || current !== sequence) return;
if (!data.success) throw new Error(data.error || 'Could not load prompts');
const found = data.results || [];
status.textContent = found.length ? `${found.length} ${found.length === 1 ? 'prompt' : 'prompts'}` : 'No matching prompts.';
found.forEach(item => {
const button = createButton('', 'pm-editor-result'); button.setAttribute('aria-pressed','false');
const text = document.createElement('span'); text.textContent = item.text;
const metadata = document.createElement('span'); metadata.className = 'pm-editor-result-meta';
metadata.textContent = [item.category, Array.isArray(item.tags) ? item.tags.join(', ') : item.tags, item.rating ? `${item.rating}/5` : ''].filter(Boolean).join(' · ');
button.append(text, metadata);
rows.listen(button, 'click', () => {
selected = item; previewText.textContent = item.text; preview.hidden = false;
results.querySelectorAll('button').forEach(row => row.setAttribute('aria-pressed', String(row === button)));
status.textContent = 'Previewing selection. Editor unchanged.'; refreshAvailability();
});
results.append(button);
});
} catch (error) {
if (scope.signal.aborted || current !== sequence || request.signal.aborted) return;
status.classList.add('pm-editor-error'); status.setAttribute('role','alert'); status.textContent = `Could not load prompts: ${error.message}`;
}
}
scope.listen(search, 'click', () => load());
scope.listen(recent, 'click', () => load(true));
scope.listen(query, 'input', () => {invalidate();onQuery(query.value);});
[query,category,tags].forEach(input => scope.listen(input, 'keydown', event => {
if (event.key === 'Enter' && !event.defaultPrevented && !event.isComposing) { event.preventDefault(); load(); }
}));
[category,tags].forEach(input => scope.listen(input,'input',() => {
invalidate(); status.textContent = 'Filters changed. Search to refresh results.'; onFilters();
}));
scope.listen(use, 'click', () => {
if (!selected || isConnected()) return;
onUse(selected, includeMetadata.querySelector('input').checked);
});
return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; },
filters:() => ({category:category.value,tags:tags.value}),
restoreFilters(state = {}) { invalidate();category.value = state.category || '';tags.value = state.tags || '';completions.forEach(completion => completion.close()); },
closeCompletions() { completions.forEach(completion => completion.close()); },
};
}
+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`;
},
};
}