Make floating dialog drag compositor-friendly and avoid forced style reads
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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: `
|
||||
<span class="mr-dock-drop-preview-icon">${getSvgIcon('internalLink')}</span>
|
||||
<strong>Dock to sidebar</strong>
|
||||
<span>Release to dock Model Resolver</span>
|
||||
`
|
||||
});
|
||||
|
||||
// Create context menu for model chips
|
||||
this.contextMenu = $el("div.mr-context-menu", {
|
||||
parent: document.body
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user