1540 lines
48 KiB
HTML
1540 lines
48 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>Komga Reader</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
:root {
|
||
--bg-deep: #0a0a0c;
|
||
--bg-surface: #131318;
|
||
--bg-card: #1a1a22;
|
||
--bg-card-hover: #22222e;
|
||
--bg-input: #1e1e28;
|
||
--border: #2a2a38;
|
||
--border-focus: #5a5aff;
|
||
--text-primary: #e8e8f0;
|
||
--text-secondary: #8888a0;
|
||
--text-muted: #555568;
|
||
--accent: #6c6cff;
|
||
--accent-hover: #8080ff;
|
||
--accent-dim: rgba(108, 108, 255, 0.15);
|
||
--danger: #ff4466;
|
||
--green: #44cc88;
|
||
--radius: 8px;
|
||
--radius-lg: 12px;
|
||
--transition: 150ms ease;
|
||
}
|
||
|
||
html, body {
|
||
touch-action: pan-x pan-y;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
body {
|
||
font-family: 'DM Sans', sans-serif;
|
||
background: var(--bg-deep);
|
||
color: var(--text-primary);
|
||
overflow: hidden;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
::-webkit-scrollbar { width: 6px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||
|
||
/* ── Login Screen ── */
|
||
#login-screen {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
background: var(--bg-deep);
|
||
}
|
||
|
||
.login-box {
|
||
width: 380px;
|
||
padding: 40px;
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
|
||
.login-box h1 {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.login-box .subtitle {
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
margin-bottom: 28px;
|
||
}
|
||
|
||
.field { margin-bottom: 16px; }
|
||
|
||
.field label {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 6px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.field input {
|
||
width: 100%;
|
||
padding: 10px 14px;
|
||
background: var(--bg-input);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
color: var(--text-primary);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color var(--transition);
|
||
}
|
||
|
||
.field input:focus { border-color: var(--border-focus); }
|
||
.field input::placeholder { color: var(--text-muted); }
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: var(--radius);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all var(--transition);
|
||
}
|
||
|
||
.btn-primary {
|
||
width: 100%;
|
||
background: var(--accent);
|
||
color: #fff;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.btn-primary:hover { background: var(--accent-hover); }
|
||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
|
||
.login-error {
|
||
color: var(--danger);
|
||
font-size: 13px;
|
||
margin-top: 12px;
|
||
display: none;
|
||
}
|
||
|
||
/* ── App Layout ── */
|
||
#app {
|
||
display: none;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
/* ── Sidebar ── */
|
||
#sidebar {
|
||
position: fixed;
|
||
left: 0; top: 0; bottom: 0;
|
||
width: 320px;
|
||
background: var(--bg-surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 100;
|
||
transition: transform 300ms ease;
|
||
}
|
||
|
||
#sidebar.hidden { transform: translateX(-100%); }
|
||
|
||
.sidebar-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-header h2 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.2px;
|
||
}
|
||
|
||
.sidebar-header .server-info {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
|
||
.breadcrumb {
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.breadcrumb-item {
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
transition: color var(--transition);
|
||
}
|
||
|
||
.breadcrumb-item:hover { color: var(--accent-hover); }
|
||
|
||
.breadcrumb-item.current {
|
||
color: var(--text-primary);
|
||
cursor: default;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.breadcrumb-sep {
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.sidebar-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
color: var(--text-muted);
|
||
padding: 12px 12px 6px;
|
||
}
|
||
|
||
.section-divider {
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin: 8px 12px;
|
||
}
|
||
|
||
.list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
transition: background var(--transition);
|
||
border: 1px solid transparent;
|
||
}
|
||
|
||
.list-item:hover { background: var(--bg-card-hover); }
|
||
|
||
.list-item.active {
|
||
background: var(--accent-dim);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.list-item-thumb {
|
||
width: 48px;
|
||
height: 64px;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
background: var(--bg-card);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.list-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.list-item-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.list-item-meta {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.list-item-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: var(--radius);
|
||
background: var(--bg-card);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.progress-bar {
|
||
height: 3px;
|
||
background: var(--border);
|
||
border-radius: 2px;
|
||
margin-top: 5px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-bar-fill {
|
||
height: 100%;
|
||
background: var(--green);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding: 10px 16px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-footer-label {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
|
||
.ui-scale-controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.ui-scale-btn {
|
||
width: 28px; height: 28px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all var(--transition);
|
||
}
|
||
|
||
.ui-scale-btn:hover {
|
||
background: var(--bg-card-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.ui-scale-value {
|
||
font-size: 11px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
min-width: 36px;
|
||
text-align: center;
|
||
user-select: none;
|
||
}
|
||
|
||
/* ── Reader Area ── */
|
||
#reader-area {
|
||
position: fixed;
|
||
left: 320px; top: 0; right: 0; bottom: 0;
|
||
background: var(--bg-deep);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
transition: left 300ms ease;
|
||
}
|
||
|
||
#reader-area.full { left: 0; }
|
||
|
||
.reader-placeholder {
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.reader-placeholder .icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.reader-placeholder p { font-size: 14px; }
|
||
|
||
#image-container {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: none;
|
||
overflow: hidden;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
|
||
#image-container.grabbing { cursor: grabbing; }
|
||
|
||
#comic-image, #comic-canvas {
|
||
position: absolute;
|
||
transform-origin: 0 0;
|
||
max-width: none;
|
||
max-height: none;
|
||
user-select: none;
|
||
-webkit-user-drag: none;
|
||
}
|
||
|
||
/* ── Reader Controls ── */
|
||
#reader-controls {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
display: none;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px 12px;
|
||
background: rgba(19, 19, 24, 0.92);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid var(--border);
|
||
border-radius: 40px;
|
||
z-index: 200;
|
||
}
|
||
|
||
.ctrl-btn {
|
||
width: 36px; height: 36px;
|
||
border: none; border-radius: 50%;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all var(--transition);
|
||
font-family: inherit;
|
||
}
|
||
|
||
.ctrl-btn:hover {
|
||
background: var(--bg-card-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.ctrl-divider {
|
||
width: 1px; height: 20px;
|
||
background: var(--border);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.page-indicator, .zoom-indicator {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-weight: 600;
|
||
user-select: none;
|
||
text-align: center;
|
||
}
|
||
|
||
.page-indicator {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
padding: 0 10px;
|
||
min-width: 60px;
|
||
}
|
||
|
||
.zoom-indicator {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
padding: 0 6px;
|
||
min-width: 48px;
|
||
}
|
||
|
||
.fit-dropdown { position: relative; }
|
||
|
||
.fit-menu {
|
||
position: absolute;
|
||
bottom: 44px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 4px;
|
||
display: none;
|
||
min-width: 140px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.fit-menu.open { display: block; }
|
||
|
||
.fit-option {
|
||
display: block;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none; border-radius: 4px;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: all var(--transition);
|
||
}
|
||
|
||
.fit-option:hover {
|
||
background: var(--bg-card-hover);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.fit-option.active {
|
||
color: var(--accent);
|
||
background: var(--accent-dim);
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(10, 10, 12, 0.7);
|
||
z-index: 50;
|
||
}
|
||
|
||
.spinner {
|
||
width: 32px; height: 32px;
|
||
border: 2px solid var(--border);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.help-hint {
|
||
position: fixed;
|
||
bottom: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
background: rgba(19, 19, 24, 0.8);
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
z-index: 150;
|
||
display: none;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
}
|
||
|
||
kbd {
|
||
display: inline-block;
|
||
padding: 1px 5px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 10px;
|
||
}
|
||
/* ── Shortcuts modal ── */
|
||
#shortcuts-modal {
|
||
position: fixed;
|
||
bottom: 80px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--bg-surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 20px 24px;
|
||
z-index: 250;
|
||
display: none;
|
||
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
|
||
min-width: 340px;
|
||
}
|
||
|
||
#shortcuts-modal.open { display: block; }
|
||
|
||
.shortcuts-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
margin-bottom: 12px;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.shortcuts-grid {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 6px 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.shortcut-keys {
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.shortcut-desc {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.shortcut-section {
|
||
grid-column: 1 / -1;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--text-muted);
|
||
margin-top: 8px;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
.shortcut-section:first-child { margin-top: 0; }
|
||
|
||
/* ── Fullscreen mode ── */
|
||
body.is-fullscreen #sidebar {
|
||
display: none !important;
|
||
}
|
||
|
||
body.is-fullscreen #reader-area {
|
||
left: 0 !important;
|
||
}
|
||
|
||
body.is-fullscreen #reader-controls,
|
||
body.is-fullscreen #shortcuts-modal {
|
||
opacity: 0;
|
||
transition: opacity 300ms ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
body.is-fullscreen.show-controls #reader-controls,
|
||
body.is-fullscreen.show-controls #shortcuts-modal {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
body.is-fullscreen .help-hint {
|
||
display: none !important;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Login Screen -->
|
||
<div id="login-screen">
|
||
<div class="login-box">
|
||
<h1>Komga Reader</h1>
|
||
<p class="subtitle">Connect to your Komga server</p>
|
||
<div class="field">
|
||
<label>Server URL</label>
|
||
<input type="url" id="server-url" placeholder="http://localhost:25600" autocomplete="url">
|
||
</div>
|
||
<div class="field">
|
||
<label>Username</label>
|
||
<input type="text" id="username" placeholder="user@email.com" autocomplete="username">
|
||
</div>
|
||
<div class="field">
|
||
<label>Password</label>
|
||
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password">
|
||
</div>
|
||
<button class="btn btn-primary" id="login-btn">Connect</button>
|
||
<div class="login-error" id="login-error"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main App -->
|
||
<div id="app">
|
||
<div id="sidebar">
|
||
<div class="sidebar-header">
|
||
<div>
|
||
<h2>Komga Reader</h2>
|
||
<div class="server-info" id="server-display"></div>
|
||
</div>
|
||
</div>
|
||
<div class="breadcrumb" id="breadcrumb"></div>
|
||
<div class="sidebar-content" id="sidebar-list"></div>
|
||
<div class="sidebar-footer">
|
||
<span class="sidebar-footer-label">UI Scale</span>
|
||
<div class="ui-scale-controls">
|
||
<button class="ui-scale-btn" id="ui-scale-down" title="Decrease UI size">−</button>
|
||
<span class="ui-scale-value" id="ui-scale-value">100%</span>
|
||
<button class="ui-scale-btn" id="ui-scale-up" title="Increase UI size">+</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="reader-area">
|
||
<div class="reader-placeholder" id="reader-placeholder">
|
||
<div class="icon">📖</div>
|
||
<p>Select a book from the sidebar to start reading</p>
|
||
</div>
|
||
<div id="image-container">
|
||
<img id="comic-image" alt="Comic page">
|
||
<canvas id="comic-canvas" style="display:none;"></canvas>
|
||
<div class="loading-overlay" id="loading-overlay">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="shortcuts-modal">
|
||
<div class="shortcuts-title">Keyboard Shortcuts</div>
|
||
<div class="shortcuts-grid">
|
||
<div class="shortcut-section">Navigation</div>
|
||
<div class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>←</kbd> <kbd>→</kbd></div>
|
||
<div class="shortcut-desc">Previous / next page</div>
|
||
<div class="shortcut-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>↑</kbd> <kbd>↓</kbd></div>
|
||
<div class="shortcut-desc">Pan</div>
|
||
<div class="shortcut-section">Zoom</div>
|
||
<div class="shortcut-keys">scroll</div>
|
||
<div class="shortcut-desc">Zoom at cursor</div>
|
||
<div class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>↑</kbd> <kbd>↓</kbd></div>
|
||
<div class="shortcut-desc">Zoom in / out</div>
|
||
<div class="shortcut-keys"><kbd>+</kbd> <kbd>-</kbd></div>
|
||
<div class="shortcut-desc">Zoom in / out</div>
|
||
<div class="shortcut-keys"><kbd>0</kbd></div>
|
||
<div class="shortcut-desc">Reset zoom to fit</div>
|
||
<div class="shortcut-keys">double-click</div>
|
||
<div class="shortcut-desc">Reset zoom to fit</div>
|
||
<div class="shortcut-section">View</div>
|
||
<div class="shortcut-keys"><kbd>C</kbd></div>
|
||
<div class="shortcut-desc">Center page horizontally</div>
|
||
<div class="shortcut-keys"><kbd>D</kbd></div>
|
||
<div class="shortcut-desc">Toggle single / double page</div>
|
||
<div class="shortcut-keys"><kbd>F</kbd></div>
|
||
<div class="shortcut-desc">Toggle fullscreen</div>
|
||
<div class="shortcut-keys"><kbd>Tab</kbd></div>
|
||
<div class="shortcut-desc">Toggle sidebar</div>
|
||
<div class="shortcut-keys"><kbd>?</kbd></div>
|
||
<div class="shortcut-desc">Toggle this help</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="reader-controls">
|
||
<button class="ctrl-btn" id="btn-prev" title="Previous page (Ctrl+←)">◀</button>
|
||
<span class="page-indicator" id="page-indicator">-</span>
|
||
<button class="ctrl-btn" id="btn-next" title="Next page (Ctrl+→)">▶</button>
|
||
<div class="ctrl-divider"></div>
|
||
<button class="ctrl-btn" id="btn-zoom-out" title="Zoom out (-)">−</button>
|
||
<span class="zoom-indicator" id="zoom-indicator">100%</span>
|
||
<button class="ctrl-btn" id="btn-zoom-in" title="Zoom in (+)">+</button>
|
||
<button class="ctrl-btn" id="btn-zoom-reset" title="Reset zoom (0)">⊙</button>
|
||
<div class="ctrl-divider"></div>
|
||
<div class="fit-dropdown">
|
||
<button class="ctrl-btn" id="btn-fit" title="Fit mode">⊞</button>
|
||
<div class="fit-menu" id="fit-menu">
|
||
<button class="fit-option active" data-fit="width">Fit width</button>
|
||
<button class="fit-option" data-fit="height">Fit height</button>
|
||
<button class="fit-option" data-fit="both">Fit page</button>
|
||
<button class="fit-option" data-fit="original">Original size</button>
|
||
</div>
|
||
</div>
|
||
<div class="ctrl-divider"></div>
|
||
<button class="ctrl-btn" id="btn-page-mode" title="Toggle single/double page (D)">⊡</button>
|
||
<div class="ctrl-divider"></div>
|
||
<button class="ctrl-btn" id="btn-fullscreen" title="Fullscreen (F)">⛶</button>
|
||
<button class="ctrl-btn" id="btn-toggle-sidebar" title="Toggle sidebar (Tab)">☰</button>
|
||
<button class="ctrl-btn" id="btn-shortcuts" title="Keyboard shortcuts (?)">?</button>
|
||
</div>
|
||
|
||
<div class="help-hint" id="help-hint">
|
||
<kbd>←</kbd> <kbd>→</kbd> <kbd>↑</kbd> <kbd>↓</kbd> pan · <kbd>Ctrl</kbd>+<kbd>←</kbd> <kbd>→</kbd> prev/next page · scroll to zoom · drag to pan
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// BROWSER ZOOM PREVENTION
|
||
//
|
||
// Firefox handles trackpad pinch at the compositor level (APZ)
|
||
// BEFORE JavaScript can preventDefault. We use two strategies:
|
||
//
|
||
// 1. Intercept ctrl+wheel at capture phase (works in Chrome,
|
||
// partially in Firefox for mouse wheel)
|
||
// 2. Monitor visualViewport.scale and counteract any browser
|
||
// zoom by inverse-scaling the body (works in all browsers)
|
||
// ══════════════════════════════════════════════════════════════
|
||
|
||
// Strategy 1: capture phase interception
|
||
window.addEventListener('wheel', (e) => {
|
||
if (e.ctrlKey || e.metaKey) e.preventDefault();
|
||
}, { passive: false, capture: true });
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && ['+', '-', '=', '0'].includes(e.key)) e.preventDefault();
|
||
}, { passive: false, capture: true });
|
||
|
||
document.addEventListener('gesturestart', (e) => e.preventDefault(), { passive: false });
|
||
document.addEventListener('gesturechange', (e) => e.preventDefault(), { passive: false });
|
||
|
||
// Strategy 2: counteract via visualViewport
|
||
if (window.visualViewport) {
|
||
const vv = window.visualViewport;
|
||
|
||
function counteractZoom() {
|
||
// Only counteract browser-initiated zoom (pinch), not our intentional UI zoom
|
||
const browserScale = vv.scale;
|
||
if (Math.abs(browserScale - 1) > 0.01) {
|
||
const inv = 1 / browserScale;
|
||
document.body.style.transform = `scale(${inv})`;
|
||
document.body.style.transformOrigin = `${vv.offsetLeft}px ${vv.offsetTop}px`;
|
||
document.body.style.width = (browserScale * 100) + '%';
|
||
document.body.style.height = (browserScale * 100) + '%';
|
||
} else {
|
||
document.body.style.transform = '';
|
||
document.body.style.transformOrigin = '';
|
||
document.body.style.width = '';
|
||
document.body.style.height = '';
|
||
}
|
||
}
|
||
|
||
vv.addEventListener('resize', counteractZoom);
|
||
vv.addEventListener('scroll', counteractZoom);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// STATE
|
||
// ══════════════════════════════════════════════
|
||
let serverUrl = '';
|
||
let authHeader = '';
|
||
let currentView = 'libraries';
|
||
let currentLibrary = null;
|
||
let currentSeries = null;
|
||
let currentBook = null;
|
||
let currentPage = 0;
|
||
let totalPages = 0;
|
||
|
||
let scale = 1, panX = 0, panY = 0;
|
||
let isDragging = false, dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
|
||
let fitMode = 'width';
|
||
let naturalW = 0, naturalH = 0;
|
||
let pageMode = 'single'; // 'single' or 'double'
|
||
|
||
const el = id => document.getElementById(id);
|
||
const $loginScreen = el('login-screen');
|
||
const $app = el('app');
|
||
const $serverUrl = el('server-url');
|
||
const $username = el('username');
|
||
const $password = el('password');
|
||
const $loginBtn = el('login-btn');
|
||
const $loginError = el('login-error');
|
||
const $serverDisplay = el('server-display');
|
||
const $breadcrumb = el('breadcrumb');
|
||
const $sidebarList = el('sidebar-list');
|
||
const $sidebar = el('sidebar');
|
||
const $readerArea = el('reader-area');
|
||
const $readerPlaceholder = el('reader-placeholder');
|
||
const $imageContainer = el('image-container');
|
||
const $comicImage = el('comic-image');
|
||
const $readerControls = el('reader-controls');
|
||
const $pageIndicator = el('page-indicator');
|
||
const $zoomIndicator = el('zoom-indicator');
|
||
const $loadingOverlay = el('loading-overlay');
|
||
const $helpHint = el('help-hint');
|
||
const $fitMenu = el('fit-menu');
|
||
|
||
// ══════════════════════════════════════════════
|
||
// API
|
||
// ══════════════════════════════════════════════
|
||
async function api(path) {
|
||
const resp = await fetch(serverUrl.replace(/\/$/, '') + path, {
|
||
headers: { 'Authorization': authHeader }
|
||
});
|
||
if (!resp.ok) throw new Error(`API ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
function thumbnailUrl(type, id) {
|
||
return serverUrl.replace(/\/$/, '') + `/api/v1/${type}/${id}/thumbnail`;
|
||
}
|
||
|
||
function authImg(img, url) {
|
||
img.onerror = () => {
|
||
fetch(url, { headers: { 'Authorization': authHeader } })
|
||
.then(r => r.blob())
|
||
.then(blob => img.src = URL.createObjectURL(blob))
|
||
.catch(() => {});
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// LOGIN
|
||
// ══════════════════════════════════════════════
|
||
$loginBtn.addEventListener('click', doLogin);
|
||
document.querySelectorAll('#login-screen input').forEach(i =>
|
||
i.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); })
|
||
);
|
||
|
||
const saved = localStorage.getItem('komga-reader-conn');
|
||
if (saved) { try { const s = JSON.parse(saved); $serverUrl.value = s.url; $username.value = s.user; } catch(e) {} }
|
||
|
||
async function doLogin() {
|
||
const url = $serverUrl.value.trim();
|
||
const user = $username.value.trim();
|
||
const pass = $password.value;
|
||
if (!url || !user) return;
|
||
|
||
$loginBtn.disabled = true;
|
||
$loginBtn.textContent = 'Connecting…';
|
||
$loginError.style.display = 'none';
|
||
serverUrl = url;
|
||
authHeader = 'Basic ' + btoa(user + ':' + pass);
|
||
|
||
try {
|
||
await api('/api/v1/libraries');
|
||
localStorage.setItem('komga-reader-conn', JSON.stringify({ url, user }));
|
||
showApp();
|
||
} catch (e) {
|
||
$loginError.textContent = 'Connection failed. Check URL and credentials.';
|
||
$loginError.style.display = 'block';
|
||
} finally {
|
||
$loginBtn.disabled = false;
|
||
$loginBtn.textContent = 'Connect';
|
||
}
|
||
}
|
||
|
||
function showApp() {
|
||
$loginScreen.style.display = 'none';
|
||
$app.style.display = 'block';
|
||
try { $serverDisplay.textContent = new URL(serverUrl).host; } catch(e) { $serverDisplay.textContent = serverUrl; }
|
||
loadLibraries();
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// NAVIGATION
|
||
// ══════════════════════════════════════════════
|
||
async function loadLibraries() {
|
||
currentView = 'libraries';
|
||
currentLibrary = null;
|
||
currentSeries = null;
|
||
updateBreadcrumb();
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
|
||
|
||
try {
|
||
const [libs, keepReading] = await Promise.all([
|
||
api('/api/v1/libraries'),
|
||
api('/api/v1/books?read_status=IN_PROGRESS&sort=readProgress.readDate,desc&size=20')
|
||
.catch(() => ({ content: [] }))
|
||
]);
|
||
|
||
$sidebarList.innerHTML = '';
|
||
|
||
// ── Continue Reading section ──
|
||
const inProgress = keepReading.content || keepReading;
|
||
if (inProgress.length > 0) {
|
||
appendLabel('Continue Reading');
|
||
|
||
inProgress.forEach(book => {
|
||
const progress = book.readProgress;
|
||
const pagesCount = book.media?.pagesCount || 0;
|
||
const pct = pagesCount > 0 ? Math.round((progress.page / pagesCount) * 100) : 0;
|
||
const seriesTitle = book.seriesTitle || '';
|
||
|
||
$sidebarList.appendChild(makeListItem({
|
||
id: book.id,
|
||
title: book.metadata?.title || book.name,
|
||
meta: (seriesTitle ? seriesTitle + ' · ' : '') + `p.${progress.page}/${pagesCount} · ${pct}%`,
|
||
thumbUrl: thumbnailUrl('books', book.id),
|
||
onClick: () => openBook(book),
|
||
progress: pct,
|
||
}));
|
||
});
|
||
|
||
const divider = document.createElement('div');
|
||
divider.className = 'section-divider';
|
||
$sidebarList.appendChild(divider);
|
||
}
|
||
|
||
// ── Libraries section ──
|
||
appendLabel('Libraries');
|
||
libs.forEach(lib => {
|
||
$sidebarList.appendChild(makeListItem({
|
||
id: lib.id,
|
||
title: lib.name,
|
||
meta: lib.root,
|
||
icon: '📚',
|
||
onClick: () => loadSeries(lib),
|
||
}));
|
||
});
|
||
} catch(e) {
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
async function loadSeries(library) {
|
||
currentView = 'series';
|
||
currentLibrary = library;
|
||
currentSeries = null;
|
||
updateBreadcrumb();
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
|
||
|
||
try {
|
||
const data = await api(`/api/v1/series?library_id=${library.id}&size=500&sort=metadata.titleSort,asc`);
|
||
const list = data.content || data;
|
||
$sidebarList.innerHTML = '';
|
||
list.forEach(s => {
|
||
$sidebarList.appendChild(makeListItem({
|
||
id: s.id,
|
||
title: s.metadata?.title || s.name,
|
||
meta: `${s.booksCount || '?'} book${s.booksCount !== 1 ? 's' : ''}`,
|
||
thumbUrl: thumbnailUrl('series', s.id),
|
||
onClick: () => loadBooks(s),
|
||
}));
|
||
});
|
||
if (!list.length) $sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No series</div>';
|
||
} catch(e) {
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
async function loadBooks(series) {
|
||
currentView = 'books';
|
||
currentSeries = series;
|
||
updateBreadcrumb();
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">Loading…</div>';
|
||
|
||
try {
|
||
const data = await api(`/api/v1/series/${series.id}/books?sort=metadata.numberSort,asc&size=500`);
|
||
const list = data.content || data;
|
||
$sidebarList.innerHTML = '';
|
||
list.forEach(b => {
|
||
const pagesCount = b.media?.pagesCount || 0;
|
||
const progress = b.readProgress;
|
||
let meta = `${pagesCount} pages`;
|
||
let pct = null;
|
||
if (progress && !progress.completed && progress.page > 0) {
|
||
pct = pagesCount > 0 ? Math.round((progress.page / pagesCount) * 100) : 0;
|
||
meta += ` · ${pct}%`;
|
||
} else if (progress && progress.completed) {
|
||
meta += ' · done';
|
||
}
|
||
|
||
$sidebarList.appendChild(makeListItem({
|
||
id: b.id,
|
||
title: b.metadata?.title || b.name,
|
||
meta,
|
||
thumbUrl: thumbnailUrl('books', b.id),
|
||
onClick: () => openBook(b),
|
||
progress: pct,
|
||
}));
|
||
});
|
||
if (!list.length) $sidebarList.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:13px;">No books</div>';
|
||
} catch(e) {
|
||
$sidebarList.innerHTML = '<div style="padding:20px;color:var(--danger);font-size:13px;">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
function appendLabel(text) {
|
||
const l = document.createElement('div');
|
||
l.className = 'section-label';
|
||
l.textContent = text;
|
||
$sidebarList.appendChild(l);
|
||
}
|
||
|
||
function makeListItem(item) {
|
||
const div = document.createElement('div');
|
||
div.className = 'list-item';
|
||
if (currentBook && item.id === currentBook.id) div.classList.add('active');
|
||
|
||
if (item.thumbUrl) {
|
||
const img = document.createElement('img');
|
||
img.className = 'list-item-thumb';
|
||
img.loading = 'lazy';
|
||
authImg(img, item.thumbUrl);
|
||
div.appendChild(img);
|
||
} else if (item.icon) {
|
||
const icon = document.createElement('div');
|
||
icon.className = 'list-item-icon';
|
||
icon.textContent = item.icon;
|
||
div.appendChild(icon);
|
||
}
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'list-item-info';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'list-item-title';
|
||
title.textContent = item.title;
|
||
info.appendChild(title);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'list-item-meta';
|
||
meta.textContent = item.meta;
|
||
info.appendChild(meta);
|
||
|
||
if (item.progress != null && item.progress > 0 && item.progress < 100) {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'progress-bar';
|
||
const fill = document.createElement('div');
|
||
fill.className = 'progress-bar-fill';
|
||
fill.style.width = item.progress + '%';
|
||
bar.appendChild(fill);
|
||
info.appendChild(bar);
|
||
}
|
||
|
||
div.appendChild(info);
|
||
div.addEventListener('click', () => item.onClick());
|
||
return div;
|
||
}
|
||
|
||
function updateBreadcrumb() {
|
||
const parts = [{ label: 'Libraries', onClick: loadLibraries, current: currentView === 'libraries' }];
|
||
if (currentLibrary) parts.push({ label: currentLibrary.name, onClick: () => loadSeries(currentLibrary), current: currentView === 'series' });
|
||
if (currentSeries) parts.push({ label: currentSeries.metadata?.title || currentSeries.name, onClick: () => loadBooks(currentSeries), current: currentView === 'books' });
|
||
|
||
$breadcrumb.innerHTML = '';
|
||
parts.forEach((p, i) => {
|
||
if (i > 0) { const sep = document.createElement('span'); sep.className = 'breadcrumb-sep'; sep.textContent = '›'; $breadcrumb.appendChild(sep); }
|
||
const s = document.createElement('span');
|
||
s.className = 'breadcrumb-item' + (p.current ? ' current' : '');
|
||
s.textContent = p.label;
|
||
if (!p.current) s.addEventListener('click', p.onClick);
|
||
$breadcrumb.appendChild(s);
|
||
});
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// BOOK READER
|
||
// ══════════════════════════════════════════════
|
||
async function openBook(book) {
|
||
currentBook = book;
|
||
currentPage = 1;
|
||
document.querySelectorAll('.list-item').forEach(e => e.classList.remove('active'));
|
||
|
||
try {
|
||
const pages = await api(`/api/v1/books/${book.id}/pages`);
|
||
totalPages = pages.length;
|
||
|
||
try {
|
||
const bd = await api(`/api/v1/books/${book.id}`);
|
||
if (bd.readProgress && !bd.readProgress.completed) currentPage = bd.readProgress.page || 1;
|
||
} catch(e) {}
|
||
|
||
$readerPlaceholder.style.display = 'none';
|
||
$imageContainer.style.display = 'block';
|
||
$readerControls.style.display = 'flex';
|
||
$helpHint.style.display = 'block';
|
||
setTimeout(() => { $helpHint.style.display = 'none'; }, 4000);
|
||
loadPage(currentPage);
|
||
} catch(e) {
|
||
console.error('Failed to open book', e);
|
||
}
|
||
}
|
||
|
||
function fetchPageBlob(pageNum) {
|
||
const url = serverUrl.replace(/\/$/, '') + `/api/v1/books/${currentBook.id}/pages/${pageNum}`;
|
||
return fetch(url, { headers: { 'Authorization': authHeader } }).then(r => r.blob());
|
||
}
|
||
|
||
function blobToImage(blob) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const img = new Image();
|
||
img.onload = () => resolve({ img, url });
|
||
img.onerror = reject;
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
const $comicCanvas = el('comic-canvas');
|
||
let activeElement = $comicImage; // track which element is visible
|
||
|
||
function showElement(elem) {
|
||
$comicImage.style.display = elem === $comicImage ? '' : 'none';
|
||
$comicCanvas.style.display = elem === $comicCanvas ? '' : 'none';
|
||
activeElement = elem;
|
||
}
|
||
|
||
function loadPage(pageNum) {
|
||
if (pageNum < 1 || pageNum > totalPages) return;
|
||
currentPage = pageNum;
|
||
$loadingOverlay.style.display = 'flex';
|
||
|
||
if (pageMode === 'double') {
|
||
const leftPage = currentPage;
|
||
const rightPage = currentPage + 1 <= totalPages ? currentPage + 1 : null;
|
||
|
||
$pageIndicator.textContent = rightPage
|
||
? `${leftPage}-${rightPage} / ${totalPages}`
|
||
: `${leftPage} / ${totalPages}`;
|
||
|
||
const fetches = [fetchPageBlob(leftPage)];
|
||
if (rightPage) fetches.push(fetchPageBlob(rightPage));
|
||
|
||
Promise.all(fetches)
|
||
.then(blobs => Promise.all(blobs.map(blobToImage)))
|
||
.then(pages => {
|
||
const left = pages[0];
|
||
const right = pages[1];
|
||
|
||
const lw = left.img.naturalWidth, lh = left.img.naturalHeight;
|
||
const rw = right ? right.img.naturalWidth : 0;
|
||
const rh = right ? right.img.naturalHeight : 0;
|
||
const gap = 0;
|
||
const canvasW = lw + (right ? gap + rw : 0);
|
||
const canvasH = Math.max(lh, rh || 0);
|
||
|
||
$comicCanvas.width = canvasW;
|
||
$comicCanvas.height = canvasH;
|
||
const ctx = $comicCanvas.getContext('2d');
|
||
ctx.fillStyle = '#0a0a0c';
|
||
ctx.fillRect(0, 0, canvasW, canvasH);
|
||
ctx.drawImage(left.img, 0, (canvasH - lh) / 2);
|
||
if (right) ctx.drawImage(right.img, lw + gap, (canvasH - rh) / 2);
|
||
|
||
URL.revokeObjectURL(left.url);
|
||
if (right) URL.revokeObjectURL(right.url);
|
||
|
||
const prevW = naturalW;
|
||
naturalW = canvasW;
|
||
naturalH = canvasH;
|
||
showElement($comicCanvas);
|
||
|
||
if (!prevW) applyFitMode(); else centerAtCurrentScale();
|
||
$loadingOverlay.style.display = 'none';
|
||
updateReadProgress(rightPage || leftPage);
|
||
})
|
||
.catch(() => { $loadingOverlay.style.display = 'none'; });
|
||
} else {
|
||
$pageIndicator.textContent = `${currentPage} / ${totalPages}`;
|
||
|
||
fetchPageBlob(currentPage)
|
||
.then(blobToImage)
|
||
.then(({ img, url: objUrl }) => {
|
||
if ($comicImage.src.startsWith('blob:')) URL.revokeObjectURL($comicImage.src);
|
||
$comicImage.src = objUrl;
|
||
const prevW = naturalW;
|
||
naturalW = img.naturalWidth;
|
||
naturalH = img.naturalHeight;
|
||
showElement($comicImage);
|
||
|
||
if (!prevW) applyFitMode(); else centerAtCurrentScale();
|
||
$loadingOverlay.style.display = 'none';
|
||
updateReadProgress(currentPage);
|
||
})
|
||
.catch(() => { $loadingOverlay.style.display = 'none'; });
|
||
}
|
||
}
|
||
|
||
function pageStep() {
|
||
return pageMode === 'double' ? 2 : 1;
|
||
}
|
||
|
||
function updateReadProgress(page) {
|
||
fetch(serverUrl.replace(/\/$/, '') + `/api/v1/books/${currentBook.id}/read-progress`, {
|
||
method: 'PATCH',
|
||
headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ page, completed: page >= totalPages })
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// ══════════════════════════════════════════════
|
||
// ZOOM & PAN
|
||
// ══════════════════════════════════════════════
|
||
function applyFitMode() {
|
||
const cw = $imageContainer.clientWidth, ch = $imageContainer.clientHeight;
|
||
if (!naturalW || !naturalH) return;
|
||
|
||
switch (fitMode) {
|
||
case 'width': scale = cw / naturalW; break;
|
||
case 'height': scale = ch / naturalH; break;
|
||
case 'both': scale = Math.min(cw / naturalW, ch / naturalH); break;
|
||
case 'original': scale = 1; break;
|
||
default: scale = cw / naturalW;
|
||
}
|
||
|
||
panX = Math.max(0, (cw - naturalW * scale) / 2);
|
||
panY = Math.max(0, (ch - naturalH * scale) / 2);
|
||
updateTransform();
|
||
}
|
||
|
||
function centerAtCurrentScale() {
|
||
const cw = $imageContainer.clientWidth, ch = $imageContainer.clientHeight;
|
||
if (!naturalW || !naturalH) return;
|
||
const scaledW = naturalW * scale;
|
||
const scaledH = naturalH * scale;
|
||
panX = (cw - scaledW) / 2;
|
||
panY = Math.max(0, (ch - scaledH) / 2);
|
||
updateTransform();
|
||
}
|
||
|
||
function updateTransform() {
|
||
const t = `translate(${panX}px, ${panY}px) scale(${scale})`;
|
||
activeElement.style.transform = t;
|
||
$zoomIndicator.textContent = Math.round(scale * 100) + '%';
|
||
}
|
||
|
||
function zoomAt(cx, cy, delta) {
|
||
const oldScale = scale;
|
||
scale = Math.max(0.1, Math.min(10, scale * (delta > 0 ? 0.92 : 1.08)));
|
||
const imgX = (cx - panX) / oldScale;
|
||
const imgY = (cy - panY) / oldScale;
|
||
panX = cx - imgX * scale;
|
||
panY = cy - imgY * scale;
|
||
updateTransform();
|
||
}
|
||
|
||
function zoomStep(dir) {
|
||
zoomAt($imageContainer.clientWidth / 2, $imageContainer.clientHeight / 2, dir);
|
||
}
|
||
|
||
// ── ALL wheel on reader = zoom (no ctrl needed) ──
|
||
$imageContainer.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const rect = $imageContainer.getBoundingClientRect();
|
||
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
|
||
zoomAt(e.clientX - rect.left, e.clientY - rect.top, delta);
|
||
}, { passive: false });
|
||
|
||
// ── Touch pinch ──
|
||
let lastTouchDist = 0, lastTouchMidX = 0, lastTouchMidY = 0;
|
||
|
||
$imageContainer.addEventListener('touchstart', (e) => {
|
||
if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
const t = Array.from(e.touches);
|
||
lastTouchDist = getTouchDist(t);
|
||
const m = getTouchMid(t);
|
||
lastTouchMidX = m.x; lastTouchMidY = m.y;
|
||
}
|
||
}, { passive: false });
|
||
|
||
$imageContainer.addEventListener('touchmove', (e) => {
|
||
if (e.touches.length === 2) {
|
||
e.preventDefault();
|
||
const t = Array.from(e.touches);
|
||
const d = getTouchDist(t), m = getTouchMid(t);
|
||
const rect = $imageContainer.getBoundingClientRect();
|
||
const cx = m.x - rect.left, cy = m.y - rect.top;
|
||
const old = scale;
|
||
scale = Math.max(0.1, Math.min(10, scale * (d / lastTouchDist)));
|
||
const ix = (cx - panX) / old, iy = (cy - panY) / old;
|
||
panX = cx - ix * scale + (m.x - lastTouchMidX);
|
||
panY = cy - iy * scale + (m.y - lastTouchMidY);
|
||
updateTransform();
|
||
lastTouchDist = d; lastTouchMidX = m.x; lastTouchMidY = m.y;
|
||
}
|
||
}, { passive: false });
|
||
|
||
function getTouchDist(t) { const dx = t[0].clientX-t[1].clientX, dy = t[0].clientY-t[1].clientY; return Math.sqrt(dx*dx+dy*dy); }
|
||
function getTouchMid(t) { return { x: (t[0].clientX+t[1].clientX)/2, y: (t[0].clientY+t[1].clientY)/2 }; }
|
||
|
||
// ── Drag to pan ──
|
||
$imageContainer.addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
isDragging = true;
|
||
dragStartX = e.clientX; dragStartY = e.clientY;
|
||
dragStartPanX = panX; dragStartPanY = panY;
|
||
$imageContainer.classList.add('grabbing');
|
||
e.preventDefault();
|
||
});
|
||
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
panX = dragStartPanX + (e.clientX - dragStartX);
|
||
panY = dragStartPanY + (e.clientY - dragStartY);
|
||
updateTransform();
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
isDragging = false;
|
||
$imageContainer.classList.remove('grabbing');
|
||
});
|
||
|
||
$imageContainer.addEventListener('dblclick', (e) => { e.preventDefault(); applyFitMode(); });
|
||
|
||
// ══════════════════════════════════════════════
|
||
// CONTROLS
|
||
// ══════════════════════════════════════════════
|
||
el('btn-prev').addEventListener('click', () => loadPage(currentPage - pageStep()));
|
||
el('btn-next').addEventListener('click', () => loadPage(currentPage + pageStep()));
|
||
el('btn-zoom-in').addEventListener('click', () => zoomStep(-1));
|
||
el('btn-zoom-out').addEventListener('click', () => zoomStep(1));
|
||
el('btn-zoom-reset').addEventListener('click', () => applyFitMode());
|
||
|
||
el('btn-fit').addEventListener('click', (e) => { e.stopPropagation(); $fitMenu.classList.toggle('open'); });
|
||
document.querySelectorAll('.fit-option').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
fitMode = btn.dataset.fit;
|
||
document.querySelectorAll('.fit-option').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
$fitMenu.classList.remove('open');
|
||
applyFitMode();
|
||
});
|
||
});
|
||
document.addEventListener('click', () => {
|
||
$fitMenu.classList.remove('open');
|
||
el('shortcuts-modal').classList.remove('open');
|
||
});
|
||
|
||
function toggleSidebar() {
|
||
$sidebar.classList.toggle('hidden');
|
||
$readerArea.classList.toggle('full');
|
||
setTimeout(() => { if (naturalW) applyFitMode(); }, 310);
|
||
}
|
||
|
||
el('btn-toggle-sidebar').addEventListener('click', toggleSidebar);
|
||
el('btn-shortcuts').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
el('shortcuts-modal').classList.toggle('open');
|
||
});
|
||
|
||
// ══════════════════════════════════════════════
|
||
// FULLSCREEN
|
||
// ══════════════════════════════════════════════
|
||
let fsControlsTimeout = null;
|
||
|
||
function toggleFullscreen() {
|
||
if (!document.fullscreenElement) {
|
||
document.documentElement.requestFullscreen?.();
|
||
} else {
|
||
document.exitFullscreen?.();
|
||
}
|
||
}
|
||
|
||
document.addEventListener('fullscreenchange', () => {
|
||
if (document.fullscreenElement) {
|
||
document.body.classList.add('is-fullscreen');
|
||
showFsControls();
|
||
} else {
|
||
document.body.classList.remove('is-fullscreen', 'show-controls');
|
||
clearTimeout(fsControlsTimeout);
|
||
// Restore sidebar state
|
||
if (!$sidebar.classList.contains('hidden')) {
|
||
$readerArea.classList.remove('full');
|
||
}
|
||
setTimeout(() => { if (naturalW) applyFitMode(); }, 100);
|
||
}
|
||
setTimeout(() => { if (naturalW) applyFitMode(); }, 100);
|
||
});
|
||
|
||
function showFsControls() {
|
||
document.body.classList.add('show-controls');
|
||
clearTimeout(fsControlsTimeout);
|
||
fsControlsTimeout = setTimeout(() => {
|
||
document.body.classList.remove('show-controls');
|
||
}, 2500);
|
||
}
|
||
|
||
document.addEventListener('mousemove', () => {
|
||
if (document.fullscreenElement) showFsControls();
|
||
});
|
||
|
||
el('btn-fullscreen').addEventListener('click', toggleFullscreen);
|
||
|
||
function togglePageMode() {
|
||
pageMode = pageMode === 'single' ? 'double' : 'single';
|
||
const btn = el('btn-page-mode');
|
||
btn.textContent = pageMode === 'double' ? '⊟' : '⊡';
|
||
btn.title = pageMode === 'double' ? 'Double page (D)' : 'Single page (D)';
|
||
if (currentBook) {
|
||
naturalW = 0; // force fit mode recalc
|
||
loadPage(currentPage);
|
||
}
|
||
}
|
||
|
||
el('btn-page-mode').addEventListener('click', togglePageMode);
|
||
|
||
// ── Keyboard ──
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT') return;
|
||
if (e.ctrlKey || e.metaKey) {
|
||
if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomStep(-1); }
|
||
else if (e.key === '-') { e.preventDefault(); zoomStep(1); }
|
||
else if (e.key === '0') { e.preventDefault(); applyFitMode(); }
|
||
else if (e.key === 'ArrowLeft') { e.preventDefault(); loadPage(currentPage - pageStep()); }
|
||
else if (e.key === 'ArrowRight') { e.preventDefault(); loadPage(currentPage + pageStep()); }
|
||
else if (e.key === 'ArrowUp') { e.preventDefault(); zoomStep(-1); }
|
||
else if (e.key === 'ArrowDown') { e.preventDefault(); zoomStep(1); }
|
||
return;
|
||
}
|
||
switch (e.key) {
|
||
case 'ArrowLeft': e.preventDefault(); panX += 80; updateTransform(); break;
|
||
case 'ArrowRight': e.preventDefault(); panX -= 80; updateTransform(); break;
|
||
case 'ArrowUp': e.preventDefault(); panY += 80; updateTransform(); break;
|
||
case 'ArrowDown': e.preventDefault(); panY -= 80; updateTransform(); break;
|
||
case '+': case '=': e.preventDefault(); zoomStep(-1); break;
|
||
case '-': case '_': e.preventDefault(); zoomStep(1); break;
|
||
case '0': e.preventDefault(); applyFitMode(); break;
|
||
case 'c': case 'C':
|
||
e.preventDefault();
|
||
panX = ($imageContainer.clientWidth - naturalW * scale) / 2;
|
||
updateTransform();
|
||
break;
|
||
case 'Escape':
|
||
el('shortcuts-modal').classList.remove('open');
|
||
break;
|
||
case 'Tab': e.preventDefault(); toggleSidebar(); break;
|
||
case 'd': case 'D':
|
||
e.preventDefault();
|
||
togglePageMode();
|
||
break;
|
||
case '?':
|
||
e.preventDefault();
|
||
el('shortcuts-modal').classList.toggle('open');
|
||
break;
|
||
case 'f': case 'F':
|
||
e.preventDefault();
|
||
toggleFullscreen();
|
||
break;
|
||
}
|
||
});
|
||
|
||
// ══════════════════════════════════════════════
|
||
// UI SCALE
|
||
// ══════════════════════════════════════════════
|
||
let uiScale = parseFloat(localStorage.getItem('komga-ui-scale') || '1');
|
||
applyUiScale();
|
||
|
||
function applyUiScale() {
|
||
document.documentElement.style.zoom = uiScale;
|
||
el('ui-scale-value').textContent = Math.round(uiScale * 100) + '%';
|
||
localStorage.setItem('komga-ui-scale', uiScale);
|
||
}
|
||
|
||
el('ui-scale-up').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
uiScale = Math.min(2, uiScale + 0.05);
|
||
applyUiScale();
|
||
});
|
||
|
||
el('ui-scale-down').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
uiScale = Math.max(0.5, uiScale - 0.05);
|
||
applyUiScale();
|
||
});
|
||
|
||
window.addEventListener('resize', () => { if (naturalW) applyFitMode(); });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|