feat(ui): add Focused Prompt Manager with shared autocomplete (#191)
* feat(ui): add Focused Prompt Manager node (#186) * feat(ui): autocomplete saved Focused metadata with shared controls (#186) * feat(ui): autocomplete Focused library filters (#186)
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
const { installClassicControls } = await import(api.fileURL('/prompt_manager/js/classic-node.js'));
|
||||
const { installFocusedNode } = await import(api.fileURL('/prompt_manager/js/focused-node.js'));
|
||||
const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused']);
|
||||
|
||||
// ============================================================================
|
||||
// Top Bar Button Integration (similar to rgthree-comfy approach)
|
||||
@@ -197,7 +199,7 @@ function syncPromptManagerWidgets() {
|
||||
const nodes = graph._nodes || graph.nodes || [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === "PromptManager" || node.type === "PromptManagerText") {
|
||||
if (promptManagerTypes.has(node.type)) {
|
||||
if (node.widgets && node.widgets.length > 0) {
|
||||
if (!node.widgets_values) {
|
||||
node.widgets_values = [];
|
||||
@@ -257,7 +259,7 @@ app.registerExtension({
|
||||
if (graph && output) {
|
||||
const nodes = graph._nodes || graph.nodes || [];
|
||||
for (const node of nodes) {
|
||||
if ((node.type === "PromptManager" || node.type === "PromptManagerText") && output[node.id]) {
|
||||
if (promptManagerTypes.has(node.type) && output[node.id]) {
|
||||
const outputNode = output[node.id];
|
||||
|
||||
// Skip if text input is connected to another node (value is a link reference array)
|
||||
@@ -290,6 +292,10 @@ app.registerExtension({
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === 'PromptManagerFocused') {
|
||||
installFocusedNode(nodeType, app, api, parseSearchQuery);
|
||||
return;
|
||||
}
|
||||
if (nodeData.name === "PromptManager" || nodeData.name === "PromptManagerText") {
|
||||
// Store original methods
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
CLIPTextEncode: ["text"],
|
||||
PromptManager: PROMPT_MANAGER_WIDGETS,
|
||||
PromptManagerText: PROMPT_MANAGER_WIDGETS,
|
||||
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
|
||||
CheckpointLoader: ["config_name", "ckpt_name"],
|
||||
UNETLoader: ["unet_name", "weight_dtype"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import {createScope, createButton, createField, mountTheme} from './shared/toolkit/core.js';
|
||||
import {createPromptLibrary} from './prompt-library.js';
|
||||
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
|
||||
|
||||
const names = ['text', 'category', 'tags', 'search_text', 'prepend_text', 'append_text'];
|
||||
let nextEditorId = 0;
|
||||
|
||||
function loadStyles() {
|
||||
const href = new URL('./prompt-editor.css', import.meta.url).href;
|
||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === href)) return;
|
||||
const link = document.createElement('link'); Object.assign(link, {rel:'stylesheet', href}); document.head.append(link);
|
||||
}
|
||||
|
||||
export function installFocusedNode(nodeType, app, api, parseQuery) {
|
||||
const prototype = nodeType.prototype;
|
||||
const created = prototype.onNodeCreated;
|
||||
const removed = prototype.onRemoved;
|
||||
const configure = prototype.configure;
|
||||
const serialize = prototype.serialize;
|
||||
const connections = prototype.onConnectionsChange;
|
||||
prototype.onNodeCreated = function (...args) {
|
||||
created?.apply(this, args);
|
||||
const scope = createScope(); this._focusedScope = scope;
|
||||
const editorId = `pm-focused-${++nextEditorId}`;
|
||||
this.properties ||= {};
|
||||
const widgets = new Map();
|
||||
for (const name of names) {
|
||||
let widget = this.widgets?.find(item => item.name === name);
|
||||
if (!widget) widget = this.addWidget('text', name, '', () => {}, {});
|
||||
widget.hidden = true; widget.type = 'hidden'; widget.computeSize = () => [0, -4];
|
||||
widgets.set(name, widget);
|
||||
}
|
||||
const element = document.createElement('div'); element.className = 'ca-tools pm-focused';
|
||||
element.setAttribute('aria-label','Focused prompt editor');
|
||||
loadStyles(); mountTheme(element, app.ui?.settings, scope);
|
||||
const toolbar = document.createElement('div'); toolbar.className = 'pm-editor-row pm-editor-toolbar';
|
||||
const title = document.createElement('strong'); title.textContent = 'Prompt Manager';
|
||||
const browse = createButton('Browse library','ca-primary'); browse.setAttribute('aria-expanded','false');
|
||||
const web = createButton('Web UI ↗'); toolbar.append(title, browse, web);
|
||||
let webDisplayMode = 'popup';
|
||||
this._focusedSettingsReady = fetch(api.fileURL('/prompt_manager/settings'),{signal:scope.signal})
|
||||
.then(response => response.ok ? response.json() : null)
|
||||
.then(data => {
|
||||
const mode = data?.settings?.webui_display_mode;
|
||||
if (!scope.signal.aborted && data?.success && ['popup','newtab'].includes(mode)) webDisplayMode = mode;
|
||||
}).catch(() => {});
|
||||
const textWidget = widgets.get('text');
|
||||
const nativeInput = textWidget.inputEl;
|
||||
const inputDescriptor = Object.getOwnPropertyDescriptor(textWidget,'inputEl');
|
||||
const main = document.createElement('textarea'); main.rows = 6; main.spellcheck = nativeInput?.spellcheck ?? false;
|
||||
// ComfyUI owns widget.element; inputEl exposes our visible editor to extensions.
|
||||
Object.defineProperty(textWidget,'inputEl',{configurable:true,writable:true,value:main});
|
||||
scope.cleanup(() => {
|
||||
if (inputDescriptor) Object.defineProperty(textWidget,'inputEl',inputDescriptor);
|
||||
else delete textWidget.inputEl;
|
||||
});
|
||||
const mainField = createField('Main prompt', main, `${editorId}-text`);
|
||||
const connected = document.createElement('p'); connected.className = 'pm-editor-status'; connected.textContent = 'Text is supplied by an upstream connection.';
|
||||
const controls = new Map([['text', main]]);
|
||||
const details = new Map();
|
||||
const completions = new Map();
|
||||
element.append(toolbar, mainField, connected);
|
||||
function disclosure(key, label) {
|
||||
const section = document.createElement('details'); section.className = 'pm-editor-details';
|
||||
const summary = document.createElement('summary'); summary.textContent = label; section.append(summary); element.append(section); details.set(key,section);
|
||||
scope.listen(section, 'toggle', () => {
|
||||
if (!section.open) completions.forEach(completion => completion.close());
|
||||
if (!scope.signal.aborted) thisNode.properties.focusedUI = snapshot();
|
||||
});
|
||||
return section;
|
||||
}
|
||||
const thisNode = this;
|
||||
const composition = disclosure('composition','Composition · prepend / append');
|
||||
const metadata = disclosure('metadata','Metadata · category / tags');
|
||||
for (const [section, fields] of [[composition,[['prepend_text','Prepend'],['append_text','Append']]],[metadata,[['category','Category'],['tags','Tags']]]]) {
|
||||
const grid = document.createElement('div'); grid.className = 'pm-editor-fields';
|
||||
for (const [name,label] of fields) {
|
||||
const input = document.createElement('input'); input.type = 'text'; input.setAttribute('aria-label',label); controls.set(name,input);
|
||||
const field = createField(label,input,`${editorId}-${name}`);
|
||||
const note = document.createElement('span'); note.className = 'pm-editor-connection'; note.textContent = 'Supplied by an upstream connection.'; note.hidden = true;
|
||||
field.append(note); input._connectionNote = note; grid.append(field);
|
||||
if (name === 'category' || name === 'tags') completions.set(name, attachMetadataAutocomplete(input, {
|
||||
scope,api,settings:app.ui?.settings,multiple:name === 'tags',available:() => !isConnected(name) && input.getClientRects().length > 0,
|
||||
}));
|
||||
}
|
||||
section.append(grid);
|
||||
}
|
||||
const composed = disclosure('preview','Composed text preview');
|
||||
const preview = document.createElement('p'); preview.className = 'pm-editor-preview'; preview.dataset.composedPreview = ''; composed.append(preview);
|
||||
const footer = document.createElement('p'); footer.className = 'pm-editor-status'; footer.setAttribute('role','status'); footer.textContent = 'Main prompt is saved when this node runs.';
|
||||
function isConnected(name) { return thisNode.inputs?.some(input => input.name === name && input.link != null); }
|
||||
function value(name) { return String(widgets.get(name)?.value ?? ''); }
|
||||
function write(name, text) {
|
||||
if (isConnected(name)) return;
|
||||
const widget = widgets.get(name); widget.value = text;
|
||||
if (widget.inputEl) widget.inputEl.value = text;
|
||||
const index = thisNode.widgets.indexOf(widget);
|
||||
if (thisNode.widgets_values) thisNode.widgets_values[index] = text;
|
||||
widget.callback?.(text, app.canvas, thisNode);
|
||||
thisNode.setDirtyCanvas(true,true); app.graph?.setDirtyCanvas(true,true);
|
||||
}
|
||||
function render() {
|
||||
mainField.hidden = Boolean(isConnected('text')); connected.hidden = !isConnected('text');
|
||||
controls.forEach((input,name) => {
|
||||
input.value = value(name);
|
||||
if (name !== 'text') {
|
||||
if (isConnected(name)) completions.get(name)?.close();
|
||||
input.hidden = Boolean(isConnected(name));
|
||||
input._connectionNote.hidden = !isConnected(name);
|
||||
}
|
||||
});
|
||||
preview.textContent = ['prepend_text','text','append_text'].map(name => isConnected(name) ? `[${name} supplied by connection]` : name === 'text' ? value(name) : value(name).trim()).filter(Boolean).join(' ');
|
||||
library.refreshAvailability();
|
||||
}
|
||||
const library = createPromptLibrary({scope,api,settings:app.ui?.settings,parseQuery,isConnected:() => isConnected('text'), onQuery:text => write('search_text',text), onFilters:() => {this.properties.focusedUI = snapshot();}, onUse:(prompt,includeMetadata) => {
|
||||
if (isConnected('text')) return;
|
||||
write('text',prompt.text);
|
||||
if (includeMetadata) {
|
||||
write('category',prompt.category || '');
|
||||
write('tags',Array.isArray(prompt.tags) ? prompt.tags.join(', ') : prompt.tags || '');
|
||||
}
|
||||
render(); footer.setAttribute('role','status'); footer.textContent = 'Loaded prompt. Saved when this node runs.'; main.focus();
|
||||
}});
|
||||
element.append(library.element,footer);
|
||||
function snapshot() {
|
||||
return {libraryOpen:!library.element.hidden,libraryFilters:library.filters(),...Object.fromEntries([...details].map(([name,section]) => [name,section.open]))};
|
||||
}
|
||||
function restore() {
|
||||
const state = thisNode.properties.focusedUI || {};
|
||||
details.forEach((section,name) => {section.open = Boolean(state[name]);});
|
||||
library.element.hidden = !state.libraryOpen; browse.textContent = state.libraryOpen ? 'Hide library' : 'Browse library'; browse.setAttribute('aria-expanded', String(Boolean(state.libraryOpen)));
|
||||
library.setQuery(value('search_text')); library.restoreFilters(state.libraryFilters); render();
|
||||
}
|
||||
controls.forEach((input,name) => scope.listen(input,'input',() => {write(name,input.value);render();footer.setAttribute('role','status');footer.textContent='Main prompt is saved when this node runs.';}));
|
||||
scope.listen(browse,'click',() => {
|
||||
library.element.hidden = !library.element.hidden;
|
||||
library.closeCompletions();
|
||||
browse.textContent = library.element.hidden ? 'Browse library' : 'Hide library'; browse.setAttribute('aria-expanded',String(!library.element.hidden));
|
||||
this.properties.focusedUI = snapshot(); if (!library.element.hidden) library.focus();
|
||||
});
|
||||
scope.listen(web,'click',() => {
|
||||
const url = api.fileURL('/prompt_manager/web');
|
||||
const opened = webDisplayMode === 'newtab' ? window.open(url,'_blank') : window.open(url,'_blank','width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||
footer.setAttribute('role',opened ? 'status' : 'alert');
|
||||
footer.textContent = opened ? 'Web UI opened.' : 'Allow popups to open Web UI, then try again.';
|
||||
});
|
||||
this._focusedView = {element,render,restore,snapshot};
|
||||
this.addDOMWidget('prompt_manager_focused_ui','div',element,{serialize:false, hideOnZoom:false, getMinHeight:() => 360});
|
||||
this.size = [550,650]; this.setSize(this.size); restore();
|
||||
};
|
||||
prototype.configure = function (...args) { const result = configure?.apply(this,args); this._focusedView?.restore(); return result; };
|
||||
prototype.serialize = function (...args) {
|
||||
if (this._focusedView) this.properties.focusedUI = this._focusedView.snapshot();
|
||||
return serialize?.apply(this,args);
|
||||
};
|
||||
prototype.onConnectionsChange = function (...args) { const result = connections?.apply(this,args); this._focusedView?.render(); return result; };
|
||||
prototype.onRemoved = function (...args) { this._focusedScope?.dispose(); return removed?.apply(this,args); };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {createScope} from './shared/toolkit/core.js';
|
||||
import {createSuggestionView} from './shared/toolkit/suggestions.js';
|
||||
|
||||
export function attachMetadataAutocomplete(input, {scope: owner, api, settings, multiple = false, available = () => true}) {
|
||||
const scope = createScope();
|
||||
owner.cleanup(() => scope.dispose());
|
||||
const view = createSuggestionView(scope, settings, {label: multiple ? 'Saved tags' : 'Saved categories'});
|
||||
const attributes = ['role','aria-autocomplete','aria-controls','aria-expanded','aria-activedescendant','autocomplete'];
|
||||
const original = new Map(attributes.map(name => [name,input.getAttribute(name)]));
|
||||
input.setAttribute('role','combobox');
|
||||
input.setAttribute('aria-autocomplete','list');
|
||||
input.setAttribute('aria-controls',view.element.id);
|
||||
input.setAttribute('aria-expanded','false');
|
||||
input.autocomplete = 'off';
|
||||
let timer, request, sequence = 0, items = [], selected = -1;
|
||||
function close() {
|
||||
clearTimeout(timer); request?.abort(); sequence++;
|
||||
items = []; selected = -1; view.hide();
|
||||
input.setAttribute('aria-expanded','false');
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
scope.cleanup(() => {
|
||||
close();
|
||||
original.forEach((value,name) => value === null ? input.removeAttribute(name) : input.setAttribute(name,value));
|
||||
});
|
||||
function choose(index) {
|
||||
selected = index;
|
||||
input.setAttribute('aria-activedescendant',view.select(index));
|
||||
}
|
||||
function insert(item) {
|
||||
if (!available()) return close();
|
||||
const parts = input.value.split(',');
|
||||
parts.pop();
|
||||
input.value = multiple ? [...parts.map(part => part.trim()).filter(Boolean),item.name].join(', ') + ', ' : item.name;
|
||||
input.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
close(); input.focus();
|
||||
}
|
||||
async function load() {
|
||||
const current = ++sequence;
|
||||
request = new AbortController();
|
||||
const parts = input.value.split(',');
|
||||
const query = (multiple ? parts.pop() : input.value).trim();
|
||||
const committed = new Set(parts.map(part => part.trim().toLowerCase()));
|
||||
const url = multiple ? `/prompt_manager/tags/suggest?${new URLSearchParams({q:query,limit:'20'})}` : '/prompt_manager/categories';
|
||||
try {
|
||||
const response = await fetch(api.fileURL(url),{signal:request.signal});
|
||||
if (scope.signal.aborted || current !== sequence) return;
|
||||
if (!response.ok) return close();
|
||||
const data = await response.json();
|
||||
if (scope.signal.aborted || current !== sequence || document.activeElement !== input || !available()) return;
|
||||
if (!data.success) return close();
|
||||
items = (multiple ? data.suggestions : data.categories).map(item => ({name:typeof item === 'string' ? item : item.name}))
|
||||
.filter(item => item.name.toLowerCase().startsWith(query.toLowerCase()) && (!multiple || !committed.has(item.name.toLowerCase()))).slice(0,15);
|
||||
if (!items.length) return close();
|
||||
selected = -1;
|
||||
view.render(items,choose,insert); view.position(input.getBoundingClientRect());
|
||||
input.setAttribute('aria-expanded','true');
|
||||
} catch {
|
||||
if (current === sequence && !scope.signal.aborted) close();
|
||||
}
|
||||
}
|
||||
function schedule() {
|
||||
close();
|
||||
if (available()) timer = setTimeout(load,150);
|
||||
}
|
||||
scope.listen(input,'input',schedule);
|
||||
scope.listen(input,'focus',schedule);
|
||||
scope.listen(input,'blur',close);
|
||||
scope.listen(input,'keydown',event => {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === 'Escape' || event.key === 'Tab') {close();return;}
|
||||
if (!items.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
choose(selected < 0 ? (event.key === 'ArrowDown' ? 0 : items.length - 1) : (selected + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length);
|
||||
} else if (event.key === 'Enter' && selected >= 0) {
|
||||
event.preventDefault(); event.stopPropagation(); insert(items[selected]);
|
||||
}
|
||||
});
|
||||
scope.listen(window,'resize',close);
|
||||
document.addEventListener('scroll',event => {
|
||||
if (!view.element.hidden && !view.element.contains(event.target)) close();
|
||||
},{capture:true,signal:scope.signal});
|
||||
return {close};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.pm-focused { inline-size: 100%; block-size: 100%; min-block-size: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; overflow: auto; container: pm-editor / inline-size; border-radius: 5px; }
|
||||
.pm-focused [hidden] { display: none !important; }
|
||||
.pm-editor-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.pm-editor-toolbar > strong { margin-inline-end: auto; font-weight: 500; }
|
||||
.pm-focused .ca-field-label { align-self: flex-start; margin-inline: 0; text-align: start; }
|
||||
.pm-focused > .ca-field { display: flex; flex: 1; min-block-size: 180px; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
||||
.pm-focused textarea { flex: 1; inline-size: 100%; min-block-size: 160px; resize: vertical; line-height: 1.65; }
|
||||
.pm-editor-details { border-block-end: 1px solid var(--ca-line); padding-block: 5px; }
|
||||
.pm-editor-details > summary { color: var(--ca-muted); cursor: pointer; }
|
||||
.pm-editor-fields { display: grid; grid-template-columns: minmax(0,1fr); gap: 10px; padding-block: 10px; }
|
||||
.pm-editor-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 5px; text-align: start; }
|
||||
.pm-editor-fields input { inline-size: 100%; }
|
||||
.pm-editor-preview { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 10px; border-radius: 5px; background: var(--ca-field); }
|
||||
.pm-editor-status, .pm-editor-connection { color: var(--ca-muted); font-size: 12px; margin: 0; overflow-wrap: anywhere; }
|
||||
.pm-editor-error { color: var(--ca-error-text); background: var(--ca-error-bg); padding: 8px; border-radius: 5px; }
|
||||
.pm-editor-library { display: grid; gap: 10px; border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; }
|
||||
.pm-editor-library h3 { font: inherit; font-weight: 500; margin: 0; }
|
||||
.pm-editor-query { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.pm-editor-query input { flex: 1; inline-size: 100%; min-inline-size: 100px; }
|
||||
.pm-editor-results { display: grid; gap: 6px; max-block-size: 300px; overflow: auto; }
|
||||
.pm-focused button.pm-editor-result { flex-direction: column; align-items: stretch; text-align: start; white-space: normal; overflow-wrap: anywhere; }
|
||||
.pm-editor-result[aria-pressed="true"] { border-color: var(--ca-accent); box-shadow: inset 3px 0 var(--ca-accent); }
|
||||
.pm-editor-result-meta { color: var(--ca-muted); font-size: 11px; }
|
||||
.pm-editor-result > span:first-child { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; line-clamp: 2; overflow: hidden; }
|
||||
.pm-editor-selection { display: grid; gap: 10px; }
|
||||
.pm-editor-selection .pm-editor-row { justify-content: space-between; }
|
||||
@container pm-editor (inline-size > 420px) { .pm-editor-fields { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@@ -0,0 +1,107 @@
|
||||
import {createScope, createButton, createSwitch, createField} from './shared/toolkit/core.js';
|
||||
import {attachMetadataAutocomplete} from './metadata-autocomplete.js';
|
||||
let nextLibraryId = 0;
|
||||
|
||||
export function createPromptLibrary({scope, api, settings, parseQuery, onUse, isConnected, onQuery, onFilters}) {
|
||||
const element = document.createElement('section');
|
||||
element.className = 'pm-editor-library';
|
||||
element.setAttribute('aria-label', 'Prompt library');
|
||||
element.hidden = true;
|
||||
const heading = document.createElement('h3'); heading.textContent = 'Prompt library';
|
||||
const libraryId = `pm-library-${++nextLibraryId}`;
|
||||
const queryRow = document.createElement('div'); queryRow.className = 'pm-editor-query';
|
||||
const query = document.createElement('input'); query.type = 'search'; query.placeholder = 'Search text or tag:portrait'; query.setAttribute('aria-label', 'Search saved prompts');
|
||||
const search = createButton('Search', 'ca-primary');
|
||||
const recent = createButton('Recent');
|
||||
queryRow.append(query, search, recent);
|
||||
const filters = document.createElement('div'); filters.className = 'pm-editor-fields';
|
||||
const category = document.createElement('input'); category.type = 'text';
|
||||
const tags = document.createElement('input'); tags.type = 'text';
|
||||
filters.append(createField('Filter category',category,`${libraryId}-category`),createField('Filter tags',tags,`${libraryId}-tags`));
|
||||
const hint = document.createElement('p'); hint.className = 'pm-editor-status'; hint.textContent = 'Search combines text, category and tags. Recent shows all recent prompts.';
|
||||
const completions = [category,tags].map((input,index) => attachMetadataAutocomplete(input, {
|
||||
scope,api,settings,multiple:index === 1,available:() => !element.hidden && input.getClientRects().length > 0,
|
||||
}));
|
||||
const status = document.createElement('p'); status.className = 'pm-editor-status'; status.setAttribute('role', 'status'); status.textContent = 'Search or browse recent prompts.';
|
||||
const results = document.createElement('div'); results.className = 'pm-editor-results';
|
||||
const preview = document.createElement('section'); preview.className = 'pm-editor-selection'; preview.hidden = true;
|
||||
const previewText = document.createElement('p'); previewText.className = 'pm-editor-preview';
|
||||
const actions = document.createElement('div'); actions.className = 'pm-editor-row';
|
||||
const includeMetadata = createSwitch('Load metadata', '', `${libraryId}-load-metadata`);
|
||||
const use = createButton('Use prompt', 'ca-primary'); use.disabled = true;
|
||||
actions.append(includeMetadata, use); preview.append(previewText, actions);
|
||||
element.append(heading, queryRow, filters, hint, status, results, preview);
|
||||
let selected = null;
|
||||
let request = null;
|
||||
let sequence = 0;
|
||||
let rows = createScope();
|
||||
scope.cleanup(() => { sequence++; request?.abort(); rows.dispose(); });
|
||||
function refreshAvailability() { use.disabled = !selected || isConnected(); }
|
||||
function invalidate() {
|
||||
sequence++; request?.abort(); selected = null; preview.hidden = true; refreshAvailability();
|
||||
rows.dispose(); rows = createScope(); results.replaceChildren();
|
||||
status.classList.remove('pm-editor-error'); status.setAttribute('role','status'); status.textContent = 'Search to refresh results.';
|
||||
}
|
||||
async function load(recentOnly = false) {
|
||||
request?.abort(); request = new AbortController();
|
||||
const current = ++sequence;
|
||||
selected = null; preview.hidden = true; refreshAvailability();
|
||||
rows.dispose(); rows = createScope(); results.replaceChildren();
|
||||
const parsed = parseQuery(query.value);
|
||||
const text = parsed.text;
|
||||
const tagFilters = [...new Map([...parsed.tags,...tags.value.split(',')].map(tag => tag.trim()).filter(Boolean).map(tag => [tag.toLowerCase(),tag])).values()];
|
||||
const categoryFilter = category.value.trim();
|
||||
status.classList.remove('pm-editor-error'); status.setAttribute('role', 'status');
|
||||
if (!recentOnly && !text && !tagFilters.length && !categoryFilter) { status.textContent = 'Enter search text, a category or tags.'; return; }
|
||||
const params = new URLSearchParams(recentOnly ? {limit:'20', sort:'last_used_desc'} : {limit:'50'});
|
||||
if (!recentOnly) {
|
||||
if (text) params.set('text', text);
|
||||
if (tagFilters.length) params.set('tags', tagFilters.join(','));
|
||||
if (categoryFilter) params.set('category', categoryFilter);
|
||||
}
|
||||
status.textContent = 'Loading prompts…';
|
||||
try {
|
||||
const response = await fetch(api.fileURL(`/prompt_manager/${recentOnly ? 'recent' : 'search'}?${params}`), {signal:request.signal});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const data = await response.json();
|
||||
if (scope.signal.aborted || current !== sequence) return;
|
||||
if (!data.success) throw new Error(data.error || 'Could not load prompts');
|
||||
const found = data.results || [];
|
||||
status.textContent = found.length ? `${found.length} ${found.length === 1 ? 'prompt' : 'prompts'}` : 'No matching prompts.';
|
||||
found.forEach(item => {
|
||||
const button = createButton('', 'pm-editor-result'); button.setAttribute('aria-pressed','false');
|
||||
const text = document.createElement('span'); text.textContent = item.text;
|
||||
const metadata = document.createElement('span'); metadata.className = 'pm-editor-result-meta';
|
||||
metadata.textContent = [item.category, Array.isArray(item.tags) ? item.tags.join(', ') : item.tags, item.rating ? `${item.rating}/5` : ''].filter(Boolean).join(' · ');
|
||||
button.append(text, metadata);
|
||||
rows.listen(button, 'click', () => {
|
||||
selected = item; previewText.textContent = item.text; preview.hidden = false;
|
||||
results.querySelectorAll('button').forEach(row => row.setAttribute('aria-pressed', String(row === button)));
|
||||
status.textContent = 'Previewing selection. Editor unchanged.'; refreshAvailability();
|
||||
});
|
||||
results.append(button);
|
||||
});
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted || current !== sequence || request.signal.aborted) return;
|
||||
status.classList.add('pm-editor-error'); status.setAttribute('role','alert'); status.textContent = `Could not load prompts: ${error.message}`;
|
||||
}
|
||||
}
|
||||
scope.listen(search, 'click', () => load());
|
||||
scope.listen(recent, 'click', () => load(true));
|
||||
scope.listen(query, 'input', () => {invalidate();onQuery(query.value);});
|
||||
[query,category,tags].forEach(input => scope.listen(input, 'keydown', event => {
|
||||
if (event.key === 'Enter' && !event.defaultPrevented && !event.isComposing) { event.preventDefault(); load(); }
|
||||
}));
|
||||
[category,tags].forEach(input => scope.listen(input,'input',() => {
|
||||
invalidate(); status.textContent = 'Filters changed. Search to refresh results.'; onFilters();
|
||||
}));
|
||||
scope.listen(use, 'click', () => {
|
||||
if (!selected || isConnected()) return;
|
||||
onUse(selected, includeMetadata.querySelector('input').checked);
|
||||
});
|
||||
return {element, focus:() => query.focus(), refreshAvailability, setQuery(value) { query.value = value || ''; },
|
||||
filters:() => ({category:category.value,tags:tags.value}),
|
||||
restoreFilters(state = {}) { invalidate();category.value = state.category || '';tags.value = state.tags || '';completions.forEach(completion => completion.close()); },
|
||||
closeCompletions() { completions.forEach(completion => completion.close()); },
|
||||
};
|
||||
}
|
||||
@@ -21,3 +21,14 @@ range, touch-target and forced-colors rules (original lines 1–61, 97–99, 118
|
||||
170–172 and closing brace 174). Feature-view selectors remain in KikoTools.
|
||||
The retained declarations are unchanged; the extracted stylesheet SHA-256 is
|
||||
`3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b`.
|
||||
|
||||
## Suggestion view extraction
|
||||
|
||||
`ui/suggestions.js` derives from KikoTools `web/ui/autocomplete-suggestions.js`
|
||||
at commit `1820fb19ea5d200e8cd2a09bcbbcc35926f13228` (SHA-256 `2aecc78f53b7be3d67b77337b4a340f948afcc4cb1196ccf3b70739ef1f0d002`).
|
||||
The generic view preserves its scope-based cleanup, safe text rendering and
|
||||
viewport positioning. Pack-specific classes and IDs are replaced by `ca-` names;
|
||||
callers can supply a list label and omit type badges. Corresponding completion
|
||||
styles are extracted from KikoTools' `web/ui/controls.css`. KikoTools is unchanged.
|
||||
Keyboard handling, input ARIA state, matching and insertion remain in consumers.
|
||||
The stylesheet hash above describes the initial extraction before this addition.
|
||||
|
||||
@@ -67,3 +67,13 @@
|
||||
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
|
||||
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
|
||||
}
|
||||
|
||||
.ca-tools.ca-completions { position: fixed; z-index: 10000; min-inline-size: min(240px, calc(100dvw - 16px)); max-inline-size: min(420px, calc(100dvw - 16px)); max-block-size: min(240px, calc(100dvh - 16px)); overflow: auto; border: 1px solid var(--ca-border); border-radius: 6px; box-shadow: 0 6px 20px #0005; box-sizing: border-box; }
|
||||
.ca-tools.ca-completions[hidden] { display: none; }
|
||||
.ca-tools.ca-completions .ca-completion-option { display: flex; align-items: flex-start; gap: 8px; padding: 7px 10px; min-block-size: 32px; cursor: pointer; }
|
||||
.ca-completion-type { flex-shrink: 0; font-size: 11px; color: var(--ca-focus); min-inline-size: 44px; }
|
||||
.ca-completion-name { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.ca-completion-directory { color: var(--ca-muted); }
|
||||
.ca-tools.ca-completions [aria-selected="true"] { background: var(--ca-accent); color: #fff; outline: 2px solid var(--ca-focus); outline-offset: -2px; }
|
||||
.ca-tools.ca-completions [aria-selected="true"] :where(.ca-completion-type, .ca-completion-directory) { color: inherit; }
|
||||
@media (pointer: coarse) { .ca-tools.ca-completions .ca-completion-option { min-block-size: 40px; } }
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "@comfyassets/toolkit",
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/66644f5620e10efeaba090dca2d5be54baa2ee14",
|
||||
"integrity": "sha512-jvDj3iYJHjRCH7nZimoF4KNLwEVdzKVQIOZrMfoLg9aLnHm98R8YvBUOvnlJ+JNrM8/LnD2m30u3ggSTnR0/sw==",
|
||||
"resolved": "https://codeload.github.com/ComfyAssets/ComfyAssets-Toolkit/tar.gz/b5a34b1d12336cd1bdfd6b890b80db63cbc3c389",
|
||||
"integrity": "sha512-rhI9xDH0U1WPBq7zuAN4rE6bLWx7ujTM7IIBNXINexN/z4FoQK4jnGXPMtNhW1XbbEpoZPZdExnrJpcRRPZXUw==",
|
||||
"files": {
|
||||
"core.js": {
|
||||
"source": "ui/core.js",
|
||||
"sha256": "cd10b700441a607cb1d56051cf0a430c0b36f901c0c351d8f4dae067090d8838"
|
||||
},
|
||||
"suggestions.js": {
|
||||
"source": "ui/suggestions.js",
|
||||
"sha256": "979b7867bcf7c8d5bec2c6631cec4c15a5cbb5774c4eaa554989b954555b9a04"
|
||||
},
|
||||
"controls.css": {
|
||||
"source": "ui/controls.css",
|
||||
"sha256": "3ae1ca39077ffc3621b86ae7ef77202841fa27061b0e4623f2bb0413b27ecd7b"
|
||||
"sha256": "9bb8662a5c911537c0577c5b21a861356550ec6235fcf2101d6585cc148efb8d"
|
||||
},
|
||||
"LICENSE": {
|
||||
"source": "LICENSE",
|
||||
@@ -18,7 +22,7 @@
|
||||
},
|
||||
"SOURCE.md": {
|
||||
"source": "SOURCE.md",
|
||||
"sha256": "46c226483f1d0c01524b1363435ccb3b1e67c2b0f6ed9161f7f087e49e8ed5a9"
|
||||
"sha256": "713be4367ac20a587aa7740b985086dc86a866706584de4d56646c6ed0e574c9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createScope, mountTheme } from './core.js';
|
||||
|
||||
let nextList = 0;
|
||||
|
||||
export function suggestionPosition(field, popup, viewport) {
|
||||
const left = Math.max(8, Math.min(field.left, viewport.width - popup.width - 8));
|
||||
const below = field.bottom + 4;
|
||||
const top = below + popup.height <= viewport.height - 8 ? below : field.top - popup.height - 4;
|
||||
return { left, top: Math.max(8, Math.min(top, viewport.height - popup.height - 8)) };
|
||||
}
|
||||
|
||||
export function createSuggestionView(scope, settings, { label = 'Suggestions' } = {}) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'ca-tools ca-completions';
|
||||
do { element.id = `ca-completions-${++nextList}`; }
|
||||
while (document.getElementById(element.id));
|
||||
element.role = 'listbox';
|
||||
element.setAttribute('aria-label', label);
|
||||
element.hidden = true;
|
||||
document.body.append(element);
|
||||
mountTheme(element, settings, scope);
|
||||
let optionsScope;
|
||||
scope.cleanup(() => { optionsScope?.dispose(); element.remove(); });
|
||||
return {
|
||||
element,
|
||||
hide() {
|
||||
optionsScope?.dispose();
|
||||
element.hidden = true;
|
||||
element.replaceChildren();
|
||||
},
|
||||
render(suggestions, onSelect, onInsert) {
|
||||
optionsScope?.dispose();
|
||||
optionsScope = createScope();
|
||||
element.replaceChildren();
|
||||
suggestions.forEach((item, index) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'ca-completion-option';
|
||||
option.id = `${element.id}-${index}`;
|
||||
option.role = 'option';
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'ca-completion-type';
|
||||
badge.textContent = item.type === 'embedding' ? '[EMB]' : item.type === 'lora' ? '[LORA]' : '[TAG]';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'ca-completion-name';
|
||||
const path = item.type === 'embedding' ? item.fullPath : null;
|
||||
if (path?.includes('/')) {
|
||||
const directory = document.createElement('span');
|
||||
directory.className = 'ca-completion-directory';
|
||||
directory.textContent = path.slice(0, path.lastIndexOf('/') + 1);
|
||||
name.append(directory, document.createTextNode(path.slice(path.lastIndexOf('/') + 1)));
|
||||
} else name.textContent = item.name;
|
||||
if (item.type) option.append(badge);
|
||||
option.append(name);
|
||||
optionsScope.listen(option, 'pointerdown', event => event.preventDefault());
|
||||
optionsScope.listen(option, 'pointermove', () => onSelect(index));
|
||||
optionsScope.listen(option, 'click', () => onInsert(item));
|
||||
element.append(option);
|
||||
});
|
||||
element.hidden = false;
|
||||
},
|
||||
select(index) {
|
||||
const options = [...element.children];
|
||||
options.forEach((option, position) => option.setAttribute('aria-selected', String(position === index)));
|
||||
options[index]?.scrollIntoView({ block: 'nearest' });
|
||||
return options[index]?.id;
|
||||
},
|
||||
position(field) {
|
||||
const popup = element.getBoundingClientRect();
|
||||
const { left, top } = suggestionPosition(field, popup, { width: innerWidth, height: innerHeight });
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user