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();