Support optional companion txt files named lora.Variant.txt alongside the default lora.txt, with toggles in the Grid and List LoRA loader settings dialog to merge character/concept tags into the active trigger list. Co-authored-by: Cursor <cursoragent@cursor.com>
1463 lines
47 KiB
JavaScript
1463 lines
47 KiB
JavaScript
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 = `
|
|
<div class="panel browse-panel">
|
|
<div class="panel-header">
|
|
<span class="panel-label">Browse</span>
|
|
<div class="breadcrumbs"></div>
|
|
<div class="header-controls">
|
|
<label class="sfw-switch" title="Hide NSFW folders and their contents at any level">
|
|
<span class="sfw-label">SFW</span>
|
|
<span class="switch-track">
|
|
<input type="checkbox" class="sfw-checkbox" />
|
|
<span class="switch-thumb"></span>
|
|
</span>
|
|
</label>
|
|
<div class="size-control" title="Thumbnail size">
|
|
<span class="size-label">Size</span>
|
|
<input type="range" class="size-slider"
|
|
min="${GRID_CELL_MIN}" max="${GRID_CELL_MAX}" step="${GRID_CELL_STEP}" />
|
|
</div>
|
|
<input class="search-box" type="text" placeholder="Search..." />
|
|
</div>
|
|
</div>
|
|
<div class="grid-scroll browse">
|
|
<div class="grid browse-grid"></div>
|
|
</div>
|
|
</div>
|
|
<div class="panel selected-panel">
|
|
<div class="panel-header">
|
|
<span class="panel-label">Selected LoRAs</span>
|
|
</div>
|
|
<div class="grid-scroll selected">
|
|
<div class="grid selected-grid"></div>
|
|
</div>
|
|
</div>
|
|
<div class="panel tags-panel">
|
|
<div class="panel-header">
|
|
<span class="panel-label">Trigger Tags</span>
|
|
</div>
|
|
<div class="tags-scroll">
|
|
<div class="tags-list"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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 = `<div class="empty-msg">No LoRAs in this folder</div>`;
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
this.browseGridEl.appendChild(this.createBrowseItem(item));
|
|
}
|
|
} catch (err) {
|
|
this.browseGridEl.innerHTML = `<div class="empty-msg">Failed to load folder</div>`;
|
|
console.error("[GridLoraLoader]", err);
|
|
}
|
|
}
|
|
|
|
async renderSearch() {
|
|
this.breadcrumbsEl.innerHTML = `<span style="color:#888">Search results</span>`;
|
|
this.browseGridEl.innerHTML = `<div class="empty-msg">Searching...</div>`;
|
|
|
|
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 = `<div class="empty-msg">No matches found</div>`;
|
|
return;
|
|
}
|
|
|
|
for (const lora of loras) {
|
|
this.browseGridEl.appendChild(this.createBrowseItem({ type: "lora", ...lora, showFolder: true }));
|
|
}
|
|
} catch (err) {
|
|
this.browseGridEl.innerHTML = `<div class="empty-msg">Search failed</div>`;
|
|
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 = `
|
|
<div class="folder-icon">📁</div>
|
|
<div class="item-label" title="${item.name}">${item.name}</div>
|
|
`;
|
|
el.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.setBrowsePath(item.path);
|
|
});
|
|
} else {
|
|
const imgHtml = item.hasPreview
|
|
? `<img alt="" loading="lazy" />`
|
|
: `<div class="placeholder">🎨</div>`;
|
|
el.innerHTML = `
|
|
${imgHtml}
|
|
<div class="item-label" title="${triggerTitle(item)}">${item.name}</div>
|
|
`;
|
|
if (item.showFolder && item.folder) {
|
|
const label = el.querySelector(".item-label");
|
|
label.innerHTML = `${item.name}<div class="search-folder">${item.folder}</div>`;
|
|
}
|
|
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 = `<div class="empty-msg">Double-click LoRAs above to add them</div>`;
|
|
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 = `
|
|
<img alt="" loading="lazy" />
|
|
<span class="strength-badge" title="LoRA strength">${formatStrength(entry.strength)}</span>
|
|
<button class="cog-btn" title="LoRA settings">⚙</button>
|
|
<div class="item-label" title="${formatTriggers(formatEnabledTriggers(entry)) || entry.lora}">${entry.name || entry.lora.split("/").pop()}</div>
|
|
`;
|
|
|
|
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);
|
|
};
|
|
},
|
|
});
|