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 データ diff --git a/web/js/autocomplete.js b/web/js/autocomplete.js index 7c6e3e1..208598e 100644 --- a/web/js/autocomplete.js +++ b/web/js/autocomplete.js @@ -7,16 +7,59 @@ import { formatCountHumanReadable, hiraToKata, kataToHira, + isContainsLetterOrNumber, normalizeTagToInsert, normalizeTagToSearch, - findAllTagPositions, extractTagsFromTextArea, + getCurrentTagRange, getViewportMargin } from './utils.js'; 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); @@ -153,7 +155,6 @@ 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. */ @@ -169,8 +170,30 @@ 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 - const partial = text.substring(start, cursorPos).trimStart(); + // 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 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); } @@ -184,36 +207,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 { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos); + const replaceStart = Math.min(cursorPos, tagStart); + let replaceEnd = cursorPos; - const currentWordStart = text.substring(startPos, cursorPos).search(/\S|$/) + startPos; - 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 - 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; @@ -431,7 +441,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 +535,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 +733,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, @@ -866,4 +876,4 @@ export class AutocompleteEventHandler { */ handleClick(event) { } -} +} \ No newline at end of file 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 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); } /** diff --git a/web/js/utils.js b/web/js/utils.js index 845e41f..5207d54 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -80,22 +80,50 @@ 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 (?= 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 (clampedCursorPos < pos.end) { + break; + } + // 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; + } + } + + 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", "year:2000" -> "year:2000") + // This applies to the tag *after* parentheses are handled. + const weightRegex = /(.*?):([0-9](\.\d+)?)$/; + const weightMatch = adjustedTag.match(weightRegex); + + 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) { + 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