284 lines
7.3 KiB
TypeScript
284 lines
7.3 KiB
TypeScript
const dataElement = document.getElementById("collage-data") as HTMLScriptElement;
|
|
const stage = document.querySelector<HTMLElement>(".collage-stage");
|
|
const track = document.querySelector<HTMLElement>(".collage-track");
|
|
const config = JSON.parse(dataElement.textContent);
|
|
|
|
if (!stage || !track) {
|
|
throw new Error("Collage stage could not be initialized.");
|
|
}
|
|
|
|
let lastFrame = performance.now();
|
|
let itemCursor = 0;
|
|
const columns = [];
|
|
const deviceScale = window.devicePixelRatio || 1;
|
|
const hydrationQueue = [];
|
|
const hydrateDelay = Math.max(Number(config.hydrateDelay) || 0, 0);
|
|
const placeholderHeight = 160;
|
|
const cardColumns = new WeakMap();
|
|
let trackTop = 0;
|
|
let hydrationTimer;
|
|
const cardObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const card = entry.target as HTMLElement;
|
|
const column = cardColumns.get(card);
|
|
const previousExtent = cardExtent(card);
|
|
|
|
card.dataset.height = String(Math.max(entry.contentRect.height, placeholderHeight));
|
|
|
|
if (column) {
|
|
column.contentHeight += cardExtent(card) - previousExtent;
|
|
}
|
|
}
|
|
});
|
|
|
|
function snapPixel(value) {
|
|
return Math.round(value * deviceScale) / deviceScale;
|
|
}
|
|
|
|
function slugify(value = "") {
|
|
return String(value)
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "") || "unknown";
|
|
}
|
|
|
|
function executeScripts(root) {
|
|
for (const script of root.querySelectorAll("script")) {
|
|
const replacement = document.createElement("script");
|
|
|
|
for (const attribute of script.attributes) {
|
|
replacement.setAttribute(attribute.name, attribute.value);
|
|
}
|
|
|
|
replacement.textContent = script.textContent;
|
|
script.replaceWith(replacement);
|
|
}
|
|
}
|
|
|
|
function scheduleHydration(card, item) {
|
|
if (!hydrateDelay) {
|
|
hydrateCard(card, item);
|
|
return;
|
|
}
|
|
|
|
hydrationQueue.push({ card, item });
|
|
|
|
if (!hydrationTimer) {
|
|
hydrationTimer = setInterval(hydrateNextCard, hydrateDelay);
|
|
}
|
|
}
|
|
|
|
function hydrateNextCard() {
|
|
let next = hydrationQueue.shift();
|
|
|
|
while (next && !next.card.isConnected) {
|
|
next = hydrationQueue.shift();
|
|
}
|
|
|
|
if (!next) {
|
|
clearInterval(hydrationTimer);
|
|
hydrationTimer = undefined;
|
|
return;
|
|
}
|
|
|
|
hydrateCard(next.card, next.item);
|
|
}
|
|
|
|
function nudgeProviderWidgets(root) {
|
|
window.twttr?.widgets?.load?.(root);
|
|
window.instgrm?.Embeds?.process?.();
|
|
window.bluesky?.scan?.(root);
|
|
}
|
|
|
|
function hydrateCard(card, item) {
|
|
if (item.type === "photo" && item.url) {
|
|
const image = document.createElement("img");
|
|
image.src = item.url;
|
|
image.alt = item.title || "";
|
|
card.replaceChildren(image);
|
|
} else {
|
|
card.innerHTML = item.html || "";
|
|
}
|
|
|
|
executeScripts(card);
|
|
nudgeProviderWidgets(card);
|
|
card.classList.add("is-hydrated");
|
|
}
|
|
|
|
function createCard(column, item) {
|
|
const card = document.createElement("section");
|
|
card.className = `embed collage-card provider-${slugify(item.providerName)}`;
|
|
card.dataset.source = item.targetUrl;
|
|
card.dataset.height = String(placeholderHeight);
|
|
card.style.setProperty("--embed-width", `${config.cardWidth}px`);
|
|
cardColumns.set(card, column);
|
|
column.contentHeight += cardExtent(card);
|
|
cardObserver.observe(card);
|
|
scheduleHydration(card, item);
|
|
return card;
|
|
}
|
|
|
|
function createColumn(index) {
|
|
const element = document.createElement("div");
|
|
element.className = "collage-column";
|
|
element.dataset.column = String(index);
|
|
track.append(element);
|
|
|
|
return {
|
|
element,
|
|
index,
|
|
offset: 0,
|
|
contentHeight: 0,
|
|
};
|
|
}
|
|
|
|
function cardExtent(card) {
|
|
return cardHeight(card) + config.spacing;
|
|
}
|
|
|
|
function cardHeight(card) {
|
|
return Math.max(Number(card.dataset.height) || 0, placeholderHeight);
|
|
}
|
|
|
|
function candidateY(column) {
|
|
return column.contentHeight - column.offset;
|
|
}
|
|
|
|
function isTooCloseToSamePost(item, column) {
|
|
if (!config.repeatDistance || config.items.length < 2) {
|
|
return false;
|
|
}
|
|
|
|
const minDistanceSquared = config.repeatDistance * config.repeatDistance;
|
|
const nextY = candidateY(column);
|
|
|
|
for (const otherColumn of columns) {
|
|
const columnDistance = Math.abs(column.index - otherColumn.index) * (config.cardWidth + config.spacing);
|
|
|
|
for (const card of otherColumn.element.children) {
|
|
if (card.dataset.source !== item.targetUrl) {
|
|
continue;
|
|
}
|
|
|
|
const existingY = card.offsetTop - otherColumn.offset;
|
|
const yDistance = nextY - existingY;
|
|
const distanceSquared = (columnDistance * columnDistance) + (yDistance * yDistance);
|
|
|
|
if (distanceSquared < minDistanceSquared) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function randomCandidate() {
|
|
return config.items[Math.floor(Math.random() * config.items.length)];
|
|
}
|
|
|
|
function sequentialCandidate(attempt) {
|
|
return config.items[(itemCursor + attempt) % config.items.length];
|
|
}
|
|
|
|
function nextItem(column) {
|
|
const maxAttempts = Math.max(config.items.length * 2, 8);
|
|
let fallback = config.items[0];
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
const item = config.shuffle ? randomCandidate() : sequentialCandidate(attempt);
|
|
fallback = item;
|
|
|
|
if (!isTooCloseToSamePost(item, column)) {
|
|
if (!config.shuffle) {
|
|
itemCursor += attempt + 1;
|
|
}
|
|
|
|
return item;
|
|
}
|
|
}
|
|
|
|
if (!config.shuffle) {
|
|
itemCursor += 1;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
function fillColumn(column) {
|
|
const targetHeight = stage.clientHeight + Math.max(config.cardWidth, 720) + config.spacing;
|
|
let guard = 0;
|
|
|
|
while (column.contentHeight - column.offset < targetHeight && guard < 24) {
|
|
column.element.append(createCard(column, nextItem(column)));
|
|
guard += 1;
|
|
}
|
|
}
|
|
|
|
function fillColumns() {
|
|
for (const column of columns) {
|
|
fillColumn(column);
|
|
}
|
|
}
|
|
|
|
function recycleColumn(column) {
|
|
let firstCard = column.element.firstElementChild;
|
|
|
|
while (firstCard && isFullyAboveStage(firstCard, column)) {
|
|
column.contentHeight -= cardExtent(firstCard);
|
|
column.offset -= cardExtent(firstCard);
|
|
cardObserver.unobserve(firstCard);
|
|
cardColumns.delete(firstCard);
|
|
firstCard.remove();
|
|
column.element.append(createCard(column, nextItem(column)));
|
|
firstCard = column.element.firstElementChild;
|
|
}
|
|
}
|
|
|
|
function isFullyAboveStage(card, column) {
|
|
const cardBottom = trackTop + cardHeight(card) - column.offset;
|
|
|
|
return cardBottom <= 0;
|
|
}
|
|
|
|
function scrollSpeed() {
|
|
const baseDistance = Math.max(stage.clientHeight, config.cardWidth * config.repeat);
|
|
return baseDistance / config.duration;
|
|
}
|
|
|
|
function frame(now) {
|
|
const elapsedSeconds = Math.min((now - lastFrame) / 1000, 0.1);
|
|
lastFrame = now;
|
|
|
|
for (const column of columns) {
|
|
column.offset += scrollSpeed() * elapsedSeconds;
|
|
recycleColumn(column);
|
|
fillColumn(column);
|
|
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
|
|
}
|
|
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
for (let index = 0; index < config.columns; index += 1) {
|
|
columns.push(createColumn(index));
|
|
}
|
|
|
|
fillColumns();
|
|
trackTop = track.offsetTop;
|
|
|
|
for (const [index, column] of columns.entries()) {
|
|
const firstCard = column.element.firstElementChild;
|
|
const stagger = firstCard ? Math.min(index * config.spacing * 0.75, cardExtent(firstCard) * 0.4) : 0;
|
|
column.offset = stagger;
|
|
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
|
|
}
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
trackTop = track.offsetTop;
|
|
fillColumns();
|
|
});
|
|
observer.observe(stage);
|
|
|
|
requestAnimationFrame(frame);
|