- refactor: Refactoring for code readability
- Support keyboard navigation for related tags
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user