+31
-11
@@ -19,7 +19,7 @@ import { settingValues } from './settings.js';
|
||||
// --- Autocomplete Logic ---
|
||||
|
||||
/**
|
||||
* Uses a set of variations to match a target string.
|
||||
* Checks if a target string matches any of the query variations based on several rules.
|
||||
* @param {string} target - The target word to match.
|
||||
* @param {Set<string>} queries - Set of query variations.
|
||||
* @returns {{matched: boolean, isExactMatch: boolean}}
|
||||
@@ -34,11 +34,19 @@ function matchWord(target, queries) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExactMatch) {
|
||||
for (const variation of queries) {
|
||||
if (!isContainsLetterOrNumber(variation)) {
|
||||
const hasWildcardPrefix = variation.startsWith('__');
|
||||
if (hasWildcardPrefix) {
|
||||
// If variation has wildcard prefix, only attempt a direct partial match. (e.g. "__wildcard__")
|
||||
if (target.includes(variation)) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
} else if (!isContainsLetterOrNumber(variation)) {
|
||||
// If the query variation contains only symbols,
|
||||
// match if the target also contains only symbols and includes the variation.
|
||||
// match if the target also contains only symbols and includes the variation. (e.g. "^_^", "^^^")
|
||||
if (!isContainsLetterOrNumber(target) && target.includes(variation)) {
|
||||
matched = true;
|
||||
break;
|
||||
@@ -48,15 +56,17 @@ function matchWord(target, queries) {
|
||||
if (target.includes(variation)) {
|
||||
matched = true;
|
||||
break;
|
||||
// If direct partial match fails, try matching after removing
|
||||
// common symbols from both target and variation.
|
||||
} else if (target.replace(/[-_\s']/g, '').includes(variation.replace(/[-_\s']/g, ''))) {
|
||||
}
|
||||
// If direct partial match fails, try matching after removing
|
||||
// common symbols from both target and variation.
|
||||
else if (target.replace(/[-_\s']/g, '').includes(variation.replace(/[-_\s']/g, ''))) {
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched, isExactMatch };
|
||||
}
|
||||
|
||||
@@ -750,6 +760,7 @@ class AutocompleteUI {
|
||||
export class AutocompleteEventHandler {
|
||||
constructor() {
|
||||
this.autocompleteUI = new AutocompleteUI();
|
||||
this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -761,8 +772,7 @@ export class AutocompleteEventHandler {
|
||||
if (!settingValues.enabled) return;
|
||||
if (!event.isTrusted) return; // ignore synthetic events
|
||||
|
||||
const textareaElement = event.target;
|
||||
const partialTag = getCurrentPartialTag(textareaElement);
|
||||
const partialTag = getCurrentPartialTag(event.target);
|
||||
if (partialTag.length <= 0) {
|
||||
this.autocompleteUI.hide();
|
||||
}
|
||||
@@ -791,7 +801,8 @@ export class AutocompleteEventHandler {
|
||||
handleKeyDown(event) {
|
||||
if (!settingValues.enabled) return;
|
||||
|
||||
const textareaElement = event.target;
|
||||
// Save modifier key (without shiftKey) state when a key is pressed
|
||||
this.keyDownWithModifier.set(event.key.toLowerCase(), event.ctrlKey || event.altKey || event.metaKey);
|
||||
|
||||
// Handle autocomplete navigation
|
||||
if (this.autocompleteUI && this.autocompleteUI.isVisible()) {
|
||||
@@ -806,9 +817,10 @@ export class AutocompleteEventHandler {
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
if (this.autocompleteUI.getSelectedTag() !== null) {
|
||||
const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
|
||||
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) {
|
||||
event.preventDefault();
|
||||
insertTagToTextArea(textareaElement, this.autocompleteUI.getSelectedTag());
|
||||
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag());
|
||||
}
|
||||
this.autocompleteUI.hide();
|
||||
break;
|
||||
@@ -828,6 +840,14 @@ export class AutocompleteEventHandler {
|
||||
handleKeyUp(event) {
|
||||
if (!settingValues.enabled) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Check if the key was pressed with a modifier
|
||||
if (this.keyDownWithModifier.get(key)) {
|
||||
this.keyDownWithModifier.delete(key); // Remove the pressed key from the map
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not process keyup events if Ctrl, Alt, or Meta keys are pressed.
|
||||
// This prevents autocomplete from appearing for shortcuts like Ctrl+C, Ctrl+Z, etc.
|
||||
// It also handles the release of a modifier key itself if it wasn't part of a character-producing combination.
|
||||
|
||||
+18
-11
@@ -46,13 +46,13 @@ function calculateJaccardSimilarity(tagA, tagB) {
|
||||
export function getTagFromCursorPosition(inputElement) {
|
||||
const text = inputElement.value;
|
||||
const cursorPos = inputElement.selectionStart;
|
||||
|
||||
|
||||
// Use getCurrentTagRange to get the tag at the cursor position
|
||||
const tagRange = getCurrentTagRange(text, cursorPos);
|
||||
|
||||
|
||||
// If no tag was found at the cursor position
|
||||
if (!tagRange) return null;
|
||||
|
||||
|
||||
// Return the normalized tag for searching
|
||||
return normalizeTagToSearch(tagRange.tag);
|
||||
}
|
||||
@@ -98,12 +98,12 @@ function searchRelatedTags(tag) {
|
||||
// Limit to max number of suggestions
|
||||
const result = relatedTags.slice(0, settingValues.maxRelatedTags);
|
||||
|
||||
if(settingValues._logprocessingTime) {
|
||||
if (settingValues._logprocessingTime) {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
console.debug(`[Autocomplete-Plus] Find tags to related "${tag}" took ${duration.toFixed(2)}ms.`);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -677,9 +677,16 @@ export class RelatedTagsEventHandler {
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
handleKeyDown(event) {
|
||||
const textareaElement = event.target;
|
||||
// If related tags UI is pinned, don't handle key events except for Escape
|
||||
if (this.relatedTagsUI.isPinned) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.relatedTagsUI.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For related tags panel, handle Escape key
|
||||
// Handle key events for related tags UI
|
||||
if (this.relatedTagsUI.isVisible()) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
@@ -706,11 +713,11 @@ export class RelatedTagsEventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Show related tags on Ctrl+Space
|
||||
// Show related tags on Ctrl+Shift+Space
|
||||
if (settingValues.enableRelatedTags) {
|
||||
if (event.key === ' ' && event.ctrlKey && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.relatedTagsUI.show(textareaElement);
|
||||
this.relatedTagsUI.show(event.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,8 +745,8 @@ export class RelatedTagsEventHandler {
|
||||
* @returns
|
||||
*/
|
||||
handleClick(event) {
|
||||
// Check trigger mode from settings
|
||||
if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey) {
|
||||
// Hide related tags UI if not Ctrl+Click and not pinned when trigger mode is 'ctrl+Click'
|
||||
if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey && !this.relatedTagsUI.isPinned) {
|
||||
this.relatedTagsUI.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
+155
-21
@@ -1,5 +1,23 @@
|
||||
// --- String Helper Functions ---
|
||||
|
||||
const MAX_PROMPT_WEIGHT_VALUE = 9.9;
|
||||
|
||||
// Regex constants
|
||||
const REG_ESCAPE_OPEN_PAREN = /(?<!\\)\(/g;
|
||||
const REG_ESCAPE_CLOSE_PAREN = /(?<!\\)\)/g;
|
||||
const REG_UNESCAPE_OPEN_PAREN = /\\\(/g;
|
||||
const REG_UNESCAPE_CLOSE_PAREN = /\\\)/g;
|
||||
|
||||
const REG_CONTAINS_LETTER_NUMBER = /[a-zA-Z0-9\u3040-\u30ff\u3400-\u4DBF\u4e00-\u9faf\uac00-\ud7af\u0400-\u04FF\u0590-\u05FF]/;
|
||||
|
||||
const REG_PROMPT_WEIGHT = /(.*?):([0-9](\.\d+)?)$/;
|
||||
|
||||
const REG_STRIP_LEADING_PAREN = /^(?<!\\)\((.*)/s;
|
||||
const REG_STRIP_TRAILING_PAREN = /(.*)(?<!\\)\)$/s;
|
||||
|
||||
const REG_WILDCARD_WEIGHTED_TAG = /(\d+)[_\s]*::(.*?)(?=\||$)/g;
|
||||
const REG_WILDCARD_SIMPLE_WORD = /[^{}_|]+/g;
|
||||
|
||||
/**
|
||||
* Converts Hiragana to Katakana.
|
||||
* @param {string} str Input string.
|
||||
@@ -64,7 +82,7 @@ export function formatCountHumanReadable(num) {
|
||||
export function escapeParentheses(str) {
|
||||
if (!str) return str;
|
||||
// Use lookbehind assertions to avoid double escaping
|
||||
return str.replace(/(?<!\\)\(/g, '\\(').replace(/(?<!\\)\)/g, '\\)');
|
||||
return str.replace(REG_ESCAPE_OPEN_PAREN, '\\(').replace(REG_ESCAPE_CLOSE_PAREN, '\\)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +93,7 @@ export function escapeParentheses(str) {
|
||||
*/
|
||||
export function unescapeParentheses(str) {
|
||||
if (!str) return str;
|
||||
return str.replace(/\\\(/g, '(').replace(/\\\)/g, ')');
|
||||
return str.replace(REG_UNESCAPE_OPEN_PAREN, '(').replace(REG_UNESCAPE_CLOSE_PAREN, ')');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +115,7 @@ export function removePromptWeight(str) {
|
||||
// (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) {
|
||||
if (parseFloat(p2) <= MAX_PROMPT_WEIGHT_VALUE) {
|
||||
return p1;
|
||||
}
|
||||
// Otherwise preserve the entire string (like "year:2000")
|
||||
@@ -121,7 +139,7 @@ export function removePromptWeight(str) {
|
||||
export function isContainsLetterOrNumber(str) {
|
||||
if (!str) return false;
|
||||
// Check if the string contains at least one letter or number (Latin, Japanese, Korean, CJK Extension A, Cyrillic, Hebrew)
|
||||
return /[a-zA-Z0-9\u3040-\u30ff\u3400-\u4DBF\u4e00-\u9faf\uac00-\ud7af\u0400-\u04FF\u0590-\u05FF]/.test(str);
|
||||
return REG_CONTAINS_LETTER_NUMBER.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +149,7 @@ export function isContainsLetterOrNumber(str) {
|
||||
*/
|
||||
export function normalizeTagToSearch(str) {
|
||||
if (!str) return str;
|
||||
|
||||
|
||||
if (isContainsLetterOrNumber(str)) {
|
||||
return unescapeParentheses(removePromptWeight(str).replace(/ /g, "_"));
|
||||
}
|
||||
@@ -141,8 +159,9 @@ export function normalizeTagToSearch(str) {
|
||||
|
||||
/**
|
||||
* Normalizes a tag string for input.
|
||||
* Converts underscores to spaces only if the tag contains at least one letter or number.
|
||||
* Keeps underscores for tags that are only symbols (e.g. "^_^").
|
||||
* Converts underscores to spaces only if the tag contains at least one letter or number,
|
||||
* and is not a wildcard call (e.g., "__wildcard__").
|
||||
* Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls.
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
@@ -150,9 +169,15 @@ export function normalizeTagToInsert(str) {
|
||||
if (!str) return str;
|
||||
|
||||
if (isContainsLetterOrNumber(str)) {
|
||||
return escapeParentheses(str.replace(/_/g, " "));
|
||||
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
|
||||
|
||||
if (!isWildcardCall) {
|
||||
// If doesn't wildcard call, replace underscores with spaces
|
||||
return escapeParentheses(str.replace(/_/g, " "));
|
||||
}
|
||||
}
|
||||
// Otherwise, keep as is (for emoji/face tags)
|
||||
|
||||
// Otherwise, keep it as is (e.g., ""^_^", "__wildcard__")
|
||||
return escapeParentheses(str);
|
||||
}
|
||||
|
||||
@@ -179,16 +204,113 @@ export function isValidTag(tag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds all words in a string, even those inside nested braces.
|
||||
* This helps extract all possible tags from complex nested wildcards.
|
||||
* @param {string} text The text to extract words from
|
||||
* @param {number} baseStart The starting position of the text in the original string
|
||||
* @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags
|
||||
*/
|
||||
function extractAllWords(text, baseStart) {
|
||||
const result = [];
|
||||
|
||||
// First, extract weighted tag patterns like "20::from above"
|
||||
let weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text);
|
||||
while (weightMatch !== null) {
|
||||
const tagText = weightMatch[2].trim();
|
||||
|
||||
if (tagText) {
|
||||
// Calculate position with original offsets
|
||||
const fullMatchStart = baseStart + weightMatch.index;
|
||||
const tagTextStart = fullMatchStart + weightMatch[0].indexOf(tagText);
|
||||
const tagTextEnd = tagTextStart + tagText.length;
|
||||
|
||||
result.push({
|
||||
start: tagTextStart,
|
||||
end: tagTextEnd,
|
||||
tag: tagText
|
||||
});
|
||||
}
|
||||
|
||||
weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text);
|
||||
}
|
||||
|
||||
// If no weighted tags were found, extract simple words
|
||||
if (result.length === 0) {
|
||||
// Regular expression to match words (sequences of non-whitespace characters)
|
||||
// We consider a word to be any continuous sequence of characters that's not a space, pipe, or brace
|
||||
const wordRegex = REG_WILDCARD_SIMPLE_WORD;
|
||||
let match;
|
||||
|
||||
// Find all standalone words in the text
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
const wordStart = baseStart + match.index;
|
||||
const wordEnd = wordStart + match[0].length;
|
||||
|
||||
// Remove leading and trailing spaces from the matched word
|
||||
const trimmedTag = match[0].trim();
|
||||
const leadingSpaces = match[0].length - match[0].trimStart().length;
|
||||
const adjustedStart = wordStart + leadingSpaces;
|
||||
const adjustedEnd = wordStart + leadingSpaces + trimmedTag.length;
|
||||
|
||||
result.push({
|
||||
start: adjustedStart,
|
||||
end: adjustedEnd,
|
||||
tag: trimmedTag
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a wildcard selection and returns individual tags.
|
||||
* Supports syntax like {tag1|tag2|tag3} and {weight::tag1|weight::tag2}.
|
||||
* Also handles nested wildcards like {tag1 {tag2|tag3}|tag4}.
|
||||
* @param {string} tag The complete tag text that might contain a wildcard
|
||||
* @param {number} startPos The starting position of the tag in the original text
|
||||
* @param {number} endPos The ending position of the tag in the original text
|
||||
* @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags or null
|
||||
*/
|
||||
function parseWildcardSelection(tag, startPos, endPos) {
|
||||
// Trim the tag for matching but keep original position
|
||||
const trimmedTag = tag.trim();
|
||||
|
||||
// Check if this is a wildcard selection
|
||||
if (!trimmedTag.startsWith('{') || !trimmedTag.endsWith('}')) {
|
||||
return null; // Not a wildcard
|
||||
}
|
||||
|
||||
// Calculate position offsets for the trim operation
|
||||
const leadingSpaces = tag.length - tag.trimStart().length;
|
||||
const tagStart = startPos + leadingSpaces;
|
||||
|
||||
// For nested wildcards, we'll extract all words from the content
|
||||
// This treats each word as a separate tag, regardless of nesting
|
||||
// Extract the content between the outermost braces
|
||||
const wildcardContent = trimmedTag.substring(1, trimmedTag.length - 1);
|
||||
|
||||
// Extract all words from the wildcard content, including those in nested structures
|
||||
const allTags = extractAllWords(wildcardContent, tagStart + 1);
|
||||
|
||||
return allTags.length > 0 ? allTags : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all tag positions in the given text.
|
||||
* Searches for tags separated by commas or newlines.
|
||||
* Also handles wildcard selections in the format {tag1|tag2|tag3}.
|
||||
* @param {string} text The text to search in
|
||||
* @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content
|
||||
*/
|
||||
export function findAllTagPositions(text) {
|
||||
if (!text) return [];
|
||||
|
||||
const positions = [];
|
||||
let startPos = 0;
|
||||
|
||||
// Process text segment by segment (comma or newline separated)
|
||||
while (startPos < text.length) {
|
||||
// Skip any leading whitespace, commas, or newlines
|
||||
while (startPos < text.length &&
|
||||
@@ -206,14 +328,26 @@ export function findAllTagPositions(text) {
|
||||
if (endPosNewline === -1) endPosNewline = text.length;
|
||||
|
||||
const endPos = Math.min(endPosComma, endPosNewline);
|
||||
const tag = text.substring(startPos, endPos);
|
||||
const tagText = text.substring(startPos, endPos);
|
||||
|
||||
if (tag.trim().length > 0) {
|
||||
positions.push({
|
||||
start: startPos,
|
||||
end: endPos,
|
||||
tag: tag
|
||||
});
|
||||
if (tagText.trim().length > 0) {
|
||||
const trimmedTag = tagText.trim();
|
||||
|
||||
// Check if this is a wildcard selection
|
||||
if (trimmedTag.startsWith('{') && trimmedTag.endsWith('}')) {
|
||||
// Process wildcard using our existing wildcard parser
|
||||
const wildcardTags = parseWildcardSelection(tagText, startPos, endPos);
|
||||
if (wildcardTags) {
|
||||
positions.push(...wildcardTags);
|
||||
}
|
||||
} else {
|
||||
// Normal tag, add it directly
|
||||
positions.push({
|
||||
start: startPos,
|
||||
end: endPos,
|
||||
tag: tagText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next tag
|
||||
@@ -222,6 +356,7 @@ export function findAllTagPositions(text) {
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts existing tags from the textarea with search normalization (possibly duplicated).
|
||||
* @param {HTMLTextAreaElement} textarea The textarea element to extract tags from
|
||||
@@ -302,7 +437,7 @@ export function getCurrentTagRange(text, cursorPos) {
|
||||
changedInParenStep = false;
|
||||
|
||||
// Remove leading non-escaped parenthesis
|
||||
const leadParenMatch = adjustedTag.match(/^(?<!\\)\((.*)/s);
|
||||
const leadParenMatch = adjustedTag.match(REG_STRIP_LEADING_PAREN);
|
||||
if (leadParenMatch) {
|
||||
const newTag = leadParenMatch[1];
|
||||
adjustedStart += (adjustedTag.length - newTag.length);
|
||||
@@ -311,7 +446,7 @@ export function getCurrentTagRange(text, cursorPos) {
|
||||
}
|
||||
|
||||
// Remove trailing non-escaped parenthesis
|
||||
const trailParenMatch = adjustedTag.match(/(.*)(?<!\\)\)$/s);
|
||||
const trailParenMatch = adjustedTag.match(REG_STRIP_TRAILING_PAREN);
|
||||
if (trailParenMatch) {
|
||||
const newTag = trailParenMatch[1];
|
||||
adjustedEnd -= (adjustedTag.length - newTag.length);
|
||||
@@ -331,15 +466,14 @@ 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", "year:2000" -> "year:2000")
|
||||
// This applies to the tag *after* parentheses are handled.
|
||||
const weightRegex = /(.*?):([0-9](\.\d+)?)$/;
|
||||
const weightMatch = adjustedTag.match(weightRegex);
|
||||
const weightMatch = adjustedTag.match(REG_PROMPT_WEIGHT);
|
||||
|
||||
if (weightMatch) {
|
||||
const tagPart = weightMatch[1];
|
||||
const weightValue = weightMatch[2];
|
||||
// Only consider it as a weight if it's a simple number between 0-9 possibly with decimal
|
||||
// Don't treat larger numbers like :1999 or :2000 as weights
|
||||
if (parseFloat(weightValue) <= 9.9) {
|
||||
if (parseFloat(weightValue) <= MAX_PROMPT_WEIGHT_VALUE) {
|
||||
const fullWeightString = adjustedTag.substring(tagPart.length);
|
||||
if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) {
|
||||
adjustedEnd -= fullWeightString.length;
|
||||
|
||||
Reference in New Issue
Block a user