diff --git a/web/widgets/lora_row_widget.js b/web/widgets/lora_row_widget.js index 801744a..387ea64 100644 --- a/web/widgets/lora_row_widget.js +++ b/web/widgets/lora_row_widget.js @@ -1,5 +1,6 @@ import { COLORS } from "../constants.js"; import { fitString } from "../utils.js"; +import { showSearchOverlay } from "./search_overlay.js"; import { app } from "/scripts/app.js"; // --- Constants --- @@ -238,131 +239,22 @@ export class LoraRowWidget { // --- Menu --- _showLoraMenu(event, node) { - // Destroy any previous menu - document.querySelector("._lora_picker_menu")?.remove(); - document.removeEventListener("pointerdown", this._onOutside); - - const wrapper = document.createElement("div"); - wrapper.classList.add("_lora_picker_menu"); - wrapper.style.cssText = ` - position: fixed; - z-index: 9999; - background: #1a1a1a; - border: 1px solid #444; - border-radius: 4px; - overflow: hidden; - display: flex; - flex-direction: column; - left: ${event.clientX}px; - top: ${event.clientY}px; - `; - - const input = document.createElement("input"); - input.type = "text"; - input.placeholder = "Search LoRA..."; - input.style.cssText = ` - background: #111; - color: #e0e0e0; - border: none; - border-bottom: 1px solid #444; - padding: 4px 8px; - font-family: monospace; - font-size: 11px; - outline: none; - `; - - const select = document.createElement("select"); - select.size = 15; - select.style.cssText = ` - background: #1a1a1a; - color: #e0e0e0; - border: none; - font-family: monospace; - font-size: 11px; - min-width: 250px; - max-height: 300px; - outline: none; - cursor: pointer; - `; - - const populate = (filter = "") => { - select.innerHTML = ""; - const filtered = filter - ? this.loras.filter((l) => - l.toLowerCase().includes(filter.toLowerCase()), - ) - : this.loras; - for (const lora of filtered) { - const opt = document.createElement("option"); - opt.value = lora; - opt.textContent = lora; - if (lora === this.value.lora) opt.selected = true; - select.appendChild(opt); - } - - // Adapt length to item count - select.size = Math.min(filtered.length, 15); - // If empty, display at least one line - if (select.size === 0) select.size = 1; - }; - - populate(); - // --- Event listener - Input (Enter): Fill with search --- - input.addEventListener("input", () => populate(input.value)); - - const cleanup = () => { - document.querySelector("._lora_picker_menu")?.remove(); - document.removeEventListener("pointerdown", this._onOutside); - }; - - this._onOutside = (e) => { - if (!wrapper.contains(e.target)) cleanup(); - }; - - // --- Event listener - Keydown (Change): Select value --- - select.addEventListener("change", () => { - this.value.lora = select.value; - this.onChange("lora"); - if (node.graph) node.graph.setDirtyCanvas(true, true); - cleanup(); - }); - - input.addEventListener("keydown", (e) => { - // --- Event listener - Keydown (Arrow Up/Down): Keyboard navigation --- - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - const current = select.selectedIndex; - if (e.key === "ArrowDown") { - select.selectedIndex = Math.min( - current + 1, - select.options.length - 1, - ); - } else { - select.selectedIndex = Math.max(current - 1, 0); - } - return; - } - // --- Event listener - Keydown (Enter): Validate search --- - if (e.key === "Enter" && select.options.length > 0) { - this.value.lora = - select.selectedOptions[0]?.value ?? select.options[0].value; + showSearchOverlay(event, { + items: this.loras.map((l) => ({ label: l, value: l })), + placeholder: "Search LoRA...", + initialValue: this.value.lora, + maxVisible: 15, + onFilter: (query, items) => + query + ? items.filter((i) => + i.label.toLowerCase().includes(query.toLowerCase()), + ) + : items, + onSelect: (value) => { + this.value.lora = value; this.onChange("lora"); if (node.graph) node.graph.setDirtyCanvas(true, true); - cleanup(); - } - // --- Event listener - Keydown (Escape): Cleanup --- - if (e.key === "Escape") { - cleanup(); - } + }, }); - - wrapper.appendChild(input); - wrapper.appendChild(select); - document.body.appendChild(wrapper); - - setTimeout(() => { - document.addEventListener("pointerdown", this._onOutside); - input.focus(); - }, 0); } } diff --git a/web/widgets/search_overlay.js b/web/widgets/search_overlay.js new file mode 100644 index 0000000..409d2a0 --- /dev/null +++ b/web/widgets/search_overlay.js @@ -0,0 +1,142 @@ +export const showSearchOverlay = ( + event, + { + items, + placeholder = "", + initialQuery = "", + initialValue, + maxVisible = 10, + onFilter, + onSelect, + onClose, + } = {}, +) => { + // Destroy any previous menu + document.querySelector("._search_overlay")?.remove(); + + const wrapper = document.createElement("div"); + wrapper.classList.add("_search_overlay"); + wrapper.style.cssText = ` + position: fixed; + z-index: 9999; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 4px; + overflow: hidden; + display: flex; + flex-direction: column; + left: ${event.clientX}px; + top: ${event.clientY}px; + `; + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.value = initialQuery; + input.style.cssText = ` + background: #111; + color: #e0e0e0; + border: none; + border-bottom: 1px solid #444; + padding: 4px 8px; + font-family: monospace; + font-size: 11px; + outline: none; + `; + + const select = document.createElement("select"); + select.size = maxVisible; + select.style.cssText = ` + background: #1a1a1a; + color: #e0e0e0; + border: none; + font-family: monospace; + font-size: 11px; + min-width: 250px; + max-height: 250px; + outline: none; + cursor: pointer; + `; + + const populate = (query) => { + const filtered = onFilter ? onFilter(query, items) : items; + select.innerHTML = ""; + for (const item of filtered) { + const opt = document.createElement("option"); + opt.value = item.value; + opt.textContent = item.label; + select.appendChild(opt); + } + // Adapt length to item count + select.size = Math.max(1, Math.min(filtered.length, maxVisible)); + const matchIdx = filtered.findIndex((i) => i.value === initialValue); + // If empty, display at least one line + select.selectedIndex = matchIdx !== -1 ? matchIdx : 0; + }; + + let closed = false; + const close = () => { + if (closed) return; + closed = true; + wrapper.remove(); + document.removeEventListener("pointerdown", onOutside, true); + onClose?.(); + }; + + const commit = (value) => { + close(); + onSelect?.(value); + }; + + const onOutside = (e) => { + if (!wrapper.contains(e.target)) close(); + }; + + populate(input.value); + // --- Event listener - Input (Enter): Fill with search --- + input.addEventListener("input", () => populate(input.value)); + // --- Event listener - Keydown (Change): Select value --- + select.addEventListener("change", () => commit(select.value)); + + input.addEventListener("keydown", (e) => { + // --- Event listener - Keydown (Arrow Up/Down): Keyboard navigation --- + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + const cur = select.selectedIndex; + select.selectedIndex = + e.key === "ArrowDown" + ? Math.min(cur + 1, select.options.length - 1) + : Math.max(cur - 1, 0); + return; + } + // --- Event listener - Keydown (Enter): Validate search --- + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (select.options.length > 0) { + commit( + select.selectedOptions[0]?.value ?? select.options[0].value, + ); + } + return; + } + // --- Event listener - Keydown (Escape): Cleanup --- + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }); + + wrapper.appendChild(input); + wrapper.appendChild(select); + document.body.appendChild(wrapper); + + setTimeout(() => { + document.addEventListener("pointerdown", onOutside, true); + input.focus(); + }, 0); + + return { close }; +};