Compare commits

..
Author SHA1 Message Date
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 2193 additions and 877 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
+3 -2
View File
@@ -133,6 +133,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 +144,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
+3 -2
View File
@@ -131,6 +131,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 +142,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.0"
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, ');
});
});
});
+58
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,58 @@ 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\\)');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should not replace underscores in wildcard syntax regardless of setting', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
// Wildcard syntax should preserve underscores regardless of setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should not replace underscores in symbol-only tags regardless of setting', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
// Symbol-only tags should not be affected by the setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = originalValue;
});
}); });
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;
+14 -15
View File
@@ -154,6 +154,7 @@ async function loadTags(csvUrl, siteName) {
} }
const csvText = await response.text(); const csvText = await response.text();
const lines = csvText.split('\n').filter(line => line.trim().length > 0); const lines = csvText.split('\n').filter(line => line.trim().length > 0);
const totalLines = lines.length;
const startIndex = lines[0].toLowerCase().startsWith(TAGS_CSV_HEADER) ? 1 : 0; const startIndex = lines[0].toLowerCase().startsWith(TAGS_CSV_HEADER) ? 1 : 0;
@@ -268,17 +269,17 @@ async function loadCooccurrence(csvUrl, siteName) {
const startIndex = lines[0].startsWith('tag_a,tag_b,count') ? 1 : 0; const startIndex = lines[0].startsWith('tag_a,tag_b,count') ? 1 : 0;
await processCooccurrenceInChunks(lines, startIndex, autoCompleteData[siteName].cooccurrenceMap, csvUrl, siteName); await processInChunks(lines, startIndex, autoCompleteData[siteName].cooccurrenceMap, csvUrl, siteName);
} catch (error) { } catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${csvUrl}:`, error); console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${csvUrl}:`, error);
} }
} }
/** /**
* Process Co-Occurrence CSV data in chunks to avoid blocking the UI. * Process CSV data in chunks to avoid blocking the UI.
* Modifies the targetMap directly. * Modifies the targetMap directly.
*/ */
function processCooccurrenceInChunks(lines, startIndex, targetMap, csvUrl, siteName) { function processInChunks(lines, startIndex, targetMap, csvUrl, siteName) {
return new Promise((resolve) => { return new Promise((resolve) => {
const CHUNK_SIZE = 10000; const CHUNK_SIZE = 10000;
let i = startIndex; let i = startIndex;
@@ -289,7 +290,7 @@ function processCooccurrenceInChunks(lines, startIndex, targetMap, csvUrl, siteN
for (; i < endIndex; i++) { for (; i < endIndex; i++) {
const line = lines[i]; const line = lines[i];
const columns = line.split(","); const columns = parseCSVLine(line);
if (columns.length >= 3) { if (columns.length >= 3) {
const tagA = columns[0].trim(); const tagA = columns[0].trim();
@@ -299,19 +300,17 @@ function processCooccurrenceInChunks(lines, startIndex, targetMap, csvUrl, siteN
if (!tagA || !tagB || isNaN(count)) continue; if (!tagA || !tagB || isNaN(count)) continue;
// Add tagA -> tagB relationship // Add tagA -> tagB relationship
let subMapA = targetMap.get(tagA); if (!targetMap.has(tagA)) {
if (!subMapA) { targetMap.set(tagA, new Map());
subMapA = new Map();
targetMap.set(tagA, subMapA);
} }
subMapA.set(tagB, count); targetMap.get(tagA).set(tagB, count);
let subMapB = targetMap.get(tagB);
if (!subMapB) { // Add tagB -> tagA relationship (bidirectional)
subMapB = new Map(); if (!targetMap.has(tagB)) {
targetMap.set(tagB, subMapB); targetMap.set(tagB, new Map());
} }
subMapB.set(tagA, count); targetMap.get(tagB).set(tagA, count);
pairCount++; pairCount++;
} }
@@ -543,7 +542,7 @@ async function loadLoras() {
} }
/** /**
* Load all data sources in parallel. * Load all data sources asynchronously.
*/ */
export async function loadDataAsync() { export async function loadDataAsync() {
return Promise.all([ return Promise.all([
+59 -33
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,
]), // ]),
]); // ]);
} // }
}); // });
} }
/** /**
@@ -240,7 +241,7 @@ app.registerExtension({
let rootPath = import.meta.url.replace("js/main.js", ""); let rootPath = import.meta.url.replace("js/main.js", "");
loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete
loadDataAsync(); await loadDataAsync();
}, },
// One the Settings Screen, displays reverse order in same category // One the Settings Screen, displays reverse order in same category
@@ -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;
}
} }
] ]
}); });
+35 -30
View File
@@ -4,6 +4,7 @@ import {
extractTagsFromTextArea, extractTagsFromTextArea,
findAllTagPositions, findAllTagPositions,
getCurrentTagRange, getCurrentTagRange,
getScrollbarWidth,
getViewportMargin, getViewportMargin,
isLongText, isLongText,
isValidTag, isValidTag,
@@ -259,7 +260,7 @@ class RelatedTagsUI {
this.headerControls.appendChild(this.pinBtn); this.headerControls.appendChild(this.pinBtn);
this.header.appendChild(this.headerControls); this.header.appendChild(this.headerControls);
this.root.appendChild(this.header); this.root.appendChild(this.header);
// Create a tbody for the tags // Create a tbody for the tags
@@ -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
+37 -2
View File
@@ -232,8 +232,8 @@ export function normalizeTagToInsert(str) {
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, " ")); return escapeParentheses(str.replace(/_/g, " "));
} }
} }
@@ -584,3 +584,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;
}