From 9753a27dbcf75d26fd7b30cf3e438a95d9947ccb Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 9 May 2025 00:00:35 +0900 Subject: [PATCH] - refactor: Refactoring for code readability - Support keyboard navigation for related tags --- web/css/autocomplete-plus.css | 8 + web/js/autocomplete.js | 784 ++++++++++++++++----------------- web/js/data.js | 70 ++- web/js/main.js | 2 +- web/js/related-tags.js | 796 +++++++++++++++++++--------------- web/js/utils.js | 40 -- 6 files changed, 891 insertions(+), 809 deletions(-) diff --git a/web/css/autocomplete-plus.css b/web/css/autocomplete-plus.css index 1efd2b6..33b2cad 100644 --- a/web/css/autocomplete-plus.css +++ b/web/css/autocomplete-plus.css @@ -195,6 +195,14 @@ body.dark-theme { background-color: var(--p-form-field-filled-hover-background); } +.related-tag-item.selected span { + background-color: var( + --comfy-menu-bg-selected, + var(--comfy-button-bg) + ); + font-weight: bold; +} + .related-tag-name { padding: 8px; white-space: nowrap; diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 1ac21af..aed1e3f 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -1,5 +1,6 @@ import { TagCategory, + TagData, autoCompleteData } from './data.js'; import { @@ -11,6 +12,235 @@ import { } from './utils.js'; import { settingValues } from './settings.js'; +// --- Autocomplete Logic --- + +/** + * Search tag completion candidates based on the current input and cursor position in the textarea. + * @param {HTMLTextAreaElement} textareaElement The partial tag input. + * @returns {Array<{tag: string, count: number, alias?: string[], category: string}>} The list of matching candidates. + */ +function searchCompletionCandidates(textareaElement) { + const startTime = performance.now(); // Record start time for performance measurement + + const ESCAPE_SEQUENCE = ["#", "/"]; // If the first string is that character, autocomplete will not be displayed. + const partialTag = getCurrentPartialTag(textareaElement); + if (!partialTag || partialTag.length <= 0 || ESCAPE_SEQUENCE.some(seq => partialTag.startsWith(seq))) { + return []; // No valid input for autocomplete + } + + const lowerQuery = partialTag.toLowerCase(); + const exactMatches = []; // Array for exact matches (will be displayed first) + const partialMatches = []; // Array for partial matches (will be displayed after exact matches) + const addedTags = new Set(); // Keep track of added tags to avoid duplicates + + // Generate Hiragana/Katakana variations if applicable + const queryVariations = new Set([lowerQuery, normalizeTagToSearch(lowerQuery)]); + const kataQuery = hiraToKata(lowerQuery); + if (kataQuery !== lowerQuery) { + queryVariations.add(kataQuery); + } + const hiraQuery = kataToHira(lowerQuery); + if (hiraQuery !== lowerQuery) { + queryVariations.add(hiraQuery); + } + + // Search in sortedTags (already sorted by count) + for (const tagData of autoCompleteData.sortedTags) { + let matched = false; + let isExactMatch = false; + let matchedAlias = null; + + // Ensure tagData.tag is treated as lowercase for comparison + const lowerTag = tagData.tag.toLowerCase(); + + // Check primary tag against all variations for exact match first + for (const variation of queryVariations) { + if (lowerTag === variation) { + isExactMatch = true; + matched = true; + break; + } + } + + // If not an exact match, check for partial matches in the tag + if (!isExactMatch) { + for (const variation of queryVariations) { + if (lowerTag.includes(variation)) { + matched = true; + break; + } else if (lowerTag.replace(/[\-_\s']/g, '').includes(variation.replace(/[\-_\s']/g, ''))) { + // Try to match with underscore, dash, or apostrophe removed + matched = true; + break; + } + } + } + + // If primary tag didn't match, check aliases against all variations + if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) { + for (const alias of tagData.alias) { + const lowerAlias = alias.toLowerCase(); + + // Check for exact matches in aliases first + for (const variation of queryVariations) { + if (lowerAlias === variation) { + isExactMatch = true; + matched = true; + matchedAlias = alias; + break; + } + } + + // If not an exact match in alias, check for partial matches + if (!isExactMatch) { + for (const variation of queryVariations) { + if (lowerAlias.includes(variation)) { + matched = true; + matchedAlias = alias; + break; + } + } + } + + if (matched) break; // Stop checking aliases for this tag if one matched + } + } + + // Add candidate if matched and not already added + if (matched && !addedTags.has(tagData.tag)) { + const candidateItem = { + tag: tagData.tag, + alias: tagData.alias, + category: tagData.category, + count: tagData.count, + }; + + // Add to exact matches or partial matches based on match type + if (isExactMatch) { + exactMatches.push(candidateItem); + } else { + partialMatches.push(candidateItem); + } + + addedTags.add(tagData.tag); + + // Check if we've reached the maximum suggestions limit combining both arrays + if (exactMatches.length + partialMatches.length >= settingValues.maxSuggestions) { + // Return the combined results, prioritizing exact matches + const result = [...exactMatches, ...partialMatches].slice(0, settingValues.maxSuggestions); + + if (settingValues._logprocessingTime) { + const endTime = performance.now(); + const duration = endTime - startTime; + // console.debug(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${result.length} candidates (max reached).`); + } + + return result; // Early exit + } + } + } + + // Combine results, with exact matches first + const candidates = [...exactMatches, ...partialMatches]; + + if (settingValues._logprocessingTime) { + const endTime = performance.now(); + const duration = endTime - startTime; + // console.debug(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates.`); + } + + return candidates; +} + +/** + * Extracts the current tag being typed before the cursor. + * Assumes tags are separated by commas. + * @param {HTMLTextAreaElement} inputElement + */ +function getCurrentPartialTag(inputElement) { + const text = inputElement.value; + const cursorPos = inputElement.selectionStart; + + // Find the last newline or comma before the cursor + const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); + const lastComma = text.lastIndexOf(',', cursorPos - 1); + + // Get the position of the last separator (newline or comma) before cursor + const lastSeparator = Math.max(lastNewLine, lastComma); + const start = lastSeparator === -1 ? 0 : lastSeparator + 1; + + // Extract the text between the last comma (or start) and the cursor + const partial = text.substring(start, cursorPos).trimStart(); + return partial; +} + +/** + * Inserts the selected tag into the textarea, replacing the partial tag, + * making the change undoable. + * @param {HTMLTextAreaElement} inputElement + * @param {string} tagToInsert The raw tag string to insert. + */ +function insertTagToTextArea(inputElement, tagToInsert) { + const text = inputElement.value; + const cursorPos = inputElement.selectionStart; + + const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); + const lastComma = text.lastIndexOf(',', cursorPos - 1); + + const lastSeparator = Math.max(lastNewLine, lastComma); + const start = lastSeparator === -1 ? 0 : lastSeparator + 1; + + const normalizedTag = normalizeTagToInsert(tagToInsert); + + const currentWordStart = text.substring(start, cursorPos).search(/\S|$/) + start; + const currentWordEndMatch = text.substring(cursorPos).match(/^[^,\n]+/); + + let currentWordEnd = cursorPos; + + // If the end match is found, set currentWordEnd to the end of the match + if (currentWordEndMatch && normalizedTag.lastIndexOf(currentWordEndMatch[0]) !== -1) { + currentWordEnd = cursorPos + currentWordEndMatch[0].length; + } + + // The range to replace is from the start of the current partial tag + // up to the end of the word segment at the cursor. + const replaceStart = currentWordStart; + // replaceEnd should be at least the cursor position, but extend to cover the word segment if cursor is within it. + const replaceEnd = Math.max(cursorPos, currentWordEnd); + + // Add space if the previous separator was a comma and we are not at the beginning + const needsSpaceBefore = lastSeparator === lastComma && replaceStart > 0 && text[replaceStart - 1] === ','; + const prefix = needsSpaceBefore ? ' ' : ''; + + // Standard separator (comma + space) + const needsSuffixAfter = text[replaceEnd] !== ',' + const suffix = needsSuffixAfter ? ', ' : ''; + + const textToInsertWithAffixes = prefix + normalizedTag + suffix; + + // --- Use execCommand for Undo support --- + // 1. Select the text range to be replaced + inputElement.focus(); // Ensure the element has focus + inputElement.setSelectionRange(replaceStart, replaceEnd); + + // 2. Execute the 'insertText' command + // This replaces the selection and should add the change to the undo stack + const insertTextSuccess = document.execCommand('insertText', false, textToInsertWithAffixes); + + // Fallback for browsers where execCommand might fail or is not supported + if (!insertTextSuccess) { + console.warn('[Autocomplete-Plus] execCommand("insertText") failed. Falling back to direct value manipulation (Undo might not work).'); + const textBefore = text.substring(0, replaceStart); + const textAfter = text.substring(replaceEnd); + inputElement.value = textBefore + textToInsertWithAffixes + textAfter; + // Manually set cursor position after the inserted text + const newCursorPos = replaceStart + textToInsertWithAffixes.length; + inputElement.selectionStart = inputElement.selectionEnd = newCursorPos; + // Trigger input event manually as a fallback + inputElement.dispatchEvent(new Event('input', { bubbles: true })); + } +} + // --- Autocomplete UI Class --- class AutocompleteUI { @@ -25,12 +255,13 @@ class AutocompleteUI { const tbody = document.createElement('tbody'); this.element.appendChild(tbody); - this.activeInput = null; + // Add to DOM + document.body.appendChild(this.element); + + this.target = null; this.selectedIndex = -1; this.candidates = []; - document.body.appendChild(this.element); - // Add event listener for clicks on items (listen on tbody) tbody.addEventListener('mousedown', (e) => { // Check if the click target is a TD inside a TR with a data-index @@ -38,7 +269,7 @@ class AutocompleteUI { if (row && row.dataset.index) { const index = parseInt(row.dataset.index, 10); if (!isNaN(index)) { - this.#selectItem(index); + this.#insertTag(index); e.preventDefault(); // Prevent focus loss from input e.stopPropagation(); } @@ -46,23 +277,50 @@ class AutocompleteUI { }); } - show(textareaElement, candidates) { - this.#update(candidates); - if (candidates.length > 0) { - this.#show(textareaElement); - } else { - this.#hide(); - } - } - - hide() { - this.#hide(); - } - + /** Checks if the autocomplete list is visible */ isVisible() { return this.element.style.display !== 'none'; } + /** + * Displays the autocomplete list under the given textarea element if there are candidates. + * @param {HTMLTextAreaElement} textareaElement + * @returns + */ + updateDisplay(textareaElement) { + const candidates = searchCompletionCandidates(textareaElement); + if (candidates.length <= 0) { + this.hide(); + return; + } + + this.target = textareaElement; + + this.candidates = candidates; + this.selectedIndex = 0; + + this.#updateContent(); + + // Calculate caret position using the helper function (returns viewport-relative coordinates) + this.#updatePosition(); + + // Highlight the first item + this.#highlightItem(); + + this.element.style.overflowY = 'auto'; + this.element.style.display = 'block'; // Make it visible + } + + /** + * hides the autocomplete list. + */ + hide() { + this.element.style.display = 'none'; + this.selectedIndex = -1; + this.target = null; + this.candidates = []; + } + /** Moves the selection up or down */ navigate(direction) { if (this.candidates.length === 0) return; @@ -86,22 +344,89 @@ class AutocompleteUI { } /** - * Shows the autocomplete list near the target input element. - * Adjusts max-height and enables scrolling if the list exceeds viewport bounds. - * @param {HTMLTextAreaElement} inputElement The textarea being typed into. + * Updates the list from the current candidates. */ - #show(inputElement) { - this.activeInput = inputElement; - this.selectedIndex = 0; + #updateContent() { + const tbody = this.element.querySelector('tbody'); + tbody.innerHTML = ''; // Clear previous items from tbody + if (this.candidates.length === 0) { + this.hide(); + return; + } - // Calculate caret position using the helper function (returns viewport-relative coordinates) - this.#updateListPosition(); + this.candidates.forEach((candidate, index) => { + this.#createTagElement(index, candidate); // Pass candidate directly + }); - this.element.style.overflowY = 'auto'; - this.element.style.display = 'block'; // Make it visible + // If the list is already visible, update its position in case content changed size significantly + if (this.isVisible() && this.target) { + this.#updatePosition(); + } + } - // Highlight the first item - this.#highlightItem(); + /** + * Creates a table row (tr) for a candidate item. + * @param {number} index + * @param {TagData} tagData + */ + #createTagElement(index, tagData) { + const MAX_ALIAS_LENGTH = 25; // Maximum length for alias display + const categoryText = TagCategory[tagData.category] || "unknown"; + const tbody = this.element.querySelector('tbody'); + + const item = document.createElement('tr'); + item.classList.add('autocomplete-plus-item'); + item.dataset.index = index; + item.dataset.tagCategory = categoryText; + item.style.cursor = 'pointer'; + item.style.whiteSpace = 'nowrap'; + + // 1st cell: Tag + const tagCell = document.createElement('td'); + tagCell.style.padding = '4px 8px'; + tagCell.style.overflow = 'hidden'; + tagCell.style.textOverflow = 'ellipsis'; + tagCell.style.maxWidth = '300px'; // Limit width of the tag/alias cell + + tagCell.textContent = tagData.tag; // Display the tag + + // 2nd cell: Alias (if any) + const aliasCell = document.createElement('td'); + aliasCell.classList.add('autocomplete-plus-alias'); + aliasCell.style.padding = '4px 8px'; + aliasCell.style.maxWidth = '300px'; // Limit width of the tag/alias cell + + let displayText = ""; + let displayAliasStr = ''; + if (tagData.alias && tagData.alias.length > 0) { + displayAliasStr = tagData.alias.join(', '); // Join multiple aliases with commas + // Truncate long alias + if (displayAliasStr.length > MAX_ALIAS_LENGTH) { + displayAliasStr = displayAliasStr.substring(0, MAX_ALIAS_LENGTH) + '...'; + } + displayText += ` [${displayAliasStr}]`; + } + aliasCell.textContent = displayText; + aliasCell.title = `${tagData.tag}${tagData.alias && tagData.alias.length > 0 ? ` [${tagData.alias.join(', ')}]` : ''}`; // Full text on hover + + // 3rd cell: Category + const catCell = document.createElement('td'); + catCell.textContent = categoryText.substring(0, 2); + catCell.style.padding = '4px 8px'; + catCell.style.minWidth = '50px'; // Ensure some minimum space for count alignment + + // 4th cell: Count + const countCell = document.createElement('td'); + countCell.textContent = formatCountHumanReadable(tagData.count); + countCell.style.padding = '4px 8px'; + countCell.style.textAlign = 'right'; + countCell.style.minWidth = '50px'; // Ensure some minimum space for count alignment + + item.appendChild(tagCell); + item.appendChild(aliasCell); + item.appendChild(catCell); + item.appendChild(countCell); + tbody.appendChild(item); // Append row to tbody } /** @@ -111,10 +436,10 @@ class AutocompleteUI { * License: MIT License (assumed based on repository root LICENSE file) * Considers ComfyUI canvas scale. */ - #updateListPosition() { - const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.activeInput); + #updatePosition() { + const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.target); - const elOffset = this.#calculateElementOffset(this.activeInput); + const elOffset = this.#calculateElementOffset(this.target); const elScroll = { top: this.element.scrollTop, left: this.element.scrollLeft }; this.element.scrollTop = 0; this.element.style.maxHeight = ''; // Reset max-height for accurate measurement @@ -193,106 +518,10 @@ class AutocompleteUI { this.element.style.top = `${topPosition}px`; } - /** - * Hides the autocomplete list. - */ - #hide() { - this.element.style.display = 'none'; - this.selectedIndex = -1; - this.activeInput = null; - this.candidates = []; - } - - /** - * Updates the list with new candidates. - * @param {Array<{tag: string, count: number, alias?: string}>} candidates List of candidate tags. - */ - #update(candidates) { - this.candidates = candidates; - const tbody = this.element.querySelector('tbody'); - tbody.innerHTML = ''; // Clear previous items from tbody - if (candidates.length === 0) { - this.#hide(); - return; - } - - candidates.forEach((candidate, index) => { - this.#createCandidateItem(index, candidate); // Pass candidate directly - }); - - // If the list is already visible, update its position in case content changed size significantly - if (this.isVisible() && this.activeInput) { - this.#updateListPosition(); - } - } - - /** - * Creates a table row (tr) for a candidate item. - * @param {number} index - * @param {{tag: string, count: number, alias?: string[]}} candidate - */ - #createCandidateItem(index, candidate) { - const MAX_ALIAS_LENGTH = 25; // Maximum length for alias display - const categoryText = TagCategory[candidate.category] || "unknown"; - const tbody = this.element.querySelector('tbody'); - - const item = document.createElement('tr'); - item.classList.add('autocomplete-plus-item'); - item.dataset.index = index; - item.dataset.tagCategory = categoryText; - item.style.cursor = 'pointer'; - item.style.whiteSpace = 'nowrap'; - - // 1st cell: Tag - const tagCell = document.createElement('td'); - tagCell.style.padding = '4px 8px'; - tagCell.style.overflow = 'hidden'; - tagCell.style.textOverflow = 'ellipsis'; - tagCell.style.maxWidth = '300px'; // Limit width of the tag/alias cell - - tagCell.textContent = candidate.tag; // Display the tag - - // 2nd cell: Alias (if any) - const aliasCell = document.createElement('td'); - aliasCell.classList.add('autocomplete-plus-alias'); - aliasCell.style.padding = '4px 8px'; - aliasCell.style.maxWidth = '300px'; // Limit width of the tag/alias cell - - let displayText = ""; - let displayAliasStr = ''; - if (candidate.alias && candidate.alias.length > 0) { - displayAliasStr = candidate.alias.join(', '); // Join multiple aliases with commas - // Truncate long alias - if (displayAliasStr.length > MAX_ALIAS_LENGTH) { - displayAliasStr = displayAliasStr.substring(0, MAX_ALIAS_LENGTH) + '...'; - } - displayText += ` [${displayAliasStr}]`; - } - aliasCell.textContent = displayText; - aliasCell.title = `${candidate.tag}${candidate.alias && candidate.alias.length > 0 ? ` [${candidate.alias.join(', ')}]` : ''}`; // Full text on hover - - // 3rd cell: Category - const catCell = document.createElement('td'); - catCell.textContent = categoryText.substring(0, 2); - catCell.style.padding = '4px 8px'; - catCell.style.minWidth = '50px'; // Ensure some minimum space for count alignment - - // 4th cell: Count - const countCell = document.createElement('td'); - countCell.textContent = formatCountHumanReadable(candidate.count); - countCell.style.padding = '4px 8px'; - countCell.style.textAlign = 'right'; - countCell.style.minWidth = '50px'; // Ensure some minimum space for count alignment - - item.appendChild(tagCell); - item.appendChild(aliasCell); - item.appendChild(catCell); - item.appendChild(countCell); - tbody.appendChild(item); // Append row to tbody - } - /** Highlights the item (row) at the given index */ #highlightItem() { + if(!this.getSelectedTag()) return; // No valid selection + const tbody = this.element.querySelector('tbody'); if (!tbody) return; @@ -311,9 +540,9 @@ class AutocompleteUI { * Handles the selection of an item (e.g., inserts into input). * @param {number} index The index of the selected candidate. */ - #selectItem(index) { - if (!this.activeInput || index < 0 || index >= this.candidates.length) { - this.#hide(); + #insertTag(index) { + if (!this.target || index < 0 || index >= this.candidates.length) { + this.hide(); return; } @@ -321,9 +550,9 @@ class AutocompleteUI { const selectedTag = this.candidates[index].tag; // Insert the selected tag - insertTag(this.activeInput, selectedTag); + insertTagToTextArea(this.target, selectedTag); - this.#hide(); + this.hide(); } /** @@ -529,252 +758,11 @@ class AutocompleteUI { } } -// --- Autocomplete Logic --- -const autocompleteUI = new AutocompleteUI(); - -/** - * - * @param {HTMLTextAreaElement} textareaElement - */ -function updateAutocompleteDisplay(textareaElement) { - const candidates = searchCompletionCandidates(textareaElement); - if (candidates.length > 0) { - autocompleteUI.show(textareaElement, candidates); - }else{ - autocompleteUI.hide(); - } -} - -/** - * Search tag completion candidates based on the current input and cursor position in the textarea. - * @param {HTMLTextAreaElement} textareaElement The partial tag input. - * @returns {Array<{tag: string, count: number, alias?: string}>} - */ -function searchCompletionCandidates(textareaElement) { - const startTime = performance.now(); // Record start time for performance measurement - - const ESCAPE_SEQUENCE = ["#", "/"]; // If the first string is that character, autocomplete will not be displayed. - const partialTag = getCurrentPartialTag(textareaElement); - if (!partialTag || partialTag.length <= 0 || ESCAPE_SEQUENCE.some(seq => partialTag.startsWith(seq))) { - return []; // No valid input for autocomplete - } - - const lowerQuery = partialTag.toLowerCase(); - const exactMatches = []; // Array for exact matches (will be displayed first) - const partialMatches = []; // Array for partial matches (will be displayed after exact matches) - const addedTags = new Set(); // Keep track of added tags to avoid duplicates - - // Generate Hiragana/Katakana variations if applicable - const queryVariations = new Set([lowerQuery, normalizeTagToSearch(lowerQuery)]); - const kataQuery = hiraToKata(lowerQuery); - if (kataQuery !== lowerQuery) { - queryVariations.add(kataQuery); - } - const hiraQuery = kataToHira(lowerQuery); - if (hiraQuery !== lowerQuery) { - queryVariations.add(hiraQuery); - } - - // Search in sortedTags (already sorted by count) - for (const tagData of autoCompleteData.sortedTags) { - let matched = false; - let isExactMatch = false; - let matchedAlias = null; - - // Ensure tagData.tag is treated as lowercase for comparison - const lowerTag = tagData.tag.toLowerCase(); - - // Check primary tag against all variations for exact match first - for (const variation of queryVariations) { - if (lowerTag === variation) { - isExactMatch = true; - matched = true; - break; - } - } - - // If not an exact match, check for partial matches in the tag - if (!isExactMatch) { - for (const variation of queryVariations) { - if (lowerTag.includes(variation)) { - matched = true; - break; - } else if (lowerTag.replace(/[\-_\s']/g, '').includes(variation.replace(/[\-_\s']/g, ''))) { - // Try to match with underscore, dash, or apostrophe removed - matched = true; - break; - } - } - } - - // If primary tag didn't match, check aliases against all variations - if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) { - for (const alias of tagData.alias) { - const lowerAlias = alias.toLowerCase(); - - // Check for exact matches in aliases first - for (const variation of queryVariations) { - if (lowerAlias === variation) { - isExactMatch = true; - matched = true; - matchedAlias = alias; - break; - } - } - - // If not an exact match in alias, check for partial matches - if (!isExactMatch) { - for (const variation of queryVariations) { - if (lowerAlias.includes(variation)) { - matched = true; - matchedAlias = alias; - break; - } - } - } - - if (matched) break; // Stop checking aliases for this tag if one matched - } - } - - // Add candidate if matched and not already added - if (matched && !addedTags.has(tagData.tag)) { - const candidateItem = { - tag: tagData.tag, - alias: tagData.alias, - category: tagData.category, - count: tagData.count, - }; - - // Add to exact matches or partial matches based on match type - if (isExactMatch) { - exactMatches.push(candidateItem); - } else { - partialMatches.push(candidateItem); - } - - addedTags.add(tagData.tag); - - // Check if we've reached the maximum suggestions limit combining both arrays - if (exactMatches.length + partialMatches.length >= settingValues.maxSuggestions) { - // Return the combined results, prioritizing exact matches - const result = [...exactMatches, ...partialMatches].slice(0, settingValues.maxSuggestions); - - if (settingValues._logprocessingTime) { - const endTime = performance.now(); - const duration = endTime - startTime; - // console.debug(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${result.length} candidates (max reached).`); - } - - return result; // Early exit - } - } - } - - // Combine results, with exact matches first - const candidates = [...exactMatches, ...partialMatches]; - - if (settingValues._logprocessingTime) { - const endTime = performance.now(); - const duration = endTime - startTime; - // console.debug(`[Autocomplete-Plus] Search for "${query}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates.`); - } - - return candidates; -} - -/** - * Extracts the current tag being typed before the cursor. - * Assumes tags are separated by commas. - * @param {HTMLTextAreaElement} inputElement - * @returns {string} The partial tag or empty string. - */ -function getCurrentPartialTag(inputElement) { - const text = inputElement.value; - const cursorPos = inputElement.selectionStart; - - // Find the last newline or comma before the cursor - const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); - const lastComma = text.lastIndexOf(',', cursorPos - 1); - - // Get the position of the last separator (newline or comma) before cursor - const lastSeparator = Math.max(lastNewLine, lastComma); - const start = lastSeparator === -1 ? 0 : lastSeparator + 1; - - // Extract the text between the last comma (or start) and the cursor - const partial = text.substring(start, cursorPos).trimStart(); - return partial; -} - -/** - * Inserts the selected tag into the textarea, replacing the partial tag, - * making the change undoable. - * @param {HTMLTextAreaElement} inputElement - * @param {string} tagToInsert The raw tag string to insert. - */ -function insertTag(inputElement, tagToInsert) { - const text = inputElement.value; - const cursorPos = inputElement.selectionStart; - - const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); - const lastComma = text.lastIndexOf(',', cursorPos - 1); - - const lastSeparator = Math.max(lastNewLine, lastComma); - const start = lastSeparator === -1 ? 0 : lastSeparator + 1; - - const normalizedTag = normalizeTagToInsert(tagToInsert); - - const currentWordStart = text.substring(start, cursorPos).search(/\S|$/) + start; - const currentWordEndMatch = text.substring(cursorPos).match(/^[^,\n]+/); - - let currentWordEnd = cursorPos; - - // If the end match is found, set currentWordEnd to the end of the match - if (currentWordEndMatch && normalizedTag.lastIndexOf(currentWordEndMatch[0]) !== -1) { - currentWordEnd = cursorPos + currentWordEndMatch[0].length; - } - - // The range to replace is from the start of the current partial tag - // up to the end of the word segment at the cursor. - const replaceStart = currentWordStart; - // replaceEnd should be at least the cursor position, but extend to cover the word segment if cursor is within it. - const replaceEnd = Math.max(cursorPos, currentWordEnd); - - // Add space if the previous separator was a comma and we are not at the beginning - const needsSpaceBefore = lastSeparator === lastComma && replaceStart > 0 && text[replaceStart - 1] === ','; - const prefix = needsSpaceBefore ? ' ' : ''; - - // Standard separator (comma + space) - const needsSuffixAfter = text[replaceEnd] !== ',' - const suffix = needsSuffixAfter ? ', ' : ''; - - const textToInsertWithAffixes = prefix + normalizedTag + suffix; - - // --- Use execCommand for Undo support --- - // 1. Select the text range to be replaced - inputElement.focus(); // Ensure the element has focus - inputElement.setSelectionRange(replaceStart, replaceEnd); - - // 2. Execute the 'insertText' command - // This replaces the selection and should add the change to the undo stack - const insertTextSuccess = document.execCommand('insertText', false, textToInsertWithAffixes); - - // Fallback for browsers where execCommand might fail or is not supported - if (!insertTextSuccess) { - console.warn('[Autocomplete-Plus] execCommand("insertText") failed. Falling back to direct value manipulation (Undo might not work).'); - const textBefore = text.substring(0, replaceStart); - const textAfter = text.substring(replaceEnd); - inputElement.value = textBefore + textToInsertWithAffixes + textAfter; - // Manually set cursor position after the inserted text - const newCursorPos = replaceStart + textToInsertWithAffixes.length; - inputElement.selectionStart = inputElement.selectionEnd = newCursorPos; - // Trigger input event manually as a fallback - inputElement.dispatchEvent(new Event('input', { bubbles: true })); - } -} - -// --- Autocomplete Event Handling Logic --- +// --- Autocomplete Event Handling Class --- export class AutocompleteEventHandler { + constructor() { + this.autocompleteUI = new AutocompleteUI(); + } /** * @@ -782,20 +770,18 @@ export class AutocompleteEventHandler { * @returns */ handleInput(event) { - if (!settingValues.enabled || !autocompleteUI) return; + if (!settingValues.enabled) return; if(!event.isTrusted) return; // ignore synthetic events const textareaElement = event.target; const partialTag = getCurrentPartialTag(textareaElement); if (partialTag.length <= 0){ - autocompleteUI.hide(); + this.autocompleteUI.hide(); } } handleFocus(event) { - if (!settingValues.enabled) return; - - // Add code to focus events if needed + } handleBlur(event) { @@ -803,8 +789,8 @@ export class AutocompleteEventHandler { // Need a slight delay because clicking the autocomplete list causes blur setTimeout(() => { - if (autocompleteUI && !autocompleteUI.element.contains(document.activeElement)) { - autocompleteUI.hide(); + if (!this.autocompleteUI.element.contains(document.activeElement)) { + this.autocompleteUI.hide(); } }, 150); } @@ -820,27 +806,27 @@ export class AutocompleteEventHandler { const textareaElement = event.target; // Handle autocomplete navigation - if (autocompleteUI && autocompleteUI.isVisible()) { + if (this.autocompleteUI && this.autocompleteUI.isVisible()) { switch (event.key) { case 'ArrowDown': event.preventDefault(); - autocompleteUI.navigate(1); + this.autocompleteUI.navigate(1); break; case 'ArrowUp': event.preventDefault(); - autocompleteUI.navigate(-1); + this.autocompleteUI.navigate(-1); break; case 'Enter': case 'Tab': - if (autocompleteUI.getSelectedTag() !== null) { + if (this.autocompleteUI.getSelectedTag() !== null) { event.preventDefault(); - insertTag(textareaElement, autocompleteUI.getSelectedTag()); + insertTagToTextArea(textareaElement, this.autocompleteUI.getSelectedTag()); } - autocompleteUI.hide(); + this.autocompleteUI.hide(); break; case 'Escape': event.preventDefault(); - autocompleteUI.hide(); + this.autocompleteUI.hide(); break; } } @@ -852,13 +838,13 @@ export class AutocompleteEventHandler { * @returns */ handleKeyUp(event){ - if (!settingValues.enabled || !autocompleteUI) return; + if (!settingValues.enabled) return; - if (autocompleteUI.isVisible()) { + if (this.autocompleteUI.isVisible()) { switch (event.key) { case "Escape": event.preventDefault(); - autocompleteUI.hide(); + this.autocompleteUI.hide(); break; } } else if (event.key.length > 1 && event.key != "Delete" && event.key != "Backspace") { @@ -866,7 +852,7 @@ export class AutocompleteEventHandler { } if (!event.defaultPrevented) { - updateAutocompleteDisplay(event.target); + this.autocompleteUI.updateDisplay(event.target); } } @@ -884,7 +870,5 @@ export class AutocompleteEventHandler { * @returns */ handleClick(event) { - if (!settingValues.enabled || !settingValues.enableRelatedTags || - settingValues.relatedTagsDisplayMode !== 'click') return; } } diff --git a/web/js/data.js b/web/js/data.js index 81e85f0..dc30bf0 100644 --- a/web/js/data.js +++ b/web/js/data.js @@ -12,13 +12,62 @@ export const TagCategory = [ 'meta', ] -// Data storage +/** + * Class representing a tag and its metadata + */ +export class TagData { + /** + * Create a tag data object + * @param {string} tag - The tag name + * @param {string[]} [alias=[]] - Array of aliases for the tag + * @param {string} [category='general'] - Category of the tag + * @param {number} [count=0] - Frequency count/popularity of the tag + */ + constructor(tag, alias = [], category = 'general', count = 0) { + /** @type {string} */ + this.tag = tag; + + /** @type {string[]} */ + this.alias = alias; + + /** @type {string} */ + this.category = category; + + /** @type {number} */ + this.count = count; + } + /** + * Get display label for the tag + * @returns {string} Formatted tag label + */ + getLabel() { + return `${this.tag} (${this.count})`; + } + + /** + * Check if this tag matches a search query + * @param {string} query - The search query + * @returns {boolean} True if this tag or any of its aliases match the query + */ + matches(query) { + if (!query) return false; + + const lowerQuery = query.toLowerCase(); + if (this.tag.toLowerCase().includes(lowerQuery)) return true; + + return this.alias.some(a => a.toLowerCase().includes(lowerQuery)); + } +} + +// Data storage export const autoCompleteData = { + sortedTags: [], + /** @type {Map} */ tagMap: new Map(), // Stores tag data, mapping tag names to TagData objects - - sortedTags: [], + /** @type {Map} */ + aliasMap: new Map(), // Maps aliases to their main tag names /** @type {Map>} */ cooccurrenceMap: new Map(), // Stores co-occurrence data for related tags @@ -67,12 +116,9 @@ async function loadTags(csvUrl) { // Parse aliases - might be comma-separated list inside quotes const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : []; - autoCompleteData.sortedTags.push({ - tag, - alias: aliases, - category, - count - }); + // Create a TagData instance instead of a plain object + const tagData = new TagData(tag, aliases, category, count); + autoCompleteData.sortedTags.push(tagData); }else{ console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}`); continue; @@ -85,10 +131,10 @@ async function loadTags(csvUrl) { // Build maps as before autoCompleteData.sortedTags.forEach(tagData => { autoCompleteData.tagMap.set(tagData.tag, tagData); - if (autoCompleteData.alias && Array.isArray(autoCompleteData.alias)) { - autoCompleteData.alias.forEach(alias => { + if (tagData.alias && Array.isArray(tagData.alias)) { + tagData.alias.forEach(alias => { if (!autoCompleteData.aliasMap.has(alias)) { - autoCompleteData.aliasMap.set(alias, autoCompleteData.tag); // Map alias back to the main tag + autoCompleteData.aliasMap.set(alias, tagData.tag); // Map alias back to the main tag } }); } diff --git a/web/js/main.js b/web/js/main.js index 757fde4..b1f9716 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -192,7 +192,7 @@ app.registerExtension({ { id: id + ".related_tags_position", name: "Related Tags Display Position", - description: "Position of related tags display", + description: "Display position (relative to Text area)", type: "combo", options: ["horizontal", "vertical"], defaultValue: "horizontal", diff --git a/web/js/related-tags.js b/web/js/related-tags.js index f825472..45e3bd0 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -1,333 +1,14 @@ // filepath: v:\Programs\StabilityMatrix-win-x64\Data\Packages\ComfyUI-New\custom_nodes\ComfyUI-Autocomplete-Plus\web\js\related-tags.js import { settingValues } from './settings.js'; -import { TagCategory, autoCompleteData } from './data.js'; +import { TagCategory, TagData, autoCompleteData } from './data.js'; import { normalizeTagToInsert, normalizeTagToSearch, isValidTag, - getCurrentTag, getViewportMargin } from './utils.js'; -// --- RelatedTags UI Class --- - -/** - * Class that manages the UI for displaying related tags. - * Shows a panel with tags related to the current tag under cursor. - */ -class RelatedTagsUI { - constructor() { - // Create the main container - this.root = document.createElement('div'); - this.root.id = 'related-tags-container'; - - // Create header row - this.header = document.createElement('div'); - this.header.id = 'related-tags-header'; - this.header.textContent = 'Related Tags'; - this.root.appendChild(this.header); - - // Create a tbody for the tags - this.tagsContainer = document.createElement('div'); - this.tagsContainer.id = 'related-tags-list'; - this.root.appendChild(this.tagsContainer); - - // Add to DOM - 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('mousedown', (e) => { - const row = e.target.closest('.related-tag-item'); - if (row && row.dataset.tag) { - this.selectTag(row.dataset.tag); - e.preventDefault(); - e.stopPropagation(); - } - }); - - // Timer ID for auto-refresh - this.autoRefreshTimerId = null; - } - - /** - * Shows the related tags UI for a specific tag and text input element. - * @param {HTMLTextAreaElement} inputElement The textarea being used - * @param {string} tag The tag to find relatedities for - * @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} relatedTags List of related tags - */ - show(inputElement, tag, relatedTags) { - if (!settingValues.enableRelatedTags) { - this.hide(); - return; - } - - this.activeInput = inputElement; - this.currentTag = tag; - - // Update content (even if there are no related tags, we'll show a message) - this.updateContent(relatedTags); - - // Calculate and update position - this.updatePosition(inputElement); - - // Make visible - this.root.style.display = 'block'; - - if (!autoCompleteData.initialized) { - if (this.autoRefreshTimerId) { - clearTimeout(this.autoRefreshTimerId); - } - this.autoRefreshTimerId = setTimeout(() => { - relatedTagsUI.show(inputElement, tag, relatedTags); - }, 500); - } - } - - /** - * Hides the related tags UI. - */ - hide() { - if (this.autoRefreshTimerId) { - clearTimeout(this.autoRefreshTimerId); - } - this.root.style.display = 'none'; - this.activeInput = null; - this.currentTag = null; - this.tagsContainer.innerHTML = ''; - } - - /** - * Updates the content of the related tags panel with the provided tags. - * @param {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} relatedTags - */ - updateContent(relatedTags) { - this.root.style.left = 0; - this.root.style.top = 0; - this.root.style.maxWidth = `${window.innerWidth / 2}px`; - this.root.style.maxHeight = `${window.innerHeight / 2}px`; - this.tagsContainer.innerHTML = ''; - - // Update header with current tag - this.header.innerHTML = ''; // Clear previous content - this.header.textContent = 'Tags related to: '; - const tagNameSpan = document.createElement('span'); - tagNameSpan.className = 'related-tags-header-tag-name'; - tagNameSpan.textContent = this.currentTag; - this.header.appendChild(tagNameSpan); - - if (!autoCompleteData.initialized) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'related-tags-loading-message'; - messageDiv.textContent = `Initializing cooccurrence data... [${autoCompleteData.baseLoadingProgress.cooccurrence}%]`; - this.tagsContainer.appendChild(messageDiv); - return; - } - - if (!relatedTags || relatedTags.length === 0) { - const messageCell = document.createElement('div'); - messageCell.textContent = 'No related tags found'; - this.tagsContainer.appendChild(messageCell); - return; - } - - // Create tag rows - relatedTags.forEach(tagData => { - const tagRow = this.createTagElement(tagData); - this.tagsContainer.appendChild(tagRow); - }); - } - - /** - * Creates an HTML table row for a related tag. - * @param {{tag: string, similarity: number, count: number, alias?: string[]}} tagData - * @returns {HTMLTableRowElement} The tag row element - */ - createTagElement(tagData) { - const categoryText = TagCategory[tagData.category] || "unknown"; - - const tagRow = document.createElement('div'); - tagRow.className = 'related-tag-item'; - tagRow.dataset.tag = tagData.tag; - tagRow.dataset.tagCategory = categoryText; - - // Tag name cell - const tagNameCell = document.createElement('span'); - tagNameCell.className = 'related-tag-name'; - tagNameCell.textContent = tagData.tag; - - // Alias cell (middle column) - const aliasCell = document.createElement('span'); - aliasCell.className = 'related-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 - } - - // Category cell - const categoryCell = document.createElement('span'); - categoryCell.className = `related-tag-category`; - categoryCell.textContent = `${categoryText.substring(0, 2)}`; - - // Similarity cell - const similarityCell = document.createElement('span'); - similarityCell.className = 'related-tag-similarity'; - similarityCell.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; - - // Create tooltip with more info - 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(', ')}`; - } - tagRow.title = tooltipText; - - // Add cells to row - tagRow.appendChild(tagNameCell); - tagRow.appendChild(aliasCell); - tagRow.appendChild(categoryCell); - tagRow.appendChild(similarityCell); - - return tagRow; - } - - /** - * Updates the position of the related tags panel. - * Position is calculated based on the input element, available space, - * and the setting `relatedTagsDisplayPosition`. - * @param {HTMLElement} inputElement The input element to position - */ - updatePosition(inputElement) { - - // Measure the element size without causing reflow - this.root.style.visibility = 'hidden'; - this.root.style.position = 'absolute'; // Ensure position is absolute for measurement - this.root.style.display = 'block'; - this.root.style.left = '-9999px'; - this.root.style.top = '-9999px'; - this.root.style.maxWidth = ''; // Reset max dimensions before measuring - this.root.style.maxHeight = ''; - const elemRect = this.root.getBoundingClientRect(); - // Hide it again after measurement - this.root.style.display = 'none'; - this.root.style.visibility = 'visible'; - this.root.style.position = ''; // Reset position style - this.root.style.left = ''; - this.root.style.top = ''; - - // Get the optimal placement area - const placementArea = this.#getOptimalPlacementArea(inputElement.getBoundingClientRect(), elemRect.width, elemRect.height); - - // Calculate final styles, fitting the element within the placement area - const finalMaxWidth = Math.min(elemRect.width, placementArea.width); - const finalMaxHeight = Math.min(elemRect.height, placementArea.height); - - // Adjust position if the element is smaller than the area (e.g., center or align based on mode) - // For simplicity, we'll just use the calculated top-left corner of the area for now. - // More sophisticated alignment could be added here if needed. - let finalLeft = placementArea.x; - let finalTop = placementArea.y; - - // Apply Styles - this.root.style.left = `${finalLeft}px`; - this.root.style.top = `${finalTop}px`; - this.root.style.maxWidth = `${finalMaxWidth}px`; - this.root.style.maxHeight = `${finalMaxHeight}px`; - } - - /** - * Handles the selection of a related 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 related tags UI is currently visible. - * @returns {boolean} - */ - isVisible() { - return this.root.style.display !== 'none'; - } - - - /** - * Calculates the optimal placement area for the panel based on available space. - * @param {DOMRect} inputRect - Bounding rectangle of the input element. - * @param {number} elemWidth - Width of the panel element. - * @param {number} elemHeight - Height of the panel element. - * @returns {{ x: number, y: number, width: number, height: number }} The calculated placement area. - */ - #getOptimalPlacementArea(inputRect, elemWidth, elemHeight) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - let margin = getViewportMargin(); - - // Find optimal max width baesd on viewport and input element - const maxWidth = Math.max( - Math.min(inputRect.right, viewportWidth - margin.right) - inputRect.left, - (viewportWidth - margin.left - margin.right) / 2 - ); - - const area = { - x: Math.max(inputRect.x, margin.left), - y: Math.max(inputRect.y, margin.top), - width: Math.min(elemWidth, maxWidth), - height: Math.min(elemHeight, viewportHeight - margin.top - margin.bottom) - }; - - if (settingValues.relatedTagsDisplayPosition === 'vertical') { - // Vertical placement - const topSpace = inputRect.top - margin.top; - const bottomSpace = viewportHeight - inputRect.bottom - margin.bottom; - if (topSpace > bottomSpace) { - // Place above - area.height = Math.min(area.height, topSpace); - area.y = Math.max(inputRect.y - area.height, margin.top); - } else { - // Place below - area.height = Math.min(area.height, bottomSpace); - area.y = inputRect.bottom; - } - - // Adjust x position to avoid overflow - area.x = Math.min(area.x, viewportWidth - area.width - margin.right); - } else { - // Horizontal placement - const leftSpace = inputRect.x - margin.left; - const rightSpace = viewportWidth - inputRect.right - margin.right; - if (leftSpace > rightSpace) { - // Place left - area.width = Math.min(area.width, leftSpace); - area.x = Math.max(inputRect.x - area.width, margin.left); - } else { - // Place right - area.width = Math.min(area.width, rightSpace); - area.x = inputRect.right; - } - - // Adjust y position to avoid overflow - area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom); - } - - return area; - } -} - -// --- Helper Functions --- +// --- RelatedTags Logic --- /** * Calculates the Jaccard similarity between two tags. @@ -354,12 +35,52 @@ function calculateJaccardSimilarity(tagA, tagB) { return union > 0 ? intersection / union : 0; } +/** + * Extracts the tag at the current cursor position. + * Handles tags separated by commas or newlines. + * @param {HTMLTextAreaElement} inputElement The textarea element + * @returns {string|null} The tag at cursor or null + */ +export function getTagFromCursorPosition(inputElement) { + const text = inputElement.value; + const cursorPos = inputElement.selectionStart; + + // Find the start position of the current tag + // Look for the last comma or newline before the cursor + const lastComma = text.lastIndexOf(',', cursorPos - 1); + const lastNewline = text.lastIndexOf('\n', cursorPos - 1); + let startPos = Math.max(lastComma, lastNewline); + startPos = startPos === -1 ? 0 : startPos + 1; // If no separator found, start from the beginning + + // Find the end position of the current tag + // Look for the next comma or newline after the start position (or cursor position if more appropriate) + // We search from startPos to correctly handle cases where the cursor is at the beginning of a tag + let searchEndFrom = Math.max(cursorPos, startPos); + let endPosComma = text.indexOf(',', searchEndFrom); + let endPosNewline = text.indexOf('\n', searchEndFrom); + + // If a separator is not found, treat it as the end of the text + if (endPosComma === -1) endPosComma = text.length; + if (endPosNewline === -1) endPosNewline = text.length; + + // Choose the closer separator as the end position + let endPos = Math.min(endPosComma, endPosNewline); + + // Extract and trim the tag + const tag = text.substring(startPos, endPos).trim(); + + // If no tag found, return null + if (!tag) return null; + + // Process the tag: swap underscores/spaces and unescape parentheses + return tag; +} + /** * Finds related tags for a given tag. * @param {string} tag The tag to find related tags for - * @returns {Array<{tag: string, similarity: number, count: number, alias?: string[]}>} */ -function findRelatedTags(tag) { +function searchRelatedTags(tag) { const startTime = performance.now(); // Record start time for performance measurement if (!tag || !autoCompleteData.cooccurrenceMap.has(tag)) { @@ -412,7 +133,7 @@ function findRelatedTags(tag) { * @param {HTMLTextAreaElement} inputElement * @param {string} tagToInsert */ -function insertTag(inputElement, tagToInsert) { +function insertTagToTextArea(inputElement, tagToInsert) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; @@ -552,33 +273,378 @@ function findAllTagPositions(text) { return positions; } -// Helper function to show related tags based on cursor position -function showRelatedTagsForCurrentPosition(textareaElement) { - // Get the tag at current cursor position - const currentTag = normalizeTagToSearch(getCurrentTag(textareaElement)); +// --- RelatedTags UI Class --- - // If no valid tag or tag is too short, hide the panel - if (!isValidTag(currentTag)) { - relatedTagsUI.hide(); - return; +/** + * Class that manages the UI for displaying related tags. + * Shows a panel with tags related to the current tag under cursor. + */ +class RelatedTagsUI { + constructor() { + // Create the main container + this.root = document.createElement('div'); + this.root.id = 'related-tags-container'; + + // Create header row + this.header = document.createElement('div'); + this.header.id = 'related-tags-header'; + this.header.textContent = 'Related Tags'; + this.root.appendChild(this.header); + + // Create a tbody for the tags + this.tagsContainer = document.createElement('div'); + this.tagsContainer.id = 'related-tags-list'; + this.root.appendChild(this.tagsContainer); + + // Add to DOM + document.body.appendChild(this.root); + + this.target = null; + this.selectedIndex = -1; + this.relatedTags = []; + + // Timer ID for auto-refresh + this.autoRefreshTimerId = null; + + // Add click handler for tag selection + this.tagsContainer.addEventListener('mousedown', (e) => { + const row = e.target.closest('.related-tag-item'); + if (row && row.dataset.tag) { + this.#insertTag(row.dataset.tag); + e.preventDefault(); + e.stopPropagation(); + } + }); } - // Find related tags - const relatedTagsResults = findRelatedTags(currentTag); + /** + * Checks if the related tags UI is currently visible. + * @returns {boolean} + */ + isVisible() { + return this.root.style.display !== 'none'; + } - // Always show the panel with current tag, even if there are no related tags - relatedTagsUI.show(textareaElement, currentTag, relatedTagsResults); + /** + * Display + * @param {HTMLTextAreaElement} textareaElement The textarea being used + */ + updateDisplay(textareaElement) { + if (!settingValues.enableRelatedTags) { + this.hide(); + return; + } + + // Get the tag at current cursor position + this.currentTag = normalizeTagToSearch(getTagFromCursorPosition(textareaElement)); + + // If no valid tag or tag is too short, hide the panel + if (!isValidTag(this.currentTag)) { + this.hide(); + return; + } + + this.target = textareaElement; + + this.relatedTags = searchRelatedTags(this.currentTag); + this.selectedIndex = 0; + + // Update content (even if there are no related tags, we'll show a message) + this.#updateContent(); + + this.#updatePosition(); + + // Highlight the first item if available + this.#highlightItem(); + + // Make visible + this.root.style.display = 'block'; + + // Update initialization status if not already done + if (!autoCompleteData.initialized) { + if (this.autoRefreshTimerId) { + clearTimeout(this.autoRefreshTimerId); + } + const self = this; + this.autoRefreshTimerId = setTimeout(() => { + self.updateDisplay(textareaElement); + }, 500); + } + } + + /** + * Hides the related tags UI. + */ + hide() { + if (this.autoRefreshTimerId) { + clearTimeout(this.autoRefreshTimerId); + } + + this.root.style.display = 'none'; + this.selectedIndex = -1; + this.relatedTags = null; + this.target = null; + } + + /** Moves the selection up or down */ + navigate(direction) { + if (this.relatedTags.length === 0) return; + this.selectedIndex += direction; + + if (this.selectedIndex < 0) { + this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom + } else if (this.selectedIndex >= this.relatedTags.length) { + this.selectedIndex = 0; // Wrap around to top + } + this.#highlightItem(); + } + + /** Selects the currently highlighted item */ + getSelectedTag() { + if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) { + return this.relatedTags[this.selectedIndex].tag; + } + + return null; // No valid selection + } + + /** + * Updates the content of the related tags panel with the provided tags. + */ + #updateContent() { + this.root.style.left = 0; + this.root.style.top = 0; + this.root.style.maxWidth = `${window.innerWidth / 2}px`; + this.root.style.maxHeight = `${window.innerHeight / 2}px`; + this.tagsContainer.innerHTML = ''; + + // Update header with current tag + this.header.innerHTML = ''; // Clear previous content + this.header.textContent = 'Tags related to: '; + const tagNameSpan = document.createElement('span'); + tagNameSpan.className = 'related-tags-header-tag-name'; + tagNameSpan.textContent = this.currentTag; + this.header.appendChild(tagNameSpan); + + if (!autoCompleteData.initialized) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'related-tags-loading-message'; + messageDiv.textContent = `Initializing cooccurrence data... [${autoCompleteData.baseLoadingProgress.cooccurrence}%]`; + this.tagsContainer.appendChild(messageDiv); + return; + } + + if (!this.relatedTags || this.relatedTags.length === 0) { + const messageCell = document.createElement('div'); + messageCell.textContent = 'No related tags found'; + this.tagsContainer.appendChild(messageCell); + return; + } + + // Create tag rows + this.relatedTags.forEach(tagData => { + const tagRow = this.#createTagElement(tagData); + this.tagsContainer.appendChild(tagRow); + }); + } + + /** + * Creates an HTML table row for a related tag. + * @param {TagData} tagData The tag data to display + * @returns {HTMLTableRowElement} The tag row element + */ + #createTagElement(tagData) { + const categoryText = TagCategory[tagData.category] || "unknown"; + + const tagRow = document.createElement('div'); + tagRow.className = 'related-tag-item'; + tagRow.dataset.tag = tagData.tag; + tagRow.dataset.tagCategory = categoryText; + + // Tag name cell + const tagNameCell = document.createElement('span'); + tagNameCell.className = 'related-tag-name'; + tagNameCell.textContent = tagData.tag; + + // Alias cell (middle column) + const aliasCell = document.createElement('span'); + aliasCell.className = 'related-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 + } + + // Category cell + const categoryCell = document.createElement('span'); + categoryCell.className = `related-tag-category`; + categoryCell.textContent = `${categoryText.substring(0, 2)}`; + + // Similarity cell + const similarityCell = document.createElement('span'); + similarityCell.className = 'related-tag-similarity'; + similarityCell.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; + + // Create tooltip with more info + 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(', ')}`; + } + tagRow.title = tooltipText; + + // Add cells to row + tagRow.appendChild(tagNameCell); + tagRow.appendChild(aliasCell); + tagRow.appendChild(categoryCell); + tagRow.appendChild(similarityCell); + + return tagRow; + } + + /** + * Updates the position of the related tags panel. + * Position is calculated based on the input element, available space, + * and the setting `relatedTagsDisplayPosition`. + * @param {HTMLElement} inputElement The input element to position + */ + #updatePosition() { + + // Measure the element size without causing reflow + this.root.style.visibility = 'hidden'; + this.root.style.position = 'absolute'; // Ensure position is absolute for measurement + this.root.style.display = 'block'; + this.root.style.left = '-9999px'; + this.root.style.top = '-9999px'; + this.root.style.maxWidth = ''; // Reset max dimensions before measuring + this.root.style.maxHeight = ''; + const elemRect = this.root.getBoundingClientRect(); + // Hide it again after measurement + this.root.style.display = 'none'; + this.root.style.visibility = 'visible'; + this.root.style.position = ''; // Reset position style + this.root.style.left = ''; + this.root.style.top = ''; + + // Get the optimal placement area + const placementArea = this.#getOptimalPlacementArea(this.target.getBoundingClientRect(), elemRect.width, elemRect.height); + + // Calculate final styles, fitting the element within the placement area + const finalMaxWidth = Math.min(elemRect.width, placementArea.width); + const finalMaxHeight = Math.min(elemRect.height, placementArea.height); + + // Adjust position if the element is smaller than the area (e.g., center or align based on mode) + // For simplicity, we'll just use the calculated top-left corner of the area for now. + // More sophisticated alignment could be added here if needed. + let finalLeft = placementArea.x; + let finalTop = placementArea.y; + + // Apply Styles + this.root.style.left = `${finalLeft}px`; + this.root.style.top = `${finalTop}px`; + this.root.style.maxWidth = `${finalMaxWidth}px`; + this.root.style.maxHeight = `${finalMaxHeight}px`; + } + + /** Highlights the item (row) at the given index */ + #highlightItem() { + if (this.getSelectedTag() === null) return; // No valid selection + + const items = this.tagsContainer.children; // Get rows + for (let i = 0; i < items.length; i++) { + if (i === this.selectedIndex) { + items[i].classList.add('selected'); // Use CSS class for selection + items[i].scrollIntoView({ block: 'nearest' }); + } else { + items[i].classList.remove('selected'); + } + } + } + + /** + * Handles the selection of a related tag. + * Inserts the tag into the active input. + * @param {string} tag + */ + #insertTag(tag) { + if (!this.target) return; + + // Use the same insertTag function from autocomplete.js + insertTagToTextArea(this.target, tag); + + // Hide the panel after selection + this.hide(); + } + + /** + * Calculates the optimal placement area for the panel based on available space. + * @param {DOMRect} inputRect - Bounding rectangle of the input element. + * @param {number} elemWidth - Width of the panel element. + * @param {number} elemHeight - Height of the panel element. + * @returns {{ x: number, y: number, width: number, height: number }} The calculated placement area. + */ + #getOptimalPlacementArea(inputRect, elemWidth, elemHeight) { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let margin = getViewportMargin(); + + // Find optimal max width baesd on viewport and input element + const maxWidth = Math.max( + Math.min(inputRect.right, viewportWidth - margin.right) - inputRect.left, + (viewportWidth - margin.left - margin.right) / 2 + ); + + const area = { + x: Math.max(inputRect.x, margin.left), + y: Math.max(inputRect.y, margin.top), + width: Math.min(elemWidth, maxWidth), + height: Math.min(elemHeight, viewportHeight - margin.top - margin.bottom) + }; + + if (settingValues.relatedTagsDisplayPosition === 'vertical') { + // Vertical placement + const topSpace = inputRect.top - margin.top; + const bottomSpace = viewportHeight - inputRect.bottom - margin.bottom; + if (topSpace > bottomSpace) { + // Place above + area.height = Math.min(area.height, topSpace); + area.y = Math.max(inputRect.y - area.height, margin.top); + } else { + // Place below + area.height = Math.min(area.height, bottomSpace); + area.y = inputRect.bottom; + } + + // Adjust x position to avoid overflow + area.x = Math.min(area.x, viewportWidth - area.width - margin.right); + } else { + // Horizontal placement + const leftSpace = inputRect.x - margin.left; + const rightSpace = viewportWidth - inputRect.right - margin.right; + if (leftSpace > rightSpace) { + // Place left + area.width = Math.min(area.width, leftSpace); + area.x = Math.max(inputRect.x - area.width, margin.left); + } else { + // Place right + area.width = Math.min(area.width, rightSpace); + area.x = inputRect.right; + } + + // Adjust y position to avoid overflow + area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom); + } + + return area; + } } - -// --- Main Exports --- - -// Create a singleton instance -const relatedTagsUI = new RelatedTagsUI(); - +// --- RelatedTags Event Handling Class --- export class RelatedTagsEventHandler { constructor() { - + // Singleton instance of RelatedTagsUI + this.relatedTagsUI = new RelatedTagsUI(); } /** @@ -587,8 +653,8 @@ export class RelatedTagsEventHandler { */ handleInput(event) { if (settingValues.enableRelatedTags) { - if (relatedTagsUI && relatedTagsUI.isVisible()) { - relatedTagsUI.hide(); + if (this.relatedTagsUI && this.relatedTagsUI.isVisible()) { + this.relatedTagsUI.hide(); } } } @@ -616,13 +682,13 @@ export class RelatedTagsEventHandler { // Check if the new focused element is part of the related tags UI. // document.activeElement refers to the currently focused element. const activeElement = document.activeElement; - const relatedTagsElement = relatedTagsUI.root; + const relatedTagsElement = this.relatedTagsUI.root; // If the focus is not within the related tags UI, hide it. - if (relatedTagsUI && !relatedTagsElement.contains(activeElement)) { - relatedTagsUI.hide(); + if (this.relatedTagsUI && !relatedTagsElement.contains(activeElement)) { + this.relatedTagsUI.hide(); } - }, 150); // Delay in milliseconds (adjust if necessary) + }, 150); } /** @@ -633,10 +699,28 @@ export class RelatedTagsEventHandler { const textareaElement = event.target; // For related tags panel, handle Escape key - if (relatedTagsUI && relatedTagsUI.isVisible()) { - if (event.key === 'Escape') { - event.preventDefault(); - relatedTagsUI.hide(); + if (this.relatedTagsUI && this.relatedTagsUI.isVisible()) { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.relatedTagsUI.navigate(1); + break; + case 'ArrowUp': + event.preventDefault(); + this.relatedTagsUI.navigate(-1); + break; + case 'Enter': + case 'Tab': + if (this.relatedTagsUI.getSelectedTag() !== null) { + event.preventDefault(); + insertTagToTextArea(textareaElement, this.relatedTagsUI.getSelectedTag()); + } + this.relatedTagsUI.hide(); + break; + case 'Escape': + event.preventDefault(); + this.relatedTagsUI.hide(); + break; } } @@ -644,7 +728,7 @@ export class RelatedTagsEventHandler { if (settingValues.enableRelatedTags) { if (event.key === ' ' && event.ctrlKey && event.shiftKey) { event.preventDefault(); - showRelatedTagsForCurrentPosition(textareaElement); + this.relatedTagsUI.updateDisplay(textareaElement); } } } @@ -667,12 +751,12 @@ export class RelatedTagsEventHandler { } /** - * + * Show related tags based on the current tag under the cursor. * @param {MouseEvent} event * @returns */ handleClick(event) { const textareaElement = event.target; - showRelatedTagsForCurrentPosition(textareaElement); + this.relatedTagsUI.updateDisplay(textareaElement); } } \ No newline at end of file diff --git a/web/js/utils.js b/web/js/utils.js index 1306644..86e1ab8 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -137,46 +137,6 @@ export function isValidTag(tag) { return true; } -/** - * Extracts the tag at the current cursor position. - * Handles tags separated by commas or newlines. - * @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 the start position of the current tag - // Look for the last comma or newline before the cursor - const lastComma = text.lastIndexOf(',', cursorPos - 1); - const lastNewline = text.lastIndexOf('\n', cursorPos - 1); - let startPos = Math.max(lastComma, lastNewline); - startPos = startPos === -1 ? 0 : startPos + 1; // If no separator found, start from the beginning - - // Find the end position of the current tag - // Look for the next comma or newline after the start position (or cursor position if more appropriate) - // We search from startPos to correctly handle cases where the cursor is at the beginning of a tag - let searchEndFrom = Math.max(cursorPos, startPos); - let endPosComma = text.indexOf(',', searchEndFrom); - let endPosNewline = text.indexOf('\n', searchEndFrom); - - // If a separator is not found, treat it as the end of the text - if (endPosComma === -1) endPosComma = text.length; - if (endPosNewline === -1) endPosNewline = text.length; - - // Choose the closer separator as the end position - let endPos = Math.min(endPosComma, endPosNewline); - - // Extract and trim the tag - const tag = text.substring(startPos, endPos).trim(); - - // If no tag found, return null - if (!tag) return null; - - // Process the tag: swap underscores/spaces and unescape parentheses - return tag; -} // --- End String Helper Functions ---