From ec58282c19c3b60a769d4ed92687503dc4584d68 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 00:07:18 +0300 Subject: [PATCH] Eliminate tab reload animation after saving card properties - CardSection._animateEntrance: skip after first render to prevent card fade-in replaying on every data refresh - automations: use reconcile() on subsequent renders instead of full innerHTML replacement that destroyed and recreated all cards - streams: same reconcile() approach for all 9 CardSections - targets/dashboard/streams: only show setTabRefreshing loading bar on first render when the tab is empty Co-Authored-By: Claude Opus 4.6 --- .../static/js/core/card-sections.js | 2 + .../static/js/features/automations.js | 23 ++++--- .../static/js/features/dashboard.js | 2 +- .../static/js/features/streams.js | 66 +++++++++++-------- .../static/js/features/targets.js | 2 +- 5 files changed, 58 insertions(+), 37 deletions(-) diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index aad1519..02b5413 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -355,6 +355,8 @@ export class CardSection { // ── private ── _animateEntrance(content) { + if (this._animated) return; + this._animated = true; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)'; const cards = content.querySelectorAll(selector); diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index d6f9f11..0ed7b90 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -49,7 +49,7 @@ export async function loadAutomations() { set_automationsLoading(true); const container = document.getElementById('automations-content'); if (!container) { set_automationsLoading(false); return; } - setTabRefreshing('automations-content', true); + if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true); try { const [automations, scenes] = await Promise.all([ @@ -85,15 +85,20 @@ function renderAutomations(automations, sceneMap) { const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) }))); const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) }))); - const toolbar = `
`; - container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); - csAutomations.bind(); - csScenes.bind(); + if (csAutomations.isMounted()) { + csAutomations.reconcile(autoItems); + csScenes.reconcile(sceneItems); + } else { + const toolbar = `
`; + container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems); + csAutomations.bind(); + csScenes.bind(); - // Localize data-i18n elements within the container - container.querySelectorAll('[data-i18n]').forEach(el => { - el.textContent = t(el.getAttribute('data-i18n')); - }); + // Localize data-i18n elements within the container + container.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.getAttribute('data-i18n')); + }); + } } function createAutomationCard(automation, sceneMap = new Map()) { diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index b025202..3292434 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -364,7 +364,7 @@ export async function loadDashboard(forceFullRender = false) { set_dashboardLoading(true); const container = document.getElementById('dashboard-content'); if (!container) { set_dashboardLoading(false); return; } - setTabRefreshing('dashboard-content', true); + if (!container.children.length) setTabRefreshing('dashboard-content', true); try { // Fire all requests in a single batch to avoid sequential RTTs diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index c93d02f..20514fd 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1092,7 +1092,7 @@ function _tplRenderSpectrum() { export async function loadPictureSources() { if (_sourcesLoading) return; set_sourcesLoading(true); - setTabRefreshing('streams-list', true); + if (!csRawStreams.isMounted()) setTabRefreshing('streams-list', true); try { const [streams] = await Promise.all([ streamsCache.fetch(), @@ -1368,33 +1368,47 @@ function renderPictureSourcesList(streams) { }); }; - const panels = tabs.map(tab => { - let panelContent = ''; + // Build item arrays for all sections + const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); + const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) }))); + const procStreamItems = csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); + const procTemplateItems = csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) }))); + const multiItems = csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); + const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))); + const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) }))); + const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))); + const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) }))); - if (tab.key === 'raw') { - panelContent = - csRawStreams.render(csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) + - csRawTemplates.render(csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })))); - } else if (tab.key === 'processed') { - panelContent = - csProcStreams.render(csProcStreams.applySortOrder(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))) + - csProcTemplates.render(csProcTemplates.applySortOrder(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })))); - } else if (tab.key === 'audio') { - panelContent = - csAudioMulti.render(csAudioMulti.applySortOrder(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) + - csAudioMono.render(csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })))) + - csAudioTemplates.render(csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })))); - } else if (tab.key === 'value') { - panelContent = csValueSources.render(csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })))); - } else { - panelContent = csStaticStreams.render(csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })))); - } + if (csRawStreams.isMounted()) { + // Incremental update: reconcile cards in-place + tabs.forEach(tab => { + const btn = container.querySelector(`.stream-tab-btn[data-stream-tab="${tab.key}"] .stream-tab-count`); + if (btn) btn.textContent = tab.count; + }); + csRawStreams.reconcile(rawStreamItems); + csRawTemplates.reconcile(rawTemplateItems); + csProcStreams.reconcile(procStreamItems); + csProcTemplates.reconcile(procTemplateItems); + csAudioMulti.reconcile(multiItems); + csAudioMono.reconcile(monoItems); + csAudioTemplates.reconcile(audioTemplateItems); + csStaticStreams.reconcile(staticItems); + csValueSources.reconcile(valueItems); + } else { + // First render: build full HTML + const panels = tabs.map(tab => { + let panelContent = ''; + if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems); + else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems); + else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems); + else if (tab.key === 'value') panelContent = csValueSources.render(valueItems); + else panelContent = csStaticStreams.render(staticItems); + return `
${panelContent}
`; + }).join(''); - return `
${panelContent}
`; - }).join(''); - - container.innerHTML = tabBar + panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]); + container.innerHTML = tabBar + panels; + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]); + } } export function onStreamTypeChange() { diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index acede51..c60aaca 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -471,7 +471,7 @@ export async function loadTargetsTab() { // Skip if another loadTargetsTab or a button action is already running if (_loadTargetsLock || _actionInFlight) return; _loadTargetsLock = true; - setTabRefreshing('targets-panel-content', true); + if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); try { // Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel