├── .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 |
--------------------------------------------------------------------------------