mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)
This commit is contained in:
parent
f8c6b5c07e
commit
721e1884af
41 changed files with 2960 additions and 143 deletions
|
|
@ -110,6 +110,13 @@
|
|||
document.addEventListener('keydown', function(e){
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
|
||||
var k = e.key.toLowerCase();
|
||||
// If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
|
||||
try {
|
||||
var active = document.activeElement;
|
||||
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
|
||||
return;
|
||||
}
|
||||
} catch(_) { /* noop */ }
|
||||
if (keymap[k]){ e.preventDefault(); keymap[k](); }
|
||||
});
|
||||
|
||||
|
|
@ -165,6 +172,7 @@
|
|||
hydrateProgress(document);
|
||||
syncShowSkipped(document);
|
||||
initCardFilters(document);
|
||||
initVirtualization(document);
|
||||
});
|
||||
|
||||
// Hydrate progress bars with width based on data-pct
|
||||
|
|
@ -192,8 +200,31 @@
|
|||
hydrateProgress(e.target);
|
||||
syncShowSkipped(e.target);
|
||||
initCardFilters(e.target);
|
||||
initVirtualization(e.target);
|
||||
});
|
||||
|
||||
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
|
||||
function scrollCardIntoView(name){
|
||||
if (!name) return;
|
||||
try{
|
||||
var section = document.querySelector('section');
|
||||
var grid = section && section.querySelector('.card-grid');
|
||||
if (!grid) return;
|
||||
// If virtualized, force a render around the approximate match by searching stored children
|
||||
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if (!target) {
|
||||
// Trigger a render update and try again
|
||||
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
|
||||
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
|
||||
}
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
target.focus && target.focus();
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
window.scrollCardIntoView = scrollCardIntoView;
|
||||
|
||||
// --- Card grid filters, reasons, and collapsible groups ---
|
||||
function initCardFilters(root){
|
||||
var section = (root || document).querySelector('section');
|
||||
|
|
@ -250,7 +281,7 @@
|
|||
}
|
||||
});
|
||||
// Filter tiles
|
||||
var tiles = section.querySelectorAll('.card-grid .card-tile');
|
||||
var tiles = section.querySelectorAll('.card-grid .card-tile');
|
||||
var visible = 0;
|
||||
tiles.forEach(function(tile){
|
||||
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
|
||||
|
|
@ -272,7 +303,7 @@
|
|||
return { name: name.toLowerCase(), owned: owned, gc: gc };
|
||||
}
|
||||
section.querySelectorAll('.card-grid').forEach(function(grid){
|
||||
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
|
||||
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
|
||||
arr.sort(function(a,b){
|
||||
var ka = keyFor(a), kb = keyFor(b);
|
||||
if (sortMode === 'owned'){
|
||||
|
|
@ -368,4 +399,268 @@
|
|||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
|
||||
function initVirtualization(root){
|
||||
try{
|
||||
var body = document.body || document.documentElement;
|
||||
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
|
||||
// Global diagnostics aggregator
|
||||
var GLOBAL = (function(){
|
||||
if (!DIAG) return null;
|
||||
if (window.__virtGlobal) return window.__virtGlobal;
|
||||
var store = { grids: [], summaryEl: null };
|
||||
function ensure(){
|
||||
if (!store.summaryEl){
|
||||
var el = document.createElement('div');
|
||||
el.id = 'virt-global-diag';
|
||||
el.style.position = 'fixed';
|
||||
el.style.right = '8px';
|
||||
el.style.bottom = '8px';
|
||||
el.style.background = 'rgba(17,24,39,.85)';
|
||||
el.style.border = '1px solid var(--border)';
|
||||
el.style.padding = '.25rem .5rem';
|
||||
el.style.borderRadius = '6px';
|
||||
el.style.fontSize = '12px';
|
||||
el.style.color = '#cbd5e1';
|
||||
el.style.zIndex = '50';
|
||||
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
|
||||
el.style.cursor = 'default';
|
||||
// Hidden by default; toggle with 'v'
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
store.summaryEl = el;
|
||||
}
|
||||
return store.summaryEl;
|
||||
}
|
||||
function update(){
|
||||
var el = ensure(); if (!el) return;
|
||||
var g = store.grids;
|
||||
var total = 0, visible = 0, lastMs = 0;
|
||||
for (var i=0;i<g.length;i++){
|
||||
total += g[i].total||0;
|
||||
visible += (g[i].end||0) - (g[i].start||0);
|
||||
lastMs = Math.max(lastMs, g[i].lastMs||0);
|
||||
}
|
||||
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
|
||||
}
|
||||
function register(gridId, ref){
|
||||
store.grids.push({ id: gridId, ref: ref });
|
||||
update();
|
||||
return {
|
||||
set: function(stats){
|
||||
for (var i=0;i<store.grids.length;i++){
|
||||
if (store.grids[i].id === gridId){
|
||||
store.grids[i] = Object.assign({ id: gridId, ref: ref }, stats);
|
||||
break;
|
||||
}
|
||||
}
|
||||
update();
|
||||
},
|
||||
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
|
||||
};
|
||||
}
|
||||
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
|
||||
return window.__virtGlobal;
|
||||
})();
|
||||
// Support card grids and other scroll containers (e.g., #owned-box)
|
||||
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
|
||||
if (!grids.length) return;
|
||||
grids.forEach(function(grid){
|
||||
if (grid.__virtBound) return;
|
||||
grid.__virtBound = true;
|
||||
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
|
||||
var container = grid;
|
||||
container.style.position = container.style.position || 'relative';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'virt-wrapper';
|
||||
// Ensure wrapper itself is a grid to preserve multi-column layout inside
|
||||
// when the container (e.g., .card-grid) is virtualized.
|
||||
wrapper.style.display = 'grid';
|
||||
// Move children into a fragment store (for owned, children live under UL)
|
||||
var source = container;
|
||||
// If this is the owned box, use the UL inside as the source list
|
||||
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
|
||||
if (ownedGrid) { source = ownedGrid; }
|
||||
var all = Array.prototype.slice.call(source.children);
|
||||
var store = document.createElement('div');
|
||||
store.style.display = 'none';
|
||||
all.forEach(function(n){ store.appendChild(n); });
|
||||
var padTop = document.createElement('div');
|
||||
var padBottom = document.createElement('div');
|
||||
padTop.style.height = '0px'; padBottom.style.height = '0px';
|
||||
// For owned, keep the UL but render into it; otherwise append wrapper to container
|
||||
if (ownedGrid){
|
||||
ownedGrid.innerHTML = '';
|
||||
ownedGrid.appendChild(padTop);
|
||||
ownedGrid.appendChild(wrapper);
|
||||
ownedGrid.appendChild(padBottom);
|
||||
ownedGrid.appendChild(store);
|
||||
} else {
|
||||
container.appendChild(wrapper);
|
||||
container.appendChild(padBottom);
|
||||
container.appendChild(store);
|
||||
}
|
||||
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
|
||||
var perRow = 1;
|
||||
// Optional diagnostics overlay
|
||||
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
|
||||
var renderCount = 0; var measureCount = 0; var swapCount = 0;
|
||||
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
|
||||
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
|
||||
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
|
||||
function ensureDiag(){
|
||||
if (!DIAG) return null;
|
||||
if (diagBox) return diagBox;
|
||||
diagBox = document.createElement('div');
|
||||
diagBox.className = 'virt-diag';
|
||||
diagBox.style.position = 'sticky';
|
||||
diagBox.style.top = '0';
|
||||
diagBox.style.zIndex = '5';
|
||||
diagBox.style.background = 'rgba(17,24,39,.85)';
|
||||
diagBox.style.border = '1px solid var(--border)';
|
||||
diagBox.style.padding = '.25rem .5rem';
|
||||
diagBox.style.borderRadius = '6px';
|
||||
diagBox.style.fontSize = '12px';
|
||||
diagBox.style.margin = '0 0 .35rem 0';
|
||||
diagBox.style.color = '#cbd5e1';
|
||||
diagBox.style.display = 'none'; // hidden until toggled
|
||||
// Controls
|
||||
var controls = document.createElement('div');
|
||||
controls.style.display = 'flex';
|
||||
controls.style.gap = '.35rem';
|
||||
controls.style.alignItems = 'center';
|
||||
controls.style.marginBottom = '.25rem';
|
||||
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
|
||||
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
|
||||
btnCopy.addEventListener('click', function(){ try{ var payload = {
|
||||
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
|
||||
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
|
||||
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
|
||||
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
|
||||
});
|
||||
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
|
||||
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
|
||||
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
|
||||
diagBox.appendChild(controls);
|
||||
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
|
||||
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
|
||||
host.insertBefore(diagBox, host.firstChild);
|
||||
return diagBox;
|
||||
}
|
||||
function measure(){
|
||||
try {
|
||||
measureCount++;
|
||||
// create a temp tile to measure if none
|
||||
var probe = store.firstElementChild || all[0];
|
||||
if (probe){
|
||||
var fake = probe.cloneNode(true);
|
||||
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
|
||||
(ownedGrid || container).appendChild(fake);
|
||||
var rect = fake.getBoundingClientRect();
|
||||
rowH = Math.max(120, Math.ceil(rect.height) + 16);
|
||||
(ownedGrid || container).removeChild(fake);
|
||||
}
|
||||
// Estimate perRow via computed styles of grid
|
||||
var style = window.getComputedStyle(ownedGrid || container);
|
||||
var cols = style.getPropertyValue('grid-template-columns');
|
||||
// Mirror grid settings onto the wrapper so its children still flow in columns
|
||||
try {
|
||||
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
|
||||
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
|
||||
if (gap && gap.trim()) wrapper.style.gap = gap;
|
||||
// Inherit justify/align if present
|
||||
var ji = style.getPropertyValue('justify-items');
|
||||
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
|
||||
var ai = style.getPropertyValue('align-items');
|
||||
if (ai && ai.trim()) wrapper.style.alignItems = ai;
|
||||
} catch(_) {}
|
||||
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
|
||||
} catch(_){}
|
||||
}
|
||||
measure();
|
||||
var total = all.length;
|
||||
var start = 0, end = 0;
|
||||
function render(){
|
||||
var t0 = DIAG ? performance.now() : 0;
|
||||
var scroller = container;
|
||||
var vh = scroller.clientHeight || window.innerHeight;
|
||||
var scrollTop = scroller.scrollTop;
|
||||
// If container isn’t scrollable, use window scroll offset
|
||||
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
|
||||
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
|
||||
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
|
||||
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
|
||||
var newStart = rowStart * perRow;
|
||||
var newEnd = Math.min(total, rowEnd * perRow);
|
||||
if (newStart === start && newEnd === end) return; // no change
|
||||
start = newStart; end = newEnd;
|
||||
// Padding
|
||||
var beforeRows = Math.floor(start / perRow);
|
||||
var afterRows = Math.ceil((total - end) / perRow);
|
||||
padTop.style.height = (beforeRows * rowH) + 'px';
|
||||
padBottom.style.height = (afterRows * rowH) + 'px';
|
||||
// Render visible children
|
||||
wrapper.innerHTML = '';
|
||||
for (var i = start; i < end; i++) {
|
||||
var node = all[i];
|
||||
if (node) wrapper.appendChild(node);
|
||||
}
|
||||
if (DIAG){
|
||||
var box = ensureDiag();
|
||||
if (box){
|
||||
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
|
||||
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
|
||||
var textEl = box.querySelector('.virt-diag-text');
|
||||
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
|
||||
textEl.textContent = msg;
|
||||
// Health hint
|
||||
var bad = (dt > 33) || (vis > 300);
|
||||
var warn = (!bad) && ((dt > 16) || (vis > 200));
|
||||
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
|
||||
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
|
||||
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
function onScroll(){ render(); }
|
||||
function onResize(){ measure(); render(); }
|
||||
container.addEventListener('scroll', onScroll);
|
||||
window.addEventListener('resize', onResize);
|
||||
// Initial size; ensure container is scrollable for our logic
|
||||
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
|
||||
container.style.overflow = container.style.overflow || 'auto';
|
||||
render();
|
||||
// Re-render after filters resort or HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
|
||||
// Keyboard toggle for overlays: 'v'
|
||||
if (DIAG && !window.__virtHotkeyBound){
|
||||
window.__virtHotkeyBound = true;
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
|
||||
if (e.key && e.key.toLowerCase() === 'v'){
|
||||
e.preventDefault();
|
||||
// Toggle all virt-diag boxes and the global summary
|
||||
var shown = null;
|
||||
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
|
||||
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
}
|
||||
});
|
||||
}catch(_){ }
|
||||
}
|
||||
|
||||
// LQIP blur/fade-in for thumbnails marked with data-lqip
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
try{
|
||||
document.querySelectorAll('img[data-lqip]')
|
||||
.forEach(function(img){
|
||||
img.classList.add('lqip');
|
||||
img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
|
||||
});
|
||||
}catch(_){ }
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue