From b5a2209854730c3f832364b9047c536784a99eca Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 14 Sep 2026 00:42:48 +0200 Subject: [PATCH] Prevent model tooltip preview flicker --- tests/test_downloads_tab_workflow_route.mjs | 47 ++++++++ web/resolver/search/search_panel.js | 118 +++++++++++++------- 2 files changed, 124 insertions(+), 41 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 3606fce..5578eca 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -908,6 +908,53 @@ test('model tooltip stays hidden while the context menu is visible', () => { ); }); +test('model tooltip waits for its preview before becoming visible', async () => { + const showTooltip = eval(`(${extractMethod(searchPanelMethodsSource, 'showTooltip')})`); + const window = new Window(); + const previousDocument = globalThis.document; + const target = window.document.createElement('span'); + const tooltipElement = window.document.createElement('div'); + let resolveMedia; + + target.setAttribute('data-tooltip', 'model.safetensors'); + target.setAttribute('data-tooltip-image', '/model-preview?path=model.safetensors'); + + globalThis.document = window.document; + try { + const dialog = { + contextMenu: { style: { display: 'none' } }, + tooltipElement, + normalizeTooltipTarget() {}, + async getTooltipPreviewMediaType() { + return 'image'; + }, + waitForTooltipMedia() { + return new Promise(resolve => { + resolveMedia = resolve; + }); + }, + positionTooltip() {}, + }; + + const tooltipPromise = showTooltip.call(dialog, target); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.equal(tooltipElement.style.display, 'none'); + assert.equal(tooltipElement.hasAttribute('data-visible'), false); + assert.ok(tooltipElement.querySelector('.mr-tooltip-preview')); + + resolveMedia(true); + await tooltipPromise; + + assert.equal(tooltipElement.style.display, 'block'); + assert.equal(tooltipElement.getAttribute('data-visible'), 'true'); + assert.equal(tooltipElement.classList.contains('mr-global-tooltip-with-image'), true); + } finally { + globalThis.document = previousDocument; + await window.happyDOM.close(); + } +}); + test('model tooltip revalidates and detects video previews from the preview route', async () => { const getTooltipPreviewMediaType = eval(`(${extractMethod(searchPanelMethodsSource, 'getTooltipPreviewMediaType')})`); const previousFetch = globalThis.fetch; diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index c57dbf4..f07dea7 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -662,6 +662,47 @@ export const searchPanelMethods = { } }, + waitForTooltipMedia(media, mediaType, previewUrl) { + if (!media || !previewUrl) return Promise.resolve(false); + + return new Promise((resolve) => { + const readyEvent = mediaType === 'video' ? 'loadeddata' : 'load'; + let settled = false; + const finish = (loaded) => { + if (settled) return; + settled = true; + media.removeEventListener(readyEvent, onReady); + media.removeEventListener('error', onError); + resolve(loaded); + }; + const onReady = () => { + if (mediaType === 'image' && typeof media.decode === 'function') { + media.decode().then( + () => finish(true), + () => finish(false), + ); + return; + } + finish(true); + }; + const onError = () => finish(false); + + media.addEventListener(readyEvent, onReady); + media.addEventListener('error', onError); + try { + media.src = previewUrl; + } catch (_error) { + finish(false); + return; + } + + const alreadyReady = mediaType === 'video' + ? Number(media.readyState || 0) >= 2 + : Boolean(media.complete && Number(media.naturalWidth || 0) > 0); + if (alreadyReady) Promise.resolve().then(onReady); + }); + }, + async showTooltip(target) { if (!target || !this.tooltipElement) return; if (this.contextMenu?.style.display === 'block') { @@ -680,16 +721,10 @@ export const searchPanelMethods = { this._tooltipTarget = target; this.tooltipElement.replaceChildren(); this.tooltipElement.classList.remove('mr-global-tooltip-with-image'); + this.tooltipElement.style.display = 'none'; + this.tooltipElement.removeAttribute('data-visible'); if (imageUrl) { - const label = document.createElement('div'); - label.className = 'mr-tooltip-label'; - label.textContent = text; - this.tooltipElement.append(label); - this.tooltipElement.style.display = 'block'; - this.positionTooltip(target); - this.tooltipElement.setAttribute('data-visible', 'true'); - const mediaType = await this.getTooltipPreviewMediaType(imageUrl); if ( this._tooltipTarget !== target @@ -698,41 +733,42 @@ export const searchPanelMethods = { return; } if (!mediaType) { - this.tooltipElement.classList.remove('mr-global-tooltip-with-image'); - this.positionTooltip(target); - return; - } - - const media = document.createElement(mediaType === 'video' ? 'video' : 'img'); - media.className = 'mr-tooltip-preview'; - if (mediaType === 'video') { - media.autoplay = true; - media.loop = true; - media.muted = true; - media.playsInline = true; - media.preload = 'metadata'; + this.tooltipElement.textContent = text; } else { - media.alt = ''; - media.decoding = 'async'; - } - - media.addEventListener(mediaType === 'video' ? 'loadeddata' : 'load', () => { - if (this._tooltipTarget === target) { - this.positionTooltip(target); - if (mediaType === 'video') { - media.play?.().catch(() => {}); - } + const label = document.createElement('div'); + label.className = 'mr-tooltip-label'; + label.textContent = text; + const media = document.createElement(mediaType === 'video' ? 'video' : 'img'); + media.className = 'mr-tooltip-preview'; + if (mediaType === 'video') { + media.autoplay = true; + media.loop = true; + media.muted = true; + media.playsInline = true; + media.preload = 'metadata'; + } else { + media.alt = ''; + media.decoding = 'async'; } - }); - media.addEventListener('error', () => { - if (this._tooltipTarget !== target) return; - media.remove(); - this.tooltipElement.classList.remove('mr-global-tooltip-with-image'); - this.positionTooltip(target); - }); - media.src = imageUrl; - this.tooltipElement.prepend(media); - this.tooltipElement.classList.add('mr-global-tooltip-with-image'); + + this.tooltipElement.append(label); + this.tooltipElement.prepend(media); + this.tooltipElement.classList.add('mr-global-tooltip-with-image'); + const mediaLoaded = await this.waitForTooltipMedia(media, mediaType, imageUrl); + if ( + this._tooltipTarget !== target + || this.contextMenu?.style.display === 'block' + ) { + return; + } + if (!mediaLoaded) { + media.remove(); + this.tooltipElement.classList.remove('mr-global-tooltip-with-image'); + this.tooltipElement.textContent = text; + } else if (mediaType === 'video') { + media.play?.().catch(() => {}); + } + } } else { this.tooltipElement.textContent = text; }