From af2a3cd645a6f816b1153a754fec72649cc03412 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 29 May 2026 03:38:23 +0200 Subject: [PATCH] Add docking functionality to Model Linker dialog --- web/css/linker-shell.css | 65 +++++++ web/linker.js | 410 ++++++++++++++++++++++++++++++++++++++- web/utils/icon_utils.js | 1 + 3 files changed, 469 insertions(+), 7 deletions(-) diff --git a/web/css/linker-shell.css b/web/css/linker-shell.css index 625a19a..e311f2e 100644 --- a/web/css/linker-shell.css +++ b/web/css/linker-shell.css @@ -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; diff --git a/web/linker.js b/web/linker.js index fa8c069..6af28dd 100644 --- a/web/linker.js +++ b/web/linker.js @@ -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); diff --git a/web/utils/icon_utils.js b/web/utils/icon_utils.js index 947cf85..b14a60c 100644 --- a/web/utils/icon_utils.js +++ b/web/utils/icon_utils.js @@ -7,6 +7,7 @@ const SVG_ICONS = { locate: ``, download: ``, externalLink: ``, + internalLink: ``, database: ``, archive: ``, loraManager: ``,