├── .gitignore ├── examples ├── Subject │ ├── 1 boy.tag │ └── 1 girl.tag ├── Starting Prompts │ ├── Negative.tag │ └── Positive.tag └── Style │ ├── 2D │ └── Anime.tag │ └── 3D │ └── Digital.tag ├── ui.png ├── editor.png ├── lib_ez ├── __init__.py ├── gradio.py ├── utils.py └── settings.py ├── CHANGELOG.md ├── LICENSE ├── README.md ├── scripts ├── eztags.py └── editor.py ├── style.css └── javascript ├── styler.js └── editor.js /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | cards 3 | -------------------------------------------------------------------------------- /examples/Subject/1 boy.tag: -------------------------------------------------------------------------------- 1 | 1boy, solo 2 | -------------------------------------------------------------------------------- /examples/Subject/1 girl.tag: -------------------------------------------------------------------------------- 1 | 1girl, solo 2 | -------------------------------------------------------------------------------- /examples/Starting Prompts/Negative.tag: -------------------------------------------------------------------------------- 1 | (low quality, worst quality) 2 | -------------------------------------------------------------------------------- /examples/Starting Prompts/Positive.tag: -------------------------------------------------------------------------------- 1 | (high quality, best quality) 2 | -------------------------------------------------------------------------------- /examples/Style/2D/Anime.tag: -------------------------------------------------------------------------------- 1 | absurdres, official_art, key_visual, anime 2 | -------------------------------------------------------------------------------- /ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Haoming02/sd-webui-easy-tag-insert/HEAD/ui.png -------------------------------------------------------------------------------- /editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Haoming02/sd-webui-easy-tag-insert/HEAD/editor.png -------------------------------------------------------------------------------- /examples/Style/3D/Digital.tag: -------------------------------------------------------------------------------- 1 | realistic, detailed, sharp, focus, volumetric lighting, unreal engine 2 | -------------------------------------------------------------------------------- /lib_ez/__init__.py: -------------------------------------------------------------------------------- 1 | from modules.scripts import basedir 2 | import os.path 3 | 4 | EXAMPLE_FOLDER = os.path.join(basedir(), "examples") 5 | CARDS_FOLDER = os.path.join(basedir(), "cards") 6 | -------------------------------------------------------------------------------- /lib_ez/gradio.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | 3 | is_gradio_4: bool = str(gr.__version__).startswith("4") 4 | 5 | 6 | def js(func: str) -> dict: 7 | return {("js" if is_gradio_4 else "_js"): func} 8 | -------------------------------------------------------------------------------- /lib_ez/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | special = re.compile("[^\w\s]") 4 | 5 | 6 | def sanitize(item: str) -> str: 7 | """Convert an arbitrary string into a web-safe text for display""" 8 | return re.sub(special, "", item).replace(" ", "-") 9 | 10 | 11 | def sanitize_int(index: int) -> str: 12 | """Convert a number into string for sorting""" 13 | return f"{index:03}" 14 | -------------------------------------------------------------------------------- /lib_ez/settings.py: -------------------------------------------------------------------------------- 1 | from modules.shared import OptionInfo, opts 2 | 3 | 4 | def on_ez_settings(): 5 | opts.add_option( 6 | "ez_use_category", 7 | OptionInfo( 8 | True, 9 | "Group the Cards based on Categories", 10 | section=("ez", "EZ Tags"), 11 | category_id="ui", 12 | ).needs_reload_ui(), 13 | ) 14 | opts.add_option( 15 | "ez_use_style", 16 | OptionInfo( 17 | False, 18 | "Use custom style", 19 | section=("ez", "EZ Tags"), 20 | category_id="ui", 21 | ) 22 | .info("smaller cards with gradient styles") 23 | .info("note: this will disable preview images and metadata buttons") 24 | .needs_reload_ui(), 25 | ) 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### v3.0.0 - 2025 Mar.07 2 | - Overhaul 2: Electric Boogaloo 3 | 4 |
5 | 6 | #### v2.0.0 - 2024 Oct.09 7 | - Overhaul 8 | 9 | #### v1.6.1 - 2024 Aug.30 10 | - Add Settings **Section** 11 | 12 | #### v1.6.0 - 2024 Jul.04 13 | - Overhaul the entire page 14 | - Support **Preview** images 15 | 16 | #### v1.5.0 - 2024 Jun.10 17 | - Implement **Editor** for tags 18 | 19 | #### v1.4.0 - 2024 May.20 20 | - Load from files directly without YAML 21 | 22 | #### v1.3.0 - 2024 Mar.09 23 | - Rewrite for Webui **v1.8.0** 24 | 25 | #### v1.2.1 - 2023 Oct.24 26 | - Improved **Sorting** 27 | 28 | #### v1.2.0 - 2023 Sep.05 29 | - Rewrite for Webui **v1.6.0** 30 | 31 | #### v1.1.2 - 2023 Jun.03 32 | - **Active** Polling 33 | 34 | #### v1.1.0 - 2023 May.31 35 | - Load the same Category from **Multiple YAML** 36 | 37 | #### v1.0.0 - 2023 May.31 38 | - Extension **Released**! 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Haoming 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 | # SD Webui Easy Tag Insert 2 | This is an Extension for the [Automatic1111 Webui](https://github.com/AUTOMATIC1111/stable-diffusion-webui), which trivializes inserting prompts. 3 | 4 | > Compatible with [Forge](https://github.com/lllyasviel/stable-diffusion-webui-forge) 5 | 6 |

7 |
8 | (with Group the Cards based on Categories enabled) 9 |

10 | 11 | ## How to Use 12 | This Extension creates a new "Extra Networks" tab - **EZ-Tags**. You can customize the cards so that they insert the specified prompts when clicked. Support both **Positive** and **Negative** prompt fields. 13 | 14 | ## Use Cases 15 | You can use this Extension to simply make shortcuts for long prompts: 16 | 17 | ```yaml 18 | Positive: (high quality, best quality) 19 | Negative: (low quality, worst quality) 20 | ``` 21 | 22 | This is useful for LoRA **trigger words**, especially those that contain multiple concepts/characters: 23 | 24 | ```yaml 25 | Chara1: trigger1, 26 | Chara2: trigger2, 27 | Chara3: trigger3, 28 | ``` 29 | 30 | ## How to Edit Cards 31 | The cards are loaded from the `.tag` files inside the `cards` folder. On a fresh install, the Extension will automatically rename the `examples` folder into `cards`. You may add/remove cards by modifying the Table in the **EZ Tags Editor** tab: 32 | 33 | - Press the **Load** button first to load the cards into the Table 34 | - To add a new card, simply write a new entry in the last row 35 | - The Table will automatically expand 36 | - Completely empty rows will be deleted 37 | - To remove a card, press the `❌` button 38 | - You can have `\` character in **Category** for better grouping; these get created as sub-folders 39 | - Do **not** add `\` in **Name** 40 | - Order of the cards does not matter 41 | - Press the **Save** button to save the tags into the folder 42 | - Rows with any column empty will be **ignored** 43 | - You can then live reload the cards by pressing the **Refresh** button in the `Extra Networks`, without having to restart the UI 44 | 45 |

46 | 47 |

48 | 49 | ## Settings 50 | > *found in the `EZ Tags` section under the User Interface category of the **Settings** tab* 51 | 52 | - **Group the Cards based on Categories:** This create a new row per category, to help you find the card more easily 53 | - Recommended to lower the card size if enabled 54 | - **Use custom style:** This overrides the default `Extra Networks` look with smaller and stylized cards, resulting in a more compact selection 55 | - *(The original look before the **v3.0** rewrite)* 56 | -------------------------------------------------------------------------------- /scripts/eztags.py: -------------------------------------------------------------------------------- 1 | from modules.ui_extra_networks import ExtraNetworksPage, quote_js, register_page 2 | from modules.script_callbacks import on_before_ui, on_ui_settings 3 | from modules.shared import opts 4 | from glob import glob 5 | import os.path 6 | 7 | from lib_ez import EXAMPLE_FOLDER, CARDS_FOLDER 8 | from lib_ez.utils import sanitize, sanitize_int 9 | from lib_ez.settings import on_ez_settings 10 | 11 | 12 | class EasyTags(ExtraNetworksPage): 13 | cards_db: dict[str, str] = {} 14 | 15 | def __init__(self): 16 | super().__init__("EZ-Tags") 17 | self.allow_negative_prompt = True 18 | 19 | if not os.path.exists(CARDS_FOLDER): 20 | from shutil import copytree 21 | 22 | print('\n[EZ Tags] "cards" folder not found. Initializing...\n') 23 | copytree(EXAMPLE_FOLDER, CARDS_FOLDER) 24 | 25 | self.refresh() 26 | 27 | def refresh(self): 28 | EasyTags.cards_db.clear() 29 | self.metadata.clear() 30 | 31 | objs = glob(os.path.join(CARDS_FOLDER, "**", "*.tag"), recursive=True) 32 | 33 | for path in objs: 34 | name, _ = os.path.splitext(os.path.basename(path)) 35 | if name in EasyTags.cards_db: 36 | print(f'\n[EZ Tags] Duplicated filename "{name}" was found!\n') 37 | continue 38 | 39 | EasyTags.cards_db.update({name: path}) 40 | 41 | def create_item(self, name: str, index: int = -1, *arg, **kwarg): 42 | filename = EasyTags.cards_db[name] 43 | with open(filename, "r", encoding="utf-8") as card: 44 | prompt = card.readline().strip() 45 | 46 | path = os.path.splitext(filename)[0] 47 | relative_path = os.path.relpath(path, CARDS_FOLDER) 48 | name = os.path.basename(relative_path) 49 | category = relative_path[: -len(name)] 50 | 51 | return { 52 | "name": name.strip(), 53 | "filename": filename, 54 | "shorthash": f"{hash(name)}", 55 | "preview": self.find_preview(path), 56 | "description": self.find_description(path), 57 | "search_terms": [self.search_terms_from_path(filename)], 58 | "prompt": quote_js(prompt), 59 | "local_preview": f"{path}.preview.{opts.samples_format}", 60 | "metadata": prompt, 61 | "sort_keys": { 62 | "default": sanitize(f"{category}-{name}"), 63 | "date_created": index, 64 | "date_modified": sanitize(f"{category}-{index}"), 65 | "name": sanitize(name), 66 | }, 67 | } 68 | 69 | def list_items(self): 70 | for i, name in enumerate(EasyTags.cards_db.keys()): 71 | yield self.create_item(name, sanitize_int(i + 1)) 72 | 73 | def allowed_directories_for_previews(self): 74 | return [CARDS_FOLDER] 75 | 76 | 77 | on_ui_settings(on_ez_settings) 78 | on_before_ui(lambda: register_page(EasyTags())) 79 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | #ez-editor table { 2 | width: 80vw; 3 | margin: auto; 4 | table-layout: fixed; 5 | } 6 | 7 | #ez-editor table :is(th, td) { 8 | border: 2px solid var(--table-border-color) !important; 9 | text-overflow: clip; 10 | white-space: nowrap; 11 | overflow: hidden; 12 | } 13 | 14 | #ez-editor thead th { 15 | text-align: center; 16 | padding: 1em; 17 | } 18 | 19 | #ez-editor thead tr { 20 | background-color: var(--block-background-fill); 21 | } 22 | 23 | #ez-editor tbody td { 24 | padding: 1em; 25 | } 26 | 27 | #ez-editor tbody tr:nth-child(odd) { 28 | background-color: var(--table-odd-background-fill); 29 | } 30 | 31 | #ez-editor tbody tr:nth-child(even) { 32 | background-color: var(--table-even-background-fill); 33 | } 34 | 35 | .ez-tag-container div.style-bracket { 36 | background: var(--input-background-fill); 37 | border-radius: 0.5em; 38 | padding: 0.25em; 39 | margin: 0.5em; 40 | position: relative; 41 | overflow: hidden; 42 | display: block; 43 | } 44 | 45 | @supports(selector(:has(div))) { 46 | .ez-tag-container div.style-bracket { 47 | display: none; 48 | } 49 | 50 | .ez-tag-container div.style-bracket:has(> div > :not(.hidden)) { 51 | display: block; 52 | } 53 | } 54 | 55 | .ez-tag-container div.style-bracket>span { 56 | position: absolute; 57 | top: 50%; 58 | left: 50%; 59 | transform: translate(-50%, -50%); 60 | font-size: 2em; 61 | color: var(--block-title-text-color); 62 | pointer-events: none; 63 | user-select: none; 64 | white-space: nowrap; 65 | z-index: 10; 66 | opacity: 0.25; 67 | } 68 | 69 | .ez-tag-container div.style-bracket:hover>span { 70 | z-index: unset; 71 | opacity: 0.1; 72 | } 73 | 74 | .ez-tag-container.ez-style .extra-network-cards { 75 | padding: 0.5em; 76 | user-select: none; 77 | } 78 | 79 | .ez-tag-container.ez-style .card { 80 | background-image: linear-gradient(90deg, var(--button-secondary-background-fill), var(--button-primary-background-fill)); 81 | background-size: 105%; 82 | height: 3em; 83 | width: calc(10% - 2em); 84 | min-width: 6em; 85 | margin: 0.5em; 86 | } 87 | 88 | .ez-tag-container.ez-style .card .actions { 89 | background: none; 90 | box-shadow: none; 91 | position: inherit; 92 | width: 100%; 93 | height: 100%; 94 | } 95 | 96 | .ez-tag-container.ez-style .card .actions .name { 97 | display: inline-flex; 98 | position: relative; 99 | align-items: center; 100 | width: 90%; 101 | height: 100%; 102 | left: 1em; 103 | overflow: hidden; 104 | white-space: nowrap; 105 | } 106 | 107 | .ez-tag-container.ez-style .card .actions .additional { 108 | display: none; 109 | } 110 | 111 | .ez-tag-container.ez-style .card .actions .description { 112 | display: none; 113 | } 114 | 115 | .ez-tag-container.ez-style .card .button-row { 116 | display: none 117 | } 118 | -------------------------------------------------------------------------------- /javascript/styler.js: -------------------------------------------------------------------------------- 1 | class EasyTagStyler { 2 | 3 | static #refresh = { "txt": null, "img": null }; 4 | 5 | static init() { 6 | for (const mode of ["txt", "img"]) { 7 | const observer = new MutationObserver((mutationsList) => { 8 | for (const mutation of mutationsList) { 9 | if (mutation.type === "childList") { 10 | const timer = this.#refresh[mode]; 11 | if (timer) clearTimeout(timer); 12 | 13 | this.#refresh[mode] = setTimeout(() => { this.#addStyle(mode); }, 250); 14 | return; 15 | } 16 | } 17 | }); 18 | 19 | const page = document.getElementById(`${mode}2img_ez-tags_cards_html`); 20 | observer.observe(page, { childList: true, subtree: true }); 21 | } 22 | } 23 | 24 | /** @param {string} mode "txt" | "img" */ 25 | static #addStyle(mode) { 26 | const page = document.getElementById(`${mode}2img_ez-tags_cards_html`); 27 | if (page.querySelector(".pending") != null) 28 | setTimeout(() => this.#addStyle(mode), 100); 29 | if (page.querySelector(".style-bracket") != null) 30 | return; 31 | 32 | const cardList = document.getElementById(`${mode}2img_ez-tags_cards`); 33 | const cards = cardList.querySelectorAll("div.card"); 34 | if (cards.length === 0) 35 | return; 36 | 37 | const categories = {}; 38 | for (const card of cards) { 39 | const data = card.getAttribute("data-sort-date_modified"); 40 | const key = data.substring(0, data.length - 3); 41 | 42 | if (categories.hasOwnProperty(key)) 43 | categories[key].push(card); 44 | else 45 | categories[key] = [card]; 46 | } 47 | 48 | for (const [key, value] of Object.entries(categories)) { 49 | const category = document.createElement("div"); 50 | category.classList.add("style-bracket"); 51 | category.setAttribute("category", key); 52 | 53 | const label = document.createElement("span"); 54 | label.textContent = key.replaceAll("-", " "); 55 | label.setAttribute("align", "center"); 56 | const container = document.createElement("div"); 57 | value.forEach((card) => { container.appendChild(card); }); 58 | 59 | category.appendChild(label); 60 | category.appendChild(container); 61 | cardList.appendChild(category); 62 | } 63 | } 64 | 65 | } 66 | 67 | onUiLoaded(() => { 68 | const config_category = document.getElementById("setting_ez_use_category").querySelector("input[type=checkbox]"); 69 | const config_style = document.getElementById("setting_ez_use_style").querySelector("input[type=checkbox]"); 70 | 71 | for (const mode of ['txt', 'img']) { 72 | const container = document.getElementById(`${mode}2img_ez-tags_cards_html`); 73 | container.classList.add("ez-tag-container"); 74 | if (config_style.checked) 75 | container.classList.add("ez-style"); 76 | } 77 | 78 | if (config_category.checked) 79 | EasyTagStyler.init(); 80 | }); 81 | -------------------------------------------------------------------------------- /scripts/editor.py: -------------------------------------------------------------------------------- 1 | from modules.script_callbacks import on_ui_tabs 2 | from json import loads, dumps 3 | from glob import glob 4 | import gradio as gr 5 | import os 6 | 7 | from lib_ez import CARDS_FOLDER 8 | from lib_ez.gradio import js 9 | 10 | EZ_CACHE: dict[str, dict[str, str]] = {} 11 | 12 | 13 | def delete_empty_folders(path: str): 14 | for par, folders, _ in os.walk(path, topdown=False): 15 | for folder in [os.path.join(par, f) for f in folders]: 16 | if not os.listdir(folder): 17 | os.rmdir(folder) 18 | 19 | 20 | def load() -> str: 21 | EZ_CACHE.clear() 22 | cards: list[str] = glob(os.path.join(CARDS_FOLDER, "**", "*.tag"), recursive=True) 23 | 24 | if len(cards) == 0: 25 | gr.Warning(f'No valid ".tag" file found in "{CARDS_FOLDER}"...') 26 | return "" 27 | 28 | for card in cards: 29 | with open(card, "r", encoding="utf-8") as file: 30 | prompt = file.readline().strip() 31 | 32 | path, _ = os.path.splitext(card) 33 | relative_path = os.path.relpath(path, CARDS_FOLDER) 34 | category, name = relative_path.rsplit(os.sep, 1) 35 | 36 | if category not in EZ_CACHE: 37 | EZ_CACHE.update({category: {name: prompt}}) 38 | else: 39 | EZ_CACHE[category].update({name: prompt}) 40 | 41 | return dumps(EZ_CACHE) 42 | 43 | 44 | def save(json_str: str): 45 | data: dict[str, dict[str, str]] = loads(json_str) 46 | changes: int = 0 47 | 48 | for category, cards in data.items(): 49 | for name, prompt in cards.items(): 50 | try: 51 | if EZ_CACHE[category][name] == prompt: 52 | EZ_CACHE[category].pop(name) 53 | continue 54 | except KeyError: 55 | pass 56 | 57 | os.makedirs(os.path.join(CARDS_FOLDER, category), exist_ok=True) 58 | with open( 59 | os.path.join(CARDS_FOLDER, category, f"{name}.tag"), 60 | encoding="utf-8", 61 | mode="w+", 62 | ) as card: 63 | card.write(f"{prompt}\n") 64 | changes += 1 65 | 66 | for category, cards in EZ_CACHE.items(): 67 | for name, prompt in cards.items(): 68 | if data.get(category, {}).get(name, False): 69 | continue 70 | 71 | os.remove(os.path.join(CARDS_FOLDER, category, f"{name}.tag")) 72 | changes += 1 73 | 74 | EZ_CACHE.clear() 75 | for k, v in data.items(): 76 | EZ_CACHE.update({k: v}) 77 | 78 | gr.Info(f"Cards Saved ({changes}x Change{'s' if changes > 1 else ''} Made)") 79 | delete_empty_folders(CARDS_FOLDER) 80 | 81 | 82 | def editor_ui(): 83 | with gr.Blocks() as TAGS_EDITOR: 84 | with gr.Row(): 85 | save_btn = gr.Button("Save", variant="primary", interactive=False) 86 | load_btn = gr.Button("Load") 87 | 88 | gr.HTML('
') 89 | 90 | with gr.Row(visible=False): 91 | tags = gr.Textbox(elem_id="ez-editor-box") 92 | real_save_btn = gr.Button("Save", elem_id="ez-editor-btn") 93 | 94 | save_btn.click(fn=None, **js("() => { EasyTagEditor.save(); }")) 95 | real_save_btn.click(fn=save, inputs=[tags]) 96 | load_btn.click(fn=load, outputs=[tags]).success( 97 | fn=lambda: gr.update(interactive=True), 98 | outputs=[save_btn], 99 | **js("() => { EasyTagEditor.load(); }"), 100 | ) 101 | 102 | return [(TAGS_EDITOR, "EZ Tags Editor", "sd-webui-ez-tags-editor")] 103 | 104 | 105 | on_ui_tabs(editor_ui) 106 | -------------------------------------------------------------------------------- /javascript/editor.js: -------------------------------------------------------------------------------- 1 | class EasyTagEditor { 2 | 3 | /** @type {HTMLTableElement} */ 4 | static #table; 5 | /** @type {HTMLTextAreaElement} */ 6 | static #field; 7 | /** @type {HTMLButtonElement} */ 8 | static #button; 9 | 10 | /** @param {HTMLDivElement} frame */ 11 | static #constructTable(frame) { 12 | const table = document.createElement("table"); 13 | const thead = document.createElement('thead'); 14 | 15 | const columnWidths = ["15%", "15%", "65%", "5%"]; 16 | const colgroup = document.createElement('colgroup'); 17 | for (const width of columnWidths) { 18 | const col = document.createElement('col'); 19 | col.style.width = width; 20 | colgroup.appendChild(col); 21 | } 22 | table.appendChild(colgroup); 23 | 24 | const headers = ["Category", "Name", "Prompt", "Del"]; 25 | const thr = thead.insertRow(); 26 | for (const header of headers) { 27 | const th = document.createElement('th'); 28 | th.textContent = header; 29 | thr.appendChild(th); 30 | } 31 | table.appendChild(thead); 32 | 33 | const tbody = document.createElement('tbody'); 34 | table.appendChild(tbody); 35 | this.#table = tbody; 36 | 37 | frame.appendChild(table); 38 | } 39 | 40 | static init() { 41 | this.#field = document.getElementById("ez-editor-box").querySelector("textarea"); 42 | this.#button = document.getElementById("ez-editor-btn"); 43 | this.#constructTable(document.getElementById("ez-editor")); 44 | 45 | this.#table.addEventListener("keyup", () => { this.#onEdit(); }); 46 | this.#table.addEventListener("paste", (e) => { 47 | e.preventDefault(); 48 | const text = e.clipboardData.getData('text/plain'); 49 | const selection = window.getSelection(); 50 | 51 | if (selection.rangeCount > 0) { 52 | const range = selection.getRangeAt(0); 53 | range.deleteContents(); 54 | const node = document.createTextNode(text); 55 | range.insertNode(node); 56 | 57 | range.setStartAfter(node); 58 | range.collapse(true); 59 | selection.removeAllRanges(); 60 | selection.addRange(range); 61 | } 62 | }); 63 | } 64 | 65 | static save() { 66 | const data = {}; 67 | const rows = this.#table.querySelectorAll("tr"); 68 | 69 | for (const row of rows) { 70 | const cells = row.querySelectorAll("td"); 71 | const category = cells[0].textContent.trim(); 72 | const name = cells[1].textContent.trim(); 73 | const prompt = cells[2].textContent.trim(); 74 | 75 | if ((!category) || (!name) || (!prompt)) 76 | continue; 77 | 78 | if (!data.hasOwnProperty(category)) 79 | data[category] = {}; 80 | 81 | data[category][name] = prompt; 82 | } 83 | 84 | this.#field.value = JSON.stringify(data); 85 | updateInput(this.#field); 86 | this.#button.click(); 87 | } 88 | 89 | static load() { 90 | while (this.#table.firstChild) 91 | this.#table.removeChild(this.#table.firstChild); 92 | 93 | const val = this.#field.value; 94 | if (Boolean(val.trim())) { 95 | const data = JSON.parse(val); 96 | for (const [category, cards] of Object.entries(data)) { 97 | for (const [name, prompt] of Object.entries(cards)) 98 | this.#addRow([category, name, prompt]) 99 | } 100 | } 101 | 102 | this.#addRow(["", "", ""]); 103 | } 104 | 105 | /** @param {string[]} content [category, name, prompt] */ 106 | static #addRow(content) { 107 | const tr = this.#table.insertRow(); 108 | 109 | for (const txt of content) { 110 | const td = tr.insertCell(); 111 | td.contentEditable = true; 112 | td.textContent = txt; 113 | } 114 | 115 | const td = tr.insertCell(); 116 | td.style.textAlign = "center"; 117 | 118 | const del = document.createElement("button"); 119 | del.title = "Delete this Card"; 120 | del.style.margin = "auto"; 121 | del.textContent = "❌"; 122 | td.appendChild(del); 123 | 124 | del.onclick = () => { tr.remove(); this.#onEdit(); } 125 | } 126 | 127 | static #onEdit() { 128 | const rows = this.#table.querySelectorAll("tr"); 129 | const count = rows.length; 130 | 131 | for (let i = count - 1; i >= 0; i--) 132 | if (this.#isEmpty(rows[i])) 133 | rows[i].remove(); 134 | 135 | this.#addRow(["", "", ""]); 136 | } 137 | 138 | /** @param {HTMLTableRowElement} row @returns {boolean} */ 139 | static #isEmpty(row) { 140 | const cells = row.querySelectorAll("td"); 141 | return ( 142 | (!cells[0].textContent.trim()) && 143 | (!cells[1].textContent.trim()) && 144 | (!cells[2].textContent.trim()) 145 | ) 146 | } 147 | 148 | } 149 | 150 | onUiLoaded(() => { EasyTagEditor.init(); }); 151 | --------------------------------------------------------------------------------