diff options
| author | Leonard Kugis <leonard@kug.is> | 2026-09-15 17:36:18 +0200 |
|---|---|---|
| committer | Leonard Kugis <leonard@kug.is> | 2026-09-15 17:36:18 +0200 |
| commit | 0605e67c29ab735615860a6649ebd77a38b534f8 (patch) | |
| tree | 02df2b18a3f5a3895267f1b370d390508e93f2ca | |
| parent | 422d2e024cc5927280bfc9e2794797d510005728 (diff) | |
| download | medman-0605e67c29ab735615860a6649ebd77a38b534f8.tar.gz | |
Fixed scroll anchor element
| -rw-r--r-- | templates/index.html | 113 |
1 files changed, 87 insertions, 26 deletions
diff --git a/templates/index.html b/templates/index.html index dfb4c2c..3808a3d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,7 +47,7 @@ header.collapsed h1 { font-size:0.9rem; } /* ── main layout ────────────────────────────────────────── */ main { flex:1; display:flex; overflow:hidden; } -/* ── sidebar ────────────────────────────────────────────── */ +/* ── sidebar ─ fixed, does not scroll with content ──────── */ #side { width:240px; min-width:240px; background:var(--surface); border-right:1px solid var(--border); padding:10px; overflow-y:auto; flex-shrink:0; display:flex; flex-direction:column; gap:6px; transition:width 0.2s, min-width 0.2s, padding 0.2s; } #side.collapsed { width:0; min-width:0; padding:10px 0; overflow:hidden; border-right:none; } @@ -105,7 +105,7 @@ main { flex:1; display:flex; overflow:hidden; } .card .chk { position:absolute; top:4px; left:4px; width:18px; height:18px; border-radius:4px; border:2px solid rgba(255,255,255,0.4); background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; font-size:0.6rem; color:#fff; cursor:pointer; z-index:5; } .card.sel .chk { background:var(--accent); border-color:var(--accent); } -/* ── removed pagination; loading indicator ──────────────── */ +/* ── loading indicator ──────────────────────────────────── */ #load-more { display:flex; justify-content:center; padding:16px; } #load-more .spinner { width:24px; height:24px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; display:none; } #load-more .spinner.on { display:block; } @@ -250,27 +250,48 @@ const S = { _obs:null, // IntersectionObserver instance _sentinel:null, // sentinel element _scrollTop:0, // saved scroll position when closing modal + _totalCount:null, // total file count (from first batch header or estimate) + _urlAfter:null, // hash to skip-to on initial load }; function st(msg){let e=document.getElementById('status');e.textContent=msg;e.classList.add('on');clearTimeout(e._t);e._t=setTimeout(()=>e.classList.remove('on'),2200);} async function api(u,o={}){let r=await fetch(u,o);if(!r.ok){let e=await r.json().catch(()=>({error:r.statusText}));throw new Error(e.error||r.statusText);}return r.json();} // ── URL helpers ───────────────────────────────────────────────────────────── + +// Write current state to URL: tags, search mode, scroll anchor (last visible hash), selection function _updateURL(){ let p=new URLSearchParams(); if(S.sm && S.stags.length) p.set('tags',S.stags.join(',')); - else if(!S.sm) p.set('all','1'); - // encode scroll anchor: hash of last rendered file - if(S._rendered && S._rendered.length){ - let last=S._rendered[S._rendered.length-1]; - if(last) p.set('after',last.hash); - } + else if(!S.sm && !S.stags.length) p.set('all','1'); + + // scroll anchor: use the hash of the first card currently visible at the top + let anchor=_visibleAnchor(); + if(anchor) p.set('anchor',anchor); + if(S.sel.size) p.set('sel',[...S.sel].join(',')); + let q=p.toString(); let url=location.pathname+(q?'?'+q:''); history.replaceState(null,'',url); } +// Find the first card whose top edge is within (or above) the content viewport +function _visibleAnchor(){ + let content=document.getElementById('content'); + let rect=content.getBoundingClientRect(); + let topEdge=rect.top; + let cards=document.querySelectorAll('#grid .card'); + for(let c of cards){ + let cr=c.getBoundingClientRect(); + // first card whose bottom is below the top of the viewport + if(cr.bottom>=topEdge) return c.dataset.hash; + } + // fallback: first rendered card + if(S._rendered&&S._rendered.length) return S._rendered[0].hash; + return null; +} + function _loadStateFromURL(){ let p=new URLSearchParams(location.search); let tags=p.get('tags'); @@ -285,8 +306,7 @@ function _loadStateFromURL(){ let sel=p.get('sel'); if(sel) S._urlSel=new Set(sel.split(',').filter(Boolean)); else S._urlSel=new Set(); - // 'after' is used to skip past already-loaded content on initial load - S._urlAfter=p.get('after')||null; + S._urlAnchor=p.get('anchor')||null; } // ── Header & sidebar toggle ───────────────────────────────────────────────── @@ -388,7 +408,6 @@ document.getElementById('ti-btn').addEventListener('click',async()=>{ document.getElementById('ti-in').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('ti-btn').click();}); // ── Files: continuous scroll ──────────────────────────────────────────────── -// Fetch one batch of files. Returns empty array when exhausted. async function _fetchBatch(afterHash=null){ let params=new URLSearchParams(); if(S.sm && S.stags.length){ @@ -421,7 +440,6 @@ function _bindCardEvents(c){ if(e.target.classList.contains('chk')||e.target.closest('.chk')){ toggleSel(h,c); e.stopPropagation(); return; } - // Save scroll position before opening S._scrollTop=document.getElementById('content').scrollTop; op(h); }); @@ -456,7 +474,7 @@ function _appendCards(items){ _bindCardEvents(card); g.appendChild(card); } - // Update sentinel + // Update sentinel at the bottom of #content if(S._sentinel) S._sentinel.remove(); let sentinel=document.createElement('div'); sentinel.style.height='1px'; sentinel.id='obs-sentinel'; @@ -475,27 +493,59 @@ function _observeSentinel(){ } async function resetAndReload(){ - // Clear grid document.getElementById('grid').innerHTML=''; document.getElementById('empty').style.display='none'; if(S._sentinel){S._sentinel.remove();S._sentinel=null;} S.files=[]; S._rendered=[]; S._exhausted=false; S._loading=false; document.getElementById('load-spin').classList.remove('on'); - _updateURL(); - // Restore URL selections S.sel.clear(); if(S._urlSel){for(let h of S._urlSel)S.sel.add(h);} updateSelUI(); - // Load initial batch + _updateURL(); _observeSentinel(); - await _loadMore(); - // If grid still empty after first batch, show empty message + + // If we have an anchor, fast-load until we reach it + if(S._urlAnchor){ + await _fastForwardToAnchor(S._urlAnchor); + S._urlAnchor=null; + } else { + await _loadMore(); + } + if(!S.files.length){ document.getElementById('empty').style.display='block'; } } +// Keep loading batches until the anchor hash appears, then scroll it into view +async function _fastForwardToAnchor(anchorHash){ + let maxBatches=50; // safety limit + while(maxBatches-->0){ + let after=S._rendered&&S._rendered.length?S._rendered[S._rendered.length-1].hash:null; + let batch=await _fetchBatch(after); + if(!batch.length){S._exhausted=true; break;} + + let found=false; + for(let f of batch){ + S.files.push(f); + if(f.hash===anchorHash) found=true; + } + _appendCards(batch); + S._rendered=Array.isArray(S._rendered)?S._rendered.concat(batch):batch.slice(); + + if(found){ + // Scroll to the anchor card + requestAnimationFrame(()=>{ + let el=document.querySelector(`#grid .card[data-hash="${CSS.escape(anchorHash)}"]`); + if(el) el.scrollIntoView({block:'start',behavior:'instant'}); + }); + S._exhausted=false; // there may be more, keep observer active + break; + } + } + _updateURL(); +} + async function reloadFiles(){ - // Used after mutations (tag, delete, move): full reload preserving scroll let scrollTop=document.getElementById('content').scrollTop; document.getElementById('grid').innerHTML=''; if(S._sentinel){S._sentinel.remove();S._sentinel=null;} @@ -507,7 +557,6 @@ async function reloadFiles(){ if(!S.files.length){ document.getElementById('empty').style.display='block'; } - // Restore scroll position (approximate) requestAnimationFrame(()=>{document.getElementById('content').scrollTop=scrollTop;}); } @@ -527,7 +576,6 @@ function updateSelUI(){ } function clearSel(){S.sel.clear();updateSelUI();_updateURL(); refreshCardSelections();} function selAll(){ - // Select all currently rendered cards let cards=document.querySelectorAll('#grid .card'); for(let c of cards){S.sel.add(c.dataset.hash);} updateSelUI();_updateURL(); refreshCardSelections(); @@ -545,7 +593,6 @@ function _clearNavKd(){ function opNav(delta){ if(!S.ph)return; let idx=S.files.findIndex(x=>x.hash===S.ph); if(idx<0)return; let nidx=idx+delta; if(nidx<0||nidx>=S.files.length)return; - // Ensure next item is loaded if near the end if(nidx>=S.files.length-S.PAGE/2 && !S._exhausted){_loadMore();} op(S.files[nidx].hash); } @@ -718,7 +765,6 @@ function closeModal(){ _clearNavKd(); document.getElementById('prev-modal').classList.remove('on'); S.ph=null; - // Restore scroll position requestAnimationFrame(()=>{ document.getElementById('content').scrollTop=S._scrollTop||0; }); @@ -728,11 +774,26 @@ document.getElementById('prev-modal').addEventListener('click',e=>{if(e.target== document.getElementById('conf-no').addEventListener('click',()=>document.getElementById('conf-modal').classList.remove('on')); document.addEventListener('keydown',e=>{if(e.key==='Escape'){if(document.getElementById('prev-modal').classList.contains('on'))closeModal();}}); +// Debounced URL update on scroll +let _scrollTimer=null; +document.getElementById('content').addEventListener('scroll',()=>{ + if(_scrollTimer) clearTimeout(_scrollTimer); + _scrollTimer=setTimeout(()=>_updateURL(),200); +}, {passive:true}); + // ── Init ──────────────────────────────────────────────────────────────────── _loadStateFromURL(); lt(); ldd(); _observeSentinel(); -_loadMore().then(()=>{ + +// Start loading — if there's an anchor in the URL, fast-forward to it +(async ()=>{ + if(S._urlAnchor){ + await _fastForwardToAnchor(S._urlAnchor); + S._urlAnchor=null; + } else { + await _loadMore(); + } if(!S.files.length) document.getElementById('empty').style.display='block'; // Apply URL selections if(S._urlSel&&S._urlSel.size){ @@ -741,7 +802,7 @@ _loadMore().then(()=>{ refreshCardSelections(); } _updateURL(); -}); +})(); </script> </body> </html> |
