Compare commits

...
Author SHA1 Message Date
newtextdoc1111 65960a2394 fix: Bump version to 1.6.1 2025-10-29 15:54:45 +09:00
newtextdoc1111 92f01b84a0 docs: Add bulk tag insertion section 2025-10-29 15:53:34 +09:00
newtextdoc1111 3f8eb3bc0c Merge pull request #43 from newtextdoc1111/dev
Merge Dev
2025-10-29 14:55:37 +09:00
newtextdoc1111 fe42e198d2 fix: Fix tag normalization to handle multi-tags (#36) 2025-10-29 14:52:31 +09:00
newtextdoc1111 98d9fdfc50 fix: Bump version to 1.6.0 2025-10-26 03:30:50 +09:00
newtextdoc1111 504b681ab8 Merge pull request #42 from newtextdoc1111/dev
Merge Dev
2025-10-26 03:27:03 +09:00
newtextdoc1111 aca0ed6326 feat: Add setting to replace underscores with spaces in tag insertion (#39) 2025-10-24 06:16:11 +09:00
newtextdoc1111 ab941ab329 chore: update actions/setup-node to version 6 2025-10-24 06:03:52 +09:00
newtextdoc1111 c4e408eede fix: update Node.js version to 22 in CI workflow 2025-10-22 08:55:04 +09:00
newtextdoc1111 f8c86203e4 chore: update babel-jest and jest to version 30.2.0 2025-10-22 08:30:18 +09:00
newtextdoc1111 5a4ded44b8 feat: Add testing support for autocomplete functions 2025-10-22 08:29:48 +09:00
newtextdoc1111 295d0c0cdc fix: Add null checks to autocomplete functions 2025-10-22 04:15:13 +09:00
newtextdoc1111 37ce793208 fix: Include hyphens in tag encoder split regex 2025-10-22 04:06:56 +09:00
newtextdoc1111 2386fc7aa0 fix: Optimize CSV loading process 2025-10-22 04:01:59 +09:00
newtextdoc1111 937c5bb283 fix: Fix TagData creation for invalid tag in RelatedTagsUI 2025-10-19 23:39:45 +09:00
newtextdoc1111 512dd88d7a docs: Add display settings documentation 2025-10-18 05:19:31 +09:00
newtextdoc1111 096314933d fix: Bump version to 1.5.0 2025-10-18 02:58:02 +09:00
newtextdoc1111 05c890ee37 Merge pull request #41 from newtextdoc1111/dev
Merge Dev
2025-10-18 02:52:46 +09:00
newtextdoc1111 d4725dfebc feat: Add option to hide aliases in autocomplete and related tags display (#33) 2025-10-18 02:38:00 +09:00
newtextdoc1111 c4d5eee89d feat: Bump version to 1.4.1 2025-10-14 23:54:20 +09:00
newtextdoc1111 fa4d87ee77 Merge pull request #40 from newtextdoc1111/dev
Merge Dev
2025-10-14 23:49:43 +09:00
newtextdoc1111 3cb5a41cc8 fix: Temporarily comment out CSV update check button until remote file detection is properly implemented 2025-10-14 23:46:41 +09:00
newtextdoc1111 16a325b798 fix: Temporarily comment out remote file modification checks in the downloader 2025-08-30 23:40:27 +09:00
newtextdoc1111 e3171940fb fix: Removed the process that periodically checks for CSV file updates. 2025-08-30 23:39:52 +09:00
17 changed files with 2248 additions and 878 deletions
+2 -2
View File
@@ -12,9 +12,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: '18' node-version: '22'
cache: 'npm' cache: 'npm'
- name: Install dependencies - name: Install dependencies
+21 -3
View File
@@ -118,11 +118,28 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
>[!NOTE] >[!NOTE]
> If there are multiple user CSV files, they are loaded in alphabetical order. If the same tag exists in multiple files, the one loaded first is retained. The basic CSV is loaded last. > If there are multiple user CSV files, they are loaded in alphabetical order. If the same tag exists in multiple files, the one loaded first is retained. The basic CSV is loaded last.
### Bulk Tag Insertion (Pseudo "Chants")
By enclosing multiple tags with `""` (double quotation marks), you can insert frequently used tags in bulk.
This is similar to the **Chants** feature in [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants).
For example, by preparing the following CSV, you can quickly insert corresponding tags by typing `<c:Basic-HighQuality>` or `<c:Basic-Negative>`.
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * Text enclosed in `""` does not escape `()` (parentheses). Tags that originally contain parentheses should be written in the CSV with escaped parentheses. Example: `copyright_(series)` -> `copyright_\(series\)`
> * The alias column also supports `""`, allowing you to assign multiple aliases
## Settings ## Settings
### Tag Source ### Tag Source
> [!TIP] > [!NOTE]
> The source of tag data such as Danbooru or e621 is called the "tag source". > The source of tag data such as Danbooru or e621 is called the "tag source".
- **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources. - **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources.
@@ -133,6 +150,7 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
- **Enable Autocomplete**: Enable/disable the autocomplete feature. - **Enable Autocomplete**: Enable/disable the autocomplete feature.
- **Max suggestions**: Maximum number of autocomplete suggestions to display. - **Max suggestions**: Maximum number of autocomplete suggestions to display.
- **Replace '_' with 'Space'**: Replaces underscores with spaces when inserting tags. This setting also affects related tag display.
- **Enable Loras and Embeddings**: Display Lora and Embedding in the suggestions. - **Enable Loras and Embeddings**: Display Lora and Embedding in the suggestions.
- **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details). - **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details).
@@ -143,9 +161,9 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
- **Default Display Position**: Default display position when ComfyUI starts. - **Default Display Position**: Default display position when ComfyUI starts.
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click) - **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click)
### Miscellaneous ### Display
- **Check CSV updates**: Click the "Check Now" button to check if new CSV files are available in HuggingFace and download them if necessary. - **Hide Alias**: Hide/show the Alias ​​column in autocomplete and related tags (default is show)
## Advanced Settings ## Advanced Settings
+21 -3
View File
@@ -116,11 +116,28 @@ worst_quality,5,9999999,
>[!NOTE] >[!NOTE]
> ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。 > ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。
### 複数タグの一括挿入機能(疑似 Chants)
`""` (ダブルクォーテーション)で複数タグを囲むことで、よく使うタグを一括で挿入できます。
これは [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants) で **Chants** と呼ばれる機能に似ています。
例として以下のCSVを用意することで、`<c:Basic-HighQuality>` や `<c:Basic-Negative>` と入力して対応したタグを素早く挿入出来ます。
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * `""` で囲まれたテキストは `()` (括弧)がエスケープされません。元々括弧が含まれるタグはエスケープした状態でCSVに記述してください。 例: `copyright_(series)` -> `copyright_\(series\)`
> * エイリアス列も `""` に対応しているので、複数のエイリアスを付けられます
## 設定 ## 設定
### タグソース ### タグソース
> [!TIP] > [!NOTE]
> Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます > Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます
- **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します - **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します
@@ -131,6 +148,7 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化 - **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max Suggestions**: オートコンプリート候補の最大表示件数 - **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する - **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください) - **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
@@ -141,9 +159,9 @@ worst_quality,5,9999999,
- **Default Display Position**: ComfyUI起動時のデフォルト表示位置 - **Default Display Position**: ComfyUI起動時のデフォルト表示位置
- **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック) - **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
### その他 ### 表示
- **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います - **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
## 上級者向け設定 ## 上級者向け設定
+11 -19
View File
@@ -235,16 +235,7 @@ class Downloader:
def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False): def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False):
"""Checks HuggingFace for file updates and updates metadata.""" """Checks HuggingFace for file updates and updates metadata."""
perform_hf_check = True perform_hf_check = force_check
if not force_check and dataset_meta.get("last_remote_check_timestamp"):
try:
last_check_dt = datetime.fromisoformat(dataset_meta["last_remote_check_timestamp"])
if now_utc - last_check_dt < timedelta(days=7):
perform_hf_check = False
except (ValueError, KeyError, TypeError):
print(
"[Autocomplete-Plus] Invalid or missing timestamp for last_remote_check_timestamp. Will perform remote check."
)
if perform_hf_check: if perform_hf_check:
huggingface_dataset_id = dataset_meta["hf_dataset_id"] huggingface_dataset_id = dataset_meta["hf_dataset_id"]
@@ -303,15 +294,16 @@ class Downloader:
return f"File {file_name} is missing or empty locally." return f"File {file_name} is missing or empty locally."
# Check if the last modified date on HuggingFace is newer than the last download date # Check if the last modified date on HuggingFace is newer than the last download date
try: # Temporarily comment out until changes to remote files can be detected
last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"]) # try:
hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"]) # last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"])
if hf_modified_dt > last_download_dt: # hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"])
return f"Remote file {file_name} is newer (HF: {last_download_dt}, Local Download: {hf_modified_dt})." # if hf_modified_dt > last_download_dt:
except (ValueError, TypeError): # return f"Remote file {file_name} is newer (HF: {hf_modified_dt}, Local Download: {last_download_dt})."
file_meta_entry["last_download"] = None # except (ValueError, TypeError):
file_meta_entry["last_modified_on_hf"] = None # file_meta_entry["last_download"] = None
return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity." # file_meta_entry["last_modified_on_hf"] = None
# return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity."
# If the file is missing or empty, but the last download timestamp exists, we need to retry. # If the file is missing or empty, but the last download timestamp exists, we need to retry.
if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None: if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None:
+1501 -739
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,9 +6,9 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.27.1", "@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"babel-jest": "^29.7.0", "babel-jest": "^30.2.0",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"jest": "^29.7.0", "jest": "^30.2.0",
"stylelint": "^16.19.1", "stylelint": "^16.19.1",
"stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard": "^38.0.0" "stylelint-config-standard": "^38.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-autocomplete-plus" name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI" description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.4.0" version = "1.6.1"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["",] dependencies = ["",]
+378
View File
@@ -0,0 +1,378 @@
import {
__test__
} from "../../web/js/autocomplete.js";
import {
TagData,
TagSource,
ModelTagSource,
autoCompleteData
} from "../../web/js/data.js";
import {
createFlexSearchDocument,
createFlexSearchDocumentForModel
} from "../../web/js/searchengine.js"
const {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
} = __test__;
// Helper function to create mock textarea element
function createMockTextarea(value, selectionStart, selectionEnd = -1) {
return {
value: value,
selectionStart: selectionStart,
selectionEnd: selectionEnd > selectionStart ? selectionEnd : selectionStart,
nodeName: 'TEXTAREA',
focus() { document.setFocus(this); },
setSelectionRange: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
}),
scrollTop: 0,
scrollLeft: 0,
ownerDocument: {
defaultView: typeof window !== 'undefined' ? window : {},
documentElement: typeof document !== 'undefined' ? document.documentElement : {}
},
dispatchEvent: () => { }
};
}
// Test data setup function
function setupTestData() {
// Clear existing data
Object.keys(autoCompleteData).forEach(key => delete autoCompleteData[key]);
// Create mock AutocompleteData structure for each source
const sources = [...Object.values(TagSource), ...Object.values(ModelTagSource)];
sources.forEach(source => {
autoCompleteData[source] = {
flexSearchDocument: null,
sortedTags: [],
tagMap: new Map(),
aliasMap: new Map(),
cooccurrenceMap: new Map(),
isInitializing: false,
initialized: true,
baseLoadingProgress: {
cooccurrence: 0
}
};
});
// Add sample tag data for testing
const sampleTags = [
new TagData('1girl', 0, 1000000, ['one_girl'], TagSource.Danbooru),
new TagData('blue_hair', 0, 500000, ['blue hair'], TagSource.Danbooru),
new TagData('long_hair', 0, 800000, [], TagSource.Danbooru),
new TagData('__wildcard__', 0, 100, [], TagSource.Danbooru),
new TagData(':d', 0, 50000, [], TagSource.Danbooru),
new TagData('test_tag', 0, 1000, ['test'], TagSource.E621),
new TagData('<lora:test_model>', 0, 0, [], ModelTagSource.Lora),
new TagData('embedding:test_embedding', 0, 0, [], ModelTagSource.Embeddings)
];
// Distribute tags across sources
sampleTags.forEach(tagData => {
const source = tagData.source;
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(tagData.tag, tagData);
// Add aliases to alias map
if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => {
autoCompleteData[source].aliasMap.set(alias, tagData.tag);
});
}
});
// Sort tags and build flexsearch index
sources.forEach(source => {
autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
const isModelSrc = Object.values(ModelTagSource).includes(source);
const doc = isModelSrc ? createFlexSearchDocumentForModel() : createFlexSearchDocument();
autoCompleteData[source].sortedTags.forEach((tagData, i) => {
doc.add(i, tagData);
});
autoCompleteData[source].flexSearchDocument = doc;
});
}
describe('Autocomplete Functions', () => {
beforeEach(() => {
setupTestData();
// Mock global document
global.document = {
execCommand: (commandId, showUI, value) => {
switch (commandId) {
case "insertText":
if (document.focusedElement) {
document.focusedElement.value += value;
}
break;
default:
throw new Error('Not implemented!');
}
},
createElement: () => ({
id: '',
style: {},
innerHTML: '',
appendChild: () => { },
getBoundingClientRect: () => ({
top: 0,
left: 0,
width: 300,
height: 100
})
}),
setFocus: (element) => {
document.focusedElement = element;
},
body: {
appendChild: () => { },
removeChild: () => { }
},
focusedElement: null
};
// Mock global window
global.window = {
getComputedStyle: () => ({
lineHeight: '20px',
fontSize: '14px',
fontFamily: 'Arial'
})
};
});
describe('matchWord', () => {
test('should match exact queries', () => {
const queries = new Set(['1girl', '1girls']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match partial queries', () => {
const queries = new Set(['girl']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle wildcard prefixes', () => {
const queries = new Set(['__wild']);
const result = matchWord('__wildcard__', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should handle symbol-only queries', () => {
const queries = new Set([':d']);
const result = matchWord(':d', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(true);
});
test('should match after removing common symbols', () => {
const queries = new Set(['blue hair']);
const result = matchWord('blue_hair', queries);
expect(result.matched).toBe(true);
expect(result.isExactMatch).toBe(false);
});
test('should not match unrelated queries', () => {
const queries = new Set(['test']);
const result = matchWord('1girl', queries);
expect(result.matched).toBe(false);
expect(result.isExactMatch).toBe(false);
});
});
describe('getCurrentPartialTag', () => {
test('should extract partial tag before cursor', () => {
const textarea = createMockTextarea('1girl, blue_hair', 14);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_ha');
});
test('should return full tag when cursor is at the end', () => {
const textarea = createMockTextarea('1girl', 5);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('1girl');
});
test('should handle empty textarea', () => {
const textarea = createMockTextarea('', 0);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('');
});
test('should handle newline separators', () => {
const textarea = createMockTextarea('1girl\nblue_h', 12);
const result = getCurrentPartialTag(textarea);
expect(typeof result).toBe('string');
expect(result).toBe('blue_h');
});
});
describe('searchCompletionCandidates', () => {
test('should return an empty array for empty input', () => {
const textarea = createMockTextarea('', 0);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for null input', () => {
const results = searchCompletionCandidates(null);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return an empty array for undefined input', () => {
const results = searchCompletionCandidates(undefined);
expect(Array.isArray(results)).toBe(true);
expect(results.length).toBe(0);
});
test('should return multiple candidates for valid input', () => {
const textarea = createMockTextarea('test', 4);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
test('should find wildcard tag', () => {
const textarea = createMockTextarea('__wild', 6);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining(['__wildcard__']));
});
test('should find emoticon tag', () => {
const textarea = createMockTextarea(':d', 2);
const results = searchCompletionCandidates(textarea);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([':d']));
});
});
describe('sequentialSearch', () => {
test('should find and return matching tags', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = sequentialSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('searchWithFlexSearch', () => {
test('should find and return matching tags using FlexSearch', () => {
const partialTag = 'test';
const queryVariations = new Set([partialTag.toLowerCase()]);
const results = searchWithFlexSearch(partialTag, queryVariations);
expect(Array.isArray(results)).toBe(true);
expect(results.map(tagData => tagData.tag))
.toEqual(expect.arrayContaining([
'test_tag', '<lora:test_model>', 'embedding:test_embedding'
]));
});
});
describe('insertTagToTextArea', () => {
test('should insert tag and replace underscore with space', () => {
const textarea = createMockTextarea('1girl, ', 7);
const tagData = { tag: 'blue_hair', source: 'danbooru' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('1girl, blue hair, ');
});
test('should complete model tag and add trailing comma', () => {
const textarea = createMockTextarea('<lora:', 6);
const tagData = { tag: '<lora:my_model>', source: 'lora' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('<lora:my_model>, ');
});
test('should handle wildcard tag insertion', () => {
const textarea = createMockTextarea('__wild', 6);
const tagData = { tag: '__wildcard__', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe('__wildcard__, ');
});
test('should handle emoticon tag insertion', () => {
const textarea = createMockTextarea(':d', 2);
const tagData = { tag: ':d', source: 'danbooru' };
insertTagToTextArea(textarea, tagData);
expect(textarea.value).toBe(':d, ');
});
});
});
+73
View File
@@ -1,3 +1,4 @@
import { settingValues } from '../../web/js/settings.js';
import { import {
extractTagsFromTextArea, extractTagsFromTextArea,
normalizeTagToSearch, normalizeTagToSearch,
@@ -135,6 +136,11 @@ describe('normalizeTagToSearch', () => {
}); });
describe('normalizeTagToInsert', () => { describe('normalizeTagToInsert', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
afterEach(() => {
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should return null or empty string for invalid inputs', () => { test('should return null or empty string for invalid inputs', () => {
expect(normalizeTagToInsert(null)).toBeNull(); expect(normalizeTagToInsert(null)).toBeNull();
expect(normalizeTagToInsert(undefined)).toBeUndefined(); expect(normalizeTagToInsert(undefined)).toBeUndefined();
@@ -167,6 +173,73 @@ describe('normalizeTagToInsert', () => {
expect(normalizeTagToInsert('year:2000')).toBe('year:2000'); expect(normalizeTagToInsert('year:2000')).toBe('year:2000');
expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar'); expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar');
}); });
test('should respect replaceUnderscoreWithSpace setting when true', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair')).toBe('blue hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long curly hair');
});
test('should respect replaceUnderscoreWithSpace setting when false', () => {
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair')).toBe('blue_hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red_eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long_curly_hair');
});
test('should respect replaceUnderscoreWithSpace setting with parentheses', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue hair\\(style\\)');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue_hair\\(style\\)');
});
test('should not replace underscores in wildcard syntax regardless of setting', () => {
// Wildcard syntax should preserve underscores regardless of setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
});
test('should not replace underscores in symbol-only tags regardless of setting', () => {
// Symbol-only tags should not be affected by the setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
});
test('should not escape parentheses when tag contains commas (multi-tags)', () => {
const multiTag = 'masterpiece, best quality, (high quality), ultra-detailed';
expect(normalizeTagToInsert(multiTag)).toBe('masterpiece, best quality, (high quality), ultra-detailed');
});
test('should not escape parentheses in multi-tag strings with parentheses', () => {
const multiTag = '(worst quality, low quality, normal quality), bad anatomy';
expect(normalizeTagToInsert(multiTag)).toBe('(worst quality, low quality, normal quality), bad anatomy');
});
test('should handle multi-tags with both underscores and parentheses correctly', () => {
settingValues.replaceUnderscoreWithSpace = true;
const multiTag = 'best_quality, (high_quality:1.2), ultra_detailed';
expect(normalizeTagToInsert(multiTag)).toBe('best quality, (high quality:1.2), ultra detailed');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert(multiTag)).toBe('best_quality, (high_quality:1.2), ultra_detailed');
});
test('should still escape parentheses for single tags without commas', () => {
// Single tags (not multi-tags) should still have parentheses escaped
expect(normalizeTagToInsert('blue(hair)')).toBe('blue\\(hair\\)');
expect(normalizeTagToInsert('(tag)')).toBe('\\(tag\\)');
});
}); });
describe('getCurrentTagRange', () => { describe('getCurrentTagRange', () => {
+10 -2
View File
@@ -97,10 +97,14 @@ body.dark-theme {
display: grid; display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%); box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto; grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto auto; grid-template-columns: max-content 1fr auto;
overflow-y: auto; overflow-y: auto;
} }
#autocomplete-plus-list.no-alias {
grid-template-columns: max-content auto;
}
.autocomplete-plus-item { .autocomplete-plus-item {
display: grid; display: grid;
cursor: pointer; cursor: pointer;
@@ -292,10 +296,14 @@ body.dark-theme {
display: grid; display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%); box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto; grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto auto; grid-template-columns: max-content 1fr auto;
overflow-y: auto; overflow-y: auto;
} }
#related-tags-list.no-alias {
grid-template-columns: max-content auto;
}
.related-tag-item { .related-tag-item {
display: grid; display: grid;
cursor: pointer; cursor: pointer;
+74 -27
View File
@@ -17,6 +17,7 @@ import {
extractTagsFromTextArea, extractTagsFromTextArea,
getCurrentTagRange, getCurrentTagRange,
getViewportMargin, getViewportMargin,
getScrollbarWidth,
IconSvgHtmlString IconSvgHtmlString
} from './utils.js'; } from './utils.js';
import { settingValues } from './settings.js'; import { settingValues } from './settings.js';
@@ -258,6 +259,10 @@ function searchWithFlexSearch(partialTag, queryVariations) {
* @returns {string} The current partial tag. * @returns {string} The current partial tag.
*/ */
function getCurrentPartialTag(inputElement) { function getCurrentPartialTag(inputElement) {
if (!inputElement) {
return "";
}
const text = inputElement.value; const text = inputElement.value;
const cursorPos = inputElement.selectionStart; const cursorPos = inputElement.selectionStart;
@@ -303,18 +308,33 @@ function getCurrentPartialTag(inputElement) {
* @param {TagData} tagDataToInsert The raw tag string to insert. * @param {TagData} tagDataToInsert The raw tag string to insert.
*/ */
function insertTagToTextArea(inputElement, tagDataToInsert) { function insertTagToTextArea(inputElement, tagDataToInsert) {
if (!inputElement || !tagDataToInsert) {
return;
}
const text = inputElement.value; const text = inputElement.value;
const cursorPos = inputElement.selectionStart; const cursorPos = inputElement.selectionStart;
const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos); const tagRange = getCurrentTagRange(text, cursorPos);
let tagStart, tagEnd, currentTag;
if (!tagRange) {
// Fallback: insert at cursor position
tagStart = cursorPos;
tagEnd = cursorPos;
currentTag = '';
} else {
({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange);
}
const replaceStart = Math.min(cursorPos, tagStart); const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos; let replaceEnd = cursorPos;
let normalizedTag; let normalizedTag;
if(Object.values(ModelTagSource).includes(tagDataToInsert.source)){ if (Object.values(ModelTagSource).includes(tagDataToInsert.source)) {
// If the tag is from a model tag source, don't want to normalize it // If the tag is from a model tag source, don't want to normalize it
normalizedTag = tagDataToInsert.tag; normalizedTag = tagDataToInsert.tag;
}else{ } else {
normalizedTag = normalizeTagToInsert(tagDataToInsert.tag); normalizedTag = normalizeTagToInsert(tagDataToInsert.tag);
} }
@@ -471,6 +491,8 @@ class AutocompleteUI {
return; return;
} }
this.tagsList.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target); const existingTags = extractTagsFromTextArea(this.target);
const currentTag = getCurrentPartialTag(this.target); const currentTag = getCurrentPartialTag(this.target);
@@ -488,6 +510,7 @@ class AutocompleteUI {
*/ */
#createTagElement(tagData, isExisting) { #createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div'); const tagRow = document.createElement('div');
tagRow.classList.add('autocomplete-plus-item', tagData.source); tagRow.classList.add('autocomplete-plus-item', tagData.source);
@@ -517,26 +540,29 @@ class AutocompleteUI {
alias.className = 'autocomplete-plus-alias'; alias.className = 'autocomplete-plus-alias';
// Display alias if available // Display alias if available
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`; alias.textContent = `${aliasText}`;
alias.title = tagData.alias.join(', '); // Full alias on hover alias.title = aliasText; // Full alias on hover
} }
// Category
const category = document.createElement('span');
category.className = `autocomplete-plus-category`;
category.textContent = `${categoryText.substring(0, 2)}`;
category.title = categoryText; // Full category on hover
// Count // Count
const tagCount = document.createElement('span'); const tagCount = document.createElement('span');
tagCount.className = `autocomplete-plus-tag-count`; tagCount.className = `autocomplete-plus-tag-count`;
tagCount.textContent = formatCountHumanReadable(tagData.count); tagCount.textContent = formatCountHumanReadable(tagData.count);
// Create tooltip with more info
let tooltipText = `Count: ${tagData.count}\nCategory: ${categoryText}`;
if (aliasText.length > 0) {
tooltipText += `\nAlias: ${aliasText}`;
}
tagRow.title = tooltipText;
tagRow.appendChild(tagName); tagRow.appendChild(tagName);
tagRow.appendChild(alias);
tagRow.appendChild(category); if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(tagCount); tagRow.appendChild(tagCount);
this.tagsList.appendChild(tagRow); this.tagsList.appendChild(tagRow);
} }
@@ -574,21 +600,14 @@ class AutocompleteUI {
let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale; let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale;
let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;; let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;;
const maxWidth = Math.min(rootRect.width, viewportWidth / 2);
const naturalHeight = rootRect.height; const naturalHeight = rootRect.height;
//Horizontal Collision Detection and Adjustment
if (leftPosition + maxWidth > viewportWidth - margin.right) {
leftPosition = viewportWidth - maxWidth - margin.right;
}
if (leftPosition < margin.left) {
leftPosition = margin.left;
}
// Vertical Collision Detection and Adjustment // Vertical Collision Detection and Adjustment
const availableSpaceBelow = viewportHeight - topPosition - margin.bottom; const availableSpaceBelow = viewportHeight - topPosition - margin.bottom;
const availableSpaceAbove = caretTop - margin.top; const availableSpaceAbove = caretTop - margin.top;
let needsVerticalScroll = false;
let maxHeight = rootRect.height;
if (naturalHeight <= availableSpaceBelow) { if (naturalHeight <= availableSpaceBelow) {
// Fits perfectly below the caret // Fits perfectly below the caret
@@ -600,14 +619,15 @@ class AutocompleteUI {
topPosition = caretTop - naturalHeight - margin.top; topPosition = caretTop - naturalHeight - margin.top;
} else { } else {
// Doesn't fit perfectly either below or above, needs scrolling. // Doesn't fit perfectly either below or above, needs scrolling.
needsVerticalScroll = true;
// Choose the position (above or below) that offers more space. // Choose the position (above or below) that offers more space.
if (availableSpaceBelow >= availableSpaceAbove) { if (availableSpaceBelow >= availableSpaceAbove) {
// Scroll below: topPosition remains as initially calculated // Scroll below: topPosition remains as initially calculated
this.tagsList.style.maxHeight = `${availableSpaceBelow}px`; maxHeight = availableSpaceBelow;
} else { } else {
// Scroll above: Position near the top edge and set max-height // Scroll above: Position near the top edge and set max-height
topPosition = margin.top; topPosition = margin.top;
this.tagsList.style.maxHeight = `${availableSpaceAbove}px`; maxHeight = availableSpaceAbove;
} }
} }
} }
@@ -616,16 +636,30 @@ class AutocompleteUI {
if (topPosition < margin.top) { if (topPosition < margin.top) {
topPosition = margin.top; topPosition = margin.top;
// If pushed down, recalculate max-height if it was set based on top alignment // If pushed down, recalculate max-height if it was set based on top alignment
if (this.tagsList.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { if (availableSpaceBelow < availableSpaceAbove) {
// Recalculate max-height based on space from the top margin // Recalculate max-height based on space from the top margin
this.tagsList.style.maxHeight = `${viewportHeight - margin.top - margin.bottom}px`; maxHeight = viewportHeight - margin.top - margin.bottom;
needsVerticalScroll = true;
} }
} }
// Calculate maxWidth considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = needsVerticalScroll ? getScrollbarWidth() : 0;
const maxWidth = Math.min(rootRect.width + scrollbarWidth, viewportWidth / 2);
// Horizontal Collision Detection and Adjustment
if (leftPosition + maxWidth > viewportWidth - margin.right) {
leftPosition = viewportWidth - maxWidth - margin.right;
}
if (leftPosition < margin.left) {
leftPosition = margin.left;
}
// Apply the calculated position and display the element // Apply the calculated position and display the element
this.root.style.left = `${leftPosition}px`; this.root.style.left = `${leftPosition}px`;
this.root.style.top = `${topPosition}px`; this.root.style.top = `${topPosition}px`;
this.root.style.maxWidth = `${maxWidth}px`; this.root.style.maxWidth = `${maxWidth}px`;
this.tagsList.style.maxHeight = `${maxHeight}px`;
} }
/** Highlights the item (row) at the given index */ /** Highlights the item (row) at the given index */
@@ -1010,3 +1044,16 @@ export class AutocompleteEventHandler {
handleClick(event) { handleClick(event) {
} }
} }
// Export functions for testing
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment
? {
searchCompletionCandidates,
sequentialSearch,
searchWithFlexSearch,
matchWord,
getCurrentPartialTag,
insertTagToTextArea
}
: undefined;
+8 -12
View File
@@ -184,18 +184,8 @@ async function loadTags(csvUrl, siteName) {
updateMaxTagLength(tag.length); updateMaxTagLength(tag.length);
autoCompleteData[siteName].sortedTags.push(tagData); autoCompleteData[siteName].sortedTags.push(tagData);
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
}
}
// Sort by count in descending order // Set the tag and its alias in the maps
autoCompleteData[siteName].sortedTags.sort((a, b) => b.count - a.count);
// Build maps as before, but ensure not to overwrite if already processed from extra files
autoCompleteData[siteName].sortedTags.forEach(tagData => {
if (!autoCompleteData[siteName].tagMap.has(tagData.tag)) {
autoCompleteData[siteName].tagMap.set(tagData.tag, tagData); autoCompleteData[siteName].tagMap.set(tagData.tag, tagData);
if (tagData.alias && Array.isArray(tagData.alias)) { if (tagData.alias && Array.isArray(tagData.alias)) {
tagData.alias.forEach(alias => { tagData.alias.forEach(alias => {
@@ -204,8 +194,11 @@ async function loadTags(csvUrl, siteName) {
} }
}); });
} }
} else {
console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`);
continue;
} }
}); }
} catch (error) { } catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error); console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error);
@@ -450,6 +443,9 @@ async function initializeDataFromCSV(csvListData, source) {
await Promise.all([ await Promise.all([
Promise.all(tagsLoadPromiseFactories.map(factory => factory())) Promise.all(tagsLoadPromiseFactories.map(factory => factory()))
.then(() => { .then(() => {
// Sort by count in descending order
autoCompleteData[source].sortedTags.sort((a, b) => b.count - a.count);
// Build FlexSearch index after tags are loaded // Build FlexSearch index after tags are loaded
return buildFlexSearchIndex(source); return buildFlexSearchIndex(source);
}) })
+58 -32
View File
@@ -190,40 +190,41 @@ async function addExtraSettings() {
} }
// Add extra setting for checking new CSV updates // Add extra setting for checking new CSV updates
app.ui.settings.addSetting({ // Note: Temporarily comment out until changes to remote files can be detected
id: id + ".check_new_csv", // app.ui.settings.addSetting({
defaultValue: null, // id: id + ".check_new_csv",
name: "Check CSV updates", // defaultValue: null,
category: [name, "Misc", "Check new CSV"], // name: "Check CSV updates",
type: () => { // category: [name, "Misc", "Check new CSV"],
const lastCheckSpan = $el("span", { // type: () => {
textContent: lastCheckTimeText, // const lastCheckSpan = $el("span", {
className: "text-sm text-gray-500", // textContent: lastCheckTimeText,
style: { // className: "text-sm text-gray-500",
marginRight: "16px" // style: {
} // marginRight: "16px"
}); // }
// });
const checkButton = $el("button", { // const checkButton = $el("button", {
textContent: "Check now", // textContent: "Check now",
className: "p-button p-component p-button-primary", // className: "p-button p-component p-button-primary",
onclick: async () => { // onclick: async () => {
await performUpdateCheck(checkButton, lastCheckSpan); // await performUpdateCheck(checkButton, lastCheckSpan);
} // }
}); // });
return $el("div", { // return $el("div", {
className: "flex-row items-center gap-2", // className: "flex-row items-center gap-2",
}, [ // }, [
$el("div", { // $el("div", {
className: "p-component", // className: "p-component",
}, [ // }, [
lastCheckSpan, // lastCheckSpan,
checkButton, // checkButton,
]), // ]),
]); // ]);
} // }
}); // });
} }
/** /**
@@ -281,6 +282,7 @@ app.registerExtension({
settingValues.tagSource = newVal; settingValues.tagSource = newVal;
} }
}, },
// --- Autocomplete Settings --- // --- Autocomplete Settings ---
{ {
id: id + ".use_fast_search", id: id + ".use_fast_search",
@@ -304,6 +306,17 @@ app.registerExtension({
settingValues.enableModels = newVal; settingValues.enableModels = newVal;
} }
}, },
{
id: id + ".replace_underscore_with_space",
name: "Replace '_' with 'Space'",
tooltip: "This setting also affects related tags display.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Replace Underscore with Space"],
onChange: (newVal, oldVal) => {
settingValues.replaceUnderscoreWithSpace = newVal;
}
},
{ {
id: id + ".max_suggestions", id: id + ".max_suggestions",
name: "Max suggestions", name: "Max suggestions",
@@ -382,6 +395,19 @@ app.registerExtension({
onChange: (newVal, oldVal) => { onChange: (newVal, oldVal) => {
settingValues.enableRelatedTags = newVal; settingValues.enableRelatedTags = newVal;
} }
},
// --- Display settings ---
{
id: id + ".display_hide_alias",
name: "Hide Alias",
tooltip: "Hide alias in the autocomplete and related tags display",
type: "boolean",
defaultValue: false,
category: [name, "Display", "Hide Alias"],
onChange: (newVal, oldVal) => {
settingValues.hideAlias = newVal;
}
} }
] ]
}); });
+34 -29
View File
@@ -4,6 +4,7 @@ import {
extractTagsFromTextArea, extractTagsFromTextArea,
findAllTagPositions, findAllTagPositions,
getCurrentTagRange, getCurrentTagRange,
getScrollbarWidth,
getViewportMargin, getViewportMargin,
isLongText, isLongText,
isValidTag, isValidTag,
@@ -410,17 +411,12 @@ class RelatedTagsUI {
.find((tagData) => tagData !== undefined); .find((tagData) => tagData !== undefined);
if (!tagData) { if (!tagData) {
tagData = new TagData({ // Create a dummy TagData if not found
tag: this.currentTag, tagData = new TagData(this.currentTag, null, 0, [], TagSource.Danbooru);
source: TagSource.Danbooru,
category: 'unknown',
count: 0,
alias: [],
});
} }
const tagText = this.currentTag; const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const categoryText = TagCategory[tagData.source][tagData.category]; const aliasText = tagData.alias.join(', ');
// Update header text with current tag // Update header text with current tag
this.headerText.innerHTML = ''; // Clear previous content this.headerText.innerHTML = ''; // Clear previous content
@@ -428,15 +424,15 @@ class RelatedTagsUI {
const tagName = document.createElement('span'); const tagName = document.createElement('span');
tagName.classList.add('related-tags-header-tag-name', tagData.source); tagName.classList.add('related-tags-header-tag-name', tagData.source);
tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}`; tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}\nAlias: ${aliasText}`;
tagName.dataset.tagCategory = categoryText; tagName.dataset.tagCategory = categoryText;
if (tagData.source && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) { if (tagData.source && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) {
const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`; const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`;
tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' tagName.innerHTML = settingValues.tagSourceIconPosition == 'left'
? `${tagSourceIconHtml} ${tagText}` ? `${tagSourceIconHtml} ${tagData.tag}`
: `${tagText} ${tagSourceIconHtml}`; : `${tagData.tag} ${tagSourceIconHtml}`;
} else { } else {
tagName.textContent += tagText; tagName.textContent += tagData.tag;
} }
@@ -447,8 +443,8 @@ class RelatedTagsUI {
this.headerAlias.innerHTML = ''; this.headerAlias.innerHTML = '';
// Add alias if available // Add alias if available
if (tagData.alias?.length > 0) { if (aliasText.length > 0 && !settingValues.hideAlias) {
this.headerAlias.textContent = tagData.alias.join(', '); this.headerAlias.textContent = aliasText;
this.headerAlias.style.display = 'block'; this.headerAlias.style.display = 'block';
} }
@@ -485,6 +481,9 @@ class RelatedTagsUI {
return; return;
} }
// Toggle column class based on settings
this.tagsContainer.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target); const existingTags = extractTagsFromTextArea(this.target);
// Create tag rows // Create tag rows
@@ -503,6 +502,7 @@ class RelatedTagsUI {
*/ */
#createTagElement(tagData, isExisting) { #createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div'); const tagRow = document.createElement('div');
tagRow.classList.add('related-tag-item', tagData.source); tagRow.classList.add('related-tag-item', tagData.source);
@@ -524,33 +524,30 @@ class RelatedTagsUI {
alias.className = 'related-tag-alias'; alias.className = 'related-tag-alias';
// Display alias if available // Display alias if available
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`; alias.textContent = `${aliasText}`;
alias.title = tagData.alias.join(', '); // Full alias on hover alias.title = aliasText; // Full alias on hover
} }
// Category
const category = document.createElement('span');
category.className = `related-tag-category`;
category.textContent = `${categoryText.substring(0, 2)}`;
// Similarity // Similarity
const similarity = document.createElement('span'); const similarity = document.createElement('span');
similarity.className = 'related-tag-similarity'; similarity.className = 'related-tag-similarity';
similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`;
// Create tooltip with more info // Create tooltip with more info
let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}`; let tooltipText = `Similarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}\nCategory: ${categoryText}`;
if (tagData.alias && tagData.alias.length > 0) { if (aliasText.length > 0) {
tooltipText += `\nAlias: ${tagData.alias.join(', ')}`; tooltipText += `\nAlias: ${aliasText}`;
} }
tagRow.title = tooltipText; tagRow.title = tooltipText;
// Add cells to row // Add cells to row
tagRow.appendChild(tagName); tagRow.appendChild(tagName);
tagRow.appendChild(alias);
tagRow.appendChild(category); if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(similarity); tagRow.appendChild(similarity);
return tagRow; return tagRow;
@@ -580,7 +577,7 @@ class RelatedTagsUI {
const newHeaderRect = this.header.getBoundingClientRect(); const newHeaderRect = this.header.getBoundingClientRect();
if(this.relatedTags.length > 0){ if (this.relatedTags.length > 0) {
this.tagsContainer.style.maxHeight = `${placementArea.height - newHeaderRect.height}px`; this.tagsContainer.style.maxHeight = `${placementArea.height - newHeaderRect.height}px`;
} }
@@ -669,6 +666,10 @@ class RelatedTagsUI {
area.y = targetRect.bottom; area.y = targetRect.bottom;
} }
// Calculate width considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = area.height < elemHeight ? getScrollbarWidth() : 0;
area.width = Math.min(elemWidth + scrollbarWidth, maxWidth);
// Adjust x position to avoid overflow // Adjust x position to avoid overflow
area.x = Math.min(area.x, viewportWidth - area.width - margin.right); area.x = Math.min(area.x, viewportWidth - area.width - margin.right);
} else { } else {
@@ -685,6 +686,10 @@ class RelatedTagsUI {
area.x = targetRect.right; area.x = targetRect.right;
} }
// Calculate width considering scrollbar width if vertical scrolling is needed
const scrollbarWidth = area.height < elemHeight ? getScrollbarWidth() : 0;
area.width = Math.min(area.width + scrollbarWidth, viewportWidth - margin.left - margin.right);
// Adjust y position to avoid overflow // Adjust y position to avoid overflow
area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom); area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom);
} }
+1 -1
View File
@@ -14,7 +14,7 @@ function createTagEncoder() {
cache: true, cache: true,
// filter: new Set(['and', 'to', 'be', 'on']), // filter: new Set(['and', 'to', 'be', 'on']),
replacer: [/(?<=[a-zA-Z\)])_$/, ''], // Remove trailing underscores after letters/parentheses replacer: [/(?<=[a-zA-Z\)])_$/, ''], // Remove trailing underscores after letters/parentheses
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \n]/ // Split on underscores between words, parentheses, spaces, and newlines split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \-\n]/ // Split on underscores between words, parentheses, spaces, hyphens, and newlines
}); });
} }
+4
View File
@@ -9,6 +9,7 @@ export const settingValues = {
maxSuggestions: 10, maxSuggestions: 10,
enableModels: true, // Enable Lora and Embedding suggestions enableModels: true, // Enable Lora and Embedding suggestions
useFastSearch: false, useFastSearch: false,
replaceUnderscoreWithSpace: true, // Replace underscores with spaces in tag insertion
// Related tags feature settings // Related tags feature settings
enableRelatedTags: true, enableRelatedTags: true,
@@ -16,6 +17,9 @@ export const settingValues = {
relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical' relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical'
relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click' relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click'
// Display settings
hideAlias: false, // Hide alias in the autocomplete and related tags display
// Internal logic settings // Internal logic settings
_useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong _useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong
+47 -4
View File
@@ -223,23 +223,31 @@ export function normalizeTagToSearch(str) {
* Converts underscores to spaces only if the tag contains at least one letter or number, * Converts underscores to spaces only if the tag contains at least one letter or number,
* and is not a wildcard call (e.g., "__wildcard__"). * and is not a wildcard call (e.g., "__wildcard__").
* Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls. * Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls.
* For multi-tags (multiple tags with commas), parentheses are not escaped to preserve prompt weight syntax.
* @param {string} str * @param {string} str
* @returns {string} * @returns {string}
*/ */
export function normalizeTagToInsert(str) { export function normalizeTagToInsert(str) {
if (!str) return str; if (!str) return str;
// Check if multi-tag string
const isMultiTag = str.includes(',');
// Check if the string contains at least one letter or number
if (isContainsLetterOrNumber(str)) { if (isContainsLetterOrNumber(str)) {
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4; const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
if (!isWildcardCall) { if (!isWildcardCall && settingValues.replaceUnderscoreWithSpace) {
// If doesn't wildcard call, replace underscores with spaces // If doesn't wildcard call and setting is enabled, replace underscores with spaces
return escapeParentheses(str.replace(/_/g, " ")); const result = str.replace(/_/g, " ");
// Only escape parentheses for single tags, not multi-tags
return isMultiTag ? result : escapeParentheses(result);
} }
} }
// Otherwise, keep it as is (e.g., ""^_^", "__wildcard__") // Otherwise, keep it as is (e.g., ""^_^", "__wildcard__")
return escapeParentheses(str); // Only escape parentheses for single tags, not multi-tags
return isMultiTag ? str : escapeParentheses(str);
} }
/** /**
@@ -584,3 +592,38 @@ export function getViewportMargin() {
right: rightBarRect.width, right: rightBarRect.width,
}; };
} }
// Cache for scrollbar width measurement
let _cachedScrollbarWidth;
/**
* Measures and returns the scrollbar width for the current browser.
* Creates a temporary element to measure the scrollbar width accurately.
* @returns {number} The scrollbar width in pixels
*/
export function getScrollbarWidth() {
// Return cached value if available
if (_cachedScrollbarWidth !== undefined) {
return _cachedScrollbarWidth;
}
// Create a temporary div with scrollbar
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.position = 'absolute';
outer.style.overflow = 'scroll';
outer.style.top = '-9999px';
outer.style.width = '100px';
document.body.appendChild(outer);
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
// Cache the result
_cachedScrollbarWidth = outer.offsetWidth - inner.offsetWidth;
document.body.removeChild(outer);
return _cachedScrollbarWidth;
}