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:
@@ -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
@@ -35,7 +35,7 @@
|
||||
|
||||
- タグのエイリアスも検索対象に含まれます。日本語のひらがな、カタカナは区別せず検索されます
|
||||
- タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです
|
||||
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを確定した場合、代わりに入力済みのタグを選択状態にします
|
||||
- 入力済みのタグはグレーアウトで表示されます
|
||||
|
||||
## 関連タグ
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
- 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます
|
||||
- ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます
|
||||
- ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します
|
||||
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします
|
||||
- `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます
|
||||
|
||||
## CSV データ
|
||||
|
||||
+92
-82
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user