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();
+ }
},
/**