Implement similar tags feature with UI and settings integration

This commit is contained in:
newtextdoc1111
2025-05-02 22:10:59 +09:00
parent 87e067854c
commit edfb389a3c
5 changed files with 851 additions and 71 deletions
-4
View File
@@ -1,8 +1,4 @@
from .modules.api import *
# --- Original Node Mappings (Keep or modify as needed) ---
WEB_DIRECTORY = "./web"
NODE_CLASS_MAPPINGS = {}
__all__ = []
print("### Loading: ComfyUI-Autocomplete-Plus ###")
@@ -47,3 +47,80 @@
color: var(--comfy-menu-text-selected, var(--button-text)); /* Use selected text color, fallback */
font-weight: bold;
}
/* Similar Tags UI Styles */
#similar-tags-container {
background-color: var(--comfy-input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
min-width: 200px;
max-width: 300px;
overflow-y: auto;
position: absolute;
z-index: 10000;
color: var(--input-text);
font-size: 0.9em;
}
#similar-tags-header {
background-color: var(--comfy-menu-bg);
color: var(--desc-text);
padding: 8px 12px;
font-weight: bold;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 1;
}
#similar-tags-list {
max-height: 300px;
overflow-y: auto;
padding: 5px;
}
.similar-tag-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
margin: 2px 0;
cursor: pointer;
border-radius: 3px;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-color-translucent);
}
.similar-tag-item:last-child {
border-bottom: none;
}
.similar-tag-item:hover {
background-color: var(--comfy-hover-bg);
}
.similar-tag-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.similar-tag-similarity {
color: var(--desc-text);
font-size: 0.85em;
padding: 2px 6px;
background-color: var(--comfy-menu-bg);
border-radius: 10px;
min-width: 36px;
text-align: center;
}
.similar-tags-empty {
padding: 12px;
text-align: center;
color: var(--error-text);
font-style: italic;
}
+285 -63
View File
@@ -1,4 +1,11 @@
import { settingValues } from './settings.js';
import {
findSimilarTags,
getCurrentTag,
showSimilarTags,
hideSimilarTags,
isSimilarTagsVisible
} from './similar-tags.js';
// --- Autocomplete UI Class ---
@@ -635,7 +642,13 @@ function insertTag(inputElement, tagToInsert) {
const lastSeparator = Math.max(lastNewLine, lastComma);
const start = lastSeparator === -1 ? 0 : lastSeparator + 1;
const actualTag = tagToInsert.replace("_", " ");
// Replace underscores with spaces and escape parentheses
const actualTag = tagToInsert
.replace(/_/g, " ")
.replace(/\\\(/g, "\\\\(") // Escape backslashes before parentheses first
.replace(/\\\)/g, "\\\\)")
.replace(/\(/g, "\\(") // Escape opening parenthesis
.replace(/\)/g, "\\)"); // Escape closing parenthesis
// Find the next comma or newline after the cursor position
let endComma = text.indexOf(',', cursorPos);
@@ -666,7 +679,9 @@ function insertTag(inputElement, tagToInsert) {
const textBefore = text.substring(0, start) + (needsSpaceBefore ? ' ' : '');
// Get text after the cursor
const textAfter = text.substring(cursorPos);
// We need to determine the correct end position for replacement
// It should be the end of the word/tag being replaced, not just the cursor position
const textAfter = text.substring(end); // Use 'end' instead of 'cursorPos'
// Standard separator (comma + space)
const suffix = ', ';
@@ -696,6 +711,11 @@ function handleInput(event) {
} else {
autocompleteUI.hide();
}
// Handle similar tags (if enabled and in click mode, hide until clicked)
if (settingValues.enableSimilarTags && settingValues.similarTagsDisplayMode !== 'hover') {
hideSimilarTags();
}
}
function handleFocus(event) {
@@ -715,42 +735,104 @@ function handleBlur(event) {
if (autocompleteUI && !autocompleteUI.element.contains(document.activeElement)) {
autocompleteUI.hide();
}
// Don't hide similar tags panel on blur if it doesn't contain target
// This is handled by the similar-tags module
}, 150);
}
function handleKeyDown(event) {
if (!settingValues.enabled || !autocompleteUI || !autocompleteUI.isVisible()) return;
if (!settingValues.enabled) return;
const textareaElement = event.target;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
autocompleteUI.navigate(1);
break;
case 'ArrowUp':
event.preventDefault();
autocompleteUI.navigate(-1);
break;
case 'Enter':
case 'Tab':
if (autocompleteUI.getSelectedTag() !== null) {
// Handle autocomplete navigation
if (autocompleteUI && autocompleteUI.isVisible()) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
autocompleteUI.navigate(1);
break;
case 'ArrowUp':
event.preventDefault();
autocompleteUI.navigate(-1);
break;
case 'Enter':
case 'Tab':
if (autocompleteUI.getSelectedTag() !== null) {
event.preventDefault();
insertTag(textareaElement, autocompleteUI.getSelectedTag());
} else {
// Allow default Tab/Enter if no item is selected
autocompleteUI.hide();
}
break;
case 'Escape':
event.preventDefault();
insertTag(textareaElement, autocompleteUI.getSelectedTag());
} else {
// Allow default Tab/Enter if no item is selected
autocompleteUI.hide();
}
break;
case 'Escape':
break;
}
}
// For similar tags panel, handle Escape key
else if (isSimilarTagsVisible()) {
if (event.key === 'Escape') {
event.preventDefault();
autocompleteUI.hide();
break;
hideSimilarTags();
}
}
// In click mode, show similar tags on Ctrl+Space
else if (settingValues.enableSimilarTags && settingValues.similarTagsDisplayMode === 'click') {
if (event.key === ' ' && event.ctrlKey) {
event.preventDefault();
showSimilarTagsForCurrentPosition(textareaElement);
}
}
}
// New event handler for mousemove to show similar tags on hover
function handleMouseMove(event) {
if (!settingValues.enabled || !settingValues.enableSimilarTags ||
settingValues.similarTagsDisplayMode !== 'hover') return;
const textareaElement = event.target;
// Throttle the mousemove event to avoid too many calculations
if (!textareaElement.dataset.lastMoveTime ||
Date.now() - textareaElement.dataset.lastMoveTime > 200) {
textareaElement.dataset.lastMoveTime = Date.now();
showSimilarTagsForCurrentPosition(textareaElement);
}
}
// New event handler for click to show similar tags
function handleClick(event) {
if (!settingValues.enabled || !settingValues.enableSimilarTags ||
settingValues.similarTagsDisplayMode !== 'click') return;
const textareaElement = event.target;
showSimilarTagsForCurrentPosition(textareaElement);
}
// Helper function to show similar tags based on cursor position
function showSimilarTagsForCurrentPosition(textareaElement) {
// Get the tag at current cursor position
const currentTag = getCurrentTag(textareaElement);
// If no valid tag or tag is too short, hide the panel
if (!currentTag || currentTag.length < 2) {
hideSimilarTags();
return;
}
// Find similar tags
const similarTagsResults = findSimilarTags(currentTag, cooccurrenceMap, tagMap);
// Always show the panel with current tag, even if there are no similar tags
showSimilarTags(textareaElement, currentTag, similarTagsResults);
}
// Data storage
let settings = null;
let tagMap = new Map();
let aliasMap = new Map();
let sortedTags = []; // Now populated directly from sorted API response
@@ -762,20 +844,55 @@ let cooccurrenceLoaded = false;
// --- Data Loading Functions ---
/**
* Loads and processes tag data from the Python API endpoint.
* Loads and processes tag data from the CSV file.
*/
async function loadTags() {
const url = '/autocomplete-plus/tags';
async function loadTags(rootPath) {
const startTime = performance.now(); // 処理開始時間を記録
const url = rootPath + 'data/danbooru_tags.csv';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); // Data is already sorted by count
sortedTags = data; // Assign directly as it's pre-sorted
data.forEach(tagData => {
const csvText = await response.text(); // Get raw CSV text
const lines = csvText.split('\n').filter(line => line.trim().length > 0);
// Skip header row if present (tag,alias,count)
const startIndex = lines[0].startsWith('tag,alias,count') ? 1 : 0;
const parsedData = [];
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i];
// Handle CSV parsing properly (consider quotes and commas in values)
const columns = parseCSVLine(line);
if (columns.length >= 3) {
const tag = columns[0].trim();
const aliasStr = columns[1].trim();
const count = parseInt(columns[2].trim(), 10);
// Skip invalid entries
if (!tag || isNaN(count)) continue;
// Parse aliases - might be comma-separated list inside quotes
const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : [];
parsedData.push({
tag,
alias: aliases,
count
});
}
}
// Sort by count in descending order
parsedData.sort((a, b) => b.count - a.count);
sortedTags = parsedData;
// Build maps as before
sortedTags.forEach(tagData => {
tagMap.set(tagData.tag, tagData);
if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => {
@@ -787,7 +904,10 @@ async function loadTags() {
});
tagsLoaded = true;
console.log(`[Autocomplete-Plus] Processed ${sortedTags.length} tags from API.`);
// 処理終了時間を記録し、パフォーマンスログを出力
const endTime = performance.now();
const duration = endTime - startTime;
console.log(`[Autocomplete-Plus] Processed ${sortedTags.length} tags from CSV in ${duration.toFixed(2)}ms.`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${url}:`, error);
@@ -796,52 +916,150 @@ async function loadTags() {
}
/**
* Loads and processes cooccurrence data from the Python API endpoint.
* Loads and processes cooccurrence data from the CSV file using chunked processing.
*/
async function loadCooccurrence() {
const url = '/autocomplete-plus/cooccurrence';
async function loadCooccurrence(rootPath) {
const url = rootPath + 'data/danbooru_tags_cooccurrence.csv';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); // Data is in { tag_a: { tag_b: count, ... }, ... } format
const csvText = await response.text();
const lines = csvText.split('\n').filter(line => line.trim().length > 0);
// Skip header row if present (tag_a,tag_b,count)
const startIndex = lines[0].startsWith('tag_a,tag_b,count') ? 1 : 0;
// Create a new Map to store the bidirectional relationships
const bidirectionalMap = new Map();
// Process CSV data in chunks
await processInChunks(lines, startIndex, bidirectionalMap);
// Assign the bidirectional map to the cooccurrenceMap
let actualPairCount = 0;
let primaryTagCount = 0;
let pairCount = 0;
for (const tagA in data) {
if (Object.hasOwnProperty.call(data, tagA)) {
const innerMap = new Map();
const pairs = data[tagA];
for (const tagB in pairs) {
if (Object.hasOwnProperty.call(pairs, tagB)) {
innerMap.set(tagB, pairs[tagB]);
pairCount++;
}
}
if (innerMap.size > 0) {
cooccurrenceMap.set(tagA, innerMap);
primaryTagCount++;
}
}
cooccurrenceMap.clear();
for (const [tag, relatedTags] of bidirectionalMap) {
cooccurrenceMap.set(tag, relatedTags);
actualPairCount += relatedTags.size;
primaryTagCount++;
}
cooccurrenceLoaded = true;
console.log(`[Autocomplete-Plus] Processed ${pairCount} cooccurrence pairs for ${primaryTagCount} primary tags from API.`);
console.log(`[Autocomplete-Plus] Processed bidirectional relationships for ${primaryTagCount} tags from CSV.`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${url}:`, error);
cooccurrenceLoaded = false;
}
}
/**
* Process CSV data in chunks to avoid blocking the UI
*/
function processInChunks(lines, startIndex, bidirectionalMap) {
return new Promise((resolve) => {
const CHUNK_SIZE = 10000; // Process 10,000 lines at a time
let i = startIndex;
let pairCount = 0;
function processChunk() {
const endIndex = Math.min(i + CHUNK_SIZE, lines.length);
for (; i < endIndex; i++) {
const line = lines[i];
const columns = parseCSVLine(line);
if (columns.length >= 3) {
const tagA = columns[0].trim();
const tagB = columns[1].trim();
const count = parseInt(columns[2].trim(), 10);
// Skip invalid entries
if (!tagA || !tagB || isNaN(count)) continue;
// Add tagA -> tagB relationship
if (!bidirectionalMap.has(tagA)) {
bidirectionalMap.set(tagA, new Map());
}
bidirectionalMap.get(tagA).set(tagB, count);
// Add tagB -> tagA relationship (bidirectional)
if (!bidirectionalMap.has(tagB)) {
bidirectionalMap.set(tagB, new Map());
}
bidirectionalMap.get(tagB).set(tagA, count);
pairCount++;
}
}
if (i < lines.length) {
// Report progress
const progress = Math.round((i / lines.length) * 100);
console.log(`[Autocomplete-Plus] Processing: ${progress}% complete (${pairCount} pairs processed)`);
// Schedule next chunk with setTimeout to allow UI updates
setTimeout(processChunk, 0);
} else {
console.log(`[Autocomplete-Plus] Finished processing ${pairCount} one-way cooccurrence pairs`);
resolve();
}
}
// Start processing the first chunk
processChunk();
});
}
/**
* Parse a CSV line properly, handling quoted values that may contain commas.
* @param {string} line A single CSV line
* @returns {string[]} Array of column values
*/
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
// Escaped quote (double quote inside quotes)
current += '"';
i++; // Skip the next quote
} else {
// Toggle quote mode
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// End of column
result.push(current);
current = '';
} else {
current += char;
}
}
// Don't forget to add the last column
result.push(current);
return result;
}
// --- End Data Loading Functions ---
// --- Initialization ---
export async function initializeAutocomplete() {
export async function initializeAutocomplete(rootPath) {
try {
await Promise.all([loadTags(), loadCooccurrence()]);
await Promise.all([loadTags(rootPath), loadCooccurrence(rootPath)]);
if (!tagsLoaded) {
console.warn("[Autocomplete-Plus] Tags not loaded, cannot initialize autocomplete.");
@@ -883,6 +1101,10 @@ export async function initializeAutocomplete() {
element.addEventListener('blur', handleBlur);
element.addEventListener('keydown', handleKeyDown);
// Add new event listeners for similar tags feature
element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('click', handleClick);
element.dataset.autocompleteAttached = 'true';
}
@@ -894,8 +1116,8 @@ export async function initializeAutocomplete() {
// Start observing the document body for changes
observer.observe(document.body, { childList: true, subtree: true });
console.log("[Autocomplete-Plus] Autocomplete initialized and observer started.");
console.log("[Autocomplete-Plus] Autocomplete and similar tags features initialized.");
} catch (e) {
console.error("[Autocomplete-Plus] Error during API data loading:", error);
console.error("[Autocomplete-Plus] Error during API data loading:", e);
}
}
+44 -4
View File
@@ -21,9 +21,9 @@ app.registerExtension({
name: name,
setup() {
let rootPath = import.meta.url.replace("js/main.js", "");
loadCSS(rootPath + "css/autocomplete.css"); // Load CSS for autocomplete
loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete
initializeAutocomplete(); // Initialize after tags are loaded
initializeAutocomplete(rootPath); // Initialize after tags are loaded
},
settings: [
{
@@ -32,7 +32,7 @@ app.registerExtension({
description: "Enable or disable the autocomplete feature.",
type: "boolean",
defaultValue: true,
category: [name, "General", "Enable Autocomplete"],
category: [name, "Autocompletion", "Enable Autocomplete"],
onChange: (newVal, oldVal) => {
settingValues.enabled = newVal;
}
@@ -47,10 +47,50 @@ app.registerExtension({
step: 5,
},
defaultValue: 10,
category: [name, "Behaviour", "Max suggestions"],
category: [name, "Autocompletion", "Max suggestions"],
onChange: (newVal, oldVal) => {
settingValues.maxSuggestions = newVal;
}
},
// Similar Tags Settings
{
id: id + ".similar_tags_enable",
name: "Enable Similar Tags",
description: "Enable or disable the similar tags feature.",
type: "boolean",
defaultValue: true,
category: [name, "Similar Tags", "Enable Similar Tags"],
onChange: (newVal, oldVal) => {
settingValues.enableSimilarTags = newVal;
}
},
{
id: id + ".max_similar_tags",
name: "Max similar tags",
description: "Maximum number of similar tags to display",
type: "slider",
attrs: {
min: 5,
max: 50,
step: 5,
},
defaultValue: 20,
category: [name, "Similar Tags", "Max similar tags"],
onChange: (newVal, oldVal) => {
settingValues.maxSimilarTags = newVal;
}
},
{
id: id + ".similar_tags_mode",
name: "Similar Tags Display Mode",
description: "When to show similar tags",
type: "combo",
options: ["hover", "click"],
defaultValue: "hover",
category: [name, "Similar Tags", "Display Mode"],
onChange: (newVal, oldVal) => {
settingValues.similarTagsDisplayMode = newVal;
}
}
]
});
+445
View File
@@ -0,0 +1,445 @@
// filepath: v:\Programs\StabilityMatrix-win-x64\Data\Packages\ComfyUI-New\custom_nodes\ComfyUI-Autocomplete-Plus\web\js\similar-tags.js
import { settingValues } from './settings.js';
// --- SimilarTags UI Class ---
/**
* Class that manages the UI for displaying similar tags.
* Shows a panel with tags similar to the current tag under cursor.
*/
class SimilarTagsUI {
constructor() {
// Create the main container
this.element = document.createElement('div');
this.element.id = 'similar-tags-container';
this.element.style.display = 'none'; // Initially hidden
this.element.style.position = 'absolute';
this.element.style.zIndex = '10000';
// Header with title
this.header = document.createElement('div');
this.header.id = 'similar-tags-header';
this.header.textContent = 'Similar Tags';
this.element.appendChild(this.header);
// Container for tags list
this.tagsContainer = document.createElement('div');
this.tagsContainer.id = 'similar-tags-list';
this.element.appendChild(this.tagsContainer);
// Add to DOM
document.body.appendChild(this.element);
// Track the active input and current tag
this.activeInput = null;
this.currentTag = null;
// Add click handler for tag selection
this.tagsContainer.addEventListener('click', (e) => {
const tagElement = e.target.closest('.similar-tag-item');
if (tagElement && tagElement.dataset.tag) {
this.selectTag(tagElement.dataset.tag);
e.preventDefault();
e.stopPropagation();
}
});
}
/**
* Shows the similar tags UI for a specific tag and text input element.
* @param {HTMLTextAreaElement} inputElement The textarea being used
* @param {string} tag The tag to find similarities for
* @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} similarTags List of similar tags
*/
show(inputElement, tag, similarTags) {
if (!settingValues.enableSimilarTags) {
this.hide();
return;
}
this.activeInput = inputElement;
this.currentTag = tag;
// Update content (even if there are no similar tags, we'll show a message)
this.updateContent(similarTags);
// Calculate and update position
this.updatePosition(inputElement);
// Make visible
this.element.style.display = 'block';
}
/**
* Hides the similar tags UI.
*/
hide() {
this.element.style.display = 'none';
this.activeInput = null;
this.currentTag = null;
this.tagsContainer.innerHTML = '';
}
/**
* Updates the content of the similar tags panel with the provided tags.
* @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} similarTags
*/
updateContent(similarTags) {
this.tagsContainer.innerHTML = '';
// Update header with current tag
this.header.textContent = `Similar to: ${this.currentTag}`;
if (!similarTags || similarTags.length === 0) {
const noTags = document.createElement('div');
noTags.className = 'similar-tags-empty';
noTags.textContent = 'No similar tags found';
this.tagsContainer.appendChild(noTags);
return;
}
// Create tag items
similarTags.forEach(tagData => {
const tagElement = this.createTagElement(tagData);
this.tagsContainer.appendChild(tagElement);
});
}
/**
* Creates an HTML element for a similar tag.
* @param {{tag: string, similarity: number, count: number, alias?: string[]}} tagData
* @returns {HTMLElement} The tag element
*/
createTagElement(tagData) {
const tagElement = document.createElement('div');
tagElement.className = 'similar-tag-item';
tagElement.dataset.tag = tagData.tag;
// Tag name
const tagName = document.createElement('span');
tagName.className = 'similar-tag-name';
tagName.textContent = tagData.tag;
tagElement.appendChild(tagName);
// Tag similarity (as percentage)
const similarity = document.createElement('span');
similarity.className = 'similar-tag-similarity';
similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`;
tagElement.appendChild(similarity);
// Create tooltip with more info
let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${tagData.similarity}\nCount: ${tagData.count}`;
if (tagData.alias && tagData.alias.length > 0) {
tooltipText += `\nAlias: ${tagData.alias.join(', ')}`;
}
tagElement.title = tooltipText;
return tagElement;
}
/**
* Updates the position of the similar tags panel.
* Position is calculated based on the input element and available space.
* @param {HTMLElement} inputElement
*/
updatePosition(inputElement) {
// Reset position for accurate measurement
this.element.style.maxHeight = '';
this.element.style.maxWidth = '';
// Get the bounds of the input element
const inputRect = inputElement.getBoundingClientRect();
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Get ComfyUI canvas scale if available
const scale = window.app?.canvas?.ds?.scale ?? 1.0;
// Make element briefly visible for measurement but not actually showing
this.element.style.visibility = 'hidden';
this.element.style.display = 'block';
const elemRect = this.element.getBoundingClientRect();
this.element.style.display = 'none';
this.element.style.visibility = 'visible';
// Calculate dimensions
const elemWidth = elemRect.width;
const elemHeight = elemRect.height;
// Set initial position to be to the right of the textarea
let left = inputRect.right + 10;
let top = inputRect.top;
// Check if we have enough space to the right
const rightSpace = viewportWidth - left;
if (rightSpace < elemWidth) {
// Not enough space to the right, try placing it to the left
left = inputRect.left - elemWidth - 10;
// If still not enough space, place it below the textarea
if (left < 0) {
left = inputRect.left;
top = inputRect.bottom + 10;
// If not enough space below either, place it above
const bottomSpace = viewportHeight - top;
if (bottomSpace < elemHeight) {
top = inputRect.top - elemHeight - 10;
// If still not enough space, just center it on screen
if (top < 0) {
left = Math.max(10, (viewportWidth - elemWidth) / 2);
top = Math.max(10, (viewportHeight - elemHeight) / 2);
}
}
}
}
// Ensure the element stays within viewport
left = Math.max(10, Math.min(viewportWidth - elemWidth - 10, left));
top = Math.max(10, Math.min(viewportHeight - elemHeight - 10, top));
// Update element position
this.element.style.left = `${left}px`;
this.element.style.top = `${top}px`;
// Set max dimensions to ensure scrolling if content is too large
const maxHeight = viewportHeight - top - 20;
this.element.style.maxHeight = `${maxHeight}px`;
this.element.style.maxWidth = '300px';
}
/**
* Handles the selection of a similar tag.
* Inserts the tag into the active input.
* @param {string} tag
*/
selectTag(tag) {
if (!this.activeInput) return;
// Use the same insertTag function from autocomplete.js
insertTag(this.activeInput, tag);
// Hide the panel after selection
this.hide();
}
/**
* Checks if the similar tags UI is currently visible.
* @returns {boolean}
*/
isVisible() {
return this.element.style.display !== 'none';
}
}
// --- Helper Functions ---
/**
* Calculates the Jaccard similarity between two tags.
* Jaccard similarity = (A ∩ B) / (A ∪ B) = (A ∩ B) / (|A| + |B| - |A ∩ B|)
* @param {string} tagA The first tag
* @param {string} tagB The second tag
* @param {Map<string, Map<string, number>>} cooccurrenceMap The cooccurrence data
* @param {Map<string, {tag: string, count: number}>} tagMap The tag data map
* @returns {number} Similarity score between 0 and 1
*/
function calculateJaccardSimilarity(tagA, tagB, cooccurrenceMap, tagMap) {
// Get the count of tagA and tagB individually
const countA = tagMap.get(tagA)?.count || 0;
const countB = tagMap.get(tagB)?.count || 0;
if (countA === 0 || countB === 0) return 0;
// Get the cooccurrence count
const cooccurrenceAB = cooccurrenceMap.get(tagA)?.get(tagB) || 0;
// Calculate Jaccard similarity
// (A ∩ B) / (A ∪ B) = (A ∩ B) / (|A| + |B| - |A ∩ B|)
const intersection = cooccurrenceAB;
const union = countA + countB - cooccurrenceAB;
return union > 0 ? intersection / union : 0;
}
/**
* Finds similar tags for a given tag.
* @param {string} tag The tag to find similar tags for
* @param {Map<string, Map<string, number>>} cooccurrenceMap Map of cooccurrence data
* @param {Map<string, {tag: string, count: number, alias?: string[]}>} tagMap Map of tag data
* @returns {Array<{tag: string, similarity: number, count: number, alias?: string[]}>}
*/
export function findSimilarTags(tag, cooccurrenceMap, tagMap) {
if (!tag || !cooccurrenceMap.has(tag)) {
return [];
}
const cooccurrences = cooccurrenceMap.get(tag);
const similarTags = [];
// Convert to array for sorting
cooccurrences.forEach((count, coTag) => {
// Skip the tag itself
if (coTag === tag) return;
// Get tag data
const tagData = tagMap.get(coTag);
if (!tagData) return;
// Calculate similarity
const similarity = calculateJaccardSimilarity(tag, coTag, cooccurrenceMap, tagMap);
similarTags.push({
tag: coTag,
similarity: similarity,
count: tagData.count,
alias: tagData.alias
});
});
// Sort by similarity (highest first)
similarTags.sort((a, b) => b.similarity - a.similarity);
// Limit to max number of suggestions
return similarTags.slice(0, settingValues.maxSimilarTags);
}
/**
* Extracts the tag at the current cursor position.
* @param {HTMLTextAreaElement} inputElement The textarea element
* @returns {string|null} The tag at cursor or null
*/
export function getCurrentTag(inputElement) {
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
// Find boundaries (commas or start/end of text)
let startPos = text.lastIndexOf(',', cursorPos - 1);
startPos = startPos === -1 ? 0 : startPos + 1;
let endPos = text.indexOf(',', cursorPos);
endPos = endPos === -1 ? text.length : endPos;
// Extract and trim the tag
const tag = text.substring(startPos, endPos).trim();
// If no tag found, return null
if (!tag) return null;
// Convert space-separated tag to underscore-separated format
// This is important for matching with the database and finding similar tags
const normalizedTag = tag.replace(/\s+/g, '_').replace(/\\\(/g, '(').replace(/\\\)/g, ')')
return normalizedTag;
}
/**
* Function to insert a tag into the textarea.
* Appends the selected tag after the current tag.
* @param {HTMLTextAreaElement} inputElement
* @param {string} tagToInsert
*/
function insertTag(inputElement, tagToInsert) {
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
// Find the boundaries of the current tag the cursor is in or near
let startPos = text.lastIndexOf(',', cursorPos - 1);
startPos = startPos === -1 ? 0 : startPos + 1;
let endPos = text.indexOf(',', cursorPos);
endPos = endPos === -1 ? text.length : endPos;
// Find the end of the word/tag at the cursor position, even if there's no comma after it
// This helps find the true end of the tag we want to append after
// Search for the next comma, newline, or end of string after the start position
let potentialEndComma = text.indexOf(',', startPos);
let potentialEndNewline = text.indexOf('\n', startPos);
if (potentialEndComma === -1) potentialEndComma = text.length;
if (potentialEndNewline === -1) potentialEndNewline = text.length;
let currentTagEndPos = Math.min(potentialEndComma, potentialEndNewline);
// Ensure the end position is not before the cursor if the cursor is within the tag
currentTagEndPos = Math.max(cursorPos, currentTagEndPos);
// Find the actual end of the tag by trimming trailing whitespace before the determined end position
// Adjust endPos based on the actual content boundary, not just separators
let boundarySearchStart = startPos;
let firstSeparatorAfterStart = text.substring(boundarySearchStart).search(/[,\n]/);
if (firstSeparatorAfterStart !== -1) {
currentTagEndPos = boundarySearchStart + firstSeparatorAfterStart;
} else {
currentTagEndPos = text.length; // If no separator found, tag goes to the end
}
// Trim trailing spaces from the identified tag segment to find the precise end
let effectiveEndPos = currentTagEndPos;
while (effectiveEndPos > startPos && /\s/.test(text[effectiveEndPos - 1])) {
effectiveEndPos--;
}
// Prepare the tag to be inserted (escape parentheses, replace underscores)
const actualTag = tagToInsert
.replace(/_/g, " ")
.replace(/\\\(/g, "\\\\(") // Escape backslashes before parentheses first
.replace(/\\\)/g, "\\\\)")
.replace(/\(/g, "\\(") // Escape opening parenthesis
.replace(/\)/g, "\\)"); // Escape closing parenthesis
// Text before the insertion point (end of the current tag)
const textBefore = text.substring(0, effectiveEndPos);
// Text after the insertion point
const textAfter = text.substring(effectiveEndPos);
// Standard separator (comma + space)
const separator = ', ';
// Construct the new value: keep existing tag, add separator and new tag
const newValue = textBefore + separator + actualTag + textAfter;
// Set the new value
inputElement.value = newValue;
// Set cursor position after the newly inserted tag and separator
const newCursorPos = textBefore.length + separator.length + actualTag.length;
inputElement.selectionStart = inputElement.selectionEnd = newCursorPos;
// Trigger input event to notify ComfyUI about the change
inputElement.dispatchEvent(new Event('input', { bubbles: true }));
}
// --- Main Exports ---
// Create a singleton instance
const similarTagsUI = new SimilarTagsUI();
/**
* Shows similar tags for the tag at the current cursor position.
* @param {HTMLTextAreaElement} inputElement The textarea element
* @param {string} tag The tag to find similarities for
* @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} similarTags List of similar tags
*/
export function showSimilarTags(inputElement, tag, similarTags) {
similarTagsUI.show(inputElement, tag, similarTags);
}
/**
* Hides the similar tags UI.
*/
export function hideSimilarTags() {
similarTagsUI.hide();
}
/**
* Checks if the similar tags UI is currently visible.
* @returns {boolean} True if visible
*/
export function isSimilarTagsVisible() {
return similarTagsUI.isVisible();
}