Merge pull request #5 from newtextdoc1111/dev

Fixed an issue where autocomplete didn’t work correctly when parentheses, colons, or emoticon tags were used.
This commit is contained in:
newtextdoc1111
2025-05-15 16:10:15 +09:00
committed by GitHub
6 changed files with 278 additions and 147 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -35,7 +35,7 @@
- タグのエイリアスも検索対象に含まれます。日本語のひらがな、カタカナは区別せず検索されます
- タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを確定した場合、代わりに入力済みのタグを選択状態にします
- 入力済みのタグはグレーアウトで表示されます
## 関連タグ
@@ -46,6 +46,7 @@
- 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます
- ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます
- ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします
- `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます
## CSV データ
+92 -82
View File
@@ -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<string>} 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) {
}
}
}
+1 -22
View File
@@ -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<string, TagData>} */
tagMap: new Map(), // Stores tag data, mapping tag names to TagData objects
/** @type {Map<string, TagData>} */
aliasMap: new Map(), // Maps aliases to their main tag names
+16 -35
View File
@@ -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);
}
/**
+165 -6
View File
@@ -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 (?<!\\) to avoid matching escaped brackets
result = result.replace(/^(?<!\\)\((.+)$/, '$1');
result = result.replace(/^(.+)(?<!\\)\)$/, '$1');
return result;
}
/**
* 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 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);
}
/**
* Normalizes a tag string for input.
* @param {string} str
@@ -103,16 +131,29 @@ export function removePromptWeight(str) {
*/
export function normalizeTagToSearch(str) {
if (!str) return str;
return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_"));
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
* @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);
}
/**
@@ -196,6 +237,124 @@ 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 || 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 (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 (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(/^(?<!\\)\((.*)/s);
if (leadParenMatch) {
const newTag = leadParenMatch[1];
adjustedStart += (adjustedTag.length - newTag.length);
adjustedTag = newTag;
changedInParenStep = true;
}
// Remove trailing non-escaped parenthesis
const trailParenMatch = adjustedTag.match(/(.*)(?<!\\)\)$/s);
if (trailParenMatch) {
const newTag = trailParenMatch[1];
adjustedEnd -= (adjustedTag.length - newTag.length);
adjustedTag = newTag;
changedInParenStep = true;
}
// If the tag becomes empty or invalid during parenthesis removal, stop.
if (adjustedStart >= 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