- refactor: Refactoring for code readability

- Support keyboard navigation for related tags
This commit is contained in:
newtextdoc1111
2025-05-09 00:00:35 +09:00
parent 5d0d51f2c3
commit 9753a27dbc
6 changed files with 891 additions and 809 deletions
+8
View File
@@ -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;
+384 -400
View File
@@ -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;
}
}
+58 -12
View File
@@ -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<string, TagData>} */
tagMap: new Map(), // Stores tag data, mapping tag names to TagData objects
sortedTags: [],
/** @type {Map<string, TagData>} */
aliasMap: new Map(), // Maps aliases to their main tag names
/** @type {Map<string, Map<string, number>>} */
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
}
});
}
+1 -1
View File
@@ -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",
+440 -356
View File
@@ -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);
}
}
-40
View File
@@ -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 ---