From e24e82e3dd794f50ff13f86ac1e9eb5014cdfbb6 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 23:39:35 +0900 Subject: [PATCH 1/9] fix: Track keydown events with modifiers to improve autocomplete display handling --- web/js/autocomplete.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 208598e..0199c91 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -750,6 +750,7 @@ class AutocompleteUI { export class AutocompleteEventHandler { constructor() { this.autocompleteUI = new AutocompleteUI(); + this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers } /** @@ -761,8 +762,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 +791,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 +807,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 +830,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. From 2b1baf8bfa9200c6ab20a3b2b033d155b2547d1d Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 00:20:28 +0900 Subject: [PATCH 2/9] fix: Hide related tags UI when not Ctrl+Click and not pinned --- web/js/related-tags.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 387f852..77881ca 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -738,8 +738,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; } From 785dd6dd62fb5e110e7bc6950c174ee0f76b55b8 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 00:33:05 +0900 Subject: [PATCH 3/9] fix: Update key event handling for related tags UI to support ui pinning --- web/js/related-tags.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 77881ca..6173c46 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -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); } } } From b6e37d2e0920069f0ad43b872763cdf75d0baffc Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 00:36:17 +0900 Subject: [PATCH 4/9] style: code formatting --- web/js/related-tags.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/js/related-tags.js b/web/js/related-tags.js index 6173c46..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; } From 2f8c8052335f104fbf3fc00492c876d74e60751c Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 03:32:32 +0900 Subject: [PATCH 5/9] feat: Add wildcard tag parsing and extraction functions for enhanced tag handling --- web/js/utils.js | 128 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 8 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 5207d54..50204ff 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -1,4 +1,6 @@ // --- String Helper Functions --- +const REG_WILDCARD_WEIGHTED_TAG = /(\d+)[_\s]*::(.*?)(?=\||$)/g; +const REG_WILDCARD_SIMPLE_WORD = /[^{}_|]+/g; /** * Converts Hiragana to Katakana. @@ -179,16 +181,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 +305,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 +333,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 From 2bc28a28dea4909b1a0ddd5197c8a162136fc8ac Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 03:32:51 +0900 Subject: [PATCH 6/9] refactor: Replace inline regex patterns with constants for better readability and maintainability --- web/js/utils.js | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 50204ff..885ae70 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -1,4 +1,20 @@ // --- 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") @@ -123,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); } /** @@ -414,7 +430,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; From 9b72e8472784091bd58b113941703a1325c6b441 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 20:12:08 +0900 Subject: [PATCH 7/9] fix: Update normalizeTagToInsert function to handle wildcard calls correctly (#6) --- web/js/utils.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 885ae70..0481c93 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -159,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} */ @@ -168,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); } From 26d563926f95d3e1a9073653b42d30190e011287 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 20:12:49 +0900 Subject: [PATCH 8/9] fix: Enhance matchWord function to support wildcard prefix matching for improved tag queries --- web/js/autocomplete.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 0199c91..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 }; } From 26528e65cd556b38c7d49c7ff13bcc563fb12e63 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Fri, 16 May 2025 20:13:08 +0900 Subject: [PATCH 9/9] style: code formatting --- web/js/utils.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 0481c93..056ff81 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -149,7 +149,7 @@ export function isContainsLetterOrNumber(str) { */ export function normalizeTagToSearch(str) { if (!str) return str; - + if (isContainsLetterOrNumber(str)) { return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_")); } @@ -213,18 +213,18 @@ export function isValidTag(tag) { */ 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, @@ -234,25 +234,25 @@ function extractAllWords(text, baseStart) { 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, @@ -260,7 +260,7 @@ function extractAllWords(text, baseStart) { }); } } - + return result; } @@ -276,24 +276,24 @@ function extractAllWords(text, baseStart) { 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; } @@ -306,10 +306,10 @@ function parseWildcardSelection(tag, startPos, endPos) { */ 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 @@ -332,7 +332,7 @@ export function findAllTagPositions(text) { 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