lazyloading

This commit is contained in:
2026-05-18 18:26:46 +02:00
parent f87642341b
commit 3ac1489d1f
2 changed files with 67 additions and 20 deletions

View File

@@ -23,6 +23,22 @@
let chatFocused = document.hasFocus(); let chatFocused = document.hasFocus();
const ytOembedCache = new Map(); // videoId → {title, author_name} const ytOembedCache = new Map(); // videoId → {title, author_name}
// Shared IntersectionObserver for lazy-loading embedded images.
// Images are rendered with data-lazy-src; this observer sets the real src
// when the image is within 200px of the visible scroll area.
const lazyImgObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
const src = img.dataset.lazySrc;
if (src) { img.src = src; delete img.dataset.lazySrc; }
lazyImgObserver.unobserve(img);
}
}, {
rootMargin: '200px', // start loading 200px before entering viewport
threshold: 0
});
function updateBadge() { function updateBadge() {
const badge = document.getElementById('gchat-badge'); const badge = document.getElementById('gchat-badge');
const bubble = document.getElementById('gchat-reopen-bubble'); const bubble = document.getElementById('gchat-reopen-bubble');
@@ -187,7 +203,7 @@
'gi' 'gi'
); );
html = html.replace(imageRegex, url => html = html.replace(imageRegex, url =>
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>` `<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
); );
// 6b. Raw video URLs from allowed hosts → <video> // 6b. Raw video URLs from allowed hosts → <video>
@@ -270,7 +286,7 @@
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path)) if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`; return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path)) if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`; return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path)) if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`; return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
} }
@@ -310,13 +326,17 @@
</div>`; </div>`;
} }
function scrollToBottom(force = false) { function scrollToBottom(force = false, smooth = false) {
const el = document.getElementById('gchat-messages'); const el = document.getElementById('gchat-messages');
if (!el) return; if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (!force && !nearBottom) return; if (!force && !nearBottom) return;
// Double rAF ensures the browser has committed the layout before we measure scrollHeight if (smooth) {
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; })); el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
} else {
// Double rAF ensures layout is committed before reading scrollHeight
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
}
} }
/** /**
@@ -351,9 +371,15 @@
}; };
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
// Debounce: batch rapid layout changes (e.g. progressive image renders)
// into a single smooth scroll instead of many jarring instant jumps.
let debounceTimer = null;
ro = new ResizeObserver(() => { ro = new ResizeObserver(() => {
if (userScrolledUp) { cleanup(); return; } if (userScrolledUp) { cleanup(); return; }
el.scrollTop = el.scrollHeight; clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, 80);
}); });
ro.observe(el); ro.observe(el);
} else { } else {
@@ -364,10 +390,9 @@
setTimeout(cleanup, durationMs); setTimeout(cleanup, durationMs);
// Immediate snaps // First snap is instant (no animation — the panel just opened)
scrollToBottom(true); scrollToBottom(true);
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 200); setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 600);
} }
async function fetchYtOembed(cardEl) { async function fetchYtOembed(cardEl) {
@@ -470,9 +495,16 @@
s.addEventListener('click', () => s.classList.toggle('revealed')); s.addEventListener('click', () => s.classList.toggle('revealed'));
}); });
// Embedded images: scroll to bottom when loaded + open modal on click // Embedded images: register with lazy observer; scroll on load only for new messages (not history)
node.querySelectorAll('.gchat-embed-img img').forEach(img => { node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
img.addEventListener('load', () => scrollToBottom(scrollForce)); img.addEventListener('load', () => scrollToBottom(scrollForce));
img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in';
lazyImgObserver.observe(img);
});
// Also handle already-src'd images (avatars etc.)
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
img.addEventListener('load', () => scrollToBottom(scrollForce));
img.addEventListener('click', () => openImgModal(img.src)); img.addEventListener('click', () => openImgModal(img.src));
img.style.cursor = 'zoom-in'; img.style.cursor = 'zoom-in';
}); });
@@ -498,10 +530,9 @@
if (!data.success) return; if (!data.success) return;
const container = document.getElementById('gchat-messages'); const container = document.getElementById('gchat-messages');
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
// Pass scrollForce=true so every img.load in the batch force-scrolls (data.messages || []).forEach(m => appendMsg(m, false));
(data.messages || []).forEach(m => appendMsg(m, true)); // One instant snap — images above viewport won't load (lazy) so no layout shift
// Start sticky scroll: watches ResizeObserver + input events, holds 8s container.scrollTop = container.scrollHeight;
startStickyScroll(8000);
} catch (e) { } catch (e) {
console.error('[Chat] Failed to load history:', e); console.error('[Chat] Failed to load history:', e);
} }

View File

@@ -1201,8 +1201,10 @@ if (window.__dmLoaded) {
bubble.appendChild(placeholder); bubble.appendChild(placeholder);
} }
// Auto-decrypt and render // Defer decrypt until the placeholder scrolls near the viewport.
(async () => { // This means attachments above the initial view never cause layout shifts.
const decryptWhenVisible = () => {
(async () => {
const blob = await fetchAndDecryptAttachment(att.id, att.mime); const blob = await fetchAndDecryptAttachment(att.id, att.mime);
if (!blob || !placeholder.parentNode) return; if (!blob || !placeholder.parentNode) return;
@@ -1264,6 +1266,16 @@ if (window.__dmLoaded) {
requestAnimationFrame(() => requestAnimationFrame(snapIfAtBottom)); requestAnimationFrame(() => requestAnimationFrame(snapIfAtBottom));
} }
})(); })();
}; // end decryptWhenVisible
// Use IntersectionObserver to defer decryption until near viewport.
// Bottom messages (already visible) fire immediately; older ones wait.
const io = new IntersectionObserver((entries, obs) => {
if (!entries[0].isIntersecting) return;
obs.disconnect();
decryptWhenVisible();
}, { rootMargin: '200px', threshold: 0 });
io.observe(placeholder);
} }
} }
@@ -2481,9 +2493,13 @@ if (window.__dmLoaded) {
// ResizeObserver: re-snap whenever content grows (images, attachments decrypting) // ResizeObserver: re-snap whenever content grows (images, attachments decrypting)
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
let debounceTimer = null;
ro = new ResizeObserver(() => { ro = new ResizeObserver(() => {
if (userScrolledUp) { cleanup(); return; } if (userScrolledUp) { cleanup(); return; }
el.scrollTop = el.scrollHeight; clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, 80);
}); });
ro.observe(el); ro.observe(el);
} else { } else {
@@ -2496,7 +2512,7 @@ if (window.__dmLoaded) {
// Disconnect after the deadline regardless // Disconnect after the deadline regardless
setTimeout(cleanup, durationMs); setTimeout(cleanup, durationMs);
// Immediate snaps as safety net for content already in the DOM // First snaps are instant (panel just opened — no animation needed)
snapToBottom(el, true); snapToBottom(el, true);
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 200); setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 200);
setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 600); setTimeout(() => { if (!userScrolledUp) snapToBottom(el, true); }, 600);