为站内页面跳转显示顶部进度条,并增强子级页面返回导航。
// ==UserScript==
// @name LINUX.SB Navigation Progress
// @namespace https://linux.sb/
// @version 1.4.1
// @description 为站内页面跳转显示顶部进度条,并增强子级页面返回导航。
// @match https://linux.sb/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
var SHOW_DELAY_MS = 100;
var SLOW_PROGRESS_DELAY_MS = 2000;
var SAFETY_TIMEOUT_MS = 15000;
var COMPLETE_DURATION_MS = 180;
var COMPLETE_HOLD_MS = 220;
var FADE_DURATION_MS = 160;
var MOUNT_RETRY_MS = 16;
var HISTORY_FINISH_DELAY_MS = 80;
var STORAGE_KEY = 'lsb-navigation-progress-pending-v1';
var CONTAINER_ID = 'lsb-navigation-progress';
var STYLE_ID = 'lsb-navigation-progress-style';
var FILL_CLASS = 'lsb-navigation-progress-fill';
var CHILD_NAVIGATION_ID = 'lsb-child-navigation';
var CHILD_BACK_CLASS = 'lsb-child-navigation-back';
var FOOTER_BACK_BUTTON_ID = 'lsb-footer-back-button';
var FOOTER_BACK_CLASS = 'lsb-footer-back-button';
var ARROW_LEFT_SVG = [
'<svg class="lucide lucide-arrow-left" xmlns="http://www.w3.org/2000/svg"',
' width="20" height="20" viewBox="0 0 24 24"',
' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"',
' stroke-linejoin="round" aria-hidden="true" focusable="false">',
'<path d="m12 19-7-7 7-7"></path><path d="M19 12H5"></path></svg>'
].join('');
var CSS_TEXT = [
'#' + CONTAINER_ID + ' {',
' position: fixed;',
' top: 0;',
' left: 0;',
' z-index: 2147483647;',
' width: 100%;',
' height: 3px;',
' overflow: hidden;',
' box-sizing: border-box;',
' background: rgba(30, 30, 32, .16);',
' opacity: 0;',
' visibility: hidden;',
' pointer-events: none;',
' transition: opacity ' + FADE_DURATION_MS + 'ms ease, visibility 0s linear ' + FADE_DURATION_MS + 'ms;',
' contain: layout paint;',
'}',
'#' + CONTAINER_ID + '[data-visible="true"] {',
' opacity: 1;',
' visibility: visible;',
' transition-delay: 0s;',
'}',
'#' + CONTAINER_ID + ' .' + FILL_CLASS + ' {',
' width: 100%;',
' height: 100%;',
' background: #FEB005;',
' box-shadow: 0 0 8px rgba(254, 176, 5, .55);',
' transform: scaleX(0);',
' transform-origin: left center;',
' transition-property: transform;',
' transition-timing-function: ease-out;',
' will-change: transform;',
'}',
'#' + CHILD_NAVIGATION_ID + ' {',
' display: flex;',
' align-items: center;',
' gap: 8px;',
' width: min(var(--lsb-wide-max, 1076px), calc(100vw - 48px));',
' min-width: 0;',
' margin: 14px auto 0;',
' background-color: #fff;',
' border: 1px solid var(--line);',
' border-radius: var(--radius);',
' box-shadow: 0 6px 18px var(--shadow-base);',
' box-sizing: border-box;',
'}',
'#' + CHILD_NAVIGATION_ID + ' .' + CHILD_BACK_CLASS + ' {',
' display: inline-flex;',
' align-items: center;',
' justify-content: center;',
' flex: 0 0 36px;',
' width: 36px;',
' height: 36px;',
' padding: 0;',
' border: 0;',
' border-radius: var(--radius, 6px);',
' background: transparent;',
' color: var(--text-muted, #737373);',
' font: inherit;',
' cursor: pointer;',
' transition: background-color 160ms ease, color 160ms ease;',
'}',
'#' + CHILD_NAVIGATION_ID + ' .' + CHILD_BACK_CLASS + ':hover {',
' background: var(--brand-soft, rgba(254, 176, 5, .12));',
' color: var(--brand-hover, #FEB005);',
'}',
'#' + CHILD_NAVIGATION_ID + ' .' + CHILD_BACK_CLASS + ':focus-visible {',
' outline: 2px solid var(--focus-ring, rgba(254, 176, 5, .58));',
' outline-offset: 2px;',
'}',
'#' + CHILD_NAVIGATION_ID + ' .' + CHILD_BACK_CLASS + ' svg {',
' display: block;',
' width: 20px;',
' height: 20px;',
' pointer-events: none;',
'}',
'#' + CHILD_NAVIGATION_ID + ' .breadcrumb {',
' display: flex !important;',
' align-items: center;',
' flex: 1 1 auto;',
' flex-wrap: nowrap !important;',
' gap: 8px;',
' min-width: 0;',
' margin: 0 !important;',
' padding: 0 !important;',
' overflow-x: auto;',
' overflow-y: hidden;',
' white-space: nowrap;',
' scrollbar-width: none;',
'}',
'#' + CHILD_NAVIGATION_ID + ' .breadcrumb::-webkit-scrollbar {',
' display: none;',
'}',
'#' + FOOTER_BACK_BUTTON_ID + ' {',
' bottom: calc(64px + env(safe-area-inset-bottom));',
'}',
'@media (min-width: 721px) and (max-width: 1180px) {',
' #' + CHILD_NAVIGATION_ID + ' { width: calc(100vw - 24px); }',
'}',
'@media (max-width: 720px) {',
' #' + CHILD_NAVIGATION_ID + ' { width: calc(100vw - 10px); margin-top: 10px; }',
' #' + FOOTER_BACK_BUTTON_ID + ' { bottom: calc(56px + env(safe-area-inset-bottom)); }',
'}',
'@media (prefers-reduced-motion: reduce) {',
' #' + CONTAINER_ID + ',',
' #' + CONTAINER_ID + ' .' + FILL_CLASS + ',',
' #' + CHILD_NAVIGATION_ID + ' .' + CHILD_BACK_CLASS + ',',
' #' + FOOTER_BACK_BUTTON_ID + ' {',
' transition: none !important;',
' }',
'}'
].join('\n');
var container = null;
var fill = null;
var showTimer = 0;
var slowTimer = 0;
var safetyTimer = 0;
var fadeTimer = 0;
var resetTimer = 0;
var mountTimer = 0;
var completionTimer = 0;
var pendingClickEvent = null;
var activeNavigationSignal = null;
var activeNavigationAbortHandler = null;
var childNavigationObserver = null;
var footerBackVisibilityObserver = null;
var phase = 'idle';
function getSessionStorage() {
try {
return window.sessionStorage || null;
} catch (error) {
return null;
}
}
function writePendingNavigation() {
var storage = getSessionStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEY, String(Date.now()));
} catch (error) {
// Storage is optional; current-page progress still works without it.
}
}
function clearPendingNavigation() {
var storage = getSessionStorage();
if (!storage) {
return;
}
try {
storage.removeItem(STORAGE_KEY);
} catch (error) {
// Ignore unavailable or blocked session storage.
}
}
function detachNavigationAbort() {
if (activeNavigationSignal && activeNavigationAbortHandler &&
typeof activeNavigationSignal.removeEventListener === 'function') {
try {
activeNavigationSignal.removeEventListener('abort', activeNavigationAbortHandler);
} catch (error) {
// Ignore signals that do not support listener removal.
}
}
activeNavigationSignal = null;
activeNavigationAbortHandler = null;
}
function readPendingNavigationTimestamp() {
var storage = getSessionStorage();
if (!storage) {
return null;
}
try {
var rawTimestamp = storage.getItem(STORAGE_KEY);
if (rawTimestamp === null || String(rawTimestamp).trim() === '') {
return null;
}
var timestamp = Number(rawTimestamp);
var age = Date.now() - timestamp;
if (timestamp !== timestamp || !isFinite(timestamp) || age < 0 || age >= SAFETY_TIMEOUT_MS) {
storage.removeItem(STORAGE_KEY);
return null;
}
return timestamp;
} catch (error) {
return null;
}
}
function injectStyle() {
if (document.getElementById(STYLE_ID)) {
return;
}
var root = document.head || document.documentElement;
if (!root) {
return;
}
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS_TEXT;
root.appendChild(style);
}
function isHomePage() {
try {
var currentUrl = new URL(window.location.href);
var path = currentUrl.pathname.replace(/\/+$/, '') || '/';
if (path === '/') {
return true;
}
return path === '/index.php' &&
!currentUrl.searchParams.has('a') &&
!currentUrl.searchParams.has('do') &&
!currentUrl.searchParams.has('id');
} catch (error) {
return false;
}
}
function disconnectChildNavigationObserver() {
if (childNavigationObserver) {
try {
childNavigationObserver.disconnect();
} catch (error) {
// Navigation mounting must not affect the existing progress bar.
}
childNavigationObserver = null;
}
}
function createBackButton(className, id) {
var button = document.createElement('button');
if (id) {
button.id = id;
}
button.setAttribute('type', 'button');
button.className = className;
button.setAttribute('aria-label', '返回上一页');
button.setAttribute('title', '返回上一页');
button.innerHTML = ARROW_LEFT_SVG;
button.addEventListener('click', function () {
window.history.back();
});
return button;
}
function mountChildNavigation(allowMissingBreadcrumb) {
if (isHomePage() || document.getElementById(CHILD_NAVIGATION_ID)) {
return true;
}
var wrap = document.querySelector('main.wrap');
if (!wrap || !wrap.parentElement) {
return false;
}
var breadcrumb = document.querySelector('.main-panel .breadcrumb');
if (!breadcrumb && !allowMissingBreadcrumb) {
return false;
}
injectStyle();
var navigation = document.createElement('nav');
navigation.id = CHILD_NAVIGATION_ID;
navigation.setAttribute('aria-label', '页面路径');
var backButton = createBackButton(CHILD_BACK_CLASS);
navigation.appendChild(backButton);
if (breadcrumb) {
navigation.appendChild(breadcrumb);
}
wrap.parentElement.insertBefore(navigation, wrap);
return true;
}
function tryMountChildNavigation(allowMissingBreadcrumb) {
var mounted = false;
try {
mounted = mountChildNavigation(Boolean(allowMissingBreadcrumb));
} catch (error) {
mounted = false;
}
if (mounted) {
disconnectChildNavigationObserver();
}
return mounted;
}
function installChildNavigation() {
if (isHomePage() || tryMountChildNavigation(document.readyState !== 'loading')) {
return;
}
if (typeof MutationObserver === 'function' && document.documentElement) {
try {
childNavigationObserver = new MutationObserver(function () {
tryMountChildNavigation(false);
});
childNavigationObserver.observe(document.documentElement, { childList: true, subtree: true });
} catch (error) {
disconnectChildNavigationObserver();
}
}
document.addEventListener('DOMContentLoaded', function () {
tryMountChildNavigation(true);
disconnectChildNavigationObserver();
}, { once: true });
}
function syncFooterBackVisibility(sourceButton, footerBackButton) {
if (sourceButton.hasAttribute('hidden')) {
footerBackButton.setAttribute('hidden', '');
} else {
footerBackButton.removeAttribute('hidden');
}
}
function mountFooterBackButton() {
if (isHomePage() || document.getElementById(FOOTER_BACK_BUTTON_ID)) {
return true;
}
var footer = document.querySelector('.footer');
var sourceButton = footer ? footer.querySelector('[data-back-to-top]') : null;
if (!footer || !sourceButton) {
return false;
}
injectStyle();
var footerBackButton = createBackButton('back-to-top-button ' + FOOTER_BACK_CLASS,
FOOTER_BACK_BUTTON_ID);
syncFooterBackVisibility(sourceButton, footerBackButton);
footer.insertBefore(footerBackButton, sourceButton);
if (typeof MutationObserver === 'function') {
try {
footerBackVisibilityObserver = new MutationObserver(function () {
syncFooterBackVisibility(sourceButton, footerBackButton);
});
footerBackVisibilityObserver.observe(sourceButton, {
attributes: true,
attributeFilter: ['hidden']
});
} catch (error) {
footerBackVisibilityObserver = null;
}
}
return true;
}
function tryMountFooterBackButton() {
try {
return mountFooterBackButton();
} catch (error) {
return false;
}
}
function installFooterBackButton() {
if (isHomePage() || tryMountFooterBackButton() || document.readyState !== 'loading') {
return;
}
document.addEventListener('DOMContentLoaded', tryMountFooterBackButton, { once: true });
}
function ensureProgressBar() {
if (container && container.isConnected && fill) {
return true;
}
injectStyle();
container = document.getElementById(CONTAINER_ID);
if (container) {
fill = container.querySelector('.' + FILL_CLASS);
return Boolean(fill);
}
var root = document.body || document.documentElement;
if (!root) {
return false;
}
container = document.createElement('div');
container.id = CONTAINER_ID;
container.setAttribute('role', 'progressbar');
container.setAttribute('aria-label', '页面加载进度');
container.setAttribute('aria-valuemin', '0');
container.setAttribute('aria-valuemax', '100');
container.setAttribute('aria-valuenow', '0');
container.setAttribute('aria-hidden', 'true');
fill = document.createElement('div');
fill.className = FILL_CLASS;
container.appendChild(fill);
root.appendChild(container);
return true;
}
function setProgress(percent, duration) {
if (!ensureProgressBar()) {
return false;
}
fill.style.transitionDuration = Math.max(0, duration) + 'ms';
fill.style.transform = 'scaleX(' + (percent / 100) + ')';
container.setAttribute('aria-valuenow', String(percent));
return true;
}
function showProgressBar() {
if (!ensureProgressBar()) {
return false;
}
container.setAttribute('data-visible', 'true');
container.setAttribute('aria-hidden', 'false');
return true;
}
function clearTimers() {
window.clearTimeout(showTimer);
window.clearTimeout(slowTimer);
window.clearTimeout(safetyTimer);
window.clearTimeout(fadeTimer);
window.clearTimeout(resetTimer);
window.clearTimeout(mountTimer);
window.clearTimeout(completionTimer);
showTimer = 0;
slowTimer = 0;
safetyTimer = 0;
fadeTimer = 0;
resetTimer = 0;
mountTimer = 0;
completionTimer = 0;
pendingClickEvent = null;
}
function resetProgress() {
clearTimers();
clearPendingNavigation();
detachNavigationAbort();
phase = 'idle';
if (!container) {
return;
}
container.removeAttribute('data-visible');
container.setAttribute('aria-hidden', 'true');
setProgress(0, 0);
}
function scheduleSafetyReset(delay) {
window.clearTimeout(safetyTimer);
safetyTimer = window.setTimeout(resetProgress, delay === undefined ? SAFETY_TIMEOUT_MS : delay);
}
function beginProgress() {
detachNavigationAbort();
clearTimers();
phase = 'loading';
writePendingNavigation();
if (showProgressBar()) {
setProgress(0, 0);
void fill.offsetWidth;
setProgress(80, 300);
}
slowTimer = window.setTimeout(function () {
slowTimer = 0;
if (phase === 'loading') {
setProgress(90, 1200);
}
}, SLOW_PROGRESS_DELAY_MS);
scheduleSafetyReset();
}
function mountResumedProgress() {
mountTimer = 0;
if (phase !== 'resumed') {
return;
}
if (showProgressBar()) {
setProgress(90, 0);
return;
}
mountTimer = window.setTimeout(mountResumedProgress, MOUNT_RETRY_MS);
}
function resumeProgress(startedAt) {
clearTimers();
detachNavigationAbort();
phase = 'resumed';
mountResumedProgress();
scheduleSafetyReset(Math.max(0, SAFETY_TIMEOUT_MS - (Date.now() - startedAt)));
}
function scheduleHistoryCompletion() {
window.clearTimeout(completionTimer);
completionTimer = window.setTimeout(function () {
completionTimer = 0;
finishProgress();
}, HISTORY_FINISH_DELAY_MS);
}
function finishProgress() {
if (phase !== 'resumed') {
return;
}
window.clearTimeout(safetyTimer);
window.clearTimeout(mountTimer);
window.clearTimeout(completionTimer);
safetyTimer = 0;
mountTimer = 0;
completionTimer = 0;
phase = 'finishing';
detachNavigationAbort();
clearPendingNavigation();
showProgressBar();
setProgress(100, COMPLETE_DURATION_MS);
fadeTimer = window.setTimeout(function () {
fadeTimer = 0;
if (container) {
container.removeAttribute('data-visible');
container.setAttribute('aria-hidden', 'true');
}
resetTimer = window.setTimeout(function () {
resetTimer = 0;
phase = 'idle';
setProgress(0, 0);
}, FADE_DURATION_MS);
}, COMPLETE_HOLD_MS);
}
function getNavigationUrl(event, currentHref) {
if (!event || event.defaultPrevented || event.button !== 0 ||
event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
return null;
}
var target = event.target;
if (target && target.nodeType !== 1) {
target = target.parentElement;
}
if (!target || typeof target.closest !== 'function') {
return null;
}
var link = target.closest('a[href]');
if (!link || link.hasAttribute('download')) {
return null;
}
var linkTarget = String(link.getAttribute('target') || '').trim().toLowerCase();
if (linkTarget && linkTarget !== '_self') {
return null;
}
var rawHref = link.getAttribute('href');
if (rawHref === null) {
return null;
}
rawHref = String(rawHref).trim();
try {
var currentUrl = new URL(currentHref);
var navigationUrl = new URL(rawHref, currentUrl);
if (navigationUrl.protocol !== 'https:' || navigationUrl.origin !== currentUrl.origin) {
return null;
}
var sameResource = navigationUrl.pathname === currentUrl.pathname &&
navigationUrl.search === currentUrl.search;
var isFragmentNavigation = sameResource &&
(rawHref.indexOf('#') >= 0 || Boolean(currentUrl.hash));
return isFragmentNavigation ? null : navigationUrl;
} catch (error) {
return null;
}
}
function isSameOriginHttpsUrl(rawUrl) {
try {
var currentUrl = new URL(window.location.href);
var destinationUrl = new URL(rawUrl, currentUrl);
return destinationUrl.protocol === 'https:' && destinationUrl.origin === currentUrl.origin;
} catch (error) {
return false;
}
}
function watchNavigationAbort(event) {
var signal = event && event.signal;
if (!signal || typeof signal.addEventListener !== 'function') {
return;
}
activeNavigationSignal = signal;
activeNavigationAbortHandler = resetProgress;
try {
signal.addEventListener('abort', activeNavigationAbortHandler, { once: true });
if (signal.aborted) {
resetProgress();
}
} catch (error) {
detachNavigationAbort();
}
}
function handleNavigationEvent(event) {
if (!event || event.navigationType !== 'traverse' || !event.destination ||
event.destination.sameDocument !== false || !isSameOriginHttpsUrl(event.destination.url)) {
return;
}
try {
beginProgress();
watchNavigationAbort(event);
} catch (error) {
resetProgress();
}
}
function installNavigationListener() {
try {
if (window.navigation && typeof window.navigation.addEventListener === 'function') {
window.navigation.addEventListener('navigate', handleNavigationEvent, false);
}
} catch (error) {
// Navigation API is optional.
}
}
function isBackForwardNavigation() {
try {
var performanceApi = window.performance;
if (performanceApi && typeof performanceApi.getEntriesByType === 'function') {
var entries = performanceApi.getEntriesByType('navigation');
if (entries && entries[0] && entries[0].type === 'back_forward') {
return true;
}
}
return Boolean(performanceApi && performanceApi.navigation && performanceApi.navigation.type === 2);
} catch (error) {
return false;
}
}
function scheduleProgress(event) {
if (phase === 'loading') {
resetProgress();
}
window.clearTimeout(showTimer);
pendingClickEvent = event;
showTimer = window.setTimeout(function () {
var clickEvent = pendingClickEvent;
showTimer = 0;
pendingClickEvent = null;
if (!clickEvent || clickEvent.defaultPrevented) {
return;
}
try {
beginProgress();
} catch (error) {
resetProgress();
}
}, SHOW_DELAY_MS);
}
function handleDocumentClick(event) {
if (getNavigationUrl(event, window.location.href)) {
scheduleProgress(event);
}
}
function handlePageShow(event) {
if (!event.persisted) {
return;
}
try {
var startedAt = readPendingNavigationTimestamp();
if (startedAt === null) {
startedAt = Date.now();
}
resumeProgress(startedAt);
scheduleHistoryCompletion();
} catch (error) {
resetProgress();
}
}
installChildNavigation();
installFooterBackButton();
document.addEventListener('click', handleDocumentClick, false);
window.addEventListener('pageshow', handlePageShow, false);
installNavigationListener();
var pendingNavigationTimestamp = readPendingNavigationTimestamp();
if (pendingNavigationTimestamp === null && isBackForwardNavigation()) {
pendingNavigationTimestamp = Date.now();
}
if (pendingNavigationTimestamp !== null) {
try {
resumeProgress(pendingNavigationTimestamp);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', finishProgress, { once: true });
} else {
window.setTimeout(finishProgress, 0);
}
} catch (error) {
resetProgress();
}
}
})();