feat: add docked drag-to-undock behavior for Model Resolver
Allow dragging the docked Model Resolver dialog from its drag handle to undock it into a floating window, with a live undock drop preview. - Remember the dock container width and reuse it for the undock preview - Restore the pre-dock floating size instead of falling back to defaults - Restrict topbar drag while docked to the dedicated drag handle - Update tests to cover the new docked drag lifecycle and preview markup
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: `
|
||||
<span class="mr-dock-drop-preview-icon">${getSvgIcon('externalLink')}</span>
|
||||
<strong>Undock to floating window</strong>
|
||||
<span>Release to undock Model Resolver</span>
|
||||
`
|
||||
});
|
||||
|
||||
// Create context menu for model chips
|
||||
this.contextMenu = $el("div.mr-context-menu", {
|
||||
parent: document.body
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user