Stream the media grid with a scroll buffer
This commit is contained in:
@@ -40,6 +40,14 @@ mobile draft at 390px.
|
|||||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||||
|
- [x] C45 Streaming media grid. The grid rebuilt every card on each appended page, which made
|
||||||
|
filtered browsing flash, and the `Load more` trigger only worked once the grid could already
|
||||||
|
scroll, so short filtered pages dead-stopped at ~11 cards. Now pages append incrementally
|
||||||
|
(existing images are never rebuilt), a stream buffer capped at 1.5 viewport heights keeps the
|
||||||
|
scroll alive while the next page loads, auto-fill cascades while the buffer is on screen, the
|
||||||
|
buffer note reads `Loading more…` / `Scroll for more` / `End of results`, and an elastic nudge
|
||||||
|
replaces the hard stop at the bottom. Smoke test: first load reaches 120 cards via auto-fill,
|
||||||
|
scrolling appends 120 → 440 with no broken images.
|
||||||
- [x] C44 One traffic pipeline and split ETAs. Walks and backfill batches now share a
|
- [x] C44 One traffic pipeline and split ETAs. Walks and backfill batches now share a
|
||||||
media-traffic lock: a walk holds it for its whole run, a batch for one batch, so the pacing
|
media-traffic lock: a walk holds it for its whole run, a batch for one batch, so the pacing
|
||||||
settings bound total traffic instead of each action. Starting then immediately stopping a
|
settings bound total traffic instead of each action. Starting then immediately stopping a
|
||||||
|
|||||||
+8
-3
@@ -175,9 +175,14 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
|
|||||||
can take its place while the hidden one stays resumable. The strip text turns red and reads
|
can take its place while the hidden one stays resumable. The strip text turns red and reads
|
||||||
`API limited, retry in Ns` while Discord throttles media traffic, and a translucent
|
`API limited, retry in Ns` while Discord throttles media traffic, and a translucent
|
||||||
`N scrapes completed` line appears when scrapes finish — it lives until the tab is reloaded. Each
|
`N scrapes completed` line appears when scrapes finish — it lives until the tab is reloaded. Each
|
||||||
row shows `X/Yp (~Z%)` when the search total is known. The grid scrolls infinitely
|
row shows `X/Yp (~Z%)` when the search total is known. The grid streams: appending a page inserts
|
||||||
(a **Load more** button appears as a fallback). Media whose source answers `403` shows a
|
only the new cards, so loaded images never rebuild or flash, and a stream **buffer** below the last
|
||||||
**PRIVATE** tile and `404`/`410` shows **GONE**, with the reason in the tooltip. Some channels deny the bot the search endpoint (`Missing Access`) even though history
|
row keeps the scroll alive while the next page is in flight. The buffer is capped at 1.5 viewport
|
||||||
|
heights, shrinks as the filtered results run out, and says `Loading more…` / `Scroll for more` /
|
||||||
|
`End of results`; auto-fill cascades pages while the buffer is still in view, so a filter that
|
||||||
|
returns few items does not dead-stop at one page. At the very bottom while a page loads, an
|
||||||
|
elastic nudge replaces the hard stop. Media whose source answers `403` shows a **PRIVATE** tile
|
||||||
|
and `404`/`410` shows **GONE**, with the reason in the tooltip. Some channels deny the bot the search endpoint (`Missing Access`) even though history
|
||||||
reads work, so the total is optional.
|
reads work, so the total is optional.
|
||||||
**New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to
|
**New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to
|
||||||
channels; each row has Links and Download). Every scrape button asks for confirmation; the
|
channels; each row has Links and Download). Every scrape button asks for confirmation; the
|
||||||
|
|||||||
+6
-2
@@ -135,8 +135,12 @@ box live in Chat, not Call, and the connection stats are not chips yet.
|
|||||||
apply until **Apply filters** is pressed; **Reset** clears every filter and the zoom, unloads the
|
apply until **Apply filters** is pressed; **Reset** clears every filter and the zoom, unloads the
|
||||||
grid, and brings back the initial Load media prompt.
|
grid, and brings back the initial Load media prompt.
|
||||||
- A **Card size** slider scales tiles from two-thirds of the old size up to one image per row; the
|
- A **Card size** slider scales tiles from two-thirds of the old size up to one image per row; the
|
||||||
page size follows the layout — full rows about 1.5 screens tall — and the grid infinite-scrolls,
|
page size follows the layout — full rows about 1.5 screens tall — and the grid streams: each page
|
||||||
preloading the next page's JSON and preview images just before they are needed.
|
appends only its new cards (no image flash) and a buffer below the last row, capped at 1.5
|
||||||
|
viewport heights, keeps the scroll alive while the next page loads. Auto-fill runs while the
|
||||||
|
buffer is on screen, so short filtered pages never dead-stop; the note reads `Loading more…`,
|
||||||
|
`Scroll for more`, or `End of results`, and an elastic nudge replaces the hard stop at the bottom
|
||||||
|
while loading. The next page's JSON and previews are preloaded just before they are needed.
|
||||||
**New scrape…** opens a popup with **Server** and **Group chat**
|
**New scrape…** opens a popup with **Server** and **Group chat**
|
||||||
tabs: servers expand into their text channels, each channel (and each group chat) has
|
tabs: servers expand into their text channels, each channel (and each group chat) has
|
||||||
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
||||||
|
|||||||
@@ -750,3 +750,21 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
text-decoration: underline dotted color-mix(in srgb, var(--color-neutral-400) 60%, transparent);
|
text-decoration: underline dotted color-mix(in srgb, var(--color-neutral-400) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stream buffer: scrollable room while the next page loads. */
|
||||||
|
.vk-media-buffer {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-media-buffer-note {
|
||||||
|
padding: 10px 0;
|
||||||
|
font: 400 11px/1.5 ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
}
|
||||||
|
|||||||
+209
-39
@@ -35,6 +35,9 @@
|
|||||||
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
|
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
|
||||||
draft: null,
|
draft: null,
|
||||||
prefetched: null,
|
prefetched: null,
|
||||||
|
prefetchPromise: null,
|
||||||
|
itemKeys: new Set(),
|
||||||
|
loadError: false,
|
||||||
pageSize: 60,
|
pageSize: 60,
|
||||||
backfill: null,
|
backfill: null,
|
||||||
preferences: null,
|
preferences: null,
|
||||||
@@ -101,6 +104,14 @@
|
|||||||
const tunablesHost = overlay.querySelector("#bot-tunables");
|
const tunablesHost = overlay.querySelector("#bot-tunables");
|
||||||
const botErrors = overlay.querySelector("#bot-errors");
|
const botErrors = overlay.querySelector("#bot-errors");
|
||||||
|
|
||||||
|
// The stream buffer: extra scrollable room so hitting the bottom is never a dead stop.
|
||||||
|
const buffer = el("div", "vk-media-buffer");
|
||||||
|
const bufferNote = el("div", "vk-media-buffer-note", "");
|
||||||
|
const bufferRetry = button("Retry", () => void loadNextPage());
|
||||||
|
bufferRetry.classList.add("vk-media-buffer-retry");
|
||||||
|
bufferRetry.hidden = true;
|
||||||
|
buffer.append(bufferNote, bufferRetry);
|
||||||
|
|
||||||
const filterDrawer = el("div", "vk-media-drawer");
|
const filterDrawer = el("div", "vk-media-drawer");
|
||||||
filterDrawer.hidden = true;
|
filterDrawer.hidden = true;
|
||||||
toolbar.after(filterDrawer);
|
toolbar.after(filterDrawer);
|
||||||
@@ -297,43 +308,84 @@
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append only the cards that are new, so existing images never get rebuilt. */
|
||||||
|
function appendPage(items) {
|
||||||
|
const fresh = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.scrapeId}:${item.itemId}`;
|
||||||
|
if (state.itemKeys.has(key)) continue;
|
||||||
|
state.itemKeys.add(key);
|
||||||
|
fresh.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.items = state.items.concat(fresh);
|
||||||
|
state.offset += items.length;
|
||||||
|
state.more = items.length >= state.pageSize;
|
||||||
|
state.loadError = false;
|
||||||
|
|
||||||
|
for (const item of fresh) grid.insertBefore(renderCard(item), buffer);
|
||||||
|
layoutBuffer();
|
||||||
|
renderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the list and render everything from scratch. */
|
||||||
|
function renderCards() {
|
||||||
|
grid.replaceChildren();
|
||||||
|
for (const item of state.items) grid.append(renderCard(item));
|
||||||
|
if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters."));
|
||||||
|
grid.append(buffer);
|
||||||
|
layoutBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMedia(reset) {
|
async function loadMedia(reset) {
|
||||||
if (!state.loaded || state.loading) return;
|
if (!state.loaded || state.loading) return;
|
||||||
|
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
|
state.loadError = false;
|
||||||
|
layoutBuffer();
|
||||||
if (reset) {
|
if (reset) {
|
||||||
state.items = [];
|
state.items = [];
|
||||||
|
state.itemKeys = new Set();
|
||||||
state.offset = 0;
|
state.offset = 0;
|
||||||
state.more = false;
|
state.more = true;
|
||||||
state.prefetched = null;
|
state.prefetched = null;
|
||||||
|
state.prefetchPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await window.VK.api.get(
|
const items = await window.VK.api.get(
|
||||||
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
||||||
);
|
);
|
||||||
state.items = state.items.concat(items);
|
|
||||||
|
if (reset) {
|
||||||
|
for (const item of items) state.itemKeys.add(`${item.scrapeId}:${item.itemId}`);
|
||||||
|
state.items = items;
|
||||||
state.offset += items.length;
|
state.offset += items.length;
|
||||||
state.more = items.length >= state.pageSize;
|
state.more = items.length >= state.pageSize;
|
||||||
|
renderCards();
|
||||||
|
} else {
|
||||||
|
appendPage(items);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
state.loadError = true;
|
||||||
setStatus(`media load failed: ${error.message}`);
|
setStatus(`media load failed: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGrid();
|
layoutBuffer();
|
||||||
void loadStats();
|
void loadStats();
|
||||||
void prefetchNext();
|
void prefetchNext();
|
||||||
|
void fillBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Warm the next page's JSON and preview images before the user reaches them. */
|
/** Warm the next page's JSON and preview images before the user reaches them. */
|
||||||
async function prefetchNext() {
|
async function prefetchNext() {
|
||||||
if (!state.loaded || !state.more || state.prefetched || state.loading) return;
|
if (!state.loaded || !state.more || state.prefetched || state.prefetchPromise || state.loading) return;
|
||||||
|
|
||||||
try {
|
state.prefetchPromise = window.VK.api
|
||||||
const items = await window.VK.api.get(
|
.get(`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`)
|
||||||
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
.then(items => {
|
||||||
);
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
state.more = false;
|
state.more = false;
|
||||||
return;
|
return;
|
||||||
@@ -346,9 +398,87 @@
|
|||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = src;
|
image.src = src;
|
||||||
}
|
}
|
||||||
} catch {
|
})
|
||||||
|
.catch(() => {
|
||||||
// Preloading is best effort; the scroll handler falls back to a request.
|
// Preloading is best effort; the scroll handler falls back to a request.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
state.prefetchPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await state.prefetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ensure the next page is on its way when the buffer is close or already in view. */
|
||||||
|
async function loadNextPage() {
|
||||||
|
if (state.loading || !state.loaded || !state.more) return;
|
||||||
|
|
||||||
|
if (state.prefetchPromise) await state.prefetchPromise;
|
||||||
|
|
||||||
|
if (state.prefetched) {
|
||||||
|
const items = state.prefetched;
|
||||||
|
state.prefetched = null;
|
||||||
|
appendPage(items);
|
||||||
|
void prefetchNext();
|
||||||
|
void fillBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadMedia(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buffer keeps scolling alive while the next page loads. Its height is capped at
|
||||||
|
* 1.5 viewport heights and shrinks as the end of the filtered results comes closer.
|
||||||
|
*/
|
||||||
|
function layoutBuffer() {
|
||||||
|
if (!state.loaded) {
|
||||||
|
buffer.style.height = "0px";
|
||||||
|
bufferNote.textContent = "";
|
||||||
|
bufferRetry.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.more && !state.loadError) {
|
||||||
|
buffer.style.height = "";
|
||||||
|
bufferNote.textContent = state.items.length ? "End of results" : "";
|
||||||
|
bufferRetry.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = Math.round((grid.clientHeight || 600) * 1.5);
|
||||||
|
const rowHeight = gridRowHeight();
|
||||||
|
const total = filteredTotal();
|
||||||
|
const remainingRows = total === null ? null : Math.ceil(Math.max(0, total - state.offset) / gridColumns());
|
||||||
|
const wanted = remainingRows === null ? cap : remainingRows * rowHeight;
|
||||||
|
buffer.style.height = `${Math.max(rowHeight, Math.min(cap, wanted))}px`;
|
||||||
|
|
||||||
|
bufferNote.textContent = state.loadError
|
||||||
|
? "Could not load more."
|
||||||
|
: state.loading
|
||||||
|
? "Loading more\u2026"
|
||||||
|
: "Scroll for more";
|
||||||
|
bufferRetry.hidden = !state.loadError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtered total from the stats the status row already uses; null until known. */
|
||||||
|
function filteredTotal() {
|
||||||
|
const stats = state.stats;
|
||||||
|
if (!stats) return null;
|
||||||
|
return (
|
||||||
|
(stats.image?.total ?? 0) +
|
||||||
|
(stats.gif?.total ?? 0) +
|
||||||
|
(stats.video?.total ?? 0) +
|
||||||
|
(stats.audio?.total ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep loading while the buffer is on screen, so short filtered pages never dead-stop. */
|
||||||
|
function fillBuffer() {
|
||||||
|
if (!state.more || state.loading || !state.loaded) return;
|
||||||
|
const top = buffer.getBoundingClientRect().top;
|
||||||
|
const bottom = grid.getBoundingClientRect().bottom;
|
||||||
|
if (top <= bottom + 320) void loadNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
||||||
@@ -942,16 +1072,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of state.items) grid.append(renderCard(item));
|
renderCards();
|
||||||
|
|
||||||
if (!state.items.length) {
|
|
||||||
grid.append(el("div", "vk-media-status", "No media matches the filters."));
|
|
||||||
} else if (state.more) {
|
|
||||||
const more = el("button", "btn btn-secondary vk-media-load-more", "Load more");
|
|
||||||
more.type = "button";
|
|
||||||
more.addEventListener("click", () => void loadMedia(false));
|
|
||||||
grid.append(more);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterSelect(options, value, onChange) {
|
function filterSelect(options, value, onChange) {
|
||||||
@@ -1313,12 +1434,19 @@
|
|||||||
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
|
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gridColumns() {
|
||||||
|
const width = cardWidth();
|
||||||
|
return Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridRowHeight() {
|
||||||
|
return cardWidth() * 1.16 + 30;
|
||||||
|
}
|
||||||
|
|
||||||
/** Items per page: full rows, about 1.5 screens tall. */
|
/** Items per page: full rows, about 1.5 screens tall. */
|
||||||
function computePageSize() {
|
function computePageSize() {
|
||||||
const width = cardWidth();
|
const columns = gridColumns();
|
||||||
const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
|
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (gridRowHeight() + 8)));
|
||||||
const rowHeight = width * 1.16 + 30;
|
|
||||||
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8)));
|
|
||||||
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
|
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,6 +1456,7 @@
|
|||||||
grid.style.gridTemplateColumns =
|
grid.style.gridTemplateColumns =
|
||||||
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
|
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
|
||||||
state.pageSize = computePageSize();
|
state.pageSize = computePageSize();
|
||||||
|
layoutBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetMediaView() {
|
function resetMediaView() {
|
||||||
@@ -1414,25 +1543,66 @@
|
|||||||
if (state.view === "bot") void refreshBackfill();
|
if (state.view === "bot") void refreshBackfill();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
// Infinite scroll: the buffer entering the viewport starts the next page.
|
||||||
grid.addEventListener("scroll", () => {
|
const bufferObserver = new IntersectionObserver(
|
||||||
if (state.loading || !state.loaded) return;
|
entries => {
|
||||||
if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return;
|
if (entries.some(entry => entry.isIntersecting)) void loadNextPage();
|
||||||
|
},
|
||||||
|
{ root: grid, rootMargin: "400px 0px" }
|
||||||
|
);
|
||||||
|
bufferObserver.observe(buffer);
|
||||||
|
|
||||||
if (state.prefetched) {
|
grid.addEventListener("scroll", () => {
|
||||||
const items = state.prefetched;
|
if (!state.loaded) return;
|
||||||
state.prefetched = null;
|
const remaining = grid.scrollHeight - grid.scrollTop - grid.clientHeight;
|
||||||
state.items = state.items.concat(items);
|
if (remaining < 400) void loadNextPage();
|
||||||
state.offset += items.length;
|
});
|
||||||
state.more = items.length >= state.pageSize;
|
|
||||||
renderGrid();
|
// Past the buffer, a small elastic nudge instead of a hard stop while more is loading.
|
||||||
void loadStats();
|
let elasticOffset = 0;
|
||||||
void prefetchNext();
|
let elasticTimer = null;
|
||||||
return;
|
|
||||||
|
function elastic(delta) {
|
||||||
|
if (!state.more || !state.loading) return;
|
||||||
|
const atBottom = grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2;
|
||||||
|
if (!atBottom) return;
|
||||||
|
|
||||||
|
elasticOffset = Math.max(-18, Math.min(0, elasticOffset - delta * 0.35));
|
||||||
|
grid.style.transition = "none";
|
||||||
|
grid.style.transform = `translateY(${elasticOffset}px)`;
|
||||||
|
|
||||||
|
clearTimeout(elasticTimer);
|
||||||
|
elasticTimer = setTimeout(() => {
|
||||||
|
grid.style.transition = "transform 160ms ease-out";
|
||||||
|
grid.style.transform = "";
|
||||||
|
elasticOffset = 0;
|
||||||
|
}, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.more) void loadMedia(false);
|
grid.addEventListener(
|
||||||
});
|
"wheel",
|
||||||
|
event => {
|
||||||
|
if (event.deltaY > 0) elastic(event.deltaY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
let touchStartY = 0;
|
||||||
|
grid.addEventListener(
|
||||||
|
"touchstart",
|
||||||
|
event => {
|
||||||
|
touchStartY = event.touches[0]?.clientY ?? 0;
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
grid.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
event => {
|
||||||
|
const y = event.touches[0]?.clientY ?? 0;
|
||||||
|
if (touchStartY - y > 0) elastic((touchStartY - y) / 8);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Scrape popup: server or group-chat trees ----
|
// ---- Scrape popup: server or group-chat trees ----
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,33 @@ try {
|
|||||||
);
|
);
|
||||||
step("broken images", broken);
|
step("broken images", broken);
|
||||||
check("no broken images", broken === 0, broken);
|
check("no broken images", broken === 0, broken);
|
||||||
|
check("stream buffer present", (await page.locator(".vk-media-buffer").count()) === 1);
|
||||||
|
check("no load-more button", (await page.locator(".vk-media-load-more").count()) === 0);
|
||||||
|
|
||||||
|
// Infinite scroll: scrolling into the buffer appends cards without rebuilding the grid.
|
||||||
|
const beforeScroll = await page.locator(".vk-media-figure").count();
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
await page.locator("#media-grid").evaluate(node => {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
}
|
||||||
|
const afterScroll = await page.locator(".vk-media-figure").count();
|
||||||
|
step("infinite scroll", { before: beforeScroll, after: afterScroll });
|
||||||
|
check("infinite scroll appends cards", afterScroll > beforeScroll, {
|
||||||
|
before: beforeScroll,
|
||||||
|
after: afterScroll
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
"appended cards do not corrupt loaded ones",
|
||||||
|
(await page.$$eval(".vk-media-figure .vk-media-media img", images =>
|
||||||
|
images.filter(image => image.complete && image.naturalWidth === 0).length
|
||||||
|
)) === 0
|
||||||
|
);
|
||||||
|
await page.locator("#media-grid").evaluate(node => {
|
||||||
|
node.scrollTop = 0;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
await page.getByRole("button", { name: /^People/ }).click();
|
await page.getByRole("button", { name: /^People/ }).click();
|
||||||
step("people checkboxes", await page.locator(".vk-media-people input").count());
|
step("people checkboxes", await page.locator(".vk-media-people input").count());
|
||||||
|
|||||||
Reference in New Issue
Block a user