refactor: move monolithic link dialog methods into domain-based modules

This commit is contained in:
Dariusz L
2026-06-05 04:32:56 +02:00
parent 51ebb17f89
commit b8b6439354
15 changed files with 70 additions and 70 deletions
+625
View File
@@ -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);
};
}
};
+453
View File
@@ -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}`;
}
};
+256
View File
@@ -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
}
}
};