A Tampermonkey script for specific comic sites that fits images to the viewport and enables precise image-by-image scrolling.
// ==UserScript==
// @name Magazine Comic Viewer Helper
// @name:ja マガジン・コミック・ビューア・ヘルパー
// @author kuchida1981
// @namespace https://github.com/kuchida1981/comic-viewer-helper
// @version 1.3.0-unstable.5e7f525
// @description A Tampermonkey script for specific comic sites that fits images to the viewport and enables precise image-by-image scrolling.
// @description:ja 特定の漫画サイトで画像をビューポートに合わせ、画像単位のスクロールを可能にするユーザースクリプトです。
// @license ISC
// @match https://something/magazine/*
// @match https://something/fanzine/*
// @run-at document-idle
// @grant none
// ==/UserScript==
/**
* ⚠️ DO NOT EDIT THIS FILE DIRECTLY ⚠️
* This file is automatically generated by the build process.
* Please edit files in the `src/` directory instead and run `npm run build`.
*/
(function() {
"use strict";
const STORAGE_KEYS = {
DUAL_VIEW: "comic-viewer-helper-dual-view",
GUI_POS: "comic-viewer-helper-gui-pos",
ENABLED: "comic-viewer-helper-enabled"
};
class Store {
constructor() {
this.state = {
enabled: localStorage.getItem(STORAGE_KEYS.ENABLED) !== "false",
isDualViewEnabled: localStorage.getItem(STORAGE_KEYS.DUAL_VIEW) === "true",
spreadOffset: 0,
currentVisibleIndex: 0,
guiPos: this._loadGuiPos(),
metadata: {
title: "",
tags: [],
relatedWorks: []
},
isMetadataModalOpen: false,
isHelpModalOpen: false
};
this.listeners = [];
}
/**
* @returns {StoreState}
*/
getState() {
return { ...this.state };
}
/**
* @param {Partial<StoreState>} patch
*/
setState(patch) {
let changed = false;
for (const key in patch) {
const k = (
/** @type {keyof StoreState} */
key
);
if (this.state[k] !== patch[k]) {
this.state[k] = patch[k];
changed = true;
}
}
if (!changed) return;
if ("enabled" in patch) {
localStorage.setItem(STORAGE_KEYS.ENABLED, String(patch.enabled));
}
if ("isDualViewEnabled" in patch) {
localStorage.setItem(STORAGE_KEYS.DUAL_VIEW, String(patch.isDualViewEnabled));
}
if ("guiPos" in patch) {
localStorage.setItem(STORAGE_KEYS.GUI_POS, JSON.stringify(patch.guiPos));
}
this._notify();
}
/**
* @param {Function} callback
*/
subscribe(callback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter((l) => l !== callback);
};
}
_notify() {
this.listeners.forEach((callback) => callback(this.getState()));
}
_loadGuiPos() {
try {
const saved = localStorage.getItem(STORAGE_KEYS.GUI_POS);
if (!saved) return null;
const pos = JSON.parse(saved);
const buffer = 50;
if (pos.left < -buffer || pos.left > window.innerWidth + buffer || pos.top < -buffer || pos.top > window.innerHeight + buffer) {
return null;
}
return pos;
} catch {
return null;
}
}
}
const CONTAINER_SELECTOR = "#post-comic";
const DefaultAdapter = {
// Always match as a fallback (should be checked last)
match: () => true,
getContainer: () => (
/** @type {HTMLElement | null} */
document.querySelector(CONTAINER_SELECTOR)
),
getImages: () => (
/** @type {HTMLImageElement[]} */
Array.from(document.querySelectorAll(`${CONTAINER_SELECTOR} img`))
),
getMetadata: () => {
const title = document.querySelector("h1")?.textContent?.trim() || "Unknown Title";
const tags = Array.from(document.querySelectorAll("#post-tag a")).map((a) => ({
text: a.textContent?.trim() || "",
href: (
/** @type {HTMLAnchorElement} */
a.href
)
}));
const relatedWorks = Array.from(document.querySelectorAll(".post-list-image")).map((el) => {
const anchor = el.closest("a");
const img = el.querySelector("img");
const titleEl = el.querySelector("span") || anchor?.querySelector("span");
return {
title: titleEl?.textContent?.trim() || "Untitled",
href: anchor?.href || "",
thumb: img?.src || ""
};
});
return { title, tags, relatedWorks };
}
};
function calculateVisibleHeight(rect, windowHeight) {
const visibleTop = Math.max(0, rect.top);
const visibleBottom = Math.min(windowHeight, rect.bottom);
return Math.max(0, visibleBottom - visibleTop);
}
function shouldPairWithNext(current, next, isDualViewEnabled) {
if (!isDualViewEnabled) return false;
if (current.isLandscape) return false;
if (!next) return false;
if (next.isLandscape) return false;
return true;
}
function getPrimaryVisibleImageIndex(imgs, windowHeight) {
if (imgs.length === 0) return -1;
let maxVisibleHeight = 0;
let minDistanceToCenter = Infinity;
let primaryIndex = -1;
const viewportCenter = windowHeight / 2;
imgs.forEach((img, index) => {
const rect = img.getBoundingClientRect();
const visibleHeight = calculateVisibleHeight(rect, windowHeight);
if (visibleHeight > 0) {
const elementCenter = (rect.top + rect.bottom) / 2;
const distanceToCenter = Math.abs(viewportCenter - elementCenter);
if (visibleHeight > maxVisibleHeight || visibleHeight === maxVisibleHeight && distanceToCenter < minDistanceToCenter) {
maxVisibleHeight = visibleHeight;
minDistanceToCenter = distanceToCenter;
primaryIndex = index;
}
}
});
return primaryIndex;
}
function getImageElementByIndex(imgs, index) {
if (index < 0 || index >= imgs.length) return null;
return imgs[index];
}
function cleanupDOM(container) {
const allImages = (
/** @type {HTMLImageElement[]} */
Array.from(container.querySelectorAll("img"))
);
const wrappers = container.querySelectorAll(".comic-row-wrapper");
wrappers.forEach((w) => w.remove());
allImages.forEach((img) => {
img.style.cssText = "";
});
return allImages;
}
function fitImagesToViewport(container, spreadOffset = 0, isDualViewEnabled = false) {
if (!container) return;
const allImages = cleanupDOM(container);
const vw = window.innerWidth;
const vh = window.innerHeight;
Object.assign(container.style, {
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "0",
margin: "0",
width: "100%",
maxWidth: "none"
});
for (let i = 0; i < allImages.length; i++) {
const img = allImages[i];
const isLandscape = img.naturalWidth > img.naturalHeight;
let pairWithNext = false;
const effectiveIndex = i - spreadOffset;
const isPairingPosition = effectiveIndex >= 0 && effectiveIndex % 2 === 0;
if (isDualViewEnabled && isPairingPosition && i + 1 < allImages.length) {
const nextImg = allImages[i + 1];
const nextIsLandscape = nextImg.naturalWidth > nextImg.naturalHeight;
if (shouldPairWithNext({ isLandscape }, { isLandscape: nextIsLandscape }, isDualViewEnabled)) {
pairWithNext = true;
}
}
const row = document.createElement("div");
row.className = "comic-row-wrapper";
Object.assign(row.style, {
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100vw",
maxWidth: "100vw",
marginLeft: "calc(50% - 50vw)",
marginRight: "calc(50% - 50vw)",
height: "100vh",
marginBottom: "0",
position: "relative",
boxSizing: "border-box"
});
if (pairWithNext) {
const nextImg = allImages[i + 1];
row.style.flexDirection = "row-reverse";
[img, nextImg].forEach((im) => {
Object.assign(im.style, {
maxWidth: "50%",
maxHeight: "100%",
width: "auto",
height: "auto",
objectFit: "contain",
margin: "0",
display: "block"
});
});
row.appendChild(img);
row.appendChild(nextImg);
container.appendChild(row);
i++;
} else {
Object.assign(img.style, {
maxWidth: `${vw}px`,
maxHeight: `${vh}px`,
width: "auto",
height: "auto",
display: "block",
margin: "0 auto",
flexShrink: "0",
objectFit: "contain"
});
row.appendChild(img);
container.appendChild(row);
}
}
}
function revertToOriginal(originalImages, container) {
if (!container) return;
container.style.cssText = "";
originalImages.forEach((img) => {
img.style.cssText = "";
container.appendChild(img);
});
const wrappers = container.querySelectorAll(".comic-row-wrapper");
wrappers.forEach((w) => w.remove());
}
function getNavigationDirection(event, threshold = 50) {
if (Math.abs(event.deltaY) < threshold) {
return "none";
}
return event.deltaY > 0 ? "next" : "prev";
}
class Navigator {
/**
* @param {import('../global').SiteAdapter} adapter
* @param {import('../store.js').Store} store
*/
constructor(adapter, store) {
this.adapter = adapter;
this.store = store;
this.originalImages = [];
this.getImages = this.getImages.bind(this);
this.jumpToPage = this.jumpToPage.bind(this);
this.scrollToImage = this.scrollToImage.bind(this);
this.scrollToEdge = this.scrollToEdge.bind(this);
this.applyLayout = this.applyLayout.bind(this);
this.updatePageCounter = this.updatePageCounter.bind(this);
this.init = this.init.bind(this);
this._lastEnabled = void 0;
this._lastDualView = void 0;
this._lastSpreadOffset = void 0;
}
init() {
this.store.subscribe((state) => {
const layoutChanged = state.enabled !== this._lastEnabled || state.isDualViewEnabled !== this._lastDualView || state.spreadOffset !== this._lastSpreadOffset;
if (layoutChanged) {
this.applyLayout();
this._lastEnabled = state.enabled;
this._lastDualView = state.isDualViewEnabled;
this._lastSpreadOffset = state.spreadOffset;
}
});
const initialState = this.store.getState();
this._lastEnabled = initialState.enabled;
this._lastDualView = initialState.isDualViewEnabled;
this._lastSpreadOffset = initialState.spreadOffset;
const imgs = this.getImages();
imgs.forEach((img) => {
if (!img.complete) {
img.addEventListener("load", () => {
requestAnimationFrame(() => this.applyLayout());
});
}
});
if (initialState.enabled) {
this.applyLayout();
}
}
/**
* @returns {HTMLImageElement[]}
*/
getImages() {
if (this.originalImages.length > 0) return this.originalImages;
this.originalImages = this.adapter.getImages();
return this.originalImages;
}
updatePageCounter() {
const state = this.store.getState();
const { enabled } = state;
if (!enabled) return;
const imgs = this.getImages();
const currentIndex = getPrimaryVisibleImageIndex(imgs, window.innerHeight);
if (currentIndex !== -1) {
this.store.setState({ currentVisibleIndex: currentIndex });
}
}
/**
* @param {string | number} pageNumber
* @returns {boolean}
*/
jumpToPage(pageNumber) {
const imgs = this.getImages();
const index = typeof pageNumber === "string" ? parseInt(pageNumber, 10) - 1 : pageNumber - 1;
const targetImg = getImageElementByIndex(imgs, index);
if (targetImg) {
targetImg.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
} else {
this.updatePageCounter();
return false;
}
}
/**
* @param {number} direction
*/
scrollToImage(direction) {
const imgs = this.getImages();
if (imgs.length === 0) return;
const { isDualViewEnabled } = this.store.getState();
const currentIndex = getPrimaryVisibleImageIndex(imgs, window.innerHeight);
let targetIndex = currentIndex + direction;
if (targetIndex < 0) targetIndex = 0;
if (targetIndex >= imgs.length) targetIndex = imgs.length - 1;
const prospectiveTargetImg = imgs[targetIndex];
if (isDualViewEnabled && direction !== 0 && currentIndex !== -1) {
const currentImg = imgs[currentIndex];
if (currentImg && prospectiveTargetImg && prospectiveTargetImg.parentElement === currentImg.parentElement && prospectiveTargetImg.parentElement?.classList.contains("comic-row-wrapper")) {
targetIndex += direction;
}
}
const finalIndex = Math.max(0, Math.min(targetIndex, imgs.length - 1));
const finalTarget = imgs[finalIndex];
if (finalTarget) {
finalTarget.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
/**
* @param {'start' | 'end'} position
*/
scrollToEdge(position) {
const imgs = this.getImages();
if (imgs.length === 0) return;
const target = position === "start" ? imgs[0] : imgs[imgs.length - 1];
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
/**
* @param {number} [forcedIndex]
*/
applyLayout(forcedIndex) {
const { enabled, isDualViewEnabled, spreadOffset } = this.store.getState();
const container = this.adapter.getContainer();
if (!container) return;
if (!enabled) {
revertToOriginal(this.getImages(), container);
return;
}
const imgs = this.getImages();
const currentIndex = forcedIndex !== void 0 ? forcedIndex : getPrimaryVisibleImageIndex(imgs, window.innerHeight);
fitImagesToViewport(container, spreadOffset, isDualViewEnabled);
this.updatePageCounter();
if (currentIndex !== -1) {
const targetImg = imgs[currentIndex];
if (targetImg) targetImg.scrollIntoView({ block: "center" });
}
}
}
const styles = `
#comic-helper-ui {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10000;
display: flex;
gap: 8px;
background-color: rgba(0, 0, 0, 0.7);
padding: 8px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
cursor: move;
user-select: none;
touch-action: none;
align-items: center;
white-space: nowrap;
width: max-content;
opacity: 0.3;
transition: opacity 0.3s;
}
#comic-helper-ui:hover {
opacity: 1.0;
}
.comic-helper-button {
cursor: pointer;
padding: 6px 12px;
border: none;
background: #fff;
color: #333;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
min-width: 50px;
transition: background 0.2s;
}
.comic-helper-button:hover {
background: #eee;
}
.comic-helper-power-btn {
cursor: pointer;
border: none;
background: transparent;
font-size: 16px;
padding: 0 4px;
font-weight: bold;
transition: color 0.2s;
}
.comic-helper-power-btn.enabled { color: #4CAF50; }
.comic-helper-power-btn.disabled { color: #888; }
.comic-helper-counter-wrapper {
color: #fff;
font-size: 14px;
font-weight: bold;
padding: 0 8px;
display: flex;
align-items: center;
user-select: none;
}
.comic-helper-page-input {
width: 45px;
background: transparent;
border: 1px solid transparent;
color: #fff;
font-size: 14px;
font-weight: bold;
text-align: right;
padding: 2px;
outline: none;
margin: 0;
transition: border 0.2s, background 0.2s;
}
.comic-helper-page-input:focus {
border: 1px solid #fff;
background: rgba(255, 255, 255, 0.1);
}
/* Hide spin buttons */
.comic-helper-page-input::-webkit-outer-spin-button,
.comic-helper-page-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.comic-helper-page-input[type=number] {
-moz-appearance: textfield;
}
.comic-helper-label {
display: flex;
align-items: center;
color: #fff;
font-size: 12px;
cursor: pointer;
user-select: none;
margin-right: 8px;
}
.comic-helper-label input {
margin-right: 4px;
}
.comic-helper-adjust-btn {
cursor: pointer;
padding: 2px 6px;
border: 1px solid #fff;
background: transparent;
color: #fff;
border-radius: 4px;
font-size: 10px;
margin-left: 4px;
font-weight: normal;
transition: background 0.2s;
}
.comic-helper-adjust-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Metadata Modal Styles */
.comic-helper-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 20000;
display: flex;
justify-content: center;
align-items: center;
}
.comic-helper-modal-content {
background: #1a1a1a;
color: #eee;
width: 80%;
max-width: 800px;
max-height: 80%;
padding: 24px;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
overflow-y: auto;
position: relative;
border: 1px solid #333;
}
.comic-helper-modal-close {
position: absolute;
top: 16px;
right: 16px;
background: transparent;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
line-height: 1;
}
.comic-helper-modal-close:hover {
color: #fff;
}
.comic-helper-modal-title {
margin-top: 0;
margin-bottom: 20px;
font-size: 20px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.comic-helper-section-title {
font-size: 14px;
color: #888;
margin: 20px 0 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
.comic-helper-tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.comic-helper-tag-chip {
background: #333;
color: #ccc;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.comic-helper-tag-chip:hover {
background: #444;
color: #fff;
}
.comic-helper-related-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
margin-top: 10px;
}
.comic-helper-related-item {
text-decoration: none;
color: #ccc;
font-size: 11px;
transition: transform 0.2s;
}
.comic-helper-related-item:hover {
transform: translateY(-4px);
}
.comic-helper-related-thumb {
width: 100%;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 4px;
background: #222;
margin-bottom: 6px;
}
.comic-helper-related-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
/* Help Modal Styles */
.comic-helper-shortcut-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.comic-helper-shortcut-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #222;
}
.comic-helper-shortcut-keys {
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: 40%;
}
.comic-helper-kbd {
background: #444;
border: 1px solid #555;
border-radius: 4px;
box-shadow: 0 1px 0 rgba(0,0,0,0.2), 0 0 0 2px #333 inset;
color: #eee;
display: inline-block;
font-size: 11px;
font-family: monospace;
line-height: 1.4;
margin: 0 2px;
padding: 2px 6px;
white-space: nowrap;
}
.comic-helper-shortcut-label {
color: #eee;
font-size: 13px;
font-weight: bold;
flex: 1;
margin: 0 12px;
}
.comic-helper-shortcut-desc {
color: #bbb;
font-size: 13px;
flex: 1;
}
`;
function injectStyles() {
const id = "comic-helper-style";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = styles;
document.head.appendChild(style);
}
function createElement(tag, options = {}, children = []) {
const el = document.createElement(tag);
if (options.id) el.id = options.id;
if (options.className) el.className = options.className;
if (options.textContent) el.textContent = options.textContent;
if (options.title) el.title = options.title;
if (el instanceof HTMLInputElement) {
if (options.type) el.type = options.type;
if (options.checked !== void 0) el.checked = options.checked;
}
if (options.style) {
Object.assign(el.style, options.style);
}
if (options.attributes) {
for (const [key, value] of Object.entries(options.attributes)) {
el.setAttribute(key, String(value));
}
}
if (options.events) {
for (const [type, listener] of Object.entries(options.events)) {
el.addEventListener(type, listener);
}
}
children.forEach((child) => {
if (typeof child === "string") {
el.appendChild(document.createTextNode(child));
} else if (child instanceof HTMLElement) {
el.appendChild(child);
}
});
return el;
}
const MESSAGES = {
en: {
ui: {
spread: "Spread",
offset: "Offset",
info: "Info",
help: "Help",
close: "Close",
tags: "Tags",
related: "Related Works",
version: "Version",
stable: "stable",
unstable: "unstable",
keyboardShortcuts: "Keyboard Shortcuts",
goFirst: "Go to First",
goPrev: "Go to Previous",
goNext: "Go to Next",
goLast: "Go to Last",
showMetadata: "Show Metadata",
showHelp: "Show Help",
shiftOffset: "Shift spread pairing by 1 page (Offset)",
space: "Space",
enable: "Enable Comic Viewer Helper",
disable: "Disable Comic Viewer Helper"
},
shortcuts: {
nextPage: { label: "Next Page", desc: "Move to next page" },
prevPage: { label: "Prev Page", desc: "Move to previous page" },
dualView: { label: "Dual View", desc: "Toggle Dual View" },
spreadOffset: { label: "Spread Offset", desc: "Toggle Offset (0 ↔ 1)", cond: "Dual View only" },
metadata: { label: "Metadata", desc: "Show metadata" },
help: { label: "Help", desc: "Show this help" },
closeModal: { label: "Close Modal", desc: "Close modal" }
}
},
ja: {
ui: {
spread: "見開き",
offset: "オフセット",
info: "作品情報",
help: "ヘルプ",
close: "閉じる",
tags: "タグ",
related: "関連作品",
version: "バージョン",
stable: "安定版",
unstable: "開発版",
keyboardShortcuts: "キーボードショートカット",
goFirst: "最初へ",
goPrev: "前へ",
goNext: "次へ",
goLast: "最後へ",
showMetadata: "作品情報を表示",
showHelp: "ヘルプを表示",
shiftOffset: "見開きペアを1ページ分ずらす(オフセット)",
space: "スペース",
enable: "スクリプトを有効にする",
disable: "スクリプトを無効にする"
},
shortcuts: {
nextPage: { label: "次ページ", desc: "次のページへ移動" },
prevPage: { label: "前ページ", desc: "前のページへ移動" },
dualView: { label: "見開き", desc: "見開きモードのON/OFF" },
spreadOffset: { label: "見開きオフセット", desc: "見開きオフセットの切替 (0 ↔ 1)", cond: "見開きモード中のみ" },
metadata: { label: "作品情報", desc: "作品情報(メタデータ)の表示" },
help: { label: "ヘルプ", desc: "このヘルプの表示" },
closeModal: { label: "閉じる", desc: "モーダルを閉じる" }
}
}
};
const getLanguage = () => {
const lang = (navigator.language || "en").split("-")[0];
return MESSAGES[lang] ? lang : "en";
};
const currentLang = getLanguage();
function t(path) {
const keys = path.split(".");
let result = MESSAGES[currentLang];
let fallback = MESSAGES["en"];
for (const key of keys) {
result = result ? result[key] : void 0;
fallback = fallback ? fallback[key] : void 0;
}
return result ?? fallback ?? path;
}
function createPowerButton({ isEnabled, onClick }) {
const el = createElement("button", {
className: `comic-helper-power-btn ${isEnabled ? "enabled" : "disabled"}`,
title: isEnabled ? t("ui.disable") : t("ui.enable"),
textContent: "⚡",
style: {
marginRight: isEnabled ? "8px" : "0"
},
events: {
click: (e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}
}
});
return {
el,
/** @param {boolean} enabled */
update: (enabled) => {
el.className = `comic-helper-power-btn ${enabled ? "enabled" : "disabled"}`;
el.title = enabled ? t("ui.disable") : t("ui.enable");
el.style.marginRight = enabled ? "8px" : "0";
}
};
}
function createPageCounter({ current, total, onJump }) {
const input = (
/** @type {HTMLInputElement} */
createElement("input", {
type: "number",
className: "comic-helper-page-input",
attributes: { min: 1 },
events: {
keydown: (e) => {
if (e instanceof KeyboardEvent && e.key === "Enter") {
e.preventDefault();
onJump(input.value);
}
},
focus: () => {
input.select();
}
}
})
);
input.value = String(current);
const totalLabel = createElement("span", {
id: "comic-total-counter",
textContent: ` / ${total}`
});
const el = createElement("span", {
className: "comic-helper-counter-wrapper"
}, [input, totalLabel]);
return {
el,
input,
/**
* @param {number} current
* @param {number} total
*/
update: (current2, total2) => {
if (document.activeElement !== input) {
input.value = String(current2);
}
totalLabel.textContent = ` / ${total2}`;
}
};
}
function createSpreadControls({ isDualViewEnabled, onToggle, onAdjust }) {
const checkbox = (
/** @type {HTMLInputElement} */
createElement("input", {
type: "checkbox",
checked: isDualViewEnabled,
events: {
change: (e) => {
if (e.target instanceof HTMLInputElement) {
onToggle(e.target.checked);
e.target.blur();
}
}
}
})
);
const label = createElement("label", {
className: "comic-helper-label"
}, [checkbox, "Spread"]);
const createAdjustBtn = () => createElement("button", {
className: "comic-helper-adjust-btn",
textContent: "Offset",
title: t("ui.shiftOffset"),
events: {
click: (e) => {
e.preventDefault();
e.stopPropagation();
onAdjust();
}
}
});
let adjustBtn = isDualViewEnabled ? createAdjustBtn() : null;
const el = createElement("div", {
style: { display: "flex", alignItems: "center" }
}, [label]);
if (adjustBtn) el.appendChild(adjustBtn);
return {
el,
/** @param {boolean} enabled */
update: (enabled) => {
checkbox.checked = enabled;
if (enabled) {
if (!adjustBtn) {
adjustBtn = createAdjustBtn();
el.appendChild(adjustBtn);
}
} else {
if (adjustBtn) {
adjustBtn.remove();
adjustBtn = null;
}
}
}
};
}
function createNavigationButtons({ onFirst, onPrev, onNext, onLast, onInfo, onHelp }) {
const configs = [
{ text: "<<", title: t("ui.goFirst"), action: onFirst },
{ text: "<", title: t("ui.goPrev"), action: onPrev },
{ text: ">", title: t("ui.goNext"), action: onNext },
{ text: ">>", title: t("ui.goLast"), action: onLast },
{ text: "Info", title: t("ui.showMetadata"), action: onInfo },
{ text: "?", title: t("ui.showHelp"), action: onHelp }
];
const elements = configs.map((cfg) => createElement("button", {
className: "comic-helper-button",
textContent: cfg.text,
title: cfg.title,
events: {
click: (e) => {
e.preventDefault();
e.stopPropagation();
cfg.action();
if (e.target instanceof HTMLElement) e.target.blur();
}
}
}));
return {
elements,
update: () => {
}
// No dynamic state for these buttons yet
};
}
function createMetadataModal({ metadata, onClose }) {
const { title, tags, relatedWorks } = metadata;
const closeBtn = createElement("button", {
className: "comic-helper-modal-close",
textContent: "×",
title: t("ui.close"),
events: {
click: (e) => {
e.preventDefault();
onClose();
}
}
});
const titleEl = createElement("h2", {
className: "comic-helper-modal-title",
textContent: title
});
const tagChips = tags.map((tag) => createElement("a", {
className: "comic-helper-tag-chip",
textContent: tag.text,
attributes: { href: tag.href, target: "_blank" },
events: {
click: (e) => e.stopPropagation()
}
}));
const tagSection = createElement("div", {}, [
createElement("div", { className: "comic-helper-section-title", textContent: t("ui.tags") }),
createElement("div", { className: "comic-helper-tag-list" }, tagChips)
]);
const relatedItems = relatedWorks.map((work) => {
const thumb = createElement("img", {
className: "comic-helper-related-thumb",
attributes: { src: work.thumb, loading: "lazy" }
});
const workTitle = createElement("div", {
className: "comic-helper-related-title",
textContent: work.title
});
return createElement("a", {
className: "comic-helper-related-item",
attributes: { href: work.href, target: "_blank" },
events: {
click: (e) => e.stopPropagation()
}
}, [thumb, workTitle]);
});
const relatedSection = createElement("div", {}, [
createElement("div", { className: "comic-helper-section-title", textContent: t("ui.related") }),
createElement("div", { className: "comic-helper-related-grid" }, relatedItems)
]);
const versionTag = createElement("div", {
className: "comic-helper-modal-version",
style: {
fontSize: "11px",
color: "#888",
marginTop: "15px",
textAlign: "right",
borderTop: "1px solid #eee",
paddingTop: "5px"
},
textContent: `${t("ui.version")}: v${"1.3.0-unstable.5e7f525"} (${t("ui.unstable")})`
});
const content = createElement("div", {
className: "comic-helper-modal-content",
events: {
click: (e) => e.stopPropagation()
}
}, [closeBtn, titleEl, tagSection, relatedSection, versionTag]);
const overlay = createElement("div", {
className: "comic-helper-modal-overlay",
events: {
click: (e) => {
e.preventDefault();
onClose();
}
}
}, [content]);
return {
el: overlay,
update: () => {
}
// No dynamic update needed once opened
};
}
const SHORTCUTS = [
{
id: "nextPage",
label: t("shortcuts.nextPage.label"),
keys: ["j", "ArrowDown", "PageDown", "ArrowRight", "Space"],
description: t("shortcuts.nextPage.desc")
},
{
id: "prevPage",
label: t("shortcuts.prevPage.label"),
keys: ["k", "ArrowUp", "PageUp", "ArrowLeft", "Shift+Space"],
description: t("shortcuts.prevPage.desc")
},
{
id: "dualView",
label: t("shortcuts.dualView.label"),
keys: ["d"],
description: t("shortcuts.dualView.desc")
},
{
id: "spreadOffset",
label: t("shortcuts.spreadOffset.label"),
keys: ["o"],
description: t("shortcuts.spreadOffset.desc"),
condition: t("shortcuts.spreadOffset.cond")
},
{
id: "metadata",
label: t("shortcuts.metadata.label"),
keys: ["i"],
description: t("shortcuts.metadata.desc")
},
{
id: "help",
label: t("shortcuts.help.label"),
keys: ["?"],
description: t("shortcuts.help.desc")
},
{
id: "closeModal",
label: t("shortcuts.closeModal.label"),
keys: ["Escape"],
description: t("shortcuts.closeModal.desc")
}
];
function createHelpModal({ onClose }) {
const closeBtn = createElement("button", {
className: "comic-helper-modal-close",
textContent: "×",
title: t("ui.close"),
events: {
click: (e) => {
e.preventDefault();
onClose();
}
}
});
const titleEl = createElement("h2", {
className: "comic-helper-modal-title",
textContent: t("ui.keyboardShortcuts")
});
const shortcutRows = SHORTCUTS.map((sc) => {
const keyLabels = sc.keys.map((k) => {
const label = k === " " ? t("ui.space") : k;
return createElement("kbd", { className: "comic-helper-kbd", textContent: label });
});
const keyContainer = createElement("div", { className: "comic-helper-shortcut-keys" }, keyLabels);
const labelEl = createElement("div", { className: "comic-helper-shortcut-label", textContent: sc.label });
const descText = sc.condition ? `${sc.description} (${sc.condition})` : sc.description;
const descEl = createElement("div", { className: "comic-helper-shortcut-desc", textContent: descText });
return createElement("div", { className: "comic-helper-shortcut-row" }, [keyContainer, labelEl, descEl]);
});
const shortcutList = createElement("div", { className: "comic-helper-shortcut-list" }, shortcutRows);
const content = createElement("div", {
className: "comic-helper-modal-content",
events: {
click: (e) => e.stopPropagation()
}
}, [closeBtn, titleEl, shortcutList]);
const overlay = createElement("div", {
className: "comic-helper-modal-overlay",
events: {
click: (e) => {
e.preventDefault();
onClose();
}
}
}, [content]);
return {
el: overlay,
update: () => {
}
};
}
class Draggable {
/**
* @param {HTMLElement} element
* @param {Object} options
* @param {Function} [options.onDragEnd]
*/
constructor(element, options = {}) {
this.element = element;
this.onDragEnd = options.onDragEnd || (() => {
});
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.initialTop = 0;
this.initialLeft = 0;
this._onMouseDown = this._onMouseDown.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
this.element.addEventListener("mousedown", this._onMouseDown);
}
/**
* @param {MouseEvent} e
*/
_onMouseDown(e) {
if (e.button !== 0 || !(e.target instanceof HTMLElement)) return;
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT") return;
this.isDragging = true;
const rect = this.element.getBoundingClientRect();
this.initialTop = rect.top;
this.initialLeft = rect.left;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
Object.assign(this.element.style, {
top: `${this.initialTop}px`,
left: `${this.initialLeft}px`,
bottom: "auto",
right: "auto"
});
document.addEventListener("mousemove", this._onMouseMove);
document.addEventListener("mouseup", this._onMouseUp);
e.preventDefault();
}
/**
* Clamp the element's position to keep it within the viewport
* @returns {{top: number, left: number}} The clamped position
*/
clampToViewport() {
const rect = this.element.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const padding = 10;
let top = rect.top;
let left = rect.left;
const maxTop = vh - rect.height - padding;
const maxLeft = vw - rect.width - padding;
top = Math.max(padding, Math.min(top, maxTop));
left = Math.max(padding, Math.min(left, maxLeft));
Object.assign(this.element.style, {
top: `${top}px`,
left: `${left}px`,
bottom: "auto",
right: "auto"
});
return { top, left };
}
/**
* @param {MouseEvent} e
*/
_onMouseMove(e) {
if (!this.isDragging) return;
const deltaX = e.clientX - this.dragStartX;
const deltaY = e.clientY - this.dragStartY;
this.element.style.top = `${this.initialTop + deltaY}px`;
this.element.style.left = `${this.initialLeft + deltaX}px`;
this.clampToViewport();
}
_onMouseUp() {
if (!this.isDragging) return;
this.isDragging = false;
document.removeEventListener("mousemove", this._onMouseMove);
document.removeEventListener("mouseup", this._onMouseUp);
const { top, left } = this.clampToViewport();
this.onDragEnd(top, left);
}
destroy() {
this.element.removeEventListener("mousedown", this._onMouseDown);
document.removeEventListener("mousemove", this._onMouseMove);
document.removeEventListener("mouseup", this._onMouseUp);
}
}
class UIManager {
/**
* @param {import('../global').SiteAdapter} adapter
* @param {import('../store.js').Store} store
* @param {import('./Navigator.js').Navigator} navigator
*/
constructor(adapter, store, navigator2) {
this.adapter = adapter;
this.store = store;
this.navigator = navigator2;
this.powerComp = null;
this.counterComp = null;
this.spreadComp = null;
this.draggable = null;
this.modalEl = null;
this.helpModalEl = null;
this.updateUI = this.updateUI.bind(this);
this.init = this.init.bind(this);
}
init() {
injectStyles();
this.updateUI();
this.store.subscribe(this.updateUI);
window.addEventListener("resize", () => {
if (this.draggable) {
const { top, left } = this.draggable.clampToViewport();
this.store.setState({ guiPos: { top, left } });
}
});
}
updateUI() {
const state = this.store.getState();
const { enabled, isDualViewEnabled, guiPos, currentVisibleIndex } = state;
let container = document.getElementById("comic-helper-ui");
if (!container) {
container = createElement("div", { id: "comic-helper-ui" });
if (guiPos) {
Object.assign(container.style, {
top: `${guiPos.top}px`,
left: `${guiPos.left}px`,
bottom: "auto",
right: "auto"
});
}
this.draggable = new Draggable(container, {
onDragEnd: (top, left) => this.store.setState({ guiPos: { top, left } })
});
document.body.appendChild(container);
}
if (!this.powerComp) {
this.powerComp = createPowerButton({
isEnabled: enabled,
onClick: () => {
const newState = !this.store.getState().enabled;
this.store.setState({ enabled: newState });
}
});
container.appendChild(this.powerComp.el);
}
const imgs = this.navigator.getImages();
if (!this.counterComp) {
this.counterComp = createPageCounter({
current: currentVisibleIndex + 1,
total: imgs.length,
onJump: (val) => {
const success = this.navigator.jumpToPage(val);
if (this.counterComp) {
this.counterComp.input.blur();
if (!success) {
this.counterComp.input.style.backgroundColor = "rgba(255, 0, 0, 0.3)";
setTimeout(() => {
if (this.counterComp) this.counterComp.input.style.backgroundColor = "";
}, 500);
}
}
}
});
container.appendChild(this.counterComp.el);
}
if (!this.spreadComp) {
this.spreadComp = createSpreadControls({
isDualViewEnabled,
onToggle: (val) => this.store.setState({ isDualViewEnabled: val }),
onAdjust: () => {
const { spreadOffset } = this.store.getState();
this.store.setState({ spreadOffset: spreadOffset === 0 ? 1 : 0 });
}
});
container.appendChild(this.spreadComp.el);
}
if (container.querySelectorAll(".comic-helper-button").length === 0) {
const navBtns = createNavigationButtons({
onFirst: () => this.navigator.scrollToEdge("start"),
onPrev: () => this.navigator.scrollToImage(-1),
onNext: () => this.navigator.scrollToImage(1),
onLast: () => this.navigator.scrollToEdge("end"),
onInfo: () => this.store.setState({ isMetadataModalOpen: true }),
onHelp: () => this.store.setState({ isHelpModalOpen: true })
});
navBtns.elements.forEach((btn) => container.appendChild(btn));
}
const { isMetadataModalOpen, isHelpModalOpen, metadata } = state;
if (isHelpModalOpen) {
if (!this.helpModalEl) {
const modal = createHelpModal({
onClose: () => this.store.setState({ isHelpModalOpen: false })
});
this.helpModalEl = modal.el;
document.body.appendChild(this.helpModalEl);
}
} else {
if (this.helpModalEl) {
this.helpModalEl.remove();
this.helpModalEl = null;
}
}
if (isMetadataModalOpen) {
if (!this.modalEl) {
const modal = createMetadataModal({
metadata,
onClose: () => this.store.setState({ isMetadataModalOpen: false })
});
this.modalEl = modal.el;
document.body.appendChild(this.modalEl);
}
} else {
if (this.modalEl) {
this.modalEl.remove();
this.modalEl = null;
}
}
this.powerComp.update(enabled);
if (!enabled) {
container.style.padding = "4px 8px";
this.counterComp.el.style.display = "none";
this.spreadComp.el.style.display = "none";
container.querySelectorAll(".comic-helper-button").forEach((btn) => {
btn.style.display = "none";
});
return;
}
container.style.padding = "8px";
this.counterComp.el.style.display = "flex";
this.spreadComp.el.style.display = "flex";
container.querySelectorAll(".comic-helper-button").forEach((btn) => {
btn.style.display = "inline-block";
});
this.counterComp.update(currentVisibleIndex + 1, imgs.length);
this.spreadComp.update(isDualViewEnabled);
}
}
class InputManager {
/**
* @param {import('../store.js').Store} store
* @param {import('./Navigator.js').Navigator} navigator
*/
constructor(store, navigator2) {
this.store = store;
this.navigator = navigator2;
this.lastWheelTime = 0;
this.WHEEL_THROTTLE_MS = 500;
this.WHEEL_THRESHOLD = 1;
this.resizeReq = void 0;
this.scrollReq = void 0;
this.handleWheel = this.handleWheel.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
}
init() {
window.addEventListener("wheel", this.handleWheel, { passive: false });
document.addEventListener("keydown", this.onKeyDown, true);
window.addEventListener("resize", this.handleResize);
window.addEventListener("scroll", this.handleScroll);
}
/**
* @param {EventTarget | null} target
* @returns {boolean}
*/
isInputField(target) {
if (!(target instanceof HTMLElement)) return false;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || !!target.isContentEditable;
}
/**
* @param {WheelEvent} e
*/
handleWheel(e) {
const { enabled, isDualViewEnabled, currentVisibleIndex, isMetadataModalOpen, isHelpModalOpen } = this.store.getState();
if (!enabled) return;
if (isMetadataModalOpen || isHelpModalOpen) {
const modalContent = document.querySelector(".comic-helper-modal-content");
if (modalContent && modalContent.contains(
/** @type {Node} */
e.target
)) {
return;
}
e.preventDefault();
return;
}
e.preventDefault();
const now = Date.now();
if (now - this.lastWheelTime < this.WHEEL_THROTTLE_MS) return;
const direction = getNavigationDirection(e, this.WHEEL_THRESHOLD);
if (direction === "none") return;
const imgs = this.navigator.getImages();
if (imgs.length === 0) return;
this.lastWheelTime = now;
const step = isDualViewEnabled ? 2 : 1;
const nextIndex = direction === "next" ? Math.min(currentVisibleIndex + step, imgs.length - 1) : Math.max(currentVisibleIndex - step, 0);
this.navigator.jumpToPage(nextIndex + 1);
}
/**
* @param {KeyboardEvent} e
*/
onKeyDown(e) {
if (this.isInputField(e.target) || e.ctrlKey || e.metaKey || e.altKey) return;
const { enabled, isDualViewEnabled, isMetadataModalOpen, isHelpModalOpen } = this.store.getState();
if (e.key === "Escape") {
if (isMetadataModalOpen || isHelpModalOpen) {
e.preventDefault();
this.store.setState({ isMetadataModalOpen: false, isHelpModalOpen: false });
return;
}
}
const isKey = (id) => {
const sc = SHORTCUTS.find((s) => s.id === id);
if (!sc) return false;
return sc.keys.some((k) => {
if (k.startsWith("Shift+")) {
const baseKey = k.replace("Shift+", "");
return e.shiftKey && e.key === (baseKey === "Space" ? " " : baseKey);
}
return !e.shiftKey && e.key === (k === "Space" ? " " : k);
});
};
if (isKey("help") && isHelpModalOpen) {
e.preventDefault();
this.store.setState({ isHelpModalOpen: false });
return;
}
if (isMetadataModalOpen || isHelpModalOpen || !enabled) return;
if (isKey("nextPage")) {
e.preventDefault();
this.navigator.scrollToImage(1);
} else if (isKey("prevPage")) {
e.preventDefault();
this.navigator.scrollToImage(-1);
} else if (isKey("dualView")) {
e.preventDefault();
this.store.setState({ isDualViewEnabled: !isDualViewEnabled });
} else if (isKey("spreadOffset") && isDualViewEnabled) {
e.preventDefault();
const { spreadOffset } = this.store.getState();
this.store.setState({ spreadOffset: spreadOffset === 0 ? 1 : 0 });
} else if (isKey("metadata")) {
e.preventDefault();
this.store.setState({ isMetadataModalOpen: !isMetadataModalOpen });
} else if (isKey("help")) {
e.preventDefault();
this.store.setState({ isHelpModalOpen: !isHelpModalOpen });
}
}
handleResize() {
const { enabled, currentVisibleIndex } = this.store.getState();
if (!enabled) return;
if (this.resizeReq) cancelAnimationFrame(this.resizeReq);
this.resizeReq = requestAnimationFrame(() => this.navigator.applyLayout(currentVisibleIndex));
}
handleScroll() {
if (!this.store.getState().enabled) return;
if (this.scrollReq) cancelAnimationFrame(this.scrollReq);
this.scrollReq = requestAnimationFrame(() => this.navigator.updatePageCounter());
}
}
class App {
constructor() {
this.store = new Store();
const adapters = [DefaultAdapter];
this.adapter = adapters.find((a) => a.match(window.location.href)) || DefaultAdapter;
this.navigator = new Navigator(this.adapter, this.store);
this.uiManager = new UIManager(this.adapter, this.store, this.navigator);
this.inputManager = new InputManager(this.store, this.navigator);
this.init = this.init.bind(this);
}
init() {
const container = this.adapter.getContainer();
if (!container) return;
const metadata = this.adapter.getMetadata?.() ?? { title: "Unknown Title", tags: [], relatedWorks: [] };
this.store.setState({ metadata });
this.navigator.init();
this.uiManager.init();
this.inputManager.init();
}
}
const app = new App();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", app.init);
} else {
app.init();
}
})();