diff --git a/web/css/autocomplete-plus.css b/web/css/autocomplete-plus.css index 8551721..325bcd5 100644 --- a/web/css/autocomplete-plus.css +++ b/web/css/autocomplete-plus.css @@ -6,7 +6,7 @@ --autocomplete-plus-text-color-cat-character: var(--p-green-600); --autocomplete-plus-text-color-cat-meta: var(--p-yellow-600); --autocomplete-plus-text-color-cat-unknown: var(--p-gray-600); - --autocomplete-plus-text-color-disabled: var(--p-gray-600); + --autocomplete-plus-text-color-disabled: var(--p-gray-400); /* Selected color */ --autocomplete-plus-text-color-cat-general-selected: var(--p-blue-200); @@ -36,26 +36,30 @@ body.dark-theme { --autocomplete-plus-text-color-cat-unknown-selected: var(--p-gray-200); } -#autocomplete-plus-list { +#autocomplete-plus-root { position: absolute; z-index: 999; - min-width: 200px; - border: 1px solid var(--border-color); - border-radius: 4px; + top: 0; + left: 0; + display: none; + width: fit-content; background-color: var(--comfy-input-bg); - box-shadow: 0 2px 5px rgb(0 0 0 / 20%); color: var(--input-text); - font-size: 0.9em; +} + +#autocomplete-plus-list { + display: grid; + box-shadow: 0 2px 8px rgb(0 0 0 / 30%); + grid-auto-rows: auto; + grid-template-columns: max-content 1fr auto auto; overflow-y: auto; } .autocomplete-plus-item { - overflow: hidden; - padding: 5px 10px; - border-bottom: 1px solid var(--border-color); + display: grid; cursor: pointer; - text-overflow: ellipsis; - white-space: nowrap; + grid-column: 1 / -1; + grid-template-columns: subgrid; } .autocomplete-plus-item[data-tag-category="general"] { @@ -82,26 +86,22 @@ body.dark-theme { color: var(--autocomplete-plus-text-color-cat-unknown); } -.autocomplete-plus-item:last-child { - border-bottom: none; +.autocomplete-plus-item span { + align-content: center; + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); } /* Alternating row colors */ -.autocomplete-plus-item:nth-child(even) { +.autocomplete-plus-item:nth-child(even) span { background-color: var(--comfy-menu-bg); } -.autocomplete-plus-item:nth-child(odd) { +.autocomplete-plus-item:nth-child(odd) span { background-color: var(--comfy-menu-secondary-bg); } -/* Hover effect */ -.autocomplete-plus-item:hover { - background-color: var(--comfy-hover-bg); -} - -/* Selected item style */ -.autocomplete-plus-item.selected { +.autocomplete-plus-item.selected span { background-color: var( --comfy-menu-bg-selected, var(--comfy-button-bg) @@ -109,7 +109,20 @@ body.dark-theme { font-weight: bold; } -.autocomplete-plus-item .autocomplete-plus-alias { +.autocomplete-plus-item:hover span { + background-color: var(--comfy-hover-bg); +} + +.autocomplete-plus-tag-name { + padding: 8px; + white-space: nowrap; +} + +.autocomplete-plus-tag-name.autocomplete-plus-already-exists { + color: var(--autocomplete-plus-text-color-disabled); +} + +.autocomplete-plus-alias { overflow: hidden; color: var(--descrip-text); text-overflow: ellipsis; @@ -117,7 +130,7 @@ body.dark-theme { } /* Related Tags UI Styles */ -#related-tags-container { +#related-tags-root { position: absolute; z-index: 999; top: 0; @@ -210,6 +223,7 @@ body.dark-theme { color: var(--autocomplete-plus-text-color-cat-unknown); } +/* stylelint-disable-next-line no-descending-specificity */ .related-tag-item span { align-content: center; padding: 4px 8px; diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 2b62462..28e098b 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -9,6 +9,9 @@ import { kataToHira, normalizeTagToInsert, normalizeTagToSearch, + findAllTagPositions, + extractTagsFromTextArea, + getViewportMargin } from './utils.js'; import { settingValues } from './settings.js'; @@ -17,30 +20,29 @@ import { settingValues } from './settings.js'; /** * 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. + * @returns {Array} 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 + + const exactMatches = []; + const partialMatches = []; + const addedTags = new Set(); // Generate Hiragana/Katakana variations if applicable - const queryVariations = new Set([lowerQuery, normalizeTagToSearch(lowerQuery)]); - const kataQuery = hiraToKata(lowerQuery); - if (kataQuery !== lowerQuery) { + const queryVariations = new Set([partialTag, normalizeTagToSearch(partialTag)]); + const kataQuery = hiraToKata(partialTag); + if (kataQuery !== partialTag) { queryVariations.add(kataQuery); } - const hiraQuery = kataToHira(lowerQuery); - if (hiraQuery !== lowerQuery) { + const hiraQuery = kataToHira(partialTag); + if (hiraQuery !== partialTag) { queryVariations.add(hiraQuery); } @@ -50,12 +52,9 @@ function searchCompletionCandidates(textareaElement) { 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) { + if (tagData.tag === variation) { isExactMatch = true; matched = true; break; @@ -65,10 +64,10 @@ function searchCompletionCandidates(textareaElement) { // If not an exact match, check for partial matches in the tag if (!isExactMatch) { for (const variation of queryVariations) { - if (lowerTag.includes(variation)) { + if (tagData.tag.includes(variation)) { matched = true; break; - } else if (lowerTag.replace(/[\-_\s']/g, '').includes(variation.replace(/[\-_\s']/g, ''))) { + } else if (tagData.tag.replace(/[\-_\s']/g, '').includes(variation.replace(/[\-_\s']/g, ''))) { // Try to match with underscore, dash, or apostrophe removed matched = true; break; @@ -132,7 +131,7 @@ function searchCompletionCandidates(textareaElement) { 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).`); + console.debug(`[Autocomplete-Plus] Search for "${partialTag}" took ${duration.toFixed(2)}ms. Found ${result.length} candidates (max reached).`); } return result; // Early exit @@ -146,7 +145,7 @@ function searchCompletionCandidates(textareaElement) { 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.`); + console.debug(`[Autocomplete-Plus] Search for "${partialTag}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates.`); } return candidates; @@ -156,6 +155,7 @@ function searchCompletionCandidates(textareaElement) { * Extracts the current tag being typed before the cursor. * Assumes tags are separated by commas. * @param {HTMLTextAreaElement} inputElement + * @returns {string} The current partial tag. */ function getCurrentPartialTag(inputElement) { const text = inputElement.value; @@ -171,7 +171,7 @@ function getCurrentPartialTag(inputElement) { // Extract the text between the last comma (or start) and the cursor const partial = text.substring(start, cursorPos).trimStart(); - return partial; + return normalizeTagToSearch(partial); } /** @@ -184,18 +184,30 @@ function insertTagToTextArea(inputElement, tagToInsert) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; - const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); + // First check if the tag exists anywhere in the textarea and select it if found + const tagPositions = findAllTagPositions(text); + for (const { start, end, tag } of tagPositions) { + const existingTag = tag.trim(); + if (existingTag === normalizeTagToInsert(tagToInsert)) { + // Tag already exists, select it and exit + inputElement.focus(); + inputElement.setSelectionRange(start, end); + return; + } + } + + // Find the current tag boundaries const lastComma = text.lastIndexOf(',', cursorPos - 1); + const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); + const lastSeparator = Math.max(lastComma, lastNewLine); + const startPos = lastSeparator === -1 ? 0 : lastSeparator + 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 currentWordStart = text.substring(startPos, cursorPos).search(/\S|$/) + startPos; const currentWordEndMatch = text.substring(cursorPos).match(/^[^,\n]+/); - + let currentWordEnd = cursorPos; + + const normalizedTag = normalizeTagToInsert(tagToInsert); // If the end match is found, set currentWordEnd to the end of the match if (currentWordEndMatch && normalizedTag.lastIndexOf(currentWordEndMatch[0]) !== -1) { @@ -245,41 +257,35 @@ function insertTagToTextArea(inputElement, tagToInsert) { class AutocompleteUI { constructor() { - this.element = document.createElement('table'); // Use table instead of div - this.element.id = 'autocomplete-plus-list'; - this.element.style.display = 'none'; // Initially hidden - this.element.style.position = 'absolute'; // Position near the input - this.element.style.borderCollapse = 'collapse'; // Optional: for table styling - this.element.style.width = 'auto'; // Adjust width automatically + this.root = document.createElement('div'); // Use table instead of div + this.root.id = 'autocomplete-plus-root'; - const tbody = document.createElement('tbody'); - this.element.appendChild(tbody); + this.tagsList = document.createElement('div'); + this.tagsList.id = 'autocomplete-plus-list'; + this.root.appendChild(this.tagsList); // Add to DOM - document.body.appendChild(this.element); + document.body.appendChild(this.root); this.target = null; this.selectedIndex = -1; this.candidates = []; // Add event listener for clicks on items (listen on tbody) - tbody.addEventListener('mousedown', (e) => { + this.tagsList.addEventListener('mousedown', (e) => { // Check if the click target is a TD inside a TR with a data-index const row = e.target.closest('tr'); - if (row && row.dataset.index) { - const index = parseInt(row.dataset.index, 10); - if (!isNaN(index)) { - this.#insertTag(index); - e.preventDefault(); // Prevent focus loss from input - e.stopPropagation(); - } + if (row && row.dataset.tag) { + this.#insertTag(row.dataset.tag); + e.preventDefault(); // Prevent focus loss from input + e.stopPropagation(); } }); } /** Checks if the autocomplete list is visible */ isVisible() { - return this.element.style.display !== 'none'; + return this.root.style.display !== 'none'; } /** @@ -288,17 +294,15 @@ class AutocompleteUI { * @returns */ updateDisplay(textareaElement) { - const candidates = searchCompletionCandidates(textareaElement); - if (candidates.length <= 0) { + this.candidates = searchCompletionCandidates(textareaElement); + if (this.candidates.length <= 0) { this.hide(); return; } this.target = textareaElement; - this.candidates = candidates; - - if(this.selectedIndex == -1) { + if (this.selectedIndex == -1) { this.selectedIndex = 0; // Reset selection to the first item } @@ -310,15 +314,14 @@ class AutocompleteUI { // Highlight the first item this.#highlightItem(); - this.element.style.overflowY = 'auto'; - this.element.style.display = 'block'; // Make it visible + this.root.style.display = 'block'; // Make it visible } /** * hides the autocomplete list. */ hide() { - this.element.style.display = 'none'; + this.root.style.display = 'none'; this.selectedIndex = -1; this.target = null; this.candidates = []; @@ -350,86 +353,71 @@ class AutocompleteUI { * Updates the list from the current candidates. */ #updateContent() { - const tbody = this.element.querySelector('tbody'); - tbody.innerHTML = ''; // Clear previous items from tbody + this.tagsList.innerHTML = ''; if (this.candidates.length === 0) { this.hide(); return; } - this.candidates.forEach((candidate, index) => { - this.#createTagElement(index, candidate); // Pass candidate directly - }); + const existingTags = extractTagsFromTextArea(this.target); + const currentTag = getCurrentPartialTag(this.target); - // If the list is already visible, update its position in case content changed size significantly - if (this.isVisible() && this.target) { - this.#updatePosition(); - } + this.candidates.forEach((tagData) => { + const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1); + const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag); + this.#createTagElement(tagData, isExistingTag); + }); } /** * Creates a table row (tr) for a candidate item. - * @param {number} index * @param {TagData} tagData + * @param {boolean} isExisting */ - #createTagElement(index, tagData) { - const MAX_ALIAS_LENGTH = 25; // Maximum length for alias display + #createTagElement(tagData, isExisting) { 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'; + const tagRow = document.createElement('div'); + tagRow.classList.add('autocomplete-plus-item'); + tagRow.dataset.tag = tagData.tag; + tagRow.dataset.tagCategory = categoryText; - // 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 + // Tag name + const tagName = document.createElement('span'); + tagName.classList.add('autocomplete-plus-tag-name'); + tagName.textContent = tagData.tag; - 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}]`; + // grayout tag name if it already exists + if (isExisting) { + tagName.classList.add('autocomplete-plus-already-exists'); } - 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 + // Alias + const alias = document.createElement('span'); + alias.classList.add('autocomplete-plus-alias'); - // 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 + // Display alias if available + if (tagData.alias && tagData.alias.length > 0) { + let aliasText = tagData.alias.join(', '); + alias.textContent = `${aliasText}`; + alias.title = tagData.alias.join(', '); // Full alias on hover + } - item.appendChild(tagCell); - item.appendChild(aliasCell); - item.appendChild(catCell); - item.appendChild(countCell); - tbody.appendChild(item); // Append row to tbody + // Category + const category = document.createElement('span'); + category.className = `autocomplete-plus-category`; + category.textContent = `${categoryText.substring(0, 2)}`; + + // Count + const tagCount = document.createElement('span'); + category.className = `autocomplete-plus-tag-count`; + tagCount.textContent = formatCountHumanReadable(tagData.count); + + tagRow.appendChild(tagName); + tagRow.appendChild(alias); + tagRow.appendChild(category); + tagRow.appendChild(tagCount); + this.tagsList.appendChild(tagRow); // Append row to tbody } /** @@ -440,95 +428,91 @@ class AutocompleteUI { * Considers ComfyUI canvas scale. */ #updatePosition() { - const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.target); - - 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 + // Measure the element size without causing reflow + this.root.style.visibility = 'hidden'; + this.root.style.display = 'block'; + this.root.style.maxWidth = ''; + this.tagsList.style.maxHeight = ''; + const rootRect = this.root.getBoundingClientRect(); + // Hide it again after measurement + this.root.style.display = 'none'; + this.root.style.visibility = 'visible'; // Get ComfyUI canvas scale if available, otherwise default to 1 const scale = window.app?.canvas?.ds?.scale ?? 1.0; - // Initial desired position: below the current text line where the caret is. - let topPosition = elOffset.top - (elScroll.top * scale) + ((caretTop - elOffset.top) + caretLineHeight) * scale; - let leftPosition = elScroll.left - elScroll.left + caretLeft; - - // Make the list visible *before* getting its dimensions to ensure they are accurate - // Use visibility instead of display to measure without affecting layout yet - this.element.style.visibility = 'hidden'; - this.element.style.display = 'block'; - const listRect = this.element.getBoundingClientRect(); // Dimensions without max-height constraint - const naturalHeight = listRect.height; - this.element.style.display = 'none'; // Hide again until final position is set - this.element.style.visibility = 'visible'; - const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const margin = 5; // Small margin from viewport edges + const margin = getViewportMargin(); + + const targetRect = this.target.getBoundingClientRect(); + const targetElmOffset = this.#calculateElementOffset(this.target); + const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.target); - // --- Horizontal Collision Detection and Adjustment --- - if (leftPosition + listRect.width > viewportWidth - margin) { - leftPosition = viewportWidth - listRect.width - margin; + // Initial desired position: below the current text line where the caret is. + let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale; + let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;; + + const maxWidth = Math.min(rootRect.width, viewportWidth / 2); + const naturalHeight = rootRect.height; + + //Horizontal Collision Detection and Adjustment + if (leftPosition + maxWidth > viewportWidth - margin.right) { + leftPosition = viewportWidth - maxWidth - margin.right; } - if (leftPosition < margin) { - leftPosition = margin; + if (leftPosition < margin.left) { + leftPosition = margin.left; } - // --- Vertical Collision Detection and Adjustment --- - const availableSpaceBelow = viewportHeight - topPosition - margin; - const availableSpaceAbove = caretTop - margin; // Space above the caret line + // Vertical Collision Detection and Adjustment + const availableSpaceBelow = viewportHeight - topPosition - margin.bottom; + const availableSpaceAbove = caretTop - margin.top; - // Reset max-height before deciding - this.element.style.maxHeight = ''; if (naturalHeight <= availableSpaceBelow) { // Fits perfectly below the caret // topPosition remains as calculated initially } else { // Doesn't fit below, check if it fits perfectly above - const topAboveCaret = caretTop - naturalHeight - margin; // Position list bottom just above caret line if (naturalHeight <= availableSpaceAbove) { // Fits perfectly above - topPosition = topAboveCaret; + topPosition = caretTop - naturalHeight - margin.top; } else { // Doesn't fit perfectly either below or above, needs scrolling. // Choose the position (above or below) that offers more space. if (availableSpaceBelow >= availableSpaceAbove) { // Scroll below: topPosition remains as initially calculated - this.element.style.maxHeight = `${availableSpaceBelow}px`; + this.tagsList.style.maxHeight = `${availableSpaceBelow}px`; } else { // Scroll above: Position near the top edge and set max-height - topPosition = margin; - this.element.style.maxHeight = `${availableSpaceAbove}px`; + topPosition = margin.top; + this.tagsList.style.maxHeight = `${availableSpaceAbove}px`; } } } // Final check to prevent going off the top edge - if (topPosition < margin) { - topPosition = margin; + if (topPosition < margin.top) { + topPosition = margin.top; // If pushed down, recalculate max-height if it was set based on top alignment - if (this.element.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { + if (this.tagsList.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { // Recalculate max-height based on space from the top margin - this.element.style.maxHeight = `${viewportHeight - margin - margin}px`; + this.tagsList.style.maxHeight = `${viewportHeight - margin.top - margin.bottom}px`; } } // Apply the calculated position and display the element - this.element.style.left = `${leftPosition}px`; - this.element.style.top = `${topPosition}px`; + this.root.style.left = `${leftPosition}px`; + this.root.style.top = `${topPosition}px`; + this.root.style.maxWidth = `${maxWidth}px`; } /** Highlights the item (row) at the given index */ #highlightItem() { - if(!this.getSelectedTag()) return; // No valid selection + if (!this.getSelectedTag()) return; // No valid selection - const tbody = this.element.querySelector('tbody'); - if (!tbody) return; - - const items = tbody.children; // Get rows (tr) from tbody + const items = this.tagsList.children; // Get rows (tr) from tbody for (let i = 0; i < items.length; i++) { if (i === this.selectedIndex) { items[i].classList.add('selected'); // Use CSS class for selection @@ -540,24 +524,21 @@ class AutocompleteUI { } /** - * Handles the selection of an item (e.g., inserts into input). - * @param {number} index The index of the selected candidate. + * Handles the selection of an item + * @param {string} selectedTag The tag to insert. */ - #insertTag(index) { - if (!this.target || index < 0 || index >= this.candidates.length) { + #insertTag(selectedTag) { + if (!this.target || !selectedTag || selectedTag.length <= 0) { this.hide(); return; } - // Get the selected tag - const selectedTag = this.candidates[index].tag; - // Insert the selected tag insertTagToTextArea(this.target, selectedTag); this.hide(); } - + /** * Gets the pixel coordinates of the caret in the input element. * Uses a temporary div to calculate the position accurately. @@ -739,16 +720,23 @@ class AutocompleteUI { return height; } + /** + * calculates the offset of the given element relative to the viewport. + * @param {HTMLElement} element + * @returns {{ top: number, left: number }} + */ #calculateElementOffset(element) { const rect = element.getBoundingClientRect(); const owner = element.ownerDocument; if (owner == null) { throw new Error("Given element does not belong to document"); } + const { defaultView, documentElement } = owner; if (defaultView == null) { throw new Error("Given element does not belong to window"); } + const offset = { top: rect.top + defaultView.pageYOffset, left: rect.left + defaultView.pageXOffset, @@ -774,17 +762,17 @@ export class AutocompleteEventHandler { */ handleInput(event) { if (!settingValues.enabled) return; - if(!event.isTrusted) return; // ignore synthetic events + if (!event.isTrusted) return; // ignore synthetic events const textareaElement = event.target; const partialTag = getCurrentPartialTag(textareaElement); - if (partialTag.length <= 0){ + if (partialTag.length <= 0) { this.autocompleteUI.hide(); } } handleFocus(event) { - + } handleBlur(event) { @@ -792,7 +780,7 @@ export class AutocompleteEventHandler { // Need a slight delay because clicking the autocomplete list causes blur setTimeout(() => { - if (!this.autocompleteUI.element.contains(document.activeElement)) { + if (!this.autocompleteUI.root.contains(document.activeElement)) { this.autocompleteUI.hide(); } }, 150); @@ -832,7 +820,7 @@ export class AutocompleteEventHandler { this.autocompleteUI.hide(); break; } - } + } } /** @@ -840,7 +828,7 @@ export class AutocompleteEventHandler { * @param {KeyboardEvent} event * @returns */ - handleKeyUp(event){ + handleKeyUp(event) { if (!settingValues.enabled) return; // Do not process keyup events if Ctrl, Alt, or Meta keys are pressed. @@ -849,24 +837,24 @@ export class AutocompleteEventHandler { if (event.ctrlKey || event.altKey || event.metaKey) { return; } - + if (this.autocompleteUI.isVisible()) { - switch (event.key) { - case "Escape": - event.preventDefault(); - this.autocompleteUI.hide(); - return; // Return here to prevent updateDisplay after hiding with Escape - // Other keys like Enter, Tab, Arrows are handled in keyDown. - // For other character keys, Backspace, Delete, we fall through to updateDisplay. - } - } else { + switch (event.key) { + case "Escape": + event.preventDefault(); + this.autocompleteUI.hide(); + return; // Return here to prevent updateDisplay after hiding with Escape + // Other keys like Enter, Tab, Arrows are handled in keyDown. + // For other character keys, Backspace, Delete, we fall through to updateDisplay. + } + } else { // If UI is not visible, and the key is a non-character key (length > 1) // and not Delete or Backspace, then do nothing. // This prevents UI from appearing on ArrowUp, F1, Shift (alone), etc. if (event.key.length > 1 && event.key !== "Delete" && event.key !== "Backspace") { - return; + return; } - } + } // If the event was not handled by the above (e.g. Escape, or ignored special keys) // and default action is not prevented, update the display. diff --git a/web/js/data.js b/web/js/data.js index 3683e44..b51e631 100644 --- a/web/js/data.js +++ b/web/js/data.js @@ -62,6 +62,7 @@ export class TagData { // Data storage export const autoCompleteData = { + /** @type {TagData[]} */ sortedTags: [], /** @type {Map} */ @@ -265,7 +266,7 @@ export async function initializeData() { const startTime = performance.now(); autoCompleteData.isInitializing = true; - console.log("[Autocomplete-Plus] Initializing autocomplete data..."); + // console.log("[Autocomplete-Plus] Initializing autocomplete data..."); try { const response = await fetch('/autocomplete-plus/csv'); diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 53e66e5..24f6205 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -5,6 +5,8 @@ import { normalizeTagToInsert, normalizeTagToSearch, isValidTag, + findAllTagPositions, + extractTagsFromTextArea, getViewportMargin } from './utils.js'; @@ -137,14 +139,11 @@ function insertTagToTextArea(inputElement, tagToInsert) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; - // Normalize the tag to insert for consistent comparison - const normalizedTagToInsert = normalizeTagToSearch(tagToInsert); - // First check if the tag exists anywhere in the textarea and select it if found const tagPositions = findAllTagPositions(text); for (const { start, end, tag } of tagPositions) { - const normalizedExistingTag = normalizeTagToSearch(tag.trim()); - if (normalizedExistingTag === normalizedTagToInsert) { + const existingTag = tag.trim(); + if (existingTag === normalizeTagToInsert(tagToInsert)) { // Tag already exists, select it and exit inputElement.focus(); inputElement.setSelectionRange(start, end); @@ -154,9 +153,9 @@ function insertTagToTextArea(inputElement, tagToInsert) { // Find the current tag boundaries 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; + const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); + const lastSeparator = Math.max(lastComma, lastNewLine); + const startPos = lastSeparator === -1 ? 0 : lastSeparator + 1; // Find the end of the current tag let endPosComma = text.indexOf(',', startPos); @@ -181,20 +180,6 @@ function insertTagToTextArea(inputElement, tagToInsert) { if (nextTagEndPosComma === -1) nextTagEndPosComma = text.length; if (nextTagEndPosNewline === -1) nextTagEndPosNewline = text.length; - const nextTagEndPos = Math.min(nextTagEndPosComma, nextTagEndPosNewline); - - // Extract the next tag and check if it matches the tag to insert - if (nextTagStartPos < text.length && nextTagStartPos < nextTagEndPos) { - const nextTag = text.substring(nextTagStartPos, nextTagEndPos).trim(); - // Check if the next tag is the same as what we want to insert - if (nextTag && normalizeTagToSearch(nextTag) === normalizedTagToInsert) { - // The tag already exists as the next tag, so select it and exit - inputElement.focus(); - inputElement.setSelectionRange(nextTagStartPos, nextTagEndPos); - return; - } - } - // Set effective insertion position to the end of current tag let effectiveEndPos = endPos; @@ -229,50 +214,6 @@ function insertTagToTextArea(inputElement, tagToInsert) { } } -/** - * Finds all tag positions in the given text. - * Searches for tags separated by commas or newlines. - * @param {string} text The text to search in - * @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content - */ -function findAllTagPositions(text) { - const positions = []; - let startPos = 0; - - while (startPos < text.length) { - // Skip any leading whitespace, commas, or newlines - while (startPos < text.length && - (text[startPos] === ' ' || text[startPos] === ',' || text[startPos] === '\n')) { - startPos++; - } - - if (startPos >= text.length) break; - - // Find the end of this tag (next comma or newline) - let endPosComma = text.indexOf(',', startPos); - let endPosNewline = text.indexOf('\n', startPos); - - if (endPosComma === -1) endPosComma = text.length; - if (endPosNewline === -1) endPosNewline = text.length; - - const endPos = Math.min(endPosComma, endPosNewline); - const tag = text.substring(startPos, endPos); - - if (tag.trim().length > 0) { - positions.push({ - start: startPos, - end: endPos, - tag: tag - }); - } - - // Move to the next tag - startPos = endPos + 1; - } - - return positions; -} - // --- RelatedTags UI Class --- /** @@ -283,7 +224,7 @@ class RelatedTagsUI { constructor() { // Create the main container this.root = document.createElement('div'); - this.root.id = 'related-tags-container'; + this.root.id = 'related-tags-root'; // Create header row this.header = document.createElement('div'); @@ -408,9 +349,8 @@ class RelatedTagsUI { if (this.autoRefreshTimerId) { clearTimeout(this.autoRefreshTimerId); } - const self = this; this.autoRefreshTimerId = setTimeout(() => { - self.updateDisplay(textareaElement); + this.updateDisplay(textareaElement); }, 500); } } @@ -482,11 +422,11 @@ class RelatedTagsUI { return; } - const existingTagsInTextarea = this.#extractTagsFromTextArea(); + const existingTags = extractTagsFromTextArea(this.target); // Create tag rows this.relatedTags.forEach(tagData => { - const isExisting = existingTagsInTextarea.has(normalizeTagToSearch(tagData.tag)); + const isExisting = existingTags.includes(tagData.tag); const tagRow = this.#createTagElement(tagData, isExisting); this.tagsContainer.appendChild(tagRow); }); @@ -506,36 +446,36 @@ class RelatedTagsUI { 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; + // Tag name + const tagName = document.createElement('span'); + tagName.className = 'related-tag-name'; + tagName.textContent = tagData.tag; // grayout tag name if it already exists if (isExisting) { - tagNameCell.classList.add('related-tag-already-exists'); + tagName.classList.add('related-tag-already-exists'); } - // Alias cell (middle column) - const aliasCell = document.createElement('span'); - aliasCell.className = 'related-tag-alias'; + // Alias + const alias = document.createElement('span'); + alias.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 + alias.textContent = `${aliasText}`; + alias.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)}`; + // Category + const category = document.createElement('span'); + category.className = `related-tag-category`; + category.textContent = `${categoryText.substring(0, 2)}`; - // Similarity cell - const similarityCell = document.createElement('span'); - similarityCell.className = 'related-tag-similarity'; - similarityCell.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; + // Similarity + const similarity = document.createElement('span'); + similarity.className = 'related-tag-similarity'; + similarity.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}`; @@ -545,10 +485,10 @@ class RelatedTagsUI { tagRow.title = tooltipText; // Add cells to row - tagRow.appendChild(tagNameCell); - tagRow.appendChild(aliasCell); - tagRow.appendChild(categoryCell); - tagRow.appendChild(similarityCell); + tagRow.appendChild(tagName); + tagRow.appendChild(alias); + tagRow.appendChild(category); + tagRow.appendChild(similarity); return tagRow; } @@ -562,24 +502,18 @@ class RelatedTagsUI { #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.position = 'absolute'; this.root.style.display = 'block'; - this.root.style.left = '-9999px'; - this.root.style.top = '-9999px'; this.root.style.maxWidth = ''; this.tagsContainer.style.maxHeight = ''; - const elemRect = this.root.getBoundingClientRect(); + const rootRect = this.root.getBoundingClientRect(); const headerRect = this.header.getBoundingClientRect(); // Hide it again after measurement this.root.style.display = 'none'; this.root.style.visibility = 'visible'; - this.root.style.position = ''; - this.root.style.left = ''; - this.root.style.top = ''; - // Get the optimal placement area - const placementArea = this.#getOptimalPlacementArea(this.target.getBoundingClientRect(), elemRect.width, elemRect.height); + const placementArea = this.#getOptimalPlacementArea(rootRect.width, rootRect.height); // Apply Styles this.root.style.left = `${placementArea.x}px`; @@ -621,57 +555,57 @@ class RelatedTagsUI { /** * 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) { + #getOptimalPlacementArea(elemWidth, elemHeight) { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - let margin = getViewportMargin(); + const margin = getViewportMargin(); + const targetRect = this.target.getBoundingClientRect(); - // Find optimal max width baesd on viewport and input element + // Find optimal max width baesd on viewport and textarea element const maxWidth = Math.max( - Math.min(inputRect.right, viewportWidth - margin.right) - inputRect.left, + Math.min(targetRect.right, viewportWidth - margin.right) - targetRect.left, (viewportWidth - margin.left - margin.right) / 2 ); const area = { - x: Math.max(inputRect.x, margin.left), - y: Math.max(inputRect.y, margin.top), + x: Math.max(targetRect.x, margin.left), + y: Math.max(targetRect.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; + const topSpace = targetRect.top - margin.top; + const bottomSpace = viewportHeight - targetRect.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); + area.y = Math.max(targetRect.y - area.height, margin.top); } else { // Place below area.height = Math.min(area.height, bottomSpace); - area.y = inputRect.bottom; + area.y = targetRect.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; + const leftSpace = targetRect.x - margin.left; + const rightSpace = viewportWidth - targetRect.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); + area.x = Math.max(targetRect.x - area.width, margin.left); } else { // Place right area.width = Math.min(area.width, rightSpace); - area.x = inputRect.right; + area.x = targetRect.right; } // Adjust y position to avoid overflow @@ -680,22 +614,6 @@ class RelatedTagsUI { return area; } - - /** - * Extracts existing tags from the textarea. - * @returns {Set} Set of existing tags in the textarea - */ - #extractTagsFromTextArea() { - const existingTagsInTextarea = new Set(); - if (this.target && this.target.value) { - const tagPositions = findAllTagPositions(this.target.value); - tagPositions.forEach(pos => { - existingTagsInTextarea.add(normalizeTagToSearch(pos.tag)); - }); - } - return existingTagsInTextarea; - } - } // --- RelatedTags Event Handling Class --- @@ -711,7 +629,7 @@ export class RelatedTagsEventHandler { */ handleInput(event) { if (settingValues.enableRelatedTags) { - if (this.relatedTagsUI && this.relatedTagsUI.isVisible()) { + if (this.relatedTagsUI.isVisible()) { this.relatedTagsUI.hide(); } } @@ -734,16 +652,9 @@ export class RelatedTagsEventHandler { return; } - // Use setTimeout to delay hiding. This allows clicks on the related tags UI - // to be processed before the UI is hidden. + // Need a slight delay because clicking the related tags list causes blur setTimeout(() => { - // 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 = this.relatedTagsUI.root; - - // If the focus is not within the related tags UI, hide it. - if (this.relatedTagsUI && !relatedTagsElement.contains(activeElement)) { + if (!this.relatedTagsUI.root.contains(document.activeElement)) { this.relatedTagsUI.hide(); } }, 150); @@ -757,7 +668,7 @@ export class RelatedTagsEventHandler { const textareaElement = event.target; // For related tags panel, handle Escape key - if (this.relatedTagsUI && this.relatedTagsUI.isVisible()) { + if (this.relatedTagsUI.isVisible()) { switch (event.key) { case 'ArrowDown': event.preventDefault(); @@ -816,7 +727,8 @@ export class RelatedTagsEventHandler { handleClick(event) { // Check trigger mode from settings if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey) { - return; // Only respond to Ctrl+Click when in ctrlClick mode + this.relatedTagsUI.hide(); + return; // Only show if Ctrl is pressed } const textareaElement = event.target; diff --git a/web/js/utils.js b/web/js/utils.js index 86e1ab8..845e41f 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -116,7 +116,7 @@ export function normalizeTagToInsert(str) { } /** - * Checks if a tag is valid. + * Checks if a tag is valid (not wildcard or Lora notation). * @param {string} tag * @returns */ @@ -138,6 +138,64 @@ export function isValidTag(tag) { return true; } +/** + * Finds all tag positions in the given text. + * Searches for tags separated by commas or newlines. + * @param {string} text The text to search in + * @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content + */ +export function findAllTagPositions(text) { + const positions = []; + let startPos = 0; + + while (startPos < text.length) { + // Skip any leading whitespace, commas, or newlines + while (startPos < text.length && + (text[startPos] === ' ' || text[startPos] === ',' || text[startPos] === '\n')) { + startPos++; + } + + if (startPos >= text.length) break; + + // Find the end of this tag (next comma or newline) + let endPosComma = text.indexOf(',', startPos); + let endPosNewline = text.indexOf('\n', startPos); + + if (endPosComma === -1) endPosComma = text.length; + if (endPosNewline === -1) endPosNewline = text.length; + + const endPos = Math.min(endPosComma, endPosNewline); + const tag = text.substring(startPos, endPos); + + if (tag.trim().length > 0) { + positions.push({ + start: startPos, + end: endPos, + tag: tag + }); + } + + // Move to the next tag + startPos = endPos + 1; + } + + return positions; +} +/** + * Extracts existing tags from the textarea with search normalization (possibly duplicated). + * @param {HTMLTextAreaElement} textarea The textarea element to extract tags from + * @returns {string[]} Array of existing tags + */ +export function extractTagsFromTextArea(textarea) { + const existingTagsInTextarea = []; + if (textarea && textarea.value) { + const tagPositions = findAllTagPositions(textarea.value); + tagPositions.forEach(pos => { + existingTagsInTextarea.push(normalizeTagToSearch(pos.tag)); + }); + } + return existingTagsInTextarea; +} // --- End String Helper Functions --- // Function to load a CSS file