import { app } from "../../scripts/app.js"; import { bindLoraContextMenu, fetchLoraTriggerVariants } from "./lora_info.js"; import { bindPreviewImage, replaceWithPreviewPlaceholder } from "./lora_preview_cache.js"; import { createEmptyTriggerEntry, entryTriggerStates, entryVariantStates, formatEnabledTriggers, initEntryTriggers, loadEntryTriggerData, LoraEntrySettingsDialog, restoreTriggerEntryFromWidget, } from "./lora_loader_settings.js"; const NODE_NAME = "GridLoraLoader"; const API_BASE = "/crashutils/loras"; const GRID_CELL_DEFAULT = 72; const GRID_CELL_MIN = 48; const GRID_CELL_MAX = 120; const GRID_CELL_STEP = 4; const GRID_GAP = 6; const DEFAULT_WIDTH = 360; const DEFAULT_HEIGHT = 540; const MIN_WIDTH = 280; const MIN_HEIGHT = 300; const PANEL_HEADER_HEIGHT = 26; const PANEL_MIN_HEIGHT = 56; const RESIZER_HEIGHT = 6; const BROWSE_PANEL_RATIO = 0.5; const SELECTED_PANEL_RATIO = 0.25; const OPTIONAL_INPUT_SLOTS = 2; const INPUT_SLOT_HEIGHT = 20; const DEFAULT_CONTENT_WIDTH = DEFAULT_WIDTH - 20; const DEFAULT_CONTENT_HEIGHT = 300; const NODE_BOTTOM_PAD = 18; const TITLE_BAR_FALLBACK = 30; const WIDGET_ROW_FALLBACK = 20; const WIDGET_ROW_GAP = 4; const LOG_PREFIX = "[GridLoraLoader]"; const LOG_VERBOSE = false; const STORAGE_KEY = "Comfy.GridLoraLoader.defaults"; function readGlobalDefaults() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); } catch { return {}; } } function writeGlobalDefaults(props) { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ sfw: props.sfw, browsePath: props.browsePath, gridCellSize: props.gridCellSize, browsePanelRatio: props.browsePanelRatio, selectedPanelRatio: props.selectedPanelRatio, }) ); } catch { // ignore quota / private mode errors } } function applyGlobalDefaults(properties) { const globals = readGlobalDefaults(); if (properties.sfw === undefined && globals.sfw !== undefined) { properties.sfw = globals.sfw; } if (properties.gridCellSize === undefined && globals.gridCellSize !== undefined) { properties.gridCellSize = globals.gridCellSize; } if (properties.browsePath === undefined && globals.browsePath !== undefined) { properties.browsePath = globals.browsePath; } if (properties.browsePanelRatio === undefined && globals.browsePanelRatio !== undefined) { properties.browsePanelRatio = globals.browsePanelRatio; } if (properties.selectedPanelRatio === undefined && globals.selectedPanelRatio !== undefined) { properties.selectedPanelRatio = globals.selectedPanelRatio; } } function getWidgetTop(node) { const domWidget = node.gridLoraWidget; if (domWidget?.last_y != null && domWidget.last_y > 0) { return domWidget.last_y; } let yOffset = TITLE_BAR_FALLBACK + OPTIONAL_INPUT_SLOTS * INPUT_SLOT_HEIGHT; for (const widget of node.widgets || []) { if (widget === domWidget) break; if (widget.computeSize) { const [, h] = widget.computeSize(); yOffset += Math.max(h, 0) + WIDGET_ROW_GAP; } else { yOffset += WIDGET_ROW_FALLBACK + WIDGET_ROW_GAP; } } return yOffset; } function getContentArea(node, nodeHeight) { const widgetY = getWidgetTop(node); const totalH = nodeHeight ?? node.size?.[1] ?? DEFAULT_HEIGHT; return { w: Math.max(MIN_WIDTH, (node.size?.[0] || DEFAULT_WIDTH) - 20), h: Math.max(MIN_HEIGHT, totalH - widgetY - NODE_BOTTOM_PAD), }; } function syncLayoutToNode(node, nodeHeight) { if (!node?.gridLoraUI) return; const { w, h } = getContentArea(node, nodeHeight); if (node._gridLoraLayoutW === w && node._gridLoraLayoutH === h) return; node._gridLoraLayoutW = w; node._gridLoraLayoutH = h; node.gridLoraUI.layoutPanels(w, h); } function applyDefaultNodeSize(node) { if (!node.size) { node.size = [DEFAULT_WIDTH, DEFAULT_HEIGHT]; } if (node.size[0] < DEFAULT_WIDTH) { node.size[0] = DEFAULT_WIDTH; } if (node.size[1] < DEFAULT_HEIGHT) { node.size[1] = DEFAULT_HEIGHT; } syncLayoutToNode(node, node.size[1]); node.setDirtyCanvas(true, true); } const _logCounts = new Map(); function gridLog(node, event, details = {}) { const nodeId = node?.id ?? "new"; const key = `${nodeId}:${event}`; const count = (_logCounts.get(key) || 0) + 1; _logCounts.set(key, count); const root = node?.gridLoraUI?.root; const payload = { count, nodeId, nodeSize: node?.size ? [node.size[0], node.size[1]] : null, layoutW: node?._gridLoraLayoutW, layoutH: node?._gridLoraLayoutH, widgetTop: getWidgetTop(node), widgetCount: node?.widgets?.length ?? 0, widgetNames: node?.widgets?.map((w) => w.name), ...details, }; if (root) { payload.dom = { styleWidth: root.style.width, styleHeight: root.style.height, offsetWidth: root.offsetWidth, offsetHeight: root.offsetHeight, scrollWidth: root.scrollWidth, scrollHeight: root.scrollHeight, clientHeight: root.clientHeight, }; if (node.gridLoraUI.browseScroll) { payload.browseScrollH = node.gridLoraUI.browseScroll.scrollHeight; } } console.log(LOG_PREFIX, event, payload); if (!LOG_VERBOSE && count > 3) { return; } } function gridLogSizeDelta(node, event, before, after) { if (!LOG_VERBOSE || !before || !after) return; if (after[0] > before[0] + 2 || after[1] > before[1] + 2) { console.warn(LOG_PREFIX, "SIZE INCREASED", event, { nodeId: node?.id, before: [...before], after: [...after], delta: [after[0] - before[0], after[1] - before[1]], }); } } const STYLES = ` .crash-grid-lora { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 11px; color: #ccc; user-select: none; box-sizing: border-box; overflow: hidden; position: relative; } .crash-grid-lora * { box-sizing: border-box; } .crash-grid-lora .panel { border: 1px solid #555; border-radius: 4px; background: #2a2a2a; overflow: hidden; position: absolute; left: 0; right: 0; } .crash-grid-lora .panel-header { display: flex; align-items: center; gap: 6px; padding: 4px 6px; background: #333; border-bottom: 1px solid #555; height: ${PANEL_HEADER_HEIGHT}px; } .crash-grid-lora .breadcrumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; flex: 1; min-width: 0; } .crash-grid-lora .crumb { color: #8af; cursor: pointer; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; } .crash-grid-lora .crumb:hover { text-decoration: underline; } .crash-grid-lora .crumb-sep { color: #666; margin: 0 1px; } .crash-grid-lora .search-box { width: 72px; min-width: 60px; padding: 2px 6px; border: 1px solid #555; border-radius: 3px; background: #1a1a1a; color: #ddd; font-size: 11px; outline: none; } .crash-grid-lora .search-box:focus { border-color: #8af; } .crash-grid-lora .header-controls { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .crash-grid-lora .sfw-switch { display: flex; align-items: center; gap: 4px; cursor: pointer; flex-shrink: 0; } .crash-grid-lora .sfw-label { color: #aaa; font-size: 10px; white-space: nowrap; } .crash-grid-lora .switch-track { position: relative; width: 32px; height: 16px; flex-shrink: 0; } .crash-grid-lora .switch-track input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } .crash-grid-lora .switch-thumb { position: absolute; inset: 0; background: #555; border-radius: 999px; transition: background 0.15s; } .crash-grid-lora .switch-thumb::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; background: #ddd; border-radius: 50%; transition: transform 0.15s; } .crash-grid-lora .switch-track input:checked + .switch-thumb { background: #4a9; } .crash-grid-lora .switch-track input:checked + .switch-thumb::after { transform: translateX(16px); } .crash-grid-lora .size-control { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .crash-grid-lora .size-label { color: #aaa; font-size: 10px; white-space: nowrap; } .crash-grid-lora .size-slider { width: 56px; height: 14px; margin: 0; accent-color: #8af; cursor: pointer; } .crash-grid-lora .grid-scroll { overflow-y: auto; overflow-x: hidden; padding: 6px; position: absolute; left: 0; right: 0; bottom: 0; } .crash-grid-lora .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--grid-cell-size, 72px), 1fr)); gap: ${GRID_GAP}px; } .crash-grid-lora .grid-item { position: relative; width: 100%; aspect-ratio: 1; border: 2px solid #444; border-radius: 4px; background: #1e1e1e; cursor: pointer; overflow: hidden; transition: border-color 0.15s, opacity 0.15s; } .crash-grid-lora .grid-item:hover { border-color: #8af; } .crash-grid-lora .grid-item.selected { border-color: #6c6; } .crash-grid-lora .grid-item.disabled { opacity: 0.35; } .crash-grid-lora .grid-item img { width: 100%; height: 100%; object-fit: cover; display: block; } .crash-grid-lora .placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: #666; font-size: calc(var(--grid-cell-size, 72px) * 0.39); } .crash-grid-lora .folder-icon { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: calc(var(--grid-cell-size, 72px) * 0.44); } .crash-grid-lora .item-label { position: absolute; bottom: 0; left: 0; right: 0; padding: 2px 3px; background: rgba(0,0,0,0.75); font-size: 9px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .crash-grid-lora .cog-btn { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; border: none; border-radius: 3px; background: rgba(0,0,0,0.7); color: #ccc; font-size: 11px; line-height: 18px; text-align: center; cursor: pointer; padding: 0; z-index: 2; } .crash-grid-lora .cog-btn:hover { background: rgba(50,50,50,0.95); color: #fff; } .crash-grid-lora .strength-badge { position: absolute; top: 2px; left: 2px; padding: 1px 4px; background: rgba(0,0,0,0.8); color: #ffd866; font-size: 9px; font-weight: 700; border-radius: 3px; line-height: 1.2; z-index: 2; pointer-events: none; } .crash-grid-lora .empty-msg { color: #666; text-align: center; padding: 20px 10px; font-style: italic; } .crash-grid-lora .panel-label { font-weight: 600; color: #aaa; white-space: nowrap; } .crash-grid-lora .search-folder { font-size: 9px; color: #888; margin-top: 1px; } .crash-grid-lora .tags-scroll { overflow-y: auto; overflow-x: hidden; padding: 6px; position: absolute; left: 0; right: 0; bottom: 0; } .crash-grid-lora .tags-list { display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start; } .crash-grid-lora .tag-chip { border: 1px solid #555; border-radius: 999px; background: #1f1f1f; color: #aaa; font-size: 10px; line-height: 1.2; padding: 3px 8px; cursor: pointer; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s; } .crash-grid-lora .tag-chip.on { border-color: #6c6; background: #243024; color: #cfc; } .crash-grid-lora .tag-chip.off { opacity: 0.45; } .crash-grid-lora .tag-chip:hover { border-color: #8af; } .crash-grid-lora .panel-resizer { position: absolute; left: 0; right: 0; height: ${RESIZER_HEIGHT}px; cursor: ns-resize; z-index: 6; touch-action: none; background: transparent; } .crash-grid-lora .panel-resizer::after { content: ""; position: absolute; left: 20%; right: 20%; top: 50%; height: 2px; transform: translateY(-50%); border-radius: 999px; background: #555; opacity: 0.6; transition: background 0.15s, opacity 0.15s; } .crash-grid-lora .panel-resizer:hover::after, .crash-grid-lora .panel-resizer.dragging::after { background: #8af; opacity: 1; } `; function formatStrength(value) { const num = Number(value); if (Number.isNaN(num)) return "?"; if (Number.isInteger(num)) return String(num); return num.toFixed(2).replace(/\.?0+$/, ""); } function formatTriggers(triggers) { if (!triggers?.length) return ""; return triggers.join(", "); } function triggerTitle(item) { const tags = formatTriggers(item.triggers); if (!tags) return item.path || item.name || ""; return `${item.name || item.path}\n${tags}`; } function isNsfwBrowsePath(path) { return String(path || "") .split(/[/\\]/) .some((part) => part.toLowerCase() === "nsfw"); } function trimNsfwBrowsePath(path) { const parts = String(path || "") .split(/[/\\]/) .filter(Boolean); const nsfwIndex = parts.findIndex((part) => part.toLowerCase() === "nsfw"); if (nsfwIndex === -1) { return parts.join("/"); } return parts.slice(0, nsfwIndex).join("/"); } function isTagEnabledForEntry(entry, word) { return entry.triggerOn?.[word] !== false; } class HiddenLoraWidget { constructor(name, value) { this.name = name; this.type = "custom"; this.value = value || { on: true, lora: null, strength: 1.0 }; } draw() {} computeSize() { return [0, -4]; } serializeValue() { return { ...this.value }; } } function clampPanelRatio(value, fallback) { const num = Number(value); if (Number.isNaN(num)) { return fallback; } return Math.min(0.85, Math.max(0.1, num)); } function normalizePanelRatios(browseRatio, selectedRatio) { let browse = clampPanelRatio(browseRatio, BROWSE_PANEL_RATIO); let selected = clampPanelRatio(selectedRatio, SELECTED_PANEL_RATIO); const minThird = 0.1; if (browse + selected > 1 - minThird) { const scale = (1 - minThird) / (browse + selected); browse *= scale; selected *= scale; } return { browse, selected }; } function computePanelHeights(totalHeight) { return Math.max(totalHeight - RESIZER_HEIGHT * 2, PANEL_MIN_HEIGHT * 3); } function clampGridCellSize(value) { const num = Number(value); if (Number.isNaN(num)) return GRID_CELL_DEFAULT; return Math.min(GRID_CELL_MAX, Math.max(GRID_CELL_MIN, Math.round(num / GRID_CELL_STEP) * GRID_CELL_STEP)); } class GridLoraLoaderUI { constructor(node) { this.node = node; node.properties = node.properties || {}; this.currentPath = node.properties.browsePath || ""; this.searchQuery = node.properties.searchQuery || ""; this.searchTimer = null; this.selected = new Map(); this.sfw = node.properties.sfw !== false; this.gridCellSize = clampGridCellSize(node.properties.gridCellSize ?? GRID_CELL_DEFAULT); const ratios = normalizePanelRatios( node.properties.browsePanelRatio ?? BROWSE_PANEL_RATIO, node.properties.selectedPanelRatio ?? SELECTED_PANEL_RATIO ); this.browseRatio = ratios.browse; this.selectedRatio = ratios.selected; this.root = document.createElement("div"); this.root.className = "crash-grid-lora"; this.applyGridCellSize(this.gridCellSize); const style = document.createElement("style"); style.textContent = STYLES; this.root.appendChild(style); const shell = document.createElement("div"); shell.innerHTML = `
Browse
Size
Selected LoRAs
Trigger Tags
`; this.root.appendChild(shell); this.resizerBrowseSelected = document.createElement("div"); this.resizerBrowseSelected.className = "panel-resizer"; this.resizerBrowseSelected.title = "Drag to resize panels"; this.resizerSelectedTags = document.createElement("div"); this.resizerSelectedTags.className = "panel-resizer"; this.resizerSelectedTags.title = "Drag to resize panels"; this.root.appendChild(this.resizerBrowseSelected); this.root.appendChild(this.resizerSelectedTags); this.browsePanel = this.root.querySelector(".browse-panel"); this.selectedPanel = this.root.querySelector(".selected-panel"); this.tagsPanel = this.root.querySelector(".tags-panel"); this.browseScroll = this.root.querySelector(".grid-scroll.browse"); this.selectedScroll = this.root.querySelector(".grid-scroll.selected"); this.tagsScroll = this.root.querySelector(".tags-scroll"); this.breadcrumbsEl = this.root.querySelector(".breadcrumbs"); this.searchEl = this.root.querySelector(".search-box"); this.sfwEl = this.root.querySelector(".sfw-checkbox"); this.sizeSliderEl = this.root.querySelector(".size-slider"); this.browseGridEl = this.root.querySelector(".browse-grid"); this.selectedGridEl = this.root.querySelector(".selected-grid"); this.tagsListEl = this.root.querySelector(".tags-list"); this.sfwEl.checked = this.sfw; this.sizeSliderEl.value = String(this.gridCellSize); this.searchEl.value = this.searchQuery; this.sfwEl.addEventListener("change", () => { this.sfw = this.sfwEl.checked; if (this.sfw && isNsfwBrowsePath(this.currentPath)) { this.currentPath = trimNsfwBrowsePath(this.currentPath); } this.saveProperties(); this.refreshBrowse(); }); this.bindPointerBlock(this.root.querySelector(".sfw-switch")); this.sizeSliderEl.addEventListener("input", () => { this.applyGridCellSize(Number(this.sizeSliderEl.value)); this.saveProperties(); }); this.bindPointerBlock(this.sizeSliderEl); this.bindPointerBlock(this.root.querySelector(".size-control")); this.searchEl.addEventListener("input", () => { clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.searchQuery = this.searchEl.value.trim(); this.saveProperties(); this.refreshBrowse(); }, 250); }); this.bindPointerBlock(this.searchEl); this.setupPanelResizers(); } setupPanelResizers() { this.bindPanelResizer(this.resizerBrowseSelected, "browse-selected"); this.bindPanelResizer(this.resizerSelectedTags, "selected-tags"); } bindPanelResizer(handle, mode) { handle.addEventListener("pointerdown", (e) => { e.preventDefault(); e.stopPropagation(); handle.setPointerCapture(e.pointerId); handle.classList.add("dragging"); const totalH = this.root.clientHeight; const available = computePanelHeights(totalH); const startY = e.clientY; const startBrowse = this.browseRatio * available; const startSelected = this.selectedRatio * available; const startTags = available - startBrowse - startSelected; const onMove = (ev) => { ev.preventDefault(); ev.stopPropagation(); const delta = ev.clientY - startY; let browse = startBrowse; let selected = startSelected; let tags = startTags; if (mode === "browse-selected") { browse = Math.max( PANEL_MIN_HEIGHT, Math.min(startBrowse + delta, available - startTags - PANEL_MIN_HEIGHT) ); selected = available - startTags - browse; } else { selected = Math.max( PANEL_MIN_HEIGHT, Math.min(startSelected + delta, available - startBrowse - PANEL_MIN_HEIGHT) ); tags = available - startBrowse - selected; } this.browseRatio = browse / available; this.selectedRatio = selected / available; this.applyPanelSizes( Math.max(this.root.clientWidth, MIN_WIDTH), totalH, browse, selected, tags ); }; const onUp = (ev) => { handle.removeEventListener("pointermove", onMove); handle.removeEventListener("pointerup", onUp); handle.removeEventListener("pointercancel", onUp); handle.classList.remove("dragging"); try { handle.releasePointerCapture(ev.pointerId); } catch { // ignore } this.saveProperties(); }; handle.addEventListener("pointermove", onMove); handle.addEventListener("pointerup", onUp); handle.addEventListener("pointercancel", onUp); }); } bindPointerBlock(el) { if (!el) return; el.addEventListener("pointerdown", (e) => e.stopPropagation()); el.addEventListener("click", (e) => e.stopPropagation()); } applyGridCellSize(size) { this.gridCellSize = clampGridCellSize(size); this.root.style.setProperty("--grid-cell-size", `${this.gridCellSize}px`); if (this.sizeSliderEl && this.sizeSliderEl.value !== String(this.gridCellSize)) { this.sizeSliderEl.value = String(this.gridCellSize); } } saveProperties() { const props = this.node.properties || {}; props.sfw = this.sfw; props.browsePath = this.currentPath; props.gridCellSize = this.gridCellSize; props.searchQuery = this.searchQuery; props.browsePanelRatio = this.browseRatio; props.selectedPanelRatio = this.selectedRatio; this.node.properties = props; writeGlobalDefaults(props); this.node.setDirtyCanvas(true, true); } loadFromProperties(props = {}) { if (props.sfw !== undefined) { this.sfw = props.sfw !== false; if (this.sfwEl) this.sfwEl.checked = this.sfw; } if (props.browsePath !== undefined) { this.currentPath = props.browsePath || ""; } if (this.sfw && isNsfwBrowsePath(this.currentPath)) { this.currentPath = trimNsfwBrowsePath(this.currentPath); } if (props.gridCellSize !== undefined) { this.applyGridCellSize(props.gridCellSize); } if (props.searchQuery !== undefined) { this.searchQuery = props.searchQuery || ""; if (this.searchEl) this.searchEl.value = this.searchQuery; } if (props.browsePanelRatio !== undefined || props.selectedPanelRatio !== undefined) { const ratios = normalizePanelRatios( props.browsePanelRatio ?? this.browseRatio, props.selectedPanelRatio ?? this.selectedRatio ); this.browseRatio = ratios.browse; this.selectedRatio = ratios.selected; } } setBrowsePath(path) { this.currentPath = path || ""; this.searchEl.value = ""; this.searchQuery = ""; this.node.properties.searchQuery = ""; this.saveProperties(); this.refreshBrowse(); } sfwParam() { return this.sfw ? "1" : "0"; } layoutPanels(width, height) { const w = Math.max(width, MIN_WIDTH); const h = Math.max(height, MIN_HEIGHT); gridLog(this.node, "layoutPanels", { width, height, appliedW: w, appliedH: h }); this.root.style.width = `${w}px`; this.root.style.height = `${h}px`; const available = computePanelHeights(h); let browse = Math.floor(available * this.browseRatio); let selected = Math.floor(available * this.selectedRatio); let tags = available - browse - selected; browse = Math.max(PANEL_MIN_HEIGHT, browse); selected = Math.max(PANEL_MIN_HEIGHT, selected); tags = Math.max(PANEL_MIN_HEIGHT, tags); const overflow = browse + selected + tags - available; if (overflow > 0) { tags = Math.max(PANEL_MIN_HEIGHT, tags - overflow); } this.applyPanelSizes(w, h, browse, selected, tags); } applyPanelSizes(width, height, browseHeight, selectedHeight, tagsHeight) { const scrollHeight = (panelHeight) => Math.max(panelHeight - PANEL_HEADER_HEIGHT, 40); this.browsePanel.style.top = "0"; this.browsePanel.style.height = `${browseHeight}px`; this.browseScroll.style.top = `${PANEL_HEADER_HEIGHT}px`; this.browseScroll.style.height = `${scrollHeight(browseHeight)}px`; const selectedTop = browseHeight + RESIZER_HEIGHT; this.resizerBrowseSelected.style.top = `${browseHeight}px`; this.resizerBrowseSelected.style.height = `${RESIZER_HEIGHT}px`; this.selectedPanel.style.top = `${selectedTop}px`; this.selectedPanel.style.height = `${selectedHeight}px`; this.selectedScroll.style.top = `${PANEL_HEADER_HEIGHT}px`; this.selectedScroll.style.height = `${scrollHeight(selectedHeight)}px`; const tagsTop = selectedTop + selectedHeight + RESIZER_HEIGHT; this.resizerSelectedTags.style.top = `${selectedTop + selectedHeight}px`; this.resizerSelectedTags.style.height = `${RESIZER_HEIGHT}px`; this.tagsPanel.style.top = `${tagsTop}px`; this.tagsPanel.style.height = `${tagsHeight}px`; this.tagsScroll.style.top = `${PANEL_HEADER_HEIGHT}px`; this.tagsScroll.style.height = `${scrollHeight(tagsHeight)}px`; } async refreshBrowse() { if (this.searchQuery) { await this.renderSearch(); } else { await this.renderFolder(); } } async renderFolder() { try { const res = await fetch( `${API_BASE}/browse?path=${encodeURIComponent(this.currentPath)}&sfw=${this.sfwParam()}` ); const data = await res.json(); this.renderBreadcrumbs(data.breadcrumbs || []); this.browseGridEl.innerHTML = ""; const items = []; for (const folder of data.folders || []) { items.push({ type: "folder", ...folder }); } for (const lora of data.loras || []) { items.push({ type: "lora", ...lora }); } if (items.length === 0) { this.browseGridEl.innerHTML = `
No LoRAs in this folder
`; return; } for (const item of items) { this.browseGridEl.appendChild(this.createBrowseItem(item)); } } catch (err) { this.browseGridEl.innerHTML = `
Failed to load folder
`; console.error("[GridLoraLoader]", err); } } async renderSearch() { this.breadcrumbsEl.innerHTML = `Search results`; this.browseGridEl.innerHTML = `
Searching...
`; try { const res = await fetch( `${API_BASE}/search?path=${encodeURIComponent(this.currentPath)}&q=${encodeURIComponent(this.searchQuery)}&sfw=${this.sfwParam()}` ); const data = await res.json(); this.browseGridEl.innerHTML = ""; const loras = data.loras || []; if (loras.length === 0) { this.browseGridEl.innerHTML = `
No matches found
`; return; } for (const lora of loras) { this.browseGridEl.appendChild(this.createBrowseItem({ type: "lora", ...lora, showFolder: true })); } } catch (err) { this.browseGridEl.innerHTML = `
Search failed
`; console.error("[GridLoraLoader]", err); } } renderBreadcrumbs(crumbs) { this.breadcrumbsEl.innerHTML = ""; crumbs.forEach((crumb, index) => { if (index > 0) { const sep = document.createElement("span"); sep.className = "crumb-sep"; sep.textContent = "/"; this.breadcrumbsEl.appendChild(sep); } const el = document.createElement("span"); el.className = "crumb"; el.textContent = crumb.name; el.title = crumb.path || "loras"; el.addEventListener("click", (e) => { e.stopPropagation(); this.setBrowsePath(crumb.path || ""); }); el.addEventListener("pointerdown", (e) => e.stopPropagation()); this.breadcrumbsEl.appendChild(el); }); } createBrowseItem(item) { const el = document.createElement("div"); el.className = "grid-item"; if (item.type === "lora" && this.selected.has(item.path)) { el.classList.add("selected"); } if (item.type === "folder") { el.innerHTML = `
📁
${item.name}
`; el.addEventListener("click", (e) => { e.stopPropagation(); this.setBrowsePath(item.path); }); } else { const imgHtml = item.hasPreview ? `` : `
🎨
`; el.innerHTML = ` ${imgHtml}
${item.name}
`; if (item.showFolder && item.folder) { const label = el.querySelector(".item-label"); label.innerHTML = `${item.name}
${item.folder}
`; } el.addEventListener("dblclick", (e) => { e.stopPropagation(); if (this.selected.has(item.path)) { this.removeSelection(item.path); } else { this.addSelection(item); } }); bindLoraContextMenu(el, item.path); this.ensureBrowseTriggers(item, el); if (item.hasPreview) { bindPreviewImage(el.querySelector("img"), item.path, replaceWithPreviewPlaceholder); } } el.addEventListener("pointerdown", (e) => e.stopPropagation()); return el; } async ensureBrowseTriggers(item, el) { if (!item?.path || !el || (item.triggers && item.triggers.length)) { return; } const data = await fetchLoraTriggerVariants(item.path); const triggers = data.default || []; if (!triggers.length) { return; } item.triggers = triggers; const label = el.querySelector(".item-label"); if (label) { label.title = triggerTitle(item); } } addSelection(item) { const loraPath = item.path; const loraName = item.name; if (this.selected.has(loraPath)) { return; } const entry = createEmptyTriggerEntry({ on: true, lora: loraPath, name: loraName, strength: 1.0, }); initEntryTriggers(entry, item.triggers || []); entry.defaultTriggers = item.triggers || []; this.selected.set(loraPath, entry); loadEntryTriggerData(entry).then(() => { if (!this.selected.has(loraPath)) return; this.renderSelected(); this.renderTags(); this.syncWidgets(); }); this.syncWidgets(); this.renderSelected(); this.renderTags(); this.refreshBrowse(); this.node.setDirtyCanvas(true, true); } removeSelection(loraPath) { if (!this.selected.has(loraPath)) return; this.selected.delete(loraPath); this.syncWidgets(); this.renderSelected(); this.renderTags(); this.refreshBrowse(); this.node.setDirtyCanvas(true, true); } renderSelected() { this.selectedGridEl.innerHTML = ""; const entries = [...this.selected.values()]; if (entries.length === 0) { this.selectedGridEl.innerHTML = `
Double-click LoRAs above to add them
`; return; } for (const entry of entries) { this.selectedGridEl.appendChild(this.createSelectedItem(entry)); } } createSelectedItem(entry) { const el = document.createElement("div"); el.className = "grid-item"; if (!entry.on) { el.classList.add("disabled"); } el.innerHTML = ` ${formatStrength(entry.strength)}
${entry.name || entry.lora.split("/").pop()}
`; bindPreviewImage(el.querySelector("img"), entry.lora, replaceWithPreviewPlaceholder); el.querySelector(".cog-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openSettings(entry); }); el.querySelector(".cog-btn").addEventListener("pointerdown", (e) => e.stopPropagation()); let clickTimer = null; el.addEventListener("click", (e) => { if (e.target.classList.contains("cog-btn")) return; e.stopPropagation(); if (clickTimer) clearTimeout(clickTimer); clickTimer = setTimeout(() => { clickTimer = null; entry.on = !entry.on; this.syncWidgets(); this.renderSelected(); this.renderTags(); this.node.setDirtyCanvas(true, true); }, 250); }); el.addEventListener("dblclick", (e) => { if (e.target.classList.contains("cog-btn")) return; e.stopPropagation(); if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } this.removeSelection(entry.lora); }); bindLoraContextMenu(el, entry.lora, [ null, { content: entry.on ? "⚫ Disable" : "🟢 Enable", callback: () => { entry.on = !entry.on; this.syncWidgets(); this.renderSelected(); this.renderTags(); this.node.setDirtyCanvas(true, true); }, }, { content: "🗑️ Remove", callback: () => this.removeSelection(entry.lora), }, ]); el.addEventListener("pointerdown", (e) => e.stopPropagation()); return el; } collectVisibleTags() { const tags = new Map(); for (const entry of this.selected.values()) { if (!entry.on) { continue; } for (const word of entry.triggers || []) { const key = word.toLowerCase(); if (!tags.has(key)) { tags.set(key, { word, enabled: isTagEnabledForEntry(entry, word), sources: [], }); } const tag = tags.get(key); tag.sources.push(entry); tag.enabled = tag.sources.every((source) => isTagEnabledForEntry(source, word)); } } return [...tags.values()].sort((a, b) => a.word.localeCompare(b.word, undefined, { sensitivity: "base" })); } setTagEnabled(word, enabled) { const key = word.toLowerCase(); for (const entry of this.selected.values()) { if (!entry.on) { continue; } for (const trigger of entry.triggers || []) { if (trigger.toLowerCase() === key) { entry.triggerOn[trigger] = enabled; } } } } renderTags() { if (!this.tagsListEl) { return; } this.tagsListEl.innerHTML = ""; const tags = this.collectVisibleTags(); if (tags.length === 0) { const empty = document.createElement("div"); empty.className = "empty-msg"; empty.textContent = this.selected.size ? "No trigger tags for enabled LoRAs" : "Add LoRAs to see trigger tags"; this.tagsListEl.appendChild(empty); return; } for (const tag of tags) { const chip = document.createElement("button"); chip.type = "button"; chip.className = `tag-chip${tag.enabled ? " on" : " off"}`; chip.textContent = tag.word; chip.title = tag.sources.map((entry) => entry.name || entry.lora).join(", "); chip.addEventListener("click", (e) => { e.stopPropagation(); this.setTagEnabled(tag.word, !tag.enabled); this.renderTags(); this.syncWidgets(); this.node.setDirtyCanvas(true, true); }); chip.addEventListener("pointerdown", (e) => e.stopPropagation()); this.tagsListEl.appendChild(chip); } } openSettings(entry) { new LoraEntrySettingsDialog(entry, { onApply: () => { this.syncWidgets(); this.renderSelected(); this.renderTags(); this.node.setDirtyCanvas(true, true); }, }).open(); } syncWidgets() { const node = this.node; gridLog(node, "syncWidgets", { selectedCount: this.selected.size }); const domWidget = node.widgets?.find((w) => w.name === "grid_lora_ui"); const others = (node.widgets || []).filter( (w) => w !== domWidget && (!w.name || !w.name.startsWith("lora_")) ); const loraWidgets = []; let counter = 0; for (const entry of this.selected.values()) { counter++; loraWidgets.push( new HiddenLoraWidget(`lora_${counter}`, { on: entry.on, lora: entry.lora, strength: entry.strength, triggerStates: entryTriggerStates(entry), variantStates: entryVariantStates(entry), }) ); } node.widgets = domWidget ? [domWidget, ...loraWidgets, ...others] : [...loraWidgets, ...others]; } async refreshSelectedTriggers() { const tasks = [...this.selected.values()].map(async (entry) => { await loadEntryTriggerData(entry); }); await Promise.all(tasks); this.renderSelected(); this.renderTags(); this.syncWidgets(); } loadFromWidgetValues(widgetValues, properties) { this.selected.clear(); for (const value of widgetValues || []) { if (value && typeof value === "object" && value.lora) { const name = value.lora.split("/").pop().replace(/\.[^.]+$/, ""); const entry = createEmptyTriggerEntry({ on: value.on !== false, lora: value.lora, name, strength: value.strength ?? 1.0, }); restoreTriggerEntryFromWidget(entry, value); this.selected.set(value.lora, entry); } } this.loadFromProperties(properties ?? this.node.properties); this.syncWidgets(); this.renderSelected(); this.renderTags(); this.refreshBrowse(); this.refreshSelectedTriggers(); } } app.registerExtension({ name: "Comfy.GridLoraLoader", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_NAME) return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; if (this.gridLoraInitialized) return result; this.gridLoraInitialized = true; gridLog(this, "onNodeCreated start"); this.properties = this.properties || {}; applyGlobalDefaults(this.properties); if (this.properties.sfw === undefined) { this.properties.sfw = true; } if (this.properties.gridCellSize === undefined) { this.properties.gridCellSize = GRID_CELL_DEFAULT; } if (this.properties.browsePath === undefined) { this.properties.browsePath = ""; } if (this.properties.searchQuery === undefined) { this.properties.searchQuery = ""; } this.serialize_widgets = true; this.widgets = this.widgets || []; this.resizable = true; const ui = new GridLoraLoaderUI(this); this.gridLoraUI = ui; const nodeRef = this; const widget = this.addDOMWidget("grid_lora_ui", "GridLoraLoader", ui.root, { serialize: false, hideOnZoom: false, }); this.gridLoraWidget = widget; widget.computeSize = function (width) { const nodeWidth = nodeRef.size?.[0] || width || DEFAULT_WIDTH; return [Math.max(MIN_WIDTH, nodeWidth - 20), DEFAULT_CONTENT_HEIGHT]; }; ui.refreshBrowse(); ui.renderSelected(); ui.renderTags(); if (this._pendingLoraValues) { ui.loadFromWidgetValues(this._pendingLoraValues, this._pendingProperties); this._pendingLoraValues = null; this._pendingProperties = null; } requestAnimationFrame(() => { applyDefaultNodeSize(this); if (!this.gridLoraWidget?.last_y) { requestAnimationFrame(() => applyDefaultNodeSize(this)); } }); return result; }; const onResize = nodeType.prototype.onResize; nodeType.prototype.onResize = function (size) { if (onResize) { onResize.apply(this, arguments); } syncLayoutToNode(this, size?.[1]); }; const onDrawForeground = nodeType.prototype.onDrawForeground; nodeType.prototype.onDrawForeground = function (ctx) { if (onDrawForeground) { onDrawForeground.apply(this, arguments); } syncLayoutToNode(this); }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (info) { const result = onConfigure ? onConfigure.apply(this, arguments) : undefined; const loraValues = (info.widgets_values || []).filter( (v) => v && typeof v === "object" && v.lora !== undefined ); if (this.gridLoraUI) { this.gridLoraUI.loadFromWidgetValues(loraValues, info.properties); requestAnimationFrame(() => applyDefaultNodeSize(this)); } else { this._pendingLoraValues = loraValues; this._pendingProperties = info.properties; } return result; }; const origConfigure = nodeType.prototype.configure; nodeType.prototype.configure = function (info) { gridLog(this, "configure", { widgetValueCount: info?.widgets_values?.length ?? 0, }); if (origConfigure) { origConfigure.apply(this, arguments); } const loraValues = (info.widgets_values || []).filter( (v) => v && typeof v === "object" && v.lora !== undefined ); if (this.gridLoraUI) { this.gridLoraUI.loadFromWidgetValues(loraValues, info.properties); requestAnimationFrame(() => applyDefaultNodeSize(this)); } else { this._pendingLoraValues = loraValues; this._pendingProperties = info.properties; } }; const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { if (onRemoved) onRemoved.apply(this, arguments); }; }, });