hgfhfd
This commit is contained in:
+57
-19
@@ -2918,11 +2918,6 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.layout-legacy .login-placeholder {
|
||||
border-left: 1px solid var(--nav-border-color) !important;
|
||||
border-right: 1px solid var(--nav-border-color) !important;
|
||||
}
|
||||
|
||||
body.layout-legacy.sidebar-right-hidden .item-layout-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -4357,7 +4352,7 @@ html[theme='95'] .sidebar-video-title {
|
||||
margin-bottom: 5px;
|
||||
padding: 5px;
|
||||
font-size: 1em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid var(--nav-border-color);
|
||||
contain: layout style;
|
||||
margin: 0px;
|
||||
}
|
||||
@@ -11344,6 +11339,7 @@ input#s_avatar {
|
||||
min-width: 50px;
|
||||
justify-content: center;
|
||||
opacity: 0.8;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Avatar-style button variant */
|
||||
@@ -11973,13 +11969,6 @@ html[theme='paper'] .char-counter,
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.login-placeholder {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--nav-link-hover-bg);
|
||||
}
|
||||
|
||||
/* Markdown Styles */
|
||||
.comment-content .greentext {
|
||||
color: #789922;
|
||||
@@ -12660,7 +12649,6 @@ span.badge.badge-current {
|
||||
}
|
||||
|
||||
#help-button {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -12991,8 +12979,17 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover,
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
/* Only once the reader is far enough down for it to help (f0ckm.js toggles .is-visible) */
|
||||
.mobile-scroll-to-top {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
|
||||
}
|
||||
.mobile-scroll-to-top.is-visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease, visibility 0s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13376,7 +13373,6 @@ body > nav.navbar {
|
||||
gap: 5px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
flex-shrink: 0;
|
||||
@@ -13438,8 +13434,10 @@ body > nav.navbar {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* At least as wide as the toggle button (.nav-user-dropdown is sized by it), wider only if an entry needs it */
|
||||
#nav-visitor-menu {
|
||||
min-width: max-content;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -13491,7 +13489,7 @@ body > nav.navbar {
|
||||
|
||||
/* On mobile: right group stays in the single top row */
|
||||
.nav-right-group {
|
||||
gap: 15px;
|
||||
gap: 5px;
|
||||
padding: 0 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -13518,6 +13516,12 @@ body > nav.navbar {
|
||||
animation: navFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Guests: menu items aligned right, next to the thumb and the visitor menu */
|
||||
.nav-collapse-guest,
|
||||
.nav-collapse-guest .nav-links {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@keyframes navFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -15522,7 +15526,9 @@ body.layout-modern .tag-ac-input {
|
||||
.item-sidebar-right-footer,
|
||||
.global-sidebar-right-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr auto;
|
||||
/* One equal column per item, however many links the footer has */
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
border-top: 1px solid var(--nav-border-color);
|
||||
@@ -22973,4 +22979,36 @@ div#flash,
|
||||
}
|
||||
.chan-rehost-edit-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
}
|
||||
/* Tap-to-activate sliders on touch devices (see f0ckm.js): locked sliders let touches through to the page,
|
||||
so scrolling over them can't change a value; a tap unlocks one */
|
||||
html.range-tap-lock input[type="range"]:not(.v0ck input):not(#volume-slider):not([data-no-tap-lock]):not(.range-unlocked) {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
html.range-tap-lock input[type="range"].range-unlocked {
|
||||
opacity: 1;
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
/* Header close (×) for modals whose header has no other close control; full screen on phones, so it must
|
||||
always be visible. Clicks the modal's own Cancel button so closing behaves exactly the same. */
|
||||
.modal-x {
|
||||
position: absolute; top: 12px; right: 12px; z-index: 1;
|
||||
width: 36px; height: 36px; margin: 0; padding: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 0 !important;
|
||||
color: var(--text-color, #fff); font-size: 1.6em; line-height: 1; cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.modal-x:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* Guest comment box (comments.js renderGuestInput): looks exactly like the real input, but inert.
|
||||
Clicks fall through to the wrapper, which opens the login modal. */
|
||||
.comment-input.is-guest { cursor: pointer; }
|
||||
.comment-input.is-guest textarea,
|
||||
.comment-input.is-guest .submit-comment {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
// ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
|
||||
|
||||
async function getHardwareFingerprint() {
|
||||
// Disabled by the operator (websrv.anon_hw_fingerprint: false): compute nothing, forget any cached value
|
||||
if (window.f0ckHwFingerprint === false) {
|
||||
try { localStorage.removeItem('f0ck_anon_hw_fp'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const cached = localStorage.getItem('f0ck_anon_hw_fp');
|
||||
if (cached) return cached;
|
||||
|
||||
+35
-8
@@ -144,7 +144,7 @@ class CommentSystem {
|
||||
let isAtBottom = currentlyAtBottom;
|
||||
|
||||
// Base the bottom state primarily on whether the input box / end-of-thread marker is visible
|
||||
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
|
||||
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice');
|
||||
|
||||
if (bottomElement) {
|
||||
const bottomRect = bottomElement.getBoundingClientRect();
|
||||
@@ -790,18 +790,25 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Render skeleton (Result: Layout visible immediately)
|
||||
// Render skeleton only if the fetch is slow. Rendering it immediately made every item switch flash an
|
||||
// empty list that the real comments then replaced a moment later.
|
||||
// Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments,
|
||||
// so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top.
|
||||
let skeletonTimer = null;
|
||||
if (!scrollToId && !preserveScroll) {
|
||||
if (this.user || !this.container.querySelector('.login-placeholder, .comment-input, .lock-notice')) {
|
||||
this.render([], this.user, initialIsSubscribed);
|
||||
this.restoreState(state);
|
||||
if (this.user || !this.container.querySelector('.comment-input, .lock-notice')) {
|
||||
skeletonTimer = setTimeout(() => {
|
||||
skeletonTimer = null;
|
||||
this.render([], this.user, initialIsSubscribed);
|
||||
this.restoreState(state);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
const cancelSkeleton = () => { if (skeletonTimer) { clearTimeout(skeletonTimer); skeletonTimer = null; } };
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
|
||||
cancelSkeleton();
|
||||
|
||||
// If server is restarting (502/503), res.ok will be false.
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
@@ -914,6 +921,7 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cancelSkeleton();
|
||||
console.error('[CommentSystem] Error loading comments:', e);
|
||||
// Catch-all for network errors, 502s, JSON parse errors (e.g. server restart)
|
||||
// If initial load already finished (SSR or first fetch), just keep existing comments on screen.
|
||||
@@ -1389,7 +1397,8 @@ class CommentSystem {
|
||||
} else if (currentUserId) {
|
||||
inputSection = this.renderInput();
|
||||
} else {
|
||||
inputSection = '<div class="login-placeholder"></div>';
|
||||
// Guests can read but not comment: an inert look-alike keeps the layout identical
|
||||
inputSection = this.renderGuestInput();
|
||||
}
|
||||
|
||||
const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view');
|
||||
@@ -1476,7 +1485,7 @@ class CommentSystem {
|
||||
});
|
||||
}
|
||||
|
||||
const mainInput = this.container.querySelector('.main-input');
|
||||
const mainInput = this.container.querySelector('.main-input:not(.is-guest)');
|
||||
if (mainInput) this.setupEmojiPicker(mainInput);
|
||||
|
||||
// Lazy emoji load: scan the just-rendered comment text for :emoji: patterns.
|
||||
@@ -2383,6 +2392,24 @@ class CommentSystem {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Same markup as renderInput() so every theme styles it identically, but inert: readonly, not focusable,
|
||||
// no pointer events on the controls, no submit. Clicking it opens the login modal.
|
||||
renderGuestInput() {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const placeholder = i18n.login_to_comment || 'Log in to comment';
|
||||
const postLabel = i18n.post || 'Post';
|
||||
return `
|
||||
<div class="comment-input main-input is-guest" aria-disabled="true" title="${placeholder}"
|
||||
onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
|
||||
<textarea placeholder="${placeholder}" readonly tabindex="-1" aria-disabled="true"></textarea>
|
||||
<div class="comment-file-preview"></div>
|
||||
<div class="input-actions">
|
||||
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderInput(parentId = null) {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const session = window.f0ckSession || {};
|
||||
@@ -2742,7 +2769,7 @@ class CommentSystem {
|
||||
});
|
||||
} else {
|
||||
// Scroll to Bottom of comments
|
||||
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice') || this.container.querySelector('.login-placeholder');
|
||||
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice');
|
||||
if (bottomElement) {
|
||||
bottomElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
|
||||
+81
-5
@@ -1,7 +1,8 @@
|
||||
// Pages whose content depends on the rating/mime filter and must reload when it changes.
|
||||
// Profile pages count even when both previews are empty (then no .posts grid is rendered).
|
||||
// Elements whose horizontal drags belong to them (sliders, sideways-scrolling rows): swipe gestures ignore touches starting here.
|
||||
window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]';
|
||||
// .f0ck-tuner-row: on touch devices sliders are locked (pointer-events none), so the touch lands on the row
|
||||
window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-row, .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]';
|
||||
|
||||
window.isFilterReloadableView = function () {
|
||||
return !!document.querySelector('.posts, .tags-grid') || /^\/user\/[^/]+\/?$/.test(window.location.pathname);
|
||||
@@ -2925,8 +2926,9 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
|
||||
|
||||
if (registerBtn && registerModal) {
|
||||
registerBtn.addEventListener('click', (e) => {
|
||||
// The nav "Register" link is only rendered while registration is open; the modal logic must not depend on it
|
||||
if (registerModal) {
|
||||
if (registerBtn) registerBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
openModal(registerModal);
|
||||
});
|
||||
@@ -3059,7 +3061,7 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
// Delegate help button click (since it's in a partial)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'help-button') {
|
||||
if (e.target.closest?.('#help-button')) {
|
||||
openModal(shortcutsModal);
|
||||
}
|
||||
});
|
||||
@@ -22291,7 +22293,29 @@ document.addEventListener('click', (e) => {
|
||||
|
||||
// ── Mobile scroll-to-top button ───────────────────────────────────────────────
|
||||
// Visible only on mobile (≤999px), appears below #comments-container so users
|
||||
// can easily get back to the top after reading comments.
|
||||
// can easily get back to the top after reading comments. Only shown once the relevant scroller is
|
||||
// scrolled far enough that getting back up by hand is a chore (1.5 screens, at least 600px).
|
||||
(() => {
|
||||
const scrollerFor = (btn) => btn.closest('#onara-modal') || document.querySelector('.index-container') || null;
|
||||
const update = () => {
|
||||
document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => {
|
||||
const sc = scrollerFor(btn);
|
||||
const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop);
|
||||
const viewH = sc ? sc.clientHeight : window.innerHeight;
|
||||
btn.classList.toggle('is-visible', top > Math.max(600, viewH * 1.5));
|
||||
});
|
||||
};
|
||||
let queued = false;
|
||||
const schedule = () => {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
requestAnimationFrame(() => { queued = false; update(); });
|
||||
};
|
||||
// Capture: scroll events don't bubble, and the scroller may be the window, .index-container or the Onara modal
|
||||
document.addEventListener('scroll', schedule, { capture: true, passive: true });
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
})();
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.mobile-scroll-to-top')) return;
|
||||
e.preventDefault();
|
||||
@@ -24357,3 +24381,55 @@ window.BulkSelection = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
// ─── Tap-to-activate range sliders on touch devices ───────────────────────────
|
||||
// A finger scrolling a panel full of sliders easily grabs one by accident. On coarse pointers, sliders
|
||||
// ignore touches (CSS: pointer-events none) until tapped; the tapped slider unlocks and relocks when the
|
||||
// user touches elsewhere or leaves it alone for a few seconds. Player controls are exempt (see LOCKABLE).
|
||||
(() => {
|
||||
if (!window.matchMedia || !window.matchMedia('(pointer: coarse)').matches) return;
|
||||
const LOCKABLE = 'input[type="range"]:not(.v0ck input):not(#volume-slider):not([data-no-tap-lock])';
|
||||
const RELOCK_MS = 4000;
|
||||
const HIT_PAD = 12;
|
||||
let active = null;
|
||||
let timer = null;
|
||||
|
||||
const lock = () => {
|
||||
if (active) active.classList.remove('range-unlocked');
|
||||
active = null;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const armTimer = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(lock, RELOCK_MS);
|
||||
};
|
||||
const hits = (el, x, y) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && x >= r.left - HIT_PAD && x <= r.right + HIT_PAD && y >= r.top - HIT_PAD && y <= r.bottom + HIT_PAD;
|
||||
};
|
||||
|
||||
document.documentElement.classList.add('range-tap-lock');
|
||||
|
||||
// A tap (no click fires after a scroll) lands on the slider's parent because the slider has no pointer events
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = Array.from(document.querySelectorAll(LOCKABLE)).find(el => el !== active && hits(el, e.clientX, e.clientY));
|
||||
if (!target) return;
|
||||
lock();
|
||||
active = target;
|
||||
active.classList.add('range-unlocked');
|
||||
armTimer();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, true);
|
||||
|
||||
// Touching anywhere outside the unlocked slider locks it again
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (!active) return;
|
||||
const t = e.touches[0];
|
||||
if (t && !hits(active, t.clientX, t.clientY)) lock();
|
||||
else armTimer();
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
// Keep it unlocked while it's being used
|
||||
document.addEventListener('input', (e) => { if (e.target === active) armTimer(); }, true);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* page-modal.js — open info pages (ranking, rules, about, privacy, terms) in a modal over the content area
|
||||
* (below the navbar, between the sidebars).
|
||||
*
|
||||
* Normal navigation swaps #main, which kills a playing video/audio. Links marked [data-page-modal]
|
||||
* instead fetch the page, lift its #main content (with the page's <style>s and the #main class, which
|
||||
* some page CSS hangs on) into the modal and re-run its inline scripts. The page underneath is untouched.
|
||||
*
|
||||
* Inside the modal, links to other modal pages load in place; any other link closes the modal and
|
||||
* navigates as usual. Direct visits to these URLs are unaffected.
|
||||
*/
|
||||
(() => {
|
||||
if (window.f0ckPageModal) return;
|
||||
|
||||
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms)\/?$/;
|
||||
|
||||
const modal = document.getElementById('page-modal');
|
||||
if (!modal) return;
|
||||
const body = modal.querySelector('.pgm-body');
|
||||
const titleEl = modal.querySelector('.pgm-title');
|
||||
let currentUrl = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
const isModalPath = (href) => {
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
return u.origin === location.origin && MODAL_PAGES.test(u.pathname);
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
const runScripts = (root) => {
|
||||
root.querySelectorAll('script').forEach((old) => {
|
||||
// JSON data blocks are read by the page scripts, not executed
|
||||
if (old.type && old.type !== 'text/javascript' && old.type !== 'module') return;
|
||||
const s = document.createElement('script');
|
||||
for (const a of old.attributes) s.setAttribute(a.name, a.value);
|
||||
s.textContent = old.textContent;
|
||||
old.replaceWith(s);
|
||||
});
|
||||
};
|
||||
|
||||
const load = async (url, title) => {
|
||||
const seq = ++loadSeq;
|
||||
currentUrl = url;
|
||||
titleEl.textContent = title || '';
|
||||
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
|
||||
try {
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
||||
if (seq !== loadSeq) return;
|
||||
const main = doc.getElementById('main');
|
||||
if (!res.ok || !main) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = `${main.className} pgm-main`.trim();
|
||||
wrap.innerHTML = main.innerHTML;
|
||||
body.innerHTML = '';
|
||||
body.appendChild(wrap);
|
||||
body.scrollTop = 0;
|
||||
if (!title) {
|
||||
const h = wrap.querySelector('h1, h2, h3');
|
||||
titleEl.textContent = h ? h.textContent.trim() : '';
|
||||
}
|
||||
runScripts(wrap);
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return;
|
||||
body.innerHTML = `<div class="pgm-loading">Could not load this page. <a href="${url}" class="pgm-fallback">Open it directly</a>.</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Placement: the modal fills the content area only, so the navbar and sidebars stay usable ──
|
||||
const visibleRect = (sel) => {
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') return r;
|
||||
}
|
||||
return 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;
|
||||
// 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`);
|
||||
};
|
||||
// Follow sidebar slide animations for a moment after a toggle
|
||||
let followUntil = 0;
|
||||
const follow = () => {
|
||||
if (modal.style.display === 'none') return;
|
||||
place();
|
||||
if (performance.now() < followUntil) requestAnimationFrame(follow);
|
||||
};
|
||||
const followFor = (ms) => {
|
||||
const running = performance.now() < followUntil;
|
||||
followUntil = performance.now() + ms;
|
||||
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'] });
|
||||
|
||||
// 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
|
||||
const LAYOUT_SEL = 'nav.navbar, .global-sidebar-right, .item-sidebar-right, .index-sidebar-right, .item-sidebar-left';
|
||||
const watched = new WeakSet();
|
||||
const ro = typeof ResizeObserver === 'function'
|
||||
? new ResizeObserver(() => { if (modal.style.display !== 'none') place(); })
|
||||
: null;
|
||||
const watchLayout = () => {
|
||||
document.querySelectorAll(LAYOUT_SEL).forEach((el) => {
|
||||
if (watched.has(el)) return;
|
||||
watched.add(el);
|
||||
if (ro) ro.observe(el);
|
||||
// Class/style toggles inside the navbar (collapse "show", inline heights) may animate: follow for a bit
|
||||
if (el.matches('nav.navbar')) {
|
||||
new MutationObserver(() => { if (modal.style.display !== 'none') followFor(600); })
|
||||
.observe(el, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const open = (url, title) => {
|
||||
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
|
||||
watchLayout();
|
||||
place();
|
||||
modal.style.display = 'flex';
|
||||
followFor(300);
|
||||
load(url, title);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = 'none';
|
||||
body.innerHTML = '';
|
||||
currentUrl = null;
|
||||
};
|
||||
|
||||
// Capture phase: runs before the global AJAX link handler
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const a = e.target.closest?.('a[href]');
|
||||
if (!a) return;
|
||||
|
||||
if (a.hasAttribute('data-page-modal')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
open(a.getAttribute('href'), a.getAttribute('title') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modal.style.display === 'none' || 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)) {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
try { if (new URL(a.href, location.origin).origin !== location.origin) return; } catch { return; }
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isModalPath(a.href)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
load(a.getAttribute('href'), '');
|
||||
return;
|
||||
}
|
||||
// Leaving the modal for a regular page: close it and let normal navigation take over
|
||||
close();
|
||||
}, true);
|
||||
|
||||
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(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display !== 'none') close();
|
||||
});
|
||||
|
||||
window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
|
||||
})();
|
||||
Reference in New Issue
Block a user