refactor: move monolithic link dialog methods into domain-based modules
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
import { app } from "../../../../../scripts/app.js";
|
||||
import { api } from "../../../../../scripts/api.js";
|
||||
import { $el } from "../../../../../scripts/ui.js";
|
||||
import { getSvgIcon } from "../../utils/icon_utils.js";
|
||||
export const dialogShellMethods = {
|
||||
createHeader() {
|
||||
// Create tabs
|
||||
this.missingTab = $el("button.ml-tab.ml-tab-active", {
|
||||
onclick: () => this.switchTab('missing')
|
||||
}, [$el("span.ml-tab-label", { textContent: "Missing Models" })]);
|
||||
|
||||
this.loadedTab = $el("button.ml-tab", {
|
||||
onclick: () => this.switchTab('loaded')
|
||||
}, [$el("span.ml-tab-label", { textContent: "Loaded Models" })]);
|
||||
|
||||
this.optionsTab = $el("button.ml-tab", {
|
||||
onclick: () => this.switchTab('options')
|
||||
}, [$el("span.ml-tab-label", { textContent: "Options" })]);
|
||||
if (this.activeTab === 'missing') {
|
||||
this.updateTabButtonStates();
|
||||
}
|
||||
|
||||
const dragHandle = $el("div", {
|
||||
id: "model-linker-drag-handle",
|
||||
ondragstart: (e) => e.preventDefault()
|
||||
}, [
|
||||
$el("span", { textContent: "⠿" })
|
||||
]);
|
||||
this.setTooltip(dragHandle, "Drag window");
|
||||
|
||||
const fullscreenButton = $el("button", {
|
||||
id: "model-linker-fullscreen-toggle",
|
||||
className: "ml-window-btn ml-window-btn--fullscreen",
|
||||
innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'),
|
||||
ariaLabel: "Toggle full screen",
|
||||
onclick: () => this.toggleFullScreen()
|
||||
});
|
||||
this.setTooltip(fullscreenButton, "Toggle full screen");
|
||||
|
||||
this.dockButton = $el("button", {
|
||||
id: "model-linker-dock-toggle",
|
||||
className: "ml-window-btn ml-window-btn--dock",
|
||||
innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'),
|
||||
ariaLabel: "Dock Model Linker to sidebar",
|
||||
onclick: () => this.dockToSidebar()
|
||||
});
|
||||
this.setTooltip(this.dockButton, "Dock to sidebar");
|
||||
|
||||
this.undockButton = $el("button", {
|
||||
id: "model-linker-undock-toggle",
|
||||
className: "ml-window-btn ml-window-btn--undock",
|
||||
innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'),
|
||||
ariaLabel: "Undock Model Linker",
|
||||
onclick: () => this.undockToFloating()
|
||||
});
|
||||
this.setTooltip(this.undockButton, "Undock to floating window");
|
||||
|
||||
return $el("div.ml-dialog-shell", {}, [
|
||||
$el("div.ml-dialog-topbar", {}, [
|
||||
$el("div.ml-dialog-brand", {}, [
|
||||
dragHandle,
|
||||
$el("div.ml-tabs", {}, [
|
||||
this.missingTab,
|
||||
this.loadedTab,
|
||||
this.optionsTab
|
||||
])
|
||||
]),
|
||||
$el("div.ml-dialog-controls", {}, [
|
||||
this.dockButton,
|
||||
this.undockButton,
|
||||
fullscreenButton,
|
||||
$el("button", {
|
||||
className: "ml-window-btn ml-window-btn--close",
|
||||
innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'),
|
||||
ariaLabel: "Close Model Linker",
|
||||
onclick: () => this.close()
|
||||
})
|
||||
])
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
captureFloatingRect() {
|
||||
if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return;
|
||||
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
|
||||
this._floatingRectBeforeDock = {
|
||||
top: Math.round(rect.top),
|
||||
left: Math.round(rect.left),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
};
|
||||
},
|
||||
|
||||
clearModalPlacementStyles() {
|
||||
if (!this.element) return;
|
||||
|
||||
[
|
||||
'position',
|
||||
'top',
|
||||
'left',
|
||||
'right',
|
||||
'bottom',
|
||||
'inset',
|
||||
'transform',
|
||||
'width',
|
||||
'height',
|
||||
'maxWidth',
|
||||
'maxHeight',
|
||||
'minWidth',
|
||||
'minHeight',
|
||||
'borderRadius',
|
||||
'resize'
|
||||
].forEach((property) => {
|
||||
this.element.style[property] = '';
|
||||
});
|
||||
},
|
||||
|
||||
restoreFloatingGeometry() {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
|
||||
el.style.width = '1100px';
|
||||
el.style.height = '700px';
|
||||
el.style.maxWidth = '100vw';
|
||||
el.style.maxHeight = '100vh';
|
||||
el.style.minWidth = '640px';
|
||||
el.style.minHeight = '420px';
|
||||
el.style.resize = 'both';
|
||||
el.style.borderRadius = '7px';
|
||||
|
||||
const rect = this._floatingRectBeforeDock;
|
||||
if (rect?.width && rect?.height) {
|
||||
el.style.width = `${rect.width}px`;
|
||||
el.style.height = `${rect.height}px`;
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.transform = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null');
|
||||
if (wh?.w && wh?.h) {
|
||||
el.style.width = `${wh.w}px`;
|
||||
el.style.height = `${wh.h}px`;
|
||||
}
|
||||
|
||||
const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
|
||||
if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
|
||||
el.style.top = `${pos.top}px`;
|
||||
el.style.left = `${pos.left}px`;
|
||||
el.style.transform = 'none';
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to centered floating geometry below.
|
||||
}
|
||||
|
||||
el.style.top = '50%';
|
||||
el.style.left = '50%';
|
||||
el.style.transform = 'translate(-50%, -50%)';
|
||||
},
|
||||
|
||||
rememberSidebarOpenMode(mode) {
|
||||
try {
|
||||
localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked');
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
shouldOpenFromSidebarFloating() {
|
||||
try {
|
||||
return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isVisible() {
|
||||
if (!this.element?.isConnected) return false;
|
||||
return getComputedStyle(this.element).display !== 'none';
|
||||
},
|
||||
|
||||
dockTo(container) {
|
||||
if (!container || !this.element) return;
|
||||
|
||||
if (!this.docked) {
|
||||
this.captureFloatingRect();
|
||||
}
|
||||
|
||||
if (this.fullscreen) {
|
||||
this.setFullScreen(false);
|
||||
}
|
||||
|
||||
this.docked = true;
|
||||
this.dockContainer = container;
|
||||
this.lastDockContainer = container;
|
||||
this.pendingDockToSidebar = false;
|
||||
this.rememberSidebarOpenMode('docked');
|
||||
this.backdrop.style.display = 'none';
|
||||
container.classList.add('ml-sidebar-dock-panel');
|
||||
this.element.classList.add('ml-is-docked');
|
||||
this.clearModalPlacementStyles();
|
||||
this.element.style.display = 'flex';
|
||||
|
||||
if (this.element.parentNode !== container) {
|
||||
container.appendChild(this.element);
|
||||
}
|
||||
},
|
||||
|
||||
dockToSidebar() {
|
||||
if (this.docked) return;
|
||||
|
||||
this.rememberSidebarOpenMode('docked');
|
||||
this.pendingDockToSidebar = true;
|
||||
|
||||
if (this.isUsableDockContainer(this.lastDockContainer)) {
|
||||
this.dockTo(this.lastDockContainer);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryOpenComfySidebarState();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.pendingDockToSidebar || this.docked) return;
|
||||
|
||||
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
|
||||
if (button instanceof HTMLElement) {
|
||||
const isActive = button.matches([
|
||||
'[aria-pressed="true"]',
|
||||
'[aria-selected="true"]',
|
||||
'[data-active="true"]',
|
||||
'[data-selected="true"]',
|
||||
'.active',
|
||||
'.is-active',
|
||||
'.selected',
|
||||
'.p-highlight'
|
||||
].join(','));
|
||||
|
||||
if (!isActive) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
isUsableDockContainer(container) {
|
||||
if (!(container instanceof HTMLElement) || !container.isConnected) return false;
|
||||
|
||||
const style = getComputedStyle(container);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
},
|
||||
|
||||
trySetSidebarStateProperty(target, property, value) {
|
||||
try {
|
||||
if (!(property in target)) return false;
|
||||
return Reflect.set(target, property, value);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
tryOpenComfySidebarState() {
|
||||
const extensionManager = app.extensionManager;
|
||||
let opened = false;
|
||||
const candidates = [
|
||||
extensionManager?.sidebarTab,
|
||||
extensionManager?.sidebarTabs,
|
||||
extensionManager?.sidebar,
|
||||
extensionManager
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (typeof candidate.openSidebar === 'function') {
|
||||
candidate.openSidebar(this.sidebarTabId);
|
||||
opened = true;
|
||||
}
|
||||
if (typeof candidate.openSidebarTab === 'function') {
|
||||
candidate.openSidebarTab(this.sidebarTabId);
|
||||
opened = true;
|
||||
}
|
||||
if (typeof candidate.setActiveSidebarTab === 'function') {
|
||||
candidate.setActiveSidebarTab(this.sidebarTabId);
|
||||
opened = true;
|
||||
}
|
||||
if (typeof candidate.setActiveSidebarTabId === 'function') {
|
||||
candidate.setActiveSidebarTabId(this.sidebarTabId);
|
||||
opened = true;
|
||||
}
|
||||
if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) {
|
||||
opened = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
return opened;
|
||||
},
|
||||
|
||||
undockToFloating({ persist = true, closeSidebar = true } = {}) {
|
||||
if (!this.element) return;
|
||||
|
||||
const wasDocked = this.docked;
|
||||
const dockContainer = this.dockContainer;
|
||||
this.docked = false;
|
||||
this.dockContainer = null;
|
||||
this.element.classList.remove('ml-is-docked');
|
||||
this.clearModalPlacementStyles();
|
||||
|
||||
if (this.element.parentNode !== document.body) {
|
||||
document.body.appendChild(this.element);
|
||||
}
|
||||
|
||||
this.element.style.display = 'flex';
|
||||
this.restoreFloatingGeometry();
|
||||
this.ensureModalHandleInViewport({ persist });
|
||||
|
||||
if (wasDocked && closeSidebar) {
|
||||
this.closeComfySidebar(dockContainer);
|
||||
}
|
||||
},
|
||||
|
||||
closeComfySidebar(dockContainer = null) {
|
||||
const closedByState = this.tryCloseComfySidebarState();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (dockContainer && !dockContainer.isConnected) return;
|
||||
|
||||
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
|
||||
if (!(button instanceof HTMLElement)) return;
|
||||
|
||||
const isActive = button.matches([
|
||||
'[aria-pressed="true"]',
|
||||
'[aria-selected="true"]',
|
||||
'[data-active="true"]',
|
||||
'[data-selected="true"]',
|
||||
'.active',
|
||||
'.is-active',
|
||||
'.selected',
|
||||
'.p-highlight'
|
||||
].join(','));
|
||||
|
||||
if (!closedByState || isActive) {
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
tryCloseComfySidebarState() {
|
||||
const extensionManager = app.extensionManager;
|
||||
let closed = false;
|
||||
const candidates = [
|
||||
extensionManager?.sidebarTab,
|
||||
extensionManager?.sidebarTabs,
|
||||
extensionManager?.sidebar,
|
||||
extensionManager
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (typeof candidate.closeSidebar === 'function') {
|
||||
candidate.closeSidebar();
|
||||
closed = true;
|
||||
}
|
||||
if (typeof candidate.closeSidebarTab === 'function') {
|
||||
candidate.closeSidebarTab(this.sidebarTabId);
|
||||
closed = true;
|
||||
}
|
||||
if (typeof candidate.setActiveSidebarTab === 'function') {
|
||||
candidate.setActiveSidebarTab(null);
|
||||
closed = true;
|
||||
}
|
||||
if (typeof candidate.setActiveSidebarTabId === 'function') {
|
||||
candidate.setActiveSidebarTabId(null);
|
||||
closed = true;
|
||||
}
|
||||
if (
|
||||
this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) ||
|
||||
this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null)
|
||||
) {
|
||||
closed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
return closed;
|
||||
},
|
||||
|
||||
// Toggle full screen mode for the dialog
|
||||
toggleFullScreen() {
|
||||
this.setFullScreen(!this.fullscreen);
|
||||
},
|
||||
|
||||
setFullScreen(enable) {
|
||||
const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen;
|
||||
if (enable && this.docked) {
|
||||
this.returnToDockedAfterFullscreen = true;
|
||||
this.undockToFloating({ persist: false });
|
||||
} else if (enable) {
|
||||
this.returnToDockedAfterFullscreen = false;
|
||||
}
|
||||
|
||||
this.fullscreen = !!enable;
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
const btn = document.getElementById('model-linker-fullscreen-toggle');
|
||||
if (enable) {
|
||||
// Save current size
|
||||
try {
|
||||
const rect = el.getBoundingClientRect();
|
||||
localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) }));
|
||||
} catch (e) {}
|
||||
el.style.top = '0';
|
||||
el.style.left = '0';
|
||||
el.style.transform = 'none';
|
||||
el.style.width = '100vw';
|
||||
el.style.height = '100vh';
|
||||
el.style.maxWidth = '100vw';
|
||||
el.style.maxHeight = '100vh';
|
||||
el.style.borderRadius = '0';
|
||||
el.style.resize = 'none';
|
||||
if (btn) {
|
||||
btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon');
|
||||
btn.setAttribute('aria-label', 'Exit full screen');
|
||||
this.setTooltip(btn, 'Exit full screen');
|
||||
}
|
||||
try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
|
||||
} else {
|
||||
this.returnToDockedAfterFullscreen = false;
|
||||
// Restore centered sizing
|
||||
el.style.maxWidth = '100vw';
|
||||
el.style.maxHeight = '100vh';
|
||||
el.style.borderRadius = '8px';
|
||||
el.style.resize = 'both';
|
||||
// Restore saved pre-FS size if available
|
||||
let wh = null;
|
||||
try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {}
|
||||
if (wh && wh.w && wh.h) {
|
||||
el.style.width = `${wh.w}px`;
|
||||
el.style.height = `${wh.h}px`;
|
||||
} else {
|
||||
el.style.width = '1100px';
|
||||
el.style.height = '700px';
|
||||
}
|
||||
// Restore last known position if available, else center
|
||||
try {
|
||||
const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
|
||||
if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
|
||||
el.style.top = `${pos.top}px`;
|
||||
el.style.left = `${pos.left}px`;
|
||||
el.style.transform = 'none';
|
||||
} else {
|
||||
el.style.top = '50%';
|
||||
el.style.left = '50%';
|
||||
el.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
} catch (e) {
|
||||
el.style.top = '50%';
|
||||
el.style.left = '50%';
|
||||
el.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
if (btn) {
|
||||
btn.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon');
|
||||
btn.setAttribute('aria-label', 'Enter full screen');
|
||||
this.setTooltip(btn, 'Enter full screen');
|
||||
}
|
||||
this.ensureModalHandleInViewport({ persist: true });
|
||||
try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
|
||||
|
||||
if (shouldReturnToDocked) {
|
||||
this.dockToSidebar();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getViewportClampedModalPosition(top, left) {
|
||||
const el = this.element;
|
||||
if (!el) return { top, left };
|
||||
|
||||
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const pad = 4;
|
||||
const handle = document.getElementById('model-linker-drag-handle');
|
||||
|
||||
if (handle) {
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const handleRect = handle.getBoundingClientRect();
|
||||
const handleOffsetLeft = handleRect.left - elRect.left;
|
||||
const handleOffsetTop = handleRect.top - elRect.top;
|
||||
const handleWidth = handleRect.width || handle.offsetWidth;
|
||||
const handleHeight = handleRect.height || handle.offsetHeight;
|
||||
const minLeft = pad - handleOffsetLeft;
|
||||
const maxLeft = vw - pad - handleOffsetLeft - handleWidth;
|
||||
const minTop = pad - handleOffsetTop;
|
||||
const maxTop = vh - pad - handleOffsetTop - handleHeight;
|
||||
|
||||
left = Math.max(minLeft, Math.min(maxLeft, left));
|
||||
top = Math.max(minTop, Math.min(maxTop, top));
|
||||
} else {
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
left = Math.max(-w + pad, Math.min(vw - pad, left));
|
||||
top = Math.max(-h + pad, Math.min(vh - pad, top));
|
||||
}
|
||||
|
||||
return { top, left };
|
||||
},
|
||||
|
||||
saveModalPosition() {
|
||||
if (this.docked) return;
|
||||
|
||||
try {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) }));
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
ensureModalHandleInViewport({ persist = false } = {}) {
|
||||
if (this.docked) return;
|
||||
if (this.fullscreen) return;
|
||||
const el = this.element;
|
||||
if (!el || getComputedStyle(el).display === 'none') return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left);
|
||||
const nextTop = Math.round(top);
|
||||
const nextLeft = Math.round(left);
|
||||
|
||||
if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return;
|
||||
|
||||
el.style.top = `${nextTop}px`;
|
||||
el.style.left = `${nextLeft}px`;
|
||||
el.style.transform = 'none';
|
||||
|
||||
if (persist) this.saveModalPosition();
|
||||
},
|
||||
|
||||
scheduleModalViewportClamp(persist = false) {
|
||||
if (this._viewportClampFrame) {
|
||||
cancelAnimationFrame(this._viewportClampFrame);
|
||||
}
|
||||
|
||||
this._viewportClampFrame = requestAnimationFrame(() => {
|
||||
this._viewportClampFrame = null;
|
||||
this.ensureModalHandleInViewport({ persist });
|
||||
});
|
||||
},
|
||||
|
||||
// Begin window drag
|
||||
startDrag(e) {
|
||||
if (this.docked) return;
|
||||
|
||||
try {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
// Switch to absolute top/left (no transform) before dragging
|
||||
el.style.top = `${rect.top}px`;
|
||||
el.style.left = `${rect.left}px`;
|
||||
el.style.transform = 'none';
|
||||
this._dragging = true;
|
||||
this._dragStart = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
top: rect.top,
|
||||
left: rect.left
|
||||
};
|
||||
// Prevent text selection while dragging
|
||||
this._prevUserSelect = document.body.style.userSelect;
|
||||
document.body.style.userSelect = 'none';
|
||||
// Attach listeners
|
||||
this._onMouseMove = (ev) => this.onDrag(ev);
|
||||
this._onMouseUp = () => this.endDrag();
|
||||
document.addEventListener('mousemove', this._onMouseMove);
|
||||
document.addEventListener('mouseup', this._onMouseUp, { once: true });
|
||||
} catch (err) { /* ignore */ }
|
||||
},
|
||||
|
||||
onDrag(e) {
|
||||
if (!this._dragging || !this._dragStart) return;
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
const dx = e.clientX - this._dragStart.x;
|
||||
const dy = e.clientY - this._dragStart.y;
|
||||
let top = this._dragStart.top + dy;
|
||||
let left = this._dragStart.left + dx;
|
||||
({ top, left } = this.getViewportClampedModalPosition(top, left));
|
||||
el.style.top = `${Math.round(top)}px`;
|
||||
el.style.left = `${Math.round(left)}px`;
|
||||
},
|
||||
|
||||
endDrag() {
|
||||
if (!this._dragging) return;
|
||||
this._dragging = false;
|
||||
document.removeEventListener('mousemove', this._onMouseMove);
|
||||
// Persist position
|
||||
this.saveModalPosition();
|
||||
// Restore selection
|
||||
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {}
|
||||
},
|
||||
|
||||
/**
|
||||
* Simple debounce helper
|
||||
*/
|
||||
debounce(callback, wait = 250) {
|
||||
let t = null;
|
||||
return (...args) => {
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(() => {
|
||||
callback.apply(this, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import { app } from "../../../../../scripts/app.js";
|
||||
import { api } from "../../../../../scripts/api.js";
|
||||
import { $el } from "../../../../../scripts/ui.js";
|
||||
import { getSvgIcon } from "../../utils/icon_utils.js";
|
||||
export const lifecycleGraphMethods = {
|
||||
async show(workflow = null) {
|
||||
this.undockToFloating({ persist: false });
|
||||
await this.showContent(workflow, { restoreFullscreen: true });
|
||||
},
|
||||
|
||||
async showDocked(container, workflow = null) {
|
||||
this.dockTo(container);
|
||||
await this.showContent(workflow, { restoreFullscreen: false });
|
||||
},
|
||||
|
||||
async showContent(workflow = null, { restoreFullscreen = true } = {}) {
|
||||
this.backdrop.style.display = "none";
|
||||
this.element.style.display = "flex";
|
||||
if (!this.docked) {
|
||||
this.scheduleModalViewportClamp();
|
||||
}
|
||||
|
||||
// Update button state in case there are active downloads
|
||||
this.updateDownloadAllButtonState();
|
||||
|
||||
// Ensure all models are loaded for dropdown
|
||||
await this.ensureCapabilitiesLoaded();
|
||||
await this.ensureAllModelsLoaded();
|
||||
await this.ensureDownloadDirectoriesLoaded();
|
||||
|
||||
// Restore fullscreen state if enabled
|
||||
try {
|
||||
const fs = localStorage.getItem('model_linker_modal_fullscreen');
|
||||
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
|
||||
} catch (e) { }
|
||||
|
||||
// Attach drag handle event listener (only once)
|
||||
this.attachDragHandleIfNeeded();
|
||||
|
||||
this.activeTab = this.restoreActiveTab();
|
||||
this.updateTabButtonStates();
|
||||
this.updateQueueVisibility();
|
||||
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
|
||||
|
||||
if (this.activeTab === 'missing') {
|
||||
await this.loadWorkflowData(workflow);
|
||||
} else if (this.activeTab === 'loaded') {
|
||||
this.switchTab('loaded', { force: true });
|
||||
} else {
|
||||
this.switchTab('options', { force: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Attach topbar drag listeners while preserving the handle-based viewport clamp.
|
||||
attachDragHandleIfNeeded() {
|
||||
if (this._dragHandleAttached) return;
|
||||
|
||||
const handle = document.getElementById('model-linker-drag-handle');
|
||||
const topbar = this.element?.querySelector('.ml-dialog-topbar');
|
||||
if (!handle || !topbar) return;
|
||||
|
||||
const onMouseDown = (e) => {
|
||||
if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
|
||||
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
|
||||
topbar.classList.add('ml-is-dragging');
|
||||
this.startDrag(e);
|
||||
};
|
||||
const onMouseUp = () => { topbar.classList.remove('ml-is-dragging'); };
|
||||
|
||||
topbar.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
this._dragHandleAttached = true;
|
||||
},
|
||||
|
||||
isTopbarDragExcluded(target) {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return !!target.closest([
|
||||
'.ml-tabs',
|
||||
'.ml-dialog-controls',
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'[role="button"]',
|
||||
'[data-ml-no-drag]'
|
||||
].join(','));
|
||||
},
|
||||
|
||||
close({ collapseSidebar = true } = {}) {
|
||||
const wasDocked = this.docked;
|
||||
const dockContainer = this.dockContainer;
|
||||
this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating');
|
||||
this._hidePreview?.();
|
||||
this.hideTooltip();
|
||||
this.backdrop.style.display = "none";
|
||||
this.element.style.display = "none";
|
||||
|
||||
if (wasDocked && collapseSidebar) {
|
||||
this.closeComfySidebar(dockContainer);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load workflow data and display missing models
|
||||
*/
|
||||
async loadWorkflowData(workflow = null) {
|
||||
if (!this.contentElement) return;
|
||||
|
||||
// Show loading state
|
||||
try {
|
||||
// Use provided workflow, or get current workflow from ComfyUI
|
||||
if (!workflow) {
|
||||
workflow = this.getCurrentWorkflow();
|
||||
}
|
||||
|
||||
if (!workflow) {
|
||||
this._analysisProgressToken = null;
|
||||
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
|
||||
return;
|
||||
}
|
||||
this.syncWorkflowScopedQueue(workflow);
|
||||
|
||||
const workflowSignature = this.getWorkflowSignature(workflow);
|
||||
if (
|
||||
workflowSignature &&
|
||||
this.cachedWorkflowSignature === workflowSignature &&
|
||||
this.cachedAnalysisData
|
||||
) {
|
||||
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
|
||||
this.reconnectActiveDownloads();
|
||||
return;
|
||||
}
|
||||
|
||||
const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this._analysisProgressToken = analysisId;
|
||||
this.contentElement.innerHTML = this.renderAnalysisProgress({
|
||||
status: 'starting',
|
||||
message: 'Starting analysis...',
|
||||
current: 0,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Call analyze endpoint
|
||||
const progressPromise = this.pollAnalysisProgress(analysisId, analysisId);
|
||||
const response = await api.fetchApi('/model_linker/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workflow, analysis_id: analysisId })
|
||||
});
|
||||
this._analysisProgressToken = null;
|
||||
await progressPromise;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.cachedWorkflowSignature = workflowSignature;
|
||||
this.cachedAnalysisData = data;
|
||||
this.displayMissingModels(this.contentElement, data);
|
||||
|
||||
// Reconnect any active downloads to their new progress divs
|
||||
this.reconnectActiveDownloads();
|
||||
|
||||
} catch (error) {
|
||||
this._analysisProgressToken = null;
|
||||
console.error('Model Linker: Error loading workflow data:', error);
|
||||
if (this.contentElement) {
|
||||
this.contentElement.innerHTML = `<p class="ml-error-text">Error: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current workflow from ComfyUI
|
||||
*/
|
||||
getCurrentWorkflow() {
|
||||
// Try to get workflow from app
|
||||
if (app?.graph) {
|
||||
try {
|
||||
// Use ComfyUI's workflow serialization
|
||||
const workflow = app.graph.serialize();
|
||||
return workflow;
|
||||
} catch (e) {
|
||||
console.warn('Model Linker: Could not serialize workflow from graph:', e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Locate and focus a node in the ComfyUI canvas
|
||||
*/
|
||||
locateNodeInGraph(nodeId) {
|
||||
try {
|
||||
if (!app?.graph) {
|
||||
this.showNotification('Cannot locate node - graph not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the node in the graph
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let locatedWithAnimation = false;
|
||||
|
||||
// Focus on the node in the canvas with animated panning when possible
|
||||
if (app.canvas?.ds && app.canvas?.canvas) {
|
||||
locatedWithAnimation = this.animateCanvasToNode(node);
|
||||
}
|
||||
|
||||
if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) {
|
||||
// Alternative method for older versions
|
||||
const canvasNode = app.graph._nodes.get(nodeId);
|
||||
if (canvasNode && canvasNode.setSelected && canvasNode.graph) {
|
||||
canvasNode.setSelected(true);
|
||||
// Scroll to node
|
||||
app.canvas.scrollToNode(canvasNode);
|
||||
}
|
||||
} else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') {
|
||||
// Alternative for other versions
|
||||
app.ui.nodeGraph.scrollToNode(node);
|
||||
} else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') {
|
||||
// Final fallback for versions where animation path is unavailable
|
||||
app.canvas.centerOnNode(node);
|
||||
}
|
||||
|
||||
// Also try to flash/select the node
|
||||
// Deselect all nodes first
|
||||
if (app.graph && app.graph.nodes) {
|
||||
app.graph.nodes.forEach(n => {
|
||||
if (n.selected) n.selected = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Select and highlight our node
|
||||
node.selected = true;
|
||||
|
||||
this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info');
|
||||
} catch (e) {
|
||||
console.error('Model Linker: Error locating node:', e);
|
||||
this.showNotification('Error locating node: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
animateCanvasToNode(node) {
|
||||
const canvas = app?.canvas;
|
||||
const ds = canvas?.ds;
|
||||
const htmlCanvas = canvas?.canvas;
|
||||
if (!canvas || !ds || !htmlCanvas) return false;
|
||||
|
||||
const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0];
|
||||
const centerX = rect[0] + (rect[2] || 0) / 2;
|
||||
const centerY = rect[1] + (rect[3] || 0) / 2;
|
||||
const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1;
|
||||
const locateViewport = this.getLocateViewport(htmlCanvas);
|
||||
const viewportWidth = locateViewport.width;
|
||||
const viewportHeight = locateViewport.height;
|
||||
|
||||
if (!viewportWidth || !viewportHeight) return false;
|
||||
|
||||
const nodeWidth = Math.max(rect[2] || 0, 1);
|
||||
const nodeHeight = Math.max(rect[3] || 0, 1);
|
||||
const paddingX = 36;
|
||||
const paddingY = 32;
|
||||
const maxLocateScale = 2.5;
|
||||
const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2));
|
||||
const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2));
|
||||
const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY));
|
||||
|
||||
const getCenteredOffset = (scale) => {
|
||||
return {
|
||||
x: -centerX + (locateViewport.centerX / scale),
|
||||
y: -centerY + (locateViewport.centerY / scale)
|
||||
};
|
||||
};
|
||||
|
||||
const startOffset = {
|
||||
x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x,
|
||||
y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y
|
||||
};
|
||||
const targetOffset = getCenteredOffset(targetScale);
|
||||
|
||||
if (this._locateAnimationFrame) {
|
||||
cancelAnimationFrame(this._locateAnimationFrame);
|
||||
this._locateAnimationFrame = null;
|
||||
}
|
||||
|
||||
const durationMs = 380;
|
||||
const startTime = performance.now();
|
||||
const easeInOutCubic = (t) => t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
const tick = (now) => {
|
||||
const progress = Math.min(1, (now - startTime) / durationMs);
|
||||
const eased = easeInOutCubic(progress);
|
||||
|
||||
ds.scale = startScale + ((targetScale - startScale) * eased);
|
||||
ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased);
|
||||
ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased);
|
||||
canvas.setDirty?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
|
||||
if (progress < 1) {
|
||||
this._locateAnimationFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
this._locateAnimationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
this._locateAnimationFrame = requestAnimationFrame(tick);
|
||||
return true;
|
||||
},
|
||||
|
||||
getLocateViewport(htmlCanvas) {
|
||||
const canvasRect = htmlCanvas.getBoundingClientRect?.();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr);
|
||||
const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr);
|
||||
const viewport = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: canvasWidth,
|
||||
bottom: canvasHeight
|
||||
};
|
||||
|
||||
if (this.docked && canvasRect?.width && canvasRect?.height) {
|
||||
const obstructionElement = this.dockContainer instanceof HTMLElement
|
||||
? this.dockContainer
|
||||
: this.element;
|
||||
const obstructionRect = obstructionElement?.getBoundingClientRect?.();
|
||||
if (obstructionRect?.width && obstructionRect?.height) {
|
||||
this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect);
|
||||
}
|
||||
}
|
||||
|
||||
const width = Math.max(1, viewport.right - viewport.left);
|
||||
const height = Math.max(1, viewport.bottom - viewport.top);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
centerX: viewport.left + (width / 2),
|
||||
centerY: viewport.top + (height / 2)
|
||||
};
|
||||
},
|
||||
|
||||
subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) {
|
||||
const obstruction = {
|
||||
left: Math.max(0, obstructionRect.left - canvasRect.left),
|
||||
top: Math.max(0, obstructionRect.top - canvasRect.top),
|
||||
right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left),
|
||||
bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top)
|
||||
};
|
||||
const overlapWidth = obstruction.right - obstruction.left;
|
||||
const overlapHeight = obstruction.bottom - obstruction.top;
|
||||
if (overlapWidth <= 0 || overlapHeight <= 0) return;
|
||||
|
||||
const coversMostHeight = overlapHeight >= canvasRect.height * 0.5;
|
||||
const coversMostWidth = overlapWidth >= canvasRect.width * 0.5;
|
||||
const edgeTolerance = 2;
|
||||
|
||||
if (coversMostHeight) {
|
||||
if (obstruction.left <= edgeTolerance) {
|
||||
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
|
||||
return;
|
||||
}
|
||||
if (obstruction.right >= canvasRect.width - edgeTolerance) {
|
||||
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
|
||||
return;
|
||||
}
|
||||
|
||||
const leftSpace = obstruction.left - viewport.left;
|
||||
const rightSpace = viewport.right - obstruction.right;
|
||||
if (rightSpace >= leftSpace) {
|
||||
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
|
||||
} else {
|
||||
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (coversMostWidth) {
|
||||
if (obstruction.top <= edgeTolerance) {
|
||||
viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom));
|
||||
return;
|
||||
}
|
||||
if (obstruction.bottom >= canvasRect.height - edgeTolerance) {
|
||||
viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reconnect active downloads to their new progress div elements after UI refresh
|
||||
*/
|
||||
reconnectActiveDownloads() {
|
||||
if (!this.contentElement) return;
|
||||
|
||||
for (const [downloadId, info] of Object.entries(this.activeDownloads)) {
|
||||
const { missing } = info;
|
||||
if (!missing) continue;
|
||||
|
||||
// Find the new progress div by ID
|
||||
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
|
||||
const newProgressDiv = this.contentElement.querySelector(`#${progressId}`);
|
||||
const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
|
||||
|
||||
if (newProgressDiv) {
|
||||
// Update the reference
|
||||
info.progressDiv = newProgressDiv;
|
||||
info.downloadBtn = newDownloadBtn;
|
||||
|
||||
// Show that download is in progress
|
||||
newProgressDiv.classList.remove('ml-is-hidden');
|
||||
newProgressDiv.classList.add('ml-is-visible');
|
||||
newProgressDiv.innerHTML = this.renderProgressWithAction({
|
||||
percent: 0,
|
||||
leftText: '<span class="ml-info-accent-text">Downloading...</span>',
|
||||
rightText: '',
|
||||
actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm',
|
||||
actionText: 'Cancel',
|
||||
actionDataAttr: `data-download-id="${downloadId}"`
|
||||
});
|
||||
|
||||
// Attach cancel handler
|
||||
const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||||
}
|
||||
|
||||
// Update download button if exists
|
||||
if (newDownloadBtn) {
|
||||
newDownloadBtn.disabled = true;
|
||||
newDownloadBtn.textContent = 'Downloading...';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getMissingModelKey(missing = {}) {
|
||||
const nodeId = missing.node_id ?? '';
|
||||
const widgetIndex = missing.widget_index ?? '';
|
||||
const subgraphId = missing.subgraph_id || '';
|
||||
const scope = missing.is_top_level !== false ? 'T' : 'F';
|
||||
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { app } from "../../../../../scripts/app.js";
|
||||
import { api } from "../../../../../scripts/api.js";
|
||||
import { $el } from "../../../../../scripts/ui.js";
|
||||
import { getSvgIcon } from "../../utils/icon_utils.js";
|
||||
export const workflowStateMethods = {
|
||||
getWorkflowSignature(workflow) {
|
||||
if (!workflow) return null;
|
||||
try {
|
||||
return JSON.stringify(workflow);
|
||||
} catch (error) {
|
||||
console.warn('Model Linker: workflow signature generation failed', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getActiveWorkflowRouteKey() {
|
||||
try {
|
||||
return window.location?.hash || '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
rememberActiveWorkflow(workflow = null) {
|
||||
const currentWorkflow = workflow || this.getCurrentWorkflow();
|
||||
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
|
||||
this.activeWorkflowSignature = this.getWorkflowSignature(currentWorkflow);
|
||||
},
|
||||
|
||||
getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) {
|
||||
return route || signature || null;
|
||||
},
|
||||
|
||||
clonePendingResolutions(list = this.pendingResolutions) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(Array.isArray(list) ? list : []));
|
||||
} catch (error) {
|
||||
console.warn('Model Linker: failed to clone queued selections', error);
|
||||
return Array.isArray(list) ? list.map(item => ({ ...item })) : [];
|
||||
}
|
||||
},
|
||||
|
||||
savePendingQueueForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
if (!key) return;
|
||||
|
||||
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
||||
if (list.length) {
|
||||
this.workflowPendingSelections.set(key, this.clonePendingResolutions(list));
|
||||
} else {
|
||||
this.workflowPendingSelections.delete(key);
|
||||
}
|
||||
},
|
||||
|
||||
restorePendingQueueForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
const saved = key ? this.workflowPendingSelections.get(key) : null;
|
||||
this.pendingResolutions = saved ? this.clonePendingResolutions(saved) : [];
|
||||
this.rebuildPendingIndex();
|
||||
this.updateApplyPendingButton?.();
|
||||
this.updateQueuePanel?.();
|
||||
this.updateQueueVisibility?.();
|
||||
},
|
||||
|
||||
cloneSearchState(state = {}) {
|
||||
let clone = {};
|
||||
try {
|
||||
clone = JSON.parse(JSON.stringify(state || {}));
|
||||
} catch (error) {
|
||||
console.warn('Model Linker: failed to clone search state', error);
|
||||
clone = { ...state };
|
||||
}
|
||||
|
||||
clone.selectedSource = clone.selectedSource || 'all';
|
||||
clone.results = this.mergeSearchResults({}, clone.results || {});
|
||||
clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources)
|
||||
? clone.lastAttemptSources
|
||||
: [];
|
||||
clone.lastAttemptFound = clone.lastAttemptFound ?? null;
|
||||
clone.lastAttemptError = clone.lastAttemptError || null;
|
||||
clone.sourceProgress = clone.sourceProgress || {};
|
||||
clone.activeSearchRunId = null;
|
||||
|
||||
for (const progress of Object.values(clone.sourceProgress)) {
|
||||
if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue;
|
||||
progress.status = 'error';
|
||||
progress.percent = 100;
|
||||
progress.message = 'Search interrupted';
|
||||
}
|
||||
|
||||
return clone;
|
||||
},
|
||||
|
||||
cloneSearchResultCache(cache = this.searchResultCache) {
|
||||
const cloned = new Map();
|
||||
for (const [key, state] of cache.entries()) {
|
||||
if (!this.isPersistableSearchState(state)) {
|
||||
continue;
|
||||
}
|
||||
cloned.set(key, this.cloneSearchState(state));
|
||||
}
|
||||
return cloned;
|
||||
},
|
||||
|
||||
isPersistableSearchState(state = {}) {
|
||||
if ((state?.selectedSource || 'all') !== 'all') return true;
|
||||
if (state?.lastAttemptError) return true;
|
||||
if (this.hasSearchResults(state?.results || {})) return true;
|
||||
|
||||
const progressEntries = Object.values(state?.sourceProgress || {});
|
||||
return progressEntries.some(progress => (
|
||||
progress?.status && progress.status !== 'pending' && progress.status !== 'running'
|
||||
));
|
||||
},
|
||||
|
||||
saveSearchCacheForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
if (!key) return;
|
||||
|
||||
const cloned = this.cloneSearchResultCache();
|
||||
if (cloned.size) {
|
||||
this.workflowSearchResultCaches.set(key, cloned);
|
||||
} else {
|
||||
this.workflowSearchResultCaches.delete(key);
|
||||
}
|
||||
},
|
||||
|
||||
restoreSearchCacheForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
const saved = key ? this.workflowSearchResultCaches.get(key) : null;
|
||||
this.searchResultCache = saved ? this.cloneSearchResultCache(saved) : new Map();
|
||||
},
|
||||
|
||||
persistSearchStateForActiveWorkflow() {
|
||||
this.saveSearchCacheForActiveWorkflow();
|
||||
},
|
||||
|
||||
clearAllSearchProgressTimers() {
|
||||
for (const timer of this.searchProgressTimers.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.searchProgressTimers.clear();
|
||||
},
|
||||
|
||||
syncWorkflowScopedQueue(workflow = null) {
|
||||
const currentWorkflow = workflow || this.getCurrentWorkflow();
|
||||
const nextRoute = this.getActiveWorkflowRouteKey();
|
||||
const nextSignature = this.getWorkflowSignature(currentWorkflow);
|
||||
const previousKey = this.getWorkflowScopedQueueKey();
|
||||
const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature);
|
||||
|
||||
if (!nextKey || nextKey === previousKey) {
|
||||
this.activeWorkflowRouteKey = nextRoute;
|
||||
this.activeWorkflowSignature = nextSignature;
|
||||
return;
|
||||
}
|
||||
|
||||
this.savePendingQueueForActiveWorkflow();
|
||||
this.saveSearchCacheForActiveWorkflow();
|
||||
this.clearWorkflowScopedState();
|
||||
this.activeWorkflowRouteKey = nextRoute;
|
||||
this.activeWorkflowSignature = nextSignature;
|
||||
this.restorePendingQueueForActiveWorkflow();
|
||||
this.restoreSearchCacheForActiveWorkflow();
|
||||
},
|
||||
|
||||
clearWorkflowScopedState() {
|
||||
this.cachedWorkflowSignature = null;
|
||||
this.cachedAnalysisData = null;
|
||||
for (const state of this.searchResultCache.values()) {
|
||||
state.activeSearchRunId = null;
|
||||
}
|
||||
this.clearAllSearchProgressTimers();
|
||||
this.searchResultCache.clear();
|
||||
this.urnResolvePromises.clear();
|
||||
this.urnLocalMatchPromises.clear();
|
||||
this.pendingResolutions = [];
|
||||
this.rebuildPendingIndex();
|
||||
this.updateApplyPendingButton?.();
|
||||
this.updateQueuePanel?.();
|
||||
this.updateQueueVisibility();
|
||||
},
|
||||
|
||||
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
|
||||
if (!this.isVisible()) return;
|
||||
|
||||
this._workflowRefreshExpectedRoute = this.getActiveWorkflowRouteKey();
|
||||
this._workflowRefreshPreviousSignature = this.activeWorkflowSignature;
|
||||
|
||||
if (this._workflowRefreshTimer) {
|
||||
clearTimeout(this._workflowRefreshTimer);
|
||||
}
|
||||
|
||||
this._workflowRefreshTimer = setTimeout(() => {
|
||||
this._workflowRefreshTimer = null;
|
||||
this.refreshForActiveWorkflowChange({
|
||||
reason,
|
||||
expectedRoute: this._workflowRefreshExpectedRoute,
|
||||
previousSignature: this._workflowRefreshPreviousSignature,
|
||||
attempt: 0
|
||||
});
|
||||
}, 180);
|
||||
},
|
||||
|
||||
async refreshForActiveWorkflowChange({ reason = 'workflow-change', expectedRoute = '', previousSignature = null, attempt = 0 } = {}) {
|
||||
if (!this.isVisible()) return;
|
||||
|
||||
const currentRoute = this.getActiveWorkflowRouteKey();
|
||||
if (expectedRoute && currentRoute !== expectedRoute) {
|
||||
this.scheduleActiveWorkflowRefresh(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const signature = this.getWorkflowSignature(workflow);
|
||||
const routeChanged = currentRoute !== this.activeWorkflowRouteKey;
|
||||
const signatureChanged = signature && signature !== this.activeWorkflowSignature;
|
||||
const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature;
|
||||
|
||||
if ((!signature || graphStillLooksOld) && attempt < 8) {
|
||||
setTimeout(() => {
|
||||
this.refreshForActiveWorkflowChange({
|
||||
reason,
|
||||
expectedRoute,
|
||||
previousSignature,
|
||||
attempt: attempt + 1
|
||||
});
|
||||
}, 180 + (attempt * 120));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeChanged && !signatureChanged) return;
|
||||
|
||||
console.log('Model Linker: active workflow changed, refreshing current tab', {
|
||||
reason,
|
||||
route: currentRoute,
|
||||
tab: this.activeTab
|
||||
});
|
||||
|
||||
this.savePendingQueueForActiveWorkflow();
|
||||
this.saveSearchCacheForActiveWorkflow();
|
||||
this.clearWorkflowScopedState();
|
||||
this.activeWorkflowRouteKey = currentRoute;
|
||||
this.activeWorkflowSignature = signature;
|
||||
this.restorePendingQueueForActiveWorkflow();
|
||||
this.restoreSearchCacheForActiveWorkflow();
|
||||
|
||||
if (this.activeTab === 'missing') {
|
||||
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
|
||||
await this.loadWorkflowData(workflow);
|
||||
} else if (this.activeTab === 'loaded') {
|
||||
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
|
||||
await this.loadLoadedModels(workflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { app } from "../../../../../scripts/app.js";
|
||||
import { api } from "../../../../../scripts/api.js";
|
||||
import { $el } from "../../../../../scripts/ui.js";
|
||||
import { getSvgIcon } from "../../utils/icon_utils.js";
|
||||
export const workflowUpdateMethods = {
|
||||
/**
|
||||
* Extract model page URL from a download URL
|
||||
* HuggingFace file: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo/blob/main/file.safetensors
|
||||
* CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123
|
||||
*/
|
||||
getModelCardUrl(downloadUrl) {
|
||||
if (!downloadUrl) return null;
|
||||
|
||||
try {
|
||||
// HuggingFace URLs
|
||||
if (downloadUrl.includes('huggingface.co')) {
|
||||
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
|
||||
if (fileMatch) {
|
||||
const repo = fileMatch[1];
|
||||
const revision = fileMatch[2];
|
||||
const filePath = fileMatch[3].split(/[?#]/)[0];
|
||||
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
|
||||
}
|
||||
|
||||
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
|
||||
if (match) {
|
||||
return `https://huggingface.co/${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// CivitAI URLs
|
||||
if (downloadUrl.includes('civitai.com')) {
|
||||
// Format: /api/download/models/123456 or /models/123456/...
|
||||
const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
|
||||
if (modelIdMatch) {
|
||||
return `https://civitai.com/models/${modelIdMatch[1]}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing model card URL:', e);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update workflow in ComfyUI's UI/memory
|
||||
* Updates the current workflow in place instead of creating a new tab
|
||||
*/
|
||||
async updateWorkflowInComfyUI(workflow) {
|
||||
if (!app || !app.graph) {
|
||||
console.warn('Model Linker: Could not update workflow - app or app.graph not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Method 1: Try to directly update the current graph using configure
|
||||
// This is the most direct way to update in place
|
||||
if (app.graph && typeof app.graph.configure === 'function') {
|
||||
app.graph.configure(workflow);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 2: Try deserialize to update the graph in place
|
||||
if (app.graph && typeof app.graph.deserialize === 'function') {
|
||||
app.graph.deserialize(workflow);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Use loadGraphData with explicit parameters to update current tab
|
||||
// The key is to NOT create a new workflow - pass null or undefined for the workflow parameter
|
||||
// clean=false means don't clear the graph first
|
||||
// restore_view=false means don't restore the viewport
|
||||
// workflow=null means update current workflow instead of creating new one
|
||||
if (app.loadGraphData) {
|
||||
// Try with null as 4th parameter first
|
||||
await app.loadGraphData(workflow, false, false, null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('Model Linker: No method available to update workflow');
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Error updating workflow in ComfyUI:', error);
|
||||
// Don't throw - allow the workflow update to continue even if UI update fails
|
||||
// The backend has already updated the workflow data
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user