diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 208598e..50b739b 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -19,7 +19,7 @@ import { settingValues } from './settings.js'; // --- Autocomplete Logic --- /** - * Uses a set of variations to match a target string. + * Checks if a target string matches any of the query variations based on several rules. * @param {string} target - The target word to match. * @param {Set} queries - Set of query variations. * @returns {{matched: boolean, isExactMatch: boolean}} @@ -34,11 +34,19 @@ function matchWord(target, queries) { break; } } + if (!isExactMatch) { for (const variation of queries) { - if (!isContainsLetterOrNumber(variation)) { + const hasWildcardPrefix = variation.startsWith('__'); + if (hasWildcardPrefix) { + // If variation has wildcard prefix, only attempt a direct partial match. (e.g. "__wildcard__") + if (target.includes(variation)) { + matched = true; + break; + } + } else if (!isContainsLetterOrNumber(variation)) { // If the query variation contains only symbols, - // match if the target also contains only symbols and includes the variation. + // match if the target also contains only symbols and includes the variation. (e.g. "^_^", "^^^") if (!isContainsLetterOrNumber(target) && target.includes(variation)) { matched = true; break; @@ -48,15 +56,17 @@ function matchWord(target, queries) { if (target.includes(variation)) { matched = true; break; - // If direct partial match fails, try matching after removing - // common symbols from both target and variation. - } else if (target.replace(/[-_\s']/g, '').includes(variation.replace(/[-_\s']/g, ''))) { + } + // If direct partial match fails, try matching after removing + // common symbols from both target and variation. + else if (target.replace(/[-_\s']/g, '').includes(variation.replace(/[-_\s']/g, ''))) { matched = true; break; } } } } + return { matched, isExactMatch }; } @@ -750,6 +760,7 @@ class AutocompleteUI { export class AutocompleteEventHandler { constructor() { this.autocompleteUI = new AutocompleteUI(); + this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers } /** @@ -761,8 +772,7 @@ export class AutocompleteEventHandler { if (!settingValues.enabled) return; if (!event.isTrusted) return; // ignore synthetic events - const textareaElement = event.target; - const partialTag = getCurrentPartialTag(textareaElement); + const partialTag = getCurrentPartialTag(event.target); if (partialTag.length <= 0) { this.autocompleteUI.hide(); } @@ -791,7 +801,8 @@ export class AutocompleteEventHandler { handleKeyDown(event) { if (!settingValues.enabled) return; - const textareaElement = event.target; + // Save modifier key (without shiftKey) state when a key is pressed + this.keyDownWithModifier.set(event.key.toLowerCase(), event.ctrlKey || event.altKey || event.metaKey); // Handle autocomplete navigation if (this.autocompleteUI && this.autocompleteUI.isVisible()) { @@ -806,9 +817,10 @@ export class AutocompleteEventHandler { break; case 'Enter': case 'Tab': - if (this.autocompleteUI.getSelectedTag() !== null) { + const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; + if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) { event.preventDefault(); - insertTagToTextArea(textareaElement, this.autocompleteUI.getSelectedTag()); + insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag()); } this.autocompleteUI.hide(); break; @@ -828,6 +840,14 @@ export class AutocompleteEventHandler { handleKeyUp(event) { if (!settingValues.enabled) return; + const key = event.key.toLowerCase(); + + // Check if the key was pressed with a modifier + if (this.keyDownWithModifier.get(key)) { + this.keyDownWithModifier.delete(key); // Remove the pressed key from the map + return; + } + // Do not process keyup events if Ctrl, Alt, or Meta keys are pressed. // This prevents autocomplete from appearing for shortcuts like Ctrl+C, Ctrl+Z, etc. // It also handles the release of a modifier key itself if it wasn't part of a character-producing combination. diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 387f852..3fa0525 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -46,13 +46,13 @@ function calculateJaccardSimilarity(tagA, tagB) { export function getTagFromCursorPosition(inputElement) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; - + // Use getCurrentTagRange to get the tag at the cursor position const tagRange = getCurrentTagRange(text, cursorPos); - + // If no tag was found at the cursor position if (!tagRange) return null; - + // Return the normalized tag for searching return normalizeTagToSearch(tagRange.tag); } @@ -98,12 +98,12 @@ function searchRelatedTags(tag) { // Limit to max number of suggestions const result = relatedTags.slice(0, settingValues.maxRelatedTags); - if(settingValues._logprocessingTime) { + if (settingValues._logprocessingTime) { const endTime = performance.now(); const duration = endTime - startTime; console.debug(`[Autocomplete-Plus] Find tags to related "${tag}" took ${duration.toFixed(2)}ms.`); } - + return result; } @@ -677,9 +677,16 @@ export class RelatedTagsEventHandler { * @param {KeyboardEvent} event */ handleKeyDown(event) { - const textareaElement = event.target; + // If related tags UI is pinned, don't handle key events except for Escape + if (this.relatedTagsUI.isPinned) { + if (event.key === 'Escape') { + event.preventDefault(); + this.relatedTagsUI.hide(); + } + return; + } - // For related tags panel, handle Escape key + // Handle key events for related tags UI if (this.relatedTagsUI.isVisible()) { switch (event.key) { case 'ArrowDown': @@ -706,11 +713,11 @@ export class RelatedTagsEventHandler { } } - // Show related tags on Ctrl+Space + // Show related tags on Ctrl+Shift+Space if (settingValues.enableRelatedTags) { if (event.key === ' ' && event.ctrlKey && event.shiftKey) { event.preventDefault(); - this.relatedTagsUI.show(textareaElement); + this.relatedTagsUI.show(event.target); } } } @@ -738,8 +745,8 @@ export class RelatedTagsEventHandler { * @returns */ handleClick(event) { - // Check trigger mode from settings - if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey) { + // Hide related tags UI if not Ctrl+Click and not pinned when trigger mode is 'ctrl+Click' + if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey && !this.relatedTagsUI.isPinned) { this.relatedTagsUI.hide(); return; } diff --git a/web/js/utils.js b/web/js/utils.js index 5207d54..056ff81 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -1,5 +1,23 @@ // --- String Helper Functions --- +const MAX_PROMPT_WEIGHT_VALUE = 9.9; + +// Regex constants +const REG_ESCAPE_OPEN_PAREN = /(? { // If the number after colon is between 0-9.9, it's likely a weight - if (parseFloat(p2) <= 9.9) { + if (parseFloat(p2) <= MAX_PROMPT_WEIGHT_VALUE) { return p1; } // Otherwise preserve the entire string (like "year:2000") @@ -121,7 +139,7 @@ export function removePromptWeight(str) { export function isContainsLetterOrNumber(str) { if (!str) return false; // Check if the string contains at least one letter or number (Latin, Japanese, Korean, CJK Extension A, Cyrillic, Hebrew) - return /[a-zA-Z0-9\u3040-\u30ff\u3400-\u4DBF\u4e00-\u9faf\uac00-\ud7af\u0400-\u04FF\u0590-\u05FF]/.test(str); + return REG_CONTAINS_LETTER_NUMBER.test(str); } /** @@ -131,7 +149,7 @@ export function isContainsLetterOrNumber(str) { */ export function normalizeTagToSearch(str) { if (!str) return str; - + if (isContainsLetterOrNumber(str)) { return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_")); } @@ -141,8 +159,9 @@ export function normalizeTagToSearch(str) { /** * Normalizes a tag string for input. - * Converts underscores to spaces only if the tag contains at least one letter or number. - * Keeps underscores for tags that are only symbols (e.g. "^_^"). + * Converts underscores to spaces only if the tag contains at least one letter or number, + * and is not a wildcard call (e.g., "__wildcard__"). + * Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls. * @param {string} str * @returns {string} */ @@ -150,9 +169,15 @@ export function normalizeTagToInsert(str) { if (!str) return str; if (isContainsLetterOrNumber(str)) { - return escapeParentheses(str.replace(/_/g, " ")); + const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4; + + if (!isWildcardCall) { + // If doesn't wildcard call, replace underscores with spaces + return escapeParentheses(str.replace(/_/g, " ")); + } } - // Otherwise, keep as is (for emoji/face tags) + + // Otherwise, keep it as is (e.g., ""^_^", "__wildcard__") return escapeParentheses(str); } @@ -179,16 +204,113 @@ export function isValidTag(tag) { return true; } +/** + * Recursively finds all words in a string, even those inside nested braces. + * This helps extract all possible tags from complex nested wildcards. + * @param {string} text The text to extract words from + * @param {number} baseStart The starting position of the text in the original string + * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags + */ +function extractAllWords(text, baseStart) { + const result = []; + + // First, extract weighted tag patterns like "20::from above" + let weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); + while (weightMatch !== null) { + const tagText = weightMatch[2].trim(); + + if (tagText) { + // Calculate position with original offsets + const fullMatchStart = baseStart + weightMatch.index; + const tagTextStart = fullMatchStart + weightMatch[0].indexOf(tagText); + const tagTextEnd = tagTextStart + tagText.length; + + result.push({ + start: tagTextStart, + end: tagTextEnd, + tag: tagText + }); + } + + weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); + } + + // If no weighted tags were found, extract simple words + if (result.length === 0) { + // Regular expression to match words (sequences of non-whitespace characters) + // We consider a word to be any continuous sequence of characters that's not a space, pipe, or brace + const wordRegex = REG_WILDCARD_SIMPLE_WORD; + let match; + + // Find all standalone words in the text + while ((match = wordRegex.exec(text)) !== null) { + const wordStart = baseStart + match.index; + const wordEnd = wordStart + match[0].length; + + // Remove leading and trailing spaces from the matched word + const trimmedTag = match[0].trim(); + const leadingSpaces = match[0].length - match[0].trimStart().length; + const adjustedStart = wordStart + leadingSpaces; + const adjustedEnd = wordStart + leadingSpaces + trimmedTag.length; + + result.push({ + start: adjustedStart, + end: adjustedEnd, + tag: trimmedTag + }); + } + } + + return result; +} + +/** + * Parses a wildcard selection and returns individual tags. + * Supports syntax like {tag1|tag2|tag3} and {weight::tag1|weight::tag2}. + * Also handles nested wildcards like {tag1 {tag2|tag3}|tag4}. + * @param {string} tag The complete tag text that might contain a wildcard + * @param {number} startPos The starting position of the tag in the original text + * @param {number} endPos The ending position of the tag in the original text + * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags or null + */ +function parseWildcardSelection(tag, startPos, endPos) { + // Trim the tag for matching but keep original position + const trimmedTag = tag.trim(); + + // Check if this is a wildcard selection + if (!trimmedTag.startsWith('{') || !trimmedTag.endsWith('}')) { + return null; // Not a wildcard + } + + // Calculate position offsets for the trim operation + const leadingSpaces = tag.length - tag.trimStart().length; + const tagStart = startPos + leadingSpaces; + + // For nested wildcards, we'll extract all words from the content + // This treats each word as a separate tag, regardless of nesting + // Extract the content between the outermost braces + const wildcardContent = trimmedTag.substring(1, trimmedTag.length - 1); + + // Extract all words from the wildcard content, including those in nested structures + const allTags = extractAllWords(wildcardContent, tagStart + 1); + + return allTags.length > 0 ? allTags : null; +} + /** * Finds all tag positions in the given text. * Searches for tags separated by commas or newlines. + * Also handles wildcard selections in the format {tag1|tag2|tag3}. * @param {string} text The text to search in * @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content */ export function findAllTagPositions(text) { + if (!text) return []; + const positions = []; let startPos = 0; + // Process text segment by segment (comma or newline separated) while (startPos < text.length) { // Skip any leading whitespace, commas, or newlines while (startPos < text.length && @@ -206,14 +328,26 @@ export function findAllTagPositions(text) { if (endPosNewline === -1) endPosNewline = text.length; const endPos = Math.min(endPosComma, endPosNewline); - const tag = text.substring(startPos, endPos); + const tagText = text.substring(startPos, endPos); - if (tag.trim().length > 0) { - positions.push({ - start: startPos, - end: endPos, - tag: tag - }); + if (tagText.trim().length > 0) { + const trimmedTag = tagText.trim(); + + // Check if this is a wildcard selection + if (trimmedTag.startsWith('{') && trimmedTag.endsWith('}')) { + // Process wildcard using our existing wildcard parser + const wildcardTags = parseWildcardSelection(tagText, startPos, endPos); + if (wildcardTags) { + positions.push(...wildcardTags); + } + } else { + // Normal tag, add it directly + positions.push({ + start: startPos, + end: endPos, + tag: tagText + }); + } } // Move to the next tag @@ -222,6 +356,7 @@ export function findAllTagPositions(text) { return positions; } + /** * Extracts existing tags from the textarea with search normalization (possibly duplicated). * @param {HTMLTextAreaElement} textarea The textarea element to extract tags from @@ -302,7 +437,7 @@ export function getCurrentTagRange(text, cursorPos) { changedInParenStep = false; // Remove leading non-escaped parenthesis - const leadParenMatch = adjustedTag.match(/^(? "standing", "foo:bar" -> "foo:bar", "year:2000" -> "year:2000") // This applies to the tag *after* parentheses are handled. - const weightRegex = /(.*?):([0-9](\.\d+)?)$/; - const weightMatch = adjustedTag.match(weightRegex); + const weightMatch = adjustedTag.match(REG_PROMPT_WEIGHT); if (weightMatch) { const tagPart = weightMatch[1]; const weightValue = weightMatch[2]; // Only consider it as a weight if it's a simple number between 0-9 possibly with decimal // Don't treat larger numbers like :1999 or :2000 as weights - if (parseFloat(weightValue) <= 9.9) { + if (parseFloat(weightValue) <= MAX_PROMPT_WEIGHT_VALUE) { const fullWeightString = adjustedTag.substring(tagPart.length); if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) { adjustedEnd -= fullWeightString.length;