gfds
This commit is contained in:
+155
-14
@@ -12,7 +12,7 @@
|
||||
(() => {
|
||||
if (window.f0ckPageModal) return;
|
||||
|
||||
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms)\/?$/;
|
||||
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms|settings)\/?$/;
|
||||
|
||||
const modal = document.getElementById('page-modal');
|
||||
if (!modal) return;
|
||||
@@ -20,6 +20,26 @@
|
||||
const titleEl = modal.querySelector('.pgm-title');
|
||||
let currentUrl = null;
|
||||
let loadSeq = 0;
|
||||
// Open state is tracked separately from display: while the close animation runs the modal is still shown
|
||||
let isOpen = false;
|
||||
let fx = null; // running open/close animation
|
||||
const reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
|
||||
const animate = (el, frames, opts) => (typeof el.animate === 'function' && !reduceMotion.matches) ? el.animate(frames, opts) : null;
|
||||
const pathOf = (href) => { try { return new URL(href, location.origin).pathname.replace(/\/+$/, '') || '/'; } catch { return null; } };
|
||||
// Sidebar/footer entries of the page that is open get .is-active (a second click on it closes the page)
|
||||
const markActive = () => {
|
||||
const cur = isOpen && currentUrl ? pathOf(currentUrl) : null;
|
||||
document.querySelectorAll('a[data-page-modal]').forEach((a) => {
|
||||
a.classList.toggle('is-active', !!cur && pathOf(a.getAttribute('href')) === cur);
|
||||
});
|
||||
};
|
||||
// URL bar + title of the page underneath, restored on close. replaceState (never push/back): a popstate
|
||||
// would make the site's AJAX router reload the page underneath, which this modal exists to avoid.
|
||||
let baseUrl = null;
|
||||
let baseTitle = null;
|
||||
const showUrl = (url) => {
|
||||
try { history.replaceState(history.state, '', url); } catch {}
|
||||
};
|
||||
|
||||
const isModalPath = (href) => {
|
||||
try {
|
||||
@@ -42,6 +62,8 @@
|
||||
const load = async (url, title) => {
|
||||
const seq = ++loadSeq;
|
||||
currentUrl = url;
|
||||
showUrl(url);
|
||||
markActive();
|
||||
titleEl.textContent = title || '';
|
||||
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
|
||||
try {
|
||||
@@ -57,10 +79,12 @@
|
||||
body.innerHTML = '';
|
||||
body.appendChild(wrap);
|
||||
body.scrollTop = 0;
|
||||
animate(wrap, [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
|
||||
if (!title) {
|
||||
const h = wrap.querySelector('h1, h2, h3');
|
||||
titleEl.textContent = h ? h.textContent.trim() : '';
|
||||
}
|
||||
if (doc.title) document.title = doc.title;
|
||||
runScripts(wrap);
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return;
|
||||
@@ -76,19 +100,50 @@
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// Top follows the navbar live (e.g. mobile burger menu collapsing). The sides follow the sidebars' *state*,
|
||||
// not their animated position, so the content never re-lays out while a sidebar slides: it changes width
|
||||
// once, when the toggle has finished, masked by a short fade (same as the main content).
|
||||
let lastSides = null;
|
||||
// Set by toggleSidebarRight at the moment the main content changes width (before the slide when
|
||||
// opening, after it when closing); wins over the body class, which only flips once the slide ends.
|
||||
let rightOverride = null;
|
||||
const place = () => {
|
||||
const vw = window.innerWidth;
|
||||
const nav = visibleRect('body > nav.navbar, nav.navbar');
|
||||
const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0;
|
||||
// Right sidebar: counts only while it is on screen (hidden = slid out past the right edge)
|
||||
const rs = visibleRect('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||
const right = rs && rs.left < vw - 1 && rs.left > vw / 2 ? Math.round(vw - rs.left) : 0;
|
||||
// Right sidebar: open unless body.sidebar-right-hidden; its layout width ignores the slide transform
|
||||
const rsEl = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||
const rsOpen = !!rsEl && !document.body.classList.contains('sidebar-right-hidden')
|
||||
&& getComputedStyle(rsEl).display !== 'none' && rsEl.offsetWidth > 0;
|
||||
let right = rsOpen ? Math.min(Math.round(rsEl.offsetWidth), Math.round(vw / 2)) : 0;
|
||||
if (rightOverride && performance.now() < rightOverride.until) right = rightOverride.right;
|
||||
// Small phones: the sidebar is a pure overlay there, the content never makes room for it
|
||||
if (vw <= 599) right = 0;
|
||||
// Left (comments) sidebar on item pages
|
||||
const ls = visibleRect('.item-sidebar-left');
|
||||
const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0;
|
||||
modal.style.setProperty('--pgm-top', `${top}px`);
|
||||
modal.style.setProperty('--pgm-right', `${right}px`);
|
||||
modal.style.setProperty('--pgm-left', `${left}px`);
|
||||
// Desktop, sidebar beside the content: the header takes the exact height of the sidebar's tab row,
|
||||
// so the two form one continuous bar under the navbar
|
||||
const tabs = right > 0 && rsEl ? rsEl.querySelector('.sidebar-tabs') : null;
|
||||
const tabsH = tabs ? tabs.getBoundingClientRect().height : 0;
|
||||
if (tabsH > 0) {
|
||||
modal.style.setProperty('--pgm-header-h', `${tabsH}px`);
|
||||
modal.classList.add('pgm-header-synced');
|
||||
} else {
|
||||
modal.style.removeProperty('--pgm-header-h');
|
||||
modal.classList.remove('pgm-header-synced');
|
||||
}
|
||||
const sides = `${left}|${right}`;
|
||||
if (sides !== lastSides) {
|
||||
const changed = lastSides !== null && modal.style.display !== 'none';
|
||||
lastSides = sides;
|
||||
modal.style.setProperty('--pgm-right', `${right}px`);
|
||||
modal.style.setProperty('--pgm-left', `${left}px`);
|
||||
if (changed && typeof body.animate === 'function') {
|
||||
body.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
|
||||
}
|
||||
}
|
||||
};
|
||||
// Follow sidebar slide animations for a moment after a toggle
|
||||
let followUntil = 0;
|
||||
@@ -103,7 +158,19 @@
|
||||
if (!running) requestAnimationFrame(follow);
|
||||
};
|
||||
window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true });
|
||||
new MutationObserver(() => followFor(600)).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
window.addEventListener('f0ck:sidebar-right-layout', (e) => {
|
||||
const d = e.detail || {};
|
||||
rightOverride = { right: d.open && !d.overlay ? Math.min(Math.round(d.width || 0), Math.round(window.innerWidth / 2)) : 0, until: performance.now() + 800 };
|
||||
if (modal.style.display !== 'none') place();
|
||||
});
|
||||
|
||||
// Sidebar toggles commit their body class when the slide has finished: update once then. The left
|
||||
// (comments) sidebar may still be transitioning, so check again after it settles.
|
||||
new MutationObserver(() => {
|
||||
if (modal.style.display === 'none') return;
|
||||
place();
|
||||
setTimeout(place, 400);
|
||||
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize):
|
||||
// follow every size change so the modal's edges stay glued to them
|
||||
@@ -126,18 +193,55 @@
|
||||
};
|
||||
|
||||
const open = (url, title) => {
|
||||
if (!isOpen || baseUrl === null) {
|
||||
baseUrl = location.pathname + location.search + location.hash;
|
||||
baseTitle = document.title;
|
||||
}
|
||||
// Fade in on a fresh open, and when reopened while the close animation is still running
|
||||
const fadeIn = !isOpen;
|
||||
isOpen = true;
|
||||
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
|
||||
watchLayout();
|
||||
place();
|
||||
modal.style.display = 'flex';
|
||||
// No visible scrollbar: focus the body so arrow keys / PageUp / PageDown / Space scroll it right away
|
||||
try { body.focus({ preventScroll: true }); } catch {}
|
||||
followFor(300);
|
||||
if (fadeIn) {
|
||||
// Mid-close: continue from where the fade-out is instead of flashing back to full opacity
|
||||
const from = fx ? parseFloat(getComputedStyle(modal).opacity) || 0 : 0;
|
||||
if (fx) { fx.cancel(); fx = null; }
|
||||
fx = animate(modal, [{ opacity: from, transform: `translateY(${10 * (1 - from)}px)` }, { opacity: 1, transform: 'none' }], { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
|
||||
if (fx) fx.onfinish = () => { fx = null; };
|
||||
}
|
||||
load(url, title);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = 'none';
|
||||
body.innerHTML = '';
|
||||
// restoreUrl = false when history already moved (back/forward): the URL bar is correct then
|
||||
const close = (restoreUrl = true) => {
|
||||
if (!isOpen) return;
|
||||
isOpen = false;
|
||||
currentUrl = null;
|
||||
++loadSeq; // drop a load still in flight so it can't rewrite the URL after closing
|
||||
if (restoreUrl && baseUrl !== null) {
|
||||
showUrl(baseUrl);
|
||||
if (baseTitle !== null) document.title = baseTitle;
|
||||
}
|
||||
baseUrl = baseTitle = null;
|
||||
markActive();
|
||||
|
||||
const hide = () => {
|
||||
fx = null;
|
||||
if (isOpen) return; // reopened meanwhile
|
||||
modal.style.display = 'none';
|
||||
lastSides = null;
|
||||
body.innerHTML = '';
|
||||
};
|
||||
if (fx) { fx.cancel(); fx = null; }
|
||||
const out = animate(modal, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(10px)' }], { duration: 160, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' });
|
||||
if (!out) { hide(); return; }
|
||||
fx = out;
|
||||
out.onfinish = () => { if (fx === out) { out.cancel(); hide(); } };
|
||||
};
|
||||
|
||||
// Capture phase: runs before the global AJAX link handler
|
||||
@@ -147,13 +251,27 @@
|
||||
if (!a) return;
|
||||
|
||||
if (a.hasAttribute('data-page-modal')) {
|
||||
// Already on that page (e.g. the gear on /settings): plain link, never the same page twice
|
||||
if (!isOpen && pathOf(a.getAttribute('href')) === pathOf(location.pathname)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Same page already open: the entry works as a toggle
|
||||
if (isOpen && currentUrl && pathOf(currentUrl) === pathOf(a.getAttribute('href'))) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
// Mobile: the sidebar overlays the content, so close it to bring the opened page into focus
|
||||
const fromSidebar = a.closest('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
|
||||
if (fromSidebar && window.matchMedia('(max-width: 999px)').matches
|
||||
&& !document.body.classList.contains('sidebar-right-hidden')
|
||||
&& typeof window.toggleSidebarRight === 'function') {
|
||||
window.toggleSidebarRight();
|
||||
}
|
||||
open(a.getAttribute('href'), a.getAttribute('title') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modal.style.display === 'none' || a.target === '_blank') return;
|
||||
if (!isOpen || a.target === '_blank') return;
|
||||
|
||||
// Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows
|
||||
if (!modal.contains(a)) {
|
||||
@@ -164,6 +282,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// In-page anchors (#section, e.g. the settings quicknav) belong to the loaded page's own scripts:
|
||||
// never reload the page for them
|
||||
const rawHref = a.getAttribute('href') || '';
|
||||
if (rawHref.startsWith('#')) return;
|
||||
|
||||
if (isModalPath(a.href)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -174,11 +297,29 @@
|
||||
close();
|
||||
}, true);
|
||||
|
||||
modal.querySelector('.pgm-close').addEventListener('click', close);
|
||||
modal.querySelector('.pgm-close').addEventListener('click', () => close());
|
||||
// Back/forward changes the page underneath: don't leave the modal covering it
|
||||
window.addEventListener('popstate', () => { if (modal.style.display !== 'none') close(); });
|
||||
window.addEventListener('popstate', () => { if (isOpen) close(false); });
|
||||
// Capture phase + stop: the page sits on top (e.g. over the Onara viewer), so Escape closes only it
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display !== 'none') close();
|
||||
if (e.key !== 'Escape' || !isOpen) return;
|
||||
e.stopImmediatePropagation();
|
||||
close();
|
||||
}, true);
|
||||
|
||||
// Shift+S toggles the settings (signed-in users: a settings link is on the page). Not while typing,
|
||||
// not on /settings itself. Plain "s" stays Flash Yank.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.repeat) return;
|
||||
if (e.key !== 'S' && e.key !== 's') return;
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
|
||||
if (document.body.classList.contains('scroller-active')) return;
|
||||
const link = document.querySelector('a[href="/settings"][data-page-modal]');
|
||||
if (!link || pathOf(location.pathname) === '/settings' && !isOpen) return;
|
||||
e.preventDefault();
|
||||
if (isOpen && currentUrl && pathOf(currentUrl) === '/settings') close();
|
||||
else open('/settings', link.getAttribute('title') || 'Settings');
|
||||
});
|
||||
|
||||
window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
|
||||
|
||||
Reference in New Issue
Block a user