diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs
index 81b3a26..d9051fd 100644
--- a/tests/test_downloads_tab_workflow_route.mjs
+++ b/tests/test_downloads_tab_workflow_route.mjs
@@ -143,14 +143,19 @@ test('floating dialog drag stays on the compositor without forced style reads',
const startDrag = extractMethod(dialogShellMethodsSource, 'startDrag');
const onDrag = eval(`(${extractMethod(dialogShellMethodsSource, 'onDrag')})`);
const endDrag = extractMethod(dialogShellMethodsSource, 'endDrag');
+ const startDockedDrag = extractMethod(dialogShellMethodsSource, 'startDockedDrag');
+ const onDockedDrag = extractMethod(dialogShellMethodsSource, 'onDockedDrag');
+ const endDockedDrag = extractMethod(dialogShellMethodsSource, 'endDockedDrag');
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, /if \(this\.docked\)\s*\{\s*this\.startDockedDrag\(e\)/);
assert.match(startDrag, /style\.willChange\s*=\s*['"]transform['"]/);
assert.match(startDrag, /setDockDropPreviewActive\(false\)/);
+ assert.match(startDrag, /updateDockDropPreviewWidth\(\)/);
assert.doesNotMatch(startDrag, /document\.body\.style\.userSelect/);
assert.doesNotMatch(startDrag, /classList\.add\(['"]mr-is-window-dragging['"]\)/);
assert.match(endDrag, /cancelAnimationFrame/);
@@ -163,6 +168,20 @@ test('floating dialog drag stays on the compositor without forced style reads',
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(resolverDialogSource, /mr-undock-drop-preview/);
+ assert.match(startDockedDrag, /getRememberedFloatingSize\(\)/);
+ assert.match(onDockedDrag, /Math\.hypot\(dx,\s*dy\)\s*<\s*5/);
+ assert.match(onDockedDrag, /requestAnimationFrame/);
+ assert.match(endDockedDrag, /this\.undockToFloating\(\{\s*persist:\s*false\s*\}\)/);
+ assert.match(endDockedDrag, /this\._floatingRectBeforeDock\s*=\s*\{\s*\.\.\.finalRect\s*\}/);
+ assert.match(
+ resolverShellCssSource,
+ /\.mr-undock-drop-preview\s*\{[^}]*pointer-events:\s*none[^}]*visibility:\s*hidden[^}]*opacity:\s*0/s
+ );
+ assert.match(
+ modelResolverSource,
+ /renderSidebarPanel\(element\)[\s\S]*?rememberDockDropPreviewWidth\?\.\(element\)/
+ );
assert.match(
resolverShellCssSource,
/\.mr-dock-drop-preview\s*\{[^}]*pointer-events:\s*none[^}]*visibility:\s*hidden[^}]*opacity:\s*0/s
@@ -201,6 +220,7 @@ test('floating dialog drag stays on the compositor without forced style reads',
_dragAnimationFrame: null,
getDockSnapThreshold: () => 64,
setDockDropPreviewActive: active => dockPreviewStates.push(active),
+ updateDockDropPreviewWidth: () => {},
};
onDrag.call(dialog, { clientX: 50, clientY: 70 });
diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js
index 3135495..b774847 100644
--- a/web/resolver/model_resolver.js
+++ b/web/resolver/model_resolver.js
@@ -668,6 +668,7 @@ export class ModelResolver {
this.dialog = new ResolverManagerDialog();
window.ModelResolverDialog = this.dialog;
}
+ this.dialog.rememberDockDropPreviewWidth?.(element);
if (this.dialog.shouldOpenFromSidebarFloating()) {
if (this.dialog.isVisible() && !this.dialog.docked) {
diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js
index 1cecead..47e90b3 100644
--- a/web/resolver/resolver_dialog.js
+++ b/web/resolver/resolver_dialog.js
@@ -95,6 +95,11 @@ export class ResolverManagerDialog extends ComfyDialog {
this._dragLayerCleanupTimer = null;
this._dragDockCandidate = false;
this._pendingDragDockRect = null;
+ this._lastDockContainerWidth = null;
+ this._dockedDragStart = null;
+ this._dockedDragPendingRect = null;
+ this._dockedDragAnimationFrame = null;
+ this._dragUndockCandidate = false;
this._analysisProgressToken = null;
this._workflowDataLoadToken = null;
this._loadedModelsLoadToken = null;
@@ -130,6 +135,16 @@ export class ResolverManagerDialog extends ComfyDialog {
`
});
+ this.undockDropPreview = $el("div.mr-undock-drop-preview", {
+ parent: document.body,
+ ariaHidden: "true",
+ innerHTML: `
+ ${getSvgIcon('externalLink')}
+ Undock to floating window
+ Release to undock 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 9da730f..c75bd5d 100644
--- a/web/resolver/shell/dialog_shell_methods.js
+++ b/web/resolver/shell/dialog_shell_methods.js
@@ -177,6 +177,7 @@ export const dialogShellMethods = {
dockTo(container) {
if (!container || !this.element) return;
+ this.rememberDockDropPreviewWidth(container);
if (!this.docked) {
if (this._pendingDragDockRect) {
this._floatingRectBeforeDock = this._pendingDragDockRect;
@@ -187,6 +188,7 @@ export const dialogShellMethods = {
}
this.setDockDropPreviewActive(false);
+ this.setUndockDropPreviewActive(false);
if (this.fullscreen) {
this.setFullScreen(false);
}
@@ -305,9 +307,13 @@ export const dialogShellMethods = {
if (!this.element) return;
this.setDockDropPreviewActive(false);
+ this.setUndockDropPreviewActive(false);
this._pendingDragDockRect = null;
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
+ if (wasDocked) {
+ this.rememberDockDropPreviewWidth(dockContainer);
+ }
this.docked = false;
this.dockContainer = null;
this.element.classList.remove('mr-is-docked');
@@ -564,6 +570,29 @@ export const dialogShellMethods = {
return Math.max(40, Math.min(64, viewportWidth * 0.045));
},
+ rememberDockDropPreviewWidth(container = this.lastDockContainer) {
+ if (!(container instanceof HTMLElement) || !container.isConnected) return;
+
+ const width = container.getBoundingClientRect().width;
+ if (Number.isFinite(width) && width > 0) {
+ this._lastDockContainerWidth = Math.round(width);
+ }
+ },
+
+ updateDockDropPreviewWidth() {
+ this.rememberDockDropPreviewWidth();
+ if (!this.dockDropPreview) return;
+
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
+ const availableWidth = Math.max(0, viewportWidth - 16);
+ const width = Math.min(Number(this._lastDockContainerWidth) || 0, availableWidth);
+ if (width > 0) {
+ this.dockDropPreview.style.width = `${Math.round(width)}px`;
+ } else {
+ this.dockDropPreview.style.removeProperty('width');
+ }
+ },
+
setDockDropPreviewActive(active) {
const nextActive = Boolean(active && !this.docked && !this.fullscreen);
if (this._dragDockCandidate === nextActive) return;
@@ -573,9 +602,149 @@ export const dialogShellMethods = {
this.dockDropPreview?.setAttribute('aria-hidden', nextActive ? 'false' : 'true');
},
+ getRememberedFloatingSize() {
+ const rememberedRect = this._floatingRectBeforeDock;
+ if (rememberedRect?.width > 0 && rememberedRect?.height > 0) {
+ return {
+ width: rememberedRect.width,
+ height: rememberedRect.height
+ };
+ }
+
+ let storedSize;
+ try {
+ storedSize = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null');
+ } catch {
+ storedSize = null;
+ }
+
+ return {
+ width: Number(storedSize?.w) || 1100,
+ height: Number(storedSize?.h) || 700
+ };
+ },
+
+ getDockedDragPreviewRect(clientX, clientY) {
+ const start = this._dockedDragStart;
+ if (!start) return null;
+
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
+ const width = Math.min(Math.max(start.width, 640), viewportWidth);
+ const height = Math.min(Math.max(start.height, 420), viewportHeight);
+ const maxLeft = Math.max(0, viewportWidth - width);
+ const maxTop = Math.max(0, viewportHeight - height);
+ const left = Math.max(0, Math.min(maxLeft, clientX - start.pointerOffsetLeft));
+ const top = Math.max(0, Math.min(maxTop, clientY - start.pointerOffsetTop));
+
+ return {
+ top: Math.round(top),
+ left: Math.round(left),
+ width: Math.round(width),
+ height: Math.round(height)
+ };
+ },
+
+ setUndockDropPreviewActive(active) {
+ const nextActive = Boolean(active && this.docked && !this.fullscreen);
+ if (this._dragUndockCandidate === nextActive) return;
+
+ this._dragUndockCandidate = nextActive;
+ this.undockDropPreview?.classList.toggle('is-active', nextActive);
+ this.undockDropPreview?.setAttribute('aria-hidden', nextActive ? 'false' : 'true');
+ },
+
+ startDockedDrag(e) {
+ const el = this.element;
+ if (!el || !this.docked || this.fullscreen) return;
+
+ e.preventDefault?.();
+ this.setDockDropPreviewActive(false);
+ this.setUndockDropPreviewActive(false);
+
+ const rect = el.getBoundingClientRect();
+ const size = this.getRememberedFloatingSize();
+ this._dockedDragStart = {
+ x: e.clientX,
+ y: e.clientY,
+ pointerOffsetLeft: Math.max(0, e.clientX - rect.left),
+ pointerOffsetTop: Math.max(0, e.clientY - rect.top),
+ width: size.width,
+ height: size.height
+ };
+ this._dockedDragPendingRect = this.getDockedDragPreviewRect(e.clientX, e.clientY);
+
+ if (this.undockDropPreview && this._dockedDragPendingRect) {
+ this.undockDropPreview.style.width = `${this._dockedDragPendingRect.width}px`;
+ this.undockDropPreview.style.height = `${this._dockedDragPendingRect.height}px`;
+ this.undockDropPreview.style.transform = `translate3d(${this._dockedDragPendingRect.left}px, ${this._dockedDragPendingRect.top}px, 0)`;
+ this.undockDropPreview.style.willChange = 'transform';
+ }
+
+ this._onMouseMove = (ev) => this.onDockedDrag(ev);
+ this._onMouseUp = () => this.endDockedDrag();
+ document.addEventListener('mousemove', this._onMouseMove);
+ document.addEventListener('mouseup', this._onMouseUp, { once: true });
+ },
+
+ onDockedDrag(e) {
+ const start = this._dockedDragStart;
+ if (!start) return;
+
+ const dx = e.clientX - start.x;
+ const dy = e.clientY - start.y;
+ if (!this._dragUndockCandidate && Math.hypot(dx, dy) < 5) return;
+
+ this._dockedDragPendingRect = this.getDockedDragPreviewRect(e.clientX, e.clientY);
+ this.setUndockDropPreviewActive(true);
+ if (this._dockedDragAnimationFrame || !this._dockedDragPendingRect) return;
+
+ this._dockedDragAnimationFrame = requestAnimationFrame(() => {
+ this._dockedDragAnimationFrame = null;
+ if (!this._dragUndockCandidate || !this._dockedDragPendingRect || !this.undockDropPreview) return;
+
+ const { left, top } = this._dockedDragPendingRect;
+ this.undockDropPreview.style.transform = `translate3d(${left}px, ${top}px, 0)`;
+ });
+ },
+
+ endDockedDrag() {
+ if (!this._dockedDragStart) return;
+
+ const shouldUndock = this._dragUndockCandidate;
+ const finalRect = shouldUndock ? this._dockedDragPendingRect : null;
+ document.removeEventListener('mousemove', this._onMouseMove);
+ this.setUndockDropPreviewActive(false);
+
+ if (this._dockedDragAnimationFrame) {
+ cancelAnimationFrame(this._dockedDragAnimationFrame);
+ this._dockedDragAnimationFrame = null;
+ }
+
+ if (this.undockDropPreview) {
+ this.undockDropPreview.style.willChange = '';
+ }
+ this._dockedDragStart = null;
+ this._dockedDragPendingRect = null;
+
+ if (finalRect) {
+ this.undockToFloating({ persist: false });
+ this.element.style.width = `${finalRect.width}px`;
+ this.element.style.height = `${finalRect.height}px`;
+ this.element.style.top = `${finalRect.top}px`;
+ this.element.style.left = `${finalRect.left}px`;
+ this.element.style.transform = 'none';
+ this._floatingRectBeforeDock = { ...finalRect };
+ this.saveModalPosition(finalRect);
+ }
+ },
+
// Begin window drag
startDrag(e) {
- if (this.docked) return;
+ if (this.docked) {
+ this.startDockedDrag(e);
+ return;
+ }
try {
const el = this.element;
@@ -587,6 +756,7 @@ export const dialogShellMethods = {
}
this._pendingDragDockRect = null;
this.setDockDropPreviewActive(false);
+ this.updateDockDropPreviewWidth();
const rect = el.getBoundingClientRect();
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js
index 569d23d..d07f1af 100644
--- a/web/resolver/shell/lifecycle_graph_methods.js
+++ b/web/resolver/shell/lifecycle_graph_methods.js
@@ -66,8 +66,9 @@ export const lifecycleGraphMethods = {
if (!handle || !topbar) return;
const onMouseDown = (e) => {
- if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
+ if (this.fullscreen) return;
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
+ if (this.docked && !e.target?.closest?.('#model-resolver-drag-handle')) return;
topbar.classList.add('mr-is-dragging');
this.startDrag(e);
};
@@ -97,6 +98,8 @@ export const lifecycleGraphMethods = {
close({ collapseSidebar = true } = {}) {
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
+ this.setDockDropPreviewActive(false);
+ this.setUndockDropPreviewActive(false);
this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating');
this._hidePreview?.();
this.hideTooltip();