Compare commits

...
22 changed files with 841 additions and 24 deletions
+8
View File
@@ -142,6 +142,14 @@ _Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professiona
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
for reproducible updates, verification, and automated update PRs.
Prompt Manager appears as **Prompt Manager (Classic)** in the Add Node menu.
It keeps the existing layout and behavior while using shared
[ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit) controls
that follow ComfyUI's light/dark theme. Existing workflows keep the same internal
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).
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+1 -1
View File
@@ -49,7 +49,7 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"PromptManager": "Prompt Manager",
"PromptManager": "Prompt Manager (Classic)",
"PromptManagerText": "Prompt Manager Text",
"PromptSearchList": "Prompt Search List",
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+150
View File
@@ -0,0 +1,150 @@
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 startCoverage(page, browserName);
page.classicErrors = [];
page.on('pageerror', error => page.classicErrors.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(() => {
document.body.replaceChildren();
document.body.style.setProperty('--bg-color','#222');
window.fixtureApp={extensions:[],registerExtension(extension){this.extensions.push(extension);},ui:{settings:new EventTarget()},canvas:{},graph:{setDirtyCanvas(){}}};
});
await page.evaluate(async () => {
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){
this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));
this.widgets_values=[]; this.inputs=[];
}
addDOMWidget(name,type,element){document.body.append(element);return {element};}
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
}
await fixtureApp.extensions[0].beforeRegisterNodeDef(Node,{name:'PromptManager'},fixtureApp);
window.node=new Node();node.onNodeCreated();
});
await expect(page.locator('.pm-classic')).toBeVisible();
});
test.afterEach(async ({page,browserName}) => {
await saveCoverage(page,browserName);
expect(page.classicErrors).toEqual([]);
});
test('Classic keeps immediate selection, search query and metadata serialization',async({page})=>{
await page.route('**/prompt_manager/search?**', route=>route.fulfill({json:{success:true,results:[{text:'A new portrait',category:'Portrait',tags:['film grain'],rating:5}]}}));
await page.evaluate(()=>{node.widgets.find(w=>w.name==='search_text').value='tag:"film grain" portrait';});
const response=page.waitForRequest('**/prompt_manager/search?**');
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
expect(new URL((await response).url()).searchParams.get('tags')).toBe('film grain');
const result=page.getByRole('button',{name:/A new portrait/});
await result.focus();await result.press('Enter');
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,3))).toEqual(['A new portrait','Portrait','film grain']);
await expect(result).toHaveAttribute('aria-pressed','true');
const released=await page.evaluate(()=>{
const previous=node.resultsSection.querySelector('button');
node.displayResults([{text:'Replacement',tags:[]}]);
previous.click();
return node.widgets.find(widget=>widget.name==='text').value;
});
expect(released).toBe('A new portrait');
await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible();
});
test('Classic detaches native text synchronization on removal',async({page})=>{
await page.waitForTimeout(60);
const values=await page.evaluate(()=>{
const widget=node.widgets.find(item=>item.name==='text');
widget.inputEl.value='Before removal';widget.inputEl.dispatchEvent(new Event('input'));
const before=widget.value;node.onRemoved();
widget.inputEl.value='After removal';widget.inputEl.dispatchEvent(new Event('input'));
return [before,widget.value];
});
expect(values).toEqual(['Before removal','Before removal']);
});
test('Classic follows host themes, keeps restore behavior and disposes resources',async({page})=>{
const panel=page.locator('.pm-classic');
await expect(panel).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#fff'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
await page.evaluate(()=>{node.properties.resultTimeout=0;node.displayResults([{text:'Example',tags:[]}]);node.hideResults();});
await page.getByRole('button',{name:/Results auto-hidden/}).click();
await expect(page.getByRole('button',{name:/Example/})).toBeVisible();
const state=await page.evaluate(()=>{
node.showNotification('Failure','error');node.onRemoved();
const disposed=node._classicScope.signal.aborted;
node.showNotification('Late response','info');
return {disposed,notifications:document.querySelectorAll('.pm-classic-notification').length,style:node._styleSheet.isConnected};
});
expect(state).toEqual({disposed:true,notifications:0,style:false});
await page.evaluate(()=>document.body.style.setProperty('--bg-color','#222'));
await expect(panel).toHaveAttribute('data-ca-theme','light');
});
test('Classic handles empty queries, empty results, failed requests and late responses',async({page})=>{
await page.getByRole('button',{name:'🔍 Search',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('Enter search text');
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[]}}));
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('No prompts found');
await page.evaluate(async()=>{
node.callNodeMethod=async()=>{throw new Error('<img src=x onerror=alert(1)>');};
await node.loadRecentPrompts();
});
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('<img src=x');
await expect(page.locator('.pm-classic-results img')).toHaveCount(0);
const late=await page.evaluate(async()=>{
let finish; node.callNodeMethod=()=>new Promise(resolve=>{finish=resolve;});
const loading=node.loadRecentPrompts();node.onRemoved();finish({results:[{text:'Late'}]});await loading;
return {notifications:node._classicNotifications.size,late:node.resultsSection.textContent.includes('Late')};
});
expect(late).toEqual({notifications:0,late:false});
});
test('shipped shared factories keep keyboard behavior and theme isolation across mounts',async({page})=>{
await page.evaluate(async()=>{
const ui=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=ui.createScope();const panel=document.createElement('div');panel.className='ca-tools';panel.id='shared-contract';
window.shared={ui,scope,panel,events:[]};
document.body.append(panel);
ui.mountTheme(panel,null,scope);
const save=ui.createButton('Save contract','ca-primary');scope.listen(save,'click',()=>shared.events.push('save'));
panel.append(save,ui.createSlider('Size',{id:'contract-size',min:0,max:100,step:1,value:50,unit:'px'},scope,value=>shared.events.push(value)),ui.createSwitch('Enabled','','contract-enabled'),ui.createField('Order',ui.createSelect([['name','Name'],['date','Date']],''),'contract-order'),ui.createSegments('View',[['grid','Grid'],['list','List']],'contract-view',scope,value=>shared.events.push(value)));
});
await page.getByRole('button',{name:'Save contract',exact:true}).press('Enter');
await page.getByRole('slider',{name:'Size'}).press('ArrowRight');
await expect(page.locator('#shared-contract output')).toHaveText('51 px');
await page.getByRole('switch',{name:'Enabled'}).uncheck();
await page.getByLabel('Order').selectOption('date');
await page.getByRole('radio',{name:'List',exact:true}).check();
expect(await page.evaluate(()=>shared.events)).toEqual(['save',51,'list']);
await page.evaluate(()=>{document.body.style.removeProperty('--bg-color');fixtureApp.ui.settings.dispatchEvent(new Event('Comfy.ColorPalette.change'));});
await page.emulateMedia({colorScheme:'light'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','light');
await page.emulateMedia({colorScheme:'dark'});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await page.evaluate(()=>{shared.scope.dispose();document.body.style.setProperty('--bg-color','#fff');});
await expect(page.locator('#shared-contract')).toHaveAttribute('data-ca-theme','dark');
await expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme','light');
});
test('Classic test button reports success and failure and releases expired notifications',async({page})=>{
await page.clock.install();
await page.evaluate(()=>{node.properties.showTestButton=true;node.createSearchUI(node._classicContainer);});
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results')).toContainText('API Connection Successful');
await page.route('**/prompt_manager/test',route=>route.fulfill({status:503,body:'unavailable'}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('503');
await page.route('**/prompt_manager/test',route=>route.fulfill({json:{success:false,message:'Not ready'}}));
await page.getByRole('button',{name:'🔧 Test API',exact:true}).click();
await expect(page.locator('.pm-classic-results [role="alert"]')).toContainText('Not ready');
await page.clock.fastForward(3001);
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
});
+37 -11
View File
@@ -55,6 +55,10 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
}, type);
const recent = page.getByRole('button', { name: '📋 Recent', exact: true });
await expect(recent).toBeVisible();
if (type === 'PromptManager') {
await expect(page.locator('.pm-classic')).toBeVisible();
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
}
await recent.click();
await page.getByText(text, { exact: true }).click();
const state = () => page.evaluate(async id => {
@@ -100,6 +104,7 @@ for (const type of ['PromptManager', 'PromptManagerText']) {
test('all node schemas and extension entrypoints load from the real host', async ({ page, request }) => {
const definitions = await (await request.get('/object_info')).json();
expect(definitions.PromptManager.display_name).toBe('Prompt Manager (Classic)');
for (const name of ['PromptManager', 'PromptManagerText', 'PromptSearchList']) expect(definitions[name]?.name).toBe(name);
const extensions = await (await request.get('/extensions')).json();
expect(extensions.filter(path => path.startsWith('/extensions/promptmanager/'))).toEqual(['/extensions/promptmanager/prompt_manager.js']);
@@ -114,20 +119,41 @@ test('all node schemas and extension entrypoints load from the real host', async
expect(result).toEqual({ type: 'PromptSearchList', extension: true });
});
test('node Web UI button opens actual standalone pages', async ({ page }) => {
for (const type of ['PromptManager', 'PromptManagerText']) {
test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => {
await page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
app.canvas.centerOnNode(node);
}, type);
const popupPromise = page.waitForEvent('popup');
await page.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 popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
});
}
test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptManagerText');
const node = LiteGraph.createNode('PromptManager');
app.graph.add(node);
node.setSize([650, 800]);
app.canvas.centerOnNode(node);
});
const popupPromise = page.waitForEvent('popup');
await page.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 popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
await expect(popup.locator('#addPromptBtn')).toBeVisible();
await popup.close();
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 expect(page.locator('.pm-classic')).toHaveAttribute('data-ca-theme', palette);
await expect(page.getByRole('button', { name: '🔍 Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await page.screenshot({ path: testInfo.outputPath(`classic-${palette}.png`) });
}
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/js/shared/toolkit/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { syncToolkit } from '../../tools/vendor/vendor-toolkit.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-toolkit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/@comfyassets/toolkit');
mkdirSync(join(dependency, 'ui'), { recursive: true });
const resolved = 'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/' + 'a'.repeat(40);
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);
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)));
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
assert.equal(JSON.parse(first).integrity, 'sha512-fixture');
syncToolkit({ root: f.root });
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
syncToolkit({ root: f.root, check: true });
writeFileSync(join(f.destination, 'core.js'), 'local edit');
assert.throws(() => syncToolkit({ root: f.root, check: true }), /drift/);
assert.equal(readFileSync(join(f.destination, 'core.js'), 'utf8'), 'local edit');
});
test('toolkit sync rejects moving pins, identity mismatches and missing sources before writing', t => {
const f = fixture(t);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': 'main' } }));
assert.throws(() => syncToolkit({ root: f.root }), /pin/);
const second = fixture(t);
writeFileSync(join(second.dependency, 'package.json'), JSON.stringify({ name: '@comfyassets/toolkit', version: '0.2.0', license: 'MIT' }));
assert.throws(() => syncToolkit({ root: second.root }), /identity/);
const third = fixture(t);
rmSync(join(third.dependency, 'ui/controls.css'));
assert.throws(() => syncToolkit({ root: third.root }), /ENOENT/);
assert.throws(() => readFileSync(join(third.destination, 'core.js')), /ENOENT/);
});
test('toolkit sync rejects lookalike download hosts and archive paths before writing', t => {
for (const prefix of [
'https://codeloadXgithub.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.githubXcom/ComfyAssets/ComfyAssets-Toolkit/tar.gz/',
'https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tarXgz/',
]) {
const f = fixture(t);
const resolved = prefix + 'a'.repeat(40);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { '@comfyassets/toolkit': resolved } }));
writeFileSync(join(f.tooling, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@comfyassets/toolkit': { version: '0.1.0', resolved, integrity: 'sha512-fixture' } } }));
assert.throws(() => syncToolkit({ root: f.root }), /immutable commit pin/);
assert.throws(() => readFileSync(join(f.destination, 'core.js')), /ENOENT/);
}
});
+20 -1
View File
@@ -86,4 +86,23 @@ host orchestration and coverage helpers. `comfyui-host.mjs` is PromptManager's
configuration adapter; keep reusable lifecycle changes in the toolkit source.
The existing npm scripts remain the public maintainer entry points. See
[TESTING.md](../../TESTING.md) for the managed real-host matrix and update policy.
This first integration does not yet ship toolkit UI assets to end users.
## Shared ComfyAssets browser controls
Prompt Manager (Classic) consumes the same pinned toolkit's browser controls.
The node identifier remains `PromptManager`; existing workflows, sockets, and
immediate result selection are preserved. PromptManager Text is unchanged.
Committed assets in `web/js/shared/toolkit/` work without npm or KikoTools installed.
Maintain common controls in [ComfyAssets Toolkit](https://github.com/ComfyAssets/ComfyAssets-Toolkit),
then update the exact archive pin and lockfile here. Regenerate using
`npm run --prefix tools/vendor sync:toolkit`; `npm run --prefix tools/vendor check`
verifies both Viewer.js and the toolkit without rewriting files. The toolkit
distribution includes its license, source history, archive integrity and per-file
hashes. Do not edit its copied files directly. Classic's controller and layout
remain in `web/js/classic-node.js` and `web/js/classic-node.css`.
Run the frontend coverage/browser suites and managed Classic/Nodes 2.0 matrix
after updates. Shared modules participate in the frontend coverage gate.
Pixaroma's reusable framework inspired this architecture; no Pixaroma code,
styles or assets are included.
+12
View File
@@ -100,5 +100,17 @@
"branches": 98,
"functions": 98,
"statements": 98
},
"classic-node.js": {
"lines": 98,
"branches": 79,
"functions": 98,
"statements": 98
},
"shared/toolkit/core.js": {
"lines": 98,
"branches": 96,
"functions": 98,
"statements": 98
}
}
+2 -1
View File
@@ -5,7 +5,8 @@
"description": "Maintainer-only tools for reproducible browser dependencies",
"scripts": {
"sync": "node vendor-viewerjs.mjs",
"check": "node vendor-viewerjs.mjs --check",
"check": "node vendor-viewerjs.mjs --check && node vendor-toolkit.mjs --check",
"sync:toolkit": "node vendor-toolkit.mjs",
"update": "node update-viewerjs.mjs",
"test": "node --test ../../tests/vendor/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
+43
View File
@@ -0,0 +1,43 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot } from './vendor-viewerjs.mjs';
export function syncToolkit({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
const pin = readJson(join(tooling, 'package.json')).devDependencies['@comfyassets/toolkit'];
if (!/^https:\/\/codeload\.github\.com\/ComfyAssets\/ComfyAssets-Toolkit\/tar\.gz\/[a-f0-9]{40}$/.test(pin)) throw new Error('Toolkit needs an immutable commit pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/@comfyassets/toolkit'];
const installed = join(tooling, 'node_modules/@comfyassets/toolkit');
const info = readJson(join(installed, 'package.json'));
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' })) {
const content = readFileSync(join(installed, source));
output.set(name, content);
files[name] = { source, sha256: createHash('sha256').update(content).digest('hex') };
}
output.set('provenance.json', Buffer.from(JSON.stringify({ name: info.name, version: info.version, resolved: pin, integrity: locked.integrity, files }, null, 2) + '\n'));
const destination = join(root, 'web/js/shared/toolkit');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Toolkit browser distribution drift: ${drift.join(', ')}`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return info.version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
if (process.argv.slice(2).some(arg => arg !== '--check')) throw new Error('Usage: vendor-toolkit.mjs [--check]');
console.log(`Toolkit ${syncToolkit({ check: process.argv.includes('--check') })}: verified`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
+19 -10
View File
@@ -2,6 +2,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
// ============================================================================
// Top Bar Button Integration (similar to rgthree-comfy approach)
@@ -302,7 +303,8 @@ app.registerExtension({
// CRITICAL FIX: Ensure text widget always returns current value during serialization
// This fixes the issue where graphToPrompt reads stale cached values
const self = this;
setTimeout(() => {
this._textSyncTimer = setTimeout(() => {
if (self._classicScope?.signal.aborted) return;
const textWidget = self.widgets?.find((w) => w.name === "text");
if (textWidget) {
// Store reference to the widget for the closure
@@ -334,13 +336,16 @@ app.registerExtension({
};
// Sync on every keystroke
textWidget.inputEl.addEventListener('input', syncValue);
const listen = self._classicScope
? (event) => self._classicScope.listen(textWidget.inputEl, event, syncValue)
: (event) => textWidget.inputEl.addEventListener(event, syncValue);
listen('input');
// Sync on change (for paste, autocomplete, etc.)
textWidget.inputEl.addEventListener('change', syncValue);
listen('change');
// Sync when focus leaves the field
textWidget.inputEl.addEventListener('blur', syncValue);
listen('blur');
// Sync on keyup as backup
textWidget.inputEl.addEventListener('keyup', syncValue);
listen('keyup');
// Initial sync
syncValue();
@@ -377,7 +382,7 @@ app.registerExtension({
width: 100%;
height: 100%;
padding: 8px;
background-color: #2a2a2a;
background-color: ${nodeData.name === 'PromptManager' ? 'var(--ca-surface)' : '#2a2a2a'};
border-radius: 4px;
box-sizing: border-box;
display: flex;
@@ -983,6 +988,7 @@ app.registerExtension({
throw new Error(`Unknown method: ${method}`);
}
if (this._classicScope) options.signal = this._classicScope.signal;
const response = await fetch(api.fileURL(url), options);
if (!response.ok) {
@@ -997,10 +1003,12 @@ app.registerExtension({
return { results: data.results || [] };
} catch (error) {
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
if (!this._classicScope?.signal.aborted) {
console.error(
`[PromptManager] API call failed for ${method}:`,
error,
);
}
throw error;
}
};
@@ -1174,6 +1182,7 @@ app.registerExtension({
document.body.removeChild(notification);
}, 3000);
};
if (nodeData.name === 'PromptManager') installClassicControls(nodeType, app, parseSearchQuery, api);
}
},
});
+14
View File
@@ -0,0 +1,14 @@
.pm-classic .pm-classic-workspace { min-block-size: 0; }
.pm-classic .pm-classic-toolbar { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
.pm-classic-toolbar > button { flex: 1; }
.pm-classic .pm-classic-results { flex: 1; min-block-size: 100px; overflow: auto; padding: 8px; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.pm-classic-message { color: var(--ca-muted); text-align: center; padding: 12px; margin: 0; overflow-wrap: anywhere; }
.pm-classic-error { color: var(--ca-error-text); background: var(--ca-error-bg); }
.pm-classic-count { color: var(--ca-text); text-align: center; margin: 0 0 10px; }
.pm-classic button.pm-classic-result { display: flex; flex-direction: column; align-items: stretch; text-align: start; inline-size: 100%; white-space: normal; margin-block-end: 5px; }
.pm-classic-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
.pm-classic-preview { overflow-wrap: anywhere; }
.pm-classic-metadata { color: var(--ca-muted); font-size: 11px; overflow-wrap: anywhere; }
.pm-classic-restore { white-space: normal !important; }
.pm-classic-notification { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; padding: 10px 15px; border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10000; max-inline-size: min(300px, calc(100vw - 40px)); overflow-wrap: anywhere; }
.pm-classic-notification[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
+156
View File
@@ -0,0 +1,156 @@
import { createScope, createButton, mountTheme } from './shared/toolkit/core.js';
function loadStyles() {
const href = new URL('./classic-node.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);
}
function message(node, text, error = false) {
node._classicResultsScope?.dispose();
const element = document.createElement('p');
element.className = error ? 'pm-classic-message pm-classic-error' : 'pm-classic-message';
element.textContent = text;
element.setAttribute('role', error ? 'alert' : 'status');
node.resultsSection.replaceChildren(element);
}
export function installClassicControls(nodeType, app, parseSearchQuery, api) {
const prototype = nodeType.prototype;
const onCreated = prototype.onNodeCreated;
const onRemoved = prototype.onRemoved;
prototype.onNodeCreated = function (...args) {
this._classicScope = createScope();
this._classicNotifications = new Set();
this._classicScope.cleanup(() => {
clearTimeout(this._textSyncTimer);
clearTimeout(this.resultHideTimer);
this._classicResultsScope?.dispose();
for (const item of this._classicNotifications) { clearTimeout(item.timer); item.element.remove(); }
this._classicNotifications.clear();
});
onCreated.apply(this, args);
};
prototype.onRemoved = function (...args) {
this._classicScope?.dispose();
return onRemoved?.apply(this, args);
};
prototype.createSearchUI = function (container) {
loadStyles();
container.classList.add('ca-tools', 'pm-classic');
mountTheme(container, app.ui?.settings, this._classicScope);
this._classicContainer = container;
const wrapper = document.createElement('div');
wrapper.className = 'pm-classic-workspace';
const buttons = document.createElement('div');
buttons.className = 'pm-classic-toolbar';
for (const [label, action, primary] of [
['🔍 Search', () => this.performSearch(), true],
['📋 Recent', () => this.loadRecentPrompts()],
['🌐 Web UI', () => this.openWebInterface()],
]) {
const button = createButton(label, primary ? 'ca-primary' : '');
this._classicScope.listen(button, 'click', action);
buttons.append(button);
}
wrapper.append(buttons);
if (this.properties.showTestButton) {
const test = createButton('🔧 Test API');
this._classicScope.listen(test, 'click', () => this.testApiConnection());
wrapper.append(test);
}
this.resultsSection = document.createElement('div');
this.resultsSection.className = 'pm-classic-results';
wrapper.append(this.resultsSection);
container.replaceChildren(wrapper);
message(this, '💡 Click Search or Recent to find prompts');
};
prototype.performSearch = async function () {
const { text, tags } = parseSearchQuery(this.widgets?.find(widget => widget.name === 'search_text')?.value || '');
if (!text && !tags.length) { message(this, '⚠️ Enter search text or tag:name'); return; }
await this._classicQuery('search_prompts', { search_text: text, tags }, '🔍 Searching database...');
};
prototype.loadRecentPrompts = async function () {
await this._classicQuery('get_recent_prompts', { limit: 20 }, '📋 Loading recent prompts...');
};
prototype._classicQuery = async function (method, params, pending) {
message(this, pending);
try {
const response = await this.callNodeMethod(method, params);
if (this._classicScope.signal.aborted) return;
this.displayResults(response.results || []);
this.showNotification(response.results?.length ? `Found ${response.results.length} prompts` : 'No recent prompts found');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ ${error.message}`, true);
this.showNotification(error.message, 'error');
}
};
prototype.displayResults = function (results) {
if (this._classicScope.signal.aborted) return;
this._searchResults = results || [];
this._selectedPromptIndex = -1;
if (!results?.length) { message(this, '📭 No prompts found'); return; }
this._classicResultsScope?.dispose();
this._classicResultsScope = createScope();
const header = document.createElement('p');
header.className = 'pm-classic-count';
header.textContent = `📚 Found ${results.length} prompt${results.length === 1 ? '' : 's'}`;
this.resultsSection.replaceChildren(header);
results.forEach((result, index) => {
const button = createButton('', 'pm-classic-result');
button.setAttribute('aria-pressed', 'false');
const preview = document.createElement('span');
preview.className = 'pm-classic-preview';
preview.textContent = result.text.length > 80 ? result.text.substring(0, 80) + '...' : result.text;
const metadata = document.createElement('span');
metadata.className = 'pm-classic-metadata';
metadata.textContent = `${result.category || 'No category'} · ${result.rating ? '⭐' + result.rating : 'No rating'} · ${result.created_at ? new Date(result.created_at).toLocaleDateString() : 'Unknown'} · ${Array.isArray(result.tags) ? result.tags.join(', ') : 'No tags'}`;
button.append(preview, metadata);
this._classicResultsScope.listen(button, 'click', () => this.usePrompt(result, index));
this.resultsSection.append(button);
});
this.startAutoHideTimer();
};
prototype.highlightSelectedResult = function (index) {
this._selectedPromptIndex = index;
this.resultsSection.querySelectorAll('.pm-classic-result').forEach((element, i) => element.setAttribute('aria-pressed', String(i === index)));
};
prototype.testApiConnection = async function () {
message(this, '🔧 Testing API connection...');
try {
const response = await fetch(api.fileURL('/prompt_manager/test'), { signal: this._classicScope.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const data = await response.json();
if (!data.success) throw new Error(data.message || 'Test failed');
if (this._classicScope.signal.aborted) return;
message(this, `✅ API Connection Successful! ${data.message || ''} ${data.timestamp || ''}`);
this.showNotification('API connection test passed!', 'success');
} catch (error) {
if (this._classicScope.signal.aborted) return;
message(this, `❌ API Connection Failed: ${error.message}`, true);
this.showNotification(`API test failed: ${error.message}`, 'error');
}
};
prototype.hideResults = function () {
if (this._classicScope.signal.aborted || this.restoreButton) return;
this.resultsSection.style.display = 'none';
this.restoreButton = createButton('🔄 Results auto-hidden · Click to restore', 'pm-classic-restore');
this._classicScope.listen(this.restoreButton, 'click', () => this.showResults());
this.resultsSection.after(this.restoreButton);
};
prototype.showNotification = function (text, type = 'info') {
if (this._classicScope.signal.aborted) return;
const element = document.createElement('div');
element.className = 'ca-tools pm-classic-notification';
element.dataset.caTheme = this._classicContainer.dataset.caTheme;
element.dataset.kind = type;
element.setAttribute('role', type === 'error' ? 'alert' : 'status');
element.textContent = text;
document.body.append(element);
const item = { element, timer: setTimeout(() => { element.remove(); this._classicNotifications.delete(item); }, 3000) };
this._classicNotifications.add(item);
};
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ComfyAssets
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# Initial source provenance
The initial browser controls are extracted from ComfyAssets' own
KikoTools modules at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228`.
KikoTools remains unchanged by this extraction. Future toolkit changes belong
here and are validated in PromptManager before a later KikoTools adoption PR.
| Toolkit file | KikoTools source | Original source SHA-256 |
| --- | --- | --- |
| `ui/core.js` | `web/ui/core.js` | `cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838` |
| `ui/controls.css` | `web/ui/controls.css` | `64d6b0f3059b0a21187796059fe14bfb5358a796a18f775543b7baef43874e80` |
Host orchestration and coverage helpers were extracted and generalized from
KikoTools' maintainer tooling, with isolated tracked-file installation and a
project preparation adapter proven against PromptManager. They have no runtime
imports from either node pack. All original code retains the ComfyAssets MIT
license. No Pixaroma implementation, styles or assets were copied.
`ui/core.js` is unchanged. `ui/controls.css` retains only the shared control,
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`.
+69
View File
@@ -0,0 +1,69 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
}
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@comfyassets/toolkit",
"version": "0.1.0",
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
"files": {
"core.js": {
"source": "ui/core.js",
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
},
"controls.css": {
"source": "ui/controls.css",
"sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "0c865294d0c580dbe365d9ff5ccd655bd2d3716ca3282cc485b5cf789ec8ea6f"
},
"SOURCE.md": {
"source": "SOURCE.md",
"sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9"
}
}
}