Add incremental card reconciliation to prevent full DOM rebuild on auto-refresh
CardSection now diffs cards by key attributes instead of rebuilding innerHTML, preserving DOM elements, filter input focus, scroll position, and Chart.js instances across the 2s targets tab auto-refresh cycle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
/**
|
||||
* CardSection — collapsible section with name filtering for card grids.
|
||||
* CardSection — collapsible section with name filtering and incremental
|
||||
* card reconciliation for card grids.
|
||||
*
|
||||
* Usage:
|
||||
* const section = new CardSection('led-devices', {
|
||||
* titleKey: 'targets.section.devices',
|
||||
* gridClass: 'devices-grid',
|
||||
* addCardOnclick: "showAddDevice()",
|
||||
* keyAttr: 'data-device-id',
|
||||
* });
|
||||
*
|
||||
* // In the render function (building innerHTML):
|
||||
* html += section.render(cardsHtml, cardCount);
|
||||
* const items = devices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
|
||||
*
|
||||
* // After container.innerHTML is set:
|
||||
* section.bind();
|
||||
* if (section.isMounted()) {
|
||||
* section.reconcile(items); // incremental DOM diff
|
||||
* } else {
|
||||
* html += section.render(items); // initial HTML string
|
||||
* // after container.innerHTML = html:
|
||||
* section.bind();
|
||||
* }
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
@@ -32,17 +38,32 @@ export class CardSection {
|
||||
* @param {string} opts.titleKey i18n key for the section title
|
||||
* @param {string} opts.gridClass CSS class for the card grid: 'devices-grid' | 'templates-grid'
|
||||
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
|
||||
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
|
||||
*/
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick }) {
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr }) {
|
||||
this.sectionKey = sectionKey;
|
||||
this.titleKey = titleKey;
|
||||
this.gridClass = gridClass;
|
||||
this.addCardOnclick = addCardOnclick || '';
|
||||
this.keyAttr = keyAttr || '';
|
||||
this._filterValue = '';
|
||||
this._lastItems = null;
|
||||
}
|
||||
|
||||
/** Returns section HTML. Call during innerHTML building. */
|
||||
render(cardsHtml, count) {
|
||||
/** True if this section's DOM element exists (i.e. not the first render). */
|
||||
isMounted() {
|
||||
return !!document.querySelector(`[data-card-section="${this.sectionKey}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns section HTML string for initial innerHTML building.
|
||||
* @param {Array<{key: string, html: string}>} items
|
||||
*/
|
||||
render(items) {
|
||||
this._lastItems = items;
|
||||
const count = items.length;
|
||||
const cardsHtml = items.map(i => i.html).join('');
|
||||
|
||||
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
|
||||
const chevron = isCollapsed ? '\u25B6' : '\u25BC';
|
||||
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
|
||||
@@ -106,6 +127,77 @@ export class CardSection {
|
||||
this._applyFilter(content, this._filterValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag card elements with their source HTML for future reconciliation
|
||||
this._tagCards(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental DOM diff — update cards in-place without rebuilding the section.
|
||||
* @param {Array<{key: string, html: string}>} items
|
||||
* @returns {{added: Set<string>, replaced: Set<string>, removed: Set<string>}}
|
||||
*/
|
||||
reconcile(items) {
|
||||
const content = document.querySelector(`[data-cs-content="${this.sectionKey}"]`);
|
||||
if (!content) return { added: new Set(), replaced: new Set(), removed: new Set() };
|
||||
|
||||
this._lastItems = items;
|
||||
|
||||
// Update count badge
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
if (countEl) countEl.textContent = items.length;
|
||||
|
||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
const added = new Set();
|
||||
const replaced = new Set();
|
||||
const removed = new Set();
|
||||
|
||||
// Process existing cards: remove or update
|
||||
if (this.keyAttr) {
|
||||
const existing = [...content.querySelectorAll(`[${this.keyAttr}]`)];
|
||||
for (const card of existing) {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (!newMap.has(key)) {
|
||||
card.remove();
|
||||
removed.add(key);
|
||||
} else {
|
||||
const newHtml = newMap.get(key);
|
||||
if (card._csHtml !== newHtml) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = newHtml;
|
||||
const newEl = tmp.firstElementChild;
|
||||
newEl._csHtml = newHtml;
|
||||
card.replaceWith(newEl);
|
||||
replaced.add(key);
|
||||
}
|
||||
// else: unchanged — skip
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new cards (keys not already in DOM)
|
||||
const existingKeys = new Set([...content.querySelectorAll(`[${this.keyAttr}]`)].map(
|
||||
el => el.getAttribute(this.keyAttr)
|
||||
));
|
||||
for (const { key, html } of items) {
|
||||
if (!existingKeys.has(key)) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const newEl = tmp.firstElementChild;
|
||||
newEl._csHtml = html;
|
||||
if (addCard) content.insertBefore(newEl, addCard);
|
||||
else content.appendChild(newEl);
|
||||
added.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply filter
|
||||
if (this._filterValue) {
|
||||
this._applyFilter(content, this._filterValue);
|
||||
}
|
||||
|
||||
return { added, replaced, removed };
|
||||
}
|
||||
|
||||
/** Bind an array of CardSection instances. */
|
||||
@@ -115,6 +207,16 @@ export class CardSection {
|
||||
|
||||
// ── private ──
|
||||
|
||||
_tagCards(content) {
|
||||
if (!this.keyAttr || !this._lastItems) return;
|
||||
const htmlMap = new Map(this._lastItems.map(i => [i.key, i.html]));
|
||||
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
|
||||
cards.forEach(card => {
|
||||
const key = card.getAttribute(this.keyAttr);
|
||||
if (htmlMap.has(key)) card._csHtml = htmlMap.get(key);
|
||||
});
|
||||
}
|
||||
|
||||
_toggleCollapse(header, content) {
|
||||
const map = _getCollapsedMap();
|
||||
const nowCollapsed = !map[this.sectionKey];
|
||||
|
||||
@@ -55,8 +55,8 @@ export async function loadProfiles() {
|
||||
function renderProfiles(profiles, runningTargetIds = new Set()) {
|
||||
const container = document.getElementById('profiles-content');
|
||||
|
||||
const cardsHtml = profiles.map(p => createProfileCard(p, runningTargetIds)).join('');
|
||||
container.innerHTML = csProfiles.render(cardsHtml, profiles.length);
|
||||
const items = profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }));
|
||||
container.innerHTML = csProfiles.render(items);
|
||||
csProfiles.bind();
|
||||
|
||||
// Localize data-i18n elements within the profiles container only
|
||||
|
||||
@@ -667,18 +667,18 @@ function renderPictureSourcesList(streams) {
|
||||
|
||||
if (tab.key === 'raw') {
|
||||
panelContent =
|
||||
csRawStreams.render(rawStreams.map(renderStreamCard).join(''), rawStreams.length) +
|
||||
csRawTemplates.render(_cachedCaptureTemplates.map(renderCaptureTemplateCard).join(''), _cachedCaptureTemplates.length);
|
||||
csRawStreams.render(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
|
||||
csRawTemplates.render(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
||||
} else if (tab.key === 'processed') {
|
||||
panelContent =
|
||||
csProcStreams.render(processedStreams.map(renderStreamCard).join(''), processedStreams.length) +
|
||||
csProcTemplates.render(_cachedPPTemplates.map(renderPPTemplateCard).join(''), _cachedPPTemplates.length);
|
||||
csProcStreams.render(processedStreams.map(s => ({ key: s.id, html: renderStreamCard(s) }))) +
|
||||
csProcTemplates.render(_cachedPPTemplates.map(t => ({ key: t.id, html: renderPPTemplateCard(t) })));
|
||||
} else if (tab.key === 'audio') {
|
||||
panelContent =
|
||||
csAudioMulti.render(multichannelSources.map(renderAudioSourceCard).join(''), multichannelSources.length) +
|
||||
csAudioMono.render(monoSources.map(renderAudioSourceCard).join(''), monoSources.length);
|
||||
csAudioMulti.render(multichannelSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) }))) +
|
||||
csAudioMono.render(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
|
||||
} else {
|
||||
panelContent = csStaticStreams.render(staticImageStreams.map(renderStreamCard).join(''), staticImageStreams.length);
|
||||
panelContent = csStaticStreams.render(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
}
|
||||
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||
|
||||
@@ -21,11 +21,11 @@ import { CardSection } from '../core/card-sections.js';
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
|
||||
// ── Card section instances ──
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()" });
|
||||
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()" });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()" });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()" });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()" });
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
|
||||
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id' });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id' });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
|
||||
|
||||
// Re-render targets tab when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
@@ -82,6 +82,23 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
|
||||
});
|
||||
}
|
||||
|
||||
function _updateTargetFpsChart(targetId, fpsTarget) {
|
||||
const chart = _targetFpsCharts[targetId];
|
||||
if (!chart) return;
|
||||
const history = _targetFpsHistory[targetId] || [];
|
||||
chart.data.labels = history.map(() => '');
|
||||
chart.data.datasets[0].data = [...history];
|
||||
chart.options.scales.y.max = fpsTarget * 1.15;
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
function _updateSubTabCounts(subTabs) {
|
||||
subTabs.forEach(tab => {
|
||||
const btn = document.querySelector(`.target-sub-tab-btn[data-target-sub-tab="${tab.key}"] .stream-tab-count`);
|
||||
if (btn) btn.textContent = tab.count;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Editor state ---
|
||||
let _editorCssSources = []; // populated when editor opens
|
||||
|
||||
@@ -433,33 +450,47 @@ export async function loadTargetsTab() {
|
||||
// Use window.createPatternTemplateCard to avoid circular import
|
||||
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
||||
|
||||
const devicesHtml = ledDevices.map(device => createDeviceCard(device)).join('');
|
||||
const cssHtml = Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('');
|
||||
const ledTargetsHtml = ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('');
|
||||
const kcTargetsHtml = kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('');
|
||||
const patternTmplHtml = patternTemplates.map(pt => createPatternTemplateCard(pt)).join('');
|
||||
// Build items arrays for each section
|
||||
const deviceItems = ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) }));
|
||||
const cssItems = Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap) }));
|
||||
const ledTargetItems = ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap) }));
|
||||
const kcTargetItems = kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap) }));
|
||||
const patternItems = patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) }));
|
||||
|
||||
const ledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||
${csDevices.render(devicesHtml, ledDevices.length)}
|
||||
${csColorStrips.render(cssHtml, Object.keys(colorStripSourceMap).length)}
|
||||
${csLedTargets.render(ledTargetsHtml, ledTargets.length)}
|
||||
</div>`;
|
||||
// Track which target cards were replaced/added (need chart re-init)
|
||||
let changedTargetIds = null;
|
||||
|
||||
const kcPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
|
||||
${csKCTargets.render(kcTargetsHtml, kcTargets.length)}
|
||||
${csPatternTemplates.render(patternTmplHtml, patternTemplates.length)}
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = tabBar + ledPanel + kcPanel;
|
||||
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
|
||||
if (csDevices.isMounted()) {
|
||||
// ── Incremental update: reconcile cards in-place ──
|
||||
_updateSubTabCounts(subTabs);
|
||||
csDevices.reconcile(deviceItems);
|
||||
csColorStrips.reconcile(cssItems);
|
||||
const ledResult = csLedTargets.reconcile(ledTargetItems);
|
||||
const kcResult = csKCTargets.reconcile(kcTargetItems);
|
||||
csPatternTemplates.reconcile(patternItems);
|
||||
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
||||
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
||||
} else {
|
||||
// ── First render: build full HTML ──
|
||||
const ledPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
||||
${csDevices.render(deviceItems)}
|
||||
${csColorStrips.render(cssItems)}
|
||||
${csLedTargets.render(ledTargetItems)}
|
||||
</div>`;
|
||||
const kcPanel = `
|
||||
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
|
||||
${csKCTargets.render(kcTargetItems)}
|
||||
${csPatternTemplates.render(patternItems)}
|
||||
</div>`;
|
||||
container.innerHTML = tabBar + ledPanel + kcPanel;
|
||||
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
|
||||
}
|
||||
|
||||
// Attach event listeners and fetch brightness for device cards
|
||||
devicesWithState.forEach(device => {
|
||||
attachDeviceListeners(device.id);
|
||||
if ((device.capabilities || []).includes('brightness_control')) {
|
||||
// Only fetch from device if we don't have a cached value yet
|
||||
if (device.id in _deviceBrightnessCache) {
|
||||
const bri = _deviceBrightnessCache[device.id];
|
||||
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
|
||||
@@ -486,18 +517,28 @@ export async function loadTargetsTab() {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Disconnect WebSockets for targets no longer processing
|
||||
Object.keys(kcWebSockets).forEach(id => {
|
||||
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
||||
});
|
||||
|
||||
// Destroy old chart instances before DOM rebuild replaces canvases
|
||||
for (const id of Object.keys(_targetFpsCharts)) {
|
||||
_targetFpsCharts[id].destroy();
|
||||
delete _targetFpsCharts[id];
|
||||
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
||||
if (changedTargetIds) {
|
||||
// Incremental: destroy only charts whose cards were replaced or removed
|
||||
for (const id of changedTargetIds) {
|
||||
if (_targetFpsCharts[id]) {
|
||||
_targetFpsCharts[id].destroy();
|
||||
delete _targetFpsCharts[id];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First render: destroy all old charts
|
||||
for (const id of Object.keys(_targetFpsCharts)) {
|
||||
_targetFpsCharts[id].destroy();
|
||||
delete _targetFpsCharts[id];
|
||||
}
|
||||
}
|
||||
|
||||
// FPS sparkline charts: push samples and init charts after HTML rebuild
|
||||
// Push FPS samples and create/update charts for running targets
|
||||
const allTargets = [...ledTargets, ...kcTargets];
|
||||
const runningIds = new Set();
|
||||
allTargets.forEach(target => {
|
||||
@@ -506,18 +547,30 @@ export async function loadTargetsTab() {
|
||||
if (target.state.fps_actual != null) {
|
||||
_pushTargetFps(target.id, target.state.fps_actual);
|
||||
}
|
||||
const history = _targetFpsHistory[target.id] || [];
|
||||
const fpsTarget = target.state.fps_target || 30;
|
||||
const device = devices.find(d => d.id === target.device_id);
|
||||
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
|
||||
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
|
||||
if (chart) _targetFpsCharts[target.id] = chart;
|
||||
// Create chart if it doesn't exist (new or replaced card)
|
||||
if (!_targetFpsCharts[target.id]) {
|
||||
const history = _targetFpsHistory[target.id] || [];
|
||||
const fpsTarget = target.state.fps_target || 30;
|
||||
const device = devices.find(d => d.id === target.device_id);
|
||||
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
|
||||
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
|
||||
if (chart) _targetFpsCharts[target.id] = chart;
|
||||
} else {
|
||||
// Chart survived reconcile — just update data
|
||||
_updateTargetFpsChart(target.id, target.state.fps_target || 30);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Clean up history and charts for targets no longer running
|
||||
Object.keys(_targetFpsHistory).forEach(id => {
|
||||
if (!runningIds.has(id)) delete _targetFpsHistory[id];
|
||||
});
|
||||
Object.keys(_targetFpsCharts).forEach(id => {
|
||||
if (!runningIds.has(id)) {
|
||||
_targetFpsCharts[id].destroy();
|
||||
delete _targetFpsCharts[id];
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
|
||||
Reference in New Issue
Block a user