Compare commits

...
Author SHA1 Message Date
newtextdoc1111 17bad4da60 fix: Bump version to 1.9.0 2025-12-01 02:30:04 +09:00
newtextdoc1111 9d12afb48c Merge pull request #55 from newtextdoc1111/dev
Merge Dev
2025-12-01 02:21:27 +09:00
newtextdoc1111 091ec15b2a feat: Improve related tags selection with F1 key wiki access support 2025-12-01 01:41:53 +09:00
newtextdoc1111 c87d92d7f8 docs: Add "Use Trailing Comma" setting doc 2025-12-01 01:34:35 +09:00
newtextdoc1111 5d5da100e1 docs: Add Wiki page opening feature doc 2025-11-30 03:56:08 +09:00
newtextdoc1111 85b86c0618 feat: Add wiki icon functionality and related methods for tag information (#45) 2025-11-30 03:37:07 +09:00
newtextdoc1111 1f93ca0259 fix: Update color variables in CSS 2025-11-29 22:01:55 +09:00
newtextdoc1111 e0486313a6 test: Update test cases for useTrailingComma setting 2025-11-27 00:22:41 +09:00
newtextdoc1111 11b4eb48f0 Merge pull request #54 from BlakeNeko/feature/add_trailing_comma
Feature/add trailing comma
2025-11-27 00:04:59 +09:00
BlakeNeko 156d569614 refactor: Update condition to use 'useTrailingComma' setting instead of 'addTrailingComma' 2025-11-26 10:05:19 +08:00
BlakeNeko 4fdef7f436 refactor: Rename 'Add Trailing Comma' to 'Use Trailing Comma' and update in localization files 2025-11-26 10:04:26 +08:00
BlakeNeko 8189b2b75d refactor: Replace 'Add Trailing Comma' setting with 'Use Trailing Comma' 2025-11-26 10:01:41 +08:00
BlakeNeko 5a76096c7e feat: Add en/zh/zh-TW translation for "addTrailingComma" option 2025-11-23 11:32:28 +08:00
BlakeNeko b6d4fb5590 feat: Add setting for trailing comma in auto-formatting 2025-11-23 11:28:45 +08:00
newtextdoc1111 3f84125262 fix: Bump version  to 1.8.0 2025-11-22 17:56:55 +09:00
newtextdoc1111 906ba108cc fix: Add "AutoFormatter" category to localization files 2025-11-22 17:51:47 +09:00
newtextdoc1111 6d54bbc7ca Merge pull request #51 from newtextdoc1111/dev
Merge dev
2025-11-22 17:32:21 +09:00
newtextdoc1111 bda6f4fa9d docs Add node link 2025-11-22 17:30:36 +09:00
newtextdoc1111 2237b0cb52 docs: Add "Auto Format Trigger" doc 2025-11-22 17:20:40 +09:00
newtextdoc1111 6e23ad89b6 feat: Added translation for "Auto Format Trigger" option 2025-11-22 17:19:19 +09:00
newtextdoc1111 389a79cac9 Merge pull request #50 from newtextdoc1111/feature/auto_format_blocklist
Feature/auto format blocklist
2025-11-22 16:52:07 +09:00
newtextdoc1111 1c4e081f9a refactor: Comment out debug logs 2025-11-22 16:49:42 +09:00
newtextdoc1111 9c57062011 docs: Update shouldAutoFormat doc comment 2025-11-22 16:49:24 +09:00
newtextdoc1111 38975c11ab refactor: Extract node info retrieval into a separate function for cleaner event handlers 2025-11-22 00:05:14 +09:00
newtextdoc1111 8459e3dac9 test: Add unit tests for auto-formatting functions 2025-11-21 19:25:44 +09:00
newtextdoc1111 e7eab66d8f feat: Enhance shouldAutoFormat logic to include single-letter placeholders in numeric checks 2025-11-21 19:25:35 +09:00
newtextdoc1111 26772ac8dd feat: Add manual formatting trigger and settings for auto-formatting 2025-11-21 19:05:22 +09:00
newtextdoc1111 f68c0c7dc2 feat: Implement auto-formatting block logic 2025-11-20 22:00:15 +09:00
newtextdoc1111 79fb35ff8c docs: Add Auto Formatter feature documentation 2025-11-20 17:05:03 +09:00
newtextdoc1111 4f776d676a feat: Added translation for "Enable auto-format" option 2025-11-20 16:32:58 +09:00
newtextdoc1111 9f8078c408 refactor: Reorganized the AutoFormatterEventHandler class to make it consistent with other code 2025-11-20 16:12:39 +09:00
newtextdoc1111 ab2e83ec38 Merge pull request #49 from BlakeNeko/feature/auto_format_on_blur
Feature/#48 auto format on blur
2025-11-18 20:01:16 +09:00
BlakeNeko 2715f1f165 Merge branch 'newtextdoc1111:main' into feature/auto_format_on_blur 2025-11-18 11:53:17 +08:00
BlakeNeko 56f506110d refactor: use AutoFormatterEventHandler to maintain architectural consistency, format code indent 2025-11-18 11:15:02 +08:00
BlakeNeko 6785a2b6a5 Merge branch 'feature/auto_format_on_blur' of https://github.com/BlakeNeko/ComfyUI-Autocomplete-Plus into feature/auto_format_on_blur 2025-11-15 23:32:43 +08:00
BlakeNeko e8b0e44cf4 fix: Remove an invalid setting option 2025-11-15 23:31:59 +08:00
BlakeNeko 4b64fae70d fix: Simplify auto format setting name and description 2025-11-15 23:17:28 +08:00
BlakeNeko e622148b27 fix: Update settings to support internationalization properly 2025-11-15 22:25:48 +08:00
BlakeNeko 7a279d71c3 refactor: Moving the enableAutoFormat setting 2025-11-15 22:15:59 +08:00
BlakeNeko ee8b265173 feat: Preserving the previous cursor position 2025-11-15 22:13:59 +08:00
BlakeNeko 80d254c6b4 refactor: Move formatPromptText and formatTextareaOnBlur functions from utils.js to auto-formatter.js 2025-11-15 22:11:46 +08:00
newtextdoc1111 8f6d451fef fix: Bump version to 1.7.2 2025-11-13 22:14:50 +09:00
newtextdoc1111 f2920add1d fix: Add tagCategory data attribute for CSS styling 2025-11-13 18:44:43 +09:00
newtextdoc1111 dbb5f66a1f fix: Bump version to 1.7.1 2025-11-13 17:06:04 +09:00
newtextdoc1111 cacee5ddad fix: Use index-based approach for tag data retrieval 2025-11-13 17:01:03 +09:00
BlakeNeko 7f23b02812 feat: add a helper function and trigger it on textarea blur 2025-11-13 11:24:57 +08:00
BlakeNeko edbea2bb43 feat: add auto format settings 2025-11-13 11:21:14 +08:00
BlakeNeko 70f916b825 feat: implement prompt auto-formatter function
- Splits text by lines and cleans up tags separated by commas
- Removes redundant spaces and empty tags
- Ensures each valid line ends with a comma and space
- Preserves empty lines for readability
2025-11-13 11:11:02 +08:00
newtextdoc1111 42f5e73515 fix: Bump version  to 1.7.0 2025-11-12 22:09:17 +09:00
newtextdoc1111 52e5d2fd5f Merge pull request #47 from newtextdoc1111/dev
Merge Dev
2025-11-12 22:03:26 +09:00
newtextdoc1111 397c90275f docs: Add auto insert comma setting to README 2025-11-12 21:37:14 +09:00
21 changed files with 907 additions and 89 deletions
+26
View File
@@ -13,6 +13,7 @@
- **:zap:No setup required**: Automatically downloads CSV data optimized for Danbooru tags.
- **:mag:Autocomplete**: Displays tag suggestions in real-time based on your input as you type.
- **:file_cabinet:Related Tags Display**: Shows a list of tags highly related to the selected tag.
- **:triangular_ruler:Auto Formatter**: Automatically formats prompt text when the textarea loses focus, cleaning up extra spaces and commas.
- **:earth_asia:Multilingual Support**: Supports input completion in Japanese, Chinese, and Korean.
- **:computer_mouse:Intuitive Operation**:
- Supports both mouse and keyboard operations.
@@ -42,6 +43,7 @@ When you type in a text input area, tags that partially match the text are displ
- Tags that have already been entered are displayed grayed out.
- You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings.
- Supports autocomplete for Lora and Embedding inputs. You can enable/disable this feature in the settings.
- Clicking the 📖 icon opens the tag's Wiki page. If a tag is selected via keyboard, you can open it with the `F1` key.
## Related Tags
@@ -52,9 +54,24 @@ When you select any tag in a text input area, a list of highly related tags is d
- The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space.
- You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header.
- You can toggle the pinned state of the displayed related tags using the "📌|🎯" button in the header. To close the UI when pinned, press the Esc key.
- Clicking the tag in the header opens the tag's Wiki page.
- Tags that have already been entered are displayed grayed out. If you try to insert a grayed-out tag, the already entered tag will instead be selected.
- You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`.
## Auto Formatter
When a text input area loses focus (e.g., by clicking outside or pressing Tab), the prompt text is automatically formatted. This feature improves readability when editing large amounts of text.
Detailed behavior is as follows:
- Automatically adds a comma and space after each tag for proper separation
- Removes extra commas and spaces between tags
- You can manually trigger formatting using the keyboard shortcut `Alt+Shift+F` (keybinding can be customized in ComfyUI settings)
- You can enable/disable this feature in the settings
> [!NOTE]
> Auto-formatting is disabled for certain nodes to prevent errors.
> Example: [Power Puter (rgthree)](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter) `code` field, [LoraLoaderBlockWeight (Inspire)](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) `block_vector` field
## CSV Data
Two basic CSV data files are required for operation. These are managed on [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) and are automatically downloaded when ComfyUI is first launched after installation, so no setup is required.
@@ -150,6 +167,7 @@ For example, by preparing the following CSV, you can quickly insert correspondin
- **Enable Autocomplete**: Enable/disable the autocomplete feature.
- **Max suggestions**: Maximum number of autocomplete suggestions to display.
- **Auto-Insert Comma**: Automatically insert a comma after tags when inserting from autocomplete.
- **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.
- **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details).
@@ -165,6 +183,14 @@ For example, by preparing the following CSV, you can quickly insert correspondin
- **Hide Alias**: Hide/show the Alias ​​column in autocomplete and related tags (default is show)
### Auto Formatter
- **Enable Auto Format**: Enable/disable the automatically format prompt text when the textarea loses focus.
- **Auto Format Trigger**: Choose when formatting is applied.
- **Auto**: Format automatically when leaving text field
- **Manual**: Format only via keyboard shortcut (default: `Alt+Shift+F`)
- **Use Trailing Comma**: If enabled, ensures all lines end with a trailing comma when formatting. If disabled, removes trailing commas.
## Advanced Settings
### Disabling CSV Update Check on Startup
+26
View File
@@ -11,6 +11,7 @@
- **:zap:セットアップ不要**: Danbooruタグに最適化された CSV データを自動でダウンロード
- **:mag:オートコンプリート**: テキスト入力中に、入力内容に基づいてタグ候補をリアルタイムで表示
- **:file_cabinet:関連タグ表示機能**: 選択したタグと関連性の高いタグを一覧表示
- **:triangular_ruler:自動フォーマット**: テキストエリアがフォーカスを失った際に、プロンプトテキストを自動的にフォーマットし、余分なスペースやカンマを整理
- **:earth_asia:多言語対応**: 日本語、中国語、韓国語での入力補完をサポート
- **:computer_mouse:直感的な操作**:
- マウスとキーボードどちらの操作にも対応
@@ -40,6 +41,7 @@
- 入力済みのタグはグレーアウトで表示されます
- Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます
- LoraとEmbeddingの入力補完に対応しています。設定から有効・無効を切り替えられます
- 「📖」アイコンをクリックするとタグのWikiページを開きます。キーボードで選択中の場合は `F1` キーで開くことが出来ます
## 関連タグ
@@ -50,9 +52,24 @@
- 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます
- ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます
- ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します
- ヘッダーのタグをクリックするとタグのWikiページを開きます
- 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします
- `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます
## 自動フォーマット
テキスト入力エリアがフォーカスを失った際(例:外側をクリック、Tabキーを押す)、プロンプトテキストを自動的にフォーマットする機能です。大量のテキストを編集する際に可読性を向上させる事が出来ます。
詳細な動作は以下になります:
- 各タグの後に自動的にカンマとスペースを追加し、適切に区切ります
- タグ間の余分なカンマとスペースを削除します
- キーボードショートカット `Alt+Shift+F` を使って手動でフォーマットすることも出来ます(キーバインドはComfyUIの設定から変更可能です)
- 設定から機能の有効化/無効化を切り替えられます
> [!NOTE]
> 一部のノードはエラーに繋がるため、自動フォーマットは実行されません。
> 例: [Power Puter (rgthree)](https://github.com/rgthree/rgthree-comfy/wiki/Node:-Power-Puter) の `code` フィールド、 [LoraLoaderBlockWeight (Inspire)](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) の `block_vector` フィールド
## CSV データ
動作には基本となる CSV データが2つ必要です。これらは [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) で管理されており、 ComfyUI にインストール後の初回起動時に自動でダウンロードされるのでセットアップは不要です。
@@ -148,6 +165,7 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Auto-Insert Comma**: タグの挿入時、末尾にカンマを追加する
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
@@ -163,6 +181,14 @@ worst_quality,5,9999999,
- **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
### 自動フォーマット
- **Enable Auto Format**: テキストエリアがフォーカスを失った際にプロンプトテキストを自動的にフォーマットする機能の有効化/無効化
- **Auto Format Trigger**: フォーマットを適用するタイミングを選択します
- **自動**: テキスト欄からフォーカスが外れた際に自動でフォーマットします
- **手動**: キーボードショートカットでのみフォーマットします(デフォルト: `Alt+Shift+F`)
- **行末にカンマを使用**: 有効にすると、フォーマット時にすべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます
## 上級者向け設定
### 起動時のCSV更新チェックを無効化する
+2 -1
View File
@@ -4,6 +4,7 @@
"Tag Source": "Tag Source",
"Autocompletion": "Autocompletion",
"Related Tags": "Related Tags",
"Display": "Display"
"Display": "Display",
"AutoFormatter": "AutoFormatter"
}
}
+16 -1
View File
@@ -36,7 +36,7 @@
"tooltip": "This setting also affects related tags display."
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "Auto Insert Comma",
"name": "Auto-Insert Comma",
"tooltip": "Automatically insert a comma after tags when inserting from autocomplete."
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
@@ -70,5 +70,20 @@
"AutocompletePlus_Display_HideAlias": {
"name": "Hide Alias",
"tooltip": "Hide alias column in the autocomplete and related tags display."
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "Auto Format Trigger",
"tooltip": "Auto: Format automatically when leaving text field.\nManual: Format only via keyboard shortcut. default keybind: (Alt+Shift+F)",
"options": {
"auto": "Auto",
"manual": "Manual"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "Use Trailing Comma",
"tooltip": "When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas."
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "Enable Auto Format"
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
"Tag Source": "タグソース",
"Autocompletion": "オートコンプリート",
"Related Tags": "関連タグ",
"Display": "表示"
"Display": "表示",
"AutoFormatter": "自動フォーマット"
}
}
+15
View File
@@ -70,5 +70,20 @@
"AutocompletePlus_Display_HideAlias": {
"name": "エイリアスを非表示",
"tooltip": "オートコンプリートと関連タグで表示されるエイリアス列を非表示にします"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自動フォーマットのトリガー",
"tooltip": "自動: テキスト欄からフォーカスが外れた際に自動でフォーマットします\n手動: キーボードショートカットでのみフォーマットします。デフォルトのキーバインド: (Alt+Shift+F)",
"options": {
"auto": "自動",
"manual": "手動"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "行末にカンマを使用",
"tooltip": "有効にすると、すべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "自動フォーマット機能の有効化"
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
"Tag Source": "標籤來源",
"Autocompletion": "自動完成",
"Related Tags": "相關標籤",
"Display": "顯示"
"Display": "顯示",
"AutoFormatter": "自動格式化"
}
}
+15
View File
@@ -70,5 +70,20 @@
"AutocompletePlus_Display_HideAlias": {
"name": "隱藏別名",
"tooltip": "隱藏自動完成和相關標籤顯示中的別名欄"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自動格式化觸發方式",
"tooltip": "自動: 離開文字方塊時自動格式化\n手動: 僅透過鍵盤快速鍵格式化。預設快速鍵: (Alt+Shift+F)",
"options": {
"auto": "自動",
"manual": "手動"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "在行末添加逗號",
"tooltip": "啟用時,確保所有行都以逗號結尾。\n停用時,移除結尾逗號。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "啟用自動格式化"
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
"Tag Source": "标签来源",
"Autocompletion": "自动完成",
"Related Tags": "相关标签",
"Display": "显示"
"Display": "显示",
"AutoFormatter": "自动格式化"
}
}
+15
View File
@@ -70,5 +70,20 @@
"AutocompletePlus_Display_HideAlias": {
"name": "隐藏别名",
"tooltip": "隐藏自动完成和相关标签显示中的别名列"
},
"AutocompletePlus_AutoFormatter_Trigger": {
"name": "自动格式化触发方式",
"tooltip": "自动: 离开文本框时自动格式化\n手动: 仅通过键盘快捷键格式化。默认快捷键: (Alt+Shift+F)",
"options": {
"auto": "自动",
"manual": "手动"
}
},
"AutocompletePlus_AutoFormatter_UseTrailingComma": {
"name": "在行末添加逗号",
"tooltip": "启用时,确保所有行都以逗号结尾。\n禁用时,移除结尾逗号。"
},
"AutocompletePlus_AutoFormatter_EnableAutoFormat": {
"name": "启用自动格式化"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.6.1"
version = "1.9.0"
license = {file = "LICENSE"}
dependencies = ["",]
+156
View File
@@ -0,0 +1,156 @@
import {
formatPromptText,
__test__
} from "../../web/js/auto-formatter.js";
import { settingValues } from "../../web/js/settings.js";
const {
shouldAutoFormat
} = __test__;
describe('AutoFormatter Functions', () => {
describe('shouldAutoFormat', () => {
const mockNodeInfo = (nodeType, inputName) => ({
nodeType,
inputName
});
test('should return false for empty text', () => {
expect(shouldAutoFormat('', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat(' ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return false for blocklisted nodes', () => {
expect(shouldAutoFormat('some text,', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('some text,', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
test('should return false for numeric data or single-letter placeholders', () => {
expect(shouldAutoFormat('0,0,0,1,1,1', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('0.5, -1.2, 0.8', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('A,B,R', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('X, 1.5, Y', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return true for text with "word + comma" pattern', () => {
expect(shouldAutoFormat('1girl, blue hair,', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat('tag1, tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
});
test('should return false if "word + comma" pattern is not found', () => {
expect(shouldAutoFormat('hello world', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('tag1 tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
});
describe('formatPromptText', () => {
// Store original setting value to restore after tests
const originalUseTrailingComma = settingValues.useTrailingComma;
afterEach(() => {
// Restore original setting after each test
settingValues.useTrailingComma = originalUseTrailingComma;
});
describe('with useTrailingComma enabled', () => {
beforeEach(() => {
settingValues.useTrailingComma = true;
});
test('should format text by adding comma and space after tags', () => {
const input = 'tag1,tag2,tag3';
const expected = 'tag1, tag2, tag3, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove extra spaces around tags', () => {
const input = ' tag1 , tag2 ';
const expected = 'tag1, tag2, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve special syntax like weights', () => {
const input = '(tag1:1.2), [tag2]';
// Note: The current implementation splits by comma.
// If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]".
// Then joins with ", ".
const expected = '(tag1:1.2), [tag2], ';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle multiple lines', () => {
const input = 'tag1, tag2\ntag3, tag4';
const expected = 'tag1, tag2, \ntag3, tag4, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should keep empty lines unchanged', () => {
const input = 'tag1, tag2\n\ntag3, tag4';
const expected = 'tag1, tag2, \n\ntag3, tag4, ';
expect(formatPromptText(input)).toBe(expected);
});
test('should not modify text that already has trailing comma', () => {
const input = 'tag1, tag2, ';
const expected = 'tag1, tag2, ';
expect(formatPromptText(input)).toBe(expected);
});
});
describe('with useTrailingComma disabled', () => {
beforeEach(() => {
settingValues.useTrailingComma = false;
});
test('should format text by adding comma and space after tags without trailing comma', () => {
const input = 'tag1,tag2,tag3';
const expected = 'tag1, tag2, tag3';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove extra spaces around tags without trailing comma', () => {
const input = ' tag1 , tag2 ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve special syntax like weights without trailing comma', () => {
const input = '(tag1:1.2), [tag2]';
// Note: The current implementation splits by comma.
// If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]".
// Then joins with ", ".
const expected = '(tag1:1.2), [tag2]';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle multiple lines without trailing comma', () => {
const input = 'tag1, tag2\ntag3, tag4';
const expected = 'tag1, tag2\ntag3, tag4';
expect(formatPromptText(input)).toBe(expected);
});
test('should keep empty lines unchanged without trailing comma', () => {
const input = 'tag1, tag2\n\ntag3, tag4';
const expected = 'tag1, tag2\n\ntag3, tag4';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove existing trailing comma when disabled', () => {
const input = 'tag1, tag2, ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
});
test('should handle empty input', () => {
expect(formatPromptText('')).toBe('');
expect(formatPromptText(null)).toBe(null);
expect(formatPromptText(undefined)).toBe(undefined);
});
test('should handle input with only spaces', () => {
expect(formatPromptText(' ')).toBe(' ');
});
});
});
+85 -22
View File
@@ -1,15 +1,21 @@
/* Light Theme */
body {
--autocomplete-plus-text-color-cat-blue: var(--p-blue-600);
--autocomplete-plus-text-color-cat-blue: var(--p-blue-700);
--autocomplete-plus-text-color-cat-red: var(--p-red-600);
--autocomplete-plus-text-color-cat-purple: var(--p-purple-600);
--autocomplete-plus-text-color-cat-green: var(--p-green-600);
--autocomplete-plus-text-color-cat-green: var(--p-green-700);
--autocomplete-plus-text-color-cat-yellow: var(--p-yellow-600);
--autocomplete-plus-text-color-cat-gray: var(--p-gray-600);
--autocomplete-plus-text-color-cat-sky: var(--p-sky-500);
--autocomplete-plus-text-color-cat-sky: var(--p-sky-600);
--autocomplete-plus-text-color-cat-orange: var(--p-orange-600);
--autocomplete-plus-text-color-cat-white: var(--p-neutral-700);
--autocomplete-plus-text-color-disabled: var(--p-gray-400);
--autocomplete-plus-list-color-even: var(--p-neutral-300);
--autocomplete-plus-list-color-odd: var(--p-neutral-400);
--wiki-icon-text-color: var(--input-text);
--wiki-icon-opacity-normal: 0.7;
--wiki-icon-opacity-hover: 1.0;
--wiki-icon-opacity-disabled: 0;
}
/* Dark Theme */
@@ -24,6 +30,12 @@ body.dark-theme {
--autocomplete-plus-text-color-cat-orange: var(--p-orange-400);
--autocomplete-plus-text-color-cat-white: var(--p-neutral-200);
--autocomplete-plus-text-color-disabled: var(--p-gray-500);
--autocomplete-plus-list-color-even: var(--p-neutral-800);
--autocomplete-plus-list-color-odd: var(--p-neutral-900);
--wiki-icon-text-color: var(--input-text);
--wiki-icon-opacity-normal: 0.5;
--wiki-icon-opacity-hover: 0.9;
--wiki-icon-opacity-disabled: 0;
}
#autocomplete-plus-root {
@@ -97,16 +109,17 @@ body.dark-theme {
display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto;
grid-template-columns: max-content max-content 1fr auto;
overflow-y: auto;
}
#autocomplete-plus-list.no-alias {
grid-template-columns: max-content auto;
grid-template-columns: max-content max-content auto;
}
.autocomplete-plus-item {
display: grid;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
grid-column: 1 / -1;
grid-template-columns: subgrid;
@@ -115,23 +128,22 @@ body.dark-theme {
.autocomplete-plus-item span {
align-content: center;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
}
/* Alternating row colors */
.autocomplete-plus-item:nth-child(even) span {
background-color: var(--comfy-menu-bg);
.autocomplete-plus-item:nth-child(even) {
background-color: var(--autocomplete-plus-list-color-even);
}
.autocomplete-plus-item:nth-child(odd) span {
background-color: var(--comfy-menu-secondary-bg);
.autocomplete-plus-item:nth-child(odd) {
background-color: var(--autocomplete-plus-list-color-odd);
}
.autocomplete-plus-item.selected span {
.autocomplete-plus-item.selected {
background-color: var(--border-color);
}
.autocomplete-plus-item:hover span {
.autocomplete-plus-item:hover {
background-color: var(--comfy-hover-bg);
}
@@ -160,6 +172,28 @@ body.dark-theme {
text-align: right;
}
.autocomplete-plus-item .autocomplete-plus-wiki-icon {
padding: 0 8px;
color: var(--wiki-icon-text-color);
cursor: pointer;
font-size: 1.2em;
opacity: var(--wiki-icon-opacity-normal);
text-align: center;
vertical-align: middle;
}
.autocomplete-plus-item .autocomplete-plus-wiki-icon:hover {
filter: brightness(1.1);
opacity: var(--wiki-icon-opacity-hover);
}
/* Hide wiki icon for tags without wiki pages */
.autocomplete-plus-item .autocomplete-plus-wiki-icon.disabled {
cursor: default;
opacity: var(--wiki-icon-opacity-disabled);
pointer-events: none;
}
/* Related Tags UI Styles */
#related-tags-root {
position: absolute;
@@ -236,7 +270,7 @@ body.dark-theme {
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border-color);
background-color: var(--comfy-menu-bg);
background-color: var(--autocomplete-plus-list-color-even);
color: var(--descrip-text);
gap: 8px;
}
@@ -255,10 +289,16 @@ body.dark-theme {
}
.related-tags-header-tag-name {
cursor: pointer;
font-weight: bold;
word-break: break-all;
}
.related-tags-header-tag-name.disabled {
cursor: default;
text-decoration: none;
}
.related-tags-header-tag-name .autocomplete-plus-tag-icon-svg {
width: 1em;
height: 1em;
@@ -296,16 +336,17 @@ body.dark-theme {
display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto;
grid-template-columns: max-content 1fr auto;
grid-template-columns: max-content max-content 1fr auto;
overflow-y: auto;
}
#related-tags-list.no-alias {
grid-template-columns: max-content auto;
grid-template-columns: max-content max-content auto;
}
.related-tag-item {
display: grid;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
grid-column: 1 / -1;
grid-template-columns: subgrid;
@@ -315,22 +356,21 @@ body.dark-theme {
.related-tag-item span {
align-content: center;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
}
.related-tag-item:nth-child(even) span {
background-color: var(--comfy-menu-bg);
.related-tag-item:nth-child(even) {
background-color: var(--autocomplete-plus-list-color-even);
}
.related-tag-item:nth-child(odd) span {
background-color: var(--comfy-menu-secondary-bg);
.related-tag-item:nth-child(odd) {
background-color: var(--autocomplete-plus-list-color-odd);
}
.related-tag-item:hover span {
.related-tag-item:hover {
background-color: var(--p-form-field-filled-hover-background);
}
.related-tag-item.selected span {
.related-tag-item.selected {
background-color: var(--border-color);
}
@@ -355,6 +395,29 @@ body.dark-theme {
white-space: nowrap;
}
.related-tag-item .related-tag-wiki-icon {
padding: 0 8px;
color: var(--wiki-icon-text-color);
cursor: pointer;
font-size: 1.2em;
opacity: var(--wiki-icon-opacity-normal);
text-align: center;
transition: opacity 0.1s ease;
vertical-align: middle;
}
.related-tag-item .related-tag-wiki-icon:hover {
filter: brightness(1.1);
opacity: var(--wiki-icon-opacity-hover);
}
/* Hide wiki icon for tags without wiki pages */
.related-tag-item .related-tag-wiki-icon.disabled {
cursor: default;
opacity: var(--wiki-icon-opacity-disabled);
pointer-events: none;
}
.related-tags-message {
width: 100%;
padding: 12px;
+199
View File
@@ -0,0 +1,199 @@
import { settingValues } from './settings.js';
import { NodeInfo } from './node-info.js';
/**
* Determines if the text content should be auto-formatted.
*
* Format conditions:
* 1. Skip formatting if node is in blocklist
* 2. Skip formatting if text contains only numbers or single letters (separated by commas)
* 3. Format if text contains "word + comma" pattern at least twice
* 4. Otherwise, don't format
*
* @param {NodeInfo} nodeInfo - The node information.
* @returns {boolean} - True if the text should be formatted, false otherwise.
*/
function shouldAutoFormat(text, nodeInfo) {
if (!text || text.trim().length === 0) return false;
// 1. Check if the node name is in the blocklist
const blocklist = [
["Power Puter (rgthree)", "code"],
["LoraLoaderBlockWeight //Inspire", "block_vector"]
];
const isBlocklisted = blocklist.some(([type, input]) =>
type === nodeInfo.nodeType && input === nodeInfo.inputName
);
if (isBlocklisted) {
// console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName} => blocklisted`);
return false;
} else {
// console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName}`);
}
const trimmedText = text.trim();
// 2. Check if the text is purely numeric data or single-letter placeholders with commas
// (e.g., "0,0,0,1,1,1" or "0.5, -1.2, 0.8" or "A,B,R" for LoRA Block Weight)
const elements = trimmedText.split(',').map(el => el.trim());
const isSingleLetterOrNumeric = elements.every(el => {
if (/^[A-Za-z]$/.test(el)) return true;
if (/^-?\d+(\.\d+)?$/.test(el)) return true;
return false;
});
if (isSingleLetterOrNumeric && elements.length > 0) {
return false; // Don't format numeric data or single-letter template patterns
}
// 3. Check if the text contains the pattern "word + comma"
const wordCommaPattern = /\w+\s*,/g;
const matches = trimmedText.match(wordCommaPattern);
if (matches == null) {
return false;
}
return true; // Text should be formatted
}
/**
* Format the prompt text: add a comma and space after each tag, and remove extra spaces.
* Preserve special syntax such as weights (tag:1.2), parentheses, and wildcards.
* @param {string} text - The original text.
* @returns {string} - The formatted text.
*/
export function formatPromptText(text) {
if (!text || text.trim().length === 0) return text;
// Split text into individual lines for processing
const lines = text.split('\n');
const formattedLines = [];
for (const line of lines) {
const trimmedLine = line.trim();
// Keep empty lines unchanged
if (trimmedLine.length === 0) {
formattedLines.push('');
continue;
}
// Split the line by commas into raw tag segments
const rawTags = trimmedLine.split(',');
const cleanedTags = [];
for (const tag of rawTags) {
// Trim spaces around each tag
const trimmedTag = tag.trim();
// Keep only non-empty tags
if (trimmedTag.length > 0) {
cleanedTags.push(trimmedTag);
}
}
// Rejoin cleaned tags with ", "
let formattedLine = cleanedTags.join(', ');
// Add a trailing comma and space if the setting is enabled and line contains valid tags
if (formattedLine.length > 0 && settingValues.useTrailingComma) {
formattedLine += ', ';
}
formattedLines.push(formattedLine);
}
// Rejoin all lines with newline characters
return formattedLines.join('\n');
}
/**
* Format the content of a textarea when it loses focus.
* @param {HTMLTextAreaElement} textarea - The target textarea element.
*/
export function formatTextareaOnBlur(textarea) {
const originalText = textarea.value;
const formattedText = formatPromptText(originalText);
if (originalText !== formattedText) {
const cursorPos = textarea.selectionStart;
textarea.value = formattedText;
// Try to preserve cursor position
const newCursorPos = Math.min(cursorPos, formattedText.length);
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
}
/**
* Event handler for auto-formatting functionality
*/
export class AutoFormatterEventHandler {
constructor() { }
/**
* Handle blur event to trigger auto-formatting
* @param {Event} event - The blur event
*/
handleBlur(event, nodeInfo) {
if (
settingValues.enableAutoFormat &&
settingValues.autoFormatTrigger === 'auto' &&
event.target.tagName === 'TEXTAREA'
) {
const textarea = event.target;
const text = textarea.value;
// Check if the content should be auto-formatted
if (shouldAutoFormat(text, nodeInfo)) {
formatTextareaOnBlur(textarea);
}
}
}
// Placeholder methods to maintain consistency with other event handlers
handleInput(event) { }
handleFocus(event) { }
handleKeyDown(event) { }
handleKeyUp(event) { }
handleMouseMove(event) { }
handleClick(event) { }
/**
* Format textarea content via manual trigger (e.g., keyboard shortcut)
* @param {HTMLTextAreaElement} textarea - The textarea element to format
* @param {NodeInfo} nodeInfo - The node information
* @returns {boolean} - True if formatting was performed, false otherwise
*/
applyFormatTextarea(textarea, nodeInfo) {
if (!textarea || textarea.tagName !== 'TEXTAREA') {
return false;
}
if (!settingValues.enableAutoFormat) {
return false;
}
const text = textarea.value;
if (shouldAutoFormat(text, nodeInfo)) {
formatTextareaOnBlur(textarea);
return true;
}
return false;
}
}
// Export functions for testing
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment
? {
shouldAutoFormat
}
: undefined;
+54 -20
View File
@@ -1,6 +1,5 @@
import {
ModelTagSource,
TagCategory,
TagData,
autoCompleteData,
getEnabledTagSourceInPriorityOrder
@@ -19,7 +18,8 @@ import {
getViewportMargin,
getScrollbarWidth,
IconSvgHtmlString,
addWeightToLora
addWeightToLora,
openTagWikiUrl
} from './utils.js';
import { settingValues } from './settings.js';
@@ -263,7 +263,7 @@ function getCurrentPartialTag(inputElement) {
if (!inputElement) {
return "";
}
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
@@ -306,19 +306,19 @@ function getCurrentPartialTag(inputElement) {
* Inserts the selected tag into the textarea, replacing the partial tag,
* making the change undoable.
* @param {HTMLTextAreaElement} inputElement
* @param {TagData} tagDataToInsert The raw tag string to insert.
* @param {TagData} tagDataToInsert
*/
function insertTagToTextArea(inputElement, tagDataToInsert) {
if (!inputElement || !tagDataToInsert) {
return;
}
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
const tagRange = getCurrentTagRange(text, cursorPos);
let tagStart, tagEnd, currentTag;
if (!tagRange) {
// Fallback: insert at cursor position
tagStart = cursorPos;
@@ -327,7 +327,7 @@ function insertTagToTextArea(inputElement, tagDataToInsert) {
} else {
({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange);
}
const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos;
@@ -407,9 +407,22 @@ class AutocompleteUI {
// Add event listener for clicks on items
this.tagsList.addEventListener('mousedown', (e) => {
// Check if wiki icon was clicked first
const wikiIcon = e.target.closest('.autocomplete-plus-wiki-icon');
if (wikiIcon && !wikiIcon.classList.contains('disabled')) {
openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
// Check if row was clicked (existing behavior)
const row = e.target.closest('.autocomplete-plus-item');
if (row && row.dataset.tag) {
this.#insertTag(row.dataset);
if (row && row.dataset.index !== undefined) {
const tagData = this.candidates[parseInt(row.dataset.index, 10)];
if (tagData) {
this.#insertTag(tagData);
}
e.preventDefault(); // Prevent focus loss from input
e.stopPropagation();
}
@@ -477,7 +490,7 @@ class AutocompleteUI {
/** Selects the currently highlighted item
* @returns {TagData|null} The selected tag data.
*/
getSelectedTag() {
getSelectedTagData() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) {
return this.candidates[this.selectedIndex];
}
@@ -500,26 +513,27 @@ class AutocompleteUI {
const existingTags = extractTagsFromTextArea(this.target);
const currentTag = getCurrentPartialTag(this.target);
this.candidates.forEach((tagData) => {
this.candidates.forEach((tagData, index) => {
const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1);
const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag);
this.#createTagElement(tagData, isExistingTag);
this.#createTagElement(tagData, index, isExistingTag);
});
}
/**
* Creates a tag element for the autocomplete list.
* @param {TagData} tagData
* @param {number} tagDataIndex
* @param {boolean} isExisting
*/
#createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
#createTagElement(tagData, tagDataIndex, isExisting) {
const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div');
tagRow.classList.add('autocomplete-plus-item', tagData.source);
tagRow.dataset.tag = tagData.tag;
tagRow.dataset.tagCategory = categoryText;
tagRow.dataset.index = tagDataIndex;
tagRow.dataset.tagCategory = categoryText; // Used to color by CSS
// Tag icon and name
const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`;
@@ -539,6 +553,18 @@ class AutocompleteUI {
tagName.classList.add('autocomplete-plus-already-exists');
}
// Wiki icon
const wikiIcon = document.createElement('span');
wikiIcon.className = 'autocomplete-plus-wiki-icon';
if (tagData.hasWikiPage) {
wikiIcon.dataset.tagName = tagData.tag;
wikiIcon.dataset.tagSource = tagData.source;
wikiIcon.textContent = '📖'
wikiIcon.title = 'Open wiki page';
} else {
wikiIcon.classList.add('disabled');
}
// Alias
const alias = document.createElement('span');
alias.className = 'autocomplete-plus-alias';
@@ -554,7 +580,7 @@ class AutocompleteUI {
tagCount.className = `autocomplete-plus-tag-count`;
tagCount.textContent = formatCountHumanReadable(tagData.count);
// Create tooltip with more info
// Create tooltip with more info
let tooltipText = `Count: ${tagData.count}\nCategory: ${categoryText}`;
if (aliasText.length > 0) {
tooltipText += `\nAlias: ${aliasText}`;
@@ -562,6 +588,7 @@ class AutocompleteUI {
tagRow.title = tooltipText;
tagRow.appendChild(tagName);
tagRow.appendChild(wikiIcon);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
@@ -668,7 +695,7 @@ class AutocompleteUI {
/** Highlights the item (row) at the given index */
#highlightItem() {
if (!this.getSelectedTag()) return; // No valid selection
if (!this.getSelectedTagData()) return; // No valid selection
const items = this.tagsList.children; // Get rows from tbody
for (let i = 0; i < items.length; i++) {
@@ -969,12 +996,19 @@ export class AutocompleteEventHandler {
case 'Enter':
case 'Tab':
const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) {
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTagData() !== null) {
event.preventDefault();
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag());
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTagData());
}
this.autocompleteUI.hide();
break;
case 'F1':
event.preventDefault();
const tagData = this.autocompleteUI.getSelectedTagData();
if (tagData && tagData.hasWikiPage) {
openTagWikiUrl(tagData.source, tagData.tag);
}
break;
case 'Escape':
event.preventDefault();
this.autocompleteUI.hide();
+17
View File
@@ -72,6 +72,23 @@ export class TagData {
this.source = source;
}
/**
* Get the category text
* @returns {string}
*/
get categoryText() {
return TagCategory[this.source][this.category] || "unknown";
}
/**
* Check if the tag has a wiki page
* @returns {boolean}
*/
get hasWikiPage() {
return Object.values(TagSource).includes(this.source)
&& ['general', 'artist', 'copyright', 'character', 'species', 'lore'].includes(this.categoryText);
}
}
class AutocompleteData {
+123 -16
View File
@@ -6,23 +6,27 @@ import { loadCSS } from "./utils.js";
import { TagSource, loadDataAsync } from "./data.js";
import { AutocompleteEventHandler } from "./autocomplete.js";
import { RelatedTagsEventHandler } from "./related-tags.js";
import { AutoFormatterEventHandler } from "./auto-formatter.js";
import { NodeInfo } from "./node-info.js";
// --- Constants ---
const id = "AutocompletePlus";
const name = "Autocomplete Plus";
// --- Module-level variables ---
const autocompleteEventHandler = new AutocompleteEventHandler();
const relatedTagsEventHandler = new RelatedTagsEventHandler();
const autoFormatterEventHandler = new AutoFormatterEventHandler();
const attachedElementNodeInfoMap = new WeakMap(); // Map to track attached elements and their node info
// --- Functions ---
/**
* Initialize event handlers for the autocomplete and related tags features.
*/
function initializeEventHandlers() {
const autocompleteEventHandler = new AutocompleteEventHandler();
const relatedTagsEventHandler = new RelatedTagsEventHandler();
const attachedElements = new WeakSet(); // Keep track of elements that have listeners attached
// Function to attach listeners
function attachListeners(element) {
if (attachedElements.has(element)) return; // Prevent double attachment
function attachListeners(element, nodeInfo) {
if (attachedElementNodeInfoMap.has(element)) return; // Prevent double attachment
element.addEventListener('input', handleInput);
element.addEventListener('focus', handleFocus);
@@ -35,7 +39,7 @@ function initializeEventHandlers() {
element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('click', handleClick);
attachedElements.add(element); // Mark as attached
attachedElementNodeInfoMap.set(element, nodeInfo); // Mark as attached and store node info
}
// Attempt Widget Override as the primary method
@@ -48,12 +52,14 @@ function initializeEventHandlers() {
// Check if the widget has an inputEl and if it's a TEXTAREA
// This is to ensure we are targeting multiline text inputs, related to '.comfy-multiline-input'
if (result && result.widget && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA') {
if (result && result.widget
&& result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA' && !result.widget.inputEl.readOnly) {
const widgetConfig = inputData && inputData[1] ? inputData[1] : {};
// Future: Add checks for Autocomplete Plus specific configurations if needed
// e.g., if (widgetConfig["AutocompletePlus.enabled"] === false) return result;
attachListeners(result.widget.inputEl);
const nodeInfo = new NodeInfo(node.comfyClass || node.constructor.name, inputName);
attachListeners(result.widget.inputEl, nodeInfo);
}
return result;
};
@@ -73,9 +79,11 @@ function initializeEventHandlers() {
targetSelectors.forEach(selector => {
// Check if the added node itself matches or contains matching elements
if (node.matches(selector)) {
attachListeners(node);
attachListeners(node, new NodeInfo('Fallback', 'unknown'));
} else {
node.querySelectorAll(selector).forEach(attachListeners);
node.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
}
});
}
@@ -85,48 +93,72 @@ function initializeEventHandlers() {
// Initial scan for existing elements
targetSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(attachListeners);
document.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
});
// Start observing the document body for changes
observer.observe(document.body, { childList: true, subtree: true });
}
/**
* Get NodeInfo for the event target element
* @param {Event} event - The DOM event
* @returns {Object|null} NodeInfo object or undefined if not found
*/
function getNodeInfo(event) {
const nodeInfo = attachedElementNodeInfoMap.get(event.target);
if (!nodeInfo) {
console.warn('[Autocomplete-Plus] Node info not found for element in ', event.target);
return null;
}
return nodeInfo;
}
function handleInput(event) {
autocompleteEventHandler.handleInput(event);
relatedTagsEventHandler.handleInput(event);
autoFormatterEventHandler.handleInput(event);
}
function handleFocus(event) {
autocompleteEventHandler.handleFocus(event);
relatedTagsEventHandler.handleFocus(event);
autoFormatterEventHandler.handleFocus(event);
}
function handleBlur(event) {
const nodeInfo = getNodeInfo(event); // Get node info to pass to auto formatter
autocompleteEventHandler.handleBlur(event);
relatedTagsEventHandler.handleBlur(event);
autoFormatterEventHandler.handleBlur(event, nodeInfo);
}
function handleKeyDown(event) {
autocompleteEventHandler.handleKeyDown(event);
relatedTagsEventHandler.handleKeyDown(event);
autoFormatterEventHandler.handleKeyDown(event);
}
function handleKeyUp(event) {
autocompleteEventHandler.handleKeyUp(event);
relatedTagsEventHandler.handleKeyUp(event);
autoFormatterEventHandler.handleKeyUp(event);
}
// New event handler for mousemove to show related tags on hover
function handleMouseMove(event) {
autocompleteEventHandler.handleMouseMove(event);
relatedTagsEventHandler.handleMouseMove(event);
autoFormatterEventHandler.handleMouseMove(event);
}
// New event handler for click to show related tags
function handleClick(event) {
autocompleteEventHandler.handleClick(event);
relatedTagsEventHandler.handleClick(event);
autoFormatterEventHandler.handleClick(event);
}
}
@@ -244,6 +276,46 @@ app.registerExtension({
await loadDataAsync();
},
// --- Commands ---
commands: [
{
id: id + ".formatPrompt",
label: name + ": Format Prompt",
function: () => {
const activeEl = document.activeElement;
if (!activeEl || activeEl.tagName !== 'TEXTAREA') {
// console.debug('[Autocomplete-Plus] Format command: No textarea is currently focused');
return;
}
const nodeInfo = attachedElementNodeInfoMap.get(activeEl);
if (!nodeInfo) {
console.warn('[Autocomplete-Plus] Format command: Node info not found for focused textarea');
// Use fallback NodeInfo
const fallbackNodeInfo = new NodeInfo('Unknown', 'unknown');
autoFormatterEventHandler.applyFormatTextarea(activeEl, fallbackNodeInfo);
return;
}
const formatted = autoFormatterEventHandler.applyFormatTextarea(activeEl, nodeInfo);
if (formatted) {
// console.debug('[Autocomplete-Plus] Format command: Formatting applied');
} else {
// console.debug('[Autocomplete-Plus] Format command: Formatting skipped (blocklisted or not applicable)');
}
}
}
],
// --- Keybindings ---
keybindings: [
{
combo: { key: "f", alt: true, shift: true },
commandId: id + ".formatPrompt"
}
],
// One the Settings Screen, displays reverse order in same category
settings: [
// --- Tag source Settings ---
@@ -318,11 +390,11 @@ app.registerExtension({
},
{
id: id + ".Autocompletion.AutoInsertComma",
name: "Auto Insert Comma",
name: "Auto-Insert Comma",
tooltip: "Automatically insert a comma after tags when inserting from autocomplete.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Auto Insert Comma"],
category: [name, "Autocompletion", "Auto-Insert Comma"],
onChange: (newVal, oldVal) => {
settingValues.autoInsertComma = newVal;
}
@@ -416,5 +488,40 @@ app.registerExtension({
settingValues.hideAlias = newVal;
}
},
// --- Auto format settings ---
{
id: id + '.AutoFormatter.UseTrailingComma',
name: 'Use Trailing Comma',
tooltip: 'When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas.',
type: 'boolean',
defaultValue: false,
category: [name, 'AutoFormatter', 'Use Trailing Comma'],
onChange: (newVal, oldVal) => {
settingValues.useTrailingComma = newVal;
},
},
{
id: id + '.AutoFormatter.Trigger',
name: 'Auto Format Trigger',
tooltip: 'Auto: Format automatically when leaving text field.\nManual: Format only via keyboard shortcut. default keybind: (Alt+Shift+F)',
type: 'combo',
options: ['auto', 'manual'],
defaultValue: 'auto',
category: [name, 'AutoFormatter', 'Auto Format Trigger'],
onChange: (newVal, oldVal) => {
settingValues.autoFormatTrigger = newVal;
},
},
{
id: id + '.AutoFormatter.EnableAutoFormat',
name: 'Enable Auto Format',
type: 'boolean',
defaultValue: true,
category: [name, 'AutoFormatter', 'Enable Auto Format'],
onChange: (newVal, oldVal) => {
settingValues.enableAutoFormat = newVal;
},
},
]
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Class to hold information about the node attached to an input element.
* Used to control behavior based on node information.
*/
export class NodeInfo {
/**
* @param {string} nodeType - The type/class name of the node
* @param {string} inputName - The name of the input widget
*/
constructor(nodeType, inputName) {
this.nodeType = nodeType;
this.inputName = inputName;
}
}
+88 -25
View File
@@ -1,4 +1,4 @@
import { TagCategory, TagData, TagSource, autoCompleteData } from './data.js';
import { TagCategory, TagData, TagSource, autoCompleteData, getEnabledTagSourceInPriorityOrder } from './data.js';
import { settingValues } from './settings.js';
import {
extractTagsFromTextArea,
@@ -10,6 +10,7 @@ import {
isValidTag,
normalizeTagToInsert,
normalizeTagToSearch,
openTagWikiUrl
} from './utils.js';
// --- RelatedTags Logic ---
@@ -95,6 +96,8 @@ function searchRelatedTags(tag) {
category: tagData.category,
source: tagData.source,
count: tagData.count,
categoryText: tagData.categoryText,
hasWikiPage: tagData.hasWikiPage
});
});
@@ -278,11 +281,31 @@ class RelatedTagsUI {
// Timer ID for auto-refresh
this.autoRefreshTimerId = null;
// Add click handler for wiki link in header tag name
this.headerText.addEventListener('mousedown', (e) => {
const tagNameEl = e.target.closest('.related-tags-header-tag-name');
if (tagNameEl && !tagNameEl.classList.contains('disabled')) {
openTagWikiUrl(tagNameEl.dataset.tagSource, tagNameEl.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
});
// Add click handler for tag selection
this.tagsContainer.addEventListener('mousedown', (e) => {
// Check if wiki icon was clicked first
const wikiIcon = e.target.closest('.related-tag-wiki-icon');
if (wikiIcon && !wikiIcon.classList.contains('disabled')) {
openTagWikiUrl(wikiIcon.dataset.tagSource, wikiIcon.dataset.tagName);
e.preventDefault();
e.stopPropagation();
return;
}
const row = e.target.closest('.related-tag-item');
if (row && row.dataset.tag) {
this.#insertTag(row.dataset.tag);
if (row && row.dataset.tagName) {
this.#insertTag(row.dataset.tagName);
e.preventDefault();
e.stopPropagation();
}
@@ -322,9 +345,6 @@ class RelatedTagsUI {
this.target = textareaElement;
this.relatedTags = searchRelatedTags(this.currentTag);
if (this.selectedIndex == -1) {
this.selectedIndex = 0; // Reset selection to the first item
}
this.#updateHeader();
this.#updateContent();
@@ -366,10 +386,18 @@ class RelatedTagsUI {
}
}
/** Moves the selection up or down */
/** Moves the selection up or down
* @param {direction} 1 for down, -1 for up
*/
navigate(direction) {
if (this.relatedTags.length === 0) return;
this.selectedIndex += direction;
if (this.selectedIndex == -1) {
// Initialize selection based on navigation direction
this.selectedIndex = direction == 1 ? 0 : this.relatedTags.length - 1;
} else {
this.selectedIndex += direction;
}
if (this.selectedIndex < 0) {
this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom
@@ -379,10 +407,27 @@ class RelatedTagsUI {
this.#highlightItem();
}
/** Selects the currently highlighted item */
getSelectedTag() {
/**
* Get TagData of the current tag
* @returns {TagData|null}
*/
getCurrentTagData() {
for (const source of getEnabledTagSourceInPriorityOrder()) {
if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) {
return autoCompleteData[source].tagMap.get(this.currentTag);
}
}
return null;
}
/**
* Selects the currently highlighted item
* @return {TagData|null}
*/
getSelectedTagData() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) {
return this.relatedTags[this.selectedIndex].tag;
return this.relatedTags[this.selectedIndex];
}
return null; // No valid selection
@@ -401,14 +446,7 @@ class RelatedTagsUI {
* Updates header content
*/
#updateHeader() {
// Find the tag data for the current tag
let tagData = Object.values(TagSource)
.map((source) => {
if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) {
return autoCompleteData[source].tagMap.get(this.currentTag);
}
})
.find((tagData) => tagData !== undefined);
let tagData = this.getCurrentTagData();
if (!tagData) {
// Create a dummy TagData if not found
@@ -426,6 +464,8 @@ class RelatedTagsUI {
tagName.classList.add('related-tags-header-tag-name', tagData.source);
tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}\nAlias: ${aliasText}`;
tagName.dataset.tagCategory = categoryText;
tagName.dataset.tagSource = tagData.source;
tagName.dataset.tagName = tagData.tag;
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>`;
tagName.innerHTML = settingValues.tagSourceIconPosition == 'left'
@@ -435,6 +475,9 @@ class RelatedTagsUI {
tagName.textContent += tagData.tag;
}
if (!tagData.hasWikiPage) {
tagName.classList.add('disabled');
}
this.headerText.appendChild(tagName);
@@ -501,12 +544,12 @@ class RelatedTagsUI {
* @returns {HTMLTableRowElement} The tag row element
*/
#createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
const tagRow = document.createElement('div');
tagRow.classList.add('related-tag-item', tagData.source);
tagRow.dataset.tag = tagData.tag;
tagRow.dataset.tagName = tagData.tag;
tagRow.dataset.tagCategory = categoryText;
// Tag name
@@ -519,6 +562,18 @@ class RelatedTagsUI {
tagName.classList.add('related-tag-already-exists');
}
// Wiki icon
const wikiIcon = document.createElement('span');
wikiIcon.className = 'related-tag-wiki-icon';
if (tagData.hasWikiPage) {
wikiIcon.dataset.tagName = tagData.tag;
wikiIcon.dataset.tagSource = tagData.source;
wikiIcon.textContent = '📖'
wikiIcon.title = 'Open wiki page';
} else {
wikiIcon.classList.add('disabled');
}
// Alias
const alias = document.createElement('span');
alias.className = 'related-tag-alias';
@@ -543,6 +598,7 @@ class RelatedTagsUI {
// Add cells to row
tagRow.appendChild(tagName);
tagRow.appendChild(wikiIcon);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
@@ -588,7 +644,7 @@ class RelatedTagsUI {
/** Highlights the item (row) at the given index */
#highlightItem() {
if (this.getSelectedTag() === null) return; // No valid selection
if (this.getSelectedTagData() === null) return; // No valid selection
const items = this.tagsContainer.children; // Get rows
for (let i = 0; i < items.length; i++) {
@@ -621,9 +677,9 @@ class RelatedTagsUI {
}
insertSelectedTag() {
const selectedTag = this.getSelectedTag();
const selectedTag = this.getSelectedTagData();
if (selectedTag) {
this.#insertTag(selectedTag);
this.#insertTag(selectedTag.tag);
}
}
@@ -769,13 +825,20 @@ export class RelatedTagsEventHandler {
break;
case 'Enter':
case 'Tab':
if (this.relatedTagsUI.getSelectedTag() !== null) {
if (this.relatedTagsUI.getSelectedTagData() !== null) {
event.preventDefault(); // Prevent Tab from changing focus
this.relatedTagsUI.insertSelectedTag();
} else if (!this.relatedTagsUI.isPinned) { // If nothing selected and not pinned, hide the panel
this.relatedTagsUI.hide();
}
break;
case 'F1':
event.preventDefault();
const tagData = this.relatedTagsUI.getSelectedTagData() || this.relatedTagsUI.getCurrentTagData();
if (tagData && tagData.hasWikiPage) {
openTagWikiUrl(tagData.source, tagData.tag);
}
break;
case 'Escape':
event.preventDefault();
this.relatedTagsUI.hide();
+5
View File
@@ -21,6 +21,11 @@ export const settingValues = {
// Display settings
hideAlias: false, // Hide alias in the autocomplete and related tags display
// Auto format settings
enableAutoFormat: true,
autoFormatTrigger: 'auto', // Options: 'auto' (format on blur + shortcut), 'manual' (shortcut only)
useTrailingComma: false, // Whether to add comma at the end of each line
// Internal logic settings
_useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong
+44
View File
@@ -654,3 +654,47 @@ export function getScrollbarWidth() {
return _cachedScrollbarWidth;
}
/**
* Opens a wiki URL in a new browser tab for the given tag.
* @param {string} tagSource - The source of the tag ('danbooru', 'e621', 'embeddings', 'lora')
* @param {string} tagName - The name of the tag
* @returns {boolean} - True if URL was opened, false if not applicable
*/
export function openTagWikiUrl(tagSource, tagName) {
if (!tagSource || !tagName) {
console.warn('[Autocomplete-Plus] Cannot open wiki: missing tagSource or tagName');
return false;
}
let wikiUrl = null;
switch (tagSource) {
case 'danbooru':
const danbooruTag = encodeURIComponent(tagName.replace(/ /g, '_'));
wikiUrl = `https://danbooru.donmai.us/wiki_pages/${danbooruTag}`;
break;
case 'e621':
const e621Tag = encodeURIComponent(tagName.replace(/ /g, '_'));
wikiUrl = `https://e621.net/wiki_pages/${e621Tag}`;
break;
case 'embeddings':
case 'lora':
// console.debug('[Autocomplete-Plus] Model tags do not have wiki pages');
return false;
default:
console.warn(`[Autocomplete-Plus] Unknown tag source: ${tagSource}`);
return false;
}
if (wikiUrl) {
// console.debug(`[Autocomplete-Plus] Opening wiki URL: ${wikiUrl}`);
window.open(wikiUrl, '_blank', 'noopener,noreferrer');
return true;
}
return false;
}