├── .babelrc ├── .github └── workflows │ ├── publish-comfyui-registry.yml │ └── test.yml ├── .gitignore ├── .pylintrc ├── .stylelintrc ├── LICENSE ├── README.md ├── __init__.py ├── docs └── README_jp.md ├── modules ├── api.py └── downloader.py ├── package-lock.json ├── package.json ├── pyproject.toml ├── test.config.js ├── tests └── js │ └── utils.test.js └── web ├── css └── autocomplete-plus.css └── js ├── autocomplete.js ├── data.js ├── main.js ├── related-tags.js ├── settings.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /.github/workflows/publish-comfyui-registry.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | jobs: 11 | publish-node: 12 | name: Publish Custom Node to registry 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Publish Custom Node 18 | uses: Comfy-Org/publish-node-action@main 19 | with: 20 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests and Linters 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: jest 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '18' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run tests 24 | run: npm test 25 | 26 | linters: 27 | name: stylelint 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v1 32 | - uses: actions-hub/stylelint@master 33 | env: 34 | PATTERN : 'web/css/*.css' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # PyCharm 7 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 8 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 9 | # and can be added to the global gitignore or merged into this file. For a more nuclear 10 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 11 | .idea/ 12 | 13 | # npm packages 14 | node_modules/ 15 | 16 | # csv files 17 | data/*.csv 18 | data/.download 19 | 20 | # csv meta information 21 | csv_meta.json 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [FORMAT] 2 | max-line-length=240 -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-idiomatic-order" 5 | ], 6 | "plugins": [ 7 | "stylelint-order" 8 | ] 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 newtextdoc1111 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-Autocomplete-Plus 2 | 3 | ## English • [日本語](docs/README_jp.md) 4 | 5 | ![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7) 6 | 7 | ## Overview 8 | 9 | **ComfyUI-Autocomplete-Plus** is a custom node that provides multiple input assistance features for any text area in [ComfyUI](https://github.com/comfyanonymous/ComfyUI). Currently, it supports Danbooru and e621 tags (e621 does not support some functions). 10 | 11 | ## Recent Updates :fire: 12 | - Added automatic and manual update check functionality for CSV files 13 | - Fixed bug where autocomplete suggestions wouldn't display at certain timings with Microsoft IME 14 | - Support for loading and displaying e621 tag CSV 15 | 16 | ## Features 17 | 18 | - **:zap:No setup required**: Automatically downloads CSV data optimized for Danbooru tags. 19 | - **:mag:Autocomplete**: Displays tag suggestions in real-time based on your input as you type. 20 | - **:file_cabinet:Related Tags Display**: Shows a list of tags highly related to the selected tag. 21 | - **:earth_asia:Multilingual Support**: Supports input completion in Japanese, Chinese, and Korean. 22 | - **:computer_mouse:Intuitive Operation**: 23 | - Supports both mouse and keyboard operations. 24 | - Natural tag insertion that considers cursor position and existing text. 25 | - **:art:Design**: Supports both light and dark themes of ComfyUI. 26 | - **:pencil:User CSV**: Allows users to add their own CSV files for autocomplete suggestions. 27 | - **Zero Dependencies**: All input assistance processing works in the browser without external libraries. 28 | 29 | ## Installation 30 | 31 | ### ComfyUI-Manager 32 | 33 | 1. Search for `Autocomplete-Plus` in [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager), install the custom node that appears, and restart. 34 | 2. The necessary CSV data will be automatically downloaded from HuggingFace upon startup. 35 | 36 | ### Manual 37 | 38 | 1. Clone or copy this repository into the `custom_nodes` folder of ComfyUI. 39 | `git clone https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus.git` 40 | 2. Launch ComfyUI. The necessary CSV data will be automatically downloaded from HuggingFace upon startup. 41 | 42 | ## Autocomplete 43 | 44 | When you type in a text input area, tags that partially match the text are displayed in descending order of post count. You can select a tag with the up and down keys, and insert the selected tag by pressing Enter or Tab. 45 | 46 | - Tag aliases are also included in the search. Japanese hiragana and katakana are searched without distinction. 47 | - Tags are color-coded by category. The color-coding rules are the same as Danbooru. 48 | - Tags that have already been entered are displayed grayed out. 49 | - You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings. 50 | 51 | ## Related Tags 52 | 53 | ![ss02](https://github.com/user-attachments/assets/854571cd-01eb-4e92-a118-2303bec0b175) 54 | 55 | When you select any tag in a text input area, a list of highly related tags is displayed. You can insert a tag by clicking it or by selecting it with the up/down arrow keys and then pressing Enter or Tab. The UI's position and size are automatically adjusted based on the text area being edited. 56 | 57 | - The display position is primarily at the bottom of the text area and automatically adjusts vertically based on available space. 58 | - You can switch between vertical and horizontal display positions using the "↕️|↔️" button in the header. 59 | - 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. 60 | - 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. 61 | - You can display related tags for the cursor position by pressing `Ctrl+Shift+Space`. 62 | 63 | ## CSV Data 64 | 65 | 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. 66 | Since the basic CSV files are based on the Danbooru dataset publicly available on HuggingFace, the post counts and related tag information may differ from the Danbooru website. 67 | 68 | > [!IMPORTANT] 69 | > The basic CSV contains both SFW and NSFW tags. 70 | 71 | **danbooru_tags.csv** 72 | 73 | This is a tag information CSV file for autocomplete, containing tag names, categories, post counts, and aliases (including Japanese, Chinese, and Korean). The column structure is the same as that used in [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete). 74 | 75 | Tag information is filtered under the following conditions: 76 | - Post count of 100 or more 77 | - Image score of 5 or more 78 | - Category is `general, character, or copyright` 79 | - Tag name does not contain `(cosplay)` 80 | 81 | **danbooru_tags_cooccurrence.csv** 82 | 83 | This is a CSV file for related tag calculation, recording tag pairs and their co-occurrence counts. 84 | 85 | Tag pairs are further filtered from the tag information CSV under the following conditions: 86 | - Co-occurrence count of 100 or more 87 | 88 | ### e621 CSV 89 | 90 | Currently, automatic download of CSV for e621 is not supported, so please manually place a CSV with the same structure as `danbooru_tags.csv` in the data folder with the name `e621_tags.csv`. 91 | Also, displaying related tags is not supported. 92 | 93 | ### User CSV 94 | 95 | Users can also use their own CSV files. CSV files should be placed in the `data` folder according to the following naming convention: 96 | 97 | - **CSV for Autocomplete**: `_tags*.csv` 98 | - **CSV for Related Tags**: `_tags_cooccurrence*.csv` 99 | 100 | For example, you can add frequently used meta tags to the autocomplete suggestions by placing a file named `danbooru_tags_meta.csv` in the `data` folder. 101 | A header row is not required. A browser reload is necessary to apply the changes. 102 | 103 | **Example of meta tags:** 104 | ```csv 105 | tag,category,count,alias 106 | masterpiece,5,9999999, 107 | best_quality,5,9999999, 108 | high_quality,5,9999999, 109 | normal_quality,5,9999999, 110 | low_quality,5,9999999, 111 | worst_quality,5,9999999, 112 | ``` 113 | 114 | When the browser is reloaded, you can check the list of loaded CSV files in the ComfyUI console log. If a file is not included in the log output, please verify that the file name follows the naming convention. 115 | 116 | **Example of ComfyUI console log output:** 117 | ``` 118 | [Autocomplete-Plus] CSV file status: 119 | * Danbooru -> base: True, extra: danbooru_tags_meta.csv // If displayed here, meta tags can be autocompleted 120 | * E621 -> base: False, extra: 121 | ``` 122 | 123 | >[!NOTE] 124 | > 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. 125 | 126 | ## Settings 127 | 128 | ### Tag Source 129 | 130 | > [!TIP] 131 | > The source of tag data such as Danbooru or e621 is called the "tag source". 132 | 133 | - **Autocomplete Tag Source**: The tag source to display in the autocomplete suggestions. Select "all" to display all loaded tag sources. 134 | - **Primary source for 'all' Source**: When `Autocomplete Tag Source` is set to "all", the tag source specified here will be displayed with priority. 135 | - **Tag Source Icon Position**: Where to display the icon of the tag source. Select "hidden" to hide it. 136 | 137 | ### Autocomplete 138 | 139 | - **Enable Autocomplete**: Enable/disable the autocomplete feature. 140 | - **Max suggestions**: Maximum number of autocomplete suggestions to display. 141 | 142 | ### Related Tags 143 | 144 | - **Enable Related Tags**: Enable/disable the related tags feature. 145 | - **Max related tags**: Maximum number of related tags to display. 146 | - **Default Display Position**: Default display position when ComfyUI starts. 147 | - **Related Tags Trigger Mode**: Which action will trigger displaying related tags (click only, Ctrl+click) 148 | 149 | ### Miscellaneous 150 | 151 | - **Check CSV updates**: Click the "Check Now" button to check if new CSV files are available in HuggingFace and download them if necessary. 152 | 153 | ## Known Issues 154 | 155 | ### Performance 156 | 157 | - Due to the large size of the CSV files, browser startup time may be longer. 158 | - It consumes memory to operate quickly in the browser. This should not be an issue on machines with specs capable of running ComfyUI. 159 | 160 | ### Autocomplete 161 | 162 | ### Related Tags 163 | - Cannot retrieve the correct tag when clicking on a dynamic prompt like `from {above|below|side}`. This is because the exact tag is not determined until the wildcard processor is executed. 164 | 165 | ## Credits 166 | 167 | - [ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) 168 | - Referenced for implementing the autocomplete function. 169 | - [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete) 170 | - Referenced for autocomplete function and CSV specifications. -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .modules.api import * 2 | from .modules import downloader 3 | 4 | # check and download necessary csv files 5 | dl = downloader.Downloader() 6 | dl.run_check_and_download() 7 | 8 | WEB_DIRECTORY = "./web" 9 | NODE_CLASS_MAPPINGS = {} 10 | __all__ = [] 11 | -------------------------------------------------------------------------------- /docs/README_jp.md: -------------------------------------------------------------------------------- 1 | # ComfyUI-Autocomplete-Plus 2 | 3 | ![ss01](https://github.com/user-attachments/assets/bb139951-ad78-4d87-b290-97aafa1221d7) 4 | 5 | ## 概要 6 | 7 | **ComfyUI-Autocomplete-Plus** は、[ComfyUI](https://github.com/comfyanonymous/ComfyUI) の任意のテキストエリアに複数の入力支援機能を提供するカスタムノードです。現在はDanbooruとe621のタグに対応しています(e621は一部の機能が未対応です)。 8 | 9 | ## 最近の更新 :fire: 10 | - CSV ファイルの自動・手動更新チェック機能の追加 11 | - Microsoft IMEでオートコンプリート候補が表示されないタイミングがある不具合の修正 12 | - e621タグ CSV の読み込みと表示のサポート 13 | 14 | ## 特徴 15 | 16 | - **:zap:セットアップ不要**: Danbooruタグに最適化された CSV データを自動でダウンロード 17 | - **:mag:オートコンプリート**: テキスト入力中に、入力内容に基づいてタグ候補をリアルタイムで表示 18 | - **:file_cabinet:関連タグ表示機能**: 選択したタグと関連性の高いタグを一覧表示 19 | - **:earth_asia:多言語対応**: 日本語、中国語、韓国語での入力補完をサポート 20 | - **:computer_mouse:直感的な操作**: 21 | - マウスとキーボードどちらの操作にも対応 22 | - カーソル位置や既存のテキストを考慮した自然なタグ挿入 23 | - **:art:デザイン**: ComfyUIのライトテーマとダークテーマの両方に対応 24 | - **:pencil:ユーザーCSV**: ユーザーが用意した CSV をオートコンプリート候補に追加可能 25 | - **依存ライブラリゼロ**: 外部ライブラリを使用せず、すべての入力支援処理がブラウザで動作 26 | 27 | ## インストール 28 | 29 | ### ComfyUI-Manager 30 | 31 | 1. [ComfyUI-Manager](https://github.com/Comfy-Org/ComfyUI-Manager) で `Autocomplete-Plus` と検索して表示されたカスタムノードをインストールし、再起動します 32 | 2. 起動時に必要な CSV データが HuggingFace から自動的にダウンロードされます 33 | 34 | ### マニュアル 35 | 36 | 1. このリポジトリを ComfyUI の `custom_nodes` フォルダにクローンまたはコピーします 37 | `git clone https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus.git` 38 | 2. ComfyUI を起動します。起動時に必要な CSV データが HuggingFace から自動的にダウンロードされます 39 | 40 | ## オートコンプリート 41 | 42 | テキスト入力エリアで文字を入力すると、テキストに部分一致するタグを投稿数の多い順で表示します。上下キーで選択、EnterかTabキーで選択したタグを挿入できます。 43 | 44 | - タグのエイリアスも検索対象に含まれます。日本語のひらがな、カタカナは区別せず検索されます 45 | - タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです 46 | - 入力済みのタグはグレーアウトで表示されます 47 | - Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます 48 | 49 | ## 関連タグ 50 | 51 | ![ss02](https://github.com/user-attachments/assets/854571cd-01eb-4e92-a118-2303bec0b175) 52 | 53 | テキスト入力エリアの任意のタグを選択すると、関連性の高いタグを一覧表示します。タグをクリックするか、キーボードの上下キーで選択後にEnterまたはTabキーでタグを挿入出来ます。UIは編集中のテキストエリアを基準に位置とサイズが自動で調整されます。 54 | 55 | - 表示位置は、テキストエリアの下部を基本とし、空きスペースに応じて上下に自動調整されます 56 | - ヘッダーの「↕️|↔️」ボタンで上下と左右の表示位置に切り替えられます 57 | - ヘッダーの「📌|🎯」ボタンで表示する関連タグの固定状態を切り替えられます。固定状態で閉じたい場合はEscキーを押します 58 | - 入力済みのタグはグレーアウトで表示されます。グレーアウトしたタグを挿入しようとした場合、代わりに入力済みのタグを選択状態にします 59 | - `Ctrl+Shift+Space` キーでカーソル位置の関連タグを表示できます 60 | 61 | ## CSV データ 62 | 63 | 動作には基本となる CSV データが2つ必要です。これらは [HuggingFace](https://huggingface.co/datasets/newtextdoc1111/danbooru-tag-csv) で管理されており、 ComfyUI にインストール後の初回起動時に自動でダウンロードされるのでセットアップは不要です。 64 | 基本 CSV ファイルはHuggingFaceで公開されているDanbooruデータセットを元にしているので、Danbooruサイトの投稿数や関連タグの情報と異なる場合があります。 65 | 66 | > [!IMPORTANT] 67 | > 基本 CSV にはSFW, NSFW両方のタグが含まれています。 68 | 69 | **danbooru_tags.csv** 70 | 71 | タグ名、カテゴリ、投稿数、エイリアス(日本語、中国語、韓国語を含む)の情報を持つオートコンプリート用のタグ情報 CSV ファイルです。このカラム構成は [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete) で使用されているものと同じです。 72 | 73 | タグ情報は以下の条件でフィルタリングされています。 74 | - 投稿数100件以上 75 | - 投稿画像のスコアが5以上 76 | - カテゴリが `general, character, copyright` のいずれか 77 | - タグ名に `(cosplay)` が含まれていない 78 | 79 | **danbooru_tags_cooccurrence.** 80 | 81 | タグペアとその共起回数を記録した、関連タグ計算用の CSV ファイルです。 82 | 83 | タグペアはタグ情報 CSV から、さらに以下の条件でフィルタリングされています。 84 | - 共起回数が100件以上 85 | 86 | ### e621 CSV 87 | 88 | 現在、e621用 CSV の自動ダウンロードは未対応なため `danbooru_tags.csv` と同じ構造の CSV を `e621_tags.csv` という名前でデータフォルダーに手動配置してください。 89 | また、関連タグ表示も同様に未対応です。 90 | 91 | ### ユーザーCSV 92 | 93 | ユーザーが自身で用意した CSV を使用することも可能です。 CSV ファイルは以下の命名規則に従って `data` フォルダーに配置してください。 94 | 95 | - **オートコンプリート用 CSV**: _tags*.csv 96 | - **関連タグ用 CSV**: _tags_cooccurrence*.csv 97 | 98 | 例として、よく使うメタタグを `danbooru_tags_meta.csv` の名前で `data` フォルダーに配置することでオートコンプリート候補に追加できます。 99 | ヘッダー行はなくても構いません。反映にはブラウザのリロードが必要です。 100 | 101 | **メタタグの例** 102 | ```csv 103 | tag,category,count,alias 104 | masterpiece,5,9999999, 105 | best_quality,5,9999999, 106 | high_quality,5,9999999, 107 | normal_quality,5,9999999, 108 | low_quality,5,9999999, 109 | worst_quality,5,9999999, 110 | ``` 111 | 112 | ブラウザリロード時、ロードされる CSV ファイル一覧をComfyUIのコンソールのログで確認出来ます。ログ出力に含まれていない場合はファイル名が命名規則に沿っているか確認してください。 113 | 114 | **ComfyUIコンソールログ出力の例:** 115 | ``` 116 | [Autocomplete-Plus] CSV file status: 117 | * Danbooru -> base: True, extra: danbooru_tags_meta.csv // ここに表示されていればメタタグを入力補完できます 118 | * E621 -> base: False, extra: 119 | ``` 120 | 121 | >[!NOTE] 122 | > ユーザー CSV が複数ある場合アルファベット順に読み込まれます。同じタグが複数のファイルに存在する場合は先に読み込まれた方が保持されます。基本 CSV は最後にロードされます。 123 | 124 | ## 設定 125 | 126 | ### タグソース 127 | 128 | > [!TIP] 129 | > Danbooruやe621等のタグデータの提供元を「タグソース」と呼びます 130 | 131 | - **Autocomplete Tag Source**: オートコンプリート候補に表示するタグソース。「all」を選択するとロード済みの全てのタグソースを表示します 132 | - **Primary source for 'all' Source**: `Autocomplete Tag Source` が「all」のとき、ここで指定したタグソースが優先して表示されます 133 | - **Tag Source Icon Position**: タグの情報源のアイコンをどの位置に表示するか。「hidden」を選択すると非表示になります 134 | 135 | ### オートコンプリート 136 | 137 | - **Enable Autocomplete**: オートコンプリート機能の有効化/無効化 138 | - **Max suggestions**: オートコンプリート候補の最大表示件数 139 | 140 | ### 関連タグ 141 | 142 | - **Enable Related Tags**: 関連タグ機能の有効化/無効化 143 | - **Max related tags**: 関連タグの最大表示件数 144 | - **Default Display Position**: ComfyUI起動時のデフォルト表示位置 145 | - **Related Tags Trigger Mode** : 関連タグを表示する際、どの操作をトリガーとするか(クリックのみ、Ctrl+クリック) 146 | 147 | ### その他 148 | 149 | - **Check CSV updates**: 「Check Now」ボタンを押すと新しい CSV ファイルがHuggingFaceにあるか確認し、必要に応じてダウンロードを行います 150 | 151 | ## 既知の問題 152 | 153 | ### パフォーマンス 154 | 155 | - CSV ファイルの容量が大きいため、ブラウザの起動時間が長くなる場合があります 156 | - ブラウザ上で高速に動作させるためにメモリを消費します。ComfyUIが動作するスペックのマシンであれば問題にはならないと思います 157 | 158 | ### オートコンプリート 159 | 160 | ### 関連タグ 161 | - ダイナミックプロンプト `from {above|below|side}` をクリックしたときに正しいタグを取得出来ない。これはワイルドカードプロセッサーが実行されるまで正確なタグが確定しないためです 162 | 163 | ## クレジット 164 | 165 | - [ComfyUI-Custom-Node](https://github.com/pythongosssss/ComfyUI-Custom-Scripts) 166 | - オートコンプリート機能の実装にあたり参考にしました 167 | - [DominikDoom/a1111-sd-webui-tagcomplete](https://github.com/DominikDoom/a1111-sd-webui-tagcomplete) 168 | - オートコンプリートの主要な機能や CSV の仕様で参考にしました 169 | -------------------------------------------------------------------------------- /modules/api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import server 4 | from aiohttp import web 5 | from . import downloader as dl 6 | 7 | # Get the absolute path to the 'data' directory 8 | # __file__ is the path to the current script (api.py) 9 | # os.path.dirname(__file__) is the directory of the current script (modules) 10 | # os.path.join(..., '..', 'data') goes up one level and then into 'data' 11 | DATA_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data")) 12 | 13 | DANBOORU_PREFIX = 'danbooru' 14 | E621_PREFIX = 'e621' 15 | 16 | TAGS_SUFFIX = 'tags' 17 | COOCCURRENCE_SUFFIX = 'tags_cooccurrence' 18 | 19 | def get_csv_file_status(): 20 | """ 21 | Returns a dictionary of csv file statuses. 22 | """ 23 | 24 | data = { 25 | DANBOORU_PREFIX: { 26 | 'base_tags': False, 27 | 'extra_tags': [], 28 | 'base_cooccurrence': False, 29 | 'extra_cooccurrence': [], 30 | }, 31 | E621_PREFIX: { 32 | 'base_tags': False, 33 | 'extra_tags': [], 34 | 'base_cooccurrence': False, 35 | 'extra_cooccurrence': [], 36 | } 37 | } 38 | 39 | for prefix in [DANBOORU_PREFIX, E621_PREFIX]: 40 | base_tags_file = f"{prefix}_{TAGS_SUFFIX}.csv" 41 | base_cooccurrence_file = f"{prefix}_{COOCCURRENCE_SUFFIX}.csv" 42 | 43 | tags_base_exists = os.path.exists(os.path.join(DATA_DIR, base_tags_file)) 44 | cooccurrence_base_exists = os.path.exists(os.path.join(DATA_DIR, base_cooccurrence_file)) 45 | 46 | tags_extra_files = [] 47 | cooccurrence_extra_files = [] 48 | 49 | all_csv_files = [f for f in os.listdir(DATA_DIR) if f.startswith(prefix) and f.endswith('.csv')] 50 | 51 | # Create extra CSV files list 52 | for filename in all_csv_files: 53 | if filename in [base_tags_file, base_cooccurrence_file]: 54 | continue # Skip base files 55 | if COOCCURRENCE_SUFFIX in filename.lower(): 56 | cooccurrence_extra_files.append(filename) 57 | elif TAGS_SUFFIX in filename.lower(): 58 | tags_extra_files.append(filename) 59 | 60 | data[prefix] = { 61 | 'base_tags': tags_base_exists, 62 | 'extra_tags': tags_extra_files, 63 | 'base_cooccurrence': cooccurrence_base_exists, 64 | 'extra_cooccurrence': cooccurrence_extra_files, 65 | } 66 | 67 | # Return the lists of extra files 68 | return data 69 | 70 | def get_last_check_time_from_metadata(): 71 | """ 72 | Helper function to get the last remote check timestamp from csv_meta.json. 73 | Returns the timestamp string if found, None otherwise. 74 | """ 75 | try: 76 | if not os.path.exists(dl.CSV_META_FILE): 77 | return None 78 | 79 | with open(dl.CSV_META_FILE, 'r', encoding='utf-8') as f: 80 | metadata = json.load(f) 81 | 82 | datasets = metadata.get("hf_datasets", []) 83 | if datasets and len(datasets) > 0: 84 | return datasets[0].get("last_remote_check_timestamp") 85 | 86 | return None 87 | 88 | except (IOError, json.JSONDecodeError) as e: 89 | print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}") 90 | return None 91 | 92 | # --- API Endpoints --- 93 | 94 | @server.PromptServer.instance.routes.get('/autocomplete-plus/csv') 95 | async def get_csv_list(_request): 96 | """ 97 | Returns CSV file status. 98 | base files: file exists boolean 99 | extra files: count of extra files 100 | """ 101 | csv_file_status = get_csv_file_status() 102 | 103 | response = { 104 | DANBOORU_PREFIX: { 105 | 'base_tags': csv_file_status[DANBOORU_PREFIX]['base_tags'], 106 | 'extra_tags': csv_file_status[DANBOORU_PREFIX]['extra_tags'], 107 | 'base_cooccurrence': csv_file_status[DANBOORU_PREFIX]['base_cooccurrence'], 108 | 'extra_cooccurrence': csv_file_status[DANBOORU_PREFIX]['extra_cooccurrence'], 109 | }, 110 | E621_PREFIX: { 111 | 'base_tags': csv_file_status[E621_PREFIX]['base_tags'], 112 | 'extra_tags': csv_file_status[E621_PREFIX]['extra_tags'], 113 | 'base_cooccurrence': csv_file_status[E621_PREFIX]['base_cooccurrence'], 114 | 'extra_cooccurrence': csv_file_status[E621_PREFIX]['extra_cooccurrence'], 115 | } 116 | } 117 | 118 | # Print csv file status to the console for debugging 119 | print(f"""[Autocomplete-Plus] CSV file status: 120 | * Danbooru -> base: {response[DANBOORU_PREFIX]['base_tags']}, extra: {", ".join(response[DANBOORU_PREFIX]['extra_tags'])} 121 | * E621 -> base: {response[E621_PREFIX]['base_tags']}, extra: {", ".join(response[E621_PREFIX]['extra_tags'])}""") 122 | 123 | return web.json_response(response) 124 | 125 | @server.PromptServer.instance.routes.get('/autocomplete-plus/csv/{source}/{suffix}/base') 126 | async def get_base_tags_file(request): 127 | """ 128 | Returns the base tags CSV file. 129 | """ 130 | source = str(request.match_info['source']) 131 | suffix = str(request.match_info['suffix']) 132 | if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [TAGS_SUFFIX, COOCCURRENCE_SUFFIX]: 133 | return web.json_response({"error": "Invalid tag source or suffix"}, status=400) 134 | 135 | file_path = os.path.join(DATA_DIR, f"{source}_{suffix}.csv") 136 | if not os.path.exists(file_path): 137 | return web.json_response({"error": "Base tags file not found"}, status=404) 138 | 139 | return web.FileResponse(file_path) 140 | 141 | @server.PromptServer.instance.routes.get('/autocomplete-plus/csv/{source}/{suffix}/extra/{index}') 142 | async def get_extra_tags_file(request): 143 | """ 144 | Returns the extra tags CSV file at the specified index. 145 | """ 146 | try: 147 | csv_file_status = get_csv_file_status() 148 | 149 | source = str(request.match_info['source']) 150 | suffix = str(request.match_info['suffix']) 151 | if source not in [DANBOORU_PREFIX, E621_PREFIX] or suffix not in [TAGS_SUFFIX, COOCCURRENCE_SUFFIX]: 152 | return web.json_response({"error": "Invalid tag source or suffix"}, status=400) 153 | 154 | index = int(request.match_info['index']) 155 | if index < 0 or index >= len(csv_file_status[source][f'extra_{suffix}']): 156 | return web.json_response({"error": "Invalid index"}, status=404) 157 | 158 | file_path = os.path.join(DATA_DIR, csv_file_status[source][f'extra_{suffix}'][index]) 159 | if not os.path.exists(file_path): 160 | return web.json_response({"error": "Extra tags file not found"}, status=404) 161 | 162 | return web.FileResponse(file_path) 163 | 164 | except ValueError: 165 | return web.json_response({"error": "Invalid index format"}, status=400) 166 | 167 | @server.PromptServer.instance.routes.post('/autocomplete-plus/csv/force-check-updates') 168 | async def force_check_csv_updates(request): 169 | """ 170 | Forces a check for CSV file updates from HuggingFace, ignoring cooldown. 171 | This allows users to manually trigger an update check at any time. 172 | """ 173 | try: 174 | print("[Autocomplete-Plus] Starting forced check for CSV updates from HuggingFace...") 175 | 176 | downloader = dl.Downloader() 177 | downloader.run_check_and_download(force_check=True) 178 | 179 | print("[Autocomplete-Plus] Forced check completed successfully.") 180 | 181 | # Get the updated last check time 182 | last_check_time = get_last_check_time_from_metadata() 183 | 184 | return web.json_response({ 185 | "success": True, 186 | "message": "Force check completed successfully", 187 | "last_check_time": last_check_time 188 | }) 189 | 190 | except Exception as e: 191 | print(f"[Autocomplete-Plus] Error during forced check: {e}") 192 | return web.json_response({ 193 | "success": False, 194 | "error": str(e) 195 | }, status=500) 196 | 197 | @server.PromptServer.instance.routes.get('/autocomplete-plus/csv/last-check-time') 198 | async def get_last_check_time(_request): 199 | """ 200 | Returns the last remote check timestamp from csv_meta.json. 201 | Returns null if the file doesn't exist or if there's an error reading it. 202 | """ 203 | try: 204 | if not os.path.exists(dl.CSV_META_FILE): 205 | return web.json_response({ 206 | "last_check_time": None, 207 | "message": "csv_meta.json file not found" 208 | }) 209 | 210 | last_check_time = get_last_check_time_from_metadata() 211 | 212 | if last_check_time is not None: 213 | return web.json_response({ 214 | "last_check_time": last_check_time 215 | }) 216 | else: 217 | return web.json_response({ 218 | "last_check_time": None, 219 | "message": "No datasets found in metadata" 220 | }) 221 | 222 | except (IOError, json.JSONDecodeError) as e: 223 | print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}") 224 | return web.json_response({ 225 | "last_check_time": None, 226 | "error": str(e) 227 | }, status=500) -------------------------------------------------------------------------------- /modules/downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import urllib.request 4 | import urllib.error 5 | import shutil 6 | import sys 7 | from datetime import datetime, timezone, timedelta 8 | from email.utils import parsedate_to_datetime 9 | from tqdm import tqdm 10 | 11 | # --- File Definitions --- 12 | 13 | # Get the absolute path to the "data" directory 14 | DATA_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "data")) 15 | TEMP_DOWNLOAD_DIR = os.path.join(DATA_DIR, ".download") 16 | 17 | # --- Metadata Constants --- 18 | CSV_META_FILE_NAME = "csv_meta.json" 19 | CSV_META_FILE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", CSV_META_FILE_NAME)) 20 | 21 | DEFAULT_CSV_METADATA = { 22 | "version": 1, 23 | "hf_datasets" : [ 24 | { 25 | "hf_dataset_id": "newtextdoc1111/danbooru-tag-csv", 26 | "last_remote_check_timestamp": None, 27 | "csv_files": [ 28 | { 29 | "file_name": "danbooru_tags.csv", 30 | "last_download": None, 31 | "last_modified_on_hf": None, 32 | }, 33 | { 34 | "file_name": "danbooru_tags_cooccurrence.csv", 35 | "last_download": None, 36 | "last_modified_on_hf": None, 37 | } 38 | ] 39 | } 40 | ] 41 | } 42 | 43 | 44 | # --- Helper Functions --- 45 | def get_file_path(file_name: str) -> str: 46 | """Returns the full local path for a given file_name.""" 47 | return os.path.join(DATA_DIR, file_name) 48 | 49 | 50 | def get_temp_download_path(file_name: str) -> str: 51 | """Returns the full temporary download path for a given file_name.""" 52 | return os.path.join(TEMP_DOWNLOAD_DIR, file_name) 53 | 54 | 55 | def check_file_valid(file_path): 56 | """Checks if a file is valid by verifying its existence and size.""" 57 | return os.path.exists(file_path) and os.path.getsize(file_path) > 0 58 | 59 | 60 | class Downloader: 61 | """ 62 | Downloader class to manage downloading and checking of CSV files from HuggingFace. 63 | """ 64 | 65 | def __init__(self): 66 | """Initializes the Downloader.""" 67 | self.csv_meta_file_exists_at_start = False 68 | 69 | self._ensure_directories_exist() 70 | self.metadata = self._load_metadata() 71 | 72 | def get_default_csv_metadata(self): 73 | """Returns the default CSV metadata.""" 74 | return json.loads(json.dumps(DEFAULT_CSV_METADATA)) 75 | 76 | def _load_metadata(self) -> list: 77 | """Loads metadata from CSV_META_FILE. Returns default if not found or error.""" 78 | default_metadata = self.get_default_csv_metadata() 79 | 80 | if not os.path.exists(CSV_META_FILE): 81 | print(f"[Autocomplete-Plus] Metadata file not found: {CSV_META_FILE}. Using default metadata.") 82 | return default_metadata 83 | 84 | try: 85 | with open(CSV_META_FILE, 'r', encoding='utf-8') as f: 86 | metadata = json.load(f) 87 | 88 | if not isinstance(metadata, dict) or metadata.get("version") != DEFAULT_CSV_METADATA["version"]: 89 | print(f"[Autocomplete-Plus] Metadata version mismatch. Expected {DEFAULT_CSV_METADATA['version']}, " 90 | f"found {metadata.get('version')}. Using default metadata.") 91 | return default_metadata 92 | else: 93 | self.csv_meta_file_exists_at_start = True 94 | return metadata 95 | 96 | except (IOError, json.JSONDecodeError) as e: 97 | print(f"[Autocomplete-Plus] Error loading metadata from {CSV_META_FILE}: {e}. Using default metadata.") 98 | return default_metadata 99 | 100 | def _save_metadata(self): 101 | """Saves metadata to CSV_META_FILE.""" 102 | try: 103 | os.makedirs(os.path.dirname(CSV_META_FILE), exist_ok=True) 104 | with open(CSV_META_FILE, 'w', encoding='utf-8') as f: 105 | json.dump(self.metadata, f, indent=2) 106 | except IOError as e: 107 | print(f"[Autocomplete-Plus] Error saving metadata to {CSV_META_FILE}: {e}") 108 | 109 | def _get_hf_file_last_modified(self, dataset_repo_id: str, hf_filename: str) -> str | None: 110 | """ 111 | Retrieves the Last-Modified header for a file on HuggingFace and returns it as an ISO 8601 string. 112 | hf_filename should be the full filename, e.g., "danbooru_tags.csv". 113 | """ 114 | url = f"https://huggingface.co/datasets/{dataset_repo_id}/resolve/main/{hf_filename}" 115 | try: 116 | req = urllib.request.Request(url, method='HEAD', 117 | headers={"User-Agent": "ComfyUI-Autocomplete-Plus (Python urllib)"}) 118 | with urllib.request.urlopen(req, timeout=10) as response: 119 | last_modified_http = response.getheader('Last-Modified') 120 | if last_modified_http: 121 | dt_object = parsedate_to_datetime(last_modified_http) 122 | if dt_object.tzinfo is None or dt_object.tzinfo.utcoffset(dt_object) is None: 123 | dt_object = dt_object.replace(tzinfo=timezone.utc) 124 | else: 125 | dt_object = dt_object.astimezone(timezone.utc) 126 | return dt_object.isoformat() 127 | else: 128 | print(f"[Autocomplete-Plus] 'Last-Modified' header not found for {hf_filename} at {url}") 129 | return None 130 | except urllib.error.HTTPError as e: 131 | print(f"[Autocomplete-Plus] HTTP error when checking last modified for {hf_filename}: {e.code} {e.reason}") 132 | return None 133 | except (urllib.error.URLError, TimeoutError) as e: 134 | print(f"[Autocomplete-Plus] URL or network error when checking last modified for {hf_filename}: {e}") 135 | return None 136 | except Exception as e: 137 | print(f"[Autocomplete-Plus] Unexpected error when checking last modified for {hf_filename}: {e}") 138 | return None 139 | 140 | def _download_file_with_progress_sync(self, hf_dataset_id, file_name: str, metadata_entry: dict): 141 | """ 142 | Downloads a file synchronously with progress display to a temporary location. 143 | """ 144 | download_url = f"https://huggingface.co/datasets/{hf_dataset_id}/resolve/main/{file_name}" 145 | final_path = get_file_path(file_name) 146 | temp_path = get_temp_download_path(file_name) 147 | now_utc = datetime.now(timezone.utc).isoformat() 148 | 149 | print(f"[Autocomplete-Plus] Attempting to download {file_name} from {download_url}") 150 | 151 | downloaded_size = 0 152 | total_size = 0 153 | 154 | try: 155 | if os.path.exists(temp_path): 156 | os.remove(temp_path) 157 | 158 | req = urllib.request.Request(download_url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 11.0; Win64)"}) 159 | 160 | with urllib.request.urlopen(req) as response: 161 | total_size_str = response.getheader("Content-Length") 162 | total_size = int(total_size_str) if total_size_str else None 163 | chunk_size = 8192 164 | 165 | with open(temp_path, "wb") as f_out, \ 166 | tqdm(total=total_size, unit='B', unit_scale=True, unit_divisor=1024, 167 | desc=f"[Autocomplete-Plus] Downloading {file_name}", leave=False, 168 | bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}{postfix}]' 169 | ) as pbar: 170 | while True: 171 | chunk = response.read(chunk_size) 172 | if not chunk: 173 | break 174 | 175 | f_out.write(chunk) 176 | downloaded_size += len(chunk) 177 | pbar.update(len(chunk)) 178 | 179 | sys.stdout.write("\r" + " " * 100 + "\r") 180 | sys.stdout.flush() 181 | 182 | shutil.move(temp_path, final_path) 183 | print(f"[Autocomplete-Plus] Successfully downloaded and moved {file_name} to {final_path}.") 184 | metadata_entry["last_download"] = now_utc 185 | 186 | except (urllib.error.URLError, OSError, TimeoutError) as e: 187 | sys.stdout.write("\n") 188 | sys.stdout.flush() 189 | print(f"[Autocomplete-Plus] Error downloading {file_name}: {e}") 190 | if os.path.exists(temp_path): 191 | try: 192 | os.remove(temp_path) 193 | print(f"[Autocomplete-Plus] Removed partially downloaded file from temp: {temp_path}") 194 | except OSError as rm_e: 195 | print(f"[Autocomplete-Plus] Error removing temporary file {temp_path}: {rm_e}") 196 | 197 | if os.path.exists(final_path): 198 | try: 199 | file_size_at_final = os.path.getsize(final_path) 200 | if file_size_at_final == 0 or \ 201 | (total_size and total_size > 0 and file_size_at_final < total_size) or \ 202 | (not total_size and downloaded_size > 0 and file_size_at_final < downloaded_size): 203 | os.remove(final_path) 204 | print( 205 | f"[Autocomplete-Plus] Removed potentially corrupted file at final destination: {final_path}") 206 | except OSError as rm_e: 207 | print(f"[Autocomplete-Plus] Error removing potentially corrupted file {final_path}: {rm_e}") 208 | 209 | def _ensure_directories_exist(self): 210 | """Ensures that DATA_DIR and TEMP_DOWNLOAD_DIR exist.""" 211 | os.makedirs(DATA_DIR, exist_ok=True) 212 | os.makedirs(TEMP_DOWNLOAD_DIR, exist_ok=True) 213 | 214 | def _check_new_csv_from_hf_dataset(self, dataset_meta: dict, now_utc: datetime, force_check: bool = False): 215 | """Checks HuggingFace for file updates and updates metadata.""" 216 | perform_hf_check = True 217 | if not force_check and dataset_meta.get("last_remote_check_timestamp"): 218 | try: 219 | last_check_dt = datetime.fromisoformat(dataset_meta["last_remote_check_timestamp"]) 220 | if now_utc - last_check_dt < timedelta(days=7): 221 | perform_hf_check = False 222 | except (ValueError, KeyError, TypeError): 223 | print( 224 | "[Autocomplete-Plus] Invalid or missing timestamp for last_remote_check_timestamp. Will perform remote check.") 225 | 226 | if perform_hf_check: 227 | huggingface_dataset_id = dataset_meta["hf_dataset_id"] 228 | 229 | print(f"[Autocomplete-Plus] Checking HuggingFace dataset {huggingface_dataset_id} for file updates...") 230 | updated_all_hf_timestamps_successfully = True 231 | for file_meta_info in dataset_meta["csv_files"]: 232 | file_name_from_meta = file_meta_info.get("file_name") 233 | if not file_name_from_meta: 234 | print("[Autocomplete-Plus] Warning: file_name missing in csv_files metadata entry.") 235 | updated_all_hf_timestamps_successfully = False 236 | continue 237 | 238 | last_modified = self._get_hf_file_last_modified(huggingface_dataset_id, file_name_from_meta) 239 | if last_modified: 240 | file_meta_info["last_modified_on_hf"] = last_modified 241 | else: 242 | updated_all_hf_timestamps_successfully = False 243 | print(f"[Autocomplete-Plus] Failed to get remote last modified time for {file_name_from_meta}.") 244 | 245 | if updated_all_hf_timestamps_successfully: 246 | dataset_meta["last_remote_check_timestamp"] = now_utc.isoformat() 247 | else: 248 | print( 249 | "[Autocomplete-Plus] Could not update all remote timestamps from HuggingFace. Will try again later.") 250 | 251 | def _download_csv_files_if_needed(self, dataset_meta: dict): 252 | """Downloads CSV files if they are missing, outdated, or previously failed.""" 253 | 254 | for file_meta_entry in dataset_meta["csv_files"]: 255 | file_name = file_meta_entry.get("file_name") 256 | if not file_name: 257 | print("[Autocomplete-Plus] Warning: file_name missing in file_meta_entry during download check.") 258 | continue 259 | 260 | reason_for_download = self.check_csv_file_should_download(file_meta_entry, file_name) 261 | 262 | if reason_for_download: 263 | print(f"[Autocomplete-Plus] Queuing download for {file_name}: {reason_for_download}") 264 | self._download_file_with_progress_sync(dataset_meta["hf_dataset_id"], file_name, file_meta_entry) 265 | 266 | def check_csv_file_should_download(self, file_meta_entry, file_name): 267 | """ 268 | Determines if a file should be downloaded based on its metadata and local status. 269 | Returns a reason string if a download is needed, or None if no action is required. 270 | """ 271 | local_file_path = get_file_path(file_name) 272 | 273 | # If the CSV_META_FILE was not found initially, we need to download the file. 274 | if not self.csv_meta_file_exists_at_start: 275 | return f"{CSV_META_FILE_NAME} was not found initially. Forcing download of {file_name}." 276 | 277 | # If the file is empty or missing, we need to download it. 278 | if not check_file_valid(local_file_path): 279 | return f"File {file_name} is missing or empty locally." 280 | 281 | # Check if the last modified date on HuggingFace is newer than the last download date 282 | try: 283 | last_download_dt = datetime.fromisoformat(file_meta_entry["last_download"]) 284 | hf_modified_dt = datetime.fromisoformat(file_meta_entry["last_modified_on_hf"]) 285 | if hf_modified_dt > last_download_dt: 286 | return f"Remote file {file_name} is newer (HF: {last_download_dt}, Local Download: {hf_modified_dt})." 287 | except (ValueError, TypeError): 288 | file_meta_entry["last_download"] = None 289 | file_meta_entry["last_modified_on_hf"] = None 290 | return f"Invalid timestamp format for {file_name}. Forcing download to ensure integrity." 291 | 292 | # If the file is missing or empty, but the last download timestamp exists, we need to retry. 293 | if not check_file_valid(local_file_path) and file_meta_entry.get("last_download") is not None: 294 | return f"File {file_name} is missing or empty locally, but last download timestamp exists. Retrying." 295 | 296 | return None 297 | 298 | def run_check_and_download(self, force_check: bool = False): 299 | """ 300 | Orchestrates the process of checking for updates and downloading CSV files. 301 | This is the main entry point for the downloader logic. 302 | 303 | Args: 304 | force_check: If True, forces a check of HuggingFace regardless of the last check timestamp. 305 | """ 306 | 307 | now_utc = datetime.now(timezone.utc) 308 | 309 | datasets_meta = self.metadata.get("hf_datasets", []) 310 | for dataset_meta in datasets_meta: 311 | self._check_new_csv_from_hf_dataset(dataset_meta, now_utc, force_check) 312 | self._download_csv_files_if_needed(dataset_meta) 313 | 314 | self._save_metadata() 315 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test": "jest" 5 | }, 6 | "devDependencies": { 7 | "@babel/core": "^7.27.1", 8 | "@babel/preset-env": "^7.27.2", 9 | "babel-jest": "^29.7.0", 10 | "jest": "^29.7.0", 11 | "stylelint": "^16.19.1", 12 | "stylelint-config-idiomatic-order": "^10.0.0", 13 | "stylelint-config-standard": "^38.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-autocomplete-plus" 3 | description = "Autocomplete and Related Tag display for ComfyUI" 4 | version = "1.0.2" 5 | license = {file = "LICENSE"} 6 | dependencies = ["",] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/newtextdoc1111/ComfyUI-Autocomplete-Plus" 10 | # Used by Comfy Registry https://comfyregistry.org 11 | 12 | [tool.comfy] 13 | PublisherId = "newtextdoc1111" 14 | DisplayName = "ComfyUI-Autocomplete-Plus" 15 | Icon = "" 16 | -------------------------------------------------------------------------------- /test.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.jsx?$": "babel-jest" 4 | }, 5 | moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx", "node"], 6 | moduleDirectories: ["node_modules", "web/js"], 7 | moduleNameMapper: { 8 | }, 9 | testEnvironment: "jsdom" 10 | }; -------------------------------------------------------------------------------- /tests/js/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | extractTagsFromTextArea, 3 | normalizeTagToSearch, 4 | normalizeTagToInsert, 5 | getCurrentTagRange, 6 | findAllTagPositions 7 | } from '../../web/js/utils.js'; 8 | 9 | // filepath: web/js/utils.test.js 10 | 11 | 12 | describe('extractTagsFromTextArea', () => { 13 | // Helper to create a mock textarea with given value 14 | function createMockTextarea(value) { 15 | return { value }; 16 | } 17 | 18 | test('should return empty array for null or empty textarea', () => { 19 | expect(extractTagsFromTextArea(null)).toEqual([]); 20 | expect(extractTagsFromTextArea(undefined)).toEqual([]); 21 | expect(extractTagsFromTextArea({})).toEqual([]); 22 | expect(extractTagsFromTextArea(createMockTextarea(''))).toEqual([]); 23 | }); 24 | 25 | test('should extract a single tag from textarea', () => { 26 | const textarea = createMockTextarea('blue_hair'); 27 | expect(extractTagsFromTextArea(textarea)).toEqual(['blue_hair']); 28 | }); 29 | 30 | test('should extract multiple comma-separated tags', () => { 31 | const textarea = createMockTextarea('blue_hair, red_eyes, smile'); 32 | expect(extractTagsFromTextArea(textarea)).toEqual(['blue_hair', 'red_eyes', 'smile']); 33 | }); 34 | 35 | test('should extract tags from multiple lines', () => { 36 | const textarea = createMockTextarea('blue_hair\nred_eyes\nsmile'); 37 | expect(extractTagsFromTextArea(textarea)).toEqual(['blue_hair', 'red_eyes', 'smile']); 38 | }); 39 | 40 | test('should handle mixed newlines and commas', () => { 41 | const textarea = createMockTextarea('blue_hair, red_eyes\nsmile, 1girl'); 42 | expect(extractTagsFromTextArea(textarea)).toEqual(['blue_hair', 'red_eyes', 'smile', '1girl']); 43 | }); 44 | 45 | test('should normalize tags by applying normalizeTagToSearch', () => { 46 | // Since normalizeTagToSearch replaces spaces with underscores and handles parentheses, 47 | // we can test that behavior here 48 | const textarea = createMockTextarea('blue hair, (red eyes), standing:1.2'); 49 | 50 | // Manually apply the same normalization to verify 51 | const expected = [ 52 | normalizeTagToSearch('blue hair'), 53 | normalizeTagToSearch('(red eyes)'), 54 | normalizeTagToSearch('standing:1.2') 55 | ]; 56 | 57 | expect(extractTagsFromTextArea(textarea)).toEqual(expected); 58 | }); 59 | 60 | test('should handle empty tags and whitespace', () => { 61 | const textarea = createMockTextarea('blue_hair, , red_eyes, ,\n,smile'); 62 | expect(extractTagsFromTextArea(textarea)).toEqual(['blue_hair', 'red_eyes', 'smile']); 63 | }); 64 | 65 | test('should handle tags with special characters', () => { 66 | const textarea = createMockTextarea('tag\\(with\\)escaped, , __wildcard__'); 67 | const expected = [ 68 | normalizeTagToSearch('tag\\(with\\)escaped'), 69 | normalizeTagToSearch(''), 70 | normalizeTagToSearch('__wildcard__') 71 | ]; 72 | expect(extractTagsFromTextArea(textarea)).toEqual(expected); 73 | }); 74 | 75 | test('should extract tags with Asian characters', () => { 76 | const textarea = createMockTextarea('青い髪, 赤い目, 微笑'); 77 | expect(extractTagsFromTextArea(textarea)).toEqual(['青い髪', '赤い目', '微笑']); 78 | }); 79 | 80 | test('should extract tags with combined formatting', () => { 81 | const textarea = createMockTextarea('(blue hair:1.2), red_eyes, (smile)'); 82 | 83 | const expected = [ 84 | normalizeTagToSearch('(blue hair:1.2)'), 85 | normalizeTagToSearch('red_eyes'), 86 | normalizeTagToSearch('(smile)') 87 | ]; 88 | 89 | expect(extractTagsFromTextArea(textarea)).toEqual(expected); 90 | }); 91 | }); 92 | 93 | 94 | describe('normalizeTagToSearch', () => { 95 | test('should return null or empty string for invalid inputs', () => { 96 | expect(normalizeTagToSearch(null)).toBeNull(); 97 | expect(normalizeTagToSearch(undefined)).toBeUndefined(); 98 | expect(normalizeTagToSearch('')).toBe(''); 99 | }); 100 | 101 | test('should replace spaces with underscores for tags with letters/numbers', () => { 102 | expect(normalizeTagToSearch('blue hair')).toBe('blue_hair'); 103 | expect(normalizeTagToSearch('1girl')).toBe('1girl'); 104 | expect(normalizeTagToSearch('blue hair red eyes')).toBe('blue_hair_red_eyes'); 105 | }); 106 | 107 | test('should remove prompt weights but preserve complex tag names with colons', () => { 108 | expect(normalizeTagToSearch('blue hair:1.2')).toBe('blue_hair'); 109 | expect(normalizeTagToSearch('standing:0.8')).toBe('standing'); 110 | expect(normalizeTagToSearch('year:2000')).toBe('year:2000'); 111 | expect(normalizeTagToSearch('foo:bar')).toBe('foo:bar'); 112 | expect(normalizeTagToSearch('428:foo')).toBe('428:foo'); 113 | }); 114 | 115 | test('should unescape parentheses', () => { 116 | expect(normalizeTagToSearch('blue\\(hair\\)')).toBe('blue(hair)'); 117 | expect(normalizeTagToSearch('\\(tag\\)')).toBe('(tag)'); 118 | }); 119 | 120 | test('should not modify symbol-only tags', () => { 121 | expect(normalizeTagToSearch(';)')).toBe(';)'); 122 | expect(normalizeTagToSearch('^_^')).toBe('^_^'); 123 | }); 124 | 125 | test('should handle Asian characters', () => { 126 | expect(normalizeTagToSearch('青い髪')).toBe('青い髪'); 127 | expect(normalizeTagToSearch('青い 髪')).toBe('青い_髪'); 128 | }); 129 | 130 | test('should handle combined cases', () => { 131 | expect(normalizeTagToSearch('blue\\(hair\\):1.2')).toBe('blue(hair)'); 132 | expect(normalizeTagToSearch('(blue hair):0.9')).toBe('blue_hair'); 133 | expect(normalizeTagToSearch('year:2000\\(future\\)')).toBe('year:2000(future)'); 134 | }); 135 | }); 136 | 137 | describe('normalizeTagToInsert', () => { 138 | test('should return null or empty string for invalid inputs', () => { 139 | expect(normalizeTagToInsert(null)).toBeNull(); 140 | expect(normalizeTagToInsert(undefined)).toBeUndefined(); 141 | expect(normalizeTagToInsert('')).toBe(''); 142 | }); 143 | 144 | test('should replace underscores with spaces for tags with letters/numbers', () => { 145 | expect(normalizeTagToInsert('blue_hair')).toBe('blue hair'); 146 | expect(normalizeTagToInsert('1girl')).toBe('1girl'); 147 | expect(normalizeTagToInsert('blue_hair_red_eyes')).toBe('blue hair red eyes'); 148 | }); 149 | 150 | test('should escape parentheses', () => { 151 | expect(normalizeTagToInsert('blue(hair)')).toBe('blue\\(hair\\)'); 152 | expect(normalizeTagToInsert('(tag)')).toBe('\\(tag\\)'); 153 | }); 154 | 155 | test('should not replace underscores in symbol-only tags', () => { 156 | expect(normalizeTagToInsert('^_^')).toBe('^_^'); 157 | expect(normalizeTagToInsert(':)')).toBe(':\\)'); 158 | }); 159 | 160 | test('should handle Asian characters', () => { 161 | expect(normalizeTagToInsert('青い髪')).toBe('青い髪'); 162 | expect(normalizeTagToInsert('青い_髪')).toBe('青い 髪'); 163 | }); 164 | 165 | test('should handle combined cases', () => { 166 | expect(normalizeTagToInsert('blue_hair(style)')).toBe('blue hair\\(style\\)'); 167 | expect(normalizeTagToInsert('year:2000')).toBe('year:2000'); 168 | expect(normalizeTagToInsert('foo:bar')).toBe('foo:bar'); 169 | }); 170 | }); 171 | 172 | describe('getCurrentTagRange', () => { 173 | test('should return null for invalid inputs', () => { 174 | expect(getCurrentTagRange(null, 0)).toBeNull(); 175 | expect(getCurrentTagRange(undefined, 0)).toBeNull(); 176 | expect(getCurrentTagRange('', -1)).toBeNull(); 177 | }); 178 | 179 | test('should last tag if cursorPos exceeds text length', () => { 180 | const text = 'blue_hair, red_eyes, smile'; 181 | const cursorPos = text.length + 5; // Cursor position beyond text length 182 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 21, end: 26, tag: 'smile' }); 183 | }); 184 | 185 | test('should return last tag if cursorPos is exactly at the end of the text', () => { 186 | const text = 'blue_hair, red_eyes, smile'; 187 | const cursorPos = text.length; // Cursor position at the end of the text 188 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 21, end: 26, tag: 'smile' }); 189 | }); 190 | 191 | test('should return the correct tag range for a single tag', () => { 192 | const text = 'blue_hair'; 193 | const cursorPos = 5; // Cursor inside the tag 194 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 9, tag: 'blue_hair' }); 195 | }); 196 | 197 | test('should return the correct tag range for multiple tags', () => { 198 | const text = 'blue_hair, red_eyes, smile'; 199 | const cursorPos = 12; // Cursor inside "red_eyes" 200 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 11, end: 19, tag: 'red_eyes' }); 201 | }); 202 | 203 | test('should handle tags with parentheses', () => { 204 | const text = '(blue hair), (red eyes), smile'; 205 | const cursorPos = 5; // Cursor inside "(blue hair)" 206 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 1, end: 10, tag: 'blue hair' }); 207 | }); 208 | 209 | test('should handle tags with prompt weights', () => { 210 | const text = 'blue_hair:1.2, red_eyes:0.8'; 211 | const cursorPos = 5; // Cursor inside "blue_hair:1.2" 212 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 9, tag: 'blue_hair' }); 213 | }); 214 | 215 | test('should handle tags with escaped parentheses', () => { 216 | const text = 'tag\\(with\\)escaped, smile'; 217 | const cursorPos = 5; // Cursor inside "tag\\(with\\)escaped" 218 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 18, tag: 'tag\\(with\\)escaped' }); 219 | }); 220 | 221 | test('should handle tags with only symbols', () => { 222 | const text = ';), >:)'; 223 | const cursorPos = 1; // Cursor inside ";)" 224 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 2, tag: ';)' }); 225 | }); 226 | 227 | test('should handle Asian characters in tags', () => { 228 | const text = '青い髪, 赤い目, 微笑'; 229 | const cursorPos = 2; // Cursor inside "青い髪" 230 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 3, tag: '青い髪' }); 231 | }); 232 | 233 | test('should handle nested parentheses', () => { 234 | const text = '((blue hair)), smile'; 235 | const cursorPos = 5; // Cursor inside "((blue hair))" 236 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 2, end: 11, tag: 'blue hair' }); 237 | }); 238 | 239 | test('should handle empty tags or invalid ranges', () => { 240 | const text = 'blue_hair, , red_eyes'; 241 | const cursorPos = 10; // Cursor inside empty tag 242 | expect(getCurrentTagRange(text, cursorPos)).toBeNull(); 243 | }); 244 | 245 | test('should handle tags with colons in names', () => { 246 | const text = 'foo:bar, standing:1.0'; 247 | const cursorPos = 5; // Cursor inside "foo:bar" 248 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 7, tag: 'foo:bar' }); 249 | }); 250 | 251 | test('should properly handle tags with numeric values after colon', () => { 252 | const text = 'year:2000, normal_tag'; 253 | const cursorPos = 7; // Cursor inside "foobar:2000" 254 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 9, tag: 'year:2000' }); 255 | }); 256 | 257 | test('should properly handle tags with numeric values before colon', () => { 258 | const text = '428:bar, normal_tag'; 259 | const cursorPos = 2; // Cursor inside "foobar:2000" 260 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 7, tag: '428:bar' }); 261 | }); 262 | 263 | test('should properly handle if cursor position on prompt weight', () => { 264 | const text = 'blue_hair:1.2, red_eyes:0.8'; 265 | const cursorPos = 11; // Cursor on ":1.2" 266 | expect(getCurrentTagRange(text, cursorPos)).toEqual({ start: 0, end: 9, tag: 'blue_hair' }); 267 | }); 268 | 269 | // Wildcard syntax tests 270 | test('should correctly identify a tag within a wildcard', () => { 271 | const text = '{short|medium|long}, other_tag'; 272 | const cursorPos = 3; // Cursor inside "short" part of the wildcard 273 | const result = getCurrentTagRange(text, cursorPos); 274 | expect(result).not.toBeNull(); 275 | expect(result.tag).toBe('short'); 276 | }); 277 | 278 | test('should correctly identify a tag with weight notation in a wildcard', () => { 279 | const text = '{20::from above|30::from behind}, other_tag'; 280 | const cursorPos = 21; // Cursor inside "from behind" part 281 | const result = getCurrentTagRange(text, cursorPos); 282 | expect(result).not.toBeNull(); 283 | expect(result.tag).toBe('from behind'); 284 | }); 285 | }); 286 | 287 | describe('findAllTagPositions', () => { 288 | test('should find positions for basic comma-separated tags', () => { 289 | const text = 'blue_hair, red_eyes, smile'; 290 | const positions = findAllTagPositions(text); 291 | 292 | expect(positions).toHaveLength(3); 293 | expect(positions[0]).toEqual({ start: 0, end: 9, tag: 'blue_hair' }); 294 | expect(positions[1]).toEqual({ start: 11, end: 19, tag: 'red_eyes' }); 295 | expect(positions[2]).toEqual({ start: 21, end: 26, tag: 'smile' }); 296 | }); 297 | 298 | test('should find positions for tags separated by newlines', () => { 299 | const text = 'blue_hair\nred_eyes\nsmile'; 300 | const positions = findAllTagPositions(text); 301 | 302 | expect(positions).toHaveLength(3); 303 | expect(positions[0]).toEqual({ start: 0, end: 9, tag: 'blue_hair' }); 304 | expect(positions[1]).toEqual({ start: 10, end: 18, tag: 'red_eyes' }); 305 | expect(positions[2]).toEqual({ start: 19, end: 24, tag: 'smile' }); 306 | }); 307 | 308 | test('should handle wildcard syntax and expand all options', () => { 309 | const text = 'normal tag, {short hair|medium hair|long hair}, another tag'; 310 | const positions = findAllTagPositions(text); 311 | 312 | expect(positions).toHaveLength(5); // 1 normal + 3 from wildcard + 1 normal 313 | 314 | // Normal tag 315 | expect(positions[0]).toEqual({ start: 0, end: 10, tag: 'normal tag' }); 316 | 317 | // Wildcard tags with correct positions inside the wildcard 318 | expect(positions[1].tag).toBe('short hair'); 319 | expect(positions[2].tag).toBe('medium hair'); 320 | expect(positions[3].tag).toBe('long hair'); 321 | 322 | // Check positions are within the original wildcard 323 | expect(positions[1].start).toBeGreaterThanOrEqual(12); 324 | expect(positions[1].end).toBeLessThanOrEqual(31); 325 | expect(positions[3].end).toBeLessThanOrEqual(45); 326 | 327 | // Last normal tag 328 | expect(positions[4]).toEqual({ start: 48, end: 59, tag: 'another tag' }); 329 | }); 330 | 331 | test('should handle weighted wildcard syntax', () => { 332 | const text = '{20::from above|20::from side|30::from up|30::from behind}'; 333 | const positions = findAllTagPositions(text); 334 | 335 | expect(positions).toHaveLength(4); 336 | expect(positions[0].tag).toBe('from above'); 337 | expect(positions[1].tag).toBe('from side'); 338 | expect(positions[2].tag).toBe('from up'); 339 | expect(positions[3].tag).toBe('from behind'); 340 | 341 | // Check relative positions 342 | expect(positions[0].start).toBeLessThan(positions[1].start); 343 | expect(positions[1].start).toBeLessThan(positions[2].start); 344 | expect(positions[2].start).toBeLessThan(positions[3].start); 345 | }); 346 | 347 | test('should skip empty parts in weighted wildcard syntax', () => { 348 | const text = '{40::white|40::black|20::}'; 349 | const positions = findAllTagPositions(text); 350 | 351 | expect(positions).toHaveLength(2); 352 | expect(positions[0].tag).toBe('white'); 353 | expect(positions[1].tag).toBe('black'); 354 | }); 355 | 356 | test('should handle nested wildcards and complex patterns', () => { 357 | const text = 'normal, {nested {option|choice}|simple}, last'; 358 | const positions = findAllTagPositions(text); 359 | 360 | // Should extract all words from nested wildcards 361 | expect(positions).toHaveLength(6); 362 | expect(positions[0].tag).toBe('normal'); 363 | expect(positions[1].tag).toBe('nested'); 364 | expect(positions[2].tag).toBe('option'); 365 | expect(positions[3].tag).toBe('choice'); 366 | expect(positions[4].tag).toBe('simple'); 367 | expect(positions[5].tag).toBe('last'); 368 | }); 369 | }); 370 | -------------------------------------------------------------------------------- /web/css/autocomplete-plus.css: -------------------------------------------------------------------------------- 1 | /* Light Theme */ 2 | body { 3 | --autocomplete-plus-text-color-cat-blue: var(--p-blue-600); 4 | --autocomplete-plus-text-color-cat-red: var(--p-red-600); 5 | --autocomplete-plus-text-color-cat-purple: var(--p-purple-600); 6 | --autocomplete-plus-text-color-cat-green: var(--p-green-600); 7 | --autocomplete-plus-text-color-cat-yellow: var(--p-yellow-600); 8 | --autocomplete-plus-text-color-cat-gray: var(--p-gray-600); 9 | --autocomplete-plus-text-color-cat-sky: var(--p-sky-500); 10 | --autocomplete-plus-text-color-cat-orange: var(--p-orange-600); 11 | --autocomplete-plus-text-color-cat-white: var(--p-neutral-700); 12 | --autocomplete-plus-text-color-disabled: var(--p-gray-400); 13 | } 14 | 15 | /* Dark Theme */ 16 | body.dark-theme { 17 | --autocomplete-plus-text-color-cat-blue: var(--p-blue-400); 18 | --autocomplete-plus-text-color-cat-red: var(--p-red-400); 19 | --autocomplete-plus-text-color-cat-purple: var(--p-purple-400); 20 | --autocomplete-plus-text-color-cat-green: var(--p-green-400); 21 | --autocomplete-plus-text-color-cat-yellow: var(--p-yellow-400); 22 | --autocomplete-plus-text-color-cat-gray: var(--p-gray-400); 23 | --autocomplete-plus-text-color-cat-sky: var(--p-sky-300); 24 | --autocomplete-plus-text-color-cat-orange: var(--p-orange-400); 25 | --autocomplete-plus-text-color-cat-white: var(--p-neutral-200); 26 | --autocomplete-plus-text-color-disabled: var(--p-gray-500); 27 | } 28 | 29 | #autocomplete-plus-root { 30 | position: absolute; 31 | z-index: 1000; 32 | top: 0; 33 | left: 0; 34 | display: none; 35 | width: fit-content; 36 | background-color: var(--comfy-input-bg); 37 | color: var(--input-text); 38 | } 39 | 40 | #autocomplete-plus-list { 41 | display: grid; 42 | box-shadow: 0 2px 8px rgb(0 0 0 / 30%); 43 | grid-auto-rows: auto; 44 | grid-template-columns: max-content 1fr auto auto; 45 | overflow-y: auto; 46 | } 47 | 48 | .autocomplete-plus-item { 49 | display: grid; 50 | cursor: pointer; 51 | grid-column: 1 / -1; 52 | grid-template-columns: subgrid; 53 | } 54 | 55 | .autocomplete-plus-item.danbooru[data-tag-category="general"] { 56 | color: var(--autocomplete-plus-text-color-cat-blue); 57 | } 58 | 59 | .autocomplete-plus-item.danbooru[data-tag-category="artist"] { 60 | color: var(--autocomplete-plus-text-color-cat-red); 61 | } 62 | 63 | .autocomplete-plus-item.danbooru[data-tag-category="copyright"] { 64 | color: var(--autocomplete-plus-text-color-cat-purple); 65 | } 66 | 67 | .autocomplete-plus-item.danbooru[data-tag-category="character"] { 68 | color: var(--autocomplete-plus-text-color-cat-green); 69 | } 70 | 71 | .autocomplete-plus-item.danbooru[data-tag-category="meta"] { 72 | color: var(--autocomplete-plus-text-color-cat-yellow); 73 | } 74 | 75 | .autocomplete-plus-item.danbooru[data-tag-category="unknown"] { 76 | color: var(--autocomplete-plus-text-color-cat-gray); 77 | } 78 | 79 | .autocomplete-plus-item.e621[data-tag-category="general"] { 80 | color: var(--autocomplete-plus-text-color-cat-sky); 81 | } 82 | 83 | .autocomplete-plus-item.e621[data-tag-category="artist"] { 84 | color: var(--autocomplete-plus-text-color-cat-orange); 85 | } 86 | 87 | .autocomplete-plus-item.e621[data-tag-category="copyright"] { 88 | color: var(--autocomplete-plus-text-color-cat-purple); 89 | } 90 | 91 | .autocomplete-plus-item.e621[data-tag-category="character"] { 92 | color: var(--autocomplete-plus-text-color-cat-green); 93 | } 94 | 95 | .autocomplete-plus-item.e621[data-tag-category="species"] { 96 | color: var(--autocomplete-plus-text-color-cat-red); 97 | } 98 | 99 | .autocomplete-plus-item.e621[data-tag-category="meta"] { 100 | color: var(--autocomplete-plus-text-color-cat-white); 101 | } 102 | 103 | .autocomplete-plus-item.e621[data-tag-category="lore"] { 104 | color: var(--autocomplete-plus-text-color-cat-red); 105 | } 106 | 107 | .autocomplete-plus-item.e621[data-tag-category="unknown"] { 108 | color: var(--autocomplete-plus-text-color-cat-purple); 109 | } 110 | 111 | .autocomplete-plus-item span { 112 | align-content: center; 113 | padding: 4px 8px; 114 | border-bottom: 1px solid var(--border-color); 115 | } 116 | 117 | /* Alternating row colors */ 118 | .autocomplete-plus-item:nth-child(even) span { 119 | background-color: var(--comfy-menu-bg); 120 | } 121 | 122 | .autocomplete-plus-item:nth-child(odd) span { 123 | background-color: var(--comfy-menu-secondary-bg); 124 | } 125 | 126 | .autocomplete-plus-item.selected span { 127 | background-color: var( 128 | --comfy-menu-bg-selected, 129 | var(--comfy-button-bg) 130 | ); 131 | font-weight: bold; 132 | } 133 | 134 | .autocomplete-plus-item:hover span { 135 | background-color: var(--comfy-hover-bg); 136 | } 137 | 138 | .autocomplete-plus-item .autocomplete-plus-tag-name { 139 | white-space: nowrap; 140 | } 141 | 142 | .autocomplete-plus-item .autocomplete-plus-tag-icon-svg { 143 | width: 1em; 144 | height: 1em; 145 | vertical-align: middle; 146 | } 147 | 148 | .autocomplete-plus-tag-name.autocomplete-plus-already-exists { 149 | color: var(--autocomplete-plus-text-color-disabled); 150 | } 151 | 152 | .autocomplete-plus-item .autocomplete-plus-alias { 153 | overflow: hidden; 154 | color: var(--descrip-text); 155 | text-overflow: ellipsis; 156 | white-space: nowrap; 157 | } 158 | 159 | .autocomplete-plus-item .autocomplete-plus-tag-count { 160 | text-align: right; 161 | } 162 | 163 | /* Related Tags UI Styles */ 164 | #related-tags-root { 165 | position: absolute; 166 | z-index: 1000; 167 | top: 0; 168 | left: 0; 169 | display: none; 170 | width: fit-content; 171 | background-color: var(--comfy-input-bg); 172 | color: var(--input-text); 173 | } 174 | 175 | #related-tags-header { 176 | position: sticky; 177 | z-index: 1; 178 | top: 0; 179 | display: flex; 180 | align-items: center; 181 | justify-content: space-between; 182 | padding: 8px 12px; 183 | border-bottom: 1px solid var(--border-color); 184 | background-color: var(--comfy-menu-bg); 185 | color: var(--descrip-text); 186 | } 187 | 188 | .related-tags-header-text { 189 | flex-grow: 1; 190 | 191 | } 192 | 193 | .related-tags-header-tag-name { 194 | font-weight: bold; 195 | } 196 | 197 | .related-tags-header-tag-name .autocomplete-plus-tag-icon-svg { 198 | width: 1em; 199 | height: 1em; 200 | vertical-align: middle; 201 | } 202 | 203 | .related-tags-header-controls { 204 | display: flex; 205 | align-items: center; 206 | margin-left: 16px; 207 | gap: 4px; 208 | } 209 | 210 | .related-tags-layout-toggle { 211 | padding: 2px 6px; 212 | border: none; 213 | border-radius: 4px; 214 | background: none; 215 | color: var(--input-text); 216 | cursor: pointer; 217 | font-size: 14px; 218 | transition: background-color 0.2s; 219 | } 220 | 221 | .related-tags-layout-toggle:hover { 222 | background-color: var(--comfy-button-bg); 223 | } 224 | 225 | .related-tags-pin-toggle { 226 | padding: 2px 6px; 227 | border: none; 228 | border-radius: 4px; 229 | background: none; 230 | color: var(--input-text); 231 | cursor: pointer; 232 | font-size: 14px; 233 | transition: background-color 0.2s; 234 | } 235 | 236 | #related-tags-list { 237 | display: grid; 238 | box-shadow: 0 2px 8px rgb(0 0 0 / 30%); 239 | grid-auto-rows: auto; 240 | grid-template-columns: max-content 1fr auto auto; 241 | overflow-y: auto; 242 | } 243 | 244 | .related-tag-item { 245 | display: grid; 246 | cursor: pointer; 247 | grid-column: 1 / -1; 248 | grid-template-columns: subgrid; 249 | } 250 | 251 | .related-tag-item.danbooru[data-tag-category="general"] { 252 | color: var(--autocomplete-plus-text-color-cat-blue); 253 | } 254 | 255 | .related-tag-item.danbooru[data-tag-category="artist"] { 256 | color: var(--autocomplete-plus-text-color-cat-red); 257 | } 258 | 259 | .related-tag-item.danbooru[data-tag-category="copyright"] { 260 | color: var(--autocomplete-plus-text-color-cat-purple); 261 | } 262 | 263 | .related-tag-item.danbooru[data-tag-category="character"] { 264 | color: var(--autocomplete-plus-text-color-cat-green); 265 | } 266 | 267 | .related-tag-item.danbooru[data-tag-category="meta"] { 268 | color: var(--autocomplete-plus-text-color-cat-yellow); 269 | } 270 | 271 | .related-tag-item.danbooru[data-tag-category="unknown"] { 272 | color: var(--autocomplete-plus-text-color-cat-gray); 273 | } 274 | 275 | .related-tag-item.e621[data-tag-category="general"] { 276 | color: var(--autocomplete-plus-text-color-cat-sky); 277 | } 278 | 279 | .related-tag-item.e621[data-tag-category="artist"] { 280 | color: var(--autocomplete-plus-text-color-cat-orange); 281 | } 282 | 283 | .related-tag-item.e621[data-tag-category="copyright"] { 284 | color: var(--autocomplete-plus-text-color-cat-purple); 285 | } 286 | 287 | .related-tag-item.e621[data-tag-category="character"] { 288 | color: var(--autocomplete-plus-text-color-cat-green); 289 | } 290 | 291 | .related-tag-item.e621[data-tag-category="species"] { 292 | color: var(--autocomplete-plus-text-color-cat-red); 293 | } 294 | 295 | .related-tag-item.e621[data-tag-category="meta"] { 296 | color: var(--autocomplete-plus-text-color-cat-white); 297 | } 298 | 299 | .related-tag-item.e621[data-tag-category="lore"] { 300 | color: var(--autocomplete-plus-text-color-cat-red); 301 | } 302 | 303 | .related-tag-item.e621[data-tag-category="unknown"] { 304 | color: var(--autocomplete-plus-text-color-cat-gray); 305 | } 306 | 307 | /* stylelint-disable-next-line no-descending-specificity */ 308 | .related-tag-item span { 309 | align-content: center; 310 | padding: 4px 8px; 311 | border-bottom: 1px solid var(--border-color); 312 | } 313 | 314 | .related-tag-item:nth-child(even) span { 315 | background-color: var(--comfy-menu-bg); 316 | } 317 | 318 | .related-tag-item:nth-child(odd) span { 319 | background-color: var(--comfy-menu-secondary-bg); 320 | } 321 | 322 | .related-tag-item:hover span { 323 | background-color: var(--p-form-field-filled-hover-background); 324 | } 325 | 326 | .related-tag-item.selected span { 327 | background-color: var( 328 | --comfy-menu-bg-selected, 329 | var(--comfy-button-bg) 330 | ); 331 | font-weight: bold; 332 | } 333 | 334 | .related-tag-item .related-tag-name { 335 | white-space: nowrap; 336 | } 337 | 338 | .related-tag-item .related-tag-name.related-tag-already-exists { 339 | color: var(--autocomplete-plus-text-color-disabled); 340 | } 341 | 342 | .related-tag-item .related-tag-alias { 343 | overflow: hidden; 344 | color: var(--descrip-text); 345 | text-overflow: ellipsis; 346 | white-space: nowrap; 347 | } 348 | 349 | .related-tag-item .related-tag-similarity { 350 | color: var(--descrip-text); 351 | text-align: right; 352 | white-space: nowrap; 353 | } 354 | 355 | .related-tags-loading-message { 356 | padding: 12px; 357 | font-style: italic; 358 | text-align: center; 359 | } 360 | 361 | .related-tags-empty { 362 | padding: 12px; 363 | color: var(--error-text); 364 | font-style: italic; 365 | text-align: center; 366 | } -------------------------------------------------------------------------------- /web/js/autocomplete.js: -------------------------------------------------------------------------------- 1 | import { 2 | TagCategory, 3 | TagData, 4 | autoCompleteData, 5 | getEnabledTagSourceInPriorityOrder 6 | } from './data.js'; 7 | import { 8 | formatCountHumanReadable, 9 | hiraToKata, 10 | kataToHira, 11 | isContainsLetterOrNumber, 12 | normalizeTagToInsert, 13 | normalizeTagToSearch, 14 | extractTagsFromTextArea, 15 | getCurrentTagRange, 16 | getViewportMargin, 17 | IconSvgHtmlString 18 | } from './utils.js'; 19 | import { settingValues } from './settings.js'; 20 | 21 | // --- Autocomplete Logic --- 22 | 23 | /** 24 | * Checks if a target string matches any of the query variations based on several rules. 25 | * @param {string} target - The target word to match. 26 | * @param {Set} queries - Set of query variations. 27 | * @returns {{matched: boolean, isExactMatch: boolean}} 28 | */ 29 | function matchWord(target, queries) { 30 | let matched = false; 31 | let isExactMatch = false; 32 | for (const variation of queries) { 33 | if (target === variation) { 34 | isExactMatch = true; 35 | matched = true; 36 | break; 37 | } 38 | } 39 | 40 | if (!isExactMatch) { 41 | for (const variation of queries) { 42 | const hasWildcardPrefix = variation.startsWith('__'); 43 | if (hasWildcardPrefix) { 44 | // If variation has wildcard prefix, only attempt a direct partial match. (e.g. "__wildcard__") 45 | if (target.includes(variation)) { 46 | matched = true; 47 | break; 48 | } 49 | } else if (!isContainsLetterOrNumber(variation)) { 50 | // If the query variation contains only symbols, 51 | // match if the target also contains only symbols and includes the variation. (e.g. "^_^", "^^^") 52 | if (!isContainsLetterOrNumber(target) && target.includes(variation)) { 53 | matched = true; 54 | break; 55 | } 56 | } else { 57 | // If the query variation contains letters or numbers, attempt a partial match. 58 | if (target.includes(variation)) { 59 | matched = true; 60 | break; 61 | } 62 | // If direct partial match fails, try matching after removing 63 | // common symbols from both target and variation. 64 | else if (target.replace(/[-_\s']/g, '').includes(variation.replace(/[-_\s']/g, ''))) { 65 | matched = true; 66 | break; 67 | } 68 | } 69 | } 70 | } 71 | 72 | return { matched, isExactMatch }; 73 | } 74 | 75 | /** 76 | * Search tag completion candidates based on the current input and cursor position in the textarea. 77 | * @param {HTMLTextAreaElement} textareaElement The partial tag input. 78 | * @returns {Array} The list of matching candidates. 79 | */ 80 | function searchCompletionCandidates(textareaElement) { 81 | const startTime = performance.now(); // Record start time for performance measurement 82 | 83 | const ESCAPE_SEQUENCE = ["#", "/"]; // If the first string is that character, autocomplete will not be displayed. 84 | const partialTag = getCurrentPartialTag(textareaElement); 85 | if (!partialTag || partialTag.length <= 0 || ESCAPE_SEQUENCE.some(seq => partialTag.startsWith(seq))) { 86 | return []; // No valid input for autocomplete 87 | } 88 | 89 | const exactMatches = []; 90 | const partialMatches = []; 91 | const addedTags = new Set(); 92 | 93 | // Generate Hiragana/Katakana variations if applicable 94 | const queryVariations = new Set([partialTag, normalizeTagToSearch(partialTag)]); 95 | const kataQuery = hiraToKata(partialTag); 96 | if (kataQuery !== partialTag) { 97 | queryVariations.add(kataQuery); 98 | } 99 | const hiraQuery = kataToHira(partialTag); 100 | if (hiraQuery !== partialTag) { 101 | queryVariations.add(hiraQuery); 102 | } 103 | 104 | const sources = getEnabledTagSourceInPriorityOrder(); 105 | for (const source of sources) { 106 | // Search in sortedTags (already sorted by count) 107 | for (const tagData of autoCompleteData[source].sortedTags) { 108 | let matched = false; 109 | let isExactMatch = false; 110 | let matchedAlias = null; 111 | 112 | // Check primary tag against all variations for exact/partial match 113 | const tagMatch = matchWord(tagData.tag, queryVariations); 114 | matched = tagMatch.matched; 115 | isExactMatch = tagMatch.isExactMatch; 116 | 117 | // If primary tag didn't match, check aliases against all variations 118 | if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) { 119 | for (const alias of tagData.alias) { 120 | const lowerAlias = alias.toLowerCase(); 121 | const aliasMatch = matchWord(lowerAlias, queryVariations); 122 | if (aliasMatch.matched) { 123 | matched = true; 124 | isExactMatch = aliasMatch.isExactMatch; 125 | matchedAlias = alias; 126 | break; 127 | } 128 | } 129 | } 130 | 131 | const tagSetKey = tagData.tag; 132 | 133 | // Add candidate if matched and not already added 134 | if (matched && !addedTags.has(tagSetKey)) { 135 | // Add to exact matches or partial matches based on match type 136 | if (isExactMatch) { 137 | exactMatches.push(tagData); 138 | } else { 139 | partialMatches.push(tagData); 140 | } 141 | 142 | addedTags.add(tagSetKey); 143 | 144 | // Check if we've reached the maximum suggestions limit combining both arrays 145 | if (exactMatches.length + partialMatches.length >= settingValues.maxSuggestions) { 146 | // Return the combined results, prioritizing exact matches 147 | const result = [...exactMatches, ...partialMatches].slice(0, settingValues.maxSuggestions); 148 | 149 | if (settingValues._logprocessingTime) { 150 | const endTime = performance.now(); 151 | const duration = endTime - startTime; 152 | console.debug(`[Autocomplete-Plus] Search for "${partialTag}" took ${duration.toFixed(2)}ms. Found ${result.length} candidates (max reached).`); 153 | } 154 | 155 | return result; // Early exit 156 | } 157 | } 158 | } 159 | } 160 | 161 | // Combine results, with exact matches first 162 | const candidates = [...exactMatches, ...partialMatches]; 163 | 164 | if (settingValues._logprocessingTime) { 165 | const endTime = performance.now(); 166 | const duration = endTime - startTime; 167 | console.debug(`[Autocomplete-Plus] Search for "${partialTag}" took ${duration.toFixed(2)}ms. Found ${candidates.length} candidates.`); 168 | } 169 | 170 | return candidates; 171 | } 172 | 173 | /** 174 | * Extracts the current tag being typed before the cursor. 175 | * @param {HTMLTextAreaElement} inputElement 176 | * @returns {string} The current partial tag. 177 | */ 178 | function getCurrentPartialTag(inputElement) { 179 | const text = inputElement.value; 180 | const cursorPos = inputElement.selectionStart; 181 | 182 | // Find the last newline or comma before the cursor 183 | const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); 184 | const lastComma = text.lastIndexOf(',', cursorPos - 1); 185 | 186 | // Get the position of the last separator (newline or comma) before cursor 187 | const lastSeparator = Math.max(lastNewLine, lastComma); 188 | const start = lastSeparator === -1 ? 0 : lastSeparator + 1; 189 | 190 | // Check if the cursor is inside a prompt weight modifier (e.g., :1.2, :.5, :1.) 191 | const segmentBeforeCursor = text.substring(start, cursorPos); 192 | const lastColon = segmentBeforeCursor.lastIndexOf(':'); 193 | if (lastColon !== -1) { 194 | const partAfterColon = segmentBeforeCursor.substring(lastColon + 1); 195 | const weight = parseFloat(partAfterColon); 196 | 197 | // If weight is a valid number and less than 10, return empty string 198 | if (weight !== NaN && weight <= 9.9) { 199 | return ""; 200 | } 201 | } 202 | 203 | // Get the tag range at the cursor position 204 | const tagRange = getCurrentTagRange(text, cursorPos); 205 | 206 | // If no tag is found or the cursor is before the start of the tag, return empty string 207 | if (!tagRange || cursorPos <= tagRange.start) { 208 | return ""; 209 | } 210 | 211 | // Extract the part of the tag up to the cursor position 212 | const partial = text.substring(tagRange.start, cursorPos).trimStart(); 213 | 214 | return normalizeTagToSearch(partial); 215 | } 216 | 217 | /** 218 | * Inserts the selected tag into the textarea, replacing the partial tag, 219 | * making the change undoable. 220 | * @param {HTMLTextAreaElement} inputElement 221 | * @param {string} tagToInsert The raw tag string to insert. 222 | */ 223 | function insertTagToTextArea(inputElement, tagToInsert) { 224 | const text = inputElement.value; 225 | const cursorPos = inputElement.selectionStart; 226 | 227 | const { start: tagStart, end: tagEnd, tag: currentTag } = getCurrentTagRange(text, cursorPos); 228 | const replaceStart = Math.min(cursorPos, tagStart); 229 | let replaceEnd = cursorPos; 230 | 231 | const normalizedTag = normalizeTagToInsert(tagToInsert); 232 | 233 | const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd(); 234 | if (normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1) { 235 | replaceEnd = cursorPos + currentTagAfterCursor.length; 236 | } 237 | 238 | // Add space if the previous separator was a comma and we are not at the beginning 239 | const needsSpaceBefore = text[replaceStart - 1] === ','; 240 | const prefix = needsSpaceBefore ? ' ' : ''; 241 | 242 | // Standard separator (comma + space) 243 | const needsSuffixAfter = !",:".includes(text[replaceEnd]); // TODO: If ":" is part of the emoticon, a suffix is ​​required (e.g. ":o") 244 | const suffix = needsSuffixAfter ? ', ' : ''; 245 | 246 | const textToInsertWithAffixes = prefix + normalizedTag + suffix; 247 | 248 | // --- Use execCommand for Undo support --- 249 | // 1. Select the text range to be replaced 250 | inputElement.focus(); // Ensure the element has focus 251 | inputElement.setSelectionRange(replaceStart, replaceEnd); 252 | 253 | // 2. Execute the 'insertText' command 254 | // This replaces the selection and should add the change to the undo stack 255 | const insertTextSuccess = document.execCommand('insertText', false, textToInsertWithAffixes); 256 | 257 | // Fallback for browsers where execCommand might fail or is not supported 258 | if (!insertTextSuccess) { 259 | console.warn('[Autocomplete-Plus] execCommand("insertText") failed. Falling back to direct value manipulation (Undo might not work).'); 260 | const textBefore = text.substring(0, replaceStart); 261 | const textAfter = text.substring(replaceEnd); 262 | inputElement.value = textBefore + textToInsertWithAffixes + textAfter; 263 | // Manually set cursor position after the inserted text 264 | const newCursorPos = replaceStart + textToInsertWithAffixes.length; 265 | inputElement.selectionStart = inputElement.selectionEnd = newCursorPos; 266 | // Trigger input event manually as a fallback 267 | inputElement.dispatchEvent(new Event('input', { bubbles: true })); 268 | } 269 | } 270 | 271 | // --- Autocomplete UI Class --- 272 | 273 | class AutocompleteUI { 274 | constructor() { 275 | this.root = document.createElement('div'); // Use table instead of div 276 | this.root.id = 'autocomplete-plus-root'; 277 | 278 | // Create svg icon element as definition 279 | this.iconSvgDef = document.createElement('div'); 280 | this.iconSvgDef.style.position = 'absolute'; 281 | this.iconSvgDef.style.display = 'none'; 282 | this.iconSvgDef.innerHTML = IconSvgHtmlString; 283 | this.root.appendChild(this.iconSvgDef); 284 | 285 | this.tagsList = document.createElement('div'); 286 | this.tagsList.id = 'autocomplete-plus-list'; 287 | this.root.appendChild(this.tagsList); 288 | 289 | // Add to DOM 290 | document.body.appendChild(this.root); 291 | 292 | this.target = null; 293 | this.selectedIndex = -1; 294 | this.candidates = []; 295 | 296 | // Add event listener for clicks on items 297 | this.tagsList.addEventListener('mousedown', (e) => { 298 | const row = e.target.closest('.autocomplete-plus-item'); 299 | if (row && row.dataset.tag) { 300 | this.#insertTag(row.dataset.tag); 301 | e.preventDefault(); // Prevent focus loss from input 302 | e.stopPropagation(); 303 | } 304 | }); 305 | } 306 | 307 | /** Checks if the autocomplete list is visible */ 308 | isVisible() { 309 | return this.root.style.display !== 'none'; 310 | } 311 | 312 | /** 313 | * Displays the autocomplete list under the given textarea element if there are candidates. 314 | * @param {HTMLTextAreaElement} textareaElement 315 | * @returns 316 | */ 317 | updateDisplay(textareaElement) { 318 | this.candidates = searchCompletionCandidates(textareaElement); 319 | if (this.candidates.length <= 0) { 320 | this.hide(); 321 | return; 322 | } 323 | 324 | this.target = textareaElement; 325 | 326 | if (this.selectedIndex == -1) { 327 | this.selectedIndex = 0; // Reset selection to the first item 328 | } 329 | 330 | this.#updateContent(); 331 | 332 | // Calculate caret position using the helper function (returns viewport-relative coordinates) 333 | this.#updatePosition(); 334 | 335 | this.root.style.display = 'block'; // Make it visible 336 | 337 | // Highlight the selected item 338 | // This function must be called after the route has been displayed, in order to scroll the highlighted item into view. 339 | this.#highlightItem(); 340 | } 341 | 342 | /** 343 | * hides the autocomplete list. 344 | */ 345 | hide() { 346 | this.root.style.display = 'none'; 347 | this.selectedIndex = -1; 348 | this.target = null; 349 | this.candidates = []; 350 | } 351 | 352 | /** Moves the selection up or down */ 353 | navigate(direction) { 354 | if (this.candidates.length === 0) return; 355 | this.selectedIndex += direction; 356 | 357 | if (this.selectedIndex < 0) { 358 | this.selectedIndex = this.candidates.length - 1; // Wrap around to bottom 359 | } else if (this.selectedIndex >= this.candidates.length) { 360 | this.selectedIndex = 0; // Wrap around to top 361 | } 362 | this.#highlightItem(); 363 | } 364 | 365 | /** Selects the currently highlighted item */ 366 | getSelectedTag() { 367 | if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) { 368 | return this.candidates[this.selectedIndex].tag; 369 | } 370 | 371 | return null; // No valid selection 372 | } 373 | 374 | /** 375 | * Updates the list from the current candidates. 376 | */ 377 | #updateContent() { 378 | this.tagsList.innerHTML = ''; 379 | if (this.candidates.length === 0) { 380 | this.hide(); 381 | return; 382 | } 383 | 384 | const existingTags = extractTagsFromTextArea(this.target); 385 | const currentTag = getCurrentPartialTag(this.target); 386 | 387 | this.candidates.forEach((tagData) => { 388 | const isExactMatch = tagData.tag === currentTag && (existingTags.filter(item => item === currentTag).length == 1); 389 | const isExistingTag = !isExactMatch && existingTags.includes(tagData.tag); 390 | this.#createTagElement(tagData, isExistingTag); 391 | }); 392 | } 393 | 394 | /** 395 | * Creates a tag element for the autocomplete list. 396 | * @param {TagData} tagData 397 | * @param {boolean} isExisting 398 | */ 399 | #createTagElement(tagData, isExisting) { 400 | const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; 401 | 402 | const tagRow = document.createElement('div'); 403 | tagRow.classList.add('autocomplete-plus-item', tagData.source); 404 | tagRow.dataset.tag = tagData.tag; 405 | tagRow.dataset.tagCategory = categoryText; 406 | 407 | // Tag icon and name 408 | const tagSourceIconHtml = ``; 409 | const tagName = document.createElement('span'); 410 | tagName.className = 'autocomplete-plus-tag-name'; 411 | if (settingValues.tagSourceIconPosition == 'hidden') { 412 | tagName.textContent = tagData.tag; 413 | } else { 414 | tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' 415 | ? `${tagSourceIconHtml} ${tagData.tag}` 416 | : `${tagData.tag} ${tagSourceIconHtml}`; 417 | } 418 | 419 | // grayout tag name if it already exists 420 | if (isExisting) { 421 | tagName.classList.add('autocomplete-plus-already-exists'); 422 | } 423 | 424 | // Alias 425 | const alias = document.createElement('span'); 426 | alias.className = 'autocomplete-plus-alias'; 427 | 428 | // Display alias if available 429 | if (tagData.alias && tagData.alias.length > 0) { 430 | let aliasText = tagData.alias.join(', '); 431 | alias.textContent = `${aliasText}`; 432 | alias.title = tagData.alias.join(', '); // Full alias on hover 433 | } 434 | 435 | // Category 436 | const category = document.createElement('span'); 437 | category.className = `autocomplete-plus-category`; 438 | category.textContent = `${categoryText.substring(0, 2)}`; 439 | category.title = categoryText; // Full category on hover 440 | 441 | // Count 442 | const tagCount = document.createElement('span'); 443 | tagCount.className = `autocomplete-plus-tag-count`; 444 | tagCount.textContent = formatCountHumanReadable(tagData.count); 445 | 446 | tagRow.appendChild(tagName); 447 | tagRow.appendChild(alias); 448 | tagRow.appendChild(category); 449 | tagRow.appendChild(tagCount); 450 | this.tagsList.appendChild(tagRow); 451 | } 452 | 453 | /** 454 | * Calculates the position of the autocomplete list based on the caret position in the input element. 455 | * Position calculation logic inspired by: 456 | * https://github.com/pythongosssss/ComfyUI-Custom-Scripts/blob/main/web/js/common/autocomplete.js 457 | * License: MIT License (assumed based on repository root LICENSE file) 458 | * Considers ComfyUI canvas scale. 459 | */ 460 | #updatePosition() { 461 | // Measure the element size without causing reflow 462 | this.root.style.visibility = 'hidden'; 463 | this.root.style.display = 'block'; 464 | this.root.style.maxWidth = ''; 465 | this.tagsList.style.maxHeight = ''; 466 | const rootRect = this.root.getBoundingClientRect(); 467 | // Hide it again after measurement 468 | this.root.style.display = 'none'; 469 | this.root.style.visibility = 'visible'; 470 | 471 | // Get ComfyUI canvas scale if available, otherwise default to 1 472 | const scale = window.app?.canvas?.ds?.scale ?? 1.0; 473 | 474 | const viewportWidth = window.innerWidth; 475 | const viewportHeight = window.innerHeight; 476 | const margin = getViewportMargin(); 477 | 478 | const targetElmOffset = this.#calculateElementOffset(this.target); 479 | 480 | const { top: caretTop, left: caretLeft, lineHeight: caretLineHeight } = this.#getCaretCoordinates(this.target); 481 | 482 | // Initial desired position: below the current text line where the caret is. 483 | let topPosition = targetElmOffset.top + ((caretTop - targetElmOffset.top) + caretLineHeight) * scale; 484 | let leftPosition = targetElmOffset.left + (caretLeft - targetElmOffset.left) * scale;; 485 | 486 | const maxWidth = Math.min(rootRect.width, viewportWidth / 2); 487 | const naturalHeight = rootRect.height; 488 | 489 | //Horizontal Collision Detection and Adjustment 490 | if (leftPosition + maxWidth > viewportWidth - margin.right) { 491 | leftPosition = viewportWidth - maxWidth - margin.right; 492 | } 493 | if (leftPosition < margin.left) { 494 | leftPosition = margin.left; 495 | } 496 | 497 | // Vertical Collision Detection and Adjustment 498 | const availableSpaceBelow = viewportHeight - topPosition - margin.bottom; 499 | const availableSpaceAbove = caretTop - margin.top; 500 | 501 | 502 | if (naturalHeight <= availableSpaceBelow) { 503 | // Fits perfectly below the caret 504 | // topPosition remains as calculated initially 505 | } else { 506 | // Doesn't fit below, check if it fits perfectly above 507 | if (naturalHeight <= availableSpaceAbove) { 508 | // Fits perfectly above 509 | topPosition = caretTop - naturalHeight - margin.top; 510 | } else { 511 | // Doesn't fit perfectly either below or above, needs scrolling. 512 | // Choose the position (above or below) that offers more space. 513 | if (availableSpaceBelow >= availableSpaceAbove) { 514 | // Scroll below: topPosition remains as initially calculated 515 | this.tagsList.style.maxHeight = `${availableSpaceBelow}px`; 516 | } else { 517 | // Scroll above: Position near the top edge and set max-height 518 | topPosition = margin.top; 519 | this.tagsList.style.maxHeight = `${availableSpaceAbove}px`; 520 | } 521 | } 522 | } 523 | 524 | // Final check to prevent going off the top edge 525 | if (topPosition < margin.top) { 526 | topPosition = margin.top; 527 | // If pushed down, recalculate max-height if it was set based on top alignment 528 | if (this.tagsList.style.maxHeight && availableSpaceBelow < availableSpaceAbove) { 529 | // Recalculate max-height based on space from the top margin 530 | this.tagsList.style.maxHeight = `${viewportHeight - margin.top - margin.bottom}px`; 531 | } 532 | } 533 | 534 | // Apply the calculated position and display the element 535 | this.root.style.left = `${leftPosition}px`; 536 | this.root.style.top = `${topPosition}px`; 537 | this.root.style.maxWidth = `${maxWidth}px`; 538 | } 539 | 540 | /** Highlights the item (row) at the given index */ 541 | #highlightItem() { 542 | if (!this.getSelectedTag()) return; // No valid selection 543 | 544 | const items = this.tagsList.children; // Get rows from tbody 545 | for (let i = 0; i < items.length; i++) { 546 | if (i === this.selectedIndex) { 547 | items[i].classList.add('selected'); // Use CSS class for selection 548 | items[i].scrollIntoView({ block: 'nearest' }); 549 | } else { 550 | items[i].classList.remove('selected'); 551 | } 552 | } 553 | } 554 | 555 | /** 556 | * Handles the selection of an item 557 | * @param {string} selectedTag The tag to insert. 558 | */ 559 | #insertTag(selectedTag) { 560 | if (!this.target || !selectedTag || selectedTag.length <= 0) { 561 | this.hide(); 562 | return; 563 | } 564 | 565 | // Insert the selected tag 566 | insertTagToTextArea(this.target, selectedTag); 567 | 568 | this.hide(); 569 | } 570 | 571 | /** 572 | * Gets the pixel coordinates of the caret in the input element. 573 | * Uses a temporary div to calculate the position accurately. 574 | * Based on https://github.com/component/textarea-caret-position 575 | * @param {HTMLTextAreaElement} element The textarea element. 576 | * @returns {{ top: number, left: number, lineHeight: number }} 577 | */ 578 | #getCaretCoordinates(element) { 579 | const properties = [ 580 | 'direction', // RTL support 581 | 'boxSizing', 582 | 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does 583 | 'height', 584 | 'overflowX', 585 | 'overflowY', // copy the scrollbar for IE 586 | 587 | 'borderTopWidth', 588 | 'borderRightWidth', 589 | 'borderBottomWidth', 590 | 'borderLeftWidth', 591 | 'borderStyle', 592 | 593 | 'paddingTop', 594 | 'paddingRight', 595 | 'paddingBottom', 596 | 'paddingLeft', 597 | 598 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font 599 | 'fontStyle', 600 | 'fontVariant', 601 | 'fontWeight', 602 | 'fontStretch', 603 | 'fontSize', 604 | 'fontSizeAdjust', 605 | 'lineHeight', 606 | 'fontFamily', 607 | 608 | 'textAlign', 609 | 'textTransform', 610 | 'textIndent', 611 | 'textDecoration', // might not make a difference, but better be safe 612 | 613 | 'letterSpacing', 614 | 'wordSpacing', 615 | 616 | 'tabSize', 617 | 'MozTabSize' // Firefox 618 | ]; 619 | 620 | const isBrowser = typeof window !== 'undefined'; 621 | const isFirefox = isBrowser && window.mozInnerScreenX != null; 622 | 623 | var debug = false; 624 | if (debug) { 625 | var el = document.querySelector("#input-textarea-caret-position-mirror-div"); 626 | if (el) el.parentNode.removeChild(el); 627 | } 628 | 629 | // The mirror div will replicate the textarea's style 630 | const div = document.createElement('div'); 631 | div.id = 'input-textarea-caret-position-mirror-div'; 632 | document.body.appendChild(div); 633 | 634 | const style = div.style; 635 | const computed = window.getComputedStyle(element); 636 | const isInput = element.nodeName === 'INPUT'; 637 | 638 | // Default textarea styles 639 | style.whiteSpace = 'pre-wrap'; 640 | if (!isInput) style.wordWrap = 'break-word'; // only for textarea-s 641 | 642 | // Position off-screen 643 | style.position = 'absolute'; // required to return coordinates properly 644 | if (!debug) style.visibility = 'hidden'; // not 'display: none' because we want rendering 645 | 646 | // Transfer the element's properties to the div 647 | properties.forEach(prop => { 648 | if (isInput && prop === "lineHeight") { 649 | // Special case for s because text is rendered centered and line height may be != height 650 | if (computed.boxSizing === "border-box") { 651 | var height = parseInt(computed.height); 652 | var outerHeight = 653 | parseInt(computed.paddingTop) + 654 | parseInt(computed.paddingBottom) + 655 | parseInt(computed.borderTopWidth) + 656 | parseInt(computed.borderBottomWidth); 657 | var targetHeight = outerHeight + parseInt(computed.lineHeight); 658 | if (height > targetHeight) { 659 | style.lineHeight = height - outerHeight + "px"; 660 | } else if (height === targetHeight) { 661 | style.lineHeight = computed.lineHeight; 662 | } else { 663 | style.lineHeight = 0; 664 | } 665 | } else { 666 | style.lineHeight = computed.height; 667 | } 668 | } else { 669 | style[prop] = computed[prop]; 670 | } 671 | }); 672 | 673 | // Calculate lineHeight more robustly 674 | let computedLineHeight = computed.lineHeight; 675 | let numericLineHeight; 676 | if (computedLineHeight === 'normal') { 677 | // Calculate fallback based on font size 678 | // const fontSize = parseFloat(computed.fontSize); 679 | // numericLineHeight = Math.round(fontSize * 1.2); // Common approximation 680 | numericLineHeight = this.#calculateLineHeightPx(element.nodeName, computed); 681 | } else { 682 | numericLineHeight = parseFloat(computedLineHeight); // Use parseFloat for pixel values like "16px" 683 | } 684 | 685 | if (isFirefox) { 686 | // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 687 | if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'; 688 | } else { 689 | style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' 690 | } 691 | 692 | div.textContent = element.value.substring(0, element.selectionStart); 693 | // The second special handling for input type=text doesn't need to be copied: 694 | // If isInput then usage is https://github.com/component/textarea-caret-position#usage-input-typetext 695 | 696 | const span = document.createElement('span'); 697 | // Wrapping must be replicated *exactly*, including whitespace spaces and carriage returns 698 | span.textContent = element.value.substring(element.selectionStart) || '.'; // || '.' because a completely empty faux span doesn't render at all 699 | div.appendChild(span); 700 | 701 | const coordinates = { 702 | top: span.offsetTop + (parseInt(computed['borderTopWidth']) || 0), 703 | left: span.offsetLeft + (parseInt(computed['borderLeftWidth']) || 0), 704 | lineHeight: numericLineHeight // Use the calculated numeric lineHeight 705 | }; 706 | 707 | // Calculate the bounding rect of the input element relative to the viewport 708 | const rect = element.getBoundingClientRect(); 709 | 710 | // Adjust the coordinates to be relative to the viewport 711 | coordinates.top = rect.top + element.scrollTop + coordinates.top; 712 | coordinates.left = rect.left + element.scrollLeft + coordinates.left; 713 | 714 | if (debug) { 715 | span.style.backgroundColor = "#aaa"; 716 | } else { 717 | document.body.removeChild(div); 718 | } 719 | 720 | return coordinates; 721 | } 722 | 723 | /** 724 | * Returns calculated line-height of the given node in pixels. 725 | */ 726 | #calculateLineHeightPx(nodeName, computedStyle) { 727 | const body = document.body; 728 | if (!body) return 0; 729 | 730 | const tempNode = document.createElement(nodeName); 731 | tempNode.innerHTML = " "; 732 | Object.assign(tempNode.style, { 733 | fontSize: computedStyle.fontSize, 734 | fontFamily: computedStyle.fontFamily, 735 | padding: "0", 736 | position: "absolute", 737 | }); 738 | body.appendChild(tempNode); 739 | 740 | // Make sure textarea has only 1 row 741 | if (tempNode instanceof HTMLTextAreaElement) { 742 | tempNode.rows = 1; 743 | } 744 | 745 | // Assume the height of the element is the line-height 746 | const height = tempNode.offsetHeight; 747 | body.removeChild(tempNode); 748 | 749 | return height; 750 | } 751 | 752 | /** 753 | * calculates the offset of the given element relative to the viewport. 754 | * @param {HTMLElement} element 755 | * @returns {{ top: number, left: number }} 756 | */ 757 | #calculateElementOffset(element) { 758 | const rect = element.getBoundingClientRect(); 759 | const owner = element.ownerDocument; 760 | if (owner == null) { 761 | throw new Error("Given element does not belong to document"); 762 | } 763 | 764 | const { defaultView, documentElement } = owner; 765 | if (defaultView == null) { 766 | throw new Error("Given element does not belong to window"); 767 | } 768 | 769 | const offset = { 770 | top: rect.top + defaultView.pageYOffset, 771 | left: rect.left + defaultView.pageXOffset, 772 | }; 773 | if (documentElement) { 774 | offset.top -= documentElement.clientTop; 775 | offset.left -= documentElement.clientLeft; 776 | } 777 | return offset; 778 | } 779 | } 780 | 781 | // --- Autocomplete Event Handling Class --- 782 | export class AutocompleteEventHandler { 783 | constructor() { 784 | this.autocompleteUI = new AutocompleteUI(); 785 | this.keyDownWithModifier = new Map(); // Keep track of keydown events with modifiers 786 | } 787 | 788 | /** 789 | * 790 | * @param {InputEvent} event 791 | * @returns 792 | */ 793 | handleInput(event) { 794 | if (!settingValues.enabled) return; 795 | if (!event.isTrusted) return; // ignore synthetic events 796 | 797 | const partialTag = getCurrentPartialTag(event.target); 798 | if (partialTag.length <= 0) { 799 | this.autocompleteUI.hide(); 800 | } 801 | } 802 | 803 | handleFocus(event) { 804 | 805 | } 806 | 807 | handleBlur(event) { 808 | if (!settingValues._hideWhenOutofFocus) return; 809 | 810 | // Need a slight delay because clicking the autocomplete list causes blur 811 | setTimeout(() => { 812 | if (!this.autocompleteUI.root.contains(document.activeElement)) { 813 | this.autocompleteUI.hide(); 814 | } 815 | }, 150); 816 | } 817 | 818 | /** 819 | * 820 | * @param {KeyboardEvent} event 821 | * @returns 822 | */ 823 | handleKeyDown(event) { 824 | if (!settingValues.enabled) return; 825 | 826 | // Save modifier key (without shiftKey) state when a key is pressed 827 | this.keyDownWithModifier.set(event.key.toLowerCase(), event.ctrlKey || event.altKey || event.metaKey); 828 | 829 | // Handle autocomplete navigation 830 | if (this.autocompleteUI.isVisible()) { 831 | switch (event.key) { 832 | case 'ArrowDown': 833 | event.preventDefault(); 834 | this.autocompleteUI.navigate(1); 835 | break; 836 | case 'ArrowUp': 837 | event.preventDefault(); 838 | this.autocompleteUI.navigate(-1); 839 | break; 840 | case 'Enter': 841 | case 'Tab': 842 | const modifierKeyPressed = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey; 843 | if (!modifierKeyPressed && this.autocompleteUI.getSelectedTag() !== null) { 844 | event.preventDefault(); 845 | insertTagToTextArea(event.target, this.autocompleteUI.getSelectedTag()); 846 | } 847 | this.autocompleteUI.hide(); 848 | break; 849 | case 'Escape': 850 | event.preventDefault(); 851 | this.autocompleteUI.hide(); 852 | break; 853 | } 854 | } 855 | } 856 | 857 | /** 858 | * 859 | * @param {KeyboardEvent} event 860 | * @returns 861 | */ 862 | handleKeyUp(event) { 863 | if (!settingValues.enabled) return; 864 | 865 | const key = event.key.toLowerCase(); 866 | 867 | // Check if the key was pressed with a modifier 868 | if (this.keyDownWithModifier.get(key)) { 869 | this.keyDownWithModifier.delete(key); // Remove the pressed key from the map 870 | return; 871 | } 872 | 873 | // Do not process keyup events if Ctrl, Alt, or Meta keys are pressed. 874 | // This prevents autocomplete from appearing for shortcuts like Ctrl+C, Ctrl+Z, etc. 875 | // It also handles the release of a modifier key itself if it wasn't part of a character-producing combination. 876 | if (event.ctrlKey || event.altKey || event.metaKey) { 877 | return; 878 | } 879 | 880 | if (this.autocompleteUI.isVisible()) { 881 | switch (event.key) { 882 | case 'ArrowDown': 883 | case 'ArrowUp': 884 | event.preventDefault(); 885 | return; // Prevent redundant display updates 886 | 887 | // For other character keys, Backspace, Delete, we fall through to updateDisplay. 888 | } 889 | } else { 890 | // If UI is not visible, and the key is a non-character key (length > 1) 891 | // and not Delete or Backspace, then do nothing. 892 | // This prevents UI from appearing on ArrowUp, F1, Shift (alone), etc. 893 | if (event.key.length > 1 && !["Delete", "Backspace", "Process"].includes(event.key)) { 894 | return; 895 | } 896 | } 897 | 898 | // If the event was not handled by the above (e.g. Arrow keys, or ignored special keys) 899 | // and default action is not prevented, update the display. 900 | // This will typically be for character inputs, Delete, Backspace or IME composition. 901 | if (!event.defaultPrevented) { 902 | this.autocompleteUI.updateDisplay(event.target); 903 | } 904 | } 905 | 906 | /** 907 | * 908 | * @param {MouseEvent} event 909 | * @returns 910 | */ 911 | handleMouseMove(event) { 912 | } 913 | 914 | /** 915 | * 916 | * @param {MouseEvent} event 917 | * @returns 918 | */ 919 | handleClick(event) { 920 | } 921 | } -------------------------------------------------------------------------------- /web/js/data.js: -------------------------------------------------------------------------------- 1 | import { settingValues } from "./settings.js"; 2 | 3 | // --- Constants --- 4 | 5 | // Tag data sources 6 | export const TagSource = { 7 | Danbooru: 'danbooru', 8 | E621: 'e621', 9 | } 10 | 11 | export const TagCategory = { 12 | 'danbooru': [ 13 | 'general', 14 | 'artist', 15 | 'unused', 16 | 'copyright', 17 | 'character', 18 | 'meta', 19 | ], 20 | 'e621': [ 21 | 'general', 22 | 'artist', 23 | 'unused', 24 | 'copyright', 25 | 'character', 26 | 'species', 27 | 'invalid', 28 | 'meta', 29 | 'lore', 30 | ] 31 | } 32 | 33 | // --- Data Structures --- 34 | 35 | /** 36 | * Class representing a tag and its metadata 37 | */ 38 | export class TagData { 39 | /** 40 | * Create a tag data object 41 | * @param {string} tag - The tag name 42 | * @param {string[]} [alias=[]] - Array of aliases for the tag 43 | * @param {string} [category='general'] - Category of the tag 44 | * @param {number} [count=0] - Frequency count/popularity of the tag 45 | * @param {string} [source=TagSources.Danbooru] - The source of the tag data 46 | */ 47 | constructor(tag, alias = [], category = 'general', count = 0, source = TagSource.Danbooru) { 48 | /** @type {string} */ 49 | this.tag = tag; 50 | 51 | /** @type {string[]} */ 52 | this.alias = alias; 53 | 54 | /** @type {string} */ 55 | this.category = category; 56 | 57 | /** @type {number} */ 58 | this.count = count; 59 | 60 | this.source = source; 61 | } 62 | } 63 | 64 | class AutocompleteData { 65 | constructor() { 66 | /** @type {TagData[]} */ 67 | this.sortedTags = []; 68 | 69 | /** @type {Map} */ 70 | this.tagMap = new Map(); 71 | 72 | /** @type {Map} */ 73 | this.aliasMap = new Map(); 74 | 75 | /** @type {Map>} */ 76 | this.cooccurrenceMap = new Map(); 77 | 78 | this.isInitializing = false; 79 | this.initialized = false; 80 | 81 | // Progress of "base" csv loading 82 | this.baseLoadingProgress = { 83 | // tags: 0, 84 | cooccurrence: 0 85 | }; 86 | } 87 | } 88 | 89 | /** 90 | * @type {Object} 91 | */ 92 | export const autoCompleteData = {}; 93 | 94 | // CSV Header for tags 95 | const TAGS_CSV_HEADER = 'tag,category,count,alias'; 96 | const TAGS_CSV_HEADER_COLUMNS = TAGS_CSV_HEADER.split(','); 97 | const TAG_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('tag'); 98 | const ALIAS_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('alias'); 99 | const CATEGORY_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('category'); 100 | const COUNT_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('count'); 101 | 102 | // --- Helder Functions --- 103 | 104 | /** 105 | * Get the available tag sources in priority order based on the current settings. 106 | * @returns {string[]} Array of available tag sources in priority order 107 | */ 108 | export function getEnabledTagSourceInPriorityOrder() { 109 | return Object.values(TagSource) 110 | .filter((s) => { 111 | return settingValues.tagSource === s || settingValues.tagSource === 'all'; 112 | }) 113 | .toSorted((a, b) => { 114 | return a === settingValues.primaryTagSource ? -1 : 1; 115 | }); 116 | } 117 | 118 | // --- Data Loading Functions --- 119 | 120 | /** 121 | * Loads tag data from a single CSV file. 122 | * @param {string} csvUrl - The URL of the CSV file to load. 123 | * @param {string} siteName - The site name (e.g., 'danbooru', 'e621'). 124 | * @returns {Promise} 125 | */ 126 | async function loadTags(csvUrl, siteName) { 127 | try { 128 | const response = await fetch(csvUrl, { cache: "no-store" }); 129 | if (!response.ok) { 130 | throw new Error(`HTTP error! status: ${response.status}`); 131 | } 132 | const csvText = await response.text(); 133 | const lines = csvText.split('\n').filter(line => line.trim().length > 0); 134 | const totalLines = lines.length; 135 | 136 | const startIndex = lines[0].toLowerCase().startsWith(TAGS_CSV_HEADER) ? 1 : 0; 137 | 138 | for (let i = startIndex; i < lines.length; i++) { 139 | const line = lines[i]; 140 | const columns = parseCSVLine(line); 141 | 142 | if (columns.length === TAGS_CSV_HEADER_COLUMNS.length) { 143 | const tag = columns[TAG_INDEX].trim(); 144 | const aliasStr = columns[ALIAS_INDEX].trim(); 145 | const category = columns[CATEGORY_INDEX].trim(); 146 | const count = parseInt(columns[COUNT_INDEX].trim(), 10); 147 | 148 | if (!tag || isNaN(count)) continue; 149 | 150 | // Skip if tag already exists (priority to earlier loaded files - extra then base) 151 | if (autoCompleteData[siteName].tagMap.has(tag)) { 152 | continue; 153 | } 154 | 155 | // Parse aliases - might be comma-separated list inside quotes 156 | const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : []; 157 | 158 | // Create a TagData instance instead of a plain object 159 | const tagData = new TagData(tag, aliases, category, count, siteName); 160 | 161 | autoCompleteData[siteName].sortedTags.push(tagData); 162 | } else { 163 | console.warn(`[Autocomplete-Plus] Invalid CSV format in line ${i + 1} of ${csvUrl}: ${line}. Expected ${TAGS_CSV_HEADER_COLUMNS.length} columns, but got ${columns.length}.`); 164 | continue; 165 | } 166 | } 167 | 168 | // Sort by count in descending order 169 | autoCompleteData[siteName].sortedTags.sort((a, b) => b.count - a.count); 170 | 171 | // Build maps as before, but ensure not to overwrite if already processed from extra files 172 | autoCompleteData[siteName].sortedTags.forEach(tagData => { 173 | if (!autoCompleteData[siteName].tagMap.has(tagData.tag)) { 174 | autoCompleteData[siteName].tagMap.set(tagData.tag, tagData); 175 | if (tagData.alias && Array.isArray(tagData.alias)) { 176 | tagData.alias.forEach(alias => { 177 | if (!autoCompleteData[siteName].aliasMap.has(alias)) { 178 | autoCompleteData[siteName].aliasMap.set(alias, tagData.tag); // Map alias back to the main tag 179 | } 180 | }); 181 | } 182 | } 183 | }); 184 | 185 | } catch (error) { 186 | console.error(`[Autocomplete-Plus] Failed to fetch or process tags from ${csvUrl}:`, error); 187 | } 188 | } 189 | 190 | /** 191 | * Loads co-occurrence data from a single CSV file. 192 | * @param {string} csvUrl - The URL of the CSV file to load. 193 | * @param {string} siteName - The site name (e.g., 'danbooru', 'e621'). 194 | * @returns {Promise} 195 | */ 196 | async function loadCooccurrence(csvUrl, siteName) { 197 | try { 198 | const response = await fetch(csvUrl, { cache: "no-store" }); 199 | if (!response.ok) { 200 | throw new Error(`HTTP error! status: ${response.status}`); 201 | } 202 | 203 | const csvText = await response.text(); 204 | const lines = csvText.split('\n').filter(line => line.trim().length > 0); 205 | 206 | const startIndex = lines[0].startsWith('tag_a,tag_b,count') ? 1 : 0; 207 | 208 | await processInChunks(lines, startIndex, autoCompleteData[siteName].cooccurrenceMap, csvUrl, siteName); 209 | } catch (error) { 210 | console.error(`[Autocomplete-Plus] Failed to fetch or process cooccurrence data from ${csvUrl}:`, error); 211 | } 212 | } 213 | 214 | /** 215 | * Process CSV data in chunks to avoid blocking the UI. 216 | * Modifies the targetMap directly. 217 | */ 218 | function processInChunks(lines, startIndex, targetMap, csvUrl, siteName) { 219 | return new Promise((resolve) => { 220 | const CHUNK_SIZE = 10000; 221 | let i = startIndex; 222 | let pairCount = 0; 223 | 224 | function processChunk() { 225 | const endIndex = Math.min(i + CHUNK_SIZE, lines.length); 226 | 227 | for (; i < endIndex; i++) { 228 | const line = lines[i]; 229 | const columns = parseCSVLine(line); 230 | 231 | if (columns.length >= 3) { 232 | const tagA = columns[0].trim(); 233 | const tagB = columns[1].trim(); 234 | const count = parseInt(columns[2].trim(), 10); 235 | 236 | if (!tagA || !tagB || isNaN(count)) continue; 237 | 238 | // Add tagA -> tagB relationship 239 | if (!targetMap.has(tagA)) { 240 | targetMap.set(tagA, new Map()); 241 | } 242 | targetMap.get(tagA).set(tagB, count); 243 | 244 | 245 | // Add tagB -> tagA relationship (bidirectional) 246 | if (!targetMap.has(tagB)) { 247 | targetMap.set(tagB, new Map()); 248 | } 249 | targetMap.get(tagB).set(tagA, count); 250 | 251 | pairCount++; 252 | } 253 | } 254 | 255 | if (i < lines.length) { 256 | autoCompleteData[siteName].baseLoadingProgress.cooccurrence = Math.round((i / lines.length) * 100); 257 | setTimeout(processChunk, 0); 258 | } else { 259 | resolve(); 260 | } 261 | } 262 | 263 | processChunk(); 264 | }); 265 | } 266 | 267 | /** 268 | * Parse a CSV line properly, handling quoted values that may contain commas. 269 | * @param {string} line A single CSV line 270 | * @returns {string[]} Array of column values 271 | */ 272 | function parseCSVLine(line) { 273 | const result = []; 274 | let current = ''; 275 | let inQuotes = false; 276 | 277 | for (let i = 0; i < line.length; i++) { 278 | const char = line[i]; 279 | 280 | if (char === '"') { 281 | if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { 282 | current += '"'; 283 | i++; 284 | } else { 285 | inQuotes = !inQuotes; 286 | } 287 | } else if (char === ',' && !inQuotes) { 288 | result.push(current); 289 | current = ''; 290 | } else { 291 | current += char; 292 | } 293 | } 294 | 295 | result.push(current); 296 | 297 | return result; 298 | } 299 | 300 | export async function fetchCsvList() { 301 | try { 302 | const response = await fetch('/autocomplete-plus/csv'); 303 | if (!response.ok) { 304 | throw new Error(`[Autocomplete-Plus] Failed to fetch CSV list: ${response.status} ${response.statusText}`); 305 | } 306 | return await response.json(); 307 | } catch (error) { 308 | console.error("[Autocomplete-Plus] Error fetch csv data:", error); 309 | } 310 | 311 | return null; 312 | } 313 | 314 | /** 315 | * Initializes the autocomplete data by fetching the list of CSV files and loading them. 316 | * This function is called when the extension is initialized. 317 | */ 318 | export async function initializeData(csvListData, source) { 319 | if (autoCompleteData.hasOwnProperty(source) === false) { 320 | autoCompleteData[source] = new AutocompleteData(); 321 | } 322 | 323 | if (autoCompleteData[source].isInitializing || autoCompleteData[source].initialized) { 324 | return; 325 | } 326 | 327 | const startTime = performance.now(); 328 | autoCompleteData[source].isInitializing = true; 329 | // console.log("[Autocomplete-Plus] Initializing autocomplete data..."); 330 | 331 | try { 332 | // Store functions that return Promises (Promise Factories) 333 | // These factories will be called later to start the actual loading. 334 | const tagsLoadPromiseFactories = []; 335 | const cooccurrenceLoadPromiseFactories = []; 336 | 337 | // Check if siteName exists in csvListData to prevent errors if a sourte is removed or misconfigured 338 | if (!csvListData[source]) { 339 | console.warn(`[Autocomplete-Plus] CSV list data not found for sourte: ${source}. Skipping.`); 340 | return; 341 | } 342 | 343 | const extraTagsFileList = csvListData[source].extra_tags || []; 344 | const extraCooccurrenceFileList = csvListData[source].extra_cooccurrence || []; 345 | 346 | const tagsUrl = `/autocomplete-plus/csv/${source}/tags`; 347 | const cooccurrenceUrl = `/autocomplete-plus/csv/${source}/tags_cooccurrence`; 348 | 349 | // Factory for loading tags for the current sourte 350 | const siteTagsLoaderFactory = async () => { 351 | let promiseChain = Promise.resolve(); 352 | for (let i = 0; i < extraTagsFileList.length; i++) { 353 | promiseChain = promiseChain.then(() => loadTags(`${tagsUrl}/extra/${i}`, source)); 354 | } 355 | if (csvListData[source].base_tags) { 356 | promiseChain = promiseChain.then(() => loadTags(`${tagsUrl}/base`, source)); 357 | } 358 | return promiseChain; 359 | }; 360 | tagsLoadPromiseFactories.push(siteTagsLoaderFactory); 361 | 362 | // Factory for loading cooccurrence data for the current sourte 363 | const siteCooccurrenceLoaderFactory = async () => { 364 | let promiseChain = Promise.resolve(); 365 | for (let i = 0; i < extraCooccurrenceFileList.length; i++) { 366 | promiseChain = promiseChain.then(() => loadCooccurrence(`${cooccurrenceUrl}/extra/${i}`, source)); 367 | } 368 | if (csvListData[source].base_cooccurrence) { 369 | promiseChain = promiseChain.then(() => loadCooccurrence(`${cooccurrenceUrl}/base`, source)); 370 | } 371 | return promiseChain; 372 | }; 373 | cooccurrenceLoadPromiseFactories.push(siteCooccurrenceLoaderFactory); 374 | 375 | // Now, execute all promise factories and wait for their completion. 376 | // The actual loading (fetch calls) will start when the factories are invoked here. 377 | await Promise.all([ 378 | Promise.all(tagsLoadPromiseFactories.map(factory => factory())).then(() => { 379 | const endTime = performance.now(); 380 | if (csvListData[source].base_tags) { 381 | console.log(`[Autocomplete-Plus] "${source}" Tags loading complete in ${(endTime - startTime).toFixed(2)}ms`); 382 | } 383 | }), 384 | Promise.all(cooccurrenceLoadPromiseFactories.map(factory => factory())).then(() => { 385 | const endTime = performance.now(); 386 | if (csvListData[source].base_cooccurrence) { 387 | console.log(`[Autocomplete-Plus] "${source}" Co-occurrence loading complete in ${(endTime - startTime).toFixed(2)}ms.`); 388 | } 389 | }) 390 | ]); 391 | 392 | autoCompleteData[source].initialized = true; 393 | } catch (error) { 394 | console.error("[Autocomplete-Plus] Error initializing autocomplete data:", error); 395 | } finally { 396 | autoCompleteData[source].isInitializing = false; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /web/js/main.js: -------------------------------------------------------------------------------- 1 | import { app } from "/scripts/app.js"; 2 | import { $el } from "/scripts/ui.js"; 3 | import { ComfyWidgets } from "/scripts/widgets.js"; 4 | import { settingValues } from "./settings.js"; 5 | import { loadCSS } from "./utils.js"; 6 | import { TagSource, fetchCsvList, initializeData } from "./data.js"; 7 | import { AutocompleteEventHandler } from "./autocomplete.js"; 8 | import { RelatedTagsEventHandler } from "./related-tags.js"; 9 | 10 | // --- Constants --- 11 | const id = "AutocompletePlus"; 12 | const name = "Autocomplete Plus"; 13 | 14 | // --- Functions --- 15 | /** 16 | * Initialize event handlers for the autocomplete and related tags features. 17 | */ 18 | function initializeEventHandlers() { 19 | const autocompleteEventHandler = new AutocompleteEventHandler(); 20 | const relatedTagsEventHandler = new RelatedTagsEventHandler(); 21 | const attachedElements = new WeakSet(); // Keep track of elements that have listeners attached 22 | 23 | // Function to attach listeners 24 | function attachListeners(element) { 25 | if (attachedElements.has(element)) return; // Prevent double attachment 26 | 27 | element.addEventListener('input', handleInput); 28 | element.addEventListener('focus', handleFocus); 29 | element.addEventListener('blur', handleBlur); 30 | element.addEventListener('keydown', handleKeyDown); 31 | element.addEventListener('keyup', handleKeyUp); 32 | // element.addEventListener('keypress', handleKeyPress); // keypress is deprecated 33 | 34 | // Add new event listeners for related tags feature 35 | element.addEventListener('mousemove', handleMouseMove); 36 | element.addEventListener('click', handleClick); 37 | 38 | attachedElements.add(element); // Mark as attached 39 | } 40 | 41 | // Attempt Widget Override as the primary method 42 | // The original ComfyWidgets.STRING arguments are (node, inputName, inputData, app) 43 | // inputData is often an array like [type, config] 44 | if (ComfyWidgets && ComfyWidgets.STRING) { 45 | const originalStringWidget = ComfyWidgets.STRING; 46 | ComfyWidgets.STRING = function (node, inputName, inputData, appInstance) { // Use appInstance to avoid conflict with global app 47 | const result = originalStringWidget.apply(this, arguments); 48 | 49 | // Check if the widget has an inputEl and if it's a TEXTAREA 50 | // This is to ensure we are targeting multiline text inputs, related to '.comfy-multiline-input' 51 | if (result && result.widget && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA') { 52 | const widgetConfig = inputData && inputData[1] ? inputData[1] : {}; 53 | // Future: Add checks for Autocomplete Plus specific configurations if needed 54 | // e.g., if (widgetConfig["AutocompletePlus.enabled"] === false) return result; 55 | 56 | attachListeners(result.widget.inputEl); 57 | } 58 | return result; 59 | }; 60 | } 61 | 62 | if (settingValues._useFallbackAttachmentForEventListener) { 63 | // Fallback and for dynamically added elements not caught by widget override: MutationObserver 64 | const targetSelectors = [ 65 | '.comfy-multiline-input', 66 | // Add other selectors if needed 67 | ]; 68 | 69 | const observer = new MutationObserver((mutations) => { 70 | mutations.forEach((mutation) => { 71 | mutation.addedNodes.forEach((node) => { 72 | if (node.nodeType === Node.ELEMENT_NODE) { 73 | targetSelectors.forEach(selector => { 74 | // Check if the added node itself matches or contains matching elements 75 | if (node.matches(selector)) { 76 | attachListeners(node); 77 | } else { 78 | node.querySelectorAll(selector).forEach(attachListeners); 79 | } 80 | }); 81 | } 82 | }); 83 | }); 84 | }); 85 | 86 | // Initial scan for existing elements 87 | targetSelectors.forEach(selector => { 88 | document.querySelectorAll(selector).forEach(attachListeners); 89 | }); 90 | 91 | // Start observing the document body for changes 92 | observer.observe(document.body, { childList: true, subtree: true }); 93 | } 94 | 95 | function handleInput(event) { 96 | autocompleteEventHandler.handleInput(event); 97 | relatedTagsEventHandler.handleInput(event); 98 | } 99 | 100 | function handleFocus(event) { 101 | autocompleteEventHandler.handleFocus(event); 102 | relatedTagsEventHandler.handleFocus(event); 103 | } 104 | 105 | function handleBlur(event) { 106 | autocompleteEventHandler.handleBlur(event); 107 | relatedTagsEventHandler.handleBlur(event); 108 | } 109 | 110 | function handleKeyDown(event) { 111 | autocompleteEventHandler.handleKeyDown(event); 112 | relatedTagsEventHandler.handleKeyDown(event); 113 | } 114 | 115 | function handleKeyUp(event) { 116 | autocompleteEventHandler.handleKeyUp(event); 117 | relatedTagsEventHandler.handleKeyUp(event); 118 | } 119 | 120 | // New event handler for mousemove to show related tags on hover 121 | function handleMouseMove(event) { 122 | autocompleteEventHandler.handleMouseMove(event); 123 | relatedTagsEventHandler.handleMouseMove(event); 124 | } 125 | 126 | // New event handler for click to show related tags 127 | function handleClick(event) { 128 | autocompleteEventHandler.handleClick(event); 129 | relatedTagsEventHandler.handleClick(event); 130 | } 131 | } 132 | 133 | /** 134 | * Add Miscellaneous settings to the settings screen 135 | */ 136 | async function addExtraSettings() { 137 | // Function to perform the update check 138 | async function performUpdateCheck(checkButton, lastCheckSpan) { 139 | checkButton.textContent = "Checking..."; 140 | checkButton.disabled = true; 141 | 142 | app.extensionManager.toast.add({ 143 | severity: "info", 144 | summary: "Check new CSV", 145 | detail: "Checking the CSV updates, see console for more details.", 146 | life: 5000 147 | }); 148 | 149 | try { 150 | const response = await fetch('/autocomplete-plus/csv/force-check-updates', { 151 | method: 'POST', 152 | headers: { 153 | 'Content-Type': 'application/json' 154 | } 155 | }); 156 | 157 | const result = await response.json(); 158 | if (result.success) { 159 | // Update last check time display using the response data 160 | if (result.last_check_time) { 161 | const newLastCheckDate = new Date(result.last_check_time); 162 | lastCheckSpan.textContent = "Last checked: " + newLastCheckDate.toLocaleString(); 163 | } else { 164 | lastCheckSpan.textContent = "Last checked: Never"; 165 | } 166 | } 167 | } catch (error) { 168 | console.error("[Autocomplete-Plus] Error during force check:", error); 169 | } finally { 170 | checkButton.textContent = "Check now"; 171 | checkButton.disabled = false; 172 | } 173 | } 174 | 175 | // Fetch last check time from API 176 | let lastCheckTimeText = "Loading..."; 177 | try { 178 | const response = await fetch('/autocomplete-plus/csv/last-check-time'); 179 | const data = await response.json(); 180 | 181 | if (data.last_check_time) { 182 | const lastCheckDate = new Date(data.last_check_time); 183 | lastCheckTimeText = "Last checked: " + lastCheckDate.toLocaleString(); 184 | } else { 185 | lastCheckTimeText = "Last checked: Never"; 186 | } 187 | } catch (error) { 188 | console.error("[Autocomplete-Plus] Error fetching last check time:", error); 189 | lastCheckTimeText = "Last checked: Error loading"; 190 | } 191 | 192 | // Add extra setting for checking new CSV updates 193 | app.ui.settings.addSetting({ 194 | id: id + ".check_new_csv", 195 | defaultValue: null, 196 | name: "Check CSV updates", 197 | category: [name, "Misc", "Check new CSV"], 198 | type: () => { 199 | const lastCheckSpan = $el("span", { 200 | textContent: lastCheckTimeText, 201 | className: "text-sm text-gray-500", 202 | style: { 203 | marginRight: "16px" 204 | } 205 | }); 206 | 207 | const checkButton = $el("button", { 208 | textContent: "Check now", 209 | className: "p-button p-component p-button-primary", 210 | onclick: async () => { 211 | await performUpdateCheck(checkButton, lastCheckSpan); 212 | } 213 | }); 214 | 215 | return $el("div", { 216 | className: "flex-row items-center gap-2", 217 | }, [ 218 | $el("div", { 219 | className: "p-component", 220 | }, [ 221 | lastCheckSpan, 222 | checkButton, 223 | ]), 224 | ]); 225 | } 226 | }); 227 | } 228 | 229 | /** 230 | * Registration of the extension 231 | */ 232 | app.registerExtension({ 233 | id: id, 234 | name: name, 235 | async setup() { 236 | initializeEventHandlers(); 237 | 238 | addExtraSettings(); 239 | 240 | let rootPath = import.meta.url.replace("js/main.js", ""); 241 | loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete 242 | 243 | fetchCsvList().then((csvList) => { 244 | Object.values(TagSource).forEach((source) => { 245 | initializeData(csvList, source); 246 | }); 247 | }); 248 | }, 249 | 250 | // One the Settings Screen, displays reverse order in same category 251 | settings: [ 252 | // --- Tag source Settings --- 253 | { 254 | id: id + ".tag_source_icon_position", 255 | name: "Tag Source Icon Position", 256 | type: "combo", 257 | options: ["left", "right", "hidden"], 258 | defaultValue: "left", 259 | category: [name, "Tag Source", "Tag Source Icon Position"], 260 | onChange: (newVal, oldVal) => { 261 | settingValues.tagSourceIconPosition = newVal; 262 | } 263 | }, 264 | { 265 | id: id + ".primary_tag_source", 266 | name: "Primary source for 'all' Source", 267 | tooltip: "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.", 268 | type: "combo", 269 | options: Object.values(TagSource), 270 | defaultValue: TagSource.Danbooru, 271 | category: [name, "Tag Source", "Prioritize Tag Source"], 272 | onChange: (newVal, oldVal) => { 273 | settingValues.primaryTagSource = newVal; 274 | } 275 | }, 276 | { 277 | id: id + ".tag_source", 278 | name: "Autocomplete Tag Source", 279 | tooltip: "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.", 280 | type: "combo", 281 | options: [...Object.values(TagSource), "all"], 282 | defaultValue: "all", 283 | category: [name, "Tag Source", "Tag Source"], 284 | onChange: (newVal, oldVal) => { 285 | settingValues.tagSource = newVal; 286 | } 287 | }, 288 | // --- Autocomplete Settings --- 289 | { 290 | id: id + ".max_suggestions", 291 | name: "Max suggestions", 292 | type: "slider", 293 | attrs: { 294 | min: 5, 295 | max: 50, 296 | step: 5, 297 | }, 298 | defaultValue: 10, 299 | category: [name, "Autocompletion", "Max suggestions"], 300 | onChange: (newVal, oldVal) => { 301 | settingValues.maxSuggestions = newVal; 302 | } 303 | }, 304 | { 305 | id: id + ".boolean", 306 | name: "Enable Autocomplete", 307 | description: "Enable or disable the autocomplete feature.", 308 | type: "boolean", 309 | defaultValue: true, 310 | category: [name, "Autocompletion", "Enable Autocomplete"], 311 | onChange: (newVal, oldVal) => { 312 | settingValues.enabled = newVal; 313 | } 314 | }, 315 | 316 | // --- Related Tags Settings --- 317 | { 318 | id: id + ".related_tags_trigger_mode", 319 | name: "Related Tags Trigger Mode", 320 | description: "Trigger mode for related tags (click or ctrl+click)", 321 | type: "combo", 322 | options: ["click", "ctrl+Click"], 323 | defaultValue: "click", 324 | category: [name, "Related Tags", "Trigger Mode"], 325 | onChange: (newVal, oldVal) => { 326 | settingValues.relatedTagsTriggerMode = newVal; 327 | } 328 | }, 329 | { 330 | id: id + ".related_tags_position", 331 | name: "Default Display Position", 332 | description: "Display position (relative to Textarea)", 333 | type: "combo", 334 | options: ["horizontal", "vertical"], 335 | defaultValue: "horizontal", 336 | category: [name, "Related Tags", "Display Position"], 337 | onChange: (newVal, oldVal) => { 338 | settingValues.relatedTagsDisplayPosition = newVal; 339 | } 340 | }, 341 | { 342 | id: id + ".max_related_tags", 343 | name: "Max related tags", 344 | description: "Maximum number of related tags to display", 345 | type: "slider", 346 | attrs: { 347 | min: 5, 348 | max: 100, 349 | step: 5, 350 | }, 351 | defaultValue: 20, 352 | category: [name, "Related Tags", "Max related tags"], 353 | onChange: (newVal, oldVal) => { 354 | settingValues.maxRelatedTags = newVal; 355 | } 356 | }, 357 | { 358 | id: id + ".related_tags_enable", 359 | name: "Enable Related Tags", 360 | description: "Enable or disable the related tags feature.", 361 | type: "boolean", 362 | defaultValue: true, 363 | category: [name, "Related Tags", "Enable Related Tags"], 364 | onChange: (newVal, oldVal) => { 365 | settingValues.enableRelatedTags = newVal; 366 | } 367 | } 368 | ] 369 | }); -------------------------------------------------------------------------------- /web/js/related-tags.js: -------------------------------------------------------------------------------- 1 | import { TagSource, TagCategory, TagData, autoCompleteData } from './data.js'; 2 | import { settingValues } from './settings.js'; 3 | import { 4 | extractTagsFromTextArea, 5 | findAllTagPositions, 6 | getViewportMargin, 7 | isValidTag, 8 | normalizeTagToInsert, 9 | normalizeTagToSearch, 10 | getCurrentTagRange, 11 | } from './utils.js'; 12 | 13 | // --- RelatedTags Logic --- 14 | 15 | /** 16 | * Calculates the Jaccard similarity between two tags. 17 | * Jaccard similarity = (A ∩ B) / (A ∪ B) = (A ∩ B) / (|A| + |B| - |A ∩ B|) 18 | * @param {string} tagSource The name of the site (e.g., 'danbooru', 'e621') 19 | * @param {string} tagA The first tag 20 | * @param {string} tagB The second tag 21 | * @returns {number} Similarity score between 0 and 1 22 | */ 23 | function calculateJaccardSimilarity(tagSource, tagA, tagB) { 24 | // Get the count of tagA and tagB individually 25 | const countA = autoCompleteData[tagSource].tagMap.get(tagA)?.count || 0; 26 | const countB = autoCompleteData[tagSource].tagMap.get(tagB)?.count || 0; 27 | 28 | if (countA === 0 || countB === 0) return 0; 29 | 30 | // Get the cooccurrence count 31 | const cooccurrenceAB = autoCompleteData[tagSource].cooccurrenceMap.get(tagA)?.get(tagB) || 0; 32 | 33 | // Calculate Jaccard similarity 34 | // (A ∩ B) / (A ∪ B) = (A ∩ B) / (|A| + |B| - |A ∩ B|) 35 | const intersection = cooccurrenceAB; 36 | const union = countA + countB - cooccurrenceAB; 37 | 38 | return union > 0 ? intersection / union : 0; 39 | } 40 | 41 | /** 42 | * Extracts the tag at the current cursor position. 43 | * Utilizes getCurrentTagRange to properly handle tags with weights and parentheses. 44 | * @param {HTMLTextAreaElement} inputElement The textarea element 45 | * @returns {string|null} The tag at cursor or null 46 | */ 47 | export function getTagFromCursorPosition(inputElement) { 48 | const text = inputElement.value; 49 | const cursorPos = inputElement.selectionStart; 50 | 51 | // Use getCurrentTagRange to get the tag at the cursor position 52 | const tagRange = getCurrentTagRange(text, cursorPos); 53 | 54 | // If no tag was found at the cursor position 55 | if (!tagRange) return null; 56 | 57 | // Return the normalized tag for searching 58 | return normalizeTagToSearch(tagRange.tag); 59 | } 60 | 61 | /** 62 | * Finds related tags for a given tag. 63 | * @param {string} tag The tag to find related tags for 64 | */ 65 | function searchRelatedTags(tag) { 66 | const startTime = performance.now(); // Record start time for performance measurement 67 | 68 | const tagSource = TagSource.Danbooru; // TODO: Leave the tag source as Danbooru until e621_tags_cooccurrence.csv is ready 69 | 70 | if (!tag || !autoCompleteData[tagSource].cooccurrenceMap.has(tag)) { 71 | return []; 72 | } 73 | 74 | const cooccurrences = autoCompleteData[tagSource].cooccurrenceMap.get(tag); 75 | const relatedTags = []; 76 | 77 | // Convert to array for sorting 78 | cooccurrences.forEach((count, coTag) => { 79 | // Skip the tag itself 80 | if (coTag === tag) return; 81 | 82 | // Get tag data 83 | const tagData = autoCompleteData[tagSource].tagMap.get(coTag); 84 | if (!tagData) return; 85 | 86 | // Calculate similarity 87 | const similarity = calculateJaccardSimilarity(tagSource, tag, coTag); 88 | 89 | relatedTags.push({ 90 | tag: coTag, 91 | similarity: similarity, 92 | alias: tagData.alias, 93 | category: tagData.category, 94 | source: tagData.source, 95 | count: tagData.count, 96 | }); 97 | }); 98 | 99 | // Sort by similarity (highest first) 100 | relatedTags.sort((a, b) => b.similarity - a.similarity); 101 | 102 | // Limit to max number of suggestions 103 | const result = relatedTags.slice(0, settingValues.maxRelatedTags); 104 | 105 | if (settingValues._logprocessingTime) { 106 | const endTime = performance.now(); 107 | const duration = endTime - startTime; 108 | console.debug(`[Autocomplete-Plus] Find tags to related "${tag}" took ${duration.toFixed(2)}ms.`); 109 | } 110 | 111 | return result; 112 | } 113 | 114 | /** 115 | * Function to insert a tag into the textarea. 116 | * Appends the selected tag after the current tag. 117 | * Supports undo by using document.execCommand. 118 | * Checks if the tag already exists in the next position. 119 | * If the tag already exists anywhere in the input, it selects that tag instead. 120 | * @param {HTMLTextAreaElement} inputElement 121 | * @param {string} tagToInsert 122 | */ 123 | function insertTagToTextArea(inputElement, tagToInsert) { 124 | const text = inputElement.value; 125 | const cursorPos = inputElement.selectionStart; 126 | 127 | // First check if the tag exists anywhere in the textarea and select it if found 128 | const tagPositions = findAllTagPositions(text); 129 | for (const { start, end, tag } of tagPositions) { 130 | const existingTag = tag.trim(); 131 | if (existingTag === normalizeTagToInsert(tagToInsert)) { 132 | // Tag already exists, select it and exit 133 | inputElement.focus(); 134 | inputElement.setSelectionRange(start, end); 135 | return; 136 | } 137 | } 138 | 139 | // Find the current tag boundaries 140 | const lastComma = text.lastIndexOf(',', cursorPos - 1); 141 | const lastNewLine = text.lastIndexOf('\n', cursorPos - 1); 142 | const lastSeparator = Math.max(lastComma, lastNewLine); 143 | const startPos = lastSeparator === -1 ? 0 : lastSeparator + 1; 144 | 145 | // Find the end of the current tag 146 | let endPosComma = text.indexOf(',', startPos); 147 | let endPosNewline = text.indexOf('\n', startPos); 148 | 149 | if (endPosComma === -1) endPosComma = text.length; 150 | if (endPosNewline === -1) endPosNewline = text.length; 151 | 152 | const endPos = Math.min(endPosComma, endPosNewline); 153 | 154 | const prefix = startPos != endPos ? ', ' : ' '; 155 | // Prepare the text to insert 156 | const normalizedTag = normalizeTagToInsert(tagToInsert); 157 | let textToInsert = prefix + normalizedTag; 158 | 159 | // --- Use execCommand for Undo support --- 160 | // 1. Select the range where the tag will be inserted 161 | inputElement.focus(); 162 | inputElement.setSelectionRange(endPos, endPos); 163 | 164 | // 2. Execute the insertText command to add the tag 165 | const insertTextSuccess = document.execCommand('insertText', false, textToInsert); 166 | 167 | // Fallback for browsers where execCommand might not be supported or might fail 168 | if (!insertTextSuccess) { 169 | console.warn('[Autocomplete-Plus] execCommand("insertText") failed. Falling back to direct value manipulation (Undo might not work).'); 170 | 171 | const textBefore = text.substring(0, endPos); 172 | const textAfter = text.substring(endPos); 173 | 174 | // Insert the tag directly into the value 175 | inputElement.value = textBefore + textToInsert + textAfter; 176 | 177 | // Set cursor position after the newly inserted tag 178 | const newCursorPos = endPos + textToInsert.length; 179 | inputElement.selectionStart = inputElement.selectionEnd = newCursorPos; 180 | 181 | // Trigger input event to notify ComfyUI about the change 182 | inputElement.dispatchEvent(new Event('input', { bubbles: true })); 183 | } 184 | } 185 | 186 | // --- RelatedTags UI Class --- 187 | 188 | /** 189 | * Class that manages the UI for displaying related tags. 190 | * Shows a panel with tags related to the current tag under cursor. 191 | */ 192 | class RelatedTagsUI { 193 | constructor() { 194 | // Create the main container 195 | this.root = document.createElement('div'); 196 | this.root.id = 'related-tags-root'; 197 | 198 | // Create header row 199 | this.header = document.createElement('div'); 200 | this.header.id = 'related-tags-header'; 201 | 202 | // Create header text div for the left side 203 | this.headerText = document.createElement('div'); 204 | this.headerText.className = 'related-tags-header-text'; 205 | this.headerText.textContent = 'Related Tags'; 206 | this.header.appendChild(this.headerText); 207 | 208 | // Create header controls for the right side 209 | this.headerControls = document.createElement('div'); 210 | this.headerControls.className = 'related-tags-header-controls'; 211 | 212 | // Create layout toggle button 213 | this.toggleLayoutBtn = document.createElement('button'); 214 | this.toggleLayoutBtn.className = 'related-tags-layout-toggle'; 215 | this.toggleLayoutBtn.title = 'Toggle between vertical and horizontal layout'; 216 | 217 | // Add click handler for layout toggle 218 | this.toggleLayoutBtn.addEventListener('click', (e) => { 219 | // Toggle the layout setting 220 | settingValues.relatedTagsDisplayPosition = 221 | settingValues.relatedTagsDisplayPosition === 'vertical' 222 | ? 'horizontal' 223 | : 'vertical'; 224 | 225 | this.#updateHeader(); 226 | this.#updatePosition(); 227 | this.root.style.display = 'block'; 228 | 229 | // Prevent default behavior 230 | e.preventDefault(); 231 | e.stopPropagation(); 232 | }); 233 | this.headerControls.appendChild(this.toggleLayoutBtn); 234 | 235 | // Create pin button 236 | this.isPinned = false; 237 | this.pinBtn = document.createElement('button'); 238 | this.pinBtn.className = 'related-tags-pin-toggle'; 239 | 240 | this.pinBtn.addEventListener('click', (e) => { 241 | this.isPinned = !this.isPinned; 242 | this.pinBtn.classList.toggle('active', this.isPinned); // For styling 243 | this.#updateHeader(); 244 | 245 | // Prevent default behavior 246 | e.preventDefault(); 247 | e.stopPropagation(); 248 | }); 249 | this.headerControls.appendChild(this.pinBtn); 250 | 251 | this.header.appendChild(this.headerControls); 252 | 253 | this.root.appendChild(this.header); 254 | 255 | // Create a tbody for the tags 256 | this.tagsContainer = document.createElement('div'); 257 | this.tagsContainer.id = 'related-tags-list'; 258 | this.root.appendChild(this.tagsContainer); 259 | 260 | // Add to DOM 261 | document.body.appendChild(this.root); 262 | 263 | this.target = null; 264 | this.selectedIndex = -1; 265 | this.relatedTags = []; 266 | 267 | // Timer ID for auto-refresh 268 | this.autoRefreshTimerId = null; 269 | 270 | // Add click handler for tag selection 271 | this.tagsContainer.addEventListener('mousedown', (e) => { 272 | const row = e.target.closest('.related-tag-item'); 273 | if (row && row.dataset.tag) { 274 | this.#insertTag(row.dataset.tag); 275 | e.preventDefault(); 276 | e.stopPropagation(); 277 | } 278 | }); 279 | } 280 | 281 | /** 282 | * Checks if the related tags UI is currently visible. 283 | * @returns {boolean} 284 | */ 285 | isVisible() { 286 | return this.root.style.display !== 'none'; 287 | } 288 | 289 | /** 290 | * Display 291 | * @param {HTMLTextAreaElement} textareaElement The textarea being used 292 | */ 293 | show(textareaElement) { 294 | if (!settingValues.enableRelatedTags) { 295 | this.hide(); 296 | return; 297 | } 298 | 299 | // Get the tag at current cursor position 300 | const currentTag = getTagFromCursorPosition(textareaElement); 301 | 302 | if (!this.isPinned) { 303 | if (isValidTag(currentTag)) { 304 | this.currentTag = currentTag 305 | } else { 306 | this.hide(); 307 | return; 308 | } 309 | } 310 | 311 | this.target = textareaElement; 312 | 313 | this.relatedTags = searchRelatedTags(this.currentTag); 314 | if (this.selectedIndex == -1) { 315 | this.selectedIndex = 0; // Reset selection to the first item 316 | } 317 | 318 | this.#updateHeader(); 319 | this.#updateContent(); 320 | this.#updatePosition(); 321 | 322 | // Make visible 323 | this.root.style.display = 'block'; 324 | 325 | // This function must be called after the content is updated and the root is displayed. 326 | this.#highlightItem(); 327 | 328 | // Update initialization status if not already done 329 | if (!autoCompleteData[TagSource.Danbooru].initialized) { 330 | if (this.autoRefreshTimerId) { 331 | clearTimeout(this.autoRefreshTimerId); 332 | } 333 | this.autoRefreshTimerId = setTimeout(() => { 334 | this.#refresh(); 335 | }, 500); 336 | } 337 | } 338 | 339 | /** 340 | * Hides the related tags UI. 341 | */ 342 | hide() { 343 | if (this.autoRefreshTimerId) { 344 | clearTimeout(this.autoRefreshTimerId); 345 | } 346 | 347 | this.root.style.display = 'none'; 348 | this.selectedIndex = -1; 349 | this.relatedTags = null; 350 | this.target = null; 351 | // Reset pinned state when hiding, unless hide was called by escape key while pinned 352 | if (document.activeElement !== this.pinBtn) { // Avoid unpinning if pin button was just clicked to hide 353 | this.isPinned = false; 354 | this.pinBtn.classList.remove('active'); 355 | } 356 | } 357 | 358 | /** Moves the selection up or down */ 359 | navigate(direction) { 360 | if (this.relatedTags.length === 0) return; 361 | this.selectedIndex += direction; 362 | 363 | if (this.selectedIndex < 0) { 364 | this.selectedIndex = this.relatedTags.length - 1; // Wrap around to bottom 365 | } else if (this.selectedIndex >= this.relatedTags.length) { 366 | this.selectedIndex = 0; // Wrap around to top 367 | } 368 | this.#highlightItem(); 369 | } 370 | 371 | /** Selects the currently highlighted item */ 372 | getSelectedTag() { 373 | if (this.selectedIndex >= 0 && this.selectedIndex < this.relatedTags.length) { 374 | return this.relatedTags[this.selectedIndex].tag; 375 | } 376 | 377 | return null; // No valid selection 378 | } 379 | 380 | /** 381 | * Refresh the displayed content 382 | */ 383 | #refresh() { 384 | if (this.target) { 385 | this.show(this.target); 386 | } 387 | } 388 | 389 | /** 390 | * Updates header content 391 | */ 392 | #updateHeader() { 393 | // Find the tag data for the current tag 394 | const tagData = Object.values(TagSource) 395 | .map((source) => { 396 | if (source in autoCompleteData && autoCompleteData[source].tagMap.has(this.currentTag)) { 397 | return autoCompleteData[source].tagMap.get(this.currentTag); 398 | } 399 | }) 400 | .find((tagData) => tagData !== undefined); 401 | 402 | // Update header text with current tag 403 | this.headerText.innerHTML = ''; // Clear previous content 404 | this.headerText.textContent = 'Tags related to: '; 405 | const tagName = document.createElement('span'); 406 | tagName.className = 'related-tags-header-tag-name'; 407 | tagName.textContent = this.currentTag; 408 | if (tagData && ['left', 'right'].includes(settingValues.tagSourceIconPosition)) { 409 | const tagSourceIconHtml = ``; 410 | tagName.innerHTML = settingValues.tagSourceIconPosition == 'left' 411 | ? `${tagSourceIconHtml} ${tagData.tag}` 412 | : `${tagData.tag} ${tagSourceIconHtml}`; 413 | } 414 | 415 | this.headerText.appendChild(tagName); 416 | 417 | // Update pin button 418 | this.pinBtn.textContent = this.isPinned ? '🎯' : '📌'; 419 | 420 | // Update the button icon 421 | this.toggleLayoutBtn.innerHTML = settingValues.relatedTagsDisplayPosition === 'vertical' 422 | ? '↔️' // Click to change display horizontally 423 | : '↕️'; // Click to change display vertically 424 | } 425 | 426 | /** 427 | * Updates the content of the related tags panel with the provided tags. 428 | */ 429 | #updateContent() { 430 | this.tagsContainer.innerHTML = ''; 431 | 432 | if (!autoCompleteData[TagSource.Danbooru].initialized) { 433 | // Show loading message 434 | const messageDiv = document.createElement('div'); 435 | messageDiv.className = 'related-tags-loading-message'; 436 | messageDiv.textContent = `Initializing cooccurrence data... [${autoCompleteData[TagSource.Danbooru].baseLoadingProgress.cooccurrence}%]`; 437 | this.tagsContainer.appendChild(messageDiv); 438 | return; 439 | } 440 | 441 | if (!this.relatedTags || this.relatedTags.length === 0) { 442 | // Show no related tags message 443 | const messageDiv = document.createElement('div'); 444 | messageDiv.textContent = 'No related tags found'; 445 | this.tagsContainer.appendChild(messageDiv); 446 | return; 447 | } 448 | 449 | const existingTags = extractTagsFromTextArea(this.target); 450 | 451 | // Create tag rows 452 | this.relatedTags.forEach(tagData => { 453 | const isExisting = existingTags.includes(tagData.tag); 454 | const tagRow = this.#createTagElement(tagData, isExisting); 455 | this.tagsContainer.appendChild(tagRow); 456 | }); 457 | } 458 | 459 | /** 460 | * Creates an HTML table row for a related tag. 461 | * @param {TagData} tagData The tag data to display 462 | * @param {boolean} isExisting Whether the tag already exists in the textarea 463 | * @returns {HTMLTableRowElement} The tag row element 464 | */ 465 | #createTagElement(tagData, isExisting) { 466 | const categoryText = TagCategory[tagData.source][tagData.category] || "unknown"; 467 | 468 | const tagRow = document.createElement('div'); 469 | tagRow.classList.add('related-tag-item', tagData.source); 470 | tagRow.dataset.tag = tagData.tag; 471 | tagRow.dataset.tagCategory = categoryText; 472 | 473 | // Tag name 474 | const tagName = document.createElement('span'); 475 | tagName.className = 'related-tag-name'; 476 | tagName.textContent = tagData.tag; 477 | 478 | // grayout tag name if it already exists 479 | if (isExisting) { 480 | tagName.classList.add('related-tag-already-exists'); 481 | } 482 | 483 | // Alias 484 | const alias = document.createElement('span'); 485 | alias.className = 'related-tag-alias'; 486 | 487 | // Display alias if available 488 | if (tagData.alias && tagData.alias.length > 0) { 489 | let aliasText = tagData.alias.join(', '); 490 | alias.textContent = `${aliasText}`; 491 | alias.title = tagData.alias.join(', '); // Full alias on hover 492 | } 493 | 494 | // Category 495 | const category = document.createElement('span'); 496 | category.className = `related-tag-category`; 497 | category.textContent = `${categoryText.substring(0, 2)}`; 498 | 499 | // Similarity 500 | const similarity = document.createElement('span'); 501 | similarity.className = 'related-tag-similarity'; 502 | similarity.textContent = `${(tagData.similarity * 100).toFixed(2)}%`; 503 | 504 | // Create tooltip with more info 505 | let tooltipText = `Tag: ${tagData.tag}\nSimilarity: ${(tagData.similarity * 100).toFixed(2)}%\nCount: ${tagData.count}`; 506 | if (tagData.alias && tagData.alias.length > 0) { 507 | tooltipText += `\nAlias: ${tagData.alias.join(', ')}`; 508 | } 509 | tagRow.title = tooltipText; 510 | 511 | // Add cells to row 512 | tagRow.appendChild(tagName); 513 | tagRow.appendChild(alias); 514 | tagRow.appendChild(category); 515 | tagRow.appendChild(similarity); 516 | 517 | return tagRow; 518 | } 519 | 520 | /** 521 | * Updates the position of the related tags panel. 522 | * Position is calculated based on the input element, available space, 523 | * and the setting `relatedTagsDisplayPosition`. 524 | * @param {HTMLElement} inputElement The input element to position 525 | */ 526 | #updatePosition() { 527 | // Measure the element size without causing reflow 528 | this.root.style.visibility = 'hidden'; 529 | this.root.style.display = 'block'; 530 | this.root.style.maxWidth = ''; 531 | this.tagsContainer.style.maxHeight = ''; 532 | const rootRect = this.root.getBoundingClientRect(); 533 | const headerRect = this.header.getBoundingClientRect(); 534 | // Hide it again after measurement 535 | this.root.style.display = 'none'; 536 | this.root.style.visibility = 'visible'; 537 | 538 | // Get the optimal placement area 539 | const placementArea = this.#getOptimalPlacementArea(rootRect.width, rootRect.height); 540 | 541 | // Apply Styles 542 | this.root.style.left = `${placementArea.x}px`; 543 | this.root.style.top = `${placementArea.y}px`; 544 | this.root.style.maxWidth = `${placementArea.width}px`; 545 | 546 | this.tagsContainer.style.maxHeight = `${placementArea.height - headerRect.height}px`; 547 | } 548 | 549 | /** Highlights the item (row) at the given index */ 550 | #highlightItem() { 551 | if (this.getSelectedTag() === null) return; // No valid selection 552 | 553 | const items = this.tagsContainer.children; // Get rows 554 | for (let i = 0; i < items.length; i++) { 555 | if (i === this.selectedIndex) { 556 | items[i].classList.add('selected'); // Use CSS class for selection 557 | items[i].scrollIntoView({ block: 'nearest' }); 558 | } else { 559 | items[i].classList.remove('selected'); 560 | } 561 | } 562 | } 563 | 564 | /** 565 | * Handles the selection of a related tag. 566 | * Inserts the tag into the active input. 567 | * @param {string} tag 568 | */ 569 | #insertTag(tag) { 570 | if (!this.target) return; 571 | 572 | // Use the same insertTag function from autocomplete.js 573 | insertTagToTextArea(this.target, tag); 574 | 575 | // Hide the panel after selection, unless pinned 576 | if (!this.isPinned) { 577 | this.hide(); 578 | } else { 579 | this.#highlightItem(); 580 | } 581 | } 582 | 583 | insertSelectedTag() { 584 | const selectedTag = this.getSelectedTag(); 585 | if (selectedTag) { 586 | this.#insertTag(selectedTag); 587 | } 588 | } 589 | 590 | /** 591 | * Calculates the optimal placement area for the panel based on available space. 592 | * @param {number} elemWidth - Width of the panel element. 593 | * @param {number} elemHeight - Height of the panel element. 594 | * @returns {{ x: number, y: number, width: number, height: number }} The calculated placement area. 595 | */ 596 | #getOptimalPlacementArea(elemWidth, elemHeight) { 597 | const viewportWidth = window.innerWidth; 598 | const viewportHeight = window.innerHeight; 599 | const margin = getViewportMargin(); 600 | const targetRect = this.target.getBoundingClientRect(); 601 | 602 | // Find optimal max width baesd on viewport and textarea element 603 | const maxWidth = Math.max( 604 | Math.min(targetRect.right, viewportWidth - margin.right) - targetRect.left, 605 | (viewportWidth - margin.left - margin.right) / 2 606 | ); 607 | 608 | const area = { 609 | x: Math.max(targetRect.x, margin.left), 610 | y: Math.max(targetRect.y, margin.top), 611 | width: Math.min(elemWidth, maxWidth), 612 | height: Math.min(elemHeight, viewportHeight - margin.top - margin.bottom) 613 | }; 614 | 615 | if (settingValues.relatedTagsDisplayPosition === 'vertical') { 616 | // Vertical placement 617 | const topSpace = targetRect.top - margin.top; 618 | const bottomSpace = viewportHeight - targetRect.bottom - margin.bottom; 619 | if (topSpace > bottomSpace) { 620 | // Place above 621 | area.height = Math.min(area.height, topSpace); 622 | area.y = Math.max(targetRect.y - area.height, margin.top); 623 | } else { 624 | // Place below 625 | area.height = Math.min(area.height, bottomSpace); 626 | area.y = targetRect.bottom; 627 | } 628 | 629 | // Adjust x position to avoid overflow 630 | area.x = Math.min(area.x, viewportWidth - area.width - margin.right); 631 | } else { 632 | // Horizontal placement 633 | const leftSpace = targetRect.x - margin.left; 634 | const rightSpace = viewportWidth - targetRect.right - margin.right; 635 | if (leftSpace > rightSpace) { 636 | // Place left 637 | area.width = Math.min(area.width, leftSpace); 638 | area.x = Math.max(targetRect.x - area.width, margin.left); 639 | } else { 640 | // Place right 641 | area.width = Math.min(area.width, rightSpace); 642 | area.x = targetRect.right; 643 | } 644 | 645 | // Adjust y position to avoid overflow 646 | area.y = Math.min(area.y, viewportHeight - area.height - margin.bottom); 647 | } 648 | 649 | return area; 650 | } 651 | } 652 | 653 | // --- RelatedTags Event Handling Class --- 654 | export class RelatedTagsEventHandler { 655 | constructor() { 656 | // Singleton instance of RelatedTagsUI 657 | this.relatedTagsUI = new RelatedTagsUI(); 658 | } 659 | 660 | /** 661 | * 662 | * @param {KeyboardEvent} event 663 | */ 664 | handleInput(event) { 665 | if (settingValues.enableRelatedTags) { 666 | if (this.relatedTagsUI.isVisible() && !this.relatedTagsUI.isPinned) { 667 | this.relatedTagsUI.hide(); 668 | } 669 | } 670 | } 671 | 672 | /** 673 | * 674 | * @param {KeyboardEvent} event 675 | */ 676 | handleFocus(event) { 677 | // Handle focus event 678 | } 679 | 680 | /** 681 | * 682 | * @param {KeyboardEvent} event 683 | */ 684 | handleBlur(event) { 685 | if (!settingValues._hideWhenOutofFocus) { 686 | return; 687 | } 688 | 689 | // Need a slight delay because clicking the related tags list causes blur 690 | setTimeout(() => { 691 | if (!this.relatedTagsUI.root.contains(document.activeElement) && !this.relatedTagsUI.isPinned) { 692 | this.relatedTagsUI.hide(); 693 | } 694 | }, 150); 695 | } 696 | 697 | /** 698 | * 699 | * @param {KeyboardEvent} event 700 | */ 701 | handleKeyDown(event) { 702 | // If related tags UI is pinned, don't handle key events except for Escape 703 | if (this.relatedTagsUI.isPinned) { 704 | if (event.key === 'Escape') { 705 | event.preventDefault(); 706 | this.relatedTagsUI.hide(); 707 | } 708 | return; 709 | } 710 | 711 | // Handle key events for related tags UI 712 | if (this.relatedTagsUI.isVisible()) { 713 | switch (event.key) { 714 | case 'ArrowDown': 715 | event.preventDefault(); 716 | this.relatedTagsUI.navigate(1); 717 | break; 718 | case 'ArrowUp': 719 | event.preventDefault(); 720 | this.relatedTagsUI.navigate(-1); 721 | break; 722 | case 'Enter': 723 | case 'Tab': 724 | if (this.relatedTagsUI.getSelectedTag() !== null) { 725 | event.preventDefault(); // Prevent Tab from changing focus 726 | this.relatedTagsUI.insertSelectedTag(); 727 | } else if (!this.relatedTagsUI.isPinned) { // If nothing selected and not pinned, hide the panel 728 | this.relatedTagsUI.hide(); 729 | } 730 | break; 731 | case 'Escape': 732 | event.preventDefault(); 733 | this.relatedTagsUI.hide(); 734 | break; 735 | } 736 | } 737 | 738 | // Show related tags on Ctrl+Shift+Space 739 | if (settingValues.enableRelatedTags) { 740 | if (event.key === ' ' && event.ctrlKey && event.shiftKey) { 741 | event.preventDefault(); 742 | this.relatedTagsUI.show(event.target); 743 | } 744 | } 745 | } 746 | 747 | /** 748 | * 749 | * @param {KeyboardEvent} event 750 | */ 751 | handleKeyUp(event) { 752 | 753 | } 754 | 755 | /** 756 | * 757 | * @param {MouseEvent} event 758 | * @returns 759 | */ 760 | handleMouseMove(event) { 761 | 762 | } 763 | 764 | /** 765 | * Show related tags based on the current tag under the cursor. 766 | * @param {MouseEvent} event 767 | * @returns 768 | */ 769 | handleClick(event) { 770 | // Hide related tags UI if not Ctrl+Click and not pinned when trigger mode is 'ctrl+Click' 771 | if (settingValues.relatedTagsTriggerMode === 'ctrl+Click' && !event.ctrlKey && !this.relatedTagsUI.isPinned) { 772 | this.relatedTagsUI.hide(); 773 | return; 774 | } 775 | 776 | const textareaElement = event.target; 777 | this.relatedTagsUI.show(textareaElement); 778 | } 779 | } -------------------------------------------------------------------------------- /web/js/settings.js: -------------------------------------------------------------------------------- 1 | export const settingValues = { 2 | // Tag source settings 3 | tagSource: 'all', // 'danbooru', 'e621', 'all' 4 | primaryTagSource: 'danbooru', // 'danbooru', 'e621' 5 | tagSourceIconPosition: 'left', // 'left', 'right', 'hidden' 6 | 7 | // Autocomplete feature settings 8 | enabled: true, 9 | maxSuggestions: 10, 10 | 11 | // Related tags feature settings 12 | enableRelatedTags: true, 13 | maxRelatedTags: 20, 14 | relatedTagsDisplayPosition: 'horizontal', // 'horizontal' or 'vertical' 15 | relatedTagsTriggerMode: 'click', // Options: 'click', 'ctrl+Click' 16 | 17 | 18 | // Internal logic settings 19 | _useFallbackAttachmentForEventListener: false, // Fallback to attach event listener when somthing goes wrong 20 | 21 | // Debugging settings (use internally) 22 | _hideWhenOutofFocus: true, // Hide UI when the input is out of focus 23 | _logprocessingTime: false, // Log processing time for debugging 24 | } -------------------------------------------------------------------------------- /web/js/utils.js: -------------------------------------------------------------------------------- 1 | // --- Html String constants --- 2 | 3 | /** 4 | * HTML string for the tag source icon. 5 | */ 6 | export const IconSvgHtmlString = ` 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 19 | 20 | 21 | e 22 | 23 | 24 | 25 | `; 26 | 27 | // --- String Helper Functions --- 28 | 29 | const MAX_PROMPT_WEIGHT_VALUE = 9.9; 30 | 31 | // Regex constants 32 | const REG_ESCAPE_OPEN_PAREN = /(? 0; i--) { 93 | if (num >= si[i].value) { 94 | break; 95 | } 96 | } 97 | // Format with one decimal place if needed, remove trailing zeros and '.'. 98 | return (num / si[i].value).toFixed(1).replace(rx, "$1") + si[i].symbol; 99 | } 100 | 101 | /** 102 | * Escapes parentheses in a string for use in prompts. 103 | * Replaces '(' with '\(' and ')' with '\)'. 104 | * Ignores already escaped parentheses like '\('. 105 | * @param {string} str The input string. 106 | * @returns {string} The string with parentheses escaped. 107 | */ 108 | export function escapeParentheses(str) { 109 | if (!str) return str; 110 | // Use lookbehind assertions to avoid double escaping 111 | return str.replace(REG_ESCAPE_OPEN_PAREN, '\\(').replace(REG_ESCAPE_CLOSE_PAREN, '\\)'); 112 | } 113 | 114 | /** 115 | * Unescapes parentheses in a string. 116 | * Replaces '\(' with '(' and '\)' with ')'. 117 | * @param {string} str The input string. 118 | * @returns {string} The string with parentheses unescaped. 119 | */ 120 | export function unescapeParentheses(str) { 121 | if (!str) return str; 122 | return str.replace(REG_UNESCAPE_OPEN_PAREN, '(').replace(REG_UNESCAPE_CLOSE_PAREN, ')'); 123 | } 124 | 125 | /** 126 | * Removes prompt weights from a tag (e.g., "tag:1.2" becomes "tag"). 127 | * Preserves tags with colons like "year:2000" or "foo:bar". 128 | * Preserves symbol-only tags like ";)" or "^_^". 129 | * @param {string} str The input tag string. 130 | * @returns {string} The tag without weight and without surrounding non-escaped brackets. 131 | */ 132 | export function removePromptWeight(str) { 133 | if (!str) return str; 134 | 135 | // For symbol-only tags (no letters/numbers), return as-is 136 | if (!isContainsLetterOrNumber(str)) { 137 | return str; 138 | } 139 | 140 | // Only remove weight notation for patterns that look like actual weights 141 | // (e.g., ":1.2" where the number is between 0-9.9) 142 | let result = str.replace(/(.+?):([0-9](\.\d+)?)$/, (match, p1, p2) => { 143 | // If the number after colon is between 0-9.9, it's likely a weight 144 | if (parseFloat(p2) <= MAX_PROMPT_WEIGHT_VALUE) { 145 | return p1; 146 | } 147 | // Otherwise preserve the entire string (like "year:2000") 148 | return match; 149 | }); 150 | 151 | // Only remove non-escaped brackets if the string contains letters or numbers 152 | // Use negative lookbehind (? 4; 199 | 200 | if (!isWildcardCall) { 201 | // If doesn't wildcard call, replace underscores with spaces 202 | return escapeParentheses(str.replace(/_/g, " ")); 203 | } 204 | } 205 | 206 | // Otherwise, keep it as is (e.g., ""^_^", "__wildcard__") 207 | return escapeParentheses(str); 208 | } 209 | 210 | /** 211 | * Checks if a tag is valid (not wildcard or Lora notation). 212 | * @param {string} tag 213 | * @returns 214 | */ 215 | export function isValidTag(tag) { 216 | if (!tag || tag.length < 2) { 217 | return false; 218 | } 219 | 220 | // Skip wildcard notation (e.g., "__character__") 221 | if (tag.startsWith('__') && tag.endsWith('__')) { 222 | return false; 223 | } 224 | 225 | // Skip Lora notation (e.g., "") 226 | if (//i.test(tag)) { 227 | return false; 228 | } 229 | 230 | return true; 231 | } 232 | 233 | /** 234 | * Recursively finds all words in a string, even those inside nested braces. 235 | * This helps extract all possible tags from complex nested wildcards. 236 | * @param {string} text The text to extract words from 237 | * @param {number} baseStart The starting position of the text in the original string 238 | * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags 239 | */ 240 | function extractAllWords(text, baseStart) { 241 | const result = []; 242 | 243 | // First, extract weighted tag patterns like "20::from above" 244 | let weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); 245 | while (weightMatch !== null) { 246 | const tagText = weightMatch[2].trim(); 247 | 248 | if (tagText) { 249 | // Calculate position with original offsets 250 | const fullMatchStart = baseStart + weightMatch.index; 251 | const tagTextStart = fullMatchStart + weightMatch[0].indexOf(tagText); 252 | const tagTextEnd = tagTextStart + tagText.length; 253 | 254 | result.push({ 255 | start: tagTextStart, 256 | end: tagTextEnd, 257 | tag: tagText 258 | }); 259 | } 260 | 261 | weightMatch = REG_WILDCARD_WEIGHTED_TAG.exec(text); 262 | } 263 | 264 | // If no weighted tags were found, extract simple words 265 | if (result.length === 0) { 266 | // Regular expression to match words (sequences of non-whitespace characters) 267 | // We consider a word to be any continuous sequence of characters that's not a space, pipe, or brace 268 | const wordRegex = REG_WILDCARD_SIMPLE_WORD; 269 | let match; 270 | 271 | // Find all standalone words in the text 272 | while ((match = wordRegex.exec(text)) !== null) { 273 | const wordStart = baseStart + match.index; 274 | const wordEnd = wordStart + match[0].length; 275 | 276 | // Remove leading and trailing spaces from the matched word 277 | const trimmedTag = match[0].trim(); 278 | const leadingSpaces = match[0].length - match[0].trimStart().length; 279 | const adjustedStart = wordStart + leadingSpaces; 280 | const adjustedEnd = wordStart + leadingSpaces + trimmedTag.length; 281 | 282 | result.push({ 283 | start: adjustedStart, 284 | end: adjustedEnd, 285 | tag: trimmedTag 286 | }); 287 | } 288 | } 289 | 290 | return result; 291 | } 292 | 293 | /** 294 | * Parses a wildcard selection and returns individual tags. 295 | * Supports syntax like {tag1|tag2|tag3} and {weight::tag1|weight::tag2}. 296 | * Also handles nested wildcards like {tag1 {tag2|tag3}|tag4}. 297 | * @param {string} tag The complete tag text that might contain a wildcard 298 | * @param {number} startPos The starting position of the tag in the original text 299 | * @param {number} endPos The ending position of the tag in the original text 300 | * @returns {Array<{start: number, end: number, tag: string}>} Array of parsed tags or null 301 | */ 302 | function parseWildcardSelection(tag, startPos, endPos) { 303 | // Trim the tag for matching but keep original position 304 | const trimmedTag = tag.trim(); 305 | 306 | // Check if this is a wildcard selection 307 | if (!trimmedTag.startsWith('{') || !trimmedTag.endsWith('}')) { 308 | return null; // Not a wildcard 309 | } 310 | 311 | // Calculate position offsets for the trim operation 312 | const leadingSpaces = tag.length - tag.trimStart().length; 313 | const tagStart = startPos + leadingSpaces; 314 | 315 | // For nested wildcards, we'll extract all words from the content 316 | // This treats each word as a separate tag, regardless of nesting 317 | // Extract the content between the outermost braces 318 | const wildcardContent = trimmedTag.substring(1, trimmedTag.length - 1); 319 | 320 | // Extract all words from the wildcard content, including those in nested structures 321 | const allTags = extractAllWords(wildcardContent, tagStart + 1); 322 | 323 | return allTags.length > 0 ? allTags : null; 324 | } 325 | 326 | /** 327 | * Finds all tag positions in the given text. 328 | * Searches for tags separated by commas or newlines. 329 | * Also handles wildcard selections in the format {tag1|tag2|tag3}. 330 | * @param {string} text The text to search in 331 | * @returns {Array<{start: number, end: number, tag: string}>} Array of tag positions and content 332 | */ 333 | export function findAllTagPositions(text) { 334 | if (!text) return []; 335 | 336 | const positions = []; 337 | let startPos = 0; 338 | 339 | // Process text segment by segment (comma or newline separated) 340 | while (startPos < text.length) { 341 | // Skip any leading whitespace, commas, or newlines 342 | while (startPos < text.length && 343 | (text[startPos] === ' ' || text[startPos] === ',' || text[startPos] === '\n')) { 344 | startPos++; 345 | } 346 | 347 | if (startPos >= text.length) break; 348 | 349 | // Find the end of this tag (next comma or newline) 350 | let endPosComma = text.indexOf(',', startPos); 351 | let endPosNewline = text.indexOf('\n', startPos); 352 | 353 | if (endPosComma === -1) endPosComma = text.length; 354 | if (endPosNewline === -1) endPosNewline = text.length; 355 | 356 | const endPos = Math.min(endPosComma, endPosNewline); 357 | const tagText = text.substring(startPos, endPos); 358 | 359 | if (tagText.trim().length > 0) { 360 | const trimmedTag = tagText.trim(); 361 | 362 | // Check if this is a wildcard selection 363 | if (trimmedTag.startsWith('{') && trimmedTag.endsWith('}')) { 364 | // Process wildcard using our existing wildcard parser 365 | const wildcardTags = parseWildcardSelection(tagText, startPos, endPos); 366 | if (wildcardTags) { 367 | positions.push(...wildcardTags); 368 | } 369 | } else { 370 | // Normal tag, add it directly 371 | positions.push({ 372 | start: startPos, 373 | end: endPos, 374 | tag: tagText 375 | }); 376 | } 377 | } 378 | 379 | // Move to the next tag 380 | startPos = endPos + 1; 381 | } 382 | 383 | return positions; 384 | } 385 | 386 | /** 387 | * Extracts existing tags from the textarea with search normalization (possibly duplicated). 388 | * @param {HTMLTextAreaElement} textarea The textarea element to extract tags from 389 | * @returns {string[]} Array of existing tags 390 | */ 391 | export function extractTagsFromTextArea(textarea) { 392 | const existingTagsInTextarea = []; 393 | if (textarea && textarea.value) { 394 | const tagPositions = findAllTagPositions(textarea.value); 395 | tagPositions.forEach(pos => { 396 | existingTagsInTextarea.push(normalizeTagToSearch(pos.tag)); 397 | }); 398 | } 399 | return existingTagsInTextarea; 400 | } 401 | 402 | /** 403 | * Gets the start and end indices of the tag at the current cursor position, 404 | * applying specific rules for prompt weights and parentheses. 405 | * @param {string} text The entire text content. 406 | * @param {number} cursorPos The current cursor position in the text. 407 | * @returns {{start: number, end: number, tag: string} | null} An object with start, end, and tag string, or null if no tag is found. 408 | */ 409 | export function getCurrentTagRange(text, cursorPos) { 410 | if (!text || typeof text !== 'string') { 411 | return null; 412 | } 413 | 414 | // Clamp cursorPos to valid range 415 | const clampedCursorPos = Math.min(Math.max(cursorPos, 0), text.length); 416 | 417 | const allTags = findAllTagPositions(text); 418 | let currentTagPos = null; 419 | 420 | for (const pos of allTags) { 421 | // Find the tag whose range [start, end] (inclusive start, exclusive end for substring) 422 | if (clampedCursorPos >= pos.start && clampedCursorPos <= pos.end) { 423 | currentTagPos = { ...pos }; // Clone the position object 424 | // If cursor is strictly within [pos.start, pos.end), this is a strong candidate. 425 | if (clampedCursorPos < pos.end) { 426 | break; 427 | } 428 | // If clampedCursorPos === pos.end, continue searching to see if a subsequent tag starts exactly here. 429 | // If no subsequent tag starts at clampedCursorPos, this currentTagPos (where cursor is at its end) will be used. 430 | } else if (currentTagPos && clampedCursorPos < pos.start) { 431 | // If we had a candidate where cursorPos === pos.end, 432 | // but now we've passed cursorPos, that candidate was the correct one. 433 | break; 434 | } 435 | } 436 | 437 | if (!currentTagPos) { 438 | return null; 439 | } 440 | 441 | let { tag, start, end } = currentTagPos; 442 | 443 | // Rule 1: If the tag consists only of symbols, return it as is. 444 | // (e.g., ";)", ">:)") 445 | if (!isContainsLetterOrNumber(tag)) { 446 | if (start < end) { // Ensure it's a valid range 447 | return { start, end, tag }; 448 | } 449 | return null; 450 | } 451 | 452 | // For tags containing letters/numbers, apply rules for parentheses and weights. 453 | let adjustedTag = tag; 454 | let adjustedStart = start; 455 | let adjustedEnd = end; 456 | 457 | // Rule 2: Exclude non-escaped parentheses surrounding the tag. 458 | // (e.g., "(black hair:1.0)" -> "black hair:1.0", "foo \(bar\)" -> "foo \(bar\)") 459 | // Apply iteratively for cases like "((tag))" if necessary, though typically one layer. 460 | 461 | let changedInParenStep; 462 | do { 463 | changedInParenStep = false; 464 | 465 | // Remove leading non-escaped parenthesis 466 | const leadParenMatch = adjustedTag.match(REG_STRIP_LEADING_PAREN); 467 | if (leadParenMatch) { 468 | const newTag = leadParenMatch[1]; 469 | adjustedStart += (adjustedTag.length - newTag.length); 470 | adjustedTag = newTag; 471 | changedInParenStep = true; 472 | } 473 | 474 | // Remove trailing non-escaped parenthesis 475 | const trailParenMatch = adjustedTag.match(REG_STRIP_TRAILING_PAREN); 476 | if (trailParenMatch) { 477 | const newTag = trailParenMatch[1]; 478 | adjustedEnd -= (adjustedTag.length - newTag.length); 479 | adjustedTag = newTag; 480 | changedInParenStep = true; 481 | } 482 | // If the tag becomes empty or invalid during parenthesis removal, stop. 483 | if (adjustedStart >= adjustedEnd) break; 484 | 485 | } while (changedInParenStep && adjustedTag.length > 0); 486 | 487 | 488 | if (adjustedStart >= adjustedEnd) { 489 | return null; // Tag became empty after parenthesis removal 490 | } 491 | 492 | // Rule 3: Exclude prompt strength syntax (e.g., ":1.0") but include colons in names. 493 | // (e.g., "standing:1.0" -> "standing", "foo:bar" -> "foo:bar", "year:2000" -> "year:2000") 494 | // This applies to the tag *after* parentheses are handled. 495 | const weightMatch = adjustedTag.match(REG_PROMPT_WEIGHT); 496 | 497 | if (weightMatch) { 498 | const tagPart = weightMatch[1]; 499 | const weightValue = weightMatch[2]; 500 | // Only consider it as a weight if it's a simple number between 0-9 possibly with decimal 501 | // Don't treat larger numbers like :1999 or :2000 as weights 502 | if (parseFloat(weightValue) <= MAX_PROMPT_WEIGHT_VALUE) { 503 | const fullWeightString = adjustedTag.substring(tagPart.length); 504 | if (tagPart.length > 0 || (tagPart.length === 0 && fullWeightString === adjustedTag)) { 505 | adjustedEnd -= fullWeightString.length; 506 | adjustedTag = tagPart; 507 | } 508 | } 509 | } 510 | 511 | if (adjustedStart >= adjustedEnd || adjustedTag.length === 0) { 512 | return null; // Tag became empty after all processing 513 | } 514 | 515 | return { start: adjustedStart, end: adjustedEnd, tag: adjustedTag }; 516 | } 517 | 518 | // --- End String Helper Functions --- 519 | 520 | /** 521 | * Load a CSS file dynamically. 522 | * @param {string} href 523 | */ 524 | export function loadCSS(href) { 525 | const link = document.createElement('link'); 526 | link.rel = 'stylesheet'; 527 | link.type = 'text/css'; 528 | link.href = href; 529 | // Ensure the CSS is loaded before other scripts might rely on its styles 530 | // by adding it to the head. 531 | document.head.appendChild(link); 532 | // console.debug(`[Autocomplete-Plus] Loaded CSS: ${href}`); // Optional: Log loading 533 | } 534 | 535 | /** 536 | * Get the viewport margin based on the positions of the top, bottom, left, and right bars. 537 | * @returns {Object} - An object containing the top, bottom, left, and right margins of the viewport. 538 | */ 539 | export function getViewportMargin() { 540 | const topBarRect = document.querySelector("#comfyui-body-top")?.getBoundingClientRect() || { top: 0, bottom: 0 }; 541 | const bottomBarRect = document.querySelector("#comfyui-body-bottom")?.getBoundingClientRect() || { top: 0, bottom: 0 }; 542 | const leftBarRect = document.querySelector("#comfyui-body-left")?.getBoundingClientRect() || { left: 0, right: 0 }; 543 | const rightBarRect = document.querySelector("#comfyui-body-right")?.getBoundingClientRect() || { left: 0, right: 0 }; 544 | 545 | return { 546 | top: topBarRect.height, 547 | bottom: bottomBarRect.height, 548 | left: leftBarRect.width, 549 | right: rightBarRect.width, 550 | }; 551 | } 552 | --------------------------------------------------------------------------------