From 8d7d60a8b1165a016eccc1526f8a4666f3985522 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Tue, 13 May 2025 17:07:40 +0900 Subject: [PATCH 01/12] fix: Update tag display behavior in README files to match actual behaviour --- README.md | 3 ++- docs/README_jp.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7328ecc..02280c9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ When you type in a text input area, tags that partially match the text are displ - Tag aliases are also included in the search. Japanese hiragana and katakana are searched without distinction. - Tags are color-coded by category. The color-coding rules are the same as Danbooru. -- Existing tags are displayed in gray. If a grayed-out tag is confirmed, the existing tag will be selected instead. +- Tags that have already been entered are displayed grayed out. ## Related Tags @@ -48,6 +48,7 @@ When you select any tag in a text input area, highly related tags list are displ - The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space. - You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header. - You can toggle the pinned state of the displayed related tags using the "📌|🎯" button in the header. To close the UI when pinned, press the Esc key. +- Tags that have already been entered are displayed grayed out. If you try to insert a grayed-out tag, the already entered tag will instead be selected. - You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`. ## CSV Data diff --git a/docs/README_jp.md b/docs/README_jp.md index 3769991..a903e37 100644 --- a/docs/README_jp.md +++ b/docs/README_jp.md @@ -35,7 +35,7 @@ - タグのエイリアスも検索対象に含まれます。日本語のひらがな、カタカナは区別せず検索されます - タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです -- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを確定した場合、代わりに入力済みのタグを選択状態にします +- 入力済みのタグはグレーアウトで表示されます ## 関連タグ @@ -46,6 +46,7 @@ - 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます - ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます - ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します +- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします - `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます ## CSV データ From 637640375784a0521f6740b7ce38682044f1dfba Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Tue, 13 May 2025 19:34:48 +0900 Subject: [PATCH 02/12] fix: Enhance tag matching logic mainly special characters --- web/js/autocomplete.js | 116 +++++++++++++++++++++-------------------- web/js/utils.js | 40 +++++++++++--- 2 files changed, 92 insertions(+), 64 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 7c6e3e1..9609348 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -7,6 +7,7 @@ import { formatCountHumanReadable, hiraToKata, kataToHira, + isContainsLetterOrNumber, normalizeTagToInsert, normalizeTagToSearch, findAllTagPositions, @@ -17,6 +18,48 @@ import { settingValues } from './settings.js'; // --- Autocomplete Logic --- +/** + * Uses a set of variations to match a target string. + * @param {string} target - The target word to match. + * @param {Set} queries - Set of query variations. + * @returns {{matched: boolean, isExactMatch: boolean}} + */ +function matchWord(target, queries) { + let matched = false; + let isExactMatch = false; + for (const variation of queries) { + if (target === variation) { + isExactMatch = true; + matched = true; + break; + } + } + if (!isExactMatch) { + for (const variation of queries) { + if (!isContainsLetterOrNumber(variation)) { + // If the query variation contains only symbols, + // match if the target also contains only symbols and includes the variation. + if (!isContainsLetterOrNumber(target) && target.includes(variation)) { + matched = true; + break; + } + } else { + // If the query variation contains letters or numbers, attempt a partial match. + 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, ''))) { + matched = true; + break; + } + } + } + } + return { matched, isExactMatch }; +} + /** * Search tag completion candidates based on the current input and cursor position in the textarea. * @param {HTMLTextAreaElement} textareaElement The partial tag input. @@ -52,73 +95,32 @@ function searchCompletionCandidates(textareaElement) { let isExactMatch = false; let matchedAlias = null; - // Check primary tag against all variations for exact match first - for (const variation of queryVariations) { - if (tagData.tag === 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 (tagData.tag.includes(variation)) { - matched = true; - break; - } else if (tagData.tag.replace(/[\-_\s']/g, '').includes(variation.replace(/[\-_\s']/g, ''))) { - // Try to match with underscore, dash, or apostrophe removed - matched = true; - break; - } - } - } + // Check primary tag against all variations for exact/partial match + const tagMatch = matchWord(tagData.tag, queryVariations); + matched = tagMatch.matched; + isExactMatch = tagMatch.isExactMatch; // 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; - } + const aliasMatch = matchWord(lowerAlias, queryVariations); + if (aliasMatch.matched) { + matched = true; + isExactMatch = aliasMatch.isExactMatch; + 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); + exactMatches.push(tagData); } else { - partialMatches.push(candidateItem); + partialMatches.push(tagData); } addedTags.add(tagData.tag); @@ -194,7 +196,7 @@ function insertTagToTextArea(inputElement, tagToInsert) { const currentWordEndMatch = text.substring(cursorPos).match(/^[^,\n]+/); let currentWordEnd = cursorPos; - + const normalizedTag = normalizeTagToInsert(tagToInsert); // If the end match is found, set currentWordEnd to the end of the match @@ -431,7 +433,7 @@ class AutocompleteUI { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const margin = getViewportMargin(); - + const targetRect = this.target.getBoundingClientRect(); const targetElmOffset = this.#calculateElementOffset(this.target); @@ -525,7 +527,7 @@ class AutocompleteUI { this.hide(); } - + /** * Gets the pixel coordinates of the caret in the input element. * Uses a temporary div to calculate the position accurately. @@ -723,7 +725,7 @@ class AutocompleteUI { if (defaultView == null) { throw new Error("Given element does not belong to window"); } - + const offset = { top: rect.top + defaultView.pageYOffset, left: rect.left + defaultView.pageXOffset, diff --git a/web/js/utils.js b/web/js/utils.js index 845e41f..2ca0f21 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -97,13 +97,16 @@ export function removePromptWeight(str) { } /** - * Normalizes a tag string for input. - * @param {string} str - * @returns + * Checks if a string contains at least one letter or number. + * This includes Latin letters, Japanese characters, Korean characters, + * CJK Extension A, Cyrillic letters, and Hebrew letters. + * @param {string} str The input string. + * @returns {boolean} True if the string contains at least one letter or number, false otherwise. */ -export function normalizeTagToSearch(str) { - if (!str) return str; - return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_")); +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); } /** @@ -111,8 +114,31 @@ export function normalizeTagToSearch(str) { * @param {string} str * @returns */ +export function normalizeTagToSearch(str) { + if (!str) return str; + + if (isContainsLetterOrNumber(str)) { + return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_")); + } + + return unescapeParentheses(removePromptWeight(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. "^_^"). + * @param {string} str + * @returns {string} + */ export function normalizeTagToInsert(str) { - return escapeParentheses(str.replace(/_/g, " ")); + if (!str) return str; + + if (isContainsLetterOrNumber(str)) { + return escapeParentheses(str.replace(/_/g, " ")); + } + // Otherwise, keep as is (for emoji/face tags) + return escapeParentheses(str); } /** From cd4901d0a5e91c702843c7a4fcd6c48505c5a87d Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Tue, 13 May 2025 19:35:16 +0900 Subject: [PATCH 03/12] fix: Remove unused methods from TagData class --- web/js/data.js | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/web/js/data.js b/web/js/data.js index c868a71..3e337e4 100644 --- a/web/js/data.js +++ b/web/js/data.js @@ -36,28 +36,6 @@ export class TagData { /** @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 @@ -67,6 +45,7 @@ export const autoCompleteData = { /** @type {Map} */ tagMap: new Map(), // Stores tag data, mapping tag names to TagData objects + /** @type {Map} */ aliasMap: new Map(), // Maps aliases to their main tag names From f4c276d782292e336be3a123c6612265aba20f88 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Wed, 14 May 2025 19:32:38 +0900 Subject: [PATCH 04/12] fix: Prevent tag insertion when cursor is inside a prompt strength modifier --- web/js/autocomplete.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 9609348..96be8be 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -171,7 +171,16 @@ function getCurrentPartialTag(inputElement) { 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 + // Check if the cursor is inside a prompt strength/weight modifier (e.g., :1.2, :.5, :1.) + const segmentBeforeCursor = text.substring(start, cursorPos); + const lastColon = segmentBeforeCursor.lastIndexOf(':'); + if (lastColon !== -1) { + const partAfterColon = segmentBeforeCursor.substring(lastColon + 1); + if (partAfterColon.length > 0 && /^[0-9\.]+$/.test(partAfterColon) && /[0-9]/.test(partAfterColon)) { + return ""; + } + } + const partial = text.substring(start, cursorPos).trimStart(); return normalizeTagToSearch(partial); } From 475ed85466737b9ff4a0b6ba8c0385177685c76f Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Wed, 14 May 2025 22:18:54 +0900 Subject: [PATCH 05/12] feat: Add function to get current tag range with handling for weights and parentheses --- web/js/utils.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/web/js/utils.js b/web/js/utils.js index 2ca0f21..79e376f 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -222,6 +222,117 @@ export function extractTagsFromTextArea(textarea) { } return existingTagsInTextarea; } + +/** + * Gets the start and end indices of the tag at the current cursor position, + * applying specific rules for prompt weights and parentheses. + * @param {string} text The entire text content. + * @param {number} cursorPos The current cursor position in the text. + * @returns {{start: number, end: number, tag: string} | null} An object with start, end, and tag string, or null if no tag is found. + */ +export function getCurrentTagRange(text, cursorPos) { + if (text === null || text === undefined || cursorPos < 0 || cursorPos > text.length) { + return null; + } + + const allTags = findAllTagPositions(text); + let currentTagPos = null; + + for (const pos of allTags) { + // Find the tag whose range [start, end] (inclusive start, exclusive end for substring) + if (cursorPos >= pos.start && cursorPos <= pos.end) { + currentTagPos = { ...pos }; // Clone the position object + // If cursor is strictly within [pos.start, pos.end), this is a strong candidate. + if (cursorPos < pos.end) { + break; + } + // If cursorPos === pos.end, continue searching to see if a subsequent tag starts exactly here. + // If no subsequent tag starts at cursorPos, this currentTagPos (where cursor is at its end) will be used. + } else if (currentTagPos && cursorPos < pos.start) { + // If we had a candidate where cursorPos === pos.end, + // but now we've passed cursorPos, that candidate was the correct one. + break; + } + } + + if (!currentTagPos) { + return null; + } + + let { tag, start, end } = currentTagPos; + + // Rule 1: If the tag consists only of symbols, return it as is. + // (e.g., ";)", ">:)") + if (!isContainsLetterOrNumber(tag)) { + if (start < end) { // Ensure it's a valid range + return { start, end, tag }; + } + return null; + } + + // For tags containing letters/numbers, apply rules for parentheses and weights. + let adjustedTag = tag; + let adjustedStart = start; + let adjustedEnd = end; + + // Rule 2: Exclude non-escaped parentheses surrounding the tag. + // (e.g., "(black hair:1.0)" -> "black hair:1.0", "foo \(bar\)" -> "foo \(bar\)") + // Apply iteratively for cases like "((tag))" if necessary, though typically one layer. + + let changedInParenStep; + do { + changedInParenStep = false; + + // Remove leading non-escaped parenthesis + const leadParenMatch = adjustedTag.match(/^(?= adjustedEnd) break; + + } while (changedInParenStep && adjustedTag.length > 0); + + + if (adjustedStart >= adjustedEnd) { + return null; // Tag became empty after parenthesis removal + } + + // Rule 3: Exclude prompt strength syntax (e.g., ":1.0") but include colons in names. + // (e.g., "standing:1.0" -> "standing", "foo:bar" -> "foo:bar") + // This applies to the tag *after* parentheses are handled. + const weightRegex = /(.*?):(\d+(\.\d+)?)$/; + const weightMatch = adjustedTag.match(weightRegex); + + if (weightMatch) { + const tagPart = weightMatch[1]; + const fullWeightString = adjustedTag.substring(tagPart.length); + + if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) { + adjustedEnd -= fullWeightString.length; + adjustedTag = tagPart; + } + } + + if (adjustedStart >= adjustedEnd || adjustedTag.length === 0) { + return null; // Tag became empty after all processing + } + + return { start: adjustedStart, end: adjustedEnd, tag: adjustedTag }; +} + // --- End String Helper Functions --- // Function to load a CSS file From d87118e3bd6e8411d236526adfe3de396ef87847 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Wed, 14 May 2025 22:19:04 +0900 Subject: [PATCH 06/12] fix: Refactor tag insertion logic to utilize getCurrentTagRange for improved accuracy --- web/js/autocomplete.js | 37 ++++++++++++------------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 96be8be..d18bc05 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -10,8 +10,8 @@ import { isContainsLetterOrNumber, normalizeTagToInsert, normalizeTagToSearch, - findAllTagPositions, extractTagsFromTextArea, + getCurrentTagRange, getViewportMargin } from './utils.js'; import { settingValues } from './settings.js'; @@ -48,8 +48,8 @@ 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. + // 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; @@ -180,7 +180,7 @@ function getCurrentPartialTag(inputElement) { return ""; } } - + const partial = text.substring(start, cursorPos).trimStart(); return normalizeTagToSearch(partial); } @@ -195,36 +195,23 @@ function insertTagToTextArea(inputElement, tagToInsert) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; - // Find the current tag boundaries - const lastComma = text.lastIndexOf(',', cursorPos - 1); - const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); - const lastSeparator = Math.max(lastComma, lastNewLine); - const startPos = lastSeparator === -1 ? 0 : lastSeparator + 1; - - const currentWordStart = text.substring(startPos, cursorPos).search(/\S|$/) + startPos; - const currentWordEndMatch = text.substring(cursorPos).match(/^[^,\n]+/); - - let currentWordEnd = cursorPos; + const {start: tagStart, end: tagEnd, tag:currentTag} = getCurrentTagRange(text, cursorPos); + const replaceStart = Math.min(cursorPos, tagStart); + let replaceEnd = cursorPos; const normalizedTag = normalizeTagToInsert(tagToInsert); - // If the end match is found, set currentWordEnd to the end of the match - if (currentWordEndMatch && normalizedTag.lastIndexOf(currentWordEndMatch[0]) !== -1) { - currentWordEnd = cursorPos + currentWordEndMatch[0].length; + const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd(); + if(normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1){ + replaceEnd = cursorPos + currentTagAfterCursor.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 needsSpaceBefore = text[replaceStart - 1] === ','; const prefix = needsSpaceBefore ? ' ' : ''; // Standard separator (comma + space) - const needsSuffixAfter = text[replaceEnd] !== ',' + const needsSuffixAfter = !",:".includes(text[replaceEnd]); // TODO: If ":" is part of the emoticon, a suffix is ​​required (e.g. ":o") const suffix = needsSuffixAfter ? ', ' : ''; const textToInsertWithAffixes = prefix + normalizedTag + suffix; From bc4f34a99af922f5c720a35c8205805e1fe8d206 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 00:10:19 +0900 Subject: [PATCH 07/12] fix: Refine weight extraction logic in getCurrentTagRange to handle simple numbers correctly --- web/js/utils.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 79e376f..8dc0e79 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -311,18 +311,22 @@ export function getCurrentTagRange(text, cursorPos) { } // Rule 3: Exclude prompt strength syntax (e.g., ":1.0") but include colons in names. - // (e.g., "standing:1.0" -> "standing", "foo:bar" -> "foo:bar") + // (e.g., "standing:1.0" -> "standing", "foo:bar" -> "foo:bar", "year:2000" -> "year:2000") // This applies to the tag *after* parentheses are handled. - const weightRegex = /(.*?):(\d+(\.\d+)?)$/; + const weightRegex = /(.*?):([0-9](\.\d+)?)$/; const weightMatch = adjustedTag.match(weightRegex); if (weightMatch) { const tagPart = weightMatch[1]; - const fullWeightString = adjustedTag.substring(tagPart.length); - - if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) { - adjustedEnd -= fullWeightString.length; - adjustedTag = tagPart; + 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) { + const fullWeightString = adjustedTag.substring(tagPart.length); + if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) { + adjustedEnd -= fullWeightString.length; + adjustedTag = tagPart; + } } } From 29cb0dab7e9f2aaaa4c9ad4e10ac3ea1de12f84e Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 03:48:19 +0900 Subject: [PATCH 08/12] fix: Enhance removePromptWeight function to better handle weights and preserve symbol-only tags --- web/js/utils.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 8dc0e79..919295e 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -80,16 +80,31 @@ export function unescapeParentheses(str) { /** * Removes prompt weights from a tag (e.g., "tag:1.2" becomes "tag"). + * Preserves tags with colons like "year:2000" or "foo:bar". + * Preserves symbol-only tags like ";)" or "^_^". * @param {string} str The input tag string. * @returns {string} The tag without weight and without surrounding non-escaped brackets. */ export function removePromptWeight(str) { if (!str) return str; - // First remove weight notation (e.g., ":1.2") - let result = str.replace(/(.+?):\d+(\.\d+)?/, '$1'); + // For symbol-only tags (no letters/numbers), return as-is + if (!isContainsLetterOrNumber(str)) { + return str; + } - // Then remove non-escaped brackets at the beginning and/or end + // Only remove weight notation for patterns that look like actual weights + // (e.g., ":1.2" where the number is between 0-9.9) + let result = str.replace(/(.+?):([0-9](\.\d+)?)$/, (match, p1, p2) => { + // If the number after colon is between 0-9.9, it's likely a weight + if (parseFloat(p2) <= 9.9) { + return p1; + } + // Otherwise preserve the entire string (like "year:2000") + return match; + }); + + // Only remove non-escaped brackets if the string contains letters or numbers // Use negative lookbehind (? Date: Thu, 15 May 2025 03:48:32 +0900 Subject: [PATCH 09/12] fix: Improve cursor position validation in getCurrentTagRange function --- web/js/utils.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/js/utils.js b/web/js/utils.js index 919295e..5207d54 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -246,24 +246,27 @@ export function extractTagsFromTextArea(textarea) { * @returns {{start: number, end: number, tag: string} | null} An object with start, end, and tag string, or null if no tag is found. */ export function getCurrentTagRange(text, cursorPos) { - if (text === null || text === undefined || cursorPos < 0 || cursorPos > text.length) { + if (!text || typeof text !== 'string') { return null; } + // Clamp cursorPos to valid range + const clampedCursorPos = Math.min(Math.max(cursorPos, 0), text.length); + const allTags = findAllTagPositions(text); let currentTagPos = null; for (const pos of allTags) { // Find the tag whose range [start, end] (inclusive start, exclusive end for substring) - if (cursorPos >= pos.start && cursorPos <= pos.end) { + if (clampedCursorPos >= pos.start && clampedCursorPos <= pos.end) { currentTagPos = { ...pos }; // Clone the position object // If cursor is strictly within [pos.start, pos.end), this is a strong candidate. - if (cursorPos < pos.end) { + if (clampedCursorPos < pos.end) { break; } - // If cursorPos === pos.end, continue searching to see if a subsequent tag starts exactly here. - // If no subsequent tag starts at cursorPos, this currentTagPos (where cursor is at its end) will be used. - } else if (currentTagPos && cursorPos < pos.start) { + // If clampedCursorPos === pos.end, continue searching to see if a subsequent tag starts exactly here. + // If no subsequent tag starts at clampedCursorPos, this currentTagPos (where cursor is at its end) will be used. + } else if (currentTagPos && clampedCursorPos < pos.start) { // If we had a candidate where cursorPos === pos.end, // but now we've passed cursorPos, that candidate was the correct one. break; From a36805725fff4b0cb59a8502e52a41c93ad0482d Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 03:48:55 +0900 Subject: [PATCH 10/12] fix: Refactor getCurrentPartialTag and getTagFromCursorPosition to utilize getCurrentTagRange for improved tag extraction --- web/js/autocomplete.js | 38 +++++++++++++++---------------- web/js/related-tags.js | 51 +++++++++++++----------------------------- 2 files changed, 34 insertions(+), 55 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index d18bc05..9df2e26 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -155,33 +155,24 @@ function searchCompletionCandidates(textareaElement) { /** * Extracts the current tag being typed before the cursor. - * Assumes tags are separated by commas. * @param {HTMLTextAreaElement} inputElement * @returns {string} The current partial tag. */ function getCurrentPartialTag(inputElement) { const text = inputElement.value; 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; - - // Check if the cursor is inside a prompt strength/weight modifier (e.g., :1.2, :.5, :1.) - const segmentBeforeCursor = text.substring(start, cursorPos); - const lastColon = segmentBeforeCursor.lastIndexOf(':'); - if (lastColon !== -1) { - const partAfterColon = segmentBeforeCursor.substring(lastColon + 1); - if (partAfterColon.length > 0 && /^[0-9\.]+$/.test(partAfterColon) && /[0-9]/.test(partAfterColon)) { - return ""; - } + + // Get the tag range at the cursor position + const tagRange = getCurrentTagRange(text, cursorPos); + + // If no tag is found or the cursor is at the start of the tag + if (!tagRange) { + return ""; } - - const partial = text.substring(start, cursorPos).trimStart(); + + // Extract the part of the tag up to the cursor position + const partial = text.substring(tagRange.start, cursorPos).trimStart(); + return normalizeTagToSearch(partial); } @@ -865,3 +856,10 @@ export class AutocompleteEventHandler { handleClick(event) { } } + +// Public test hooks - export non-exported functions for testing only +export const __test_hooks__ = { + getCurrentPartialTag, + searchCompletionCandidates, + // Add other internal functions you want to test +}; \ No newline at end of file diff --git a/web/js/related-tags.js b/web/js/related-tags.js index c51e6f5..387f852 100644 --- a/web/js/related-tags.js +++ b/web/js/related-tags.js @@ -1,12 +1,13 @@ -import { settingValues } from './settings.js'; import { TagCategory, TagData, autoCompleteData } from './data.js'; +import { settingValues } from './settings.js'; import { + extractTagsFromTextArea, + findAllTagPositions, + getViewportMargin, + isValidTag, normalizeTagToInsert, normalizeTagToSearch, - isValidTag, - findAllTagPositions, - extractTagsFromTextArea, - getViewportMargin + getCurrentTagRange } from './utils.js'; // --- RelatedTags Logic --- @@ -38,42 +39,22 @@ function calculateJaccardSimilarity(tagA, tagB) { /** * Extracts the tag at the current cursor position. - * Handles tags separated by commas or newlines. + * Utilizes getCurrentTagRange to properly handle tags with weights and parentheses. * @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; - - return normalizeTagToSearch(tag); + + // 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); } /** From c5ceca3645b4c2079654c8a29050ff47073e2ac8 Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 03:58:10 +0900 Subject: [PATCH 11/12] fix: Remove test code --- web/js/autocomplete.js | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 9df2e26..3e83b71 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -855,11 +855,4 @@ export class AutocompleteEventHandler { */ handleClick(event) { } -} - -// Public test hooks - export non-exported functions for testing only -export const __test_hooks__ = { - getCurrentPartialTag, - searchCompletionCandidates, - // Add other internal functions you want to test -}; \ No newline at end of file +} \ No newline at end of file From 17467fb967289104b2a5900595ee36612c1df90b Mon Sep 17 00:00:00 2001 From: newtextdoc1111 Date: Thu, 15 May 2025 16:03:54 +0900 Subject: [PATCH 12/12] fix: Enhance getCurrentPartialTag to handle prompt weight modifiers and improve tag range validation --- web/js/autocomplete.js | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 3e83b71..208598e 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -161,18 +161,39 @@ function searchCompletionCandidates(textareaElement) { 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; + + // Check if the cursor is inside a prompt weight modifier (e.g., :1.2, :.5, :1.) + const segmentBeforeCursor = text.substring(start, cursorPos); + const lastColon = segmentBeforeCursor.lastIndexOf(':'); + if (lastColon !== -1) { + const partAfterColon = segmentBeforeCursor.substring(lastColon + 1); + const weight = parseFloat(partAfterColon); + + // If weight is a valid number and less than 10, return empty string + if (weight !== NaN && weight <= 9.9) { + return ""; + } + } + // Get the tag range at the cursor position const tagRange = getCurrentTagRange(text, cursorPos); - - // If no tag is found or the cursor is at the start of the tag - if (!tagRange) { + + // If no tag is found or the cursor is before the start of the tag, return empty string + if (!tagRange || cursorPos <= tagRange.start) { return ""; } - + // Extract the part of the tag up to the cursor position const partial = text.substring(tagRange.start, cursorPos).trimStart(); - + return normalizeTagToSearch(partial); } @@ -186,15 +207,15 @@ function insertTagToTextArea(inputElement, tagToInsert) { const text = inputElement.value; const cursorPos = inputElement.selectionStart; - const {start: tagStart, end: tagEnd, tag:currentTag} = getCurrentTagRange(text, cursorPos); + const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos); const replaceStart = Math.min(cursorPos, tagStart); let replaceEnd = cursorPos; const normalizedTag = normalizeTagToInsert(tagToInsert); const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd(); - if(normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1){ - replaceEnd = cursorPos + currentTagAfterCursor.length; + if (normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1) { + replaceEnd = cursorPos + currentTagAfterCursor.length; } // Add space if the previous separator was a comma and we are not at the beginning