Adds a persistent numbered page navigator to Perfect Circuit category pages.
// ==UserScript==
// @name Perfect Circuit Numbered Pagination
// @namespace local.perfectcircuit.pagination
// @license MIT
// @version 0.0.3
// @description Adds a persistent numbered page navigator to Perfect Circuit category pages.
// @match https://www.perfectcircuit.com/*.html*
// @run-at document-idle
// @grant none
// @noframes
// ==/UserScript==
(() => {
"use strict";
const HOST_ID = "pc-numbered-pagination-host";
const COLLAPSED_KEY = "pc-numbered-pagination:collapsed";
const CACHE = new Map();
const PAGE_PARAM = "p";
const LIMIT_PARAM = "product_list_limit";
const DEFAULT_PER_PAGE = 48;
function normalizeText(value) {
return String(value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function toNumber(value) {
const number = Number(String(value || "").replace(/,/g, ""));
return Number.isFinite(number) ? number : 0;
}
function getCurrentPage() {
const url = new URL(location.href);
const page = toNumber(url.searchParams.get(PAGE_PARAM));
return Number.isInteger(page) && page > 0 ? page : 1;
}
function getLimitFromUrl() {
const url = new URL(location.href);
const limit = toNumber(url.searchParams.get(LIMIT_PARAM));
return Number.isInteger(limit) && limit > 0 ? limit : null;
}
function getBestPerPage(start, end, currentPage) {
const fromUrl = getLimitFromUrl();
if (fromUrl) return fromUrl;
// On page 2+, the first visible item preserves the configured page size,
// even when the current page is partial.
if (currentPage > 1 && start > 1) {
const inferred = Math.round((start - 1) / (currentPage - 1));
if (Number.isInteger(inferred) && inferred > 0) return inferred;
}
const fromRange = end - start + 1;
return fromRange > 0 ? fromRange : DEFAULT_PER_PAGE;
}
function parseCountFromText(text) {
const clean = normalizeText(text);
const match = clean.match(
/(?:\bitems?\s*)?(\d[\d,]*)\s*(?:-|\u2010|\u2011|\u2012|\u2013|\u2014|\u2015|to)\s*(\d[\d,]*)\s+of\s+(\d[\d,]*)(?:\s+items?)?/i
);
if (!match) return null;
const start = toNumber(match[1]);
const end = toNumber(match[2]);
const totalItems = toNumber(match[3]);
const currentPage = getCurrentPage();
const perPage = getBestPerPage(start, end, currentPage);
const totalPages = Math.ceil(totalItems / perPage);
if (
!Number.isInteger(start) ||
!Number.isInteger(end) ||
!Number.isInteger(totalItems) ||
start <= 0 ||
end < start ||
totalItems <= 0 ||
perPage <= 0 ||
totalPages <= 0
) {
return null;
}
return {
start,
end,
totalItems,
perPage,
totalPages,
source: "count"
};
}
function findCountInElement(element) {
if (!element) return null;
const ownerDocument = element.ownerDocument || document;
const walker = ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const text = normalizeText(node.nodeValue);
if (!text || text.length > 160) continue;
const parsed = parseCountFromText(text);
if (parsed) return parsed;
}
return null;
}
function findCountInDom() {
const scopes = document.querySelectorAll(
".toolbar, .toolbar-products, .pages, .page-main, main"
);
for (const scope of scopes) {
const parsed = findCountInElement(scope);
if (parsed) return parsed;
}
return null;
}
function parsePageNumberFromAnchor(anchor) {
try {
const url = new URL(anchor.href, location.href);
const fromParam = toNumber(url.searchParams.get(PAGE_PARAM));
if (Number.isInteger(fromParam) && fromParam > 0) return fromParam;
} catch {
return null;
}
const label = normalizeText(
[
anchor.textContent,
anchor.getAttribute("aria-label"),
anchor.getAttribute("title")
]
.filter(Boolean)
.join(" ")
);
const labelMatch = label.match(/\bpage\s+(\d+)\b/i) || label.match(/^(\d+)$/);
const fromLabel = labelMatch ? toNumber(labelMatch[1]) : 0;
return Number.isInteger(fromLabel) && fromLabel > 0 ? fromLabel : null;
}
function getVisiblePageNumbers() {
const containers = Array.from(
document.querySelectorAll(".pages, .pagination, .toolbar, .toolbar-products")
);
return containers
.flatMap((container) => Array.from(container.querySelectorAll("a[href]")))
.map(parsePageNumberFromAnchor)
.filter((page) => Number.isInteger(page) && page > 0);
}
async function fetchCurrentHtmlInfo() {
const cacheKey = location.href;
if (CACHE.has(cacheKey)) return CACHE.get(cacheKey);
const promise = fetch(location.href, { credentials: "same-origin" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then((html) => {
const parsedDocument = new DOMParser().parseFromString(html, "text/html");
return findCountInElement(parsedDocument.body);
})
.catch(() => null);
CACHE.set(cacheKey, promise);
return promise;
}
async function getPaginationInfo() {
const domInfo = findCountInDom();
if (domInfo) return domInfo;
const htmlInfo = await fetchCurrentHtmlInfo();
if (htmlInfo) return htmlInfo;
const visiblePages = getVisiblePageNumbers();
const maxVisiblePage = visiblePages.length ? Math.max(...visiblePages) : 0;
if (maxVisiblePage > 1) {
return {
start: null,
end: null,
totalItems: null,
perPage: getLimitFromUrl() || DEFAULT_PER_PAGE,
totalPages: maxVisiblePage,
source: "visible-links"
};
}
return null;
}
function makePageUrl(page) {
const url = new URL(location.href);
if (page <= 1) {
url.searchParams.delete(PAGE_PARAM);
} else {
url.searchParams.set(PAGE_PARAM, String(page));
}
url.hash = "";
return url.toString();
}
function isCollapsed() {
try {
return localStorage.getItem(COLLAPSED_KEY) === "true";
} catch {
return false;
}
}
function setCollapsed(value) {
try {
localStorage.setItem(COLLAPSED_KEY, value ? "true" : "false");
} catch {
// Storage can be unavailable in restricted browsing contexts.
}
}
function pageRange(totalPages, currentPage) {
if (totalPages <= 13) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const pages = [1, 2, 3, totalPages - 2, totalPages - 1, totalPages];
for (let page = currentPage - 4; page <= currentPage + 4; page += 1) {
pages.push(page);
}
return Array.from(new Set(pages))
.filter((page) => page >= 1 && page <= totalPages)
.sort((a, b) => a - b);
}
function buildStyles() {
return `
:host {
all: initial;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.pc-panel {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 2147483647;
box-sizing: border-box;
padding: 12px;
color: #111;
background: #fff;
border: 1px solid #c9c9c9;
border-radius: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.2);
font-size: 14px;
line-height: 1.35;
}
.pc-collapsed {
left: auto;
right: 16px;
width: auto;
padding: 0;
overflow: hidden;
}
.pc-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.pc-title {
font-weight: 700;
}
.pc-subtitle {
color: #555;
font-size: 12px;
margin-top: 2px;
}
.pc-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pc-pages {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 28vh;
overflow: auto;
}
.pc-link,
.pc-button {
box-sizing: border-box;
border: 1px solid #bbb;
border-radius: 7px;
background: #fff;
color: #111;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.pc-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 8px;
}
.pc-link[aria-current="page"] {
background: #e7e7e7;
border-color: #888;
font-weight: 800;
}
.pc-link[aria-disabled="true"],
.pc-button[disabled] {
cursor: default;
opacity: 0.45;
}
.pc-button {
padding: 6px 9px;
}
.pc-button:not([disabled]):hover,
.pc-link:not([aria-disabled="true"]):hover {
background: #f3f3f3;
}
.pc-input {
width: 72px;
box-sizing: border-box;
border: 1px solid #aaa;
border-radius: 7px;
padding: 6px 8px;
font: inherit;
}
.pc-ellipsis {
display: inline-flex;
align-items: center;
height: 32px;
color: #555;
}
.pc-open {
padding: 9px 12px;
font-weight: 700;
}
@media (max-width: 700px) {
.pc-panel {
left: 8px;
right: 8px;
bottom: 8px;
}
.pc-top {
align-items: flex-start;
flex-direction: column;
}
}
`;
}
function addPageLink(container, page, currentPage, label) {
const link = document.createElement("a");
link.className = "pc-link";
link.href = makePageUrl(page);
link.textContent = label || String(page);
link.setAttribute("aria-label", label ? `Go to ${label.toLowerCase()} page` : `Go to page ${page}`);
if (page === currentPage && !label) {
link.setAttribute("aria-current", "page");
}
container.appendChild(link);
}
function addDisabledLink(container, label) {
const link = document.createElement("a");
link.className = "pc-link";
link.textContent = label;
link.setAttribute("aria-disabled", "true");
link.tabIndex = -1;
link.addEventListener("click", (event) => event.preventDefault());
container.appendChild(link);
}
function addEllipsis(container) {
const span = document.createElement("span");
span.className = "pc-ellipsis";
span.textContent = "...";
container.appendChild(span);
}
function renderExpanded(root, info) {
const currentPage = Math.min(getCurrentPage(), info.totalPages);
const panel = document.createElement("section");
panel.className = "pc-panel";
panel.setAttribute("aria-label", "Perfect Circuit numbered pagination");
const top = document.createElement("div");
top.className = "pc-top";
const titleWrap = document.createElement("div");
const title = document.createElement("div");
title.className = "pc-title";
title.textContent = `Page ${currentPage} of ${info.totalPages}`;
const subtitle = document.createElement("div");
subtitle.className = "pc-subtitle";
subtitle.textContent = info.totalItems
? `${info.totalItems.toLocaleString()} items, ${info.perPage} per page`
: "Using visible page links only";
titleWrap.append(title, subtitle);
const controls = document.createElement("form");
controls.className = "pc-controls";
if (currentPage > 1) {
addPageLink(controls, currentPage - 1, currentPage, "Previous");
} else {
addDisabledLink(controls, "Previous");
}
if (currentPage < info.totalPages) {
addPageLink(controls, currentPage + 1, currentPage, "Next");
} else {
addDisabledLink(controls, "Next");
}
const input = document.createElement("input");
input.className = "pc-input";
input.type = "number";
input.inputMode = "numeric";
input.min = "1";
input.max = String(info.totalPages);
input.placeholder = "Page";
const jump = document.createElement("button");
jump.className = "pc-button";
jump.type = "submit";
jump.textContent = "Go";
controls.addEventListener("submit", (event) => {
event.preventDefault();
const page = toNumber(input.value);
if (!Number.isInteger(page) || page < 1 || page > info.totalPages) return;
location.href = makePageUrl(page);
});
const collapse = document.createElement("button");
collapse.className = "pc-button";
collapse.type = "button";
collapse.textContent = "Hide";
collapse.addEventListener("click", () => {
setCollapsed(true);
render(info);
});
controls.append(input, jump, collapse);
top.append(titleWrap, controls);
const pages = document.createElement("nav");
pages.className = "pc-pages";
pages.setAttribute("aria-label", "Page numbers");
const range = pageRange(info.totalPages, currentPage);
let previousPage = 0;
for (const page of range) {
if (previousPage && page > previousPage + 1) addEllipsis(pages);
addPageLink(pages, page, currentPage);
previousPage = page;
}
panel.append(top, pages);
root.appendChild(panel);
}
function renderCollapsed(root, info) {
const panel = document.createElement("section");
panel.className = "pc-panel pc-collapsed";
const open = document.createElement("button");
open.className = "pc-button pc-open";
open.type = "button";
open.textContent = `Page ${getCurrentPage()} / ${info.totalPages}`;
open.addEventListener("click", () => {
setCollapsed(false);
render(info);
});
panel.appendChild(open);
root.appendChild(panel);
}
function render(info) {
document.getElementById(HOST_ID)?.remove();
if (!info || info.totalPages <= 1) return;
const host = document.createElement("div");
host.id = HOST_ID;
const shadow = host.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = buildStyles();
shadow.appendChild(style);
if (isCollapsed()) {
renderCollapsed(shadow, info);
} else {
renderExpanded(shadow, info);
}
document.body.appendChild(host);
}
async function tryRender() {
const info = await getPaginationInfo();
render(info);
return Boolean(info);
}
async function boot() {
if (await tryRender()) return;
let attempts = 0;
const maxAttempts = 8;
let pending = false;
const timer = setInterval(async () => {
if (pending) return;
pending = true;
attempts += 1;
try {
if ((await tryRender()) || attempts >= maxAttempts) {
clearInterval(timer);
}
} finally {
pending = false;
}
}, 500);
}
boot();
})();