diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 470202c..3f65c37 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -273,7 +273,7 @@ --ml-missing-line-strong: rgba(255,255,255,0.13); --ml-missing-radius: 6px; display: grid; - grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr); + grid-template-columns: minmax(360px, 1fr) 10px var(--ml-missing-detail-track, minmax(320px, 0.9fr)); gap: 0; height: 100%; min-height: 0; @@ -291,9 +291,39 @@ .ml-missing-list-pane { display: grid; grid-template-rows: auto auto minmax(0, 1fr); - border-right: 1px solid var(--ml-missing-line); + border-right: 0; background: var(--ml-missing-bg); } +.ml-missing-browser-splitter { + position: relative; + min-width: 10px; + width: 10px; + cursor: col-resize; + user-select: none; + touch-action: none; + background: var(--ml-missing-bg); + border-left: 1px solid var(--ml-missing-line); + border-right: 1px solid var(--ml-missing-line); + outline: none; +} +.ml-missing-browser-splitter::before { + content: ""; + position: absolute; + top: 16px; + bottom: 16px; + left: 50%; + width: 2px; + border-radius: 999px; + background: rgba(255,255,255,0.13); + transform: translateX(-50%); + transition: background 0.15s ease, box-shadow 0.15s ease; +} +.ml-missing-browser-splitter:hover::before, +.ml-missing-browser-splitter:focus-visible::before, +.ml-missing-browser.is-resizing .ml-missing-browser-splitter::before { + background: rgba(114,211,138,0.78); + box-shadow: 0 0 0 3px rgba(114,211,138,0.12); +} .ml-missing-list-toolbar { display: flex; align-items: center; @@ -651,6 +681,9 @@ grid-template-columns: 1fr; overflow-y: auto; } + .ml-missing-browser-splitter { + display: none; + } .ml-missing-list-pane { max-height: 46vh; border-right: 0; diff --git a/web/linker.js b/web/linker.js index 091f5c6..4fbc35f 100644 --- a/web/linker.js +++ b/web/linker.js @@ -39,6 +39,7 @@ class LinkerManagerDialog extends ComfyDialog { this.pendingDockToSidebar = false; this.sidebarTabId = "comfyui-model-linker"; this.sidebarOpenModeStorageKey = "model_linker_sidebar_open_mode"; + this.missingBrowserSplitStorageKey = "model_linker_missing_browser_detail_w"; this.dockButton = null; this.undockButton = null; this._floatingRectBeforeDock = null; @@ -4837,7 +4838,14 @@ class LinkerManagerDialog extends ComfyDialog { ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online'); const listLayout = this.getMissingModelsListLayout(missingModels); - const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`; + let savedDetailWidth = null; + try { + savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); + } catch (e) {} + const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0 + ? `--ml-missing-detail-track:${savedDetailWidth}px;` + : ''; + const listStyle = `${splitStyle}--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`; let html = `
@@ -4906,6 +4914,7 @@ class LinkerManagerDialog extends ComfyDialog { html += `
+
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
@@ -4915,6 +4924,8 @@ class LinkerManagerDialog extends ComfyDialog { } wireMissingModelsBrowser(container, data, sortedMissingModels) { + this.wireMissingBrowserSplitter(container); + const selectRow = (row) => { const key = row.dataset.missingKey; if (!key || key === this.selectedMissingModelKey) return; @@ -4953,6 +4964,130 @@ class LinkerManagerDialog extends ComfyDialog { this.wireMissingModelDetail(container, selectedMissing, selectedIndex); } + wireMissingBrowserSplitter(container) { + const browser = container.querySelector('.ml-missing-browser'); + const splitter = browser?.querySelector('.ml-missing-browser-splitter'); + if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return; + + this.restoreMissingBrowserSplitWidth(browser); + + splitter.addEventListener('mousedown', (event) => { + if (event.button !== 0) return; + this.startMissingBrowserSplitDrag(event, browser); + }); + + splitter.addEventListener('dblclick', () => { + browser.style.removeProperty('--ml-missing-detail-track'); + try { + localStorage.removeItem(this.missingBrowserSplitStorageKey); + } catch (e) {} + }); + + splitter.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32); + }); + + this._missingBrowserResizeObserver?.disconnect?.(); + if (typeof ResizeObserver === 'function') { + this._missingBrowserResizeObserver = new ResizeObserver(() => { + if (!this._missingBrowserSplitDragging) { + this.restoreMissingBrowserSplitWidth(browser); + } + }); + this._missingBrowserResizeObserver.observe(browser); + } + } + + restoreMissingBrowserSplitWidth(browser) { + try { + const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10); + if (Number.isFinite(savedWidth) && savedWidth > 0) { + this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false }); + } + } catch (e) {} + } + + startMissingBrowserSplitDrag(event, browser) { + if (!(browser instanceof HTMLElement)) return; + + const detailPane = browser.querySelector('.ml-missing-detail-pane'); + if (!(detailPane instanceof HTMLElement)) return; + + event.preventDefault(); + this._missingBrowserSplitDragging = true; + this._missingBrowserSplitStart = { + x: event.clientX, + width: detailPane.getBoundingClientRect().width + }; + this._missingBrowserSplitBrowser = browser; + this._missingBrowserPrevUserSelect = document.body.style.userSelect; + this._missingBrowserPrevCursor = document.body.style.cursor; + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + browser.classList.add('is-resizing'); + + this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent); + this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag(); + document.addEventListener('mousemove', this._onMissingBrowserSplitMove); + document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true }); + } + + onMissingBrowserSplitDrag(event) { + if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return; + + const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x); + this.setMissingBrowserDetailWidth(this._missingBrowserSplitBrowser, nextWidth); + } + + endMissingBrowserSplitDrag() { + if (!this._missingBrowserSplitDragging) return; + + this._missingBrowserSplitDragging = false; + document.removeEventListener('mousemove', this._onMissingBrowserSplitMove); + this._missingBrowserSplitBrowser?.classList.remove('is-resizing'); + this._missingBrowserSplitBrowser = null; + this._missingBrowserSplitStart = null; + try { + document.body.style.userSelect = this._missingBrowserPrevUserSelect || ''; + document.body.style.cursor = this._missingBrowserPrevCursor || ''; + } catch (e) {} + } + + resizeMissingBrowserDetailBy(browser, delta) { + const detailPane = browser.querySelector('.ml-missing-detail-pane'); + if (!(detailPane instanceof HTMLElement)) return; + + this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta); + } + + setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) { + const bounds = this.getMissingBrowserSplitBounds(browser); + const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width))); + browser.style.setProperty('--ml-missing-detail-track', `${nextWidth}px`); + + if (persist) { + try { + localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth)); + } catch (e) {} + } + } + + getMissingBrowserSplitBounds(browser) { + const browserRect = browser.getBoundingClientRect(); + const splitter = browser.querySelector('.ml-missing-browser-splitter'); + const splitterWidth = splitter instanceof HTMLElement + ? splitter.getBoundingClientRect().width + : 10; + const available = Math.max(1, browserRect.width - splitterWidth); + const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28))); + const max = Math.max(1, available - minListWidth); + const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3)))); + + return { min, max }; + } + wireMissingModelDetail(container, missing, missingIndex) { this.wireLocalMatchButtons(container, missing, missingIndex); this.wireDownloadSearchPanel(container, missing);