From a8fa7c6c158cda4d4de7bc4659f1c7151f33aef5 Mon Sep 17 00:00:00 2001 From: Christian Bastian Date: Fri, 5 Jan 2024 07:35:24 -0500 Subject: [PATCH] Added settings & config yaml. --- .gitignore | 1 + README.md | 25 +++- __init__.py | 64 +++++++- config_loader.py | 65 ++++++++ web/model-manager.css | 48 ++++-- web/model-manager.js | 340 ++++++++++++++++++++++++++++++++++-------- 6 files changed, 452 insertions(+), 91 deletions(-) create mode 100644 config_loader.py diff --git a/.gitignore b/.gitignore index 68bc17f..c91d84d 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +ui_settings.yaml diff --git a/README.md b/README.md index c15926e..63e958a 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,27 @@ Currently it is still missing some features it should have. - Include models listed in ComfyUI's `extra_model_paths.yaml`. - Button to copy a model to the ComfyUI clipboard or embedding to system clipboard. (Embedding copying requires secure http connection.) - Button to add model to ComfyUI graph or embedding to selected nodes. (For small screens/low resolution.) -- Right, left and bottom toggleable sidebar modes. +- Right, left, top and bottom toggleable sidebar modes. - Drag a model onto the graph to add a new node. - Drag a model onto an existing node to set the model field. - Drag an embedding onto a text area to add it to the end. - Increased supported preview image types. - Correctly change colors using ComfyUI's theme colors. - Simplified UI. +- Settings tab and config file. + - Hide/Show 'add' and 'copy-to-clipboard' buttons. + - Text to always search. + - Show/Hide add embedding extension. ## TODO: +### Code + +- ☐ Javascript cleanup. + - ☐ Seperate into classes per tab? + - ☐ HTML generation all inside main class? + - ☐ More server driven, HTMX-like HTML generation? (Avoid x2 states) + ### Model Copying - ☐ Copy image? @@ -41,15 +52,9 @@ Currently it is still missing some features it should have. ### Settings -- ☐ Add `settings.yaml` and add file to `.gitignore`. (Generate if not there.) - ☐ Exclude hidden folders with a `.` prefix. -- ☐ Include a optional string to always add to searches. - ☐ Enable optional checksum to detect if a model is already downloaded. -- ☐ Enable/Disable add and copy buttons. -- ☐ Allow user to drag width of sidebar or height of bottom bar and remember it. -- ☐ Hide/Show model extension. -- ☐ Optionally remove embedding extension. -- ☐ Strict model drag on node widget textbox. +- ☐ Sidebar width. ### Search filtering and sort @@ -76,6 +81,10 @@ Currently it is still missing some features it should have. - ☐ Proper naming and labeling. - ☐ Tool tips? +### Sidebar + +- ☐ Drag sidebar width/height dynamically. + ### Directory Browser and Downloading tab (NOTE: It is a impossible to put a model automatically in the correct folder if model type information is not given or ambigious. To fully solve this requires making a file browser where files can be moved around.) diff --git a/__init__.py b/__init__.py index 35bcd6a..253dd6b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,21 +1,29 @@ import os import sys import hashlib +import importlib + from aiohttp import web import server import urllib.parse import struct import json import requests +requests.packages.urllib3.disable_warnings() + import folder_paths -requests.packages.urllib3.disable_warnings() +config_loader_path = os.path.join(os.path.dirname(__file__), 'config_loader.py') +config_loader_spec = importlib.util.spec_from_file_location('config_loader', config_loader_path) +config_loader = importlib.util.module_from_spec(config_loader_spec) +config_loader_spec.loader.exec_module(config_loader) comfyui_model_uri = os.path.join(os.getcwd(), "models") extension_uri = os.path.join(os.getcwd(), "custom_nodes" + os.path.sep + "ComfyUI-Model-Manager") index_uri = os.path.join(extension_uri, "index.json") #checksum_cache_uri = os.path.join(extension_uri, "checksum_cache.txt") no_preview_image = os.path.join(extension_uri, "no-preview.png") +ui_settings_uri = os.path.join(extension_uri, "ui_settings.yaml") image_extensions = (".apng", ".gif", ".jpeg", ".jpg", ".png", ".webp") #video_extensions = (".avi", ".mp4", ".webm") # TODO: Requires ffmpeg or cv2. Cache preview frame? @@ -38,8 +46,7 @@ def folder_paths_get_supported_pt_extensions(folder_name): # Missing API functio paths = folder_paths.folder_names_and_paths if folder_name in paths: return paths[folder_name][1] - - return set(['.ckpt', '.pt', '.bin', '.pth', '.safetensors']) + return set([".ckpt", ".pt", ".bin", ".pth", ".safetensors"]) def get_safetensor_header(path): @@ -76,6 +83,49 @@ def model_type_to_dir_name(model_type): else: return model_type +def ui_rules(): + Rule = config_loader.Rule + return [ + Rule("sidebar-default-height", 0.5, float, 0.0, 1.0), + Rule("sidebar-default-width", 0.5, float, 0.0, 1.0), + Rule("model-search-always-append", "", str), + Rule("model-show-label-extensions", False, bool), + Rule("model-show-add-button", True, bool), + Rule("model-show-copy-button", True, bool), + Rule("model-add-embedding-extension", False, bool), + Rule("model-add-drag-strict-on-field", False, bool), + Rule("model-add-offset", 25, int), + ] + + +#def server_rules(): +# Rule = config_loader.Rule +# return [ +# Rule("model_extension_download_whitelist", [".safetensors"], list), +# Rule("civitai_api_key", "", str), +# ] + + +@server.PromptServer.instance.routes.get("/model-manager/settings/load") +async def load_ui_settings(request): + rules = ui_rules() + settings = config_loader.yaml_load(ui_settings_uri, rules) + return web.json_response({ "settings": settings }) + + +@server.PromptServer.instance.routes.post("/model-manager/settings/save") +async def save_ui_settings(request): + body = await request.json() + settings = body.get("settings") + rules = ui_rules() + validated_settings = config_loader.validated(rules, settings) + success = config_loader.yaml_save(ui_settings_uri, rules, validated_settings) + return web.json_response({ + "success": success, + "settings": validated_settings if success else "", + }) + + @server.PromptServer.instance.routes.get("/model-manager/image-preview") async def img_preview(request): uri = request.query.get("uri") @@ -83,7 +133,7 @@ async def img_preview(request): image_path = no_preview_image image_extension = "png" - if (uri != "no-post"): + if uri != "no-post": rel_image_path = os.path.dirname(uri) i = uri.find(os.path.sep) @@ -225,9 +275,9 @@ async def load_download_models(request): model_items = [] for model, image, base_path_index, rel_path in file_names: - name, _ = os.path.splitext(model) + # TODO: Stop sending redundant information item = { - "name": name, + "name": model, "search-path": os.path.join(model_type, rel_path, model).replace(os.path.sep, "/"), # TODO: Remove hack "path": os.path.join(rel_path, model), } @@ -269,7 +319,7 @@ def download_model_file(url, filename): with open(dl_filename, "ab") as f: for chunk in r.iter_content(chunk_size=1024): - if chunk: + if chunk is not None: downloaded_size += len(chunk) f.write(chunk) f.flush() diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..1e10ff5 --- /dev/null +++ b/config_loader.py @@ -0,0 +1,65 @@ +import yaml +from dataclasses import dataclass + +@dataclass +class Rule: + key: any + value_default: any + value_type: type + value_min: int | float | None + value_max: int | float | None + + def __init__(self, key, value_default, value_type: type, value_min: int | float | None = None, value_max: int | float | None = None): + self.key = key + self.value_default = value_default + self.value_type = value_type + self.value_min = value_min + self.value_max = value_max + +def _get_valid_value(data: dict, r: Rule): + if r.value_type != type(r.value_default): + raise Exception(f"'value_type' does not match type of 'value_default'!") + value = data.get(r.key) + if value is None: + value = r.value_default + else: + try: + value = r.value_type(value) + except: + value = r.value_default + + value_is_numeric = r.value_type == int or r.value_type == float + if value_is_numeric and r.value_min: + if r.value_type != type(r.value_min): + raise Exception(f"Type of 'value_type' does not match the type of 'value_min'!") + value = max(r.value_min, value) + if value_is_numeric and r.value_max: + if r.value_type != type(r.value_max): + raise Exception(f"Type of 'value_type' does not match the type of 'value_max'!") + value = min(r.value_max, value) + + return value + +def validated(rules: list[Rule], data: dict = {}): + valid = {} + for r in rules: + valid[r.key] = _get_valid_value(data, r) + return valid + +def yaml_load(path, rules: list[Rule]): + data = {} + try: + with open(path, 'r') as file: + data = yaml.safe_load(file) + except: + pass + return validated(rules, data) + +def yaml_save(path, rules: list[Rule], data: dict) -> bool: + data = validated(rules, data) + try: + with open(path, 'w') as file: + yaml.dump(data, file) + return True + except: + return False diff --git a/web/model-manager.css b/web/model-manager.css index 5d6c7bd..fd9fc87 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -123,16 +123,6 @@ opacity: 1; } -.comfy-grid .model-button.model-button-success { - color: green; - border-color: green; -} - -.comfy-grid .model-button.model-button-failure { - color: darkred; - border-color: darkred; -} - .comfy-grid .model-label { user-select: text; } @@ -347,3 +337,41 @@ .model-manager .model-type-dropdown { flex: 1; } + +.model-manager .button-success { + color: green; + border-color: green; +} + +.model-manager .button-failure { + color: darkred; + border-color: darkred; +} + +/* model manager settings */ +.model-manager .model-manager-settings > div { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.model-manager .model-manager-settings button { + height: 40px; + width: 120px; +} + +.model-manager .model-manager-settings input[type="number"] { + width: 50px; +} + +.search-settings-text { + width: 100%; +} + +.model-manager .model-manager-settings textarea { + width: 100%; + font-size: 1.2em; + border: solid 2px var(--border-color); + border-radius: 8px; +} diff --git a/web/model-manager.js b/web/model-manager.js index acc965c..190a05d 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -48,13 +48,36 @@ function pathToFileString(path) { return path.slice(i); } -function insertEmbeddingIntoText(currentText, embeddingFile, extensionRegex = null) { - if (extensionRegex) { - // TODO: setting.remove_extension_embedding +function removeModelExtension(file) { + // This is a bit sloppy (can assume server sends without) + const i = file.lastIndexOf("."); + if (i != -1) { + return file.substring(0, i); } - // TODO: don't add if it is already in the text? - const sep = currentText.length === 0 || currentText.slice(-1).match(/\s/) ? "" : " "; - return currentText + sep + "(embedding:" + embeddingFile + ":1.0)"; +} + +function insertEmbeddingIntoText(text, file, removeExtension) { + let name = file; + if (removeExtension) { + name = removeModelExtension(name) + } + const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " "; + return text + sep + "(embedding:" + name + ":1.0)"; +} + +function buttonAlert(element, success, successText = "", failureText = "", resetText = "") { + const name = success ? "button-success" : "button-failure"; + element.classList.add(name); + if (successText != "" && failureText != "") { + element.innerHTML = success ? successText : failureText; + } + // TODO: debounce would be nice to get working... + window.setTimeout((element, name, innerHTML) => { + element.classList.remove(name); + if (innerHTML != "") { + element.innerHTML = innerHTML; + } + }, 500, element, name, resetText); } class Tabs { @@ -281,20 +304,8 @@ class ModelGrid { }); } - static #buttonAlert(event, successful, innerHTML) { - const element = event.target; - const name = successful ? "model-button-success" : "model-button-failure"; - element.classList.add(name); - element.innerHTML = successful ? "✔" : "✖"; - // TODO: debounce would be nice to get working... - window.setTimeout((element, name) => { - element.classList.remove(name); - element.innerHTML = innerHTML; - }, 500, element, name); - } - - static #addModel(event, modelType, path) { - let successful = false; + static #addModel(event, modelType, path, removeEmbeddingExtension, addOffset) { + let success = false; if (modelType !== "embeddings") { const nodeType = modelNodeType(modelType); const widgetIndex = modelWidgetIndex(nodeType); @@ -305,9 +316,8 @@ class ModelGrid { let isSelectedNode = false; for (var i in selectedNodes) { const selectedNode = selectedNodes[i]; - // TODO: settings.model_add_offset - node.pos[0] = selectedNode.pos[0] + 25; - node.pos[1] = selectedNode.pos[1] + 25; + node.pos[0] = selectedNode.pos[0] + addOffset; + node.pos[1] = selectedNode.pos[1] + addOffset; isSelectedNode = true; break; } @@ -318,7 +328,7 @@ class ModelGrid { } app.graph.add(node, {doProcessChange: true}); app.canvas.selectNode(node); - successful = true; + success = true; } event.stopPropagation(); } @@ -331,27 +341,33 @@ class ModelGrid { const widgetIndex = modelWidgetIndex(nodeType); const target = selectedNode.widgets[widgetIndex].element; if (target && target.type === "textarea") { - target.value = insertEmbeddingIntoText(target.value, embeddingFile); - successful = true; + target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); + success = true; } } - if (!successful) { + if (!success) { console.warn("Try selecting a node before adding the embedding."); } event.stopPropagation(); } - this.#buttonAlert(event, successful, "✚"); + buttonAlert(event.target, success, "✔", "✖", "✚"); } - static #dragAddModel(event, modelType, path) { + static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictDragToAdd) { const target = document.elementFromPoint(event.x, event.y); if (modelType !== "embeddings" && target.id === "graph-canvas") { const nodeType = modelNodeType(modelType); const widgetIndex = modelWidgetIndex(nodeType); const pos = app.canvas.convertEventToCanvasOffset(event); const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); - //if (nodeAtPos && nodeAtPos.type === nodeType && app.canvas.processNodeWidgets(nodeAtPos, pos, event) !== nodeAtPos.widgets[widgetIndex]) { // TODO: settings.strict_model_drag - if (nodeAtPos && nodeAtPos.type === nodeType) { + + let draggedOnNode = nodeAtPos && nodeAtPos.type === nodeType; + if (strictDragToAdd) { + const draggedOnWidget = app.canvas.processNodeWidgets(nodeAtPos, pos, event) === nodeAtPos.widgets[widgetIndex]; + draggedOnNode = draggedOnNode && draggedOnWidget; + } + + if (draggedOnNode) { let node = nodeAtPos; node.widgets[widgetIndex].value = path; app.canvas.selectNode(node); @@ -374,20 +390,21 @@ class ModelGrid { if (nodeAtPos) { app.canvas.selectNode(nodeAtPos); const embeddingFile = pathToFileString(path); - target.value = insertEmbeddingIntoText(target.value, embeddingFile); + target.value = insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); event.stopPropagation(); } } } - static #copyModelToClipboard(event, modelType, path) { + static #copyModelToClipboard(event, modelType, path, removeEmbeddingExtension) { const nodeType = modelNodeType(modelType); - let successful = false; + let success = false; if (nodeType === "Embedding") { if (navigator.clipboard){ - const embeddingText = pathToFileString(path); + const embeddingFile = pathToFileString(path); + const embeddingText = insertEmbeddingIntoText("", embeddingFile, removeEmbeddingExtension); navigator.clipboard.writeText(embeddingText); - successful = true; + success = true; } else { console.warn("Cannot copy the embedding to the system clipboard; Try dragging it instead."); @@ -398,24 +415,47 @@ class ModelGrid { const widgetIndex = modelWidgetIndex(nodeType); node.widgets[widgetIndex].value = path; app.canvas.copyToClipboard([node]); - successful = true; + success = true; } else { console.warn(`Unable to copy unknown model type '${modelType}.`); } - this.#buttonAlert(event, successful, "⧉︎"); + buttonAlert(event.target, success, "✔", "✖", "⧉︎"); } - static generateInnerHtml(models, modelType) { + static generateInnerHtml(models, modelType, settingsElements) { + const showAddButton = settingsElements["model-show-add-button"].checked; + const showCopyButton = settingsElements["model-show-copy-button"].checked; + const strictDragToAdd = settingsElements["model-add-drag-strict-on-field"].checked; + const addOffset = parseInt(settingsElements["model-add-offset"].value); + const showModelExtension = settingsElements["model-show-label-extensions"].checked; + const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked; if (models.length > 0) { - // TODO: settings.show_model_add_button - // TODO: settings.show_model_copy_button return models.map((item) => { const uri = item.post ?? "no-post"; const imgUrl = `/model-manager/image-preview?uri=${uri}`; - const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path); - const clickCopy = (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path); - const clickAdd = (e) => ModelGrid.#addModel(e, modelType, item.path); + let buttons = []; + if (showAddButton) { + buttons.push( + $el("button.icon-button.model-button", { + type: "button", + textContent: "⧉︎", + onclick: (e) => ModelGrid.#copyModelToClipboard(e, modelType, item.path, removeEmbeddingExtension), + draggable: false, + }) + ); + } + if (showCopyButton) { + buttons.push( + $el("button.icon-button.model-button", { + type: "button", + textContent: "✚", + onclick: (e) => ModelGrid.#addModel(e, modelType, item.path, removeEmbeddingExtension, addOffset), + draggable: false, + }) + ); + } + const dragAdd = (e) => ModelGrid.#dragAddModel(e, modelType, item.path, removeEmbeddingExtension, strictDragToAdd); return $el("div.item", {}, [ $el("img.model-preview", { src: imgUrl, @@ -429,25 +469,13 @@ class ModelGrid { $el("div.model-preview-top-right", { draggable: false, }, - [ - $el("button.icon-button.model-button", { - type: "button", - textContent: "⧉︎", - onclick: (e) => clickCopy(e), - draggable: false, - }), - $el("button.icon-button.model-button", { - type: "button", - textContent: "✚", - onclick: (e) => clickAdd(e), - draggable: false, - }), - ]), + buttons + ), $el("div.model-label", { ondragend: (e) => dragAdd(e), draggable: true, }, [ - $el("p", [item.name]) + $el("p", [showModelExtension ? item.name : removeModelExtension(item.name)]) ]), ]); }); @@ -501,10 +529,27 @@ class ModelManager extends ComfyDialog { sourceInstalledFilter: null, sourceContentFilter: null, sourceFilterBtn: null, + modelGrid: null, modelTypeSelect: null, modelContentFilter: null, + sidebarButtons: null, + + settingsTab: null, + reloadSettingsBtn: null, + saveSettingsBtn: null, + settings: { + "sidebar-default-height": null, + "sidebar-default-width": null, + "model-search-always-append": null, + "model-show-label-extensions": null, + "model-show-add-button": null, + "model-show-copy-button": null, + "model-add-embedding-extension": null, + "model-add-drag-strict-on-field": null, + "model-add-offset": null, + } }; #data = { @@ -557,7 +602,7 @@ class ModelManager extends ComfyDialog { $tabs([ $tab("Install", this.#createSourceInstall()), $tab("Models", this.#createModelTabHtml()), - $tab("Settings", []), + $tab("Settings", this.#createSettingsTabHtml()), ]), ]), ] @@ -567,6 +612,7 @@ class ModelManager extends ComfyDialog { } #init() { + this.#reloadSettings(false); this.#refreshSourceList(); this.#modelGridRefresh(); } @@ -748,15 +794,15 @@ class ModelManager extends ComfyDialog { } #modelGridUpdate() { - const searchText = this.#el.modelContentFilter.value; - // TODO: settings.always_append_to_search + const searchAppend = this.#el.settings["model-search-always-append"].value; + const searchText = this.#el.modelContentFilter.value + " " + searchAppend; const modelType = this.#el.modelTypeSelect.value; const models = this.#data.models; const modelList = ModelGrid.filter(models[modelType], searchText); const modelGrid = this.#el.modelGrid; modelGrid.innerHTML = []; - const innerHTML = ModelGrid.generateInnerHtml(modelList, modelType); + const innerHTML = ModelGrid.generateInnerHtml(modelList, modelType, this.#el.settings); modelGrid.append.apply(modelGrid, innerHTML); }; @@ -766,9 +812,8 @@ class ModelManager extends ComfyDialog { }; #setSidebar(event) { - // TODO: use checkboxes with 0 or 1 values set at once? - // TODO: settings.sidebar_side_width - // TODO: settings.sidebar_bottom_height + // TODO: settings["sidebar-default-width"] + // TODO: settings["sidebar-default-height"] // TODO: draggable resize? const button = event.target; const sidebarButtons = this.#el.sidebarButtons.children; @@ -795,6 +840,169 @@ class ModelManager extends ComfyDialog { modelManager.classList.add(newSidebarState); } } + + #setSettings(settings, reloadData) { + const el = this.#el.settings; + for (const [key, value] of Object.entries(settings)) { + const setting = el[key]; + if (setting) { + const type = setting.type; + switch (type) { + case "checkbox": setting.checked = Boolean(value); break; + case "range": setting.value = parseFloat(value); break; + case "textarea": setting.value = value; break; + case "number": setting.value = parseInt(value); break; + default: console.warn("Unknown settings input type!"); + } + } + } + + if (reloadData) { + // Is this slow? + this.#refreshSourceList(); + this.#modelGridRefresh(); + } + } + + async #reloadSettings(reloadData) { + const data = await request("/model-manager/settings/load"); + const settings = data["settings"]; + this.#setSettings(settings, reloadData); + buttonAlert(this.#el.reloadSettingsBtn, true); + }; + + async #saveSettings() { + let settings = {}; + for (const [setting, el] of Object.entries(this.#el.settings)) { + if (!el) { continue; } // hack + const type = el.type; + let value = null; + switch (type) { + case "checkbox": value = el.checked; break; + case "range": value = el.value; break; + case "textarea": value = el.value; break; + case "number": value = el.value; break; + default: console.warn("Unknown settings input type!"); + } + settings[setting] = value; + } + + const data = await request( + "/model-manager/settings/save", + { + method: "POST", + body: JSON.stringify({ "settings": settings }), + } + ); + const success = data["success"]; + if (success) { + const settings = data["settings"]; + this.#setSettings(settings, true); + } + buttonAlert(this.#el.saveSettingsBtn, success); + } + + #createSettingsTabHtml() { + const settingsTab = $el("div.model-manager-settings", [ + $el("h1", ["Settings"]), + $el("div", [ + $el("button", { + $: (el) => (this.#el.reloadSettingsBtn = el), + type: "button", + textContent: "Reload", // ⟳ + onclick: () => this.#reloadSettings(true), + }), + $el("button", { + $: (el) => (this.#el.saveSettingsBtn = el), + type: "button", + textContent: "Save", // 💾︎ + onclick: () => this.#saveSettings(), + }), + ]), + /* + $el("h2", ["Window"]), + $el("div", [ + $el("p", ["Default sidebar width"]), + $el("input", { + $: (el) => (this.#el.settings["sidebar-default-width"] = el), + type: "number", + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + $el("div", [ + $el("p", ["Default sidebar height"]), + $el("input", { + $: (el) => (this.#el.settings["sidebar-default-height"] = el), + type: "number", + textContent: "Default sidebar height", + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + */ + $el("h2", ["Model Search"]), + $el("div", [ + $el("div.search-settings-text", [ + $el("p", ["Always append to model search:"]), + $el("textarea.comfy-multiline-input", { + $: (el) => (this.#el.settings["model-search-always-append"] = el), + placeholder: "example: -nsfw", + }), + ]), + ]), + $el("div", [ + $el("input", { + $: (el) => (this.#el.settings["model-show-label-extensions"] = el), + type: "checkbox", + }), + $el("p", ["Show extensions in models tab"]), + ]), + $el("div", [ + $el("input", { + $: (el) => (this.#el.settings["model-show-add-button"] = el), + type: "checkbox", + }), + $el("p", ["Show add button"]), + ]), + $el("div", [ + $el("input", { + $: (el) => (this.#el.settings["model-show-copy-button"] = el), + type: "checkbox", + }), + $el("p", ["Show copy button"]), + ]), + $el("h2", ["Model Add"]), + $el("div", [ + $el("input", { + $: (el) => (this.#el.settings["model-add-embedding-extension"] = el), + type: "checkbox", + }), + $el("p", ["Add extension to embedding"]), + ]), + $el("div", [ + $el("input", { + $: (el) => (this.#el.settings["model-add-drag-strict-on-field"] = el), + type: "checkbox", + }), + $el("p", ["Strict dragging model onto a node's model field to add"]), + ]), + $el("div", [ + $el("p", ["Add model offset"]), + $el("input", { + $: (el) => (this.#el.settings["model-add-offset"] = el), + type: "number", + step: 5, + }), + ]), + ]); + this.#el.settingsTab = settingsTab; + return [settingsTab]; + } } let instance;