diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index da55379..1ca8f78 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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 = { diff --git a/tests/test_image_metadata_transfer.mjs b/tests/test_image_metadata_transfer.mjs index 9304d10..60c9376 100644 --- a/tests/test_image_metadata_transfer.mjs +++ b/tests/test_image_metadata_transfer.mjs @@ -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', () => { diff --git a/tests/test_tab_transitions.mjs b/tests/test_tab_transitions.mjs new file mode 100644 index 0000000..e2c8798 --- /dev/null +++ b/tests/test_tab_transitions.mjs @@ -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); + } +}); diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index a0ecaf9..5033d3e 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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); } diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index a01f2d4..bd134b1 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -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)' }, diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 733ae76..446f512 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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); } diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index 8763798..c1e006d 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -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, }); } } diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index c402800..a92adb5 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -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; diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 2b9ac31..b4a8217 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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 ? ` ` - : ''; + : ` + ${this.escapeHtml(nodeDisplayTitle)} + `; return `