diff --git a/web/css/autocomplete-plus.css b/web/css/autocomplete-plus.css index 8a29c41..0176f28 100644 --- a/web/css/autocomplete-plus.css +++ b/web/css/autocomplete-plus.css @@ -1,126 +1,148 @@ /* Basic styles for autocomplete - will be refined later */ #autocomplete-plus-list { - background-color: var(--comfy-input-bg); - border: 1px solid var(--border-color); - border-radius: 4px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - min-width: 200px; /* Increased min width */ - overflow-y: auto; - position: absolute; /* Ensure position is absolute */ - z-index: 10000; /* High z-index */ - color: var(--input-text); - font-size: 0.9em; /* Slightly smaller font */ + background-color: var(--comfy-input-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + min-width: 200px; /* Increased min width */ + overflow-y: auto; + position: absolute; /* Ensure position is absolute */ + z-index: 10000; /* High z-index */ + color: var(--input-text); + font-size: 0.9em; /* Slightly smaller font */ } .autocomplete-plus-item { - padding: 5px 10px; /* Adjusted padding */ - cursor: pointer; - white-space: nowrap; /* Prevent wrapping */ - overflow: hidden; - text-overflow: ellipsis; /* Show ellipsis if too long */ - border-bottom: 1px solid var(--border-color); /* Separator line */ + padding: 5px 10px; /* Adjusted padding */ + cursor: pointer; + white-space: nowrap; /* Prevent wrapping */ + overflow: hidden; + text-overflow: ellipsis; /* Show ellipsis if too long */ + border-bottom: 1px solid var(--border-color); /* Separator line */ } .autocomplete-plus-item:last-child { - border-bottom: none; /* No border for the last item */ + border-bottom: none; /* No border for the last item */ } /* Alternating row colors */ .autocomplete-plus-item:nth-child(even) { - background-color: var(--comfy-menu-bg); /* Slightly different background for even rows */ + background-color: var( + --comfy-menu-bg + ); /* Slightly different background for even rows */ } .autocomplete-plus-item:nth-child(odd) { - background-color: var(--comfy-input-bg); + background-color: var(--comfy-input-bg); } /* Hover effect */ .autocomplete-plus-item:hover { - background-color: var(--comfy-hover-bg); /* Use a hover variable if available, or adjust */ - color: var(--desc-text); /* Adjust text color on hover if needed */ + background-color: var( + --comfy-hover-bg + ); /* Use a hover variable if available, or adjust */ + color: var(--desc-text); /* Adjust text color on hover if needed */ } /* Selected item style */ .autocomplete-plus-item.selected { - background-color: var(--comfy-menu-bg-selected, var(--comfy-button-bg)); /* Use selection color, fallback to button */ - color: var(--comfy-menu-text-selected, var(--button-text)); /* Use selected text color, fallback */ - font-weight: bold; + background-color: var( + --comfy-menu-bg-selected, + var(--comfy-button-bg) + ); /* Use selection color, fallback to button */ + 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; + top: 0; + left: 0; + background-color: var(--comfy-input-bg); + position: absolute; + display: none; + z-index: 10000; + color: var(--input-text); + width: 800px; } -#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-container table { + table-layout: fixed; + width: 100%; } -#similar-tags-list { - max-height: 300px; - overflow-y: auto; - padding: 5px; +#similar-tags-container thead { + background-color: var(--comfy-menu-bg); + color: var(--desc-text); + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 1; +} + +.similar-tags-header-tag-name { + font-weight: bold; +} + +#similar-tags-container tbody { + display: grid; + grid-auto-rows: auto; + grid-template-columns: max-content 1fr auto; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + 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); + display: contents; + padding: 5px 10px; /* Adjusted padding */ + cursor: pointer; + white-space: nowrap; /* Prevent wrapping */ + border-bottom: 1px solid var(--border-color); /* Separator line */ } .similar-tag-item:last-child { - border-bottom: none; + border-bottom: none; } .similar-tag-item:hover { - background-color: var(--comfy-hover-bg); + background-color: var(--comfy-hover-bg); } .similar-tag-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-right: 8px; + white-space: nowrap; +} + +.similar-tag-alias { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .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; + color: var(--desc-text); + background-color: var(--comfy-menu-bg); + border-radius: 10px; + white-space: nowrap; + text-align: right; +} + +.similar-tags-loading-message { + padding: 12px; + text-align: center; + font-style: italic; } .similar-tags-empty { - padding: 12px; - text-align: center; - color: var(--error-text); - font-style: italic; + padding: 12px; + text-align: center; + color: var(--error-text); + font-style: italic; } diff --git a/web/js/similar-tags.js b/web/js/similar-tags.js index 4e87e19..e02f788 100644 --- a/web/js/similar-tags.js +++ b/web/js/similar-tags.js @@ -15,35 +15,40 @@ import { 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'; + this.root = document.createElement('div'); + this.root.id = 'similar-tags-container'; - // Header with title - this.header = document.createElement('div'); - this.header.id = 'similar-tags-header'; - this.header.textContent = 'Similar Tags'; - this.element.appendChild(this.header); + // Create the table element + this.table = document.createElement('table'); + this.table.id = 'similar-tags-table'; + this.root.appendChild(this.table); - // Container for tags list - this.tagsContainer = document.createElement('div'); - this.tagsContainer.id = 'similar-tags-list'; - this.element.appendChild(this.tagsContainer); + // Create header row + this.thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + const headerCell = document.createElement('th'); + // headerCell.colSpan = 3; // Span across all columns + headerCell.textContent = 'Similar Tags'; + headerRow.appendChild(headerCell); + this.thead.appendChild(headerRow); + this.table.appendChild(this.thead); + + // Create a tbody for the tags + this.tbody = document.createElement('tbody'); + this.table.appendChild(this.tbody); // Add to DOM - document.body.appendChild(this.element); + document.body.appendChild(this.root); // 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); + this.tbody.addEventListener('mousedown', (e) => { + const row = e.target.closest('tr'); + if (row && row.dataset.tag) { + this.selectTag(row.dataset.tag); e.preventDefault(); e.stopPropagation(); } @@ -72,17 +77,17 @@ class SimilarTagsUI { this.updatePosition(inputElement); // Make visible - this.element.style.display = 'block'; + this.root.style.display = 'block'; } /** * Hides the similar tags UI. */ hide() { - this.element.style.display = 'none'; + this.root.style.display = 'none'; this.activeInput = null; this.currentTag = null; - this.tagsContainer.innerHTML = ''; + this.tbody.innerHTML = ''; } /** @@ -90,83 +95,107 @@ class SimilarTagsUI { * @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} similarTags */ updateContent(similarTags) { - this.tagsContainer.innerHTML = ''; + this.tbody.innerHTML = ''; // Update header with current tag - this.header.innerHTML = ''; // Clear previous content - const headerText = document.createTextNode('Similar to: '); - this.header.appendChild(headerText); + this.thead.innerHTML = ''; // Clear previous content + const headerRow = document.createElement('tr'); + const headerCell = document.createElement('th'); + // headerCell.colSpan = 3; + headerCell.textContent = 'Similar Tags: '; const tagNameSpan = document.createElement('span'); tagNameSpan.className = 'similar-tags-header-tag-name'; tagNameSpan.textContent = this.currentTag; - this.header.appendChild(tagNameSpan); + headerCell.appendChild(tagNameSpan); + headerRow.appendChild(headerCell); + this.thead.appendChild(headerRow); + this.table.appendChild(this.thead); - if(!autoCompleteData.cooccurrenceLoaded){ - const noTags = document.createElement('div'); - // noTags.className = 'similar-tags-empty'; - noTags.textContent = 'Initializing cooccurrence data...'; - this.tagsContainer.appendChild(noTags); + if (!autoCompleteData.cooccurrenceLoaded) { + const emptyRow = document.createElement('tr'); + const messageCell = document.createElement('td'); + messageCell.className = 'similar-tags-loading-message'; + // messageCell.colSpan = 3; // Span across all columns + messageCell.textContent = 'Initializing cooccurrence data...'; + emptyRow.appendChild(messageCell); + this.tbody.appendChild(emptyRow); return; } 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); + const emptyRow = document.createElement('tr'); + const messageCell = document.createElement('td'); + // messageCell.colSpan = 3; // Span across all columns + messageCell.textContent = 'No similar tags found'; + emptyRow.appendChild(messageCell); + this.tbody.appendChild(emptyRow); return; } - // Create tag items + // Create tag rows similarTags.forEach(tagData => { - const tagElement = this.createTagElement(tagData); - this.tagsContainer.appendChild(tagElement); + const tagRow = this.createTagElement(tagData); + this.tbody.appendChild(tagRow); }); } /** - * Creates an HTML element for a similar tag. + * Creates an HTML table row for a similar tag. * @param {{tag: string, similarity: number, count: number, alias?: string[]}} tagData - * @returns {HTMLElement} The tag element + * @returns {HTMLTableRowElement} The tag row element */ createTagElement(tagData) { - const tagElement = document.createElement('div'); - tagElement.className = 'similar-tag-item'; - tagElement.dataset.tag = tagData.tag; + const tagRow = document.createElement('tr'); + tagRow.className = 'similar-tag-item'; + tagRow.dataset.tag = tagData.tag; + tagRow.style.cursor = 'pointer'; - // Tag name - const tagName = document.createElement('span'); - tagName.className = 'similar-tag-name'; - tagName.textContent = tagData.tag; - tagElement.appendChild(tagName); + // Tag name cell + const tagNameCell = document.createElement('td'); + tagNameCell.className = 'similar-tag-name'; + tagNameCell.textContent = tagData.tag; - // Tag similarity (as percentage) - const similarity = document.createElement('span'); - similarity.className = 'similar-tag-similarity'; - similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; - tagElement.appendChild(similarity); + // Alias cell (middle column) + const aliasCell = document.createElement('td'); + aliasCell.className = 'similar-tag-alias'; + + // Display alias if available + if (tagData.alias && tagData.alias.length > 0) { + let aliasText = tagData.alias.join(', '); + aliasCell.textContent = aliasText; + aliasCell.title = tagData.alias.join(', '); // Full alias on hover + } + + // Similarity cell + const similarityCell = document.createElement('td'); + similarityCell.className = 'similar-tag-similarity'; + similarityCell.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; // Create tooltip with more info - let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${tagData.similarity}\nCount: ${tagData.count}`; + let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}`; if (tagData.alias && tagData.alias.length > 0) { tooltipText += `\nAlias: ${tagData.alias.join(', ')}`; } - tagElement.title = tooltipText; + tagRow.title = tooltipText; - return tagElement; + // Add cells to row + tagRow.appendChild(tagNameCell); + tagRow.appendChild(aliasCell); + tagRow.appendChild(similarityCell); + + return tagRow; } /** * Updates the position of the similar tags panel. * Position is calculated based on the input element, available space, * and the setting `similarTagsDisplayPosition`. - * @param {HTMLElement} inputElement + * @param {HTMLElement} inputElement The input element to position */ updatePosition(inputElement) { - // Reset position for accurate measurement - this.element.style.maxHeight = ''; - this.element.style.maxWidth = ''; + this.root.style.maxHeight = ''; + this.root.style.maxWidth = ''; // Get the bounds of the input element const inputRect = inputElement.getBoundingClientRect(); @@ -174,49 +203,46 @@ class SimilarTagsUI { // Get viewport dimensions const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const mergin = 10; // Margin around the element + const mergin = 10; // Get ComfyUI canvas scale if available const scale = window.app?.canvas?.ds?.scale ?? 1.0; // Note: scale is not currently used in positioning logic // 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'; + this.root.style.visibility = 'hidden'; + this.root.style.display = 'block'; + const elemRect = this.root.getBoundingClientRect(); + this.root.style.display = 'none'; + this.root.style.visibility = 'visible'; - // Calculate dimensions - const elemWidth = elemRect.width; - const elemHeight = elemRect.height; - - let left, top; + let [left, top] = [0, 0]; // Determine initial position based on setting if (settingValues.similarTagsDisplayPosition === 'vertical') { // --- Vertical Positioning --- - // Initial position: below the textarea + // Calculate available space above and below the input + const spaceAbove = inputRect.top; + const spaceBelow = viewportHeight - inputRect.bottom; + + // Determine if we should place above or below based on which has more space + const placeAbove = spaceAbove > spaceBelow; + + if (placeAbove) { + // Place above the input with appropriate margin + top = inputRect.top - elemRect.height - mergin; + // Adjust max height to not exceed available space + this.root.style.maxHeight = `${spaceAbove - (mergin * 2)}px`; + } else { + // Place below the input with appropriate margin + top = inputRect.bottom + mergin; + // Adjust max height to not exceed available space + this.root.style.maxHeight = `${spaceBelow - (mergin * 2)}px`; + } + + // Ensure horizontal alignment within viewport if placed vertically + left = Math.max(mergin, Math.min(viewportWidth - elemRect.width - mergin, left)); + // Align left edge with input where possible left = inputRect.left; - top = inputRect.bottom + mergin; - - // Check if enough space below - const bottomSpace = viewportHeight - top; - if (bottomSpace < elemHeight && top > viewportHeight / 2) { // Only move above if it fits better - // Not enough space below, try placing it above - top = inputRect.top - elemHeight - mergin; - } - - // Fallback to horizontal if vertical doesn't fit well (e.g., goes off top) - if (top < 0) { - left = inputRect.right + mergin; - top = inputRect.top; - const rightSpace = viewportWidth - left; - if (rightSpace < elemWidth) { - left = inputRect.left - elemWidth - mergin; - } - } - // Ensure horizontal alignment within viewport if placed vertically - left = Math.max(mergin, Math.min(viewportWidth - elemWidth - mergin, left)); } else { // --- Horizontal Positioning (Default) --- @@ -226,9 +252,9 @@ class SimilarTagsUI { // Check if we have enough space to the right const rightSpace = viewportWidth - left; - if (rightSpace < elemWidth && left > viewportWidth / 2) { // Only move left if it fits better + if (rightSpace < elemRect.width && left > viewportWidth / 2) { // Only move left if it fits better // Not enough space to the right, try placing it to the left - left = inputRect.left - elemWidth - mergin; + left = inputRect.left - elemRect.width - mergin; } // Fallback to vertical if horizontal doesn't fit well (e.g., goes off left) @@ -236,30 +262,29 @@ class SimilarTagsUI { left = inputRect.left; top = inputRect.bottom + mergin; const bottomSpace = viewportHeight - top; - if (bottomSpace < elemHeight) { - top = inputRect.top - elemHeight - mergin; + if (bottomSpace < elemRect.height) { + top = inputRect.top - elemRect.height - mergin; } } // Ensure vertical alignment within viewport if placed horizontally - top = Math.max(mergin, Math.min(viewportHeight - elemHeight - mergin, top)); + top = Math.max(mergin, Math.min(viewportHeight - elemRect.height - mergin, top)); } - // Ensure the element stays within viewport (final check for both cases) - left = Math.max(mergin, Math.min(viewportWidth - elemWidth - mergin, left)); - top = Math.max(mergin, Math.min(viewportHeight - elemHeight - mergin, top)); + // left = Math.max(mergin, Math.min(viewportWidth - elw - mergin, left)); + // top = Math.max(mergin, Math.min(viewportHeight - elemHeight - mergin, top)); // Update element position - this.element.style.left = `${left}px`; - this.element.style.top = `${top}px`; + this.root.style.left = `${left}px`; + this.root.style.top = `${top}px`; // Set max dimensions to ensure scrolling if content is too large // Adjust max height based on final top position const maxHeight = viewportHeight - top - 20; - this.element.style.maxHeight = `${maxHeight}px`; + this.root.style.maxHeight = `${maxHeight}px`; // Adjust max width based on final left position (less critical usually) - const maxWidth = Math.min(300, viewportWidth - left - 20); // Example: limit width and ensure it fits - this.element.style.maxWidth = `${maxWidth}px`; + const maxWidth = Math.min(elemRect.width, viewportWidth - left - 20); + this.root.style.maxWidth = `${maxWidth}px`; } /** @@ -282,7 +307,7 @@ class SimilarTagsUI { * @returns {boolean} */ isVisible() { - return this.element.style.display !== 'none'; + return this.root.style.display !== 'none'; } } @@ -513,7 +538,19 @@ export class SimilarTagsEventHandler { } handleBlur(event) { - // Handle blur event + // Use setTimeout to delay hiding. This allows clicks on the similar tags UI + // to be processed before the UI is hidden. + setTimeout(() => { + // Check if the new focused element is part of the similar tags UI. + // document.activeElement refers to the currently focused element. + const activeElement = document.activeElement; + const similarTagsElement = similarTagsUI.root; + + // If the focus is not within the similar tags UI, hide it. + if (similarTagsUI && !similarTagsElement.contains(activeElement)) { + // similarTagsUI.hide(); + } + }, 150); // Delay in milliseconds (adjust if necessary) } handleKeyDown(event) {