Add docking functionality to Model Linker dialog

This commit is contained in:
Dariusz L
2026-05-29 03:38:23 +02:00
parent ba9ae41f1e
commit af2a3cd645
3 changed files with 469 additions and 7 deletions
+65
View File
@@ -145,6 +145,14 @@
padding: 0;
}
#model-linker-modal .ml-window-btn--dock {
width: 46px;
min-width: 46px;
height: 100%;
min-height: 40px;
padding: 0;
}
#model-linker-modal .ml-window-btn--close {
width: 46px;
min-width: 46px;
@@ -448,6 +456,63 @@
transform: scale(1.05);
}
.comfyui-model-linker-tab-button .mdi,
.comfyui-model-linker-tab-button .mdi-link-variant {
font-size: 18px;
line-height: 1;
}
.ml-sidebar-dock-panel {
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: var(--ml-text);
background: var(--ml-bg);
}
#model-linker-modal.ml-is-docked {
position: relative;
inset: auto;
top: auto;
left: auto;
transform: none;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
min-width: 0;
min-height: 0;
border: 0;
border-radius: 0;
box-shadow: none;
resize: none;
z-index: auto;
}
#model-linker-modal .ml-window-btn--undock {
display: none;
width: 46px;
min-width: 46px;
height: 100%;
min-height: 40px;
padding: 0;
}
#model-linker-modal.ml-is-docked .ml-window-btn--undock {
display: flex;
}
#model-linker-modal.ml-is-docked .ml-window-btn--dock {
display: none;
}
#model-linker-modal.ml-is-docked .ml-window-btn--fullscreen {
display: none;
}
@keyframes ml-slide-down {
from {
opacity: 0;
+403 -7
View File
@@ -33,6 +33,14 @@ class LinkerManagerDialog extends ComfyDialog {
this.activeTabStorageKey = 'model_linker_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
this.fullscreen = false;
this.docked = false;
this.dockContainer = null;
this.lastDockContainer = null;
this.pendingDockToSidebar = false;
this.sidebarTabId = "comfyui-model-linker";
this.dockButton = null;
this.undockButton = null;
this._floatingRectBeforeDock = null;
this._dragging = false;
this._dragStart = null;
this._analysisProgressToken = null;
@@ -2044,6 +2052,24 @@ class LinkerManagerDialog extends ComfyDialog {
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", {}, [
@@ -2056,6 +2082,8 @@ class LinkerManagerDialog extends ComfyDialog {
])
]),
$el("div.ml-dialog-controls", {}, [
this.dockButton,
this.undockButton,
fullscreenButton,
$el("button", {
className: "ml-window-btn ml-window-btn--close",
@@ -2067,6 +2095,294 @@ class LinkerManagerDialog extends ComfyDialog {
])
]);
}
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%)';
}
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.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.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;
}
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 ('activeSidebarTabId' in candidate) {
candidate.activeSidebarTabId = this.sidebarTabId;
opened = true;
}
} catch (error) {
console.debug('Model Linker: Sidebar state open attempt failed.', error);
}
}
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 ('activeSidebarTabId' in candidate) {
candidate.activeSidebarTabId = null;
closed = true;
}
if ('activeSidebarTab' in candidate && typeof candidate.activeSidebarTab !== 'function') {
candidate.activeSidebarTab = null;
closed = true;
}
} catch (error) {
console.debug('Model Linker: Sidebar state close attempt failed.', error);
}
}
return closed;
}
// Toggle full screen mode for the dialog
toggleFullScreen() {
@@ -2074,6 +2390,10 @@ class LinkerManagerDialog extends ComfyDialog {
}
setFullScreen(enable) {
if (enable && this.docked) {
this.undockToFloating({ persist: false });
}
this.fullscreen = !!enable;
const el = this.element;
if (!el) return;
@@ -2176,6 +2496,8 @@ class LinkerManagerDialog extends ComfyDialog {
}
saveModalPosition() {
if (this.docked) return;
try {
const el = this.element;
if (!el) return;
@@ -2185,6 +2507,7 @@ class LinkerManagerDialog extends ComfyDialog {
}
ensureModalHandleInViewport({ persist = false } = {}) {
if (this.docked) return;
if (this.fullscreen) return;
const el = this.element;
if (!el || getComputedStyle(el).display === 'none') return;
@@ -2216,6 +2539,8 @@ class LinkerManagerDialog extends ComfyDialog {
// Begin window drag
startDrag(e) {
if (this.docked) return;
try {
const el = this.element;
if (!el) return;
@@ -3840,9 +4165,21 @@ class LinkerManagerDialog extends ComfyDialog {
}
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";
this.scheduleModalViewportClamp();
if (!this.docked) {
this.scheduleModalViewportClamp();
}
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
@@ -3855,7 +4192,7 @@ class LinkerManagerDialog extends ComfyDialog {
// Restore fullscreen state if enabled
try {
const fs = localStorage.getItem('model_linker_modal_fullscreen');
if (fs === '1') this.setFullScreen(true);
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
} catch (e) { }
// Attach drag handle event listener (only once)
@@ -3881,7 +4218,7 @@ class LinkerManagerDialog extends ComfyDialog {
if (!handle || !topbar) return;
const onMouseDown = (e) => {
if (this.fullscreen) return; // no drag in fullscreen
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);
@@ -6225,6 +6562,9 @@ class ModelLinker {
this.linkerButton = null;
this.buttonGroup = null;
this.buttonId = "model-linker-button";
this.sidebarTabId = "comfyui-model-linker";
this.sidebarRegistered = false;
this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
this.dialog = null;
this.isCheckingMissing = false; // Prevent multiple simultaneous checks
this.lastCheckedWorkflow = null; // Track to avoid duplicate checks
@@ -6253,6 +6593,50 @@ class ModelLinker {
// Listen for workflow load events to auto-check for missing models
this.setupAutoOpenOnMissingModels();
const sidebarRegistered = this.registerSidebarButton();
if (!sidebarRegistered) {
await this.registerTopbarButton();
}
}
registerSidebarButton() {
const registerSidebarTab = app.extensionManager?.registerSidebarTab;
if (typeof registerSidebarTab !== "function") {
return false;
}
if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) {
this.sidebarRegistered = true;
return true;
}
try {
registerSidebarTab.call(app.extensionManager, {
id: this.sidebarTabId,
icon: "mdi mdi-link-variant",
title: "Model Linker",
tooltip: this.openTooltip,
type: "custom",
render: (element) => this.renderSidebarPanel(element),
});
this.sidebarRegistered = true;
window.__modelLinkerSidebarRegistered = true;
return true;
} catch (error) {
console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error);
return false;
}
}
renderSidebarPanel(element) {
element.replaceChildren();
element.style.height = "100%";
element.classList.add("ml-sidebar-dock-panel");
this.openLinkerManager({ dockContainer: element });
}
async registerTopbarButton() {
// Try to use new ComfyUI button system (like ComfyUI Manager does)
try {
// Dynamic imports for ComfyUI's button components
@@ -6260,14 +6644,14 @@ class ModelLinker {
const { ComfyButton } = await import("../../../scripts/ui/components/button.js");
// Create button group with Model Linker button
const modelLinkerTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
const modelLinkerButton = new ComfyButton({
icon: "link-variant",
action: () => this.openLinkerManager(),
content: "Model Linker",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element;
this.dialog.setTooltip(modelLinkerButton, modelLinkerTooltip);
modelLinkerButton.id = this.buttonId;
this.dialog.setTooltip(modelLinkerButton, this.openTooltip);
this.buttonGroup = new ComfyButtonGroup(
modelLinkerButton
);
@@ -6658,6 +7042,13 @@ class ModelLinker {
existingButton.remove();
}
document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => {
const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim();
if (label === 'Model Linker') {
button.closest('.comfyui-button-group')?.remove();
}
});
// Remove button group if it exists
if (this.buttonGroup?.element?.parentNode) {
this.buttonGroup.element.remove();
@@ -6686,13 +7077,18 @@ class ModelLinker {
this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.");
}
openLinkerManager() {
async openLinkerManager(options = {}) {
try {
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
this.dialog.show();
if (options.dockContainer) {
await this.dialog.showDocked(options.dockContainer, options.workflow || null);
return;
}
await this.dialog.show(options.workflow || null);
} catch (error) {
console.error("🔗 Model Linker: Error creating/showing dialog:", error);
alert("Error opening Model Linker: " + error.message);
+1
View File
@@ -7,6 +7,7 @@ const SVG_ICONS = {
locate: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12h3"></path><path d="M19 12h3"></path><path d="M12 2v3"></path><path d="M12 19v3"></path><circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2.5"></circle></svg>`,
download: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg>`,
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>`,
internalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 10v6h6"></path><path d="M21 3 8 16"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>`,
database: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="8" ry="3"></ellipse><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"></path><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg>`,
archive: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"></path><path d="M5 7l1 14h12l1-14"></path><path d="M8 7V3h8v4"></path><path d="M10 12h4"></path></svg>`,
loraManager: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" aria-hidden="true"><rect width="400" height="400" fill="#d3e3e6"></rect><path fill="#1e406e" d="M127 101h55v132h62l41 55H127z"></path></svg>`,