Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d9fdfc50 | ||
|
|
504b681ab8 | ||
|
|
aca0ed6326 | ||
|
|
ab941ab329 | ||
|
|
c4e408eede | ||
|
|
f8c86203e4 | ||
|
|
5a4ded44b8 | ||
|
|
295d0c0cdc | ||
|
|
37ce793208 | ||
|
|
2386fc7aa0 | ||
|
|
937c5bb283 | ||
|
|
512dd88d7a | ||
|
|
096314933d | ||
|
|
05c890ee37 | ||
|
|
d4725dfebc |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
|
||||||
|
|
||||||
## 上級者向け設定
|
## 上級者向け設定
|
||||||
|
|
||||||
|
|||||||
Generated
+1501
-739
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
@@ -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.1"
|
version = "1.6.0"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = ["",]
|
dependencies = ["",]
|
||||||
|
|
||||||
|
|||||||
@@ -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, ');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -282,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",
|
||||||
@@ -305,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",
|
||||||
@@ -383,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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user