From c62a88f86a2df6daa5ceec4d43582fe535b2af84 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 27 Jul 2026 19:33:19 +0200 Subject: [PATCH] Make floating dialog drag compositor-friendly and avoid forced style reads --- tests/test_downloads_tab_workflow_route.mjs | 91 ++++++++++++ web/resolver/model_resolver.js | 3 +- web/resolver/resolver_dialog.js | 16 ++ web/resolver/shell/dialog_shell_methods.js | 156 ++++++++++++++++++-- 4 files changed, 249 insertions(+), 17 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index ddd1f18..81b3a26 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -67,6 +67,10 @@ const workflowUpdateMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/shell/workflow_update_methods.js'), 'utf8' ); +const dialogShellMethodsSource = fs.readFileSync( + path.join(projectRoot, 'web/resolver/shell/dialog_shell_methods.js'), + 'utf8' +); const renderFormatMethodsSource = fs.readFileSync( path.join(projectRoot, 'web/resolver/utils/render_format_methods.js'), 'utf8' @@ -75,6 +79,10 @@ const resolverMainCssSource = fs.readFileSync( path.join(projectRoot, 'web/css/resolver-main.css'), 'utf8' ); +const resolverShellCssSource = fs.readFileSync( + path.join(projectRoot, 'web/css/resolver-shell.css'), + 'utf8' +); test('Missing Models rows use fixed-size virtualization for smooth stable scrolling', () => { const rowRules = Array.from( @@ -129,6 +137,89 @@ test('manual model selection uses the two-line status card layout', () => { assert.match(resolverMainCssSource, /\.mr-btn-danger\s*\{[^}]*rgba\(239,\s*68,\s*68/s); }); +test('floating dialog drag stays on the compositor without forced style reads', () => { + const isVisible = extractMethod(dialogShellMethodsSource, 'isVisible'); + const saveModalPosition = extractMethod(dialogShellMethodsSource, 'saveModalPosition'); + const startDrag = extractMethod(dialogShellMethodsSource, 'startDrag'); + const onDrag = eval(`(${extractMethod(dialogShellMethodsSource, 'onDrag')})`); + const endDrag = extractMethod(dialogShellMethodsSource, 'endDrag'); + + assert.doesNotMatch(isVisible, /getComputedStyle/); + assert.match(isVisible, /style\.display\s*===\s*['"]flex['"]/); + assert.match(saveModalPosition, /Number\(position\?\.top\)/); + assert.match(saveModalPosition, /Number\.isFinite\(top\)\s*&&\s*Number\.isFinite\(left\)/); + assert.match(startDrag, /preventDefault/); + assert.match(startDrag, /style\.willChange\s*=\s*['"]transform['"]/); + assert.match(startDrag, /setDockDropPreviewActive\(false\)/); + assert.doesNotMatch(startDrag, /document\.body\.style\.userSelect/); + assert.doesNotMatch(startDrag, /classList\.add\(['"]mr-is-window-dragging['"]\)/); + assert.match(endDrag, /cancelAnimationFrame/); + assert.match(endDrag, /style\.top/); + assert.match(endDrag, /style\.left/); + assert.match(endDrag, /style\.transform\s*=\s*['"]none['"]/); + assert.match(endDrag, /saveModalPosition\(finalPosition\)/); + assert.match(endDrag, /style\.willChange\s*=\s*['"]['"]/); + assert.match(endDrag, /const shouldDock = this\._dragDockCandidate/); + assert.match(endDrag, /if \(shouldDock\)\s*\{\s*this\.dockToSidebar\(\)/); + assert.doesNotMatch(resolverShellCssSource, /mr-is-window-dragging/); + assert.match(resolverDialogSource, /mr-dock-drop-preview/); + assert.match( + resolverShellCssSource, + /\.mr-dock-drop-preview\s*\{[^}]*pointer-events:\s*none[^}]*visibility:\s*hidden[^}]*opacity:\s*0/s + ); + assert.match( + resolverShellCssSource, + /\.mr-dock-drop-preview\.is-active\s*\{[^}]*visibility:\s*visible[^}]*opacity:\s*1/s + ); + + const handlerStart = modelResolverSource.indexOf('const documentClickHandler = (event) => {'); + const handlerEnd = modelResolverSource.indexOf('const focusHandler =', handlerStart); + const handlerSource = modelResolverSource.slice(handlerStart, handlerEnd); + assert.ok(handlerStart >= 0 && handlerEnd > handlerStart); + assert.ok( + handlerSource.indexOf("target?.closest('#model-resolver-modal, .model-resolver-backdrop')") < + handlerSource.indexOf('dialog?.isVisible()'), + 'clicks inside the resolver must return before checking dialog visibility' + ); + + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + let frameCallback = null; + globalThis.requestAnimationFrame = callback => { + frameCallback = callback; + return 17; + }; + + try { + const element = { style: {} }; + const dockPreviewStates = []; + const dialog = { + element, + _dragging: true, + _dragStart: { x: 10, y: 20, top: 100, left: 200 }, + _dragBounds: { minLeft: 0, maxLeft: 500, minTop: 0, maxTop: 400 }, + _dragPendingPosition: null, + _dragAnimationFrame: null, + getDockSnapThreshold: () => 64, + setDockDropPreviewActive: active => dockPreviewStates.push(active), + }; + + onDrag.call(dialog, { clientX: 50, clientY: 70 }); + + assert.equal(element.style.top, undefined); + assert.equal(element.style.left, undefined); + assert.equal(dialog._dragAnimationFrame, 17); + assert.equal(typeof frameCallback, 'function'); + assert.deepEqual(dockPreviewStates, [true]); + + frameCallback(); + + assert.equal(element.style.transform, 'translate3d(40px, 50px, 0)'); + assert.deepEqual(dialog._dragPendingPosition, { top: 150, left: 240 }); + } finally { + globalThis.requestAnimationFrame = previousRequestAnimationFrame; + } +}); + test('local and loaded model tooltips include preview image routes above full names', () => { const getModelPreviewTooltipAttrs = eval(`(${extractMethod(renderFormatMethodsSource, 'getModelPreviewTooltipAttrs')})`); const previousApi = globalThis.api; diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 0b6ba9e..3135495 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -780,10 +780,9 @@ export class ModelResolver { }; const documentClickHandler = (event) => { const owner = window.__ModelResolverWorkflowChangeOwner; - if (!owner?.dialog?.isVisible()) return; - const target = event.target instanceof Element ? event.target : null; if (target?.closest('#model-resolver-modal, .model-resolver-backdrop')) return; + if (!owner?.dialog?.isVisible()) return; if (!owner.isLikelyWorkflowTabClickTarget(target)) return; setTimeout(() => { diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 87efe1f..1cecead 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -89,6 +89,12 @@ export class ResolverManagerDialog extends ComfyDialog { this._floatingRectBeforeDock = null; this._dragging = false; this._dragStart = null; + this._dragBounds = null; + this._dragPendingPosition = null; + this._dragAnimationFrame = null; + this._dragLayerCleanupTimer = null; + this._dragDockCandidate = false; + this._pendingDragDockRect = null; this._analysisProgressToken = null; this._workflowDataLoadToken = null; this._loadedModelsLoadToken = null; @@ -114,6 +120,16 @@ export class ResolverManagerDialog extends ComfyDialog { parent: document.body }); + this.dockDropPreview = $el("div.mr-dock-drop-preview", { + parent: document.body, + ariaHidden: "true", + innerHTML: ` + ${getSvgIcon('internalLink')} + Dock to sidebar + Release to dock Model Resolver + ` + }); + // Create context menu for model chips this.contextMenu = $el("div.mr-context-menu", { parent: document.body diff --git a/web/resolver/shell/dialog_shell_methods.js b/web/resolver/shell/dialog_shell_methods.js index e9acb81..9da730f 100644 --- a/web/resolver/shell/dialog_shell_methods.js +++ b/web/resolver/shell/dialog_shell_methods.js @@ -171,16 +171,22 @@ export const dialogShellMethods = { isVisible() { if (!this.element?.isConnected) return false; - return getComputedStyle(this.element).display !== 'none'; + return this.element.style.display === 'flex'; }, dockTo(container) { if (!container || !this.element) return; if (!this.docked) { - this.captureFloatingRect(); + if (this._pendingDragDockRect) { + this._floatingRectBeforeDock = this._pendingDragDockRect; + this._pendingDragDockRect = null; + } else { + this.captureFloatingRect(); + } } + this.setDockDropPreviewActive(false); if (this.fullscreen) { this.setFullScreen(false); } @@ -298,6 +304,8 @@ export const dialogShellMethods = { undockToFloating({ persist = true, closeSidebar = true } = {}) { if (!this.element) return; + this.setDockDropPreviewActive(false); + this._pendingDragDockRect = null; const wasDocked = this.docked; const dockContainer = this.dockContainer; this.docked = false; @@ -498,20 +506,33 @@ export const dialogShellMethods = { return { top, left }; }, - saveModalPosition() { + saveModalPosition(position = null) { if (this.docked) return; const el = this.element; if (!el) return; + const top = Number(position?.top); + const left = Number(position?.left); + if (Number.isFinite(top) && Number.isFinite(left)) { + safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ + top: Math.round(top), + left: Math.round(left) + })); + return; + } + const rect = el.getBoundingClientRect(); - safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); + safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ + top: Math.round(rect.top), + left: Math.round(rect.left) + })); }, ensureModalHandleInViewport({ persist = false } = {}) { if (this.docked) return; if (this.fullscreen) return; const el = this.element; - if (!el || getComputedStyle(el).display === 'none') return; + if (!el || !this.isVisible()) return; const rect = el.getBoundingClientRect(); const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left); @@ -538,6 +559,20 @@ export const dialogShellMethods = { }); }, + getDockSnapThreshold() { + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; + return Math.max(40, Math.min(64, viewportWidth * 0.045)); + }, + + setDockDropPreviewActive(active) { + const nextActive = Boolean(active && !this.docked && !this.fullscreen); + if (this._dragDockCandidate === nextActive) return; + + this._dragDockCandidate = nextActive; + this.dockDropPreview?.classList.toggle('is-active', nextActive); + this.dockDropPreview?.setAttribute('aria-hidden', nextActive ? 'false' : 'true'); + }, + // Begin window drag startDrag(e) { if (this.docked) return; @@ -545,21 +580,58 @@ export const dialogShellMethods = { try { const el = this.element; if (!el) return; + e.preventDefault?.(); + if (this._dragLayerCleanupTimer) { + clearTimeout(this._dragLayerCleanupTimer); + this._dragLayerCleanupTimer = null; + } + this._pendingDragDockRect = null; + this.setDockDropPreviewActive(false); const rect = el.getBoundingClientRect(); + const vw = window.innerWidth || document.documentElement.clientWidth || 0; + const vh = window.innerHeight || document.documentElement.clientHeight || 0; + const pad = 4; + const handle = document.getElementById('model-resolver-drag-handle'); + + if (handle) { + const handleRect = handle.getBoundingClientRect(); + const handleOffsetLeft = handleRect.left - rect.left; + const handleOffsetTop = handleRect.top - rect.top; + const handleWidth = handleRect.width || handle.offsetWidth; + const handleHeight = handleRect.height || handle.offsetHeight; + this._dragBounds = { + minLeft: pad - handleOffsetLeft, + maxLeft: vw - pad - handleOffsetLeft - handleWidth, + minTop: pad - handleOffsetTop, + maxTop: vh - pad - handleOffsetTop - handleHeight + }; + } else { + this._dragBounds = { + minLeft: -rect.width + pad, + maxLeft: vw - pad, + minTop: -rect.height + pad, + maxTop: vh - pad + }; + } + // 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'; + el.style.willChange = 'transform'; this._dragging = true; this._dragStart = { x: e.clientX, y: e.clientY, top: rect.top, - left: rect.left + left: rect.left, + width: rect.width, + height: rect.height + }; + this._dragPendingPosition = { + top: Math.round(rect.top), + left: Math.round(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(); @@ -576,19 +648,73 @@ export const dialogShellMethods = { 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`; + const bounds = this._dragBounds; + if (bounds) { + left = Math.max(bounds.minLeft, Math.min(bounds.maxLeft, left)); + top = Math.max(bounds.minTop, Math.min(bounds.maxTop, top)); + } + + this._dragPendingPosition = { + top: Math.round(top), + left: Math.round(left) + }; + this.setDockDropPreviewActive(e.clientX <= this.getDockSnapThreshold()); + if (this._dragAnimationFrame) return; + + this._dragAnimationFrame = requestAnimationFrame(() => { + this._dragAnimationFrame = null; + if (!this._dragging || !this._dragStart || !this._dragPendingPosition) return; + + const translateX = this._dragPendingPosition.left - this._dragStart.left; + const translateY = this._dragPendingPosition.top - this._dragStart.top; + el.style.transform = `translate3d(${translateX}px, ${translateY}px, 0)`; + }); }, endDrag() { if (!this._dragging) return; + const shouldDock = this._dragDockCandidate; this._dragging = false; document.removeEventListener('mousemove', this._onMouseMove); + this.setDockDropPreviewActive(false); + + if (this._dragAnimationFrame) { + cancelAnimationFrame(this._dragAnimationFrame); + this._dragAnimationFrame = null; + } + + const el = this.element; + const finalPosition = this._dragPendingPosition || this._dragStart; + if (shouldDock && finalPosition && this._dragStart) { + this._pendingDragDockRect = { + top: Math.round(finalPosition.top), + left: Math.round(finalPosition.left), + width: this._dragStart.width, + height: this._dragStart.height + }; + } + if (el && finalPosition) { + el.style.top = `${Math.round(finalPosition.top)}px`; + el.style.left = `${Math.round(finalPosition.left)}px`; + el.style.transform = 'none'; + } + + this._dragStart = null; + this._dragBounds = null; + this._dragPendingPosition = null; // Persist position - this.saveModalPosition(); - // Restore selection - try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {} + this.saveModalPosition(finalPosition); + + this._dragLayerCleanupTimer = setTimeout(() => { + this._dragLayerCleanupTimer = null; + if (!this._dragging && this.element) { + this.element.style.willChange = ''; + } + }, 120); + + if (shouldDock) { + this.dockToSidebar(); + } }, /**