mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(nodes): add Promp (LoraManager) node and autocomplete support
This commit is contained in:
@@ -2,6 +2,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.lora_manager import LoraManager
|
from .py.lora_manager import LoraManager
|
||||||
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
||||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||||
|
from .py.nodes.prompt import PromptLoraManager
|
||||||
from .py.nodes.lora_stacker import LoraStacker
|
from .py.nodes.lora_stacker import LoraStacker
|
||||||
from .py.nodes.save_image import SaveImage
|
from .py.nodes.save_image import SaveImage
|
||||||
from .py.nodes.debug_metadata import DebugMetadata
|
from .py.nodes.debug_metadata import DebugMetadata
|
||||||
@@ -17,6 +18,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
|||||||
if str(package_root) not in sys.path:
|
if str(package_root) not in sys.path:
|
||||||
sys.path.append(str(package_root))
|
sys.path.append(str(package_root))
|
||||||
|
|
||||||
|
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
|
||||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||||
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
||||||
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
||||||
@@ -29,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
|||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
|
PromptLoraManager.NAME: PromptLoraManager,
|
||||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||||
|
|||||||
@@ -666,6 +666,7 @@ NODE_EXTRACTORS = {
|
|||||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||||
# Conditioning
|
# Conditioning
|
||||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||||
|
"PromptLoraManager": CLIPTextEncodeExtractor,
|
||||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||||
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
||||||
|
|||||||
59
py/nodes/prompt.py
Normal file
59
py/nodes/prompt.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
from nodes import CLIPTextEncode # type: ignore
|
||||||
|
|
||||||
|
class PromptLoraManager:
|
||||||
|
"""Encodes text (and optional trigger words) into CLIP conditioning."""
|
||||||
|
|
||||||
|
NAME = "Prompt (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/conditioning"
|
||||||
|
DESCRIPTION = (
|
||||||
|
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||||
|
"to guide the diffusion model towards generating specific images."
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"text": (
|
||||||
|
'STRING',
|
||||||
|
{
|
||||||
|
"multiline": True,
|
||||||
|
"pysssss.autocomplete": False,
|
||||||
|
"dynamicPrompts": True,
|
||||||
|
"tooltip": "The text to be encoded.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"clip": (
|
||||||
|
'CLIP',
|
||||||
|
{"tooltip": "The CLIP model used for encoding the text."},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"trigger_words": (
|
||||||
|
'STRING',
|
||||||
|
{
|
||||||
|
"forceInput": True,
|
||||||
|
"tooltip": (
|
||||||
|
"Optional trigger words to prepend to the text before "
|
||||||
|
"encoding."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ('CONDITIONING', 'STRING',)
|
||||||
|
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
|
||||||
|
OUTPUT_TOOLTIPS = (
|
||||||
|
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||||
|
)
|
||||||
|
FUNCTION = "encode"
|
||||||
|
|
||||||
|
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
|
||||||
|
prompt = text
|
||||||
|
if trigger_words:
|
||||||
|
prompt = ", ".join([trigger_words, text])
|
||||||
|
|
||||||
|
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||||
|
return (conditioning, prompt,)
|
||||||
@@ -10,7 +10,7 @@ const {
|
|||||||
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
|
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
|
||||||
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/loras_widget_components.js', import.meta.url).pathname,
|
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
|
||||||
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,136 @@ function parseUsageTipNumber(value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitRelativePath(relativePath = '') {
|
||||||
|
const parts = relativePath.split(/[/\\]+/).filter(Boolean);
|
||||||
|
const fileName = parts.pop() ?? '';
|
||||||
|
return {
|
||||||
|
directories: parts,
|
||||||
|
fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGeneralExtension(fileName = '') {
|
||||||
|
return fileName.replace(/\.[^.]+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLoraExtension(fileName = '') {
|
||||||
|
return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultBehavior(modelType) {
|
||||||
|
return {
|
||||||
|
enablePreview: false,
|
||||||
|
async getInsertText(_instance, relativePath) {
|
||||||
|
const trimmed = relativePath?.trim() ?? '';
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${trimmed}, `;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODEL_BEHAVIORS = {
|
||||||
|
loras: {
|
||||||
|
enablePreview: true,
|
||||||
|
init(instance) {
|
||||||
|
if (!instance.options.showPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.initPreviewTooltip({ modelType: instance.modelType });
|
||||||
|
},
|
||||||
|
showPreview(instance, relativePath, itemElement) {
|
||||||
|
if (!instance.previewTooltip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.showPreviewForItem(relativePath, itemElement);
|
||||||
|
},
|
||||||
|
hidePreview(instance) {
|
||||||
|
if (!instance.previewTooltip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.previewTooltip.hide();
|
||||||
|
},
|
||||||
|
destroy(instance) {
|
||||||
|
if (instance.previewTooltip) {
|
||||||
|
instance.previewTooltip.cleanup();
|
||||||
|
instance.previewTooltip = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getInsertText(_instance, relativePath) {
|
||||||
|
const fileName = removeLoraExtension(splitRelativePath(relativePath).fileName);
|
||||||
|
|
||||||
|
let strength = 1.0;
|
||||||
|
let hasStrength = false;
|
||||||
|
let clipStrength = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.usage_tips) {
|
||||||
|
try {
|
||||||
|
const usageTips = JSON.parse(data.usage_tips);
|
||||||
|
const parsedStrength = parseUsageTipNumber(usageTips.strength);
|
||||||
|
if (parsedStrength !== null) {
|
||||||
|
strength = parsedStrength;
|
||||||
|
hasStrength = true;
|
||||||
|
}
|
||||||
|
const clipSource = usageTips.clip_strength ?? usageTips.clipStrength;
|
||||||
|
const parsedClipStrength = parseUsageTipNumber(clipSource);
|
||||||
|
if (parsedClipStrength !== null) {
|
||||||
|
clipStrength = parsedClipStrength;
|
||||||
|
if (!hasStrength) {
|
||||||
|
strength = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse usage tips JSON:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch usage tips:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clipStrength !== null) {
|
||||||
|
return `<lora:${fileName}:${strength}:${clipStrength}>, `;
|
||||||
|
}
|
||||||
|
return `<lora:${fileName}:${strength}>, `;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
embeddings: {
|
||||||
|
enablePreview: true,
|
||||||
|
init(instance) {
|
||||||
|
if (!instance.options.showPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
instance.initPreviewTooltip({ modelType: instance.modelType });
|
||||||
|
},
|
||||||
|
async getInsertText(_instance, relativePath) {
|
||||||
|
const { directories, fileName } = splitRelativePath(relativePath);
|
||||||
|
const trimmedName = removeGeneralExtension(fileName);
|
||||||
|
const folder = directories.length ? `${directories.join('\\')}\\` : '';
|
||||||
|
return `embedding:${folder}${trimmedName}, `;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getModelBehavior(modelType) {
|
||||||
|
return MODEL_BEHAVIORS[modelType] ?? createDefaultBehavior(modelType);
|
||||||
|
}
|
||||||
|
|
||||||
class AutoComplete {
|
class AutoComplete {
|
||||||
constructor(inputElement, modelType = 'loras', options = {}) {
|
constructor(inputElement, modelType = 'loras', options = {}) {
|
||||||
this.inputElement = inputElement;
|
this.inputElement = inputElement;
|
||||||
this.modelType = modelType;
|
this.modelType = modelType;
|
||||||
|
this.behavior = getModelBehavior(modelType);
|
||||||
this.options = {
|
this.options = {
|
||||||
maxItems: 20,
|
maxItems: 20,
|
||||||
minChars: 1,
|
minChars: 1,
|
||||||
debounceDelay: 200,
|
debounceDelay: 200,
|
||||||
showPreview: true,
|
showPreview: this.behavior.enablePreview ?? false,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +155,7 @@ class AutoComplete {
|
|||||||
this.isVisible = false;
|
this.isVisible = false;
|
||||||
this.currentSearchTerm = '';
|
this.currentSearchTerm = '';
|
||||||
this.previewTooltip = null;
|
this.previewTooltip = null;
|
||||||
|
this.previewTooltipPromise = null;
|
||||||
|
|
||||||
// Initialize TextAreaCaretHelper
|
// Initialize TextAreaCaretHelper
|
||||||
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
|
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
|
||||||
@@ -88,19 +210,24 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Append to body to avoid overflow issues
|
// Append to body to avoid overflow issues
|
||||||
document.body.appendChild(this.dropdown);
|
document.body.appendChild(this.dropdown);
|
||||||
|
|
||||||
// Initialize preview tooltip if needed
|
if (typeof this.behavior.init === 'function') {
|
||||||
if (this.options.showPreview && this.modelType === 'loras') {
|
this.behavior.init(this);
|
||||||
this.initPreviewTooltip();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initPreviewTooltip() {
|
initPreviewTooltip(options = {}) {
|
||||||
|
if (this.previewTooltip || this.previewTooltipPromise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Dynamically import and create preview tooltip
|
// Dynamically import and create preview tooltip
|
||||||
import('./loras_widget_components.js').then(module => {
|
this.previewTooltipPromise = import('./preview_tooltip.js').then(module => {
|
||||||
this.previewTooltip = new module.PreviewTooltip();
|
const config = { modelType: this.modelType, ...options };
|
||||||
|
this.previewTooltip = new module.PreviewTooltip(config);
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.warn('Failed to load preview tooltip:', err);
|
console.warn('Failed to load preview tooltip:', err);
|
||||||
|
}).finally(() => {
|
||||||
|
this.previewTooltipPromise = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +347,6 @@ class AutoComplete {
|
|||||||
// Hover and selection handlers
|
// Hover and selection handlers
|
||||||
item.addEventListener('mouseenter', () => {
|
item.addEventListener('mouseenter', () => {
|
||||||
this.selectItem(index);
|
this.selectItem(index);
|
||||||
this.showPreviewForItem(relativePath, item);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
item.addEventListener('mouseleave', () => {
|
item.addEventListener('mouseleave', () => {
|
||||||
@@ -256,7 +382,7 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showPreviewForItem(relativePath, itemElement) {
|
showPreviewForItem(relativePath, itemElement) {
|
||||||
if (!this.previewTooltip) return;
|
if (!this.options.showPreview || !this.previewTooltip) return;
|
||||||
|
|
||||||
// Extract filename without extension for preview
|
// Extract filename without extension for preview
|
||||||
const fileName = relativePath.split(/[/\\]/).pop();
|
const fileName = relativePath.split(/[/\\]/).pop();
|
||||||
@@ -271,7 +397,12 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hidePreview() {
|
hidePreview() {
|
||||||
if (this.previewTooltip) {
|
if (!this.options.showPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof this.behavior.hidePreview === 'function') {
|
||||||
|
this.behavior.hidePreview(this);
|
||||||
|
} else if (this.previewTooltip) {
|
||||||
this.previewTooltip.hide();
|
this.previewTooltip.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +485,11 @@ class AutoComplete {
|
|||||||
|
|
||||||
// Show preview for selected item
|
// Show preview for selected item
|
||||||
if (this.options.showPreview) {
|
if (this.options.showPreview) {
|
||||||
this.showPreviewForItem(this.items[index], item);
|
if (typeof this.behavior.showPreview === 'function') {
|
||||||
|
this.behavior.showPreview(this, this.items[index], item);
|
||||||
|
} else if (this.previewTooltip) {
|
||||||
|
this.showPreviewForItem(this.items[index], item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,47 +525,12 @@ class AutoComplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async insertSelection(relativePath) {
|
async insertSelection(relativePath) {
|
||||||
// Extract just the filename for LoRA name
|
const insertText = await this.getInsertText(relativePath);
|
||||||
const fileName = relativePath.split(/[/\\]/).pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
if (!insertText) {
|
||||||
|
this.hide();
|
||||||
// Get usage tips and extract strength information
|
return;
|
||||||
let strength = 1.0; // Default strength
|
|
||||||
let hasStrength = false;
|
|
||||||
let clipStrength = null;
|
|
||||||
try {
|
|
||||||
const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success && data.usage_tips) {
|
|
||||||
try {
|
|
||||||
const usageTips = JSON.parse(data.usage_tips);
|
|
||||||
const parsedStrength = parseUsageTipNumber(usageTips.strength);
|
|
||||||
if (parsedStrength !== null) {
|
|
||||||
strength = parsedStrength;
|
|
||||||
hasStrength = true;
|
|
||||||
}
|
|
||||||
const clipSource = usageTips.clip_strength ?? usageTips.clipStrength;
|
|
||||||
const parsedClipStrength = parseUsageTipNumber(clipSource);
|
|
||||||
if (parsedClipStrength !== null) {
|
|
||||||
clipStrength = parsedClipStrength;
|
|
||||||
if (!hasStrength) {
|
|
||||||
strength = 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
console.warn('Failed to parse usage tips JSON:', parseError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch usage tips:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the LoRA code with strength values
|
|
||||||
const loraCode = clipStrength !== null
|
|
||||||
? `<lora:${fileName}:${strength}:${clipStrength}>, `
|
|
||||||
: `<lora:${fileName}:${strength}>, `;
|
|
||||||
|
|
||||||
const currentValue = this.inputElement.value;
|
const currentValue = this.inputElement.value;
|
||||||
const caretPos = this.getCaretPosition();
|
const caretPos = this.getCaretPosition();
|
||||||
|
|
||||||
@@ -440,8 +540,8 @@ class AutoComplete {
|
|||||||
const searchStartPos = caretPos - searchTerm.length;
|
const searchStartPos = caretPos - searchTerm.length;
|
||||||
|
|
||||||
// Only replace the search term, not everything after the last comma
|
// Only replace the search term, not everything after the last comma
|
||||||
const newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
|
const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos);
|
||||||
const newCaretPos = searchStartPos + loraCode.length;
|
const newCaretPos = searchStartPos + insertText.length;
|
||||||
|
|
||||||
this.inputElement.value = newValue;
|
this.inputElement.value = newValue;
|
||||||
|
|
||||||
@@ -455,18 +555,42 @@ class AutoComplete {
|
|||||||
this.inputElement.focus();
|
this.inputElement.focus();
|
||||||
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getInsertText(relativePath) {
|
||||||
|
if (typeof this.behavior.getInsertText === 'function') {
|
||||||
|
try {
|
||||||
|
const result = await this.behavior.getInsertText(this, relativePath);
|
||||||
|
if (typeof result === 'string' && result.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to format autocomplete insertion:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = typeof relativePath === 'string' ? relativePath.trim() : '';
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${trimmed}, `;
|
||||||
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer);
|
clearTimeout(this.debounceTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.previewTooltip) {
|
if (typeof this.behavior.destroy === 'function') {
|
||||||
|
this.behavior.destroy(this);
|
||||||
|
} else if (this.previewTooltip) {
|
||||||
this.previewTooltip.cleanup();
|
this.previewTooltip.cleanup();
|
||||||
|
this.previewTooltip = null;
|
||||||
}
|
}
|
||||||
|
this.previewTooltipPromise = null;
|
||||||
|
|
||||||
if (this.dropdown && this.dropdown.parentNode) {
|
if (this.dropdown && this.dropdown.parentNode) {
|
||||||
this.dropdown.parentNode.removeChild(this.dropdown);
|
this.dropdown.parentNode.removeChild(this.dropdown);
|
||||||
|
this.dropdown = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove event listeners would be added here if we tracked them
|
// Remove event listeners would be added here if we tracked them
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js";
|
import { createToggle, createArrowButton, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js";
|
||||||
import {
|
import {
|
||||||
parseLoraValue,
|
parseLoraValue,
|
||||||
formatLoraValue,
|
formatLoraValue,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "./loras_widget_utils.js";
|
} from "./loras_widget_utils.js";
|
||||||
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
||||||
import { forwardMiddleMouseToCanvas } from "./utils.js";
|
import { forwardMiddleMouseToCanvas } from "./utils.js";
|
||||||
|
import { PreviewTooltip } from "./preview_tooltip.js";
|
||||||
|
|
||||||
export function addLorasWidget(node, name, opts, callback) {
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
// Create container for loras
|
// Create container for loras
|
||||||
@@ -39,7 +40,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
const defaultValue = opts?.defaultVal || [];
|
const defaultValue = opts?.defaultVal || [];
|
||||||
|
|
||||||
// Create preview tooltip instance
|
// Create preview tooltip instance
|
||||||
const previewTooltip = new PreviewTooltip();
|
const previewTooltip = new PreviewTooltip({ modelType: "loras" });
|
||||||
|
|
||||||
// Selection state - only one LoRA can be selected at a time
|
// Selection state - only one LoRA can be selected at a time
|
||||||
let selectedLora = null;
|
let selectedLora = null;
|
||||||
@@ -705,4 +706,4 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return { minWidth: 400, minHeight: defaultHeight, widget };
|
return { minWidth: 400, minHeight: defaultHeight, widget };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { api } from "../../scripts/api.js";
|
|
||||||
|
|
||||||
// Function to create toggle element
|
// Function to create toggle element
|
||||||
export function createToggle(active, onChange) {
|
export function createToggle(active, onChange) {
|
||||||
const toggle = document.createElement("div");
|
const toggle = document.createElement("div");
|
||||||
@@ -217,229 +215,6 @@ export function createMenuItem(text, icon, onClick) {
|
|||||||
return menuItem;
|
return menuItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview tooltip class
|
|
||||||
export class PreviewTooltip {
|
|
||||||
constructor() {
|
|
||||||
this.element = document.createElement('div');
|
|
||||||
Object.assign(this.element.style, {
|
|
||||||
position: 'fixed',
|
|
||||||
zIndex: 9999,
|
|
||||||
background: 'rgba(0, 0, 0, 0.85)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
|
||||||
display: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
maxWidth: '300px',
|
|
||||||
pointerEvents: 'none', // Prevent interference with autocomplete
|
|
||||||
});
|
|
||||||
document.body.appendChild(this.element);
|
|
||||||
this.hideTimeout = null;
|
|
||||||
this.isFromAutocomplete = false;
|
|
||||||
|
|
||||||
// Modified event listeners for autocomplete compatibility
|
|
||||||
this.globalClickHandler = (e) => {
|
|
||||||
// Don't hide if click is on autocomplete dropdown
|
|
||||||
if (!e.target.closest('.comfy-autocomplete-dropdown')) {
|
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', this.globalClickHandler);
|
|
||||||
|
|
||||||
this.globalScrollHandler = () => this.hide();
|
|
||||||
document.addEventListener('scroll', this.globalScrollHandler, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async show(loraName, x, y, fromAutocomplete = false) {
|
|
||||||
try {
|
|
||||||
// Clear previous hide timer
|
|
||||||
if (this.hideTimeout) {
|
|
||||||
clearTimeout(this.hideTimeout);
|
|
||||||
this.hideTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track if this is from autocomplete
|
|
||||||
this.isFromAutocomplete = fromAutocomplete;
|
|
||||||
|
|
||||||
// Don't redisplay the same lora preview
|
|
||||||
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
|
||||||
this.position(x, y);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentLora = loraName;
|
|
||||||
|
|
||||||
// Get preview URL
|
|
||||||
const response = await api.fetchApi(`/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`, {
|
|
||||||
method: 'GET'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch preview URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!data.success || !data.preview_url) {
|
|
||||||
throw new Error('No preview available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing content
|
|
||||||
while (this.element.firstChild) {
|
|
||||||
this.element.removeChild(this.element.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create media container with relative positioning
|
|
||||||
const mediaContainer = document.createElement('div');
|
|
||||||
Object.assign(mediaContainer.style, {
|
|
||||||
position: 'relative',
|
|
||||||
maxWidth: '300px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
});
|
|
||||||
|
|
||||||
const isVideo = data.preview_url.endsWith('.mp4');
|
|
||||||
const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
|
|
||||||
|
|
||||||
Object.assign(mediaElement.style, {
|
|
||||||
maxWidth: '300px',
|
|
||||||
maxHeight: '300px',
|
|
||||||
objectFit: 'contain',
|
|
||||||
display: 'block',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isVideo) {
|
|
||||||
mediaElement.autoplay = true;
|
|
||||||
mediaElement.loop = true;
|
|
||||||
mediaElement.muted = true;
|
|
||||||
mediaElement.controls = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create name label with absolute positioning
|
|
||||||
const nameLabel = document.createElement('div');
|
|
||||||
nameLabel.textContent = loraName;
|
|
||||||
Object.assign(nameLabel.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '0',
|
|
||||||
left: '0',
|
|
||||||
right: '0',
|
|
||||||
padding: '8px',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
|
||||||
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
textAlign: 'center',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
WebkitBackdropFilter: 'blur(4px)',
|
|
||||||
});
|
|
||||||
|
|
||||||
mediaContainer.appendChild(mediaElement);
|
|
||||||
mediaContainer.appendChild(nameLabel);
|
|
||||||
this.element.appendChild(mediaContainer);
|
|
||||||
|
|
||||||
// Show element with opacity 0 first to get dimensions
|
|
||||||
this.element.style.opacity = '0';
|
|
||||||
this.element.style.display = 'block';
|
|
||||||
|
|
||||||
// Wait for media to load before positioning
|
|
||||||
const waitForLoad = () => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (isVideo) {
|
|
||||||
if (mediaElement.readyState >= 2) { // HAVE_CURRENT_DATA
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
mediaElement.addEventListener('loadeddata', resolve, { once: true });
|
|
||||||
mediaElement.addEventListener('error', resolve, { once: true });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (mediaElement.complete) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
mediaElement.addEventListener('load', resolve, { once: true });
|
|
||||||
mediaElement.addEventListener('error', resolve, { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a timeout to prevent hanging
|
|
||||||
setTimeout(resolve, 1000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set source after setting up load listeners
|
|
||||||
mediaElement.src = data.preview_url;
|
|
||||||
|
|
||||||
// Wait for content to load, then position and show
|
|
||||||
await waitForLoad();
|
|
||||||
|
|
||||||
// Small delay to ensure layout is complete
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
this.position(x, y);
|
|
||||||
this.element.style.transition = 'opacity 0.15s ease';
|
|
||||||
this.element.style.opacity = '1';
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to load preview:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
position(x, y) {
|
|
||||||
// Ensure preview box doesn't exceed viewport boundaries
|
|
||||||
const rect = this.element.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let left = x + 10; // Default 10px offset to the right of mouse
|
|
||||||
let top = y + 10; // Default 10px offset below mouse
|
|
||||||
|
|
||||||
// Check right boundary
|
|
||||||
if (left + rect.width > viewportWidth) {
|
|
||||||
left = x - rect.width - 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bottom boundary
|
|
||||||
if (top + rect.height > viewportHeight) {
|
|
||||||
top = y - rect.height - 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure minimum distance from edges
|
|
||||||
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
|
|
||||||
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
|
|
||||||
|
|
||||||
Object.assign(this.element.style, {
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
// Use fade-out effect
|
|
||||||
if (this.element.style.display === 'block') {
|
|
||||||
this.element.style.opacity = '0';
|
|
||||||
this.hideTimeout = setTimeout(() => {
|
|
||||||
this.element.style.display = 'none';
|
|
||||||
this.currentLora = null;
|
|
||||||
this.isFromAutocomplete = false;
|
|
||||||
// Stop video playback
|
|
||||||
const video = this.element.querySelector('video');
|
|
||||||
if (video) {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
this.hideTimeout = null;
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
if (this.hideTimeout) {
|
|
||||||
clearTimeout(this.hideTimeout);
|
|
||||||
}
|
|
||||||
// Remove event listeners properly
|
|
||||||
document.removeEventListener('click', this.globalClickHandler);
|
|
||||||
document.removeEventListener('scroll', this.globalScrollHandler, true);
|
|
||||||
this.element.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to create expand/collapse button
|
// Function to create expand/collapse button
|
||||||
export function createExpandButton(isExpanded, onClick) {
|
export function createExpandButton(isExpanded, onClick) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
|||||||
258
web/comfyui/preview_tooltip.js
Normal file
258
web/comfyui/preview_tooltip.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight preview tooltip that can display images or videos for different model types.
|
||||||
|
*/
|
||||||
|
export class PreviewTooltip {
|
||||||
|
constructor(options = {}) {
|
||||||
|
const {
|
||||||
|
modelType = "loras",
|
||||||
|
previewUrlResolver,
|
||||||
|
displayNameFormatter,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
this.modelType = modelType;
|
||||||
|
this.previewUrlResolver =
|
||||||
|
typeof previewUrlResolver === "function"
|
||||||
|
? previewUrlResolver
|
||||||
|
: (name) => this.defaultPreviewUrlResolver(name);
|
||||||
|
this.displayNameFormatter =
|
||||||
|
typeof displayNameFormatter === "function"
|
||||||
|
? displayNameFormatter
|
||||||
|
: (name) => name;
|
||||||
|
|
||||||
|
this.element = document.createElement("div");
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
position: "fixed",
|
||||||
|
zIndex: 9999,
|
||||||
|
background: "rgba(0, 0, 0, 0.85)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)",
|
||||||
|
display: "none",
|
||||||
|
overflow: "hidden",
|
||||||
|
maxWidth: "300px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
});
|
||||||
|
document.body.appendChild(this.element);
|
||||||
|
this.hideTimeout = null;
|
||||||
|
this.isFromAutocomplete = false;
|
||||||
|
this.currentModelName = null;
|
||||||
|
|
||||||
|
this.globalClickHandler = (event) => {
|
||||||
|
if (!event.target.closest(".comfy-autocomplete-dropdown")) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("click", this.globalClickHandler);
|
||||||
|
|
||||||
|
this.globalScrollHandler = () => this.hide();
|
||||||
|
document.addEventListener("scroll", this.globalScrollHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async defaultPreviewUrlResolver(modelName) {
|
||||||
|
const response = await api.fetchApi(
|
||||||
|
`/lm/${this.modelType}/preview-url?name=${encodeURIComponent(modelName)}`,
|
||||||
|
{ method: "GET" }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch preview URL");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.preview_url) {
|
||||||
|
throw new Error("No preview available");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
previewUrl: data.preview_url,
|
||||||
|
displayName: data.display_name ?? modelName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolvePreviewData(modelName) {
|
||||||
|
const raw = await this.previewUrlResolver(modelName);
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("No preview data returned");
|
||||||
|
}
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
return {
|
||||||
|
previewUrl: raw,
|
||||||
|
displayName: this.displayNameFormatter(modelName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previewUrl, displayName } = raw;
|
||||||
|
if (!previewUrl) {
|
||||||
|
throw new Error("No preview URL available");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
previewUrl,
|
||||||
|
displayName:
|
||||||
|
displayName !== undefined
|
||||||
|
? displayName
|
||||||
|
: this.displayNameFormatter(modelName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async show(modelName, x, y, fromAutocomplete = false) {
|
||||||
|
try {
|
||||||
|
if (this.hideTimeout) {
|
||||||
|
clearTimeout(this.hideTimeout);
|
||||||
|
this.hideTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFromAutocomplete = fromAutocomplete;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.element.style.display === "block" &&
|
||||||
|
this.currentModelName === modelName
|
||||||
|
) {
|
||||||
|
this.position(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentModelName = modelName;
|
||||||
|
const { previewUrl, displayName } = await this.resolvePreviewData(
|
||||||
|
modelName
|
||||||
|
);
|
||||||
|
|
||||||
|
while (this.element.firstChild) {
|
||||||
|
this.element.removeChild(this.element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaContainer = document.createElement("div");
|
||||||
|
Object.assign(mediaContainer.style, {
|
||||||
|
position: "relative",
|
||||||
|
maxWidth: "300px",
|
||||||
|
maxHeight: "300px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVideo = previewUrl.endsWith(".mp4");
|
||||||
|
const mediaElement = isVideo
|
||||||
|
? document.createElement("video")
|
||||||
|
: document.createElement("img");
|
||||||
|
|
||||||
|
Object.assign(mediaElement.style, {
|
||||||
|
maxWidth: "300px",
|
||||||
|
maxHeight: "300px",
|
||||||
|
objectFit: "contain",
|
||||||
|
display: "block",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
mediaElement.autoplay = true;
|
||||||
|
mediaElement.loop = true;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
mediaElement.controls = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLabel = document.createElement("div");
|
||||||
|
nameLabel.textContent = displayName;
|
||||||
|
Object.assign(nameLabel.style, {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "0",
|
||||||
|
left: "0",
|
||||||
|
right: "0",
|
||||||
|
padding: "8px",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontFamily:
|
||||||
|
"'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
||||||
|
background: "linear-gradient(transparent, rgba(0, 0, 0, 0.8))",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
textAlign: "center",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
WebkitBackdropFilter: "blur(4px)",
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaContainer.appendChild(mediaElement);
|
||||||
|
mediaContainer.appendChild(nameLabel);
|
||||||
|
this.element.appendChild(mediaContainer);
|
||||||
|
|
||||||
|
this.element.style.opacity = "0";
|
||||||
|
this.element.style.display = "block";
|
||||||
|
|
||||||
|
const waitForLoad = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (isVideo) {
|
||||||
|
if (mediaElement.readyState >= 2) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
mediaElement.addEventListener("loadeddata", resolve, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
mediaElement.addEventListener("error", resolve, { once: true });
|
||||||
|
}
|
||||||
|
} else if (mediaElement.complete) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
mediaElement.addEventListener("load", resolve, { once: true });
|
||||||
|
mediaElement.addEventListener("error", resolve, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(resolve, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaElement.src = previewUrl;
|
||||||
|
await waitForLoad();
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.position(x, y);
|
||||||
|
this.element.style.transition = "opacity 0.15s ease";
|
||||||
|
this.element.style.opacity = "1";
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load preview:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position(x, y) {
|
||||||
|
const rect = this.element.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = x + 10;
|
||||||
|
let top = y + 10;
|
||||||
|
|
||||||
|
if (left + rect.width > viewportWidth) {
|
||||||
|
left = x - rect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + rect.height > viewportHeight) {
|
||||||
|
top = y - rect.height - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
|
||||||
|
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
|
||||||
|
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (this.element.style.display === "block") {
|
||||||
|
this.element.style.opacity = "0";
|
||||||
|
this.hideTimeout = setTimeout(() => {
|
||||||
|
this.element.style.display = "none";
|
||||||
|
this.currentModelName = null;
|
||||||
|
this.isFromAutocomplete = false;
|
||||||
|
const video = this.element.querySelector("video");
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
this.hideTimeout = null;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this.hideTimeout) {
|
||||||
|
clearTimeout(this.hideTimeout);
|
||||||
|
}
|
||||||
|
document.removeEventListener("click", this.globalClickHandler);
|
||||||
|
document.removeEventListener("scroll", this.globalScrollHandler, true);
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
29
web/comfyui/prompt.js
Normal file
29
web/comfyui/prompt.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
import { chainCallback, setupInputWidgetWithAutocomplete } from "./utils.js";
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: "LoraManager.Prompt",
|
||||||
|
|
||||||
|
async beforeRegisterNodeDef(nodeType) {
|
||||||
|
if (nodeType.comfyClass === "Prompt (LoraManager)") {
|
||||||
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||||
|
this.serialize_widgets = true;
|
||||||
|
|
||||||
|
const textWidget = this.widgets?.[0];
|
||||||
|
if (!textWidget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalCallback =
|
||||||
|
typeof textWidget.callback === "function" ? textWidget.callback : null;
|
||||||
|
|
||||||
|
textWidget.callback = setupInputWidgetWithAutocomplete(
|
||||||
|
this,
|
||||||
|
textWidget,
|
||||||
|
originalCallback,
|
||||||
|
"embeddings"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -383,27 +383,31 @@ export function mergeLoras(lorasText, lorasArr) {
|
|||||||
* @param {Object} node - The node instance
|
* @param {Object} node - The node instance
|
||||||
* @param {Object} inputWidget - The input widget to add autocomplete to
|
* @param {Object} inputWidget - The input widget to add autocomplete to
|
||||||
* @param {Function} originalCallback - The original callback function
|
* @param {Function} originalCallback - The original callback function
|
||||||
|
* @param {string} [modelType='loras'] - The model type used by the autocomplete API
|
||||||
|
* @param {Object} [autocompleteOptions] - Additional options for the autocomplete instance
|
||||||
* @returns {Function} Enhanced callback function with autocomplete
|
* @returns {Function} Enhanced callback function with autocomplete
|
||||||
*/
|
*/
|
||||||
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback) {
|
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback, modelType = 'loras', autocompleteOptions = {}) {
|
||||||
let autocomplete = null;
|
let autocomplete = null;
|
||||||
|
const defaultOptions = {
|
||||||
|
maxItems: 20,
|
||||||
|
minChars: 1,
|
||||||
|
debounceDelay: 200,
|
||||||
|
};
|
||||||
|
const mergedOptions = { ...defaultOptions, ...autocompleteOptions };
|
||||||
|
|
||||||
// Enhanced callback that initializes autocomplete and calls original callback
|
// Enhanced callback that initializes autocomplete and calls original callback
|
||||||
const enhancedCallback = (value) => {
|
const enhancedCallback = (value) => {
|
||||||
// Initialize autocomplete on first callback if not already done
|
// Initialize autocomplete on first callback if not already done
|
||||||
if (!autocomplete && inputWidget.inputEl) {
|
if (!autocomplete && inputWidget.inputEl) {
|
||||||
autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', {
|
autocomplete = new AutoComplete(inputWidget.inputEl, modelType, mergedOptions);
|
||||||
maxItems: 20,
|
|
||||||
minChars: 1,
|
|
||||||
debounceDelay: 200
|
|
||||||
});
|
|
||||||
// Store reference for cleanup
|
// Store reference for cleanup
|
||||||
node.autocomplete = autocomplete;
|
node.autocomplete = autocomplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original callback
|
// Call the original callback
|
||||||
if (originalCallback) {
|
if (typeof originalCallback === "function") {
|
||||||
originalCallback(value);
|
originalCallback.call(node, value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -457,4 +461,4 @@ export function forwardMiddleMouseToCanvas(container) {
|
|||||||
app.canvas.processMouseUp(event);
|
app.canvas.processMouseUp(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user