From 37d31b678290160fadfadaac6546d2826d5b71b6 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 28 Jul 2026 12:12:09 +0200 Subject: [PATCH] Improve resolver docked-splitter responsiveness and external resize behavior --- tests/test_downloads_tab_workflow_route.mjs | 131 +++++++++++ web/css/resolver-main.css | 27 +-- web/resolver/resolver_dialog.js | 7 + web/resolver/shell/dialog_shell_methods.js | 205 ++++++++++++++++++ web/resolver/shell/lifecycle_graph_methods.js | 1 + web/resolver/views/missing_browser_methods.js | 94 +++++++- 6 files changed, 440 insertions(+), 25 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 0777437..fd5d70c 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -329,6 +329,137 @@ test('missing browser splitter keeps a compositor preview between live layout fr /\.mr-missing-browser\.is-resizing \.mr-missing-(?:list|detail)-pane/ ); assert.doesNotMatch(missingBrowserMethodsSource, /deferMissingBrowserSplitReleaseUi/); + assert.match(missingBrowserMethodsSource, /observedWidth\s*<\s*760/); + assert.match(missingBrowserMethodsSource, /observedWidth\s*<\s*700/); + assert.match( + missingBrowserMethodsSource, + /scheduleMissingBrowserExternalResizeRestore\(browser,\s*observedWidth\)/ + ); + assert.match( + resolverMainCssSource, + /\.mr-missing-browser\.is-detail-collapsed \.mr-missing-detail-pane\s*\{[^}]*display:\s*none/s + ); + assert.doesNotMatch(missingBrowserMethodsSource, /startMissingBrowserExternalResizeLock/); + assert.doesNotMatch(resolverMainCssSource, /mr-resolver-external-resizing/); + assert.doesNotMatch( + resolverMainCssSource, + /@media\s*\(max-width:\s*980px\)\s*\{\s*\.mr-missing-browser/ + ); +}); + +test('docked ComfyUI splitter coalesces live layout while its gutter stays responsive', () => { + const queueResize = eval( + `(${extractMethod(dialogShellMethodsSource, 'queueSidebarSplitterResize')})` + ); + const flushResize = eval( + `(${extractMethod(dialogShellMethodsSource, 'flushSidebarSplitterResize')})` + ); + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const previousWindow = globalThis.window; + const frames = []; + const originalCalls = []; + + globalThis.requestAnimationFrame = callback => { + frames.push(callback); + return frames.length; + }; + globalThis.window = { + setTimeout(callback) { + callback(); + return 1; + }, + }; + + try { + const gutter = { + style: { + transform: '', + willChange: '', + }, + }; + const state = { + proxy: {}, + originalResize(...args) { + originalCalls.push(args); + }, + gutter, + originalTransform: '', + pendingArgs: null, + animationFrame: null, + delayTimer: null, + lastLayoutAt: Number.NEGATIVE_INFINITY, + minLayoutInterval: 40, + appliedPageX: 100, + appliedPageY: 0, + vertical: false, + hasMoved: false, + }; + const context = { + _sidebarSplitterDragState: state, + flushSidebarSplitterResize: flushResize, + }; + + queueResize.call(context, state, [{ pageX: 112, pageY: 0 }]); + queueResize.call(context, state, [{ pageX: 128, pageY: 0 }]); + + assert.equal(frames.length, 1); + assert.equal(gutter.style.transform, 'translate3d(28px, 0px, 0)'); + assert.equal(originalCalls.length, 0); + + frames.shift()(); + assert.equal(originalCalls.length, 1); + assert.equal(originalCalls[0][0].pageX, 128); + assert.equal(state.appliedPageX, 128); + assert.equal(gutter.style.transform, ''); + } finally { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + globalThis.window = previousWindow; + } + + assert.match(dialogShellMethodsSource, /minLayoutInterval:\s*40/); + assert.match(dialogShellMethodsSource, /setPointerCapture\(event\.pointerId\)/); + assert.match(dialogShellMethodsSource, /translate3d\(/); + assert.match(dialogShellMethodsSource, /requestAnimationFrame\(/); + assert.doesNotMatch(dialogShellMethodsSource, /mr-resolver-external-resizing/); +}); + +test('missing browser detail collapse follows container width with hysteresis', () => { + const previousHTMLElement = globalThis.HTMLElement; + class FakeHTMLElement {} + globalThis.HTMLElement = FakeHTMLElement; + + try { + const classes = new Set(); + const browser = new FakeHTMLElement(); + browser.classList = { + contains(name) { + return classes.has(name); + }, + toggle(name, enabled) { + if (enabled) classes.add(name); + else classes.delete(name); + }, + }; + + const context = { + _missingBrowserRestoreFrame: null, + cancelMissingBrowserExternalResizeRestore() {}, + }; + const updateDetailCollapse = eval( + `(${extractMethod(missingBrowserMethodsSource, 'updateMissingBrowserDetailCollapse')})` + ); + + assert.equal(updateDetailCollapse.call(context, browser, 690), true); + assert.equal(classes.has('is-detail-collapsed'), true); + + assert.equal(updateDetailCollapse.call(context, browser, 730), false); + assert.equal(classes.has('is-detail-collapsed'), true); + + assert.equal(updateDetailCollapse.call(context, browser, 770), true); + assert.equal(classes.has('is-detail-collapsed'), false); + } finally { + globalThis.HTMLElement = previousHTMLElement; + } }); test('local and loaded model tooltips include preview image routes above full names', () => { diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index ea216b5..242c812 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -1156,26 +1156,15 @@ box-shadow: none; } -@media (max-width: 980px) { - .mr-missing-browser { - flex-direction: column; - overflow-y: auto; - } +.mr-missing-browser.is-detail-collapsed .mr-missing-browser-splitter, +.mr-missing-browser.is-detail-collapsed .mr-missing-detail-pane { + display: none; +} - .mr-missing-browser-splitter { - display: none; - } - - .mr-missing-list-pane { - max-height: 46vh; - border-right: 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - } - - .mr-missing-detail-pane { - flex: 0 0 auto; - overflow: visible; - } +.mr-missing-browser.is-detail-collapsed .mr-missing-list-pane { + flex: 1 1 100% !important; + width: 100%; + border-right: 0; } @media (max-width: 760px) { diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 47e90b3..e33354d 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -73,6 +73,10 @@ export class ResolverManagerDialog extends ComfyDialog { this.sidebarTabId = "comfyui-model-resolver"; this.sidebarOpenModeStorageKey = "model_resolver_sidebar_open_mode"; this.missingBrowserSplitStorageKey = "model_resolver_missing_browser_detail_w"; + this._missingBrowserExternalResizeTimer = null; + this._pendingMissingBrowserExternalResizeBrowser = null; + this._pendingMissingBrowserExternalResizeWidth = null; + this._missingBrowserObservedHostWidth = null; this.showResolvedModelsStorageKey = "model_resolver_show_resolved_models"; this.showAutoDownloadModelsStorageKey = "model_resolver_show_auto_download_models"; this.localMatchAlternativesCollapsedStorageKey = "model_resolver_local_match_alternatives_collapsed"; @@ -100,6 +104,9 @@ export class ResolverManagerDialog extends ComfyDialog { this._dockedDragPendingRect = null; this._dockedDragAnimationFrame = null; this._dragUndockCandidate = false; + this._sidebarSplitterPointerDownHandler = null; + this._sidebarSplitterMouseDownHandler = null; + this._sidebarSplitterDragState = null; this._analysisProgressToken = null; this._workflowDataLoadToken = null; this._loadedModelsLoadToken = null; diff --git a/web/resolver/shell/dialog_shell_methods.js b/web/resolver/shell/dialog_shell_methods.js index c75bd5d..dac35b4 100644 --- a/web/resolver/shell/dialog_shell_methods.js +++ b/web/resolver/shell/dialog_shell_methods.js @@ -207,6 +207,210 @@ export const dialogShellMethods = { if (this.element.parentNode !== container) { container.appendChild(this.element); } + this.installSidebarSplitterOptimization(); + }, + + installSidebarSplitterOptimization() { + if (this._sidebarSplitterPointerDownHandler || this._sidebarSplitterMouseDownHandler) return; + + this._sidebarSplitterPointerDownHandler = (event) => { + if (event.button !== undefined && event.button !== 0) return; + const gutter = this.getResolverSidebarGutter(event.target); + if (!gutter || typeof gutter.setPointerCapture !== 'function') return; + try { + gutter.setPointerCapture(event.pointerId); + } catch { + // Pointer capture is an optional optimization and can fail for synthetic events. + } + }; + this._sidebarSplitterMouseDownHandler = (event) => { + if (event.button !== undefined && event.button !== 0) return; + const gutter = this.getResolverSidebarGutter(event.target); + if (gutter) this.startSidebarSplitterDragOptimization(event, gutter); + }; + + document.addEventListener('pointerdown', this._sidebarSplitterPointerDownHandler, true); + document.addEventListener('mousedown', this._sidebarSplitterMouseDownHandler, true); + }, + + removeSidebarSplitterOptimization() { + this.finishSidebarSplitterDragOptimization({ flush: true }); + if (this._sidebarSplitterPointerDownHandler) { + document.removeEventListener('pointerdown', this._sidebarSplitterPointerDownHandler, true); + this._sidebarSplitterPointerDownHandler = null; + } + if (this._sidebarSplitterMouseDownHandler) { + document.removeEventListener('mousedown', this._sidebarSplitterMouseDownHandler, true); + this._sidebarSplitterMouseDownHandler = null; + } + }, + + getResolverSidebarGutter(target) { + if (!this.docked || !(this.dockContainer instanceof HTMLElement) || !this.dockContainer.isConnected) { + return null; + } + + const element = target instanceof Element ? target : null; + const gutter = element?.closest?.('.p-splitter-gutter'); + const panel = this.dockContainer.closest?.('[data-pc-name="splitterpanel"]'); + if (!(gutter instanceof HTMLElement) || !(panel instanceof HTMLElement)) return null; + + return gutter.previousElementSibling === panel || gutter.nextElementSibling === panel + ? gutter + : null; + }, + + findPrimeVueSplitterResizeOwner(gutter) { + if (!(gutter instanceof HTMLElement)) return null; + + let instance = gutter.__vueParentComponent || gutter.parentElement?.__vueParentComponent || null; + for (let depth = 0; instance && depth < 10; depth += 1, instance = instance.parent) { + const owner = instance.ctx; + if ( + owner + && typeof owner.onResize === 'function' + && typeof owner.onResizeStart === 'function' + ) { + return { + owner, + proxy: instance.proxy || owner + }; + } + } + return null; + }, + + startSidebarSplitterDragOptimization(event, gutter) { + if (this._sidebarSplitterDragState) { + this.finishSidebarSplitterDragOptimization({ flush: true }); + } + + const resizeTarget = this.findPrimeVueSplitterResizeOwner(gutter); + if (!resizeTarget) return false; + + const { owner, proxy } = resizeTarget; + const originalResize = owner.onResize; + const state = { + owner, + proxy, + originalResize, + wrappedResize: null, + gutter, + originalTransform: gutter.style.transform, + originalWillChange: gutter.style.willChange, + pendingArgs: null, + animationFrame: null, + delayTimer: null, + lastLayoutAt: typeof performance === 'object' ? performance.now() : Date.now(), + minLayoutInterval: 40, + appliedPageX: Number(event.pageX ?? event.clientX) || 0, + appliedPageY: Number(event.pageY ?? event.clientY) || 0, + vertical: proxy?.horizontal === false, + hasMoved: false, + mouseUpHandler: null, + blurHandler: null + }; + + state.wrappedResize = (...args) => this.queueSidebarSplitterResize(state, args); + try { + owner.onResize = state.wrappedResize; + } catch { + return false; + } + if (owner.onResize !== state.wrappedResize) return false; + + state.mouseUpHandler = (mouseEvent) => { + this.finishSidebarSplitterDragOptimization({ + flush: true, + finalEvent: mouseEvent + }); + }; + state.blurHandler = () => this.finishSidebarSplitterDragOptimization({ flush: true }); + this._sidebarSplitterDragState = state; + document.addEventListener('mouseup', state.mouseUpHandler, true); + window.addEventListener('blur', state.blurHandler, { once: true }); + return true; + }, + + queueSidebarSplitterResize(state, args) { + if (!state || this._sidebarSplitterDragState !== state) { + return state?.originalResize?.apply(state.proxy, args); + } + + const event = args?.[0]; + const pageX = Number(event?.pageX ?? event?.clientX); + const pageY = Number(event?.pageY ?? event?.clientY); + state.pendingArgs = args; + state.hasMoved = true; + + const offset = state.vertical + ? pageY - state.appliedPageY + : pageX - state.appliedPageX; + if (Number.isFinite(offset)) { + state.gutter.style.willChange = 'transform'; + state.gutter.style.transform = offset + ? `translate3d(${state.vertical ? 0 : Math.round(offset)}px, ${state.vertical ? Math.round(offset) : 0}px, 0)` + : state.originalTransform; + } + + if (state.animationFrame || state.delayTimer) return; + const now = typeof performance === 'object' ? performance.now() : Date.now(); + const delay = Math.max(0, state.minLayoutInterval - (now - state.lastLayoutAt)); + const requestFlush = () => { + state.delayTimer = null; + state.animationFrame = requestAnimationFrame(() => { + state.animationFrame = null; + this.flushSidebarSplitterResize(state); + }); + }; + + if (delay > 0) { + state.delayTimer = window.setTimeout(requestFlush, delay); + } else { + requestFlush(); + } + }, + + flushSidebarSplitterResize(state) { + if (!state?.pendingArgs) return false; + + const args = state.pendingArgs; + state.pendingArgs = null; + const event = args[0]; + state.originalResize.apply(state.proxy, args); + state.appliedPageX = Number(event?.pageX ?? event?.clientX) || state.appliedPageX; + state.appliedPageY = Number(event?.pageY ?? event?.clientY) || state.appliedPageY; + state.lastLayoutAt = typeof performance === 'object' ? performance.now() : Date.now(); + state.gutter.style.transform = state.originalTransform; + return true; + }, + + finishSidebarSplitterDragOptimization({ flush = false, finalEvent = null } = {}) { + const state = this._sidebarSplitterDragState; + if (!state) return false; + this._sidebarSplitterDragState = null; + + if (state.delayTimer) { + clearTimeout(state.delayTimer); + state.delayTimer = null; + } + if (state.animationFrame) { + cancelAnimationFrame(state.animationFrame); + state.animationFrame = null; + } + if (flush && state.hasMoved && finalEvent) { + state.pendingArgs = [finalEvent]; + } + if (flush) this.flushSidebarSplitterResize(state); + + if (state.owner.onResize === state.wrappedResize) { + state.owner.onResize = state.originalResize; + } + state.gutter.style.transform = state.originalTransform; + state.gutter.style.willChange = state.originalWillChange; + document.removeEventListener('mouseup', state.mouseUpHandler, true); + window.removeEventListener('blur', state.blurHandler); + return true; }, dockToSidebar() { @@ -306,6 +510,7 @@ export const dialogShellMethods = { undockToFloating({ persist = true, closeSidebar = true } = {}) { if (!this.element) return; + this.removeSidebarSplitterOptimization(); this.setDockDropPreviewActive(false); this.setUndockDropPreviewActive(false); this._pendingDragDockRect = null; diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index d07f1af..a683a2b 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -98,6 +98,7 @@ export const lifecycleGraphMethods = { close({ collapseSidebar = true } = {}) { const wasDocked = this.docked; const dockContainer = this.dockContainer; + this.removeSidebarSplitterOptimization?.(); this.setDockDropPreviewActive(false); this.setUndockDropPreviewActive(false); this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating'); diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 9859b88..fd438c1 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -959,20 +959,39 @@ export const missingBrowserMethods = { this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32); }); + this.cancelMissingBrowserExternalResizeRestore(); this._missingBrowserResizeObserver?.disconnect?.(); if (typeof ResizeObserver === 'function') { + const resizeHost = browser.parentElement instanceof HTMLElement + ? browser.parentElement + : browser; + this._missingBrowserObservedHostWidth = null; this._missingBrowserResizeObserver = new ResizeObserver((entries) => { const observedWidth = Number(entries?.[0]?.contentRect?.width); + if (!Number.isFinite(observedWidth) || observedWidth <= 0) return; + + const previousHostWidth = Number(this._missingBrowserObservedHostWidth); + this._missingBrowserObservedHostWidth = observedWidth; this.rememberMissingBrowserWidth(observedWidth); + + if (!Number.isFinite(previousHostWidth) || previousHostWidth <= 0) { + this.updateMissingBrowserDetailCollapse(browser, observedWidth); + return; + } + if (Math.abs(previousHostWidth - observedWidth) < 0.5) return; + const settlingUntil = Number(this._missingBrowserSplitSettlingUntil || 0); const isSettling = typeof performance === 'object' && typeof performance.now === 'function' && performance.now() < settlingUntil; - if (!this._missingBrowserSplitDragging && !isSettling) { - this.scheduleMissingBrowserSplitRestore(browser, observedWidth, { useDefault: false }); + if ( + !this._missingBrowserSplitDragging + && !isSettling + ) { + this.scheduleMissingBrowserExternalResizeRestore(browser, observedWidth); } }); - this._missingBrowserResizeObserver.observe(browser); + this._missingBrowserResizeObserver.observe(resizeHost); } else { this.cancelMissingBrowserPrewarmFrame(); this._missingBrowserPrewarmFrame = requestAnimationFrame(() => { @@ -981,7 +1000,8 @@ export const missingBrowserMethods = { if (this._missingBrowserSplitDragging) return; const measuredWidth = Number(browser.clientWidth || browser.getBoundingClientRect().width); this.rememberMissingBrowserWidth(measuredWidth); - if (!this._missingBrowserSplitDragging) { + this.updateMissingBrowserDetailCollapse(browser, measuredWidth); + if (!this._missingBrowserSplitDragging && !browser.classList.contains('is-detail-collapsed')) { this.scheduleMissingBrowserSplitRestore(browser, measuredWidth, { useDefault: false }); } }); @@ -994,6 +1014,68 @@ export const missingBrowserMethods = { this._missingBrowserPrewarmFrame = null; }, + updateMissingBrowserDetailCollapse(browser, width) { + if (!(browser instanceof HTMLElement)) return false; + const observedWidth = Number(width); + if (!Number.isFinite(observedWidth) || observedWidth <= 0) return false; + + const wasCollapsed = browser.classList.contains('is-detail-collapsed'); + const shouldCollapse = wasCollapsed + ? observedWidth < 760 + : observedWidth < 700; + if (shouldCollapse === wasCollapsed) return false; + + browser.classList.toggle('is-detail-collapsed', shouldCollapse); + if (shouldCollapse) { + this.cancelMissingBrowserExternalResizeRestore(); + if (this._missingBrowserRestoreFrame) { + cancelAnimationFrame(this._missingBrowserRestoreFrame); + this._missingBrowserRestoreFrame = null; + } + this._pendingMissingBrowserRestoreBrowser = null; + this._pendingMissingBrowserRestoreWidth = null; + this._pendingMissingBrowserRestoreUseDefault = false; + } + return true; + }, + + cancelMissingBrowserExternalResizeRestore() { + if (this._missingBrowserExternalResizeTimer) { + clearTimeout(this._missingBrowserExternalResizeTimer); + } + this._missingBrowserExternalResizeTimer = null; + this._pendingMissingBrowserExternalResizeBrowser = null; + this._pendingMissingBrowserExternalResizeWidth = null; + }, + + scheduleMissingBrowserExternalResizeRestore(browser, browserWidth, delay = 120) { + if (!(browser instanceof HTMLElement)) return; + + this._pendingMissingBrowserExternalResizeBrowser = browser; + this._pendingMissingBrowserExternalResizeWidth = Number(browserWidth) || null; + if (this._missingBrowserExternalResizeTimer) { + clearTimeout(this._missingBrowserExternalResizeTimer); + } + + this._missingBrowserExternalResizeTimer = setTimeout(() => { + this._missingBrowserExternalResizeTimer = null; + const targetBrowser = this._pendingMissingBrowserExternalResizeBrowser; + const targetWidth = this._pendingMissingBrowserExternalResizeWidth; + this._pendingMissingBrowserExternalResizeBrowser = null; + this._pendingMissingBrowserExternalResizeWidth = null; + + if ( + !targetBrowser?.isConnected + || this._missingBrowserSplitDragging + ) { + return; + } + this.updateMissingBrowserDetailCollapse(targetBrowser, targetWidth); + if (targetBrowser.classList.contains('is-detail-collapsed')) return; + this.scheduleMissingBrowserSplitRestore(targetBrowser, targetWidth, { useDefault: false }); + }, Math.max(0, Number(delay) || 0)); + }, + getStoredMissingBrowserSplitWidth() { if (this._missingBrowserStoredSplitWidthLoaded) { return this._missingBrowserStoredSplitWidth || null; @@ -1060,7 +1142,7 @@ export const missingBrowserMethods = { }, scheduleMissingBrowserSplitRestore(browser, browserWidth = null, { useDefault = false } = {}) { - if (!(browser instanceof HTMLElement)) return; + if (!(browser instanceof HTMLElement) || browser.classList.contains('is-detail-collapsed')) return; this._pendingMissingBrowserRestoreBrowser = browser; this._pendingMissingBrowserRestoreWidth = Number.isFinite(Number(browserWidth)) && Number(browserWidth) > 0 @@ -1100,7 +1182,7 @@ export const missingBrowserMethods = { }, startMissingBrowserSplitDrag(event, browser, splitter = null, panes = {}) { - if (!(browser instanceof HTMLElement)) return; + if (!(browser instanceof HTMLElement) || browser.classList.contains('is-detail-collapsed')) return; event.preventDefault(); event.stopPropagation?.();