From edfb389a3c3d63f075a9086d2adea37451b7bd42 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 2 May 2025 22:10:59 +0900 Subject: [PATCH] Implement similar tags feature with UI and settings integration --- __init__.py | 4 - ...autocomplete.css => autocomplete-plus.css} | 77 +++ web/js/autocomplete.js | 348 +++++++++++--- web/js/main.js | 48 +- web/js/similar-tags.js | 445 ++++++++++++++++++ 5 files changed, 851 insertions(+), 71 deletions(-) rename web/css/{autocomplete.css => autocomplete-plus.css} (51%) create mode 100644 web/js/similar-tags.js diff --git a/__init__.py b/__init__.py index dfe447d..5e70b1b 100644 --- a/__init__.py +++ b/__init__.py @@ -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 ###") \ No newline at end of file diff --git a/web/css/autocomplete.css b/web/css/autocomplete-plus.css similarity index 51% rename from web/css/autocomplete.css rename to web/css/autocomplete-plus.css index 017701d..8a29c41 100644 --- a/web/css/autocomplete.css +++ b/web/css/autocomplete-plus.css @@ -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; +} diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 915f8b6..7cbbf09 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -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); } } diff --git a/web/js/main.js b/web/js/main.js index 5633a87..5b3bf8d 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -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; + } } ] }); \ No newline at end of file diff --git a/web/js/similar-tags.js b/web/js/similar-tags.js new file mode 100644 index 0000000..3d94c44 --- /dev/null +++ b/web/js/similar-tags.js @@ -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>} cooccurrenceMap The cooccurrence data + * @param {Map} 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>} cooccurrenceMap Map of cooccurrence data + * @param {Map} 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(); +} \ No newline at end of file