mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
1090 lines
36 KiB
JavaScript
1090 lines
36 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
document.documentElement.classList.add('js-loading');
|
||
document.documentElement.classList.add('js');
|
||
|
||
const $ = (q, c = document) => (c ? c.querySelector(q) : null);
|
||
const $$ = (q, c = document) => (c ? Array.from(c.querySelectorAll(q)) : []);
|
||
const on = (el, ev, fn, opts) => el && el.addEventListener(ev, fn, opts);
|
||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||
const now = () => Date.now();
|
||
|
||
const throttle = (fn, ms = 100) => {
|
||
let t = 0, to, last;
|
||
return (...args) => {
|
||
const n = now();
|
||
if (n - t > ms) {
|
||
t = n;
|
||
fn(...args);
|
||
} else {
|
||
last = args;
|
||
clearTimeout(to);
|
||
to = setTimeout(() => {
|
||
t = now();
|
||
fn(...(last || []));
|
||
}, ms);
|
||
}
|
||
};
|
||
};
|
||
|
||
const debounce = (fn, ms = 100) => {
|
||
let to;
|
||
return (...args) => {
|
||
clearTimeout(to);
|
||
to = setTimeout(() => fn(...args), ms);
|
||
};
|
||
};
|
||
|
||
const isInteractive = (node) =>
|
||
!!node.closest('button, a, input, select, textarea, [contenteditable], .plugin-btn');
|
||
|
||
const root = $('.container');
|
||
if (!root) return;
|
||
|
||
let mosaic = $('.mosaic', root);
|
||
if (!mosaic) {
|
||
mosaic = document.createElement('section');
|
||
mosaic.className = 'mosaic';
|
||
root.prepend(mosaic);
|
||
}
|
||
|
||
mosaic.style.overflowAnchor = 'none';
|
||
|
||
const toMove = [...root.children].filter((el) => el !== mosaic);
|
||
|
||
toMove.forEach((el) => {
|
||
el.classList.add('plugin');
|
||
if (!el.querySelector('.plugin__inner')) {
|
||
const inner = document.createElement('div');
|
||
inner.className = 'plugin__inner';
|
||
while (el.firstChild) inner.appendChild(el.firstChild);
|
||
el.appendChild(inner);
|
||
}
|
||
if (!el.id) {
|
||
const guess = (el.className.match(/([a-z0-9-]+)-section/i) || [, 'tile'])[1];
|
||
el.id = `${guess}-${Math.random().toString(36).slice(2, 7)}`;
|
||
}
|
||
el.style.gridColumn = el.style.gridRow = '';
|
||
mosaic.appendChild(el);
|
||
});
|
||
|
||
const defaultWidths = {
|
||
'projects-section': 3,
|
||
'beatleader-section': 2,
|
||
'steam-section': 2,
|
||
'neofetch-section': 2,
|
||
'tech-section': 1,
|
||
'code-section': 2,
|
||
'meme-section': 1,
|
||
'lastfm-section': 2,
|
||
'webring-section': 2,
|
||
'social-section': 1,
|
||
'visitors-section': 1,
|
||
'info-section': 2,
|
||
'services-section': 2,
|
||
'places-section': 2,
|
||
'profile-section': 2,
|
||
'health-section': 1,
|
||
'personal-section': 2,
|
||
};
|
||
|
||
const preferredWidths = new Map();
|
||
const spansStoreKey = 'mosaic.spans';
|
||
const widthsStoreKey = 'mosaic.widths';
|
||
const orderStoreKey = 'mosaic.order';
|
||
const pinnedStoreKey = 'mosaic.pinned';
|
||
|
||
const savedWidths = safeJSON(localStorage.getItem(widthsStoreKey)) || {};
|
||
const savedOrder = safeJSON(localStorage.getItem(orderStoreKey)) || [];
|
||
const savedPinned = safeJSON(localStorage.getItem(pinnedStoreKey)) || {};
|
||
|
||
function safeJSON(s) {
|
||
try {
|
||
return JSON.parse(s);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
$$('.plugin', mosaic).forEach((el) => {
|
||
const key = Object.keys(defaultWidths).find((k) => el.classList.contains(k));
|
||
const w = clamp(+el.dataset.w || savedWidths[el.id] || (key ? defaultWidths[key] : 1), 1, 3);
|
||
el.dataset.w = String(w);
|
||
preferredWidths.set(el.id, w);
|
||
if (savedPinned[el.id]) el.dataset.pinned = '1';
|
||
if (el.classList.contains('profile-section')) el.dataset.pinned = '1';
|
||
});
|
||
|
||
let overlay = null;
|
||
let expanded = null;
|
||
let _scrollLockY = 0;
|
||
let initialLoadComplete = false;
|
||
let userHasScrolled = false;
|
||
let lastScrollY = 0;
|
||
let isNearBottom = false;
|
||
let lastMosaicHeight = 0;
|
||
let packingInProgress = false;
|
||
let bottomStickTimeout = null;
|
||
|
||
let skipNextPack = false;
|
||
let isAtBottom = false;
|
||
|
||
on(window, 'scroll', () => {
|
||
if (!userHasScrolled && window.scrollY > 10) {
|
||
userHasScrolled = true;
|
||
}
|
||
lastScrollY = window.scrollY;
|
||
|
||
const scrollY = window.scrollY;
|
||
const viewportHeight = window.innerHeight;
|
||
const docHeight = document.documentElement.scrollHeight;
|
||
isAtBottom = (docHeight - (scrollY + viewportHeight)) < 100;
|
||
}, {passive: true});
|
||
|
||
function lockBodyScroll() {
|
||
if (document.body.classList.contains('scroll-locked')) return;
|
||
_scrollLockY = window.scrollY || document.documentElement.scrollTop || 0;
|
||
document.body.style.position = 'fixed';
|
||
document.body.style.top = `-${_scrollLockY}px`;
|
||
document.body.style.left = '0';
|
||
document.body.style.right = '0';
|
||
document.body.style.width = '100%';
|
||
document.body.classList.add('scroll-locked');
|
||
}
|
||
|
||
function unlockBodyScroll() {
|
||
if (!document.body.classList.contains('scroll-locked')) return;
|
||
document.body.classList.remove('scroll-locked');
|
||
document.body.style.position = '';
|
||
document.body.style.top = '';
|
||
document.body.style.left = '';
|
||
document.body.style.right = '';
|
||
document.body.style.width = '';
|
||
window.scrollTo(0, _scrollLockY);
|
||
}
|
||
|
||
const MIN_COL_FALLBACK = 280;
|
||
const ROW_HEIGHT = 1;
|
||
|
||
function batchMeasureHeights(items) {
|
||
items.forEach(el => {
|
||
el.style.height = 'auto';
|
||
el.style.minHeight = '';
|
||
el.style.gridRowEnd = 'auto';
|
||
});
|
||
|
||
const heights = new Map();
|
||
let maxH = 0;
|
||
|
||
items.forEach(el => {
|
||
const rect = el.getBoundingClientRect();
|
||
const header = el.querySelector('.plugin-header');
|
||
const inner = el.querySelector('.plugin__inner');
|
||
|
||
let h = Math.ceil(rect.height);
|
||
|
||
if (header && inner) {
|
||
const headerH = header.getBoundingClientRect().height;
|
||
const innerH = inner.scrollHeight;
|
||
const computedH = Math.ceil(headerH + innerH + 20);
|
||
h = Math.max(h, computedH);
|
||
}
|
||
|
||
heights.set(el, h);
|
||
if (h > maxH) maxH = h;
|
||
});
|
||
|
||
return {heights, maxH};
|
||
}
|
||
|
||
function colCount() {
|
||
const style = getComputedStyle(mosaic);
|
||
const gapStr = style.columnGap || style.gap || '12px';
|
||
const gap = parseFloat(gapStr.split(/\s+/)[0]) || 12;
|
||
const minColRoot = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--col-min')) || 0;
|
||
const minColMosaic = parseFloat(getComputedStyle(mosaic).getPropertyValue('--col-min')) || 0;
|
||
const minCol = minColRoot || minColMosaic || MIN_COL_FALLBACK;
|
||
const w = mosaic.clientWidth;
|
||
if (w <= 0) return 1;
|
||
return Math.max(1, Math.floor((w + gap) / (minCol + gap)));
|
||
}
|
||
|
||
function clampSpansToCols() {
|
||
const cols = colCount();
|
||
$$('.plugin', mosaic).forEach((el) => {
|
||
let w = clamp(+el.dataset.w || 1, 1, 3);
|
||
if (w > cols) w = cols;
|
||
el.dataset.w = String(w);
|
||
});
|
||
}
|
||
|
||
function expandHorizontally(placements, cols) {
|
||
placements.sort((a, b) => a.row - b.row || a.col - b.col);
|
||
placements.forEach((p) => {
|
||
while (p.col + p.colSpan < cols) {
|
||
const nextCol = p.col + p.colSpan;
|
||
let canExpand = true;
|
||
for (const other of placements) {
|
||
if (other === p) continue;
|
||
const overlapsNextCol = other.col === nextCol || (other.col < nextCol && other.col + other.colSpan > nextCol);
|
||
if (!overlapsNextCol) continue;
|
||
const pEnd = p.row + p.rowSpan;
|
||
const oEnd = other.row + other.rowSpan;
|
||
if (!(p.row >= oEnd || pEnd <= other.row)) {
|
||
canExpand = false;
|
||
break;
|
||
}
|
||
}
|
||
if (canExpand) p.colSpan++;
|
||
else break;
|
||
}
|
||
});
|
||
}
|
||
|
||
function alignBottoms(placements, cols) {
|
||
const colEndRows = new Array(cols).fill(0);
|
||
placements.sort((a, b) => a.row - b.row || a.col - b.col);
|
||
placements.forEach((p) => {
|
||
for (let c = p.col; c < p.col + p.colSpan; c++) {
|
||
colEndRows[c] = Math.max(colEndRows[c], p.row + p.rowSpan);
|
||
}
|
||
});
|
||
const globalBottom = Math.max(...colEndRows);
|
||
|
||
const maxExtension = 100;
|
||
const GAP_BUFFER = 12;
|
||
|
||
placements.forEach((p) => {
|
||
let nextRowInCols = Infinity;
|
||
for (const other of placements) {
|
||
if (other === p) continue;
|
||
const overlap = other.col < (p.col + p.colSpan) && (other.col + other.colSpan) > p.col;
|
||
if (!overlap) continue;
|
||
if (other.row > p.row) nextRowInCols = Math.min(nextRowInCols, other.row);
|
||
}
|
||
const targetEnd = nextRowInCols !== Infinity ? nextRowInCols - GAP_BUFFER : globalBottom;
|
||
const desiredSpan = targetEnd - p.row;
|
||
|
||
const maxAllowedSpan = p.rowSpan + maxExtension;
|
||
if (desiredSpan > p.rowSpan && desiredSpan <= maxAllowedSpan) {
|
||
p.rowSpan = desiredSpan;
|
||
}
|
||
});
|
||
}
|
||
|
||
function packMasonry() {
|
||
if (expanded) return;
|
||
if (packingInProgress) return;
|
||
|
||
// If at bottom and not initial load, skip packing to prevent jumps
|
||
if (isAtBottom && initialLoadComplete && !skipNextPack) {
|
||
return;
|
||
}
|
||
skipNextPack = false;
|
||
|
||
packingInProgress = true;
|
||
|
||
clampSpansToCols();
|
||
const cols = colCount();
|
||
|
||
const items = $$('.plugin', mosaic);
|
||
const style = getComputedStyle(mosaic);
|
||
const gap = parseFloat(style.columnGap || style.gap || '12px') || 12;
|
||
|
||
// Measure heights
|
||
items.forEach(el => {
|
||
el.style.height = 'auto';
|
||
el.style.minHeight = '';
|
||
el.style.gridRowEnd = 'auto';
|
||
});
|
||
|
||
const heights = new Map();
|
||
let maxNaturalPx = 0;
|
||
items.forEach(el => {
|
||
const h = Math.ceil(el.getBoundingClientRect().height);
|
||
heights.set(el, h);
|
||
if (h > maxNaturalPx) maxNaturalPx = h;
|
||
});
|
||
|
||
const cssCap = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--mosaic-fill-cap'));
|
||
const HARD_CAP_PX = 900;
|
||
const VIEWPORT_CAP_PX = Math.floor(window.innerHeight * 0.92);
|
||
const fillCapPx = Math.min(
|
||
maxNaturalPx || HARD_CAP_PX,
|
||
HARD_CAP_PX,
|
||
VIEWPORT_CAP_PX,
|
||
Number.isFinite(cssCap) && cssCap > 0 ? cssCap : Infinity
|
||
);
|
||
const fillCapRows = Math.ceil((fillCapPx + gap) / ROW_HEIGHT);
|
||
|
||
const colHeights = new Array(cols).fill(0);
|
||
const placements = [];
|
||
|
||
items.forEach((el) => {
|
||
const w = Math.min(+el.dataset.w || 1, cols);
|
||
const hPx = heights.get(el) || 100;
|
||
const rowSpan = Math.ceil((hPx + gap) / ROW_HEIGHT);
|
||
|
||
let bestCol = 0;
|
||
let bestHeight = Infinity;
|
||
for (let c = 0; c <= cols - w; c++) {
|
||
const maxH = Math.max(...colHeights.slice(c, c + w));
|
||
if (maxH < bestHeight) {
|
||
bestHeight = maxH;
|
||
bestCol = c;
|
||
}
|
||
}
|
||
|
||
const startRow = Math.ceil(bestHeight / ROW_HEIGHT);
|
||
const newHeight = bestHeight + hPx + gap;
|
||
for (let i = bestCol; i < bestCol + w; i++) colHeights[i] = newHeight;
|
||
|
||
placements.push({
|
||
el,
|
||
col: bestCol,
|
||
row: startRow,
|
||
colSpan: w,
|
||
rowSpan,
|
||
height: hPx,
|
||
});
|
||
});
|
||
|
||
expandHorizontally(placements, cols);
|
||
|
||
const spansOut = {};
|
||
placements.forEach((p) => {
|
||
const minRows = Math.ceil((p.height + gap) / ROW_HEIGHT);
|
||
const maxRowsForThis = Math.max(minRows, fillCapRows);
|
||
if (p.rowSpan > maxRowsForThis) p.rowSpan = maxRowsForThis;
|
||
|
||
p.el.style.gridColumn = `${p.col + 1} / span ${p.colSpan}`;
|
||
p.el.style.gridRowStart = String(p.row + 1);
|
||
p.el.style.gridRowEnd = `span ${p.rowSpan}`;
|
||
p.el.style.height = `${p.height}px`;
|
||
|
||
spansOut[p.el.id] = p.rowSpan;
|
||
});
|
||
|
||
localStorage.setItem(spansStoreKey, JSON.stringify(spansOut));
|
||
packingInProgress = false;
|
||
}
|
||
|
||
let packScheduled = false;
|
||
|
||
function packAll(force = false) {
|
||
if (document.hidden) return;
|
||
if (expanded) return;
|
||
if (packScheduled) return;
|
||
if (packingInProgress) return;
|
||
|
||
// Skip if at bottom unless forced
|
||
if (isAtBottom && !force && initialLoadComplete) return;
|
||
|
||
if (!force && !initialLoadComplete && userHasScrolled) return;
|
||
|
||
packScheduled = true;
|
||
|
||
requestAnimationFrame(() => {
|
||
packScheduled = false;
|
||
if (force) skipNextPack = true;
|
||
packMasonry();
|
||
fitNeofetch();
|
||
});
|
||
}
|
||
|
||
const debouncedPack = debounce(() => packAll(false), 150);
|
||
|
||
function fullRepack() {
|
||
clampSpansToCols();
|
||
packMasonry();
|
||
fitNeofetch();
|
||
}
|
||
|
||
function getProjectPlugins(m) {
|
||
return Array.from(m.querySelectorAll('.projects-section, .projects-section.plugin'))
|
||
.map((n) => n.closest('.plugin') || n)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function getWebringPlugins(m) {
|
||
return Array.from(m.querySelectorAll('.webring-section, .webring-section.plugin'))
|
||
.map((n) => n.closest('.plugin') || n)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function ensureWebringFirst(m) {
|
||
const list = getWebringPlugins(m);
|
||
list.forEach((n) => {
|
||
if (n.parentElement === m && n !== m.firstElementChild) {
|
||
m.insertBefore(n, m.firstElementChild);
|
||
}
|
||
});
|
||
}
|
||
|
||
function detachProjects(m) {
|
||
const list = getProjectPlugins(m);
|
||
list.forEach((n) => n.remove());
|
||
return list;
|
||
}
|
||
|
||
function ensureProjectsLast(m) {
|
||
const list = getProjectPlugins(m);
|
||
list.forEach((n) => {
|
||
if (n.parentElement === m && n !== m.lastElementChild) {
|
||
m.appendChild(n);
|
||
}
|
||
});
|
||
}
|
||
|
||
function ensurePluginOrdering(m) {
|
||
ensureWebringFirst(m);
|
||
ensureProjectsLast(m);
|
||
}
|
||
|
||
(function initialLayout() {
|
||
if (savedOrder.length) {
|
||
const byId = Object.fromEntries($$('.plugin', mosaic).map((n) => [n.id, n]));
|
||
savedOrder.forEach((id) => byId[id] && mosaic.appendChild(byId[id]));
|
||
} else {
|
||
ensurePluginOrdering(mosaic);
|
||
}
|
||
|
||
const deferredProjects = detachProjects(mosaic);
|
||
packMasonry();
|
||
|
||
setTimeout(() => {
|
||
deferredProjects.forEach((n) => mosaic.appendChild(n));
|
||
packMasonry();
|
||
}, 50);
|
||
|
||
window.addEventListener('load', () => {
|
||
setTimeout(() => {
|
||
packMasonry();
|
||
fitNeofetch();
|
||
initialLoadComplete = true;
|
||
}, 200);
|
||
});
|
||
|
||
setTimeout(() => {
|
||
initialLoadComplete = true;
|
||
}, 1500);
|
||
})();
|
||
|
||
function setWidth(el, w) {
|
||
w = clamp(w, 1, 3);
|
||
el.dataset.w = String(w);
|
||
const widths = safeJSON(localStorage.getItem(widthsStoreKey)) || {};
|
||
widths[el.id] = w;
|
||
localStorage.setItem(widthsStoreKey, JSON.stringify(widths));
|
||
preferredWidths.set(el.id, w);
|
||
packAll(true);
|
||
}
|
||
|
||
function toggleCollapse(el) {
|
||
el.classList.toggle('is-collapsed');
|
||
setTimeout(() => packAll(true), 50);
|
||
}
|
||
|
||
function pin(el) {
|
||
el.dataset.pinned = el.dataset.pinned === '1' ? '0' : '1';
|
||
const map = safeJSON(localStorage.getItem(pinnedStoreKey)) || {};
|
||
map[el.id] = el.dataset.pinned === '1';
|
||
localStorage.setItem(pinnedStoreKey, JSON.stringify(map));
|
||
|
||
const items = $$('.plugin', mosaic);
|
||
const pinned = items.filter((n) => n.dataset.pinned === '1');
|
||
const unpinned = items.filter((n) => n.dataset.pinned !== '1');
|
||
[...pinned, ...unpinned].forEach((n) => mosaic.appendChild(n));
|
||
|
||
toast(el.dataset.pinned === '1' ? 'Pinned' : 'Unpinned');
|
||
persistOrder();
|
||
packAll(true);
|
||
}
|
||
|
||
const ICONS = {collapse: '▾', 'w-dec': '–', 'w-inc': '+', expand: '⛶'};
|
||
const TITLES = {collapse: 'Collapse', 'w-dec': 'Narrower', 'w-inc': 'Wider', expand: 'Expand'};
|
||
|
||
function makeDot(action, title) {
|
||
const b = document.createElement('button');
|
||
b.className = 'icon-btn plugin-btn';
|
||
b.type = 'button';
|
||
b.dataset.action = action;
|
||
b.setAttribute('aria-label', title);
|
||
b.title = title;
|
||
|
||
on(b, 'mouseenter', () => { b.textContent = ICONS[action] || ''; });
|
||
on(b, 'mouseleave', () => { b.textContent = ''; });
|
||
|
||
['pointerdown', 'mousedown', 'click'].forEach((ev) => {
|
||
on(b, ev, (e) => e.stopPropagation());
|
||
});
|
||
|
||
on(b, 'click', (e) => {
|
||
ripple(e);
|
||
b.blur();
|
||
handleAction(b.closest('.plugin'), action);
|
||
});
|
||
|
||
return b;
|
||
}
|
||
|
||
function ensureToolbar(el) {
|
||
if (el.classList.contains('profile-section')) return;
|
||
|
||
let titleEl = $('h1,h2,h3,h4', el.querySelector('.plugin__inner'));
|
||
if (!titleEl) {
|
||
titleEl = document.createElement('h3');
|
||
titleEl.className = 'plugin-title';
|
||
titleEl.textContent = (el.className.match(/([a-z0-9-]+)-section/i) || [, 'Block'])[1].replace(/-/g, ' ');
|
||
} else {
|
||
titleEl.classList.add('plugin-title');
|
||
}
|
||
|
||
let headerRow = $('.plugin-header', el);
|
||
if (!headerRow) {
|
||
headerRow = document.createElement('div');
|
||
headerRow.className = 'plugin-header';
|
||
headerRow.appendChild(titleEl);
|
||
el.querySelector('.plugin__inner').prepend(headerRow);
|
||
}
|
||
|
||
let bar = $('.plugin-toolbar', headerRow);
|
||
if (!bar) {
|
||
bar = document.createElement('div');
|
||
bar.className = 'plugin-toolbar';
|
||
headerRow.appendChild(bar);
|
||
['collapse', 'w-dec', 'w-inc', 'expand'].forEach((action) => bar.append(makeDot(action, TITLES[action])));
|
||
['pointerdown', 'mousedown', 'click'].forEach((ev) => bar.addEventListener(ev, (e) => e.stopPropagation()));
|
||
}
|
||
|
||
bar.style.display = 'flex';
|
||
bar.style.visibility = 'visible';
|
||
bar.style.opacity = '1';
|
||
|
||
headerRow.classList.add('drag-handle');
|
||
headerRow.removeAttribute('draggable');
|
||
headerRow.addEventListener('pointerdown', onHeaderPointerDown, {passive: false});
|
||
headerRow.addEventListener('mousedown', bringToFront);
|
||
}
|
||
|
||
$$('.plugin', mosaic).forEach((el) => ensureToolbar(el));
|
||
|
||
function handleAction(el, action) {
|
||
if (!el) return;
|
||
if (action === 'expand' || action === 'view') expand(el);
|
||
if (action === 'collapse') toggleCollapse(el);
|
||
if (action === 'pin') pin(el);
|
||
if (action === 'w-inc') setWidth(el, (+el.dataset.w || 1) + 1);
|
||
if (action === 'w-dec') setWidth(el, (+el.dataset.w || 1) - 1);
|
||
}
|
||
|
||
function persistOrder() {
|
||
const order = $$('.plugin', mosaic).map((n) => n.id);
|
||
localStorage.setItem(orderStoreKey, JSON.stringify(order));
|
||
}
|
||
|
||
function ensureOverlay() {
|
||
if (overlay) return overlay;
|
||
overlay = document.createElement('div');
|
||
overlay.className = 'plugin-overlay';
|
||
overlay.style.zIndex = '99999';
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) collapseExpanded();
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') collapseExpanded();
|
||
});
|
||
document.body.appendChild(overlay);
|
||
return overlay;
|
||
}
|
||
|
||
function expand(el, updateURL = true) {
|
||
if (expanded === el) return collapseExpanded();
|
||
collapseExpanded(false);
|
||
|
||
const rect = el.getBoundingClientRect();
|
||
const ph = document.createElement('div');
|
||
ph.className = 'plugin plugin-placeholder';
|
||
ph.dataset.w = el.dataset.w || '1';
|
||
ph.style.gridColumn = el.style.gridColumn;
|
||
ph.style.gridRowStart = el.style.gridRowStart;
|
||
ph.style.gridRowEnd = el.style.gridRowEnd;
|
||
ph.style.height = rect.height + 'px';
|
||
mosaic.insertBefore(ph, el.nextSibling);
|
||
|
||
ensureOverlay().classList.add('in');
|
||
lockBodyScroll();
|
||
|
||
el.style.height = '';
|
||
el.classList.add('plugin--expanded');
|
||
overlay.appendChild(el);
|
||
expanded = el;
|
||
|
||
if (updateURL) {
|
||
const pluginName = (el.className.match(/([a-z0-9-]+)-section/i) || [, el.id])[1] || el.id;
|
||
history.pushState({ expanded: pluginName }, '', `#${pluginName}`);
|
||
}
|
||
}
|
||
|
||
function collapseExpanded(updateURL = true) {
|
||
if (!expanded) {
|
||
ensureOverlay().classList.remove('in');
|
||
unlockBodyScroll();
|
||
return;
|
||
}
|
||
const ph = $('.plugin-placeholder', mosaic);
|
||
expanded.classList.remove('plugin--expanded');
|
||
if (ph && ph.parentNode) ph.parentNode.replaceChild(expanded, ph);
|
||
ensureOverlay().classList.remove('in');
|
||
expanded = null;
|
||
|
||
unlockBodyScroll();
|
||
packAll(true);
|
||
if (updateURL) history.pushState({}, '', window.location.pathname);
|
||
}
|
||
|
||
let pointerDown = false, startedDrag = false;
|
||
let dragEl = null, placeholderEl = null, handleEl = null;
|
||
let startX = 0, startY = 0, latestX = 0, latestY = 0;
|
||
let dragOffsetX = 0, dragOffsetY = 0;
|
||
let rafPending = false;
|
||
let latestClientY = 0;
|
||
let maxZ = 1000;
|
||
let autoScrollRAF = null;
|
||
const DRAG_START_TOL = 6;
|
||
let lastHoverTarget = null, lastSwapAt = 0;
|
||
|
||
function bringToFront(e) {
|
||
const win = e.currentTarget.closest('.plugin');
|
||
if (!win) return;
|
||
maxZ += 1;
|
||
win.style.zIndex = String(maxZ);
|
||
}
|
||
|
||
function isMobileOrToolbarHidden() {
|
||
if (window.innerWidth <= 780) return true;
|
||
const toolbar = document.querySelector('.plugin-toolbar');
|
||
if (toolbar) {
|
||
const style = getComputedStyle(toolbar);
|
||
if (style.display === 'none') return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function onHeaderPointerDown(e) {
|
||
if (e.button !== 0) return;
|
||
if (isInteractive(e.target)) return;
|
||
if (isMobileOrToolbarHidden()) return;
|
||
|
||
const win = e.currentTarget.closest('.plugin');
|
||
if (!win) return;
|
||
e.preventDefault();
|
||
|
||
pointerDown = true;
|
||
startedDrag = false;
|
||
handleEl = e.currentTarget;
|
||
dragEl = win;
|
||
|
||
const rect = dragEl.getBoundingClientRect();
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
latestX = rect.left;
|
||
latestY = rect.top;
|
||
latestClientY = e.clientY;
|
||
|
||
document.addEventListener('pointermove', onDocPointerMove, {passive: false});
|
||
document.addEventListener('pointerup', onDocPointerUp, {passive: false});
|
||
document.addEventListener('pointercancel', onDocPointerUp, {passive: false});
|
||
}
|
||
|
||
function beginDrag(e) {
|
||
if (startedDrag || !dragEl) return;
|
||
startedDrag = true;
|
||
|
||
const rect = dragEl.getBoundingClientRect();
|
||
|
||
placeholderEl = document.createElement('div');
|
||
placeholderEl.className = dragEl.className + ' plugin-placeholder';
|
||
placeholderEl.style.height = rect.height + 'px';
|
||
placeholderEl.dataset.w = dragEl.dataset.w || '1';
|
||
placeholderEl.style.gridColumn = dragEl.style.gridColumn;
|
||
placeholderEl.style.gridRowStart = dragEl.style.gridRowStart;
|
||
placeholderEl.style.gridRowEnd = dragEl.style.gridRowEnd;
|
||
|
||
mosaic.replaceChild(placeholderEl, dragEl);
|
||
|
||
dragOffsetX = e.clientX - rect.left;
|
||
dragOffsetY = e.clientY - rect.top;
|
||
|
||
bringToFront({ currentTarget: handleEl });
|
||
|
||
dragEl.style.position = 'fixed';
|
||
dragEl.style.left = rect.left + 'px';
|
||
dragEl.style.top = rect.top + 'px';
|
||
dragEl.style.width = rect.width + 'px';
|
||
dragEl.style.height = rect.height + 'px';
|
||
dragEl.classList.add('dragging');
|
||
dragEl.style.pointerEvents = 'none';
|
||
|
||
document.body.appendChild(dragEl);
|
||
document.body.classList.add('dragging-cursor');
|
||
|
||
startAutoScrollLoop();
|
||
}
|
||
|
||
function hoverSwapAt(clientX, clientY) {
|
||
if (!startedDrag || !placeholderEl) return;
|
||
|
||
const under = document.elementFromPoint(clientX, clientY);
|
||
let target = under && under.closest('.plugin');
|
||
if (!target || target === dragEl || target === placeholderEl) return;
|
||
|
||
const t = now();
|
||
if (target === lastHoverTarget && t - lastSwapAt < 60) return;
|
||
lastHoverTarget = target;
|
||
lastSwapAt = t;
|
||
|
||
swapNodes(placeholderEl, target);
|
||
}
|
||
|
||
function swapNodes(a, b) {
|
||
if (!a || !b || !a.parentNode || !b.parentNode) return;
|
||
const aNext = a.nextSibling;
|
||
const bNext = b.nextSibling;
|
||
const aParent = a.parentNode;
|
||
const bParent = b.parentNode;
|
||
aParent.insertBefore(b, aNext);
|
||
bParent.insertBefore(a, bNext);
|
||
}
|
||
|
||
function onDocPointerMove(e) {
|
||
if (!pointerDown) return;
|
||
latestClientY = e.clientY;
|
||
|
||
if (!startedDrag) {
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
if (Math.hypot(dx, dy) < DRAG_START_TOL) return;
|
||
beginDrag(e);
|
||
}
|
||
|
||
e.preventDefault();
|
||
|
||
latestX = e.clientX - dragOffsetX;
|
||
latestY = e.clientY - dragOffsetY;
|
||
|
||
if (!rafPending) {
|
||
rafPending = true;
|
||
requestAnimationFrame(() => {
|
||
rafPending = false;
|
||
if (dragEl) {
|
||
dragEl.style.left = latestX + 'px';
|
||
dragEl.style.top = latestY + 'px';
|
||
}
|
||
});
|
||
}
|
||
|
||
hoverSwapAt(e.clientX, e.clientY);
|
||
}
|
||
|
||
function onDocPointerUp() {
|
||
document.removeEventListener('pointermove', onDocPointerMove);
|
||
document.removeEventListener('pointerup', onDocPointerUp);
|
||
document.removeEventListener('pointercancel', onDocPointerUp);
|
||
stopAutoScrollLoop();
|
||
|
||
if (!startedDrag) {
|
||
pointerDown = false;
|
||
dragEl = null;
|
||
handleEl = null;
|
||
return;
|
||
}
|
||
|
||
if (dragEl && placeholderEl && placeholderEl.parentNode === mosaic) {
|
||
mosaic.replaceChild(dragEl, placeholderEl);
|
||
}
|
||
placeholderEl?.remove();
|
||
placeholderEl = null;
|
||
|
||
if (dragEl) {
|
||
dragEl.classList.remove('dragging');
|
||
dragEl.style.position = dragEl.style.left = dragEl.style.top = '';
|
||
dragEl.style.width = dragEl.style.height = '';
|
||
dragEl.style.pointerEvents = '';
|
||
}
|
||
|
||
pointerDown = false;
|
||
startedDrag = false;
|
||
dragEl = null;
|
||
handleEl = null;
|
||
lastHoverTarget = null;
|
||
|
||
document.body.classList.remove('dragging-cursor');
|
||
|
||
persistOrder();
|
||
packAll(true);
|
||
}
|
||
|
||
function startAutoScrollLoop() {
|
||
const EDGE = 28;
|
||
const MAX_STEP = 24;
|
||
const step = (delta) => clamp(Math.floor(delta * 1.2), 6, MAX_STEP);
|
||
|
||
function loop() {
|
||
if (!startedDrag) return (autoScrollRAF = null);
|
||
autoScrollRAF = requestAnimationFrame(loop);
|
||
|
||
const toTop = latestClientY;
|
||
const toBottom = window.innerHeight - latestClientY;
|
||
|
||
if (toTop < EDGE) window.scrollBy(0, -step(EDGE - toTop));
|
||
else if (toBottom < EDGE) window.scrollBy(0, step(EDGE - toBottom));
|
||
}
|
||
|
||
if (!autoScrollRAF) autoScrollRAF = requestAnimationFrame(loop);
|
||
}
|
||
|
||
function stopAutoScrollLoop() {
|
||
if (autoScrollRAF) {
|
||
cancelAnimationFrame(autoScrollRAF);
|
||
autoScrollRAF = null;
|
||
}
|
||
}
|
||
|
||
function ripple(e) {
|
||
const el = e.currentTarget;
|
||
el.classList.add('ripple-host');
|
||
const r = document.createElement('span');
|
||
r.className = 'ripple';
|
||
const rect = el.getBoundingClientRect();
|
||
const d = Math.max(rect.width, rect.height);
|
||
r.style.width = r.style.height = d + 'px';
|
||
r.style.left = (e.clientX - rect.left - d / 2) + 'px';
|
||
r.style.top = (e.clientY - rect.top - d / 2) + 'px';
|
||
el.appendChild(r);
|
||
setTimeout(() => r.remove(), 600);
|
||
}
|
||
|
||
let toastRoot;
|
||
|
||
function toast(msg) {
|
||
toastRoot ||= (() => {
|
||
const r = document.createElement('div');
|
||
r.className = 'toast-root';
|
||
document.body.appendChild(r);
|
||
return r;
|
||
})();
|
||
const n = document.createElement('div');
|
||
n.className = 'toast';
|
||
n.textContent = msg;
|
||
toastRoot.appendChild(n);
|
||
requestAnimationFrame(() => n.classList.add('in'));
|
||
setTimeout(() => n.classList.remove('in'), 1600);
|
||
setTimeout(() => n.remove(), 1900);
|
||
}
|
||
|
||
const io = new IntersectionObserver(
|
||
(entries) => entries.forEach((e) => e.target.classList.toggle('reveal', e.isIntersecting)),
|
||
{threshold: 0.08}
|
||
);
|
||
$$('.plugin', mosaic).forEach((el) => io.observe(el));
|
||
|
||
if ('fonts' in document && document.fonts.ready) {
|
||
document.fonts.ready.then(() => {
|
||
if (initialLoadComplete) packAll(false);
|
||
});
|
||
}
|
||
|
||
let resizeTimeout = null;
|
||
let lastObservedHeights = new Map();
|
||
|
||
// Throttled Observer
|
||
const ro = new ResizeObserver((entries) => {
|
||
if (!initialLoadComplete) return;
|
||
if (expanded) return;
|
||
if (packingInProgress) return;
|
||
if (isAtBottom) return; // Don't repack when at bottom
|
||
|
||
let hasSignificantChange = false;
|
||
for (const entry of entries) {
|
||
const el = entry.target;
|
||
if (!document.body.contains(el)) {
|
||
ro.unobserve(el);
|
||
continue;
|
||
}
|
||
|
||
const newHeight = entry.contentRect.height;
|
||
const lastHeight = lastObservedHeights.get(el) || 0;
|
||
|
||
if (Math.abs(newHeight - lastHeight) > 20) { // Increased threshold
|
||
hasSignificantChange = true;
|
||
lastObservedHeights.set(el, newHeight);
|
||
}
|
||
}
|
||
|
||
if (hasSignificantChange) {
|
||
debouncedPack();
|
||
}
|
||
});
|
||
|
||
function observeElement(el) {
|
||
if (!el) return;
|
||
ro.observe(el, {box: 'border-box'});
|
||
}
|
||
|
||
$$('.plugin', mosaic).forEach((n) => observeElement(n));
|
||
|
||
const mo = new MutationObserver(throttle((mutations) => {
|
||
if (!initialLoadComplete) return;
|
||
|
||
let shouldPack = false;
|
||
|
||
mutations.forEach(m => {
|
||
if (m.type === 'childList') {
|
||
m.addedNodes.forEach(node => {
|
||
if (node.nodeType === 1 && node.classList.contains('plugin')) {
|
||
observeElement(node);
|
||
ensureToolbar(node);
|
||
shouldPack = true;
|
||
}
|
||
});
|
||
} else if (m.type === 'attributes' && m.attributeName === 'class' && !m.target.classList.contains('plugin')) {
|
||
shouldPack = true;
|
||
}
|
||
});
|
||
|
||
if (shouldPack && !expanded && !packingInProgress) {
|
||
debouncedPack();
|
||
}
|
||
}, 200));
|
||
|
||
mo.observe(mosaic, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ['class', 'style', 'src'],
|
||
});
|
||
|
||
function restorePreferredWidths(maxCols = colCount()) {
|
||
$$('.plugin', mosaic).forEach((el) => {
|
||
const pref = preferredWidths.get(el.id);
|
||
if (pref != null) el.dataset.w = String(clamp(pref, 1, Math.min(3, maxCols)));
|
||
});
|
||
}
|
||
|
||
let lastCols = colCount();
|
||
on(window, 'resize', throttle(() => {
|
||
if (packingInProgress) return;
|
||
|
||
const nextCols = colCount();
|
||
clampSpansToCols();
|
||
if (nextCols > lastCols) restorePreferredWidths(nextCols);
|
||
lastCols = nextCols;
|
||
|
||
const scrollY = window.scrollY;
|
||
fullRepack();
|
||
window.scrollTo(0, scrollY);
|
||
}, 200));
|
||
|
||
on(document, 'visibilitychange', () => {
|
||
if (!document.hidden && initialLoadComplete) packAll(false);
|
||
});
|
||
|
||
let hashNavigationInitialized = false;
|
||
|
||
function handleHashChange() {
|
||
const hash = window.location.hash.slice(1);
|
||
if (hash) {
|
||
const el = $(`.${hash}-section.plugin`);
|
||
if (el && !expanded) setTimeout(() => expand(el, false), 100);
|
||
} else if (expanded) {
|
||
collapseExpanded(false);
|
||
}
|
||
}
|
||
|
||
function initHashNavigation() {
|
||
if (hashNavigationInitialized) return;
|
||
hashNavigationInitialized = true;
|
||
|
||
on(window, 'hashchange', handleHashChange, { once: false });
|
||
on(window, 'popstate', (e) => {
|
||
if (e.state && e.state.expanded) {
|
||
const el = $(`.${e.state.expanded}-section.plugin`);
|
||
if (el) expand(el, false);
|
||
} else if (expanded) {
|
||
collapseExpanded(false);
|
||
}
|
||
}, { once: false });
|
||
|
||
if (window.location.hash) setTimeout(handleHashChange, 400);
|
||
}
|
||
|
||
let windowManagerInitialized = false;
|
||
|
||
function initWindowManager() {
|
||
if (windowManagerInitialized) return;
|
||
windowManagerInitialized = true;
|
||
|
||
initHashNavigation();
|
||
|
||
on(document, 'keydown', (e) => {
|
||
if (e.altKey && e.key >= '1' && e.key <= '9') {
|
||
e.preventDefault();
|
||
const idx = parseInt(e.key, 10) - 1;
|
||
const plugins = $$('.plugin', mosaic);
|
||
if (plugins[idx]) {
|
||
plugins[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||
setTimeout(() => expand(plugins[idx]), 300);
|
||
}
|
||
}
|
||
}, { once: false });
|
||
|
||
window.mosaicUtils = {
|
||
resizeAll: () => packAll(true),
|
||
fullRepack,
|
||
expand,
|
||
collapseExpanded,
|
||
getMosaic: () => mosaic,
|
||
observe: observeElement
|
||
};
|
||
|
||
|
||
setTimeout(() => {
|
||
document.documentElement.classList.remove('js-loading');
|
||
document.documentElement.classList.add('js-loaded');
|
||
}, 100);
|
||
}
|
||
|
||
initWindowManager();
|
||
|
||
function fitNeofetch() {
|
||
document.querySelectorAll('.neofetch-section .terminal').forEach((term) => {
|
||
const pre = term.querySelector('.neofetch-pre');
|
||
if (!pre) return;
|
||
const style = getComputedStyle(term);
|
||
const scale = parseFloat(style.getPropertyValue('--neo-scale')) || 1;
|
||
const headerH = term.querySelector('.terminal-header')?.offsetHeight || 0;
|
||
const paletteH = term.querySelector('.color-palette')?.offsetHeight || 0;
|
||
const bodyPadding = 20;
|
||
const scaledPreH = Math.ceil(pre.scrollHeight * scale);
|
||
term.style.height = headerH + bodyPadding + scaledPreH + paletteH + 8 + 'px';
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.section-toggle').forEach((toggle) => {
|
||
toggle.addEventListener('click', () => {
|
||
setTimeout(() => packAll(true), 100);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('img').forEach((img) => {
|
||
if (img.complete) return;
|
||
img.addEventListener('load', () => {
|
||
if (initialLoadComplete) debouncedPack();
|
||
}, {once: true});
|
||
img.addEventListener('error', () => {
|
||
if (initialLoadComplete) debouncedPack();
|
||
}, {once: true});
|
||
});
|
||
})(); |