Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7827679de | ||
|
|
13c70e849e |
@@ -162,6 +162,12 @@ when the fields are collapsed, so LoRA trigger outputs can connect directly.
|
|||||||
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
|
Encoding, storage, and CONDITIONING/text outputs match Classic. Both layouts
|
||||||
support ComfyUI Classic and Nodes 2.0.
|
support ComfyUI Classic and Nodes 2.0.
|
||||||
|
|
||||||
|
**Prompt Manager (Split)** keeps the same editor and library visible together.
|
||||||
|
Wide nodes place the library beside the editor; narrow nodes stack the panels.
|
||||||
|
It shares Focused's category/tag autocomplete, search filters, preview-before-use,
|
||||||
|
workflow persistence, and connectable prepend/append inputs. Encoding and storage
|
||||||
|
match Classic and Focused.
|
||||||
|
|
||||||
### 🔬 ComfyUI Workflow Analysis
|
### 🔬 ComfyUI Workflow Analysis
|
||||||
|
|
||||||
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ gallery system.
|
|||||||
This module provides prompt node types:
|
This module provides prompt node types:
|
||||||
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
- PromptManager: CLIP encoding node that outputs CONDITIONING
|
||||||
- PromptManagerFocused: Editor-first layout using the same encoding contract
|
- PromptManagerFocused: Editor-first layout using the same encoding contract
|
||||||
|
- PromptManagerSplit: Side-by-side editor and library using the same contract
|
||||||
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
- PromptManagerText: Text-only node that outputs STRING with prepend/append
|
||||||
functionality
|
functionality
|
||||||
|
|
||||||
@@ -39,13 +40,18 @@ def get_version():
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
from .prompt_manager import PromptManager, PromptManagerFocused # noqa: E402
|
from .prompt_manager import ( # noqa: E402
|
||||||
|
PromptManager,
|
||||||
|
PromptManagerFocused,
|
||||||
|
PromptManagerSplit,
|
||||||
|
)
|
||||||
from .prompt_manager_text import PromptManagerText # noqa: E402
|
from .prompt_manager_text import PromptManagerText # noqa: E402
|
||||||
from .prompt_search_list import PromptSearchList # noqa: E402
|
from .prompt_search_list import PromptSearchList # noqa: E402
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"PromptManager": PromptManager,
|
"PromptManager": PromptManager,
|
||||||
"PromptManagerFocused": PromptManagerFocused,
|
"PromptManagerFocused": PromptManagerFocused,
|
||||||
|
"PromptManagerSplit": PromptManagerSplit,
|
||||||
"PromptManagerText": PromptManagerText,
|
"PromptManagerText": PromptManagerText,
|
||||||
"PromptSearchList": PromptSearchList,
|
"PromptSearchList": PromptSearchList,
|
||||||
}
|
}
|
||||||
@@ -53,6 +59,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"PromptManager": "Prompt Manager (Classic)",
|
"PromptManager": "Prompt Manager (Classic)",
|
||||||
"PromptManagerFocused": "Prompt Manager (Focused)",
|
"PromptManagerFocused": "Prompt Manager (Focused)",
|
||||||
|
"PromptManagerSplit": "Prompt Manager (Split)",
|
||||||
"PromptManagerText": "Prompt Manager Text",
|
"PromptManagerText": "Prompt Manager Text",
|
||||||
"PromptSearchList": "Prompt Search List",
|
"PromptSearchList": "Prompt Search List",
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -296,3 +296,7 @@ class PromptManagerFocused(PromptManager):
|
|||||||
{**options, "default": "", "forceInput": True},
|
{**options, "default": "", "forceInput": True},
|
||||||
)
|
)
|
||||||
return inputs
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
class PromptManagerSplit(PromptManagerFocused):
|
||||||
|
"""The persistent library layout shares Focused encoding and storage."""
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import {readFileSync} from 'node:fs';
|
|||||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||||
|
|
||||||
|
for (const variant of ['Focused', 'Split']) {
|
||||||
|
test.describe(variant, () => {
|
||||||
|
const isSplit = variant === 'Split';
|
||||||
|
const uiProperty = isSplit ? 'splitUI' : 'focusedUI';
|
||||||
|
async function openLibrary(page) {
|
||||||
|
if (isSplit) {
|
||||||
|
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button',{name:'Browse library',exact:true})).toHaveCount(0);
|
||||||
|
} else await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
||||||
|
}
|
||||||
|
|
||||||
test.beforeEach(async({page,browserName})=>{
|
test.beforeEach(async({page,browserName})=>{
|
||||||
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
|
await page.addInitScript(()=>Object.defineProperty(Crypto.prototype,'randomUUID',{value:undefined,configurable:true}));
|
||||||
await startCoverage(page,browserName);
|
await startCoverage(page,browserName);
|
||||||
@@ -10,26 +21,26 @@ test.beforeEach(async({page,browserName})=>{
|
|||||||
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('**/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.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.goto('/prompt_manager/test');
|
||||||
await page.evaluate(async()=>{
|
await page.evaluate(async variant=>{
|
||||||
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
|
document.body.replaceChildren();document.body.style.setProperty('--bg-color','#222');
|
||||||
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
|
||||||
await import('/extensions/promptmanager/prompt_manager.js');
|
await import('/extensions/promptmanager/prompt_manager.js');
|
||||||
class Node {
|
class Node {
|
||||||
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
|
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
|
||||||
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
|
||||||
addDOMWidget(name,type,element){document.body.append(element);return {element};}
|
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
|
||||||
setSize(size){this.size=size;}setDirtyCanvas(){}
|
setSize(size){this.size=size;}setDirtyCanvas(){}
|
||||||
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
serialize(){return {widgets_values:this.widgets.map(widget=>widget.value),properties:{...this.properties}};}
|
||||||
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
|
configure(data){this.widgets.forEach((widget,index)=>{widget.value=data.widgets_values[index];});this.properties={...data.properties};}
|
||||||
}
|
}
|
||||||
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:'PromptManagerFocused'},fixtureApp);
|
await fixtureApp.extension.beforeRegisterNodeDef(Node,{name:`PromptManager${variant}`},fixtureApp);
|
||||||
window.node=new Node();node.onNodeCreated();
|
window.node=new Node();node.onNodeCreated();
|
||||||
});
|
},variant);
|
||||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||||
});
|
});
|
||||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.focusedErrors).toEqual([]);});
|
||||||
|
|
||||||
test('Focused library autocompletes filters and searches without changing editor metadata', async ({page}) => {
|
test(`${variant} library autocompletes filters and searches without changing editor metadata`, async ({page}) => {
|
||||||
const requests=[];
|
const requests=[];
|
||||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait']}}));
|
||||||
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
|
await page.route('**/prompt_manager/tags/suggest?**',route=>route.fulfill({json:{success:true,suggestions:[{name:'studio'}]}}));
|
||||||
@@ -38,7 +49,7 @@ test('Focused library autocompletes filters and searches without changing editor
|
|||||||
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
|
return route.fulfill({json:{success:true,results:[{text:'Filtered portrait',category:'Portrait',tags:['studio']}]}});
|
||||||
});
|
});
|
||||||
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
|
await page.getByLabel('Main prompt',{exact:true}).fill('My draft');
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
const category=page.getByLabel('Filter category',{exact:true});
|
const category=page.getByLabel('Filter category',{exact:true});
|
||||||
await category.fill('por');
|
await category.fill('por');
|
||||||
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
await expect(page.getByRole('option',{name:'Portrait',exact:true})).toBeVisible();
|
||||||
@@ -60,7 +71,7 @@ test('Focused library autocompletes filters and searches without changing editor
|
|||||||
await category.fill('Landscape');
|
await category.fill('Landscape');
|
||||||
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
|
await expect(page.locator('.pm-editor-selection button')).toBeDisabled();
|
||||||
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
|
const saved=await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);return saved;});
|
||||||
expect(saved.properties.focusedUI.libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
expect(saved.properties[uiProperty].libraryFilters).toEqual({category:'Landscape',tags:'studio, '});
|
||||||
await expect(category).toHaveValue('Landscape');
|
await expect(category).toHaveValue('Landscape');
|
||||||
await expect(tags).toHaveValue('studio, ');
|
await expect(tags).toHaveValue('studio, ');
|
||||||
let recentRequest;
|
let recentRequest;
|
||||||
@@ -69,14 +80,15 @@ test('Focused library autocompletes filters and searches without changing editor
|
|||||||
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
|
await expect.poll(()=>Boolean(recentRequest)).toBe(true);
|
||||||
expect(recentRequest.searchParams.has('category')).toBe(false);
|
expect(recentRequest.searchParams.has('category')).toBe(false);
|
||||||
expect(recentRequest.searchParams.has('tags')).toBe(false);
|
expect(recentRequest.searchParams.has('tags')).toBe(false);
|
||||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
if (isSplit) await page.getByRole('searchbox',{name:'Search saved prompts'}).focus();
|
||||||
|
else await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||||
await expect(page.getByRole('listbox')).toBeHidden();
|
await expect(page.getByRole('listbox')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused library can search by filters alone and Enter searches after autocomplete closes', async ({page}) => {
|
test(`${variant} library can search by filters alone and Enter searches after autocomplete closes`, async ({page}) => {
|
||||||
const requests=[];
|
const requests=[];
|
||||||
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
|
await page.route('**/prompt_manager/search?**',route=>{requests.push(new URL(route.request().url()));return route.fulfill({json:{success:true,results:[]}});});
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
await page.getByLabel('Filter category',{exact:true}).fill('New category');
|
await page.getByLabel('Filter category',{exact:true}).fill('New category');
|
||||||
await page.getByLabel('Filter category',{exact:true}).press('Enter');
|
await page.getByLabel('Filter category',{exact:true}).press('Enter');
|
||||||
await expect.poll(()=>requests.length).toBe(1);
|
await expect.poll(()=>requests.length).toBe(1);
|
||||||
@@ -88,7 +100,7 @@ test('Focused library can search by filters alone and Enter searches after autoc
|
|||||||
expect(requests[1].searchParams.get('tags')).toBe('custom');
|
expect(requests[1].searchParams.get('tags')).toBe('custom');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused completes saved tags with keyboard and pointer and appends commas', async ({page}) => {
|
test(`${variant} completes saved tags with keyboard and pointer and appends commas`, async ({page}) => {
|
||||||
await page.route('**/prompt_manager/tags/suggest?**', route => {
|
await page.route('**/prompt_manager/tags/suggest?**', route => {
|
||||||
const q = new URL(route.request().url()).searchParams.get('q');
|
const q = new URL(route.request().url()).searchParams.get('q');
|
||||||
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
|
return route.fulfill({json:{success:true,suggestions:(q === 'bl' ? ['blue eyes','blue sky'] : ['portrait','blue eyes']).map(name => ({name}))}});
|
||||||
@@ -118,7 +130,7 @@ test('Focused completes saved tags with keyboard and pointer and appends commas'
|
|||||||
await expect(tags).toHaveValue('A brand new tag');
|
await expect(tags).toHaveValue('A brand new tag');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused metadata autocomplete ignores stale responses, recovers from errors and cleans up', async ({page}) => {
|
test(`${variant} metadata autocomplete ignores stale responses, recovers from errors and cleans up`, async ({page}) => {
|
||||||
let release;
|
let release;
|
||||||
let mode='delayed';
|
let mode='delayed';
|
||||||
await page.route('**/prompt_manager/tags/suggest?**', async route => {
|
await page.route('**/prompt_manager/tags/suggest?**', async route => {
|
||||||
@@ -146,7 +158,7 @@ test('Focused metadata autocomplete ignores stale responses, recovers from error
|
|||||||
await expect(page.getByRole('listbox')).toHaveCount(0);
|
await expect(page.getByRole('listbox')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused completes one category, allows new values and dismisses suggestions on blur', async ({page}) => {
|
test(`${variant} completes one category, allows new values and dismisses suggestions on blur`, async ({page}) => {
|
||||||
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
|
await page.route('**/prompt_manager/categories',route=>route.fulfill({json:{success:true,categories:['Portrait','Landscape','Portrait studio']}}));
|
||||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||||
const category=page.getByLabel('Category',{exact:true});
|
const category=page.getByLabel('Category',{exact:true});
|
||||||
@@ -168,7 +180,7 @@ test('Focused completes one category, allows new values and dismisses suggestion
|
|||||||
await expect(category).toHaveValue('New category');
|
await expect(category).toHaveValue('New category');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused shares suggestion controls safely with another bundled toolkit copy', async ({page}) => {
|
test(`${variant} shares suggestion controls safely with another bundled toolkit copy`, async ({page}) => {
|
||||||
await page.evaluate(async()=>{
|
await page.evaluate(async()=>{
|
||||||
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||||
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
|
const first=await import('/prompt_manager/js/shared/toolkit/suggestions.js?pack=first');
|
||||||
@@ -196,10 +208,10 @@ test('Focused shares suggestion controls safely with another bundled toolkit cop
|
|||||||
await expect(lists).toHaveCount(0);
|
await expect(lists).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused previews before loading, keeps metadata optional and composes text',async({page})=>{
|
test(`${variant} previews before loading, keeps metadata optional and composes text`,async({page})=>{
|
||||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
|
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:1,text:'Library portrait',category:'Portrait',tags:['studio']}]}}));
|
||||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
|
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('My draft');
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
await page.getByRole('button',{name:/Library portrait/}).click();
|
await page.getByRole('button',{name:/Library portrait/}).click();
|
||||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
|
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('My draft');
|
||||||
@@ -214,7 +226,7 @@ test('Focused previews before loading, keeps metadata optional and composes text
|
|||||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
|
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,6))).toEqual(['Library portrait','','','','trigger','detailed']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused restores workflow values and disclosures and protects connected text',async({page})=>{
|
test(`${variant} restores workflow values and disclosures and protects connected text`,async({page})=>{
|
||||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
|
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Saved draft');
|
||||||
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
await page.getByText('Metadata · category / tags',{exact:true}).click();
|
||||||
await page.getByLabel('Category',{exact:true}).fill('Saved category');
|
await page.getByLabel('Category',{exact:true}).fill('Saved category');
|
||||||
@@ -228,14 +240,14 @@ test('Focused restores workflow values and disclosures and protects connected te
|
|||||||
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
|
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused searches quoted tags with Enter and explicitly loads metadata', async ({page}) => {
|
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
|
||||||
let requested;
|
let requested;
|
||||||
await page.route('**/prompt_manager/search?**', async route => {
|
await page.route('**/prompt_manager/search?**', async route => {
|
||||||
requested = new URL(route.request().url());
|
requested = new URL(route.request().url());
|
||||||
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
|
await route.fulfill({json:{success:true,results:[{id:7,text:'Selected fox',category:'Animals',tags:['film grain','fox']}]}});
|
||||||
});
|
});
|
||||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
|
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Preserved draft');
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
|
const query=page.getByRole('searchbox',{name:'Search saved prompts'});
|
||||||
await query.fill('fox tag:"film grain"');
|
await query.fill('fox tag:"film grain"');
|
||||||
await query.press('Enter');
|
await query.press('Enter');
|
||||||
@@ -250,7 +262,7 @@ test('Focused searches quoted tags with Enter and explicitly loads metadata', as
|
|||||||
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
|
expect(await page.evaluate(()=>node.serialize().widgets_values.slice(0,4))).toEqual(['Selected fox','Animals','film grain, fox','fox tag:"film grain"']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused reports empty queries, no results, and safely renders backend and HTTP errors', async ({page}) => {
|
test(`${variant} reports empty queries, no results, and safely renders backend and HTTP errors`, async ({page}) => {
|
||||||
let requests=0;
|
let requests=0;
|
||||||
let mode='empty';
|
let mode='empty';
|
||||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||||
@@ -259,7 +271,7 @@ test('Focused reports empty queries, no results, and safely renders backend and
|
|||||||
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
|
if(mode==='http') return route.fulfill({status:503,body:'Unavailable'});
|
||||||
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
|
await route.fulfill({json:mode==='error'?{success:false,error:malicious}:{success:true,results:[]}});
|
||||||
});
|
});
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
const search=page.getByRole('button',{name:'Search',exact:true});
|
const search=page.getByRole('button',{name:'Search',exact:true});
|
||||||
await search.click();
|
await search.click();
|
||||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('Enter search text, a category or tags.');
|
||||||
@@ -287,10 +299,10 @@ test('Focused reports empty queries, no results, and safely renders backend and
|
|||||||
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
await expect(page.locator('.pm-editor-library [role="status"]')).toHaveText('No matching prompts.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused treats saved prompt content and metadata as text', async ({page}) => {
|
test(`${variant} treats saved prompt content and metadata as text`, async ({page}) => {
|
||||||
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
const malicious='<img src=x onerror="window.focusedInjected=true">';
|
||||||
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
|
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{id:8,text:malicious,category:'<script>bad()</script>',tags:['<svg onload=bad()>']}]}}));
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
await page.locator('.pm-editor-result').click();
|
await page.locator('.pm-editor-result').click();
|
||||||
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
|
await expect(page.locator('.pm-editor-selection .pm-editor-preview')).toHaveText(malicious);
|
||||||
@@ -312,9 +324,9 @@ async function deferLibraryRequests(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Focused keeps the latest result when searches and recent requests overlap', async ({page}) => {
|
test(`${variant} keeps the latest result when searches and recent requests overlap`, async ({page}) => {
|
||||||
await deferLibraryRequests(page);
|
await deferLibraryRequests(page);
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
|
await page.getByRole('searchbox',{name:'Search saved prompts'}).fill('older search');
|
||||||
await page.getByRole('button',{name:'Search',exact:true}).click();
|
await page.getByRole('button',{name:'Search',exact:true}).click();
|
||||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
@@ -333,9 +345,9 @@ test('Focused keeps the latest result when searches and recent requests overlap'
|
|||||||
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
await expect(page.locator('.pm-editor-result')).toHaveText('Latest search');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused removal aborts work and stops theme and control listeners', async ({page}) => {
|
test(`${variant} removal aborts work and stops theme and control listeners`, async ({page}) => {
|
||||||
await deferLibraryRequests(page);
|
await deferLibraryRequests(page);
|
||||||
await page.getByRole('button',{name:'Browse library',exact:true}).click();
|
await openLibrary(page);
|
||||||
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||||
const removed=await page.evaluate(()=>{
|
const removed=await page.evaluate(()=>{
|
||||||
@@ -348,11 +360,18 @@ test('Focused removal aborts work and stops theme and control listeners', async
|
|||||||
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
|
await page.evaluate(()=>completeLibraryRequest(0,'Late result after removal'));
|
||||||
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
await expect(page.locator('.pm-editor-result')).toHaveCount(0);
|
||||||
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
await expect(page.locator('.pm-focused')).toHaveAttribute('data-ca-theme','dark');
|
||||||
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
if (isSplit) {
|
||||||
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
const count=await page.evaluate(()=>libraryRequests.length);
|
||||||
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
|
expect(await page.evaluate(()=>libraryRequests.length)).toBe(count);
|
||||||
|
await expect(page.locator('.pm-editor-library')).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await page.getByRole('button',{name:'Hide library',exact:true}).click();
|
||||||
|
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toBeVisible();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused fields reflow with node width and follow live host themes', async ({page}) => {
|
test(`${variant} fields reflow with node width and follow live host themes`, async ({page}) => {
|
||||||
await page.setViewportSize({width:1000,height:1000});
|
await page.setViewportSize({width:1000,height:1000});
|
||||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||||
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
|
await page.evaluate(()=>{node._focusedView.element.style.width='700px';node._focusedView.element.style.height='850px';});
|
||||||
@@ -373,7 +392,7 @@ test('Focused fields reflow with node width and follow live host themes', async
|
|||||||
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
|
await expect.poll(()=>prepend.evaluate(element=>getComputedStyle(element).backgroundColor)).toBe(dark);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused composed preview preserves main prompt whitespace like queued execution', async ({page}) => {
|
test(`${variant} composed preview preserves main prompt whitespace like queued execution`, async ({page}) => {
|
||||||
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
|
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill(' draft ');
|
||||||
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
await page.getByText('Composition · prepend / append',{exact:true}).click();
|
||||||
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
|
await page.getByLabel('Prepend',{exact:true}).fill(' trigger ');
|
||||||
@@ -382,17 +401,17 @@ test('Focused composed preview preserves main prompt whitespace like queued exec
|
|||||||
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
|
expect(await page.locator('[data-composed-preview]').textContent()).toBe('trigger draft detail');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused exposes the visible text element for autocomplete and host extension listeners', async ({page}) => {
|
test(`${variant} exposes the visible text element for autocomplete and host extension listeners`, async ({page}) => {
|
||||||
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
|
expect(await page.evaluate(()=>node.widgets.find(widget=>widget.name==='text').inputEl===document.querySelector('.pm-focused textarea'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused expands its main editor when the node gets taller',async({page})=>{
|
test(`${variant} expands its main editor when the node gets taller`,async({page})=>{
|
||||||
await page.locator('.pm-focused').evaluate(element=>{element.style.width='550px';element.style.height='800px';});
|
await page.locator('.pm-focused').evaluate((element,isSplit)=>{element.style.width=isSplit?'900px':'550px';element.style.height='800px';},isSplit);
|
||||||
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
const editor=await page.getByRole('textbox',{name:'Main prompt',exact:true}).boundingBox();
|
||||||
expect(editor.height).toBeGreaterThan(400);
|
expect(editor.height).toBeGreaterThan(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused respects the existing Web UI display preference',async({page})=>{
|
test(`${variant} respects the existing Web UI display preference`,async({page})=>{
|
||||||
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
|
await page.route('**/prompt_manager/settings',route=>route.fulfill({json:{success:true,settings:{webui_display_mode:'newtab'}}}));
|
||||||
await page.evaluate(()=>{
|
await page.evaluate(()=>{
|
||||||
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
|
const Node=node.constructor;node.onRemoved();node._focusedView.element.remove();node=new Node();node.onNodeCreated();
|
||||||
@@ -403,9 +422,68 @@ test('Focused respects the existing Web UI display preference',async({page})=>{
|
|||||||
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused reports blocked Web UI popups without leaving the workflow',async({page})=>{
|
test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{
|
||||||
await page.evaluate(()=>{window.open=()=>null;});
|
await page.evaluate(()=>{window.open=()=>null;});
|
||||||
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
await page.getByRole('button',{name:'Web UI ↗',exact:true}).click();
|
||||||
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
|
await expect(page.locator('.pm-focused [role="alert"]')).toContainText('Allow popups');
|
||||||
await expect(page.locator('.pm-focused')).toBeVisible();
|
await expect(page.locator('.pm-focused')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isSplit) test('Split keeps the library beside the editor when wide and stacks it when narrow', async ({page}) => {
|
||||||
|
await page.setViewportSize({width:1200,height:1000});
|
||||||
|
const root=page.locator('.pm-split');
|
||||||
|
await expect(root).toHaveAttribute('aria-label','Split prompt editor');
|
||||||
|
await expect(page.getByRole('button',{name:'Hide library',exact:true})).toHaveCount(0);
|
||||||
|
await root.evaluate(element=>{element.style.width='900px';element.style.height='800px';});
|
||||||
|
const compose=root.locator('.pm-editor-workspace > .pm-editor-compose');
|
||||||
|
const library=root.locator('.pm-editor-workspace > .pm-editor-library');
|
||||||
|
await expect(compose.locator('.pm-editor-main')).toBeVisible();
|
||||||
|
let a=await compose.boundingBox(),b=await library.boundingBox();
|
||||||
|
expect(Math.abs(a.y-b.y)).toBeLessThan(2);
|
||||||
|
expect(b.x).toBeGreaterThanOrEqual(a.x+a.width);
|
||||||
|
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Library example'}]}}));
|
||||||
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
|
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||||
|
await page.getByRole('textbox',{name:'Main prompt',exact:true}).fill('Editing beside the library');
|
||||||
|
await expect(library).toBeVisible();
|
||||||
|
await expect(page.locator('.pm-editor-result')).toContainText('Library example');
|
||||||
|
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Editing beside the library');
|
||||||
|
await root.evaluate(element=>{element.style.width='340px';});
|
||||||
|
await expect.poll(async()=>{const a=await compose.boundingBox(),b=await library.boundingBox();return b.y>=a.y+a.height;}).toBe(true);
|
||||||
|
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||||
|
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Editing beside the library');
|
||||||
|
await expect(library).toBeVisible();
|
||||||
|
});
|
||||||
|
if (isSplit) test('Split bounds long recent results and selected previews at the default node size', async ({page}) => {
|
||||||
|
await page.setViewportSize({width:1200,height:1000});
|
||||||
|
const root=page.locator('.pm-split');
|
||||||
|
await root.evaluate(element=>{element.style.width='900px';element.style.height='650px';});
|
||||||
|
const longPrompt='Long saved prompt '+('detailed portrait in a studio with natural light '.repeat(110));
|
||||||
|
const prompts=Array.from({length:20},(_,index)=>({id:index+1,text:index===0?longPrompt:`Saved prompt ${index+1}`,category:'Portrait',tags:['studio']}));
|
||||||
|
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:prompts}}));
|
||||||
|
await page.getByRole('button',{name:'Recent',exact:true}).click();
|
||||||
|
const results=root.locator('.pm-editor-results');
|
||||||
|
await expect(results.locator('.pm-editor-result')).toHaveCount(20);
|
||||||
|
const rowHeights=await results.locator('.pm-editor-result > span:first-child').evaluateAll(elements=>elements.map(element=>element.getBoundingClientRect().height));
|
||||||
|
expect(rowHeights.every(height=>height>0)).toBe(true);
|
||||||
|
const resultSize=await results.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||||
|
expect.soft(resultSize.height).toBeLessThanOrEqual(300);
|
||||||
|
expect(resultSize.scroll).toBeGreaterThan(resultSize.client);
|
||||||
|
await results.locator('.pm-editor-result').first().click();
|
||||||
|
const preview=root.locator('.pm-editor-selection > .pm-editor-preview');
|
||||||
|
await expect(preview).toHaveText(longPrompt);
|
||||||
|
const previewSize=await preview.evaluate(element=>({height:element.getBoundingClientRect().height,scroll:element.scrollHeight,client:element.clientHeight}));
|
||||||
|
expect.soft(previewSize.height).toBeLessThanOrEqual(180);
|
||||||
|
expect.soft(previewSize.scroll).toBeGreaterThan(previewSize.client);
|
||||||
|
await page.getByRole('button',{name:'Use prompt',exact:true}).click();
|
||||||
|
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue(longPrompt);
|
||||||
|
const bounds=await root.boundingBox();
|
||||||
|
for (const selector of ['.pm-editor-compose','.pm-editor-library']) {
|
||||||
|
const pane=await root.locator(selector).boundingBox();
|
||||||
|
expect(pane.y).toBeGreaterThanOrEqual(bounds.y);
|
||||||
|
expect(pane.y+pane.height).toBeLessThanOrEqual(bounds.y+bounds.height);
|
||||||
|
}
|
||||||
|
expect(await root.evaluate(element=>element.scrollWidth<=element.clientWidth+1)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||||
import { installHostHooks } from './host-fixture.mjs';
|
import { installHostHooks } from './host-fixture.mjs';
|
||||||
|
|
||||||
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptSearchList', 'PromptManagerFocused']);
|
||||||
|
|
||||||
async function addFocused(page) {
|
async function addEditor(page, type) {
|
||||||
return page.evaluate(async () => {
|
await page.waitForFunction(type => Boolean(globalThis.LiteGraph?.registered_node_types?.[type]), type);
|
||||||
|
return page.evaluate(async type => {
|
||||||
const { app } = await import('/scripts/app.js');
|
const { app } = await import('/scripts/app.js');
|
||||||
const node = LiteGraph.createNode('PromptManagerFocused');
|
const node = LiteGraph.createNode(type);
|
||||||
if (!node) throw new Error('PromptManagerFocused must be registered by the real host');
|
if (!node) throw new Error(`${type} must be registered by the real host`);
|
||||||
app.graph.add(node);
|
app.graph.add(node);
|
||||||
node.setSize([680, 1050]);
|
node.setSize([type === 'PromptManagerSplit' ? 1100 : 680, 1050]);
|
||||||
app.canvas.ds.scale = .8;
|
app.canvas.ds.scale = .8;
|
||||||
app.canvas.centerOnNode(node);
|
app.canvas.centerOnNode(node);
|
||||||
return node.id;
|
return node.id;
|
||||||
});
|
}, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function model(page, id) {
|
async function model(page, id) {
|
||||||
@@ -29,19 +31,26 @@ async function seed(request, text, category = 'Focused host tests', tags = ['foc
|
|||||||
expect((await response.json()).success).toBe(true);
|
expect((await response.json()).success).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showLibrary(page) {
|
||||||
|
const button = page.locator('.pm-focused').getByRole('button', { name: 'Browse library', exact: true });
|
||||||
|
if (await button.count()) await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
async function browse(page, text) {
|
async function browse(page, text) {
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
await showLibrary(page);
|
||||||
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('Focused definition preserves Classic encoding and exposes composition STRING sockets', async ({ request }) => {
|
for (const [label, type] of [['Focused', 'PromptManagerFocused'], ['Split', 'PromptManagerSplit']]) {
|
||||||
|
test.describe(label, () => {
|
||||||
|
test(`${label} definition preserves Classic encoding and exposes composition STRING sockets`, async ({ request }) => {
|
||||||
const definitions = await (await request.get('/object_info')).json();
|
const definitions = await (await request.get('/object_info')).json();
|
||||||
const focused = definitions.PromptManagerFocused;
|
const focused = definitions[type];
|
||||||
expect(focused).toBeDefined();
|
expect(focused).toBeDefined();
|
||||||
expect(focused.display_name).toBe('Prompt Manager (Focused)');
|
expect(focused.display_name).toBe(`Prompt Manager (${label})`);
|
||||||
expect(focused.output).toEqual(definitions.PromptManager.output);
|
expect(focused.output).toEqual(definitions.PromptManager.output);
|
||||||
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
expect(focused.input.required).toEqual(definitions.PromptManager.input.required);
|
||||||
for (const name of ['prepend_text', 'append_text']) {
|
for (const name of ['prepend_text', 'append_text']) {
|
||||||
@@ -50,10 +59,10 @@ test('Focused definition preserves Classic encoding and exposes composition STRI
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused preview, explicit load, composition, renderer changes and workflow reload preserve state', async ({ page, request }, testInfo) => {
|
test(`${label} preview, explicit load, composition, renderer changes and workflow reload preserve state`, async ({ page, request }, testInfo) => {
|
||||||
const text = `Focused selected ${testInfo.project.name}`;
|
const text = `Focused selected ${testInfo.project.name}`;
|
||||||
await seed(request, text);
|
await seed(request, text);
|
||||||
const id = await addFocused(page);
|
const id = await addEditor(page, type);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||||
await expect(root).toBeVisible();
|
await expect(root).toBeVisible();
|
||||||
@@ -92,20 +101,20 @@ test('Focused preview, explicit load, composition, renderer changes and workflow
|
|||||||
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
expect(queued).toMatchObject({ text, category: 'Focused host tests', tags: 'focused-host', prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||||
await expect(editor).toHaveValue(text);
|
await expect(editor).toHaveValue(text);
|
||||||
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
await expect.poll(() => model(page, id)).toMatchObject({ text, prepend_text: 'LoRA trigger', append_text: 'high detail' });
|
||||||
await page.screenshot({ path: testInfo.outputPath('focused-reloaded.png') });
|
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-reloaded.png`) });
|
||||||
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
|
||||||
await expect(root).toHaveCount(0);
|
await expect(root).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused search preview respects metadata opt-out', async ({ page, request }, testInfo) => {
|
test(`${label} search preview respects metadata opt-out`, async ({ page, request }, testInfo) => {
|
||||||
const text = `Focused search ${testInfo.project.name}`;
|
const text = `Focused search ${testInfo.project.name}`;
|
||||||
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
await seed(request, text, 'Incoming category', ['incoming-tag']);
|
||||||
const id = await addFocused(page);
|
const id = await addEditor(page, type);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Keep category');
|
||||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('keep-tag');
|
||||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
await showLibrary(page);
|
||||||
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
await root.getByRole('searchbox', { name: 'Search saved prompts', exact: true }).fill(text);
|
||||||
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
await root.getByRole('button', { name: 'Search', exact: true }).click();
|
||||||
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
await root.locator('.pm-editor-results').getByRole('button').filter({ has: page.getByText(text, { exact: true }) }).click();
|
||||||
@@ -114,10 +123,10 @@ test('Focused search preview respects metadata opt-out', async ({ page, request
|
|||||||
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
await expect.poll(() => model(page, id)).toMatchObject({ text, category: 'Keep category', tags: 'keep-tag' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused connected STRING inputs remain linked through UI actions and reload', async ({ page, request }, testInfo) => {
|
test(`${label} connected STRING inputs remain linked through UI actions and reload`, async ({ page, request }, testInfo) => {
|
||||||
const text = `Connected library ${testInfo.project.name}`;
|
const text = `Connected library ${testInfo.project.name}`;
|
||||||
await seed(request, text);
|
await seed(request, text);
|
||||||
const id = await addFocused(page);
|
const id = await addEditor(page, type);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Local main');
|
||||||
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
await root.getByText('Composition · prepend / append', { exact: true }).click();
|
||||||
@@ -179,11 +188,11 @@ test('Focused connected STRING inputs remain linked through UI actions and reloa
|
|||||||
await expect(input).toBeEditable();
|
await expect(input).toBeEditable();
|
||||||
await expect(input).toHaveValue(value);
|
await expect(input).toHaveValue(value);
|
||||||
}
|
}
|
||||||
await page.screenshot({ path: testInfo.outputPath('focused-disconnected.png') });
|
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-disconnected.png`) });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused follows actual ComfyUI light and dark palettes', async ({ page }, testInfo) => {
|
test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, testInfo) => {
|
||||||
await addFocused(page);
|
await addEditor(page, type);
|
||||||
for (const palette of ['light', 'dark']) {
|
for (const palette of ['light', 'dark']) {
|
||||||
await page.evaluate(async palette => {
|
await page.evaluate(async palette => {
|
||||||
const { app } = await import('/scripts/app.js');
|
const { app } = await import('/scripts/app.js');
|
||||||
@@ -191,30 +200,35 @@ test('Focused follows actual ComfyUI light and dark palettes', async ({ page },
|
|||||||
}, palette);
|
}, palette);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
await expect(root).toHaveAttribute('data-ca-theme', palette);
|
||||||
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
if (label === 'Split') {
|
||||||
await page.screenshot({ path: testInfo.outputPath(`focused-${palette}.png`) });
|
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||||
|
await expect(root.getByRole('button', { name: 'Search', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||||
|
} else {
|
||||||
|
await expect(root.getByRole('button', { name: 'Browse library', exact: true })).toHaveCSS('background-color', 'rgb(8, 119, 187)');
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-${palette}.png`) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused Web UI button opens actual standalone pages', async ({ page }) => {
|
test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => {
|
||||||
await addFocused(page);
|
await addEditor(page, type);
|
||||||
const popupPromise = page.waitForEvent('popup');
|
const popupPromise = page.waitForEvent('popup');
|
||||||
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click();
|
||||||
const popup = await popupPromise;
|
const popup = await popupPromise;
|
||||||
await popup.waitForLoadState('domcontentloaded');
|
await popup.waitForLoadState('domcontentloaded');
|
||||||
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/);
|
||||||
await expect(popup.locator('body')).toContainText('Prompt');
|
await expect(popup.locator('body')).toContainText('Prompt');
|
||||||
await expect(page.locator('.pm-focused').getByRole('status')).toHaveText('Web UI opened.');
|
await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.');
|
||||||
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
await popup.goto(new URL('/prompt_manager/gallery', page.url()).href);
|
||||||
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
await expect(popup.locator('#addPromptBtn')).toBeVisible();
|
||||||
await popup.close();
|
await popup.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused metadata completion uses saved categories and tags across renderers and reload', async ({ page, request }, testInfo) => {
|
test(`${label} metadata completion uses saved categories and tags across renderers and reload`, async ({ page, request }, testInfo) => {
|
||||||
const category = `Host completion category ${testInfo.project.name}`;
|
const category = `Host completion category ${testInfo.project.name}`;
|
||||||
const tag = `host-completion-${testInfo.project.name}`;
|
const tag = `host-completion-${testInfo.project.name}`;
|
||||||
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
await seed(request, `Saved autocomplete ${testInfo.project.name}`, category, [tag]);
|
||||||
const id = await addFocused(page);
|
const id = await addEditor(page, type);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Autocomplete workflow');
|
||||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||||
@@ -244,7 +258,7 @@ test('Focused metadata completion uses saved categories and tags across renderer
|
|||||||
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
expect(bounds.y).toBeGreaterThanOrEqual(0);
|
||||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width);
|
||||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height);
|
||||||
await page.screenshot({ path: testInfo.outputPath(`focused-metadata-${palette}.png`) });
|
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-metadata-${palette}.png`) });
|
||||||
await option.click();
|
await option.click();
|
||||||
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
await expect(tagInput).toHaveValue(`keep, ${tag}, `);
|
||||||
}
|
}
|
||||||
@@ -279,20 +293,20 @@ test('Focused metadata completion uses saved categories and tags across renderer
|
|||||||
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
await expect(page.getByRole('listbox', { name: 'Saved tags', includeHidden: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Focused library autocomplete filters saved prompts without editing draft metadata', async ({ page, request }, testInfo) => {
|
test(`${label} library autocomplete filters saved prompts without editing draft metadata`, async ({ page, request }, testInfo) => {
|
||||||
const category = `Library category ${testInfo.project.name}`;
|
const category = `Library category ${testInfo.project.name}`;
|
||||||
const tag = `library-filter-${testInfo.project.name}`;
|
const tag = `library-filter-${testInfo.project.name}`;
|
||||||
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
const matchingText = `Matching library prompt ${testInfo.project.name}`;
|
||||||
await seed(request, matchingText, category, [tag]);
|
await seed(request, matchingText, category, [tag]);
|
||||||
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
await seed(request, `Wrong category ${testInfo.project.name}`, `Other category ${testInfo.project.name}`, [tag]);
|
||||||
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
await seed(request, `Wrong tag ${testInfo.project.name}`, category, [`other-filter-${testInfo.project.name}`]);
|
||||||
const id = await addFocused(page);
|
const id = await addEditor(page, type);
|
||||||
const root = page.locator('.pm-focused');
|
const root = page.locator('.pm-focused');
|
||||||
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
await root.getByRole('textbox', { name: 'Main prompt', exact: true }).fill('Keep my draft');
|
||||||
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
await root.getByText('Metadata · category / tags', { exact: true }).click();
|
||||||
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
await root.getByRole('combobox', { name: 'Category', exact: true }).fill('Draft category');
|
||||||
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
await root.getByRole('combobox', { name: 'Tags', exact: true }).fill('draft-tag');
|
||||||
await root.getByRole('button', { name: 'Browse library', exact: true }).click();
|
await showLibrary(page);
|
||||||
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
const filterCategory = root.getByRole('combobox', { name: 'Filter category', exact: true });
|
||||||
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
const filterTags = root.getByRole('combobox', { name: 'Filter tags', exact: true });
|
||||||
await filterCategory.fill(category.slice(0, -3));
|
await filterCategory.fill(category.slice(0, -3));
|
||||||
@@ -308,7 +322,7 @@ test('Focused library autocomplete filters saved prompts without editing draft m
|
|||||||
await filterTags.fill(tag.slice(0, -3));
|
await filterTags.fill(tag.slice(0, -3));
|
||||||
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
const option = page.getByRole('listbox', { name: 'Saved tags', exact: true }).getByRole('option', { name: tag, exact: true });
|
||||||
await expect(option).toBeVisible();
|
await expect(option).toBeVisible();
|
||||||
await page.screenshot({ path: testInfo.outputPath(`focused-library-filters-${palette}.png`) });
|
await page.screenshot({ path: testInfo.outputPath(`${label.toLowerCase()}-library-filters-${palette}.png`) });
|
||||||
await option.click();
|
await option.click();
|
||||||
await expect(filterTags).toHaveValue(`${tag}, `);
|
await expect(filterTags).toHaveValue(`${tag}, `);
|
||||||
}
|
}
|
||||||
@@ -344,3 +358,84 @@ test('Focused library autocomplete filters saved prompts without editing draft m
|
|||||||
await expect(results.first()).toContainText(matchingText);
|
await expect(results.first()).toContainText(matchingText);
|
||||||
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
await expect.poll(() => model(page, id)).toMatchObject(draft);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (label === 'Split') {
|
||||||
|
test('Split keeps its library visible beside the editor and stacks narrow nodes', async ({ page, request }, testInfo) => {
|
||||||
|
for (let index = 0; index < 21; index++) {
|
||||||
|
await seed(request, `Split layout recent ${testInfo.project.name} ${index}`);
|
||||||
|
}
|
||||||
|
const id = await addEditor(page, type);
|
||||||
|
const root = page.locator('.pm-split');
|
||||||
|
await page.evaluate(async id => {
|
||||||
|
const { app } = await import('/scripts/app.js');
|
||||||
|
const node = app.graph.getNodeById(id);
|
||||||
|
node.setSize([900, 650]);
|
||||||
|
app.canvas.centerOnNode(node);
|
||||||
|
}, id);
|
||||||
|
await expect(root).toHaveAttribute('aria-label', 'Split prompt editor');
|
||||||
|
const savedProperties = await page.evaluate(async id => {
|
||||||
|
const { app } = await import('/scripts/app.js');
|
||||||
|
return app.graph.getNodeById(id).serialize().properties;
|
||||||
|
}, id);
|
||||||
|
expect(savedProperties).toHaveProperty('splitUI');
|
||||||
|
expect(savedProperties).not.toHaveProperty('focusedUI');
|
||||||
|
await expect(root.getByRole('button', { name: /^(Browse|Hide) library$/ })).toHaveCount(0);
|
||||||
|
const editor = root.getByRole('textbox', { name: 'Main prompt', exact: true });
|
||||||
|
const library = root.getByRole('region', { name: 'Prompt library', exact: true });
|
||||||
|
await expect(library).toBeVisible();
|
||||||
|
const captureGeometry = async phase => {
|
||||||
|
const geometry = await page.evaluate(async id => {
|
||||||
|
const { app } = await import('/scripts/app.js');
|
||||||
|
const node = app.graph.getNodeById(id);
|
||||||
|
const root = node._focusedView.element;
|
||||||
|
const bounds = selector => root.querySelector(selector)?.getBoundingClientRect().toJSON();
|
||||||
|
const ancestors = [];
|
||||||
|
for (let element = root; element && element !== document.body; element = element.parentElement) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
ancestors.push({ tag: element.tagName, className: element.className, bounds: element.getBoundingClientRect().toJSON(), inlineStyle: element.style.cssText, computed: Object.fromEntries(['height', 'min-height', 'max-height', 'display', 'overflow', 'flex', 'contain', 'position'].map(name => [name, style.getPropertyValue(name)])) });
|
||||||
|
}
|
||||||
|
return { nodeSize: [...node.size], ancestors, root: root.getBoundingClientRect().toJSON(), library: bounds('.pm-editor-library'), results: bounds('.pm-editor-results'), use: bounds('.pm-editor-selection button'), footer: bounds(':scope > .pm-editor-status') };
|
||||||
|
}, id);
|
||||||
|
const path = testInfo.outputPath(`split-${phase}-geometry.json`);
|
||||||
|
await writeFile(path, JSON.stringify(geometry, null, 2));
|
||||||
|
await testInfo.attach(`split-${phase}-geometry.json`, { path, contentType: 'application/json' });
|
||||||
|
return geometry;
|
||||||
|
};
|
||||||
|
const beforeRecent = await captureGeometry('before-recent');
|
||||||
|
await root.getByRole('button', { name: 'Recent', exact: true }).click();
|
||||||
|
const results = library.locator('.pm-editor-results').getByRole('button');
|
||||||
|
await expect(results).toHaveCount(20);
|
||||||
|
const promptText = results.first().locator('span').first();
|
||||||
|
const selectedText = await promptText.innerText();
|
||||||
|
await results.first().click();
|
||||||
|
const use = root.getByRole('button', { name: 'Use prompt', exact: true });
|
||||||
|
const afterSelection = await captureGeometry('after-selection');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('split-default-selected.png') });
|
||||||
|
expect.soft((await promptText.boundingBox())?.height || 0).toBeGreaterThan(10);
|
||||||
|
expect.soft(afterSelection.root.height).toBeLessThanOrEqual(beforeRecent.root.height + 1);
|
||||||
|
await use.click();
|
||||||
|
await expect(editor).toHaveValue(selectedText);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const main = await editor.boundingBox();
|
||||||
|
const browser = await library.boundingBox();
|
||||||
|
return browser.x >= main.x + main.width;
|
||||||
|
}).toBe(true);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('split-wide.png') });
|
||||||
|
await page.evaluate(async id => {
|
||||||
|
const { app } = await import('/scripts/app.js');
|
||||||
|
const node = app.graph.getNodeById(id);
|
||||||
|
node.setSize([480, 1050]);
|
||||||
|
app.canvas.centerOnNode(node);
|
||||||
|
}, id);
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const main = await editor.boundingBox();
|
||||||
|
const browser = await library.boundingBox();
|
||||||
|
return browser.y >= main.y + main.height;
|
||||||
|
}).toBe(true);
|
||||||
|
await expect(library).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('split-narrow.png') });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -361,3 +361,36 @@ test("Focused workflow-only metadata recovers serialized fields and connected co
|
|||||||
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||||
assert.equal(result.checkpoint, "model.safetensors");
|
assert.equal(result.checkpoint, "model.safetensors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Split API metadata composes linked positive text and preserves negative role", () => {
|
||||||
|
const prompt = {
|
||||||
|
1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } },
|
||||||
|
2: { class_type: "PromptManagerSplit", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: "8k" } },
|
||||||
|
3: { class_type: "PromptManagerSplit", inputs: { text: "blurry", prepend_text: "avoid" } },
|
||||||
|
4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } },
|
||||||
|
5: { class_type: "PrimitiveString", inputs: { value: "lora trigger" } },
|
||||||
|
};
|
||||||
|
const result = extractGenerationParams({ prompt });
|
||||||
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||||
|
assert.equal(result.negativePrompt, "avoid blurry");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Split workflow-only metadata recovers serialized fields and connected composition", () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: 1, type: "KSampler", inputs: [{ name: "positive", link: 1 }, { name: "negative", link: 2 }, { name: "model", link: 3 }] },
|
||||||
|
{ id: 2, type: "PromptManagerSplit", widgets_values: ["a fox", "animals", "fox", "ignored search", "stale widget", "8k"], inputs: [{ name: "prepend_text", link: 4 }] },
|
||||||
|
{ id: 3, type: "PromptManagerSplit", widgets_values: ["blurry", "negative", "bad", "ignored search", "avoid", "watermark"] },
|
||||||
|
{ id: 4, type: "UNETLoader", widgets_values: ["model.safetensors", "default"] },
|
||||||
|
{ id: 5, type: "CLIPTextEncode", widgets_values: ["lora trigger"] },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
[1, 2, 0, 1, 0, "CONDITIONING"], [2, 3, 0, 1, 1, "CONDITIONING"],
|
||||||
|
[3, 4, 0, 1, 2, "MODEL"], [4, 5, 0, 2, 0, "STRING"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = extractGenerationParams({ workflow });
|
||||||
|
assert.equal(result.positivePrompt, "lora trigger a fox 8k");
|
||||||
|
assert.equal(result.negativePrompt, "avoid blurry watermark");
|
||||||
|
assert.equal(result.checkpoint, "model.safetensors");
|
||||||
|
});
|
||||||
|
|||||||
@@ -138,11 +138,22 @@ class TestPackageWiring(PackageTestCase):
|
|||||||
[
|
[
|
||||||
"PromptManager",
|
"PromptManager",
|
||||||
"PromptManagerFocused",
|
"PromptManagerFocused",
|
||||||
|
"PromptManagerSplit",
|
||||||
"PromptManagerText",
|
"PromptManagerText",
|
||||||
"PromptSearchList",
|
"PromptSearchList",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_split_display_name_and_execution_contract_are_registered(self):
|
||||||
|
node = self.package.NODE_CLASS_MAPPINGS["PromptManagerSplit"]
|
||||||
|
self.assertEqual(
|
||||||
|
self.package.NODE_DISPLAY_NAME_MAPPINGS["PromptManagerSplit"],
|
||||||
|
"Prompt Manager (Split)",
|
||||||
|
)
|
||||||
|
focused = self.package.NODE_CLASS_MAPPINGS["PromptManagerFocused"]
|
||||||
|
self.assertTrue(issubclass(node, focused))
|
||||||
|
self.assertEqual(node.INPUT_TYPES(), focused.INPUT_TYPES())
|
||||||
|
|
||||||
def test_db_factory_returns_the_shared_database(self):
|
def test_db_factory_returns_the_shared_database(self):
|
||||||
self.assertIsNotNone(self.package._global_db)
|
self.assertIsNotNone(self.package._global_db)
|
||||||
self.assertIs(self.package._db_factory(), self.package._global_db)
|
self.assertIs(self.package._db_factory(), self.package._global_db)
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ class TestPositivePromptNodes(unittest.TestCase):
|
|||||||
del graph["5"]
|
del graph["5"]
|
||||||
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||||
|
|
||||||
|
def test_split_nodes_preserve_positive_roles_and_samplerless_fallback(self):
|
||||||
|
graph = {
|
||||||
|
"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}},
|
||||||
|
"2": {"class_type": "PromptManagerSplit", "inputs": {"text": "blur"}},
|
||||||
|
"5": ksampler("1", "2"),
|
||||||
|
}
|
||||||
|
self.assertEqual(positive_prompt_nodes(graph), ["1"])
|
||||||
|
self.assertEqual(run_prompt_nodes(graph), ["1"])
|
||||||
|
graph["1"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "cat"}}
|
||||||
|
self.assertEqual(run_prompt_nodes(graph), [])
|
||||||
|
del graph["5"]
|
||||||
|
self.assertEqual(run_prompt_nodes(graph), ["2"])
|
||||||
|
|
||||||
def test_negative_only_prompt_manager_is_never_positive(self):
|
def test_negative_only_prompt_manager_is_never_positive(self):
|
||||||
graph = {
|
graph = {
|
||||||
"176": pm("blurry"),
|
"176": pm("blurry"),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Split changes presentation while inheriting Focused execution and storage."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
from prompt_manager import PromptManagerFocused, PromptManagerSplit
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitNode(unittest.TestCase):
|
||||||
|
def test_split_preserves_focused_schema_encoding_and_change_detection(self):
|
||||||
|
self.assertTrue(issubclass(PromptManagerSplit, PromptManagerFocused))
|
||||||
|
self.assertEqual(
|
||||||
|
PromptManagerSplit.INPUT_TYPES(), PromptManagerFocused.INPUT_TYPES()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
PromptManagerSplit.RETURN_TYPES, PromptManagerFocused.RETURN_TYPES
|
||||||
|
)
|
||||||
|
self.assertEqual(PromptManagerSplit.FUNCTION, PromptManagerFocused.FUNCTION)
|
||||||
|
self.assertIs(
|
||||||
|
PromptManagerSplit.encode_prompt, PromptManagerFocused.encode_prompt
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
PromptManagerSplit.IS_CHANGED(None, "cat", category="Animals"),
|
||||||
|
PromptManagerFocused.IS_CHANGED(None, "cat", category="Animals"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
|
||||||
|
node = PromptManagerSplit.__new__(PromptManagerSplit)
|
||||||
|
node.logger = logging.getLogger("test.split")
|
||||||
|
node.comfyui_integration = unittest.mock.Mock()
|
||||||
|
node._inject_lora_trigger_words = lambda text: text
|
||||||
|
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
|
||||||
|
clip = unittest.mock.Mock()
|
||||||
|
graph = {"1": {"class_type": "PromptManagerSplit", "inputs": {"text": "cat"}}}
|
||||||
|
conditioning, text = node.encode_prompt(
|
||||||
|
clip,
|
||||||
|
"cat",
|
||||||
|
category="Animals",
|
||||||
|
tags="studio",
|
||||||
|
prepend_text="trigger",
|
||||||
|
append_text="detailed",
|
||||||
|
prompt=graph,
|
||||||
|
unique_id="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(text, "trigger cat detailed")
|
||||||
|
clip.tokenize.assert_called_once_with(text)
|
||||||
|
self.assertIs(conditioning, clip.encode_from_tokens_scheduled.return_value)
|
||||||
|
tracked = node._track_prompt_execution.call_args.kwargs
|
||||||
|
self.assertEqual(tracked["text"], "cat")
|
||||||
|
self.assertEqual(tracked["category"], "Animals")
|
||||||
|
self.assertIn("studio", tracked["tags"])
|
||||||
|
self.assertEqual(tracked["prompt_graph"], graph)
|
||||||
|
self.assertEqual(tracked["unique_id"], "1")
|
||||||
@@ -136,6 +136,16 @@ class TestQueueHook(DbTestCase):
|
|||||||
self.assertEqual(self._runs(positive), 2)
|
self.assertEqual(self._runs(positive), 2)
|
||||||
self.assertEqual(self._runs(negative), 0)
|
self.assertEqual(self._runs(negative), 0)
|
||||||
|
|
||||||
|
def test_split_queued_runs_count_only_positive_even_when_cached(self):
|
||||||
|
positive, negative = self._save("a cat"), self._save("blurry")
|
||||||
|
graph = workflow()
|
||||||
|
for node_id in ("134", "176"):
|
||||||
|
graph[node_id]["class_type"] = "PromptManagerSplit"
|
||||||
|
self._queue(graph)
|
||||||
|
self._queue(graph)
|
||||||
|
self.assertEqual(self._runs(positive), 2)
|
||||||
|
self.assertEqual(self._runs(negative), 0)
|
||||||
|
|
||||||
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
|
||||||
positive = self._save("a cat")
|
positive = self._save("a cat")
|
||||||
self._queue(workflow())
|
self._queue(workflow())
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import re
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
PROMPT_MANAGER_TYPES = frozenset(
|
PROMPT_MANAGER_TYPES = frozenset(
|
||||||
{"PromptManager", "PromptManagerText", "PromptManagerFocused"}
|
{"PromptManager", "PromptManagerText", "PromptManagerFocused", "PromptManagerSplit"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
|
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
|
||||||
const { installFocusedNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
const { installFocusedNode, installSplitNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']);
|
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Top Bar Button Integration (similar to rgthree-comfy approach)
|
// Top Bar Button Integration (similar to rgthree-comfy approach)
|
||||||
@@ -292,8 +292,9 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === 'PromptManagerFocused') {
|
if (nodeData.name === 'PromptManagerFocused' || nodeData.name === 'PromptManagerSplit') {
|
||||||
installFocusedNode(nodeType, app, api, parseSearchQuery);
|
const install = nodeData.name === 'PromptManagerSplit' ? installSplitNode : installFocusedNode;
|
||||||
|
install(nodeType, app, api, parseSearchQuery);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
|
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
|
||||||
|
|||||||
@@ -236,6 +236,7 @@
|
|||||||
PromptManager: PROMPT_MANAGER_WIDGETS,
|
PromptManager: PROMPT_MANAGER_WIDGETS,
|
||||||
PromptManagerText: PROMPT_MANAGER_WIDGETS,
|
PromptManagerText: PROMPT_MANAGER_WIDGETS,
|
||||||
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
|
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
|
||||||
|
PromptManagerSplit: PROMPT_MANAGER_WIDGETS,
|
||||||
CheckpointLoader: ["config_name", "ckpt_name"],
|
CheckpointLoader: ["config_name", "ckpt_name"],
|
||||||
UNETLoader: ["unet_name", "weight_dtype"],
|
UNETLoader: ["unet_name", "weight_dtype"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ function loadStyles() {
|
|||||||
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
|
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function installFocusedNode(nodeType, app, api, parseQuery) {
|
export function installSplitNode(nodeType, app, api, parseQuery) {
|
||||||
|
installFocusedNode(nodeType, app, api, parseQuery, {layout:'split'});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installFocusedNode(nodeType, app, api, parseQuery, {layout = 'focused'} = {}) {
|
||||||
|
const split = layout === 'split';
|
||||||
|
const uiKey = split ? 'splitUI' : 'focusedUI';
|
||||||
const prototype = nodeType.prototype;
|
const prototype = nodeType.prototype;
|
||||||
const created = prototype.onNodeCreated;
|
const created = prototype.onNodeCreated;
|
||||||
const removed = prototype.onRemoved;
|
const removed = prototype.onRemoved;
|
||||||
@@ -30,13 +36,18 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
|
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
|
||||||
widgets.set(name, widget);
|
widgets.set(name, widget);
|
||||||
}
|
}
|
||||||
const element = document.createElement('div'); element.className = 'ca-tools pm-focused';
|
const element = document.createElement('div'); element.className = `ca-tools pm-focused${split ? ' pm-split' : ''}`;
|
||||||
element.setAttribute('aria-label','Focused prompt editor');
|
element.setAttribute('aria-label',split ? 'Split prompt editor' : 'Focused prompt editor');
|
||||||
loadStyles(); mountTheme(element, app.ui?.settings, scope);
|
loadStyles(); mountTheme(element, app.ui?.settings, scope);
|
||||||
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
|
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
|
||||||
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
|
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
|
||||||
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
|
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
|
||||||
const web = createButton('Web UI ↗'); toolbar.append(title, browse, web);
|
const web = createButton('Web UI ↗'); toolbar.append(title);
|
||||||
|
if (!split) toolbar.append(browse);
|
||||||
|
toolbar.append(web);
|
||||||
|
const workspace = document.createElement('div'); workspace.className = 'pm-editor-workspace';
|
||||||
|
const editor = document.createElement('div'); editor.className = 'pm-editor-compose';
|
||||||
|
workspace.append(editor);
|
||||||
let webDisplayMode = 'popup';
|
let webDisplayMode = 'popup';
|
||||||
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
|
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
|
||||||
.then(response => response.ok ? response.json() : null)
|
.then(response => response.ok ? response.json() : null)
|
||||||
@@ -55,17 +66,19 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
else delete textWidget.inputEl;
|
else delete textWidget.inputEl;
|
||||||
});
|
});
|
||||||
const mainField = createField('Main prompt', main, `${editorId}-text`);
|
const mainField = createField('Main prompt', main, `${editorId}-text`);
|
||||||
|
mainField.classList.add('pm-editor-main');
|
||||||
const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.';
|
const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.';
|
||||||
const controls = new Map([['text', main]]);
|
const controls = new Map([['text', main]]);
|
||||||
const details = new Map();
|
const details = new Map();
|
||||||
const completions = new Map();
|
const completions = new Map();
|
||||||
element.append(toolbar, mainField, connected);
|
element.append(toolbar, workspace);
|
||||||
|
editor.append(mainField, connected);
|
||||||
function disclosure(key, label) {
|
function disclosure(key, label) {
|
||||||
const section = document.createElement('details'); section.className = 'pm-editor-details';
|
const section = document.createElement('details'); section.className = 'pm-editor-details';
|
||||||
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); element.append(section); details.set(key,section);
|
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); editor.append(section); details.set(key,section);
|
||||||
scope.listen(section, 'toggle', () => {
|
scope.listen(section, 'toggle', () => {
|
||||||
if (!section.open) completions.forEach(completion => completion.close());
|
if (!section.open) completions.forEach(completion => completion.close());
|
||||||
if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot();
|
if (!scope.signal.aborted) thisNode.properties[uiKey] = snapshot();
|
||||||
});
|
});
|
||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
@@ -112,7 +125,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
|
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
|
||||||
library.refreshAvailability();
|
library.refreshAvailability();
|
||||||
}
|
}
|
||||||
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => {
|
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties[uiKey] = snapshot();}, onUse:(prompt,includeMetadata) => {
|
||||||
if (isConnected('text')) return;
|
if (isConnected('text')) return;
|
||||||
write('text',prompt.text);
|
write('text',prompt.text);
|
||||||
if (includeMetadata) {
|
if (includeMetadata) {
|
||||||
@@ -121,14 +134,14 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
}
|
}
|
||||||
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
|
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
|
||||||
}});
|
}});
|
||||||
element.append(library.element,footer);
|
workspace.append(library.element); element.append(footer);
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
|
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
|
||||||
}
|
}
|
||||||
function restore() {
|
function restore() {
|
||||||
const state = thisNode.properties.focusedUI || {};
|
const state = thisNode.properties[uiKey] || {};
|
||||||
details.forEach((section,name) => {section.open = Boolean(state[name]);});
|
details.forEach((section,name) => {section.open = Boolean(state[name]);});
|
||||||
library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
|
library.element.hidden = !split && !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
|
||||||
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
|
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
|
||||||
}
|
}
|
||||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';}));
|
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';}));
|
||||||
@@ -136,7 +149,7 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
library.element.hidden = !library.element.hidden;
|
library.element.hidden = !library.element.hidden;
|
||||||
library.closeCompletions();
|
library.closeCompletions();
|
||||||
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
|
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
|
||||||
this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus();
|
this.properties[uiKey] = snapshot(); if (!library.element.hidden) library.focus();
|
||||||
});
|
});
|
||||||
scope.listen(web,'click',() => {
|
scope.listen(web,'click',() => {
|
||||||
const url = api.fileURL('/prompt_manager/web');
|
const url = api.fileURL('/prompt_manager/web');
|
||||||
@@ -146,11 +159,11 @@ export function installFocusedNode(nodeType, app, api, parseQuery) {
|
|||||||
});
|
});
|
||||||
this._focusedView = {element,render,restore,snapshot};
|
this._focusedView = {element,render,restore,snapshot};
|
||||||
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
|
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
|
||||||
this.size = [550,650]; this.setSize(this.size); restore();
|
this.size = [split ? 900 : 550,650]; this.setSize(this.size); restore();
|
||||||
};
|
};
|
||||||
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
|
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
|
||||||
prototype.serialize = function (...args) {
|
prototype.serialize = function (...args) {
|
||||||
if (this._focusedView) this.properties.focusedUI = this._focusedView.snapshot();
|
if (this._focusedView) this.properties[uiKey] = this._focusedView.snapshot();
|
||||||
return serialize?.apply(this,args);
|
return serialize?.apply(this,args);
|
||||||
};
|
};
|
||||||
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
|
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
.pm-focused { inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
|
.pm-focused { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
|
||||||
.pm-focused [hidden] { display: none !important; }
|
.pm-focused [hidden] { display: none !important; }
|
||||||
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
|
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
|
||||||
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
|
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
|
||||||
.pm-focused > .ca-field { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
.pm-editor-workspace, .pm-editor-compose { display: contents; }
|
||||||
|
.pm-focused .pm-editor-main { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
||||||
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
|
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
|
||||||
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
|
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
|
||||||
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
|
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
|
||||||
@@ -17,11 +18,23 @@
|
|||||||
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
|
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
|
||||||
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
|
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
|
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
|
||||||
.pm-editor-results { display: grid; gap: 6px; max-block-size: 300px; overflow: auto; }
|
.pm-editor-results { display: grid; grid-auto-rows: max-content; align-content: start; gap: 6px; max-block-size: 300px; overflow: auto; }
|
||||||
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
|
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
|
||||||
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
|
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
|
||||||
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
|
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
|
||||||
.pm-editor-result > span:first-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
|
.pm-editor-result > span:first-child { flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
|
||||||
.pm-editor-selection { display: grid; gap: 10px; }
|
.pm-editor-selection { display: grid; gap: 10px; }
|
||||||
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
|
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
|
||||||
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||||
|
/* Contain intrinsic sizing so Nodes 2.0 keeps the widget within the node. */
|
||||||
|
.pm-split { contain: size; }
|
||||||
|
.pm-split .pm-editor-workspace { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; flex: 1; min-inline-size: 0; }
|
||||||
|
.pm-split .pm-editor-compose { display: flex; flex-direction: column; gap: 10px; min-inline-size: 0; min-block-size: 300px; }
|
||||||
|
.pm-split .pm-editor-library { min-inline-size: 0; align-content: start; }
|
||||||
|
.pm-split .pm-editor-preview { box-sizing: border-box; max-block-size: 180px; overflow: auto; }
|
||||||
|
@container pm-editor (inline-size > 720px) {
|
||||||
|
.pm-split .pm-editor-workspace { grid-template-columns: minmax(0,1.3fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr); min-block-size: 0; }
|
||||||
|
.pm-split .pm-editor-compose, .pm-split .pm-editor-library { min-block-size: 0; overflow: auto; }
|
||||||
|
.pm-split .pm-editor-library { border-block-start: 0; border-inline-start: 1px solid var(--ca-line); padding-block-start: 0; padding-inline-start: 12px; }
|
||||||
|
.pm-split .pm-editor-fields { grid-template-columns: minmax(0,1fr); }
|
||||||
|
}
|
||||||
|
|||||||