Compare commits

..
28 changed files with 1003 additions and 3913 deletions
+6 -16
View File
@@ -12,9 +12,9 @@ jobs:
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '18'
cache: 'npm'
- name: Install dependencies
@@ -28,17 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run stylelint
run: npx stylelint "web/css/*.css"
- uses: actions/checkout@v1
- uses: actions-hub/stylelint@master
env:
PATTERN : 'web/css/*.css'
+5 -51
View File
@@ -2,7 +2,7 @@
## English • [日本語](docs/README_jp.md)
![ss01](https://github.com/user-attachments/assets/45dd0598-4c04-49ab-85f7-33fc9026921c)
![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7)
## Overview
@@ -13,7 +13,6 @@
- **: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.
@@ -43,7 +42,6 @@ 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
@@ -54,24 +52,9 @@ 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.
@@ -135,28 +118,11 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
>[!NOTE]
> If there are multiple user CSV files, they are loaded in alphabetical order. If the same tag exists in multiple files, the one loaded first is retained. The basic CSV is loaded last.
### Bulk Tag Insertion (Pseudo "Chants")
By enclosing multiple tags with `""` (double quotation marks), you can insert frequently used tags in bulk.
This is similar to the **Chants** feature in [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants).
For example, by preparing the following CSV, you can quickly insert corresponding tags by typing `<c:Basic-HighQuality>` or `<c:Basic-Negative>`.
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * Text enclosed in `""` does not escape `()` (parentheses). Tags that originally contain parentheses should be written in the CSV with escaped parentheses. Example: `copyright_(series)` -> `copyright_\(series\)`
> * The alias column also supports `""`, allowing you to assign multiple aliases
## Settings
### Tag Source
> [!NOTE]
> [!TIP]
> The source of tag data such as Danbooru or e621 is called the "tag source".
- **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources.
@@ -167,9 +133,6 @@ 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.
- **String to add before artist tags**: Text to prepend when inserting an artist tag. For Anima models, specify `@`.
- **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).
@@ -178,20 +141,11 @@ For example, by preparing the following CSV, you can quickly insert correspondin
- **Enable Related Tags**: Enable/disable the related tags feature.
- **Max related tags**: Maximum number of related tags to display.
- **Default Display Position**: Default display position when ComfyUI starts.
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click)
- **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click)
### Display
### Miscellaneous
- **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**: When enabled, ensures all lines end with a trailing comma when formatting. If disabled, removes trailing commas.
- **Trim Surrounding Spaces**: When enabled, trim any blank lines or spaces from the beginning and end of the prompt.
- **Check CSV updates**: Click the "Check Now" button to check if new CSV files are available in HuggingFace and download them if necessary.
## Advanced Settings
+5 -51
View File
@@ -1,6 +1,6 @@
# ComfyUI-Autocomplete-Plus
![ss01](https://github.com/user-attachments/assets/45dd0598-4c04-49ab-85f7-33fc9026921c)
![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7)
## 概要
@@ -11,7 +11,6 @@
- **:zap:セットアップ不要**: Danbooruタグに最適化された CSV データを自動でダウンロード
- **:mag:オートコンプリート**: テキスト入力中に、入力内容に基づいてタグ候補をリアルタイムで表示
- **:file_cabinet:関連タグ表示機能**: 選択したタグと関連性の高いタグを一覧表示
- **:triangular_ruler:自動フォーマット**: テキストエリアがフォーカスを失った際に、プロンプトテキストを自動的にフォーマットし、余分なスペースやカンマを整理
- **:earth_asia:多言語対応**: 日本語、中国語、韓国語での入力補完をサポート
- **:computer_mouse:直感的な操作**:
- マウスとキーボードどちらの操作にも対応
@@ -41,7 +40,6 @@
- 入力済みのタグはグレーアウトで表示されます
- Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます
- LoraとEmbeddingの入力補完に対応しています。設定から有効・無効を切り替えられます
- 「📖」アイコンをクリックするとタグのWikiページを開きます。キーボードで選択中の場合は `F1` キーで開くことが出来ます
## 関連タグ
@@ -52,24 +50,9 @@
- 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます
- ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます
- ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合は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 にインストール後の初回起動時に自動でダウンロードされるのでセットアップは不要です。
@@ -133,28 +116,11 @@ worst_quality,5,9999999,
>[!NOTE]
> ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。
### 複数タグの一括挿入機能(疑似 Chants)
`""` (ダブルクォーテーション)で複数タグを囲むことで、よく使うタグを一括で挿入できます。
これは [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete?tab=readme-ov-file#chants) で **Chants** と呼ばれる機能に似ています。
例として以下のCSVを用意することで、`<c:Basic-HighQuality>` や `<c:Basic-Negative>` と入力して対応したタグを素早く挿入出来ます。
**`danbooru_tags_chants.csv`:**
```
"masterpiece, best quality, high quality, highres, ultra-detailed",5,9999999,<c:Basic-HighQuality>
"(worst quality, low quality:1.4), normal quality",5,9999999,<c:Basic-Negative>
```
>[!TIP]
> * `""` で囲まれたテキストは `()` (括弧)がエスケープされません。元々括弧が含まれるタグはエスケープした状態でCSVに記述してください。 例: `copyright_(series)` -> `copyright_\(series\)`
> * エイリアス列も `""` に対応しているので、複数のエイリアスを付けられます
## 設定
### タグソース
> [!NOTE]
> [!TIP]
> Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます
- **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します
@@ -165,9 +131,6 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Auto-Insert Comma**: タグの挿入時、末尾にカンマを追加する
- **Replace '_' with 'Space'**: タグ挿入時にアンダースコアをスペースに置き換えます。この設定は関連タグ表示にも影響します
- **String to add before artist tags**: アーティストタグを挿入する際に付加する文字列。 Anima モデルの場合は「@」を指定します
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
@@ -176,20 +139,11 @@ worst_quality,5,9999999,
- **Enable Related Tags**: 関連タグ機能の有効化/無効化
- **Max related tags**: 関連タグの最大表示件数
- **Default Display Position**: ComfyUI起動時のデフォルト表示位置
- **Related Tags Trigger Mode** : 入力済みのタグの関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
- **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)
### 表示
### その他
- **Hide Alias**: オートコンプリートと関連タグで表示されるエイリアス列の非表示/表示を切り替え(デフォルトは表示です)
### 自動フォーマット
- **Enable Auto Format**: テキストエリアがフォーカスを失った際にプロンプトテキストを自動的にフォーマットする機能の有効化/無効化
- **Auto Format Trigger**: フォーマットを適用するタイミングを選択します
- **自動**: テキスト欄からフォーカスが外れた際に自動でフォーマットします
- **手動**: キーボードショートカットでのみフォーマットします(デフォルト: `Alt+Shift+F`)
- **行末にカンマを使用**: 有効時、フォーマット時にすべての行末がカンマで終わるようになります。\n無効にすると行末のカンマが削除されます
- **先頭・末尾の空白を削除**: 有効時、プロンプトの先頭と末尾にある空行やスペースをすべて削除します
- **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います
## 上級者向け設定
-10
View File
@@ -1,10 +0,0 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "Tag Source",
"Autocompletion": "Autocompletion",
"Related Tags": "Related Tags",
"Display": "Display",
"AutoFormatter": "AutoFormatter"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "Tag Source Icon Position",
"options": {
"left": "Left",
"right": "Right",
"hidden": "Hidden"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "Primary source for 'all' Source",
"tooltip": "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "Autocomplete Tag Source",
"tooltip": "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "all"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "Use Fast Search",
"tooltip": "Tag search processing during text input operates faster, improving responsiveness."
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "Enable Loras and Embeddings"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "String to add before artist tags",
"tooltip": "Text to prepend when inserting an artist tag via autocomplete.\ne.g. '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "Replace '_' with 'Space'",
"tooltip": "This setting also affects related tags display."
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "Auto-Insert Comma",
"tooltip": "Automatically insert a comma after tags when inserting from autocomplete."
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "Max suggestions"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "Enable Autocomplete"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "Related Tags Trigger Mode",
"tooltip": "Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click).",
"options": {
"click": "Click",
"ctrl+Click": "Ctrl+Click"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "Default Display Position",
"tooltip": "Display position (relative to Textarea).",
"options": {
"horizontal": "Horizontal",
"vertical": "Vertical"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "Max related tags"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "Enable Related Tags"
},
"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"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "Trim Surrounding Spaces",
"tooltip": "When enabled, trim any blank lines from the beginning and end of the prompt."
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "タグソース",
"Autocompletion": "オートコンプリート",
"Related Tags": "関連タグ",
"Display": "表示",
"AutoFormatter": "自動フォーマット"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "タグソースのアイコン位置",
"options": {
"left": "左",
"right": "右",
"hidden": "非表示"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "優先して表示するタグソース",
"tooltip": "オートコンプリートのタグソースが「全て」のとき、ここで指定したタグソースが優先して表示されます",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "オートコンプリートのタグソース",
"tooltip": "オートコンプリート候補に表示するタグソース。\n「全て」を選択するとロード済みの全てのタグソースを表示します",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全て"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "高速検索の有効化",
"tooltip": "候補の検索を高速な処理に切り替えます。\n入力時の遅延が改善される場合があります"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "LoraとEmbeddingを候補に表示する"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "アーティストタグの前に追加する文字列",
"tooltip": "オートコンプリートからアーティストタグを挿入する際に付加する文字列。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "'_' を 'スペース' に置き換える",
"tooltip": "この設定は関連タグ表示にも影響します"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "カンマを自動で追加する",
"tooltip": "オートコンプリート確定時、タグの末尾に自動でカンマを追加する"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "候補の最大表示件数"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "オートコンプリート機能の有効化"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "関連タグを表示する操作",
"tooltip": "入力済みのタグの関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック)",
"options": {
"click": "クリック",
"ctrl+Click": "Ctrl+クリック"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "デフォルト表示位置",
"tooltip": "入力中のテキストエリアを基準に、どの位置に表示するか",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "関連タグの最大表示件数"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "関連タグ機能の有効化"
},
"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": "自動フォーマット機能の有効化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "先頭・末尾の空白を削除",
"tooltip": "有効時、プロンプトの先頭と末尾にある空行やスペースをすべて削除します。"
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "標籤來源",
"Autocompletion": "自動完成",
"Related Tags": "相關標籤",
"Display": "顯示",
"AutoFormatter": "自動格式化"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "標籤來源圖示位置",
"options": {
"left": "左",
"right": "右",
"hidden": "隱藏"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "「全部」選擇時的主要來源",
"tooltip": "當自動完成標籤來源為「全部」時,此設定決定哪個來源的標籤優先顯示在建議中。",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "自動完成標籤來源",
"tooltip": "選擇自動完成建議的標籤來源。選擇「全部」將包含所有已載入來源的標籤。",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全部"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "啟用快速搜尋",
"tooltip": "標籤搜尋處理更快,提高文字輸入時的回應速度"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "顯示Lora和Embedding"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "在藝術家標籤前添加的字串",
"tooltip": "透過自動完成插入藝術家標籤時,在標籤前添加的文字。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "將'_'替換為'空格'",
"tooltip": "此設定也會影響相關標籤顯示"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "自動插入逗號",
"tooltip": "從自動完成插入標籤時自動在標籤後添加逗號"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "最大提示數"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "啟用自動完成"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "顯示相關標籤的操作",
"tooltip": "指定觸發顯示已輸入標籤的相關標籤的操作(僅點擊、Ctrl+點擊)",
"options": {
"click": "點擊",
"ctrl+Click": "Ctrl+點擊"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "預設顯示位置",
"tooltip": "相對於文字區域的顯示位置",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "最大相關標籤數"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "啟用相關標籤"
},
"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": "啟用自動格式化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "去除首尾空白",
"tooltip": "啟用時,清除提示詞開頭和結尾的所有空行和空格。"
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"settingsCategories": {
"Autocomplete Plus": "Autocomplete Plus",
"Tag Source": "标签来源",
"Autocompletion": "自动完成",
"Related Tags": "相关标签",
"Display": "显示",
"AutoFormatter": "自动格式化"
}
}
-97
View File
@@ -1,97 +0,0 @@
{
"AutocompletePlus_TagSource_IconPosition": {
"name": "标签来源图标位置",
"options": {
"left": "左",
"right": "右",
"hidden": "隐藏"
}
},
"AutocompletePlus_TagSource_PrimaryTagSource": {
"name": "'全部'选择时的主要来源",
"tooltip": "当自动完成标签来源为\"全部\"时,此设置决定哪个来源的标签优先显示在建议中。",
"options": {
"danbooru": "Danbooru",
"e621": "e621"
}
},
"AutocompletePlus_TagSource": {
"name": "自动完成标签来源",
"tooltip": "选择自动完成建议的标签来源。选择\"全部\"将包含所有已加载来源的标签。",
"options": {
"danbooru": "Danbooru",
"e621": "e621",
"all": "全部"
}
},
"AutocompletePlus_Autocompletion_UseFastSearch": {
"name": "启用快速搜索",
"tooltip": "标签搜索处理更快,提高文本输入时的响应速度"
},
"AutocompletePlus_Autocompletion_EnableModels": {
"name": "显示Lora和Embedding"
},
"AutocompletePlus_Autocompletion_PrefixArtist": {
"name": "在艺术家标签前添加的字符串",
"tooltip": "通过自动完成插入艺术家标签时,在标签前添加的文本。\n例: '{'@'}' -> '{'@'}artist_name'."
},
"AutocompletePlus_Autocompletion_ReplaceUnderscoreWithSpace": {
"name": "将'_'替换为'空格'",
"tooltip": "此设置也会影响相关标签显示"
},
"AutocompletePlus_Autocompletion_AutoInsertComma": {
"name": "自动插入逗号",
"tooltip": "从自动完成插入标签时自动在标签后添加逗号"
},
"AutocompletePlus_Autocompletion_MaxSuggestions": {
"name": "最大提示数"
},
"AutocompletePlus_Autocompletion_Enable": {
"name": "启用自动完成"
},
"AutocompletePlus_RelatedTags_RelatedTagsTriggerMode": {
"name": "显示相关标签的操作",
"tooltip": "指定触发显示已输入标签的相关标签的操作(仅点击、Ctrl+点击)",
"options": {
"click": "点击",
"ctrl+Click": "Ctrl+点击"
}
},
"AutocompletePlus_RelatedTags_DisplayPosition": {
"name": "默认显示位置",
"tooltip": "相对于文本区域的显示位置",
"options": {
"horizontal": "水平",
"vertical": "垂直"
}
},
"AutocompletePlus_RelatedTags_MaxRelatedTags": {
"name": "最大相关标签数"
},
"AutocompletePlus_RelatedTags_Enable": {
"name": "启用相关标签"
},
"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": "启用自动格式化"
},
"AutocompletePlus_AutoFormatter_TrimSurroundingSpaces": {
"name": "去除首尾空白",
"tooltip": "启用时,清除提示词开头和结尾的所有空行和空格。"
}
}
+19 -11
View File
@@ -235,7 +235,16 @@ class Downloader:
def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False):
"""Checks HuggingFace for file updates and updates metadata."""
perform_hf_check = force_check
perform_hf_check = True
if not force_check and dataset_meta.get("last_remote_check_timestamp"):
try:
last_check_dt = datetime.fromisoformat(dataset_meta["last_remote_check_timestamp"])
if now_utc - last_check_dt < timedelta(days=7):
perform_hf_check = False
except (ValueError, KeyError, TypeError):
print(
"[Autocomplete-Plus] Invalid or missing timestamp for last_remote_check_timestamp. Will perform remote check."
)
if perform_hf_check:
huggingface_dataset_id = dataset_meta["hf_dataset_id"]
@@ -294,16 +303,15 @@ class Downloader:
return f"File {file_name} is missing or empty locally."
# Check if the last modified date on HuggingFace is newer than the last download date
# Temporarily comment out until changes to remote files can be detected
# try:
# last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"])
# hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"])
# if hf_modified_dt > last_download_dt:
# return f"Remote file {file_name} is newer (HF: {hf_modified_dt}, Local Download: {last_download_dt})."
# except (ValueError, TypeError):
# file_meta_entry["last_download"] = None
# file_meta_entry["last_modified_on_hf"] = None
# return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity."
try:
last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"])
hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"])
if hf_modified_dt > last_download_dt:
return f"Remote file {file_name} is newer (HF: {last_download_dt}, Local Download: {hf_modified_dt})."
except (ValueError, TypeError):
file_meta_entry["last_download"] = None
file_meta_entry["last_modified_on_hf"] = None
return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity."
# If the file is missing or empty, but the last download timestamp exists, we need to retry.
if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None:
+747 -1510
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,9 +6,9 @@
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"babel-jest": "^30.2.0",
"babel-jest": "^29.7.0",
"env-cmd": "^10.1.0",
"jest": "^30.2.0",
"jest": "^29.7.0",
"stylelint": "^16.19.1",
"stylelint-config-idiomatic-order": "^10.0.0",
"stylelint-config-standard": "^38.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.11.0"
version = "1.4.0"
license = {file = "LICENSE"}
dependencies = ["",]
-271
View File
@@ -1,271 +0,0 @@
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
});
// Store original setting value to restore after tests
const originalTrimSurroundingSpaces = settingValues.trimSurroundingSpaces;
afterEach(() => {
// Restore original setting after each test
settingValues.trimSurroundingSpaces = originalTrimSurroundingSpaces;
});
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('blocklist priority with trimSurroundingSpaces', () => {
test('should prioritize blocklist over trimSurroundingSpaces when disabled', () => {
settingValues.trimSurroundingSpaces = false;
// Blocklisted nodes should return false even with surrounding spaces
expect(shouldAutoFormat(' some code ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('\nsome code\n', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' 0,0,0,1,1,1 ', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
test('should prioritize blocklist over trimSurroundingSpaces when enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Blocklisted nodes should return false even with trimSurroundingSpaces enabled and surrounding spaces
expect(shouldAutoFormat(' some code ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat('\nsome code\n', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' function() { } ', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false);
expect(shouldAutoFormat(' 0,0,0,1,1,1 ', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false);
});
});
describe('trimSurroundingSpaces with non-comma-separated text', () => {
test('should return false for non-comma-separated text when trimSurroundingSpaces is disabled', () => {
settingValues.trimSurroundingSpaces = false;
// Text without word+comma pattern should return false
expect(shouldAutoFormat(' hello world ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat('\nhello world\n', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
expect(shouldAutoFormat(' tag1 tag2 ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false);
});
test('should return true for non-comma-separated text with surrounding spaces when trimSurroundingSpaces is enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Text with surrounding spaces should return true to format (trim them)
expect(shouldAutoFormat(' hello world ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat('\nhello world\n', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat(' tag1 tag2 ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
expect(shouldAutoFormat(' \nsome text\n ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true);
});
test('should return false for text without surrounding spaces even when trimSurroundingSpaces is enabled', () => {
settingValues.trimSurroundingSpaces = true;
// Text without word+comma pattern and without surrounding spaces should return false
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;
const originalTrimSurroundingSpaces = settingValues.trimSurroundingSpaces;
afterEach(() => {
// Restore original setting after each test
settingValues.useTrailingComma = originalUseTrailingComma;
settingValues.trimSurroundingSpaces = originalTrimSurroundingSpaces;
});
describe('with useTrailingComma enabled', () => {
beforeEach(() => {
settingValues.useTrailingComma = true;
settingValues.trimSurroundingSpaces = false;
});
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;
settingValues.trimSurroundingSpaces = 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);
});
});
describe('with trimSurroundingSpaces enabled', () => {
beforeEach(() => {
settingValues.trimSurroundingSpaces = true;
settingValues.useTrailingComma = false;
});
test('should remove trailing newlines and spaces', () => {
const input = 'tag1, tag2\n\n ';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove leading newlines and spaces', () => {
const input = ' \n\ntag1, tag2';
const expected = 'tag1, tag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should remove both leading and trailing whitespaces but keep middle empty lines', () => {
const input = ' \n\ntag1\n\ntag2\n\n ';
const expected = 'tag1\n\ntag2';
expect(formatPromptText(input)).toBe(expected);
});
test('should return empty string if input is only whitespace', () => {
const input = ' \n ';
expect(formatPromptText(input)).toBe('');
});
});
describe('with trimSurroundingSpaces disabled', () => {
beforeEach(() => {
settingValues.trimSurroundingSpaces = false;
settingValues.useTrailingComma = false;
});
test('should preserve trailing newlines', () => {
const input = 'tag1, tag2\n\n';
const expected = 'tag1, tag2\n\n';
expect(formatPromptText(input)).toBe(expected);
});
test('should preserve leading spaces (as first line content)', () => {
const input = ' \ntag1';
const expected = '\ntag1';
expect(formatPromptText(input)).toBe(expected);
});
test('should handle input with only spaces', () => {
expect(formatPromptText(' ')).toBe(' ');
});
});
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(' ');
});
});
});
-389
View File
@@ -1,389 +0,0 @@
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:1.0>, ');
});
test('should complete embedding tag without adding weight', () => {
const textarea = createMockTextarea('embedding:', 10);
const tagData = { tag: 'embedding:my_embedding', source: 'embeddings' };
expect(() => {
insertTagToTextArea(textarea, tagData);
}).not.toThrow();
expect(textarea.value).toBe('embedding:my_embedding, ');
});
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 -173
View File
@@ -1,11 +1,9 @@
import { settingValues } from '../../web/js/settings.js';
import {
extractTagsFromTextArea,
normalizeTagToSearch,
normalizeTagToInsert,
getCurrentTagRange,
findAllTagPositions,
addWeightToLora
findAllTagPositions
} from '../../web/js/utils.js';
// filepath: web/js/utils.test.js
@@ -137,11 +135,6 @@ describe('normalizeTagToSearch', () => {
});
describe('normalizeTagToInsert', () => {
const originalValue = settingValues.replaceUnderscoreWithSpace;
afterEach(() => {
settingValues.replaceUnderscoreWithSpace = originalValue;
});
test('should return null or empty string for invalid inputs', () => {
expect(normalizeTagToInsert(null)).toBeNull();
expect(normalizeTagToInsert(undefined)).toBeUndefined();
@@ -174,73 +167,6 @@ describe('normalizeTagToInsert', () => {
expect(normalizeTagToInsert('year:2000')).toBe('year:2000');
expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar');
});
test('should respect replaceUnderscoreWithSpace setting when true', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair')).toBe('blue hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long curly hair');
});
test('should respect replaceUnderscoreWithSpace setting when false', () => {
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair')).toBe('blue_hair');
expect(normalizeTagToInsert('red_eyes')).toBe('red_eyes');
expect(normalizeTagToInsert('long_curly_hair')).toBe('long_curly_hair');
});
test('should respect replaceUnderscoreWithSpace setting with parentheses', () => {
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue hair\\(style\\)');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue_hair\\(style\\)');
});
test('should not replace underscores in wildcard syntax regardless of setting', () => {
// Wildcard syntax should preserve underscores regardless of setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('__wildcard__')).toBe('__wildcard__');
});
test('should not replace underscores in symbol-only tags regardless of setting', () => {
// Symbol-only tags should not be affected by the setting
settingValues.replaceUnderscoreWithSpace = true;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert('^_^')).toBe('^_^');
});
test('should not escape parentheses when tag contains commas (multi-tags)', () => {
const multiTag = 'masterpiece, best quality, (high quality), ultra-detailed';
expect(normalizeTagToInsert(multiTag)).toBe('masterpiece, best quality, (high quality), ultra-detailed');
});
test('should not escape parentheses in multi-tag strings with parentheses', () => {
const multiTag = '(worst quality, low quality, normal quality), bad anatomy';
expect(normalizeTagToInsert(multiTag)).toBe('(worst quality, low quality, normal quality), bad anatomy');
});
test('should handle multi-tags with both underscores and parentheses correctly', () => {
settingValues.replaceUnderscoreWithSpace = true;
const multiTag = 'best_quality, (high_quality:1.2), ultra_detailed';
expect(normalizeTagToInsert(multiTag)).toBe('best quality, (high quality:1.2), ultra detailed');
settingValues.replaceUnderscoreWithSpace = false;
expect(normalizeTagToInsert(multiTag)).toBe('best_quality, (high_quality:1.2), ultra_detailed');
});
test('should still escape parentheses for single tags without commas', () => {
// Single tags (not multi-tags) should still have parentheses escaped
expect(normalizeTagToInsert('blue(hair)')).toBe('blue\\(hair\\)');
expect(normalizeTagToInsert('(tag)')).toBe('\\(tag\\)');
});
});
describe('getCurrentTagRange', () => {
@@ -441,101 +367,3 @@ describe('findAllTagPositions', () => {
expect(positions[5].tag).toBe('last');
});
});
describe('addWeightToModel', () => {
test('should return null or empty string for invalid inputs', () => {
expect(addWeightToLora(null)).toBeNull();
expect(addWeightToLora(undefined)).toBeUndefined();
expect(addWeightToLora('')).toBe('');
});
// LoRA format tests
test('should add default weight to LoRA without weight', () => {
expect(addWeightToLora('<lora:my_style01>')).toBe('<lora:my_style01:1.0>');
expect(addWeightToLora('<lora:anime_style>')).toBe('<lora:anime_style:1.0>');
expect(addWeightToLora('<lora:character_lora>')).toBe('<lora:character_lora:1.0>');
});
test('should preserve existing weight in LoRA', () => {
expect(addWeightToLora('<lora:my_style01:0.8>')).toBe('<lora:my_style01:0.8>');
expect(addWeightToLora('<lora:anime_style:1.2>')).toBe('<lora:anime_style:1.2>');
expect(addWeightToLora('<lora:character_lora:0.5>')).toBe('<lora:character_lora:0.5>');
});
test('should handle LoRA with path separators', () => {
expect(addWeightToLora('<lora:folder/my_style01>')).toBe('<lora:folder/my_style01:1.0>');
expect(addWeightToLora('<lora:folder/subfolder/style>')).toBe('<lora:folder/subfolder/style:1.0>');
expect(addWeightToLora('<lora:folder/my_style01:0.7>')).toBe('<lora:folder/my_style01:0.7>');
});
test('should handle LoRA with symbols', () => {
expect(addWeightToLora('<lora:folder/myLora style>')).toBe('<lora:folder/myLora style:1.0>');
expect(addWeightToLora('<lora:folder/myLora-style>')).toBe('<lora:folder/myLora-style:1.0>');
expect(addWeightToLora('<lora:folder/myLora[style]>')).toBe('<lora:folder/myLora[style]:1.0>');
expect(addWeightToLora('<lora:folder/myLora v1.0>')).toBe('<lora:folder/myLora v1.0:1.0>');
expect(addWeightToLora('<lora:folder/myLora v1.0:0.75>')).toBe('<lora:folder/myLora v1.0:0.75>');
});
test('should handle case-insensitive LoRA tags', () => {
expect(addWeightToLora('<LORA:my_style01>')).toBe('<LORA:my_style01:1.0>');
expect(addWeightToLora('<LoRa:anime_style>')).toBe('<LoRa:anime_style:1.0>');
expect(addWeightToLora('<Lora:character_lora:0.9>')).toBe('<Lora:character_lora:0.9>');
});
// Custom default weight tests
test('should use custom default weight for LoRA', () => {
expect(addWeightToLora('<lora:my_style01>', 0.5)).toBe('<lora:my_style01:0.5>');
expect(addWeightToLora('<lora:anime_style>', 1.5)).toBe('<lora:anime_style:1.5>');
expect(addWeightToLora('<lora:character_lora>', 2.0)).toBe('<lora:character_lora:2.0>');
});
test('should not override existing weight even with custom default', () => {
expect(addWeightToLora('<lora:my_style01:0.8>', 2.0)).toBe('<lora:my_style01:0.8>');
});
// Non-model format tests
test('should return non-model tags as-is', () => {
expect(addWeightToLora('blue_hair')).toBe('blue_hair');
expect(addWeightToLora('1girl')).toBe('1girl');
expect(addWeightToLora('standing:1.2')).toBe('standing:1.2');
expect(addWeightToLora('<other:tag>')).toBe('<other:tag>');
expect(addWeightToLora('random:text:format')).toBe('random:text:format');
});
// Edge cases
test('should handle malformed LoRA tags', () => {
expect(addWeightToLora('<lora:>')).toBe('<lora:>');
expect(addWeightToLora('<lora>')).toBe('<lora>');
expect(addWeightToLora('lora:my_style01')).toBe('lora:my_style01');
});
test('should handle decimal weights correctly', () => {
expect(addWeightToLora('<lora:style:0.123>')).toBe('<lora:style:0.123>');
expect(addWeightToLora('<lora:style:1.999>')).toBe('<lora:style:1.999>');
});
test('should handle special characters in model names', () => {
expect(addWeightToLora('<lora:my-style_01>')).toBe('<lora:my-style_01:1.0>');
expect(addWeightToLora('<lora:style.v2>')).toBe('<lora:style.v2:1.0>');
});
// Embedding format tests - should not be modified
test('should not modify embedding tags without weight', () => {
expect(addWeightToLora('embedding:my_embedding')).toBe('embedding:my_embedding');
expect(addWeightToLora('embedding:character_emb')).toBe('embedding:character_emb');
expect(addWeightToLora('embedding:style_embedding')).toBe('embedding:style_embedding');
});
test('should not modify embedding tags with existing weight', () => {
expect(addWeightToLora('embedding:my_embedding:0.8')).toBe('embedding:my_embedding:0.8');
expect(addWeightToLora('embedding:character_emb:1.2')).toBe('embedding:character_emb:1.2');
expect(addWeightToLora('embedding:style_embedding:0.5')).toBe('embedding:style_embedding:0.5');
});
test('should not modify embedding tags regardless of case', () => {
expect(addWeightToLora('EMBEDDING:my_embedding')).toBe('EMBEDDING:my_embedding');
expect(addWeightToLora('Embedding:character_emb')).toBe('Embedding:character_emb');
expect(addWeightToLora('EmBeDdInG:style_emb')).toBe('EmBeDdInG:style_emb');
expect(addWeightToLora('EMBEDDING:my_embedding:0.9')).toBe('EMBEDDING:my_embedding:0.9');
});
});
+20 -91
View File
@@ -1,21 +1,15 @@
/* Light Theme */
body {
--autocomplete-plus-text-color-cat-blue: var(--p-blue-700);
--autocomplete-plus-text-color-cat-blue: var(--p-blue-600);
--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-700);
--autocomplete-plus-text-color-cat-green: var(--p-green-600);
--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-600);
--autocomplete-plus-text-color-cat-sky: var(--p-sky-500);
--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 */
@@ -30,12 +24,6 @@ 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 {
@@ -109,17 +97,12 @@ body.dark-theme {
display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto;
grid-template-columns: max-content max-content 1fr auto;
grid-template-columns: max-content 1fr auto auto;
overflow-y: auto;
}
#autocomplete-plus-list.no-alias {
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;
@@ -128,22 +111,23 @@ 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) {
background-color: var(--autocomplete-plus-list-color-even);
.autocomplete-plus-item:nth-child(even) span {
background-color: var(--comfy-menu-bg);
}
.autocomplete-plus-item:nth-child(odd) {
background-color: var(--autocomplete-plus-list-color-odd);
.autocomplete-plus-item:nth-child(odd) span {
background-color: var(--comfy-menu-secondary-bg);
}
.autocomplete-plus-item.selected {
.autocomplete-plus-item.selected span {
background-color: var(--border-color);
}
.autocomplete-plus-item:hover {
.autocomplete-plus-item:hover span {
background-color: var(--comfy-hover-bg);
}
@@ -172,28 +156,6 @@ 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;
@@ -270,7 +232,7 @@ body.dark-theme {
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border-color);
background-color: var(--autocomplete-plus-list-color-even);
background-color: var(--comfy-menu-bg);
color: var(--descrip-text);
gap: 8px;
}
@@ -289,16 +251,10 @@ 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;
@@ -336,17 +292,12 @@ body.dark-theme {
display: grid;
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
grid-auto-rows: auto;
grid-template-columns: max-content max-content 1fr auto;
grid-template-columns: max-content 1fr auto auto;
overflow-y: auto;
}
#related-tags-list.no-alias {
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;
@@ -356,21 +307,22 @@ 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) {
background-color: var(--autocomplete-plus-list-color-even);
.related-tag-item:nth-child(even) span {
background-color: var(--comfy-menu-bg);
}
.related-tag-item:nth-child(odd) {
background-color: var(--autocomplete-plus-list-color-odd);
.related-tag-item:nth-child(odd) span {
background-color: var(--comfy-menu-secondary-bg);
}
.related-tag-item:hover {
.related-tag-item:hover span {
background-color: var(--p-form-field-filled-hover-background);
}
.related-tag-item.selected {
.related-tag-item.selected span {
background-color: var(--border-color);
}
@@ -395,29 +347,6 @@ 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;
-225
View File
@@ -1,225 +0,0 @@
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. If text is empty after trimming, format only if trimSurroundingSpaces is enabled, otherwise don't format
* 3. Skip formatting if text contains only numbers or single letters (separated by commas)
* 4. Format if text contains "word + comma" pattern
* 5. Format if trimSurroundingSpaces is enabled and there are surrounding spaces or line breaks
* 6. 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) 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 completely empty after trimming
if (trimmedText.length === 0) {
return settingValues.trimSurroundingSpaces;
}
// 3. 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
}
// 4. If text contains "word + comma" pattern, format it
const wordCommaPattern = /\w+\s*,/g;
if (trimmedText.match(wordCommaPattern)) {
return true;
}
// 5. If trimSurroundingSpaces is enabled and there are surrounding spaces, format to trim them
if (settingValues.trimSurroundingSpaces && text !== trimmedText) {
return true;
}
return false; // Otherwise, don't format
}
/**
* 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) {
// Handle null or undefined input
if (text == null) return text;
// If the text is empty or contains only spaces...
if (text.trim().length === 0) {
if (settingValues.trimSurroundingSpaces) {
// ...And trimSurroundingSpaces is enabled, return an empty string
return '';
} else {
// ...Otherwise, return the original text
return text;
}
}
let processedText = text;
if (settingValues.trimSurroundingSpaces) {
processedText = text.trim();
}
// Split text into individual lines for processing
const lines = processedText.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;
+47 -154
View File
@@ -1,5 +1,6 @@
import {
ModelTagSource,
TagCategory,
TagData,
autoCompleteData,
getEnabledTagSourceInPriorityOrder
@@ -16,10 +17,7 @@ import {
extractTagsFromTextArea,
getCurrentTagRange,
getViewportMargin,
getScrollbarWidth,
IconSvgHtmlString,
addWeightToLora,
openTagWikiUrl
IconSvgHtmlString
} from './utils.js';
import { settingValues } from './settings.js';
@@ -260,10 +258,6 @@ function searchWithFlexSearch(partialTag, queryVariations) {
* @returns {string} The current partial tag.
*/
function getCurrentPartialTag(inputElement) {
if (!inputElement) {
return "";
}
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
@@ -306,39 +300,21 @@ function getCurrentPartialTag(inputElement) {
* Inserts the selected tag into the textarea, replacing the partial tag,
* making the change undoable.
* @param {HTMLTextAreaElement} inputElement
* @param {TagData} tagDataToInsert
* @param {TagData} tagDataToInsert The raw tag string to insert.
*/
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;
tagEnd = cursorPos;
currentTag = '';
} else {
({ start: tagStart, end: tagEnd, tag: currentTag } = tagRange);
}
const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos);
const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos;
let normalizedTag;
if (tagDataToInsert.source === ModelTagSource.Lora) {
// If the tag is from a LoRA source, add weight to it
normalizedTag = addWeightToLora(tagDataToInsert.tag);
} else if (Object.values(ModelTagSource).includes(tagDataToInsert.source)) {
// If the tag is from other model tag sources (e.g., Embeddings), don't normalize it
if(Object.values(ModelTagSource).includes(tagDataToInsert.source)){
// If the tag is from a model tag source, don't want to normalize it
normalizedTag = tagDataToInsert.tag;
} else {
}else{
normalizedTag = normalizeTagToInsert(tagDataToInsert.tag);
}
@@ -351,13 +327,11 @@ function insertTagToTextArea(inputElement, tagDataToInsert) {
const needsSpaceBefore = text[replaceStart - 1] === ',';
const prefix = needsSpaceBefore ? ' ' : '';
const prefixArtist = tagDataToInsert.categoryText == 'artist' ? settingValues.prefixArtist : '';
// Standard separator (comma + space, or empty if autoInsertComma is disabled)
// Standard separator (comma + space)
const needsSuffixAfter = !",:".includes(text[replaceEnd]); // TODO: If ":" is part of the emoticon, a suffix is ​​required (e.g. ":o")
const suffix = (needsSuffixAfter && settingValues.autoInsertComma) ? ', ' : '';
const suffix = needsSuffixAfter ? ', ' : '';
const textToInsertWithAffixes = prefix + prefixArtist + normalizedTag + suffix;
const textToInsertWithAffixes = prefix + normalizedTag + suffix;
// --- Use execCommand for Undo support ---
// 1. Select the text range to be replaced
@@ -409,22 +383,9 @@ 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.index !== undefined) {
const tagData = this.candidates[parseInt(row.dataset.index, 10)];
if (tagData) {
this.#insertTag(tagData);
}
if (row && row.dataset.tag) {
this.#insertTag(row.dataset);
e.preventDefault(); // Prevent focus loss from input
e.stopPropagation();
}
@@ -492,7 +453,7 @@ class AutocompleteUI {
/** Selects the currently highlighted item
* @returns {TagData|null} The selected tag data.
*/
getSelectedTagData() {
getSelectedTag() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) {
return this.candidates[this.selectedIndex];
}
@@ -510,32 +471,28 @@ class AutocompleteUI {
return;
}
this.tagsList.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target);
const currentTag = getCurrentPartialTag(this.target);
this.candidates.forEach((tagData, index) => {
this.candidates.forEach((tagData) => {
const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1);
const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag);
this.#createTagElement(tagData, index, isExistingTag);
this.#createTagElement(tagData, isExistingTag);
});
}
/**
* Creates a tag element for the autocomplete list.
* @param {TagData} tagData
* @param {number} tagDataIndex
* @param {boolean} isExisting
*/
#createTagElement(tagData, tagDataIndex, isExisting) {
const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
#createTagElement(tagData, isExisting) {
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const tagRow = document.createElement('div');
tagRow.classList.add('autocomplete-plus-item', tagData.source);
tagRow.dataset.index = tagDataIndex;
tagRow.dataset.tagCategory = categoryText; // Used to color by CSS
tagRow.dataset.tag = tagData.tag;
tagRow.dataset.tagCategory = categoryText;
// Tag icon and name
const tagSourceIconHtml = `<svg class="autocomplete-plus-tag-icon-svg"><use xlink:href="#autocomplete-plus-icon-${tagData.source}"></use></svg>`;
@@ -555,47 +512,31 @@ 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';
// Display alias if available
if (aliasText.length > 0) {
if (tagData.alias && tagData.alias.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`;
alias.title = aliasText; // Full alias on hover
alias.title = tagData.alias.join(', '); // 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
const tagCount = document.createElement('span');
tagCount.className = `autocomplete-plus-tag-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(wikiIcon);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(alias);
tagRow.appendChild(category);
tagRow.appendChild(tagCount);
this.tagsList.appendChild(tagRow);
}
@@ -633,14 +574,21 @@ class AutocompleteUI {
let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale;
let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;;
const maxWidth = Math.min(rootRect.width, viewportWidth / 2);
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
const availableSpaceBelow = viewportHeight - topPosition - margin.bottom;
const availableSpaceAbove = caretTop - margin.top;
let needsVerticalScroll = false;
let maxHeight = rootRect.height;
if (naturalHeight <= availableSpaceBelow) {
// Fits perfectly below the caret
@@ -652,15 +600,14 @@ class AutocompleteUI {
topPosition = caretTop - naturalHeight - margin.top;
} else {
// Doesn't fit perfectly either below or above, needs scrolling.
needsVerticalScroll = true;
// Choose the position (above or below) that offers more space.
if (availableSpaceBelow >= availableSpaceAbove) {
// Scroll below: topPosition remains as initially calculated
maxHeight = availableSpaceBelow;
this.tagsList.style.maxHeight = `${availableSpaceBelow}px`;
} else {
// Scroll above: Position near the top edge and set max-height
topPosition = margin.top;
maxHeight = availableSpaceAbove;
this.tagsList.style.maxHeight = `${availableSpaceAbove}px`;
}
}
}
@@ -669,35 +616,21 @@ class AutocompleteUI {
if (topPosition < margin.top) {
topPosition = margin.top;
// If pushed down, recalculate max-height if it was set based on top alignment
if (availableSpaceBelow < availableSpaceAbove) {
if (this.tagsList.style.maxHeight && availableSpaceBelow < availableSpaceAbove) {
// Recalculate max-height based on space from the top margin
maxHeight = viewportHeight - margin.top - margin.bottom;
needsVerticalScroll = true;
this.tagsList.style.maxHeight = `${viewportHeight - margin.top - margin.bottom}px`;
}
}
// 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
this.root.style.left = `${leftPosition}px`;
this.root.style.top = `${topPosition}px`;
this.root.style.maxWidth = `${maxWidth}px`;
this.tagsList.style.maxHeight = `${maxHeight}px`;
}
/** Highlights the item (row) at the given index */
#highlightItem() {
if (!this.getSelectedTagData()) return; // No valid selection
if (!this.getSelectedTag()) return; // No valid selection
const items = this.tagsList.children; // Get rows from tbody
for (let i = 0; i < items.length; i++) {
@@ -941,25 +874,6 @@ export class AutocompleteEventHandler {
constructor() {
this.autocompleteUI = new AutocompleteUI();
this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers
this._debounceTimer = null; // Timer ID for debounced search
}
/**
* Calls autocompleteUI.updateDisplay() with debounce for sequential search, or immediately for fast search.
* @param {HTMLTextAreaElement} target
*/
_triggerUpdateDisplay(target) {
if (settingValues.useFastSearch || settingValues.searchDebounceTime <= 0) {
// FastSearch or debounce disabled: immediate update
this.autocompleteUI.updateDisplay(target);
} else {
// Sequential search: debounced update
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
this.autocompleteUI.updateDisplay(target);
this._debounceTimer = null;
}, settingValues.searchDebounceTime);
}
}
/**
@@ -973,7 +887,6 @@ export class AutocompleteEventHandler {
const partialTag = getCurrentPartialTag(event.target);
if (partialTag.length <= 0) {
clearTimeout(this._debounceTimer); // Cancel pending debounced search
this.autocompleteUI.hide();
}
}
@@ -1018,19 +931,12 @@ export class AutocompleteEventHandler {
case 'Enter':
case 'Tab':
const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTagData() !== null) {
if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) {
event.preventDefault();
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTagData());
insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag());
}
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();
@@ -1084,7 +990,7 @@ export class AutocompleteEventHandler {
// and default action is not prevented, update the display.
// This will typically be for character inputs, Delete, Backspace or IME composition.
if (!event.defaultPrevented) {
this._triggerUpdateDisplay(event.target);
this.autocompleteUI.updateDisplay(event.target);
}
}
@@ -1104,16 +1010,3 @@ export class AutocompleteEventHandler {
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;
+15 -31
View File
@@ -72,23 +72,6 @@ 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 {
@@ -171,7 +154,6 @@ async function loadTags(csvUrl, siteName) {
}
const csvText = await response.text();
const lines = csvText.split('\n').filter(line => line.trim().length > 0);
const totalLines = lines.length;
const startIndex = lines[0].toLowerCase().startsWith(TAGS_CSV_HEADER) ? 1 : 0;
@@ -286,17 +268,17 @@ async function loadCooccurrence(csvUrl, siteName) {
const startIndex = lines[0].startsWith('tag_a,tag_b,count') ? 1 : 0;
await processInChunks(lines, startIndex, autoCompleteData[siteName].cooccurrenceMap, csvUrl, siteName);
await processCooccurrenceInChunks(lines, startIndex, autoCompleteData[siteName].cooccurrenceMap, csvUrl, siteName);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${csvUrl}:`, error);
}
}
/**
* Process CSV data in chunks to avoid blocking the UI.
* Process Co-Occurrence CSV data in chunks to avoid blocking the UI.
* Modifies the targetMap directly.
*/
function processInChunks(lines, startIndex, targetMap, csvUrl, siteName) {
function processCooccurrenceInChunks(lines, startIndex, targetMap, csvUrl, siteName) {
return new Promise((resolve) => {
const CHUNK_SIZE = 10000;
let i = startIndex;
@@ -307,7 +289,7 @@ function processInChunks(lines, startIndex, targetMap, csvUrl, siteName) {
for (; i < endIndex; i++) {
const line = lines[i];
const columns = parseCSVLine(line);
const columns = line.split(",");
if (columns.length >= 3) {
const tagA = columns[0].trim();
@@ -317,17 +299,19 @@ function processInChunks(lines, startIndex, targetMap, csvUrl, siteName) {
if (!tagA || !tagB || isNaN(count)) continue;
// Add tagA -> tagB relationship
if (!targetMap.has(tagA)) {
targetMap.set(tagA, new Map());
let subMapA = targetMap.get(tagA);
if (!subMapA) {
subMapA = new Map();
targetMap.set(tagA, subMapA);
}
targetMap.get(tagA).set(tagB, count);
subMapA.set(tagB, count);
// Add tagB -> tagA relationship (bidirectional)
if (!targetMap.has(tagB)) {
targetMap.set(tagB, new Map());
let subMapB = targetMap.get(tagB);
if (!subMapB) {
subMapB = new Map();
targetMap.set(tagB, subMapB);
}
targetMap.get(tagB).set(tagA, count);
subMapB.set(tagA, count);
pairCount++;
}
@@ -559,7 +543,7 @@ async function loadLoras() {
}
/**
* Load all data sources asynchronously.
* Load all data sources in parallel.
*/
export async function loadDataAsync() {
return Promise.all([
+73 -238
View File
@@ -6,27 +6,23 @@ 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, nodeInfo) {
if (attachedElementNodeInfoMap.has(element)) return; // Prevent double attachment
function attachListeners(element) {
if (attachedElements.has(element)) return; // Prevent double attachment
element.addEventListener('input', handleInput);
element.addEventListener('focus', handleFocus);
@@ -39,7 +35,7 @@ function initializeEventHandlers() {
element.addEventListener('mousemove', handleMouseMove);
element.addEventListener('click', handleClick);
attachedElementNodeInfoMap.set(element, nodeInfo); // Mark as attached and store node info
attachedElements.add(element); // Mark as attached
}
// Attempt Widget Override as the primary method
@@ -50,19 +46,14 @@ function initializeEventHandlers() {
ComfyWidgets.STRING = function (node, inputName, inputData, appInstance) { // Use appInstance to avoid conflict with global app
const result = originalStringWidget.apply(this, arguments);
// Check if the widget has an element and if it's a TEXTAREA
// 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) {
// fallback for older Comfyui frontend versions
const inputEl = result.widget.element ?? result.widget.inputEl;
if (inputEl && inputEl.tagName === 'TEXTAREA' && !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;
if (result && result.widget && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA') {
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;
const nodeInfo = new NodeInfo(node.comfyClass || node.constructor.name, inputName);
attachListeners(inputEl, nodeInfo);
}
attachListeners(result.widget.inputEl);
}
return result;
};
@@ -82,11 +73,9 @@ function initializeEventHandlers() {
targetSelectors.forEach(selector => {
// Check if the added node itself matches or contains matching elements
if (node.matches(selector)) {
attachListeners(node, new NodeInfo('Fallback', 'unknown'));
attachListeners(node);
} else {
node.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
node.querySelectorAll(selector).forEach(attachListeners);
}
});
}
@@ -96,72 +85,48 @@ function initializeEventHandlers() {
// Initial scan for existing elements
targetSelectors.forEach(selector => {
document.querySelectorAll(selector).forEach(el => {
attachListeners(el, new NodeInfo('Fallback', 'unknown'));
});
document.querySelectorAll(selector).forEach(attachListeners);
});
// 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);
}
}
@@ -206,13 +171,13 @@ async function addExtraSettings() {
checkButton.disabled = false;
}
}
// Fetch last check time from API
let lastCheckTimeText = "Loading...";
try {
const response = await fetch('/autocomplete-plus/csv/last-check-time');
const data = await response.json();
if (data.last_check_time) {
const lastCheckDate = new Date(data.last_check_time);
lastCheckTimeText = "Last checked: " + lastCheckDate.toLocaleString();
@@ -225,41 +190,40 @@ async function addExtraSettings() {
}
// Add extra setting for checking new CSV updates
// Note: Temporarily comment out until changes to remote files can be detected
// app.ui.settings.addSetting({
// id: id + ".check_new_csv",
// defaultValue: null,
// name: "Check CSV updates",
// category: [name, "Misc", "Check new CSV"],
// type: () => {
// const lastCheckSpan = $el("span", {
// textContent: lastCheckTimeText,
// className: "text-sm text-gray-500",
// style: {
// marginRight: "16px"
// }
// });
app.ui.settings.addSetting({
id: id + ".check_new_csv",
defaultValue: null,
name: "Check CSV updates",
category: [name, "Misc", "Check new CSV"],
type: () => {
const lastCheckSpan = $el("span", {
textContent: lastCheckTimeText,
className: "text-sm text-gray-500",
style: {
marginRight: "16px"
}
});
// const checkButton = $el("button", {
// textContent: "Check now",
// className: "p-button p-component p-button-primary",
// onclick: async () => {
// await performUpdateCheck(checkButton, lastCheckSpan);
// }
// });
const checkButton = $el("button", {
textContent: "Check now",
className: "p-button p-component p-button-primary",
onclick: async () => {
await performUpdateCheck(checkButton, lastCheckSpan);
}
});
// return $el("div", {
// className: "flex-row items-center gap-2",
// }, [
// $el("div", {
// className: "p-component",
// }, [
// lastCheckSpan,
// checkButton,
// ]),
// ]);
// }
// });
return $el("div", {
className: "flex-row items-center gap-2",
}, [
$el("div", {
className: "p-component",
}, [
lastCheckSpan,
checkButton,
]),
]);
}
});
}
/**
@@ -276,54 +240,14 @@ app.registerExtension({
let rootPath = import.meta.url.replace("js/main.js", "");
loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete
await loadDataAsync();
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 ---
{
id: id + ".TagSource.IconPosition",
id: id + ".tag_source_icon_position",
name: "Tag Source Icon Position",
type: "combo",
options: ["left", "right", "hidden"],
@@ -334,7 +258,7 @@ app.registerExtension({
}
},
{
id: id + ".TagSource.PrimaryTagSource",
id: id + ".primary_tag_source",
name: "Primary source for 'all' Source",
tooltip: "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.",
type: "combo",
@@ -346,7 +270,7 @@ app.registerExtension({
}
},
{
id: id + ".TagSource",
id: id + ".tag_source",
name: "Autocomplete Tag Source",
tooltip: "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.",
type: "combo",
@@ -357,12 +281,11 @@ app.registerExtension({
settingValues.tagSource = newVal;
}
},
// --- Autocomplete Settings ---
{
id: id + ".Autocompletion.UseFastSearch",
id: id + ".use_fast_search",
name: "Use Fast Search",
tooltip: "Tag search processing during text input operates faster, improving responsiveness.",
tooltip: "Tag search processing during text input operates faster, improving responsiveness",
type: "boolean",
defaultValue: false,
category: [name, "Autocompletion", "Use Fast Search"],
@@ -371,8 +294,9 @@ app.registerExtension({
}
},
{
id: id + ".Autocompletion.EnableModels",
id: id + ".enable_models",
name: "Enable Loras and Embeddings",
tooltip: "Enable Lora and Embedding suggestions",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Enable Loras and Embeddings"],
@@ -381,40 +305,7 @@ app.registerExtension({
}
},
{
id: id + ".Autocompletion.PrefixArtist",
name: "String to add before artist tags",
tooltip: "Text to prepend when inserting an artist tag via autocomplete.\ne.g. '@' -> '@artist_name'.",
type: "text",
defaultValue: '',
category: [name, "Autocompletion", "String to add before artist tags"],
onChange: (newVal, oldVal) => {
settingValues.prefixArtist = newVal;
}
},
{
id: id + ".Autocompletion.ReplaceUnderscoreWithSpace",
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 + ".Autocompletion.AutoInsertComma",
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"],
onChange: (newVal, oldVal) => {
settingValues.autoInsertComma = newVal;
}
},
{
id: id + ".Autocompletion.MaxSuggestions",
id: id + ".max_suggestions",
name: "Max suggestions",
type: "slider",
attrs: {
@@ -429,8 +320,9 @@ app.registerExtension({
}
},
{
id: id + ".Autocompletion.Enable",
id: id + ".boolean",
name: "Enable Autocomplete",
description: "Enable or disable the autocomplete feature.",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Enable Autocomplete"],
@@ -441,9 +333,9 @@ app.registerExtension({
// --- Related Tags Settings ---
{
id: id + ".RelatedTags.RelatedTagsTriggerMode",
id: id + ".related_tags_trigger_mode",
name: "Related Tags Trigger Mode",
tooltip: "Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click).",
description: "Trigger mode for related tags (click or ctrl+click)",
type: "combo",
options: ["click", "ctrl+Click"],
defaultValue: "click",
@@ -453,9 +345,9 @@ app.registerExtension({
}
},
{
id: id + ".RelatedTags.DisplayPosition",
id: id + ".related_tags_position",
name: "Default Display Position",
tooltip: "Display position (relative to Textarea).",
description: "Display position (relative to Textarea)",
type: "combo",
options: ["horizontal", "vertical"],
defaultValue: "horizontal",
@@ -465,8 +357,9 @@ app.registerExtension({
}
},
{
id: id + ".RelatedTags.MaxRelatedTags",
id: id + ".max_related_tags",
name: "Max related tags",
description: "Maximum number of related tags to display",
type: "slider",
attrs: {
min: 5,
@@ -480,73 +373,15 @@ app.registerExtension({
}
},
{
id: id + ".RelatedTags.Enable",
id: id + ".related_tags_enable",
name: "Enable Related Tags",
description: "Enable or disable the related tags feature.",
type: "boolean",
defaultValue: true,
category: [name, "Related Tags", "Enable Related Tags"],
onChange: (newVal, oldVal) => {
settingValues.enableRelatedTags = newVal;
}
},
// --- Display settings ---
{
id: id + ".Display.HideAlias",
name: "Hide Alias",
tooltip: "Hide alias column in the autocomplete and related tags display.",
type: "boolean",
defaultValue: false,
category: [name, "Display", "Hide Alias"],
onChange: (newVal, oldVal) => {
settingValues.hideAlias = newVal;
}
},
// --- Auto format settings ---
{
id: id + '.AutoFormatter.TrimSurroundingSpaces',
name: 'Trim Surrounding Spaces',
tooltip: 'When enabled, trim any blank lines from the beginning and end of the prompt.',
type: 'boolean',
defaultValue: false,
category: [name, 'AutoFormatter', 'Trim Surrounding Spaces'],
onChange: (newVal, oldVal) => {
settingValues.trimSurroundingSpaces = newVal;
},
},
{
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
@@ -1,14 +0,0 @@
/**
* 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;
}
}
+55 -123
View File
@@ -1,16 +1,14 @@
import { TagCategory, TagData, TagSource, autoCompleteData, getEnabledTagSourceInPriorityOrder } from './data.js';
import { TagCategory, TagData, TagSource, autoCompleteData } from './data.js';
import { settingValues } from './settings.js';
import {
extractTagsFromTextArea,
findAllTagPositions,
getCurrentTagRange,
getScrollbarWidth,
getViewportMargin,
isLongText,
isValidTag,
normalizeTagToInsert,
normalizeTagToSearch,
openTagWikiUrl
} from './utils.js';
// --- RelatedTags Logic ---
@@ -96,8 +94,6 @@ function searchRelatedTags(tag) {
category: tagData.category,
source: tagData.source,
count: tagData.count,
categoryText: tagData.categoryText,
hasWikiPage: tagData.hasWikiPage
});
});
@@ -263,7 +259,7 @@ class RelatedTagsUI {
this.headerControls.appendChild(this.pinBtn);
this.header.appendChild(this.headerControls);
this.root.appendChild(this.header);
// Create a tbody for the tags
@@ -281,31 +277,11 @@ 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.tagName) {
this.#insertTag(row.dataset.tagName);
if (row && row.dataset.tag) {
this.#insertTag(row.dataset.tag);
e.preventDefault();
e.stopPropagation();
}
@@ -345,6 +321,9 @@ 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();
@@ -386,18 +365,10 @@ class RelatedTagsUI {
}
}
/** Moves the selection up or down
* @param {direction} 1 for down, -1 for up
*/
/** Moves the selection up or down */
navigate(direction) {
if (this.relatedTags.length === 0) return;
if (this.selectedIndex == -1) {
// Initialize selection based on navigation direction
this.selectedIndex = direction == 1 ? 0 : this.relatedTags.length - 1;
} else {
this.selectedIndex += direction;
}
this.selectedIndex += direction;
if (this.selectedIndex < 0) {
this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom
@@ -407,27 +378,10 @@ class RelatedTagsUI {
this.#highlightItem();
}
/**
* 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() {
/** Selects the currently highlighted item */
getSelectedTag() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) {
return this.relatedTags[this.selectedIndex];
return this.relatedTags[this.selectedIndex].tag;
}
return null; // No valid selection
@@ -446,15 +400,27 @@ class RelatedTagsUI {
* Updates header content
*/
#updateHeader() {
let tagData = this.getCurrentTagData();
// 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);
if (!tagData) {
// Create a dummy TagData if not found
tagData = new TagData(this.currentTag, null, 0, [], TagSource.Danbooru);
tagData = new TagData({
tag: this.currentTag,
source: TagSource.Danbooru,
category: 'unknown',
count: 0,
alias: [],
});
}
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const aliasText = tagData.alias.join(', ');
const tagText = this.currentTag;
const categoryText = TagCategory[tagData.source][tagData.category];
// Update header text with current tag
this.headerText.innerHTML = ''; // Clear previous content
@@ -462,22 +428,17 @@ class RelatedTagsUI {
const tagName = document.createElement('span');
tagName.classList.add('related-tags-header-tag-name', tagData.source);
tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}\nAlias: ${aliasText}`;
tagName.title = `Count: ${tagData.count}\nCategory: ${categoryText}`;
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'
? `${tagSourceIconHtml} ${tagData.tag}`
: `${tagData.tag} ${tagSourceIconHtml}`;
? `${tagSourceIconHtml} ${tagText}`
: `${tagText} ${tagSourceIconHtml}`;
} else {
tagName.textContent += tagData.tag;
tagName.textContent += tagText;
}
if (!tagData.hasWikiPage) {
tagName.classList.add('disabled');
}
this.headerText.appendChild(tagName);
@@ -486,8 +447,8 @@ class RelatedTagsUI {
this.headerAlias.innerHTML = '';
// Add alias if available
if (aliasText.length > 0 && !settingValues.hideAlias) {
this.headerAlias.textContent = aliasText;
if (tagData.alias?.length > 0) {
this.headerAlias.textContent = tagData.alias.join(', ');
this.headerAlias.style.display = 'block';
}
@@ -524,9 +485,6 @@ class RelatedTagsUI {
return;
}
// Toggle column class based on settings
this.tagsContainer.classList.toggle('no-alias', settingValues.hideAlias);
const existingTags = extractTagsFromTextArea(this.target);
// Create tag rows
@@ -544,12 +502,11 @@ class RelatedTagsUI {
* @returns {HTMLTableRowElement} The tag row element
*/
#createTagElement(tagData, isExisting) {
const categoryText = tagData.categoryText;
const aliasText = tagData.alias.join(', ');
const categoryText = TagCategory[tagData.source][tagData.category] || "unknown";
const tagRow = document.createElement('div');
tagRow.classList.add('related-tag-item', tagData.source);
tagRow.dataset.tagName = tagData.tag;
tagRow.dataset.tag = tagData.tag;
tagRow.dataset.tagCategory = categoryText;
// Tag name
@@ -562,48 +519,38 @@ 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';
// Display alias if available
if (aliasText.length > 0) {
if (tagData.alias && tagData.alias.length > 0) {
let aliasText = tagData.alias.join(', ');
alias.textContent = `${aliasText}`;
alias.title = aliasText; // Full alias on hover
alias.title = tagData.alias.join(', '); // Full alias on hover
}
// Category
const category = document.createElement('span');
category.className = `related-tag-category`;
category.textContent = `${categoryText.substring(0, 2)}`;
// Similarity
const similarity = document.createElement('span');
similarity.className = 'related-tag-similarity';
similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`;
// Create tooltip with more info
let tooltipText = `Similarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}\nCategory: ${categoryText}`;
if (aliasText.length > 0) {
tooltipText += `\nAlias: ${aliasText}`;
let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}`;
if (tagData.alias && tagData.alias.length > 0) {
tooltipText += `\nAlias: ${tagData.alias.join(', ')}`;
}
tagRow.title = tooltipText;
// Add cells to row
tagRow.appendChild(tagName);
tagRow.appendChild(wikiIcon);
if (!settingValues.hideAlias) {
tagRow.appendChild(alias);
}
tagRow.appendChild(alias);
tagRow.appendChild(category);
tagRow.appendChild(similarity);
return tagRow;
@@ -633,7 +580,7 @@ class RelatedTagsUI {
const newHeaderRect = this.header.getBoundingClientRect();
if (this.relatedTags.length > 0) {
if(this.relatedTags.length > 0){
this.tagsContainer.style.maxHeight = `${placementArea.height - newHeaderRect.height}px`;
}
@@ -644,7 +591,7 @@ class RelatedTagsUI {
/** Highlights the item (row) at the given index */
#highlightItem() {
if (this.getSelectedTagData() === null) return; // No valid selection
if (this.getSelectedTag() === null) return; // No valid selection
const items = this.tagsContainer.children; // Get rows
for (let i = 0; i < items.length; i++) {
@@ -677,9 +624,9 @@ class RelatedTagsUI {
}
insertSelectedTag() {
const selectedTag = this.getSelectedTagData();
const selectedTag = this.getSelectedTag();
if (selectedTag) {
this.#insertTag(selectedTag.tag);
this.#insertTag(selectedTag);
}
}
@@ -722,10 +669,6 @@ class RelatedTagsUI {
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
area.x = Math.min(area.x, viewportWidth - area.width - margin.right);
} else {
@@ -742,10 +685,6 @@ class RelatedTagsUI {
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
area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom);
}
@@ -825,20 +764,13 @@ export class RelatedTagsEventHandler {
break;
case 'Enter':
case 'Tab':
if (this.relatedTagsUI.getSelectedTagData() !== null) {
if (this.relatedTagsUI.getSelectedTag() !== 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();
+1 -1
View File
@@ -14,7 +14,7 @@ function createTagEncoder() {
cache: true,
// filter: new Set(['and', 'to', 'be', 'on']),
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, hyphens, and newlines
split: /(?<=[a-zA-Z\)])_(?=[a-zA-Z\(])|\((?=[a-zA-Z])|(?<=[a-zA-Z\)])\)|[ \n]/ // Split on underscores between words, parentheses, spaces, and newlines
});
}
-13
View File
@@ -9,10 +9,6 @@ export const settingValues = {
maxSuggestions: 10,
enableModels: true, // Enable Lora and Embedding suggestions
useFastSearch: false,
replaceUnderscoreWithSpace: true, // Replace underscores with spaces in tag insertion
prefixArtist: '', // Prefix to be attached before artist tags
autoInsertComma: true,
searchDebounceTime: 100, // Sequential search debounce time in milliseconds
// Related tags feature settings
enableRelatedTags: true,
@@ -20,15 +16,6 @@ export const settingValues = {
relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical'
relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click'
// 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
trimSurroundingSpaces: false, // Trim spaces around each tag
// Internal logic settings
_useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong
+6 -120
View File
@@ -223,31 +223,23 @@ export function normalizeTagToSearch(str) {
* Converts underscores to spaces only if the tag contains at least one letter or number,
* and is not a wildcard call (e.g., "__wildcard__").
* Keeps underscores for tags that are only symbols (e.g. "^_^") or wildcard calls.
* For multi-tags (multiple tags with commas), parentheses are not escaped to preserve prompt weight syntax.
* @param {string} str
* @param {string} str
* @returns {string}
*/
export function normalizeTagToInsert(str) {
if (!str) return str;
// Check if multi-tag string
const isMultiTag = str.includes(',');
// Check if the string contains at least one letter or number
if (isContainsLetterOrNumber(str)) {
const isWildcardCall = str.startsWith('__') && str.endsWith('__') && str.length > 4;
if (!isWildcardCall && settingValues.replaceUnderscoreWithSpace) {
// If doesn't wildcard call and setting is enabled, replace underscores with spaces
const result = str.replace(/_/g, " ");
// Only escape parentheses for single tags, not multi-tags
return isMultiTag ? result : escapeParentheses(result);
if (!isWildcardCall) {
// If doesn't wildcard call, replace underscores with spaces
return escapeParentheses(str.replace(/_/g, " "));
}
}
// Otherwise, keep it as is (e.g., ""^_^", "__wildcard__")
// Only escape parentheses for single tags, not multi-tags
return isMultiTag ? str : escapeParentheses(str);
return escapeParentheses(str);
}
/**
@@ -558,38 +550,11 @@ export function getCurrentTagRange(text, cursorPos) {
return { start: adjustedStart, end: adjustedEnd, tag: adjustedTag };
}
/**
* Adds weight to a lora tag if it doesn't already have one.
* @param {string} loraTag The model tag string (e.g., "<lora:my_style01>"
* @param {number} defaultWeight The default weight to add if not present
* @returns {string} The model tag with weight (e.g., "<lora:my_style01:1.0>")
*/
export function addWeightToLora(loraTag, defaultWeight = 1.0) {
if (!loraTag) return loraTag;
// Match LoRA format: <lora:name> or <lora:name:weight>
const loraMatch = loraTag.match(/^(<lora:[^>:]+)(:[0-9.]+)?>$/i);
if (loraMatch) {
const existingWeight = loraMatch[2];
// If weight already exists, return as-is
if (existingWeight) {
return loraTag;
}
// Add default weight, preserving original case
return `${loraMatch[1]}:${defaultWeight.toFixed(1)}>`;
}
// If it's not a recognized model format, return as-is
return loraTag;
}
// --- End String Helper Functions ---
/**
* Load a CSS file dynamically.
* @param {string} href
* @param {string} href
*/
export function loadCSS(href) {
const link = document.createElement('link');
@@ -619,82 +584,3 @@ export function getViewportMargin() {
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;
}
/**
* 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;
}