Make floating dialog drag compositor-friendly and avoid forced style reads

This commit is contained in:
Dariusz L
2026-07-27 19:33:19 +02:00
parent f6db340116
commit c62a88f86a
4 changed files with 249 additions and 17 deletions
@@ -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;
+1 -2
View File
@@ -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(() => {
+16
View File
@@ -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
+141 -15
View File
@@ -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();
}
},
/**