fix: preserve user queue, selections, and detail state during workflow refreshes and prevent open reload flicker
This commit is contained in:
@@ -3388,6 +3388,114 @@ test('workflow synchronization carries searched results across a strength-only s
|
||||
assert.equal(dialog.preserveSearchCacheAcrossNextWorkflowSync, false);
|
||||
});
|
||||
|
||||
test('same route signature update carries forward pending resolutions and download target selections', () => {
|
||||
const syncWorkflowScopedQueue = eval(
|
||||
`(${extractMethod(workflowStateMethodsSource, 'syncWorkflowScopedQueue')})`
|
||||
);
|
||||
const workflow = { nodes: [{ id: 1, type: 'LoraLoader' }] };
|
||||
const queuedItem = { missing_key: 'lora:test', target_path: 'E:/models/test.safetensors' };
|
||||
const savedTarget = { category: 'loras', subfolder: 'anime' };
|
||||
const pendingSelections = new Map([
|
||||
['workflow-a\nold-signature', [queuedItem]],
|
||||
]);
|
||||
const targetSelections = new Map([
|
||||
['workflow-a\nold-signature', new Map([['missing-1', savedTarget]])],
|
||||
]);
|
||||
let queueVisibilityUpdated = false;
|
||||
const dialog = {
|
||||
activeWorkflowRouteKey: 'workflow-a',
|
||||
activeWorkflowSignature: 'old-signature',
|
||||
activeMissingWorkflowSignature: 'old-missing-signature',
|
||||
workflowPendingSelections: pendingSelections,
|
||||
workflowDownloadTargetSelections: targetSelections,
|
||||
pendingResolutions: [queuedItem],
|
||||
downloadTargetSelections: new Map([['missing-1', savedTarget]]),
|
||||
searchResultCache: new Map(),
|
||||
getCurrentWorkflow: () => workflow,
|
||||
getActiveWorkflowRouteKey: () => 'workflow-a',
|
||||
getWorkflowSignature: () => 'new-signature',
|
||||
getMissingWorkflowSignature: () => 'new-missing-signature',
|
||||
getWorkflowScopedQueueKey(route = dialog.activeWorkflowRouteKey, signature = dialog.activeMissingWorkflowSignature || dialog.activeWorkflowSignature) {
|
||||
return `${route}\n${signature}`;
|
||||
},
|
||||
cloneSearchResultCache(cache) { return new Map(cache); },
|
||||
clonePendingResolutions(list) { return list.map(item => ({ ...item })); },
|
||||
cloneDownloadTargetSelections(map) { return new Map(map); },
|
||||
savePendingQueueForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
this.workflowPendingSelections.set(key, this.clonePendingResolutions(this.pendingResolutions));
|
||||
},
|
||||
saveAnalysisCacheForActiveWorkflow() {},
|
||||
saveLoadedModelsCacheForActiveWorkflow() {},
|
||||
saveSearchCacheForActiveWorkflow() {},
|
||||
saveDownloadTargetSelectionsForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
this.workflowDownloadTargetSelections.set(key, this.cloneDownloadTargetSelections(this.downloadTargetSelections));
|
||||
},
|
||||
clearWorkflowScopedState() {
|
||||
this.pendingResolutions = [];
|
||||
this.downloadTargetSelections = new Map();
|
||||
},
|
||||
restorePendingQueueForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
this.pendingResolutions = this.workflowPendingSelections.get(key) || [];
|
||||
},
|
||||
restoreAnalysisCacheForActiveWorkflow() {},
|
||||
restoreLoadedModelsCacheForActiveWorkflow() {},
|
||||
restoreSearchCacheForActiveWorkflow() {},
|
||||
restoreDownloadTargetSelectionsForActiveWorkflow() {
|
||||
const key = this.getWorkflowScopedQueueKey();
|
||||
this.downloadTargetSelections = this.workflowDownloadTargetSelections.get(key) || new Map();
|
||||
},
|
||||
rebuildPendingIndex() {},
|
||||
updateApplyPendingButton() {},
|
||||
updateQueuePanel() {},
|
||||
updateQueueVisibility() { queueVisibilityUpdated = true; },
|
||||
};
|
||||
syncWorkflowScopedQueue.call(dialog, workflow);
|
||||
assert.equal(dialog.pendingResolutions.length, 1);
|
||||
assert.deepEqual(dialog.pendingResolutions[0], queuedItem);
|
||||
assert.equal(dialog.downloadTargetSelections.get('missing-1'), savedTarget);
|
||||
assert.equal(queueVisibilityUpdated, true);
|
||||
});
|
||||
|
||||
test('refreshForActiveWorkflowChange preserves content for window-focus and visibility on the same route', async () => {
|
||||
const log = { debug() {} };
|
||||
const refreshForActiveWorkflowChange = eval(
|
||||
`(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})`
|
||||
);
|
||||
const workflow = { nodes: [{ id: 10, type: 'CheckpointLoaderSimple' }] };
|
||||
let loadArguments = null;
|
||||
const dialog = {
|
||||
_workflowRefreshGeneration: 1,
|
||||
activeWorkflowRouteKey: 'workflow-a',
|
||||
activeWorkflowSignature: 'old-signature',
|
||||
activeMissingWorkflowSignature: 'old-missing-signature',
|
||||
activeTab: 'missing',
|
||||
contentElement: { style: {} },
|
||||
isVisible: () => true,
|
||||
getActiveWorkflowRouteKey: () => 'workflow-a',
|
||||
getCurrentWorkflow: () => workflow,
|
||||
getWorkflowSignature: () => 'new-signature',
|
||||
getMissingWorkflowSignature: () => 'new-missing-signature',
|
||||
syncWorkflowScopedQueue() {},
|
||||
async loadWorkflowData(...args) {
|
||||
loadArguments = args;
|
||||
},
|
||||
};
|
||||
await refreshForActiveWorkflowChange.call(dialog, {
|
||||
reason: 'window-focus',
|
||||
expectedRoute: 'workflow-a',
|
||||
previousSignature: 'old-signature',
|
||||
attempt: 8,
|
||||
generation: 1,
|
||||
candidateRoute: 'workflow-a',
|
||||
candidateSignature: 'new-signature',
|
||||
});
|
||||
assert.equal(loadArguments[0], workflow);
|
||||
assert.deepEqual(loadArguments[1], { preserveContent: true });
|
||||
});
|
||||
|
||||
test('pending node context model resolves against the current workflow analysis before action', async () => {
|
||||
const resolveNodeContextMenuModel = eval(`(${extractMethod(modelResolverSource, 'resolveNodeContextMenuModel')})`);
|
||||
const pendingModel = {
|
||||
|
||||
@@ -886,8 +886,9 @@ test('metadata transfer renders locate button for target nodes', () => {
|
||||
assert.match(html, /data-image-transfer-locate/);
|
||||
assert.match(html, /data-node-id="1"/);
|
||||
assert.match(html, /data-tooltip="Center this node in the ComfyUI graph\."/);
|
||||
assert.match(html, /aria-label="Center CLIPLoader in the ComfyUI graph"/);
|
||||
assert.match(html, /aria-label="Center CLIPLoader · Node 1[^"]* in the ComfyUI graph"/);
|
||||
assert.match(html, /class="mr-node-chip-icon"/);
|
||||
assert.match(html, /class="mr-missing-row-node-label">CLIPLoader · Node 1/);
|
||||
});
|
||||
|
||||
test('metadata transfer locate button triggers locateNodeInGraph and does not toggle node selection', () => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tabsLoadedMethods } from '../web/resolver/views/tabs_loaded_methods.js';
|
||||
import { safeStorage } from '../web/resolver/utils/html_utils.js';
|
||||
|
||||
globalThis.safeStorage = safeStorage;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const lifecycleGraphMethodsSource = fs.readFileSync(
|
||||
path.join(repoRoot, 'web/resolver/shell/lifecycle_graph_methods.js'),
|
||||
'utf8'
|
||||
);
|
||||
const queueMethodsSource = fs.readFileSync(
|
||||
path.join(repoRoot, 'web/resolver/actions/queue_methods.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function extractMethod(source, methodName, paramsPattern = '[^)]*') {
|
||||
const signatureRegex = new RegExp(`\\n\\s+(?:async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`);
|
||||
const match = signatureRegex.exec(source);
|
||||
assert.ok(match, `Could not find ${methodName}`);
|
||||
const parenStart = source.indexOf('(', match.index);
|
||||
const parenEnd = source.indexOf(')', parenStart);
|
||||
const braceStart = source.indexOf('{', parenEnd);
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') depth -= 1;
|
||||
if (depth === 0) {
|
||||
const params = source.slice(parenStart + 1, parenEnd);
|
||||
const isAsync = match[0].includes('async');
|
||||
return `${isAsync ? 'async ' : ''}function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not parse ${methodName}`);
|
||||
}
|
||||
|
||||
const animateTabContentTransition = eval(`(${extractMethod(queueMethodsSource, 'animateTabContentTransition')})`);
|
||||
const closeDialog = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'close')})`);
|
||||
const showContent = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'showContent')})`);
|
||||
|
||||
test('switchTab calls animateTabContentTransition by default', () => {
|
||||
let animationCalled = 0;
|
||||
const ctx = {
|
||||
...tabsLoadedMethods,
|
||||
activeTab: 'missing',
|
||||
getValidTab: (t) => t,
|
||||
getTabButton: () => null,
|
||||
persistActiveTab: () => {},
|
||||
hideTooltip: () => {},
|
||||
animateTabContentTransition: () => { animationCalled++; },
|
||||
updateTabButtonStates: () => {},
|
||||
updateQueueVisibility: () => {},
|
||||
setMissingFooterControlsVisible: () => {},
|
||||
loadLoadedModels: () => 'loaded-loaded',
|
||||
contentElement: { style: {} },
|
||||
};
|
||||
|
||||
const res = ctx.switchTab('loaded');
|
||||
assert.equal(animationCalled, 1);
|
||||
assert.equal(res, 'loaded-loaded');
|
||||
assert.equal(ctx.activeTab, 'loaded');
|
||||
});
|
||||
|
||||
test('switchTab skips animateTabContentTransition when animate is false', () => {
|
||||
let animationCalled = 0;
|
||||
const ctx = {
|
||||
...tabsLoadedMethods,
|
||||
activeTab: 'missing',
|
||||
getValidTab: (t) => t,
|
||||
getTabButton: () => null,
|
||||
persistActiveTab: () => {},
|
||||
hideTooltip: () => {},
|
||||
animateTabContentTransition: () => { animationCalled++; },
|
||||
updateTabButtonStates: () => {},
|
||||
updateQueueVisibility: () => {},
|
||||
setMissingFooterControlsVisible: () => {},
|
||||
loadLoadedModels: () => 'loaded-loaded',
|
||||
contentElement: { style: {} },
|
||||
};
|
||||
|
||||
const res = ctx.switchTab('loaded', { force: true, animate: false });
|
||||
assert.equal(animationCalled, 0);
|
||||
assert.equal(res, 'loaded-loaded');
|
||||
assert.equal(ctx.activeTab, 'loaded');
|
||||
});
|
||||
|
||||
test('switchTab forwards workflow argument to loadWorkflowData and loadLoadedModels', () => {
|
||||
let passedMissingWorkflow = null;
|
||||
let passedLoadedWorkflow = null;
|
||||
const customWorkflow = { nodes: [{ id: 42 }] };
|
||||
|
||||
const ctx = {
|
||||
...tabsLoadedMethods,
|
||||
activeTab: 'loaded',
|
||||
getValidTab: (t) => t,
|
||||
getTabButton: () => null,
|
||||
persistActiveTab: () => {},
|
||||
hideTooltip: () => {},
|
||||
animateTabContentTransition: () => {},
|
||||
updateTabButtonStates: () => {},
|
||||
updateQueueVisibility: () => {},
|
||||
setMissingFooterControlsVisible: () => {},
|
||||
loadWorkflowData: (wf) => { passedMissingWorkflow = wf; },
|
||||
loadLoadedModels: (wf) => { passedLoadedWorkflow = wf; },
|
||||
contentElement: { style: {} },
|
||||
};
|
||||
|
||||
ctx.switchTab('missing', { force: true, animate: false, workflow: customWorkflow });
|
||||
assert.equal(passedMissingWorkflow, customWorkflow);
|
||||
|
||||
ctx.switchTab('loaded', { force: true, animate: false, workflow: customWorkflow });
|
||||
assert.equal(passedLoadedWorkflow, customWorkflow);
|
||||
});
|
||||
|
||||
test('animateTabContentTransition cancels any running animations on contentElement', () => {
|
||||
let cancelCount = 0;
|
||||
let animatedKeyframes = null;
|
||||
let animatedOptions = null;
|
||||
const dummyAnim = { cancel: () => { cancelCount++; } };
|
||||
|
||||
const ctx = {
|
||||
contentElement: {
|
||||
getAnimations: () => [dummyAnim, dummyAnim],
|
||||
animate: (keyframes, opts) => {
|
||||
animatedKeyframes = keyframes;
|
||||
animatedOptions = opts;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
animateTabContentTransition.call(ctx);
|
||||
assert.equal(cancelCount, 2);
|
||||
assert.deepEqual(animatedKeyframes, [
|
||||
{ opacity: 0.72, transform: 'translateY(7px)' },
|
||||
{ opacity: 1, transform: 'translateY(0)' }
|
||||
]);
|
||||
assert.equal(animatedOptions.duration, 180);
|
||||
});
|
||||
|
||||
test('close cancels running animations on contentElement', () => {
|
||||
let cancelCount = 0;
|
||||
const dummyAnim = { cancel: () => { cancelCount++; } };
|
||||
|
||||
const ctx = {
|
||||
docked: false,
|
||||
dockContainer: null,
|
||||
rememberSidebarOpenMode: () => {},
|
||||
setDockDropPreviewActive: () => {},
|
||||
setUndockDropPreviewActive: () => {},
|
||||
hideTooltip: () => {},
|
||||
backdrop: { style: {} },
|
||||
element: { style: {} },
|
||||
contentElement: {
|
||||
getAnimations: () => [dummyAnim],
|
||||
},
|
||||
};
|
||||
|
||||
closeDialog.call(ctx);
|
||||
assert.equal(cancelCount, 1);
|
||||
assert.equal(ctx.element.style.display, 'none');
|
||||
assert.equal(ctx.backdrop.style.display, 'none');
|
||||
});
|
||||
|
||||
test('showContent restores loaded/metadata/options tabs without animation', async () => {
|
||||
for (const targetTab of ['loaded', 'metadata', 'options']) {
|
||||
const switchCalls = [];
|
||||
const ctx = {
|
||||
backdrop: { style: {} },
|
||||
element: { style: {} },
|
||||
scheduleModalViewportClamp: () => {},
|
||||
loadFooterVersion: async () => {},
|
||||
updateDownloadAllButtonState: () => {},
|
||||
_serverSettingsLoaded: true,
|
||||
ensureCapabilitiesLoaded: async () => {},
|
||||
ensureBaseModelsLoaded: async () => {},
|
||||
ensureAllModelsLoaded: async () => {},
|
||||
ensureDownloadDirectoriesLoaded: async () => {},
|
||||
attachDragHandleIfNeeded: () => {},
|
||||
restoreActiveTab: () => targetTab,
|
||||
updateTabButtonStates: () => {},
|
||||
updateQueueVisibility: () => {},
|
||||
syncWorkflowScopedQueue: () => {},
|
||||
getCurrentWorkflow: () => ({ id: 'wf' }),
|
||||
restoreActiveDownloadsFromBackend: async () => {},
|
||||
switchTab: async (tab, opts) => {
|
||||
switchCalls.push({ tab, opts });
|
||||
},
|
||||
};
|
||||
|
||||
await showContent.call(ctx);
|
||||
assert.equal(switchCalls.length, 1);
|
||||
assert.equal(switchCalls[0].tab, targetTab);
|
||||
assert.equal(switchCalls[0].opts.animate, false);
|
||||
assert.equal(switchCalls[0].opts.force, true);
|
||||
}
|
||||
});
|
||||
+14
-16
@@ -5083,7 +5083,7 @@ button.mr-info-link:disabled {
|
||||
.mr-image-inspector-models .mr-copy-section {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
background: transparent;
|
||||
background: var(--mr-bg, #121212);
|
||||
}
|
||||
|
||||
.mr-image-inspector-loading {
|
||||
@@ -6081,20 +6081,18 @@ button.mr-info-link:disabled {
|
||||
.mr-image-transfer-node-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mr-spacing-8);
|
||||
gap: var(--mr-spacing-10);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-select {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--mr-spacing-10);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -6105,16 +6103,15 @@ button.mr-info-link:disabled {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-image-transfer-node-locate {
|
||||
padding: var(--mr-spacing-2) var(--mr-spacing-6);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
transition: var(--mr-missing-control-transition, background 0.15s ease, border-color 0.15s ease, color 0.15s ease), opacity 0.15s ease;
|
||||
.mr-image-transfer-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#model-resolver-modal .mr-image-transfer-node-locate:hover,
|
||||
#model-resolver-modal .mr-image-transfer-node-locate:focus-visible {
|
||||
opacity: 1;
|
||||
#model-resolver-modal .mr-image-transfer-node-locate {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-select:disabled {
|
||||
@@ -6147,6 +6144,7 @@ button.mr-info-link:disabled {
|
||||
background: var(--mr-accent);
|
||||
}
|
||||
|
||||
.mr-image-transfer-node-target:hover .mr-image-transfer-node-select:not(:disabled) .mr-image-transfer-node-check,
|
||||
.mr-image-transfer-node-select:hover:not(:disabled) .mr-image-transfer-node-check {
|
||||
border-color: rgba(114, 211, 138, 0.62);
|
||||
}
|
||||
|
||||
@@ -2927,6 +2927,11 @@ export const queueMethods = {
|
||||
if (!this.contentElement?.animate) return;
|
||||
|
||||
try {
|
||||
if (typeof this.contentElement.getAnimations === 'function') {
|
||||
for (const anim of this.contentElement.getAnimations()) {
|
||||
anim.cancel();
|
||||
}
|
||||
}
|
||||
this.contentElement.animate(
|
||||
[
|
||||
{ opacity: 0.72, transform: 'translateY(7px)' },
|
||||
|
||||
@@ -14,53 +14,73 @@ export const lifecycleGraphMethods = {
|
||||
},
|
||||
|
||||
async showContent(workflow = null, { restoreFullscreen = true, preserveCurrentView = false } = {}) {
|
||||
this.backdrop.style.display = "none";
|
||||
this.element.style.display = "flex";
|
||||
void this.loadFooterVersion();
|
||||
if (!this.docked) {
|
||||
this.scheduleModalViewportClamp();
|
||||
this._isOpeningContent = true;
|
||||
if (this._workflowRefreshTimer) {
|
||||
clearTimeout(this._workflowRefreshTimer);
|
||||
this._workflowRefreshTimer = null;
|
||||
}
|
||||
if (this._workflowRefreshRetryTimer) {
|
||||
clearTimeout(this._workflowRefreshRetryTimer);
|
||||
this._workflowRefreshRetryTimer = null;
|
||||
}
|
||||
|
||||
// Update button state in case there are active downloads
|
||||
this.updateDownloadAllButtonState();
|
||||
try {
|
||||
this.backdrop.style.display = "none";
|
||||
this.element.style.display = "flex";
|
||||
void this.loadFooterVersion();
|
||||
if (!this.docked) {
|
||||
this.scheduleModalViewportClamp();
|
||||
}
|
||||
|
||||
// On first open: load settings from server so tokens are available on
|
||||
// every browser without re-entering them.
|
||||
if (!this._serverSettingsLoaded) {
|
||||
this._serverSettingsLoaded = true;
|
||||
await this.loadSettingsFromServer();
|
||||
}
|
||||
// Update button state in case there are active downloads
|
||||
this.updateDownloadAllButtonState();
|
||||
|
||||
// Ensure all models are loaded for dropdown
|
||||
await this.ensureCapabilitiesLoaded();
|
||||
await this.ensureBaseModelsLoaded();
|
||||
await this.ensureAllModelsLoaded();
|
||||
await this.ensureDownloadDirectoriesLoaded();
|
||||
// On first open: load settings from server so tokens are available on
|
||||
// every browser without re-entering them.
|
||||
if (!this._serverSettingsLoaded) {
|
||||
this._serverSettingsLoaded = true;
|
||||
await this.loadSettingsFromServer();
|
||||
}
|
||||
|
||||
const fs = safeStorage.getItem('model_resolver_modal_fullscreen');
|
||||
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
|
||||
// Ensure all models are loaded for dropdown
|
||||
await this.ensureCapabilitiesLoaded();
|
||||
await this.ensureBaseModelsLoaded();
|
||||
await this.ensureAllModelsLoaded();
|
||||
await this.ensureDownloadDirectoriesLoaded();
|
||||
|
||||
// Attach drag handle event listener (only once)
|
||||
this.attachDragHandleIfNeeded();
|
||||
const fs = safeStorage.getItem('model_resolver_modal_fullscreen');
|
||||
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
|
||||
|
||||
if (!preserveCurrentView) {
|
||||
this.activeTab = this.restoreActiveTab();
|
||||
}
|
||||
this.updateTabButtonStates();
|
||||
this.updateQueueVisibility();
|
||||
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
|
||||
await this.restoreActiveDownloadsFromBackend?.();
|
||||
// Attach drag handle event listener (only once)
|
||||
this.attachDragHandleIfNeeded();
|
||||
|
||||
if (preserveCurrentView) return;
|
||||
if (!preserveCurrentView) {
|
||||
this.activeTab = this.restoreActiveTab();
|
||||
}
|
||||
this.updateTabButtonStates();
|
||||
this.updateQueueVisibility();
|
||||
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
|
||||
await this.restoreActiveDownloadsFromBackend?.();
|
||||
|
||||
if (this.activeTab === 'missing') {
|
||||
await this.loadWorkflowData(workflow);
|
||||
} else if (this.activeTab === 'loaded') {
|
||||
this.switchTab('loaded', { force: true });
|
||||
} else if (this.activeTab === 'metadata') {
|
||||
this.switchTab('metadata', { force: true });
|
||||
} else {
|
||||
this.switchTab('options', { force: true });
|
||||
if (preserveCurrentView) return;
|
||||
|
||||
if (this.activeTab === 'missing') {
|
||||
const hasExistingBrowser = Boolean(
|
||||
this.contentElement?.querySelector?.('.mr-missing-browser')
|
||||
);
|
||||
await this.loadWorkflowData(workflow, {
|
||||
preserveContent: hasExistingBrowser
|
||||
});
|
||||
} else if (this.activeTab === 'loaded') {
|
||||
await this.switchTab('loaded', { force: true, animate: false, workflow });
|
||||
} else if (this.activeTab === 'metadata') {
|
||||
await this.switchTab('metadata', { force: true, animate: false });
|
||||
} else {
|
||||
await this.switchTab('options', { force: true, animate: false });
|
||||
}
|
||||
} finally {
|
||||
this._isOpeningContent = false;
|
||||
this._lastOpenContentTimestamp = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -124,6 +144,12 @@ export const lifecycleGraphMethods = {
|
||||
this._metadataPasteCleanup = null;
|
||||
}
|
||||
|
||||
if (typeof this.contentElement?.getAnimations === 'function') {
|
||||
for (const anim of this.contentElement.getAnimations()) {
|
||||
anim.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
if (wasDocked && collapseSidebar) {
|
||||
this.closeComfySidebar(dockContainer);
|
||||
}
|
||||
|
||||
@@ -691,7 +691,11 @@ export const workflowStateMethods = {
|
||||
nextRoute,
|
||||
nextMissingSignature || nextSignature
|
||||
);
|
||||
const preserveSearchCache = Boolean(this.preserveSearchCacheAcrossNextWorkflowSync);
|
||||
const sameRoute = Boolean(nextRoute && nextRoute === this.activeWorkflowRouteKey);
|
||||
const preserveSearchCache = Boolean(
|
||||
this.preserveSearchCacheAcrossNextWorkflowSync
|
||||
|| sameRoute
|
||||
);
|
||||
const previousSearchCache = preserveSearchCache
|
||||
? this.cloneSearchResultCache(this.searchResultCache, {
|
||||
preserveActive: true,
|
||||
@@ -730,6 +734,17 @@ export const workflowStateMethods = {
|
||||
this.activeWorkflowSignature = nextSignature;
|
||||
this.activeMissingWorkflowSignature = nextMissingSignature;
|
||||
this.restorePendingQueueForActiveWorkflow();
|
||||
if (sameRoute && previousKey && (!this.pendingResolutions || !this.pendingResolutions.length)) {
|
||||
const previousPending = this.workflowPendingSelections?.get?.(previousKey);
|
||||
if (Array.isArray(previousPending) && previousPending.length) {
|
||||
this.pendingResolutions = this.clonePendingResolutions(previousPending);
|
||||
this.savePendingQueueForActiveWorkflow?.();
|
||||
this.rebuildPendingIndex?.();
|
||||
this.updateApplyPendingButton?.();
|
||||
this.updateQueuePanel?.();
|
||||
this.updateQueueVisibility?.();
|
||||
}
|
||||
}
|
||||
this.restoreAnalysisCacheForActiveWorkflow();
|
||||
this.restoreLoadedModelsCacheForActiveWorkflow();
|
||||
this.restoreSearchCacheForActiveWorkflow();
|
||||
@@ -756,6 +771,13 @@ export const workflowStateMethods = {
|
||||
}
|
||||
this.preserveSearchCacheAcrossNextWorkflowSync = false;
|
||||
this.restoreDownloadTargetSelectionsForActiveWorkflow();
|
||||
if (sameRoute && previousKey && (!this.downloadTargetSelections || !this.downloadTargetSelections.size)) {
|
||||
const previousTargets = this.workflowDownloadTargetSelections?.get?.(previousKey);
|
||||
if (previousTargets && previousTargets.size) {
|
||||
this.downloadTargetSelections = this.cloneDownloadTargetSelections(previousTargets);
|
||||
this.saveDownloadTargetSelectionsForActiveWorkflow?.();
|
||||
}
|
||||
}
|
||||
if (preservedActiveSearchMissingModels.length) {
|
||||
this.syncSearchProgressAfterResume?.(preservedActiveSearchMissingModels, { workflowKey: nextKey });
|
||||
}
|
||||
@@ -816,6 +838,14 @@ export const workflowStateMethods = {
|
||||
|
||||
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
|
||||
if (!this.isVisible() || Number(this._applyWorkflowRefreshSuppressionDepth || 0) > 0) return;
|
||||
if (this._isOpeningContent) return;
|
||||
if (
|
||||
this._lastOpenContentTimestamp
|
||||
&& (Date.now() - this._lastOpenContentTimestamp < 800)
|
||||
&& ['window-focus', 'visibility-change'].includes(reason)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = (this._workflowRefreshGeneration || 0) + 1;
|
||||
this._workflowRefreshGeneration = generation;
|
||||
@@ -924,13 +954,10 @@ export const workflowStateMethods = {
|
||||
tab: this.activeTab
|
||||
});
|
||||
|
||||
const preserveWorkflowContent = (
|
||||
!routeChanged
|
||||
&& String(reason || '').startsWith('node-')
|
||||
);
|
||||
const preserveWorkflowContent = !routeChanged;
|
||||
|
||||
this.preserveSearchCacheAcrossNextWorkflowSync = Boolean(
|
||||
!routeChanged && reason === 'node-widget-change'
|
||||
!routeChanged
|
||||
);
|
||||
this.syncWorkflowScopedQueue(workflow);
|
||||
|
||||
@@ -951,7 +978,7 @@ export const workflowStateMethods = {
|
||||
} else if (this.activeTab === 'metadata') {
|
||||
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
|
||||
await this.openMetadataTransfer?.({
|
||||
preserveContent: !routeChanged && String(reason || '').startsWith('node-'),
|
||||
preserveContent: !routeChanged,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,10 +962,19 @@ export const missingBrowserMethods = {
|
||||
|
||||
const currentDetail = currentBrowser.querySelector('.mr-missing-detail-pane');
|
||||
const nextDetail = nextBrowser.querySelector('.mr-missing-detail-pane');
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
const isDetailActive = Boolean(
|
||||
currentDetail && (
|
||||
(activeElement && currentDetail.contains(activeElement) && (
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(activeElement.tagName) || activeElement.isContentEditable
|
||||
))
|
||||
|| currentDetail.querySelector('.mr-tree-picker-dropdown:not([hidden]), [data-ml-dropdown-open], .mr-combo-list:not([hidden]), [aria-expanded="true"]')
|
||||
)
|
||||
);
|
||||
this._missingBrowserDetailPreserved = Boolean(
|
||||
currentDetail
|
||||
&& nextDetail
|
||||
&& currentDetail.outerHTML === nextDetail.outerHTML
|
||||
&& (isDetailActive || currentDetail.outerHTML === nextDetail.outerHTML)
|
||||
);
|
||||
if (currentDetail && nextDetail && !this._missingBrowserDetailPreserved) {
|
||||
currentDetail.innerHTML = nextDetail.innerHTML;
|
||||
|
||||
@@ -2429,36 +2429,46 @@ export const imageMetadataMethods = {
|
||||
const locateTooltip = node.isTopLevel === false
|
||||
? 'Open this subgraph and center the node in the ComfyUI graph.'
|
||||
: 'Center this node in the ComfyUI graph.';
|
||||
const locateButtonHtml = canLocate
|
||||
const nodeDisplayTitle = nodeName === `Node ${node.nodeId}`
|
||||
? `${nodeName}${scopeLabel}`
|
||||
: `${nodeName} · Node ${node.nodeId}${scopeLabel}`;
|
||||
const locateNodeHtml = canLocate
|
||||
? `
|
||||
<button type="button"
|
||||
class="mr-node-chip is-locatable mr-missing-row-locate mr-image-transfer-node-locate"
|
||||
class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate mr-image-transfer-node-locate"
|
||||
data-image-transfer-locate
|
||||
data-node-id="${this.escapeHtml(String(node.nodeId))}"
|
||||
data-subgraph-id="${this.escapeHtml(String(node.subgraphId || ''))}"
|
||||
data-is-top-level="${node.isTopLevel ? 'true' : 'false'}"
|
||||
data-tooltip="${this.escapeHtml(locateTooltip)}"
|
||||
aria-label="Center ${this.escapeHtml(nodeName)} in the ComfyUI graph">
|
||||
${this.getLocateIconHtml()}
|
||||
aria-label="Center ${this.escapeHtml(nodeDisplayTitle)} in the ComfyUI graph">
|
||||
${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplayTitle)}</span>
|
||||
</button>
|
||||
`
|
||||
: '';
|
||||
: `
|
||||
<span class="mr-missing-row-node mr-image-transfer-node-name">${this.escapeHtml(nodeDisplayTitle)}</span>
|
||||
`;
|
||||
return `
|
||||
<div class="mr-image-transfer-node-row${selected ? ' is-selected' : ''}" ${contextMenuAttrs}>
|
||||
<div class="mr-image-transfer-node-target">
|
||||
<div class="mr-image-transfer-node-target"
|
||||
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
|
||||
data-image-transfer-target="${this.escapeHtml(targetKey)}"
|
||||
data-image-transfer-category="${this.escapeHtml(normalizedCategory)}">
|
||||
<button type="button" class="mr-image-transfer-node-select"
|
||||
data-image-transfer-node="${this.escapeHtml(node.nodeKey)}"
|
||||
data-image-transfer-target="${this.escapeHtml(targetKey)}"
|
||||
data-image-transfer-category="${this.escapeHtml(normalizedCategory)}"
|
||||
aria-pressed="${selected ? 'true' : 'false'}"
|
||||
aria-label="Select ${this.escapeHtml(nodeDisplayTitle)} for model transfer"
|
||||
aria-disabled="false">
|
||||
<span class="mr-image-transfer-node-check" aria-hidden="true">${selected ? '✓' : ''}</span>
|
||||
<span class="mr-image-transfer-node-details">
|
||||
<span class="mr-image-transfer-node-name">${this.escapeHtml(nodeName)} · Node ${this.escapeHtml(node.nodeId)}${this.escapeHtml(scopeLabel)}</span>
|
||||
<span class="mr-image-transfer-node-meta">${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')} · <span class="mr-image-transfer-node-status ${node.active ? 'is-active' : 'is-inactive'}">${node.active ? 'Active' : 'Inactive'}</span></span>
|
||||
</span>
|
||||
</button>
|
||||
${locateButtonHtml}
|
||||
<div class="mr-image-transfer-node-details">
|
||||
<div class="mr-image-transfer-node-header">
|
||||
${locateNodeHtml}
|
||||
</div>
|
||||
<span class="mr-image-transfer-node-meta">${this.escapeHtml(nodeType)} · ${countText(node.refs.length, 'model slot')} · <span class="mr-image-transfer-node-status ${node.active ? 'is-active' : 'is-inactive'}">${node.active ? 'Active' : 'Inactive'}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-image-transfer-node-preview">${previewHtml}</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const tabsLoadedMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
switchTab(tab, { force = false } = {}) {
|
||||
switchTab(tab, { force = false, animate = true, workflow = null } = {}) {
|
||||
const nextTab = this.getValidTab(tab);
|
||||
const nextTabButton = this.getTabButton(nextTab);
|
||||
if (
|
||||
@@ -76,7 +76,9 @@ export const tabsLoadedMethods = {
|
||||
}
|
||||
}
|
||||
this.hideTooltip();
|
||||
this.animateTabContentTransition();
|
||||
if (animate) {
|
||||
this.animateTabContentTransition();
|
||||
}
|
||||
this.updateTabButtonStates();
|
||||
this.updateQueueVisibility();
|
||||
|
||||
@@ -85,13 +87,13 @@ export const tabsLoadedMethods = {
|
||||
this.contentElement.style.overflowY = 'auto';
|
||||
}
|
||||
this.setMissingFooterControlsVisible(true);
|
||||
return this.loadWorkflowData();
|
||||
return this.loadWorkflowData(workflow);
|
||||
} else if (this.activeTab === 'loaded') {
|
||||
if (this.contentElement) {
|
||||
this.contentElement.style.overflowY = 'auto';
|
||||
}
|
||||
this.setMissingFooterControlsVisible(false);
|
||||
return this.loadLoadedModels();
|
||||
return this.loadLoadedModels(workflow);
|
||||
} else if (this.activeTab === 'metadata') {
|
||||
if (this.contentElement) {
|
||||
this.contentElement.style.overflowY = 'auto';
|
||||
|
||||
Reference in New Issue
Block a user