Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5160dbe703 | ||
|
|
cde61bffbb | ||
|
|
062a7c3c79 |
@@ -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
@@ -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 |
@@ -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);
|
||||
});
|
||||
@@ -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`) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
Vendored
+62
@@ -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/);
|
||||
}
|
||||
});
|
||||
Vendored
+20
-1
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -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",
|
||||
|
||||
Vendored
+43
@@ -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
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user