Implement similar tags feature with UI and settings integration
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user