@gheop/tojiru 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/cli.js +10 -4
- package/dist/cli.js.map +1 -1
- package/dist/convert.js +11 -3
- package/dist/convert.js.map +1 -1
- package/dist/extractors/outline.js +36 -0
- package/dist/extractors/outline.js.map +1 -0
- package/dist/extractors/pdf.js +23 -3
- package/dist/extractors/pdf.js.map +1 -1
- package/dist/extractors/types.js.map +1 -1
- package/dist/manifest.js +13 -2
- package/dist/manifest.js.map +1 -1
- package/dist/output/folder.js +6 -1
- package/dist/output/folder.js.map +1 -1
- package/dist/output/single-file.js +15 -22
- package/dist/output/single-file.js.map +1 -1
- package/dist/pages.js +14 -5
- package/dist/pages.js.map +1 -1
- package/dist/search.js +13 -0
- package/dist/search.js.map +1 -0
- package/package.json +1 -1
- package/reader/index.html +10 -3
- package/reader/reader.css +122 -17
- package/reader/reader.js +213 -14
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ tojiru book.pdf --out site/
|
|
|
15
15
|
- **PDF** pages become crisp **vector SVG** (via poppler/mupdf). Text stays sharp at any zoom, and for born-digital PDFs the bundle is often *smaller* than the source.
|
|
16
16
|
- **Comics** (CBZ, CB7, CBR) and **DjVu** become image pages with thumbnails.
|
|
17
17
|
- The reader lazy-loads pages as you scroll (`IntersectionObserver`), has a thumbnail sidebar, keyboard navigation, reading-position memory (`localStorage`), and `#page=N` deep links.
|
|
18
|
+
- **Full-text search** (Ctrl+F) for PDFs with a text layer, a **table of contents** built from the PDF outline, a **dark theme** that follows the system, a phone-friendly layout, and an optional **double-page / right-to-left (manga)** view.
|
|
18
19
|
- SVG pages are gzipped and **inflated in the browser** (`DecompressionStream`), so they render correctly on any host regardless of its `Content-Encoding` configuration.
|
|
19
20
|
|
|
20
21
|
## Supported formats
|
|
@@ -29,6 +30,8 @@ tojiru book.pdf --out site/
|
|
|
29
30
|
|
|
30
31
|
Comics work out of the box. Only PDF and DjVu need a system tool, detected at runtime — if it's missing, `tojiru` tells you which package to install instead of crashing.
|
|
31
32
|
|
|
33
|
+
Two PDF extras are picked up automatically when present: `pdftotext` (poppler, usually installed with `pdftocairo`) builds the search index, and `mutool` (mupdf) reads the outline for the table of contents. Both are optional — without them you just lose that one feature.
|
|
34
|
+
|
|
32
35
|
## Install
|
|
33
36
|
|
|
34
37
|
Requires **Node ≥ 20**.
|
|
@@ -57,9 +60,9 @@ Optional system tools (install only what you need):
|
|
|
57
60
|
|
|
58
61
|
```bash
|
|
59
62
|
# Debian/Ubuntu
|
|
60
|
-
sudo apt install poppler-utils djvulibre-bin p7zip-full
|
|
63
|
+
sudo apt install poppler-utils djvulibre-bin p7zip-full mupdf-tools
|
|
61
64
|
# Fedora
|
|
62
|
-
sudo dnf install poppler-utils djvulibre p7zip
|
|
65
|
+
sudo dnf install poppler-utils djvulibre p7zip mupdf
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
## Usage
|
|
@@ -82,6 +85,23 @@ Generation shows per-page progress on stderr (e.g. `Converting 12/30`).
|
|
|
82
85
|
| `--single-file [file]` | Output a single portable HTML file instead of a folder |
|
|
83
86
|
| `--image-format <fmt>` | Raster page encoding: `keep` (as-is, default) or `webp` |
|
|
84
87
|
| `--quality <n>` | WebP quality 1-100 for lossy raster pages (default: 80) |
|
|
88
|
+
| `--spread` | Lay pages out two-up (double-page spread) |
|
|
89
|
+
| `--rtl` | Right-to-left reading order (manga); pairs with `--spread` |
|
|
90
|
+
| `--paged` | Default to the paged view (one page per screen); the reader can switch back |
|
|
91
|
+
|
|
92
|
+
### Reading
|
|
93
|
+
|
|
94
|
+
The generated reader works the same whatever the source format:
|
|
95
|
+
|
|
96
|
+
- **Search** — Ctrl+F (or `/`) opens a text search for PDFs that shipped a text layer. Matches list the page with a highlighted excerpt; click one to jump there.
|
|
97
|
+
- **Table of contents** — when a PDF has an outline, it shows at the top of the thumbnail column; click an entry to jump to its page, and the current section is highlighted as you read.
|
|
98
|
+
- **Dark mode** — follows the system by default; the ◐ button toggles it and remembers your choice.
|
|
99
|
+
- **Paged view** — the ≣ / ▭ button switches between continuous scroll and one page per screen (horizontal, arrow-key or swipe navigation). `--paged` makes the paged view the default; the choice is remembered.
|
|
100
|
+
- **Double-page / manga** — `--spread` shows two pages per row; add `--rtl` for right-to-left order (page 1 on the right, left arrow advances).
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
tojiru manga.cbz --out manga/ --image-format webp --spread --rtl
|
|
104
|
+
```
|
|
85
105
|
|
|
86
106
|
### Single file
|
|
87
107
|
|
|
@@ -163,6 +183,22 @@ MIT — see [LICENSE](LICENSE).
|
|
|
163
183
|
|
|
164
184
|
## Changelog
|
|
165
185
|
|
|
186
|
+
### v0.9.0 — Paged view, contents highlight, leaner comics (2026-06-26)
|
|
187
|
+
|
|
188
|
+
- **Paged reading view** — the ≣ / ▭ button switches the reader between continuous scroll and one page per screen (horizontal, arrow-key or swipe navigation), and the choice is remembered. `--paged` makes it the build default; `--rtl` pages it right-to-left.
|
|
189
|
+
- **The table of contents follows along** — the outline entry covering the current page is highlighted as you read.
|
|
190
|
+
- **`--image-format webp` never inflates a comic** — each page keeps WebP or its original, whichever is smaller. An already-compressed scan that used to grow ~2× now stays at its source size, while colour comics still drop to ~×0.3 (measured points in [`examples/BENCH.md`](examples/BENCH.md)).
|
|
191
|
+
- Tests run in CI on Node 24 (the Node 20 deprecation warning is gone), and a flaky reader e2e was de-flaked.
|
|
192
|
+
|
|
193
|
+
### v0.8.0 — Search, dark mode, contents and manga layout (2026-06-26)
|
|
194
|
+
|
|
195
|
+
- **Full-text search** (Ctrl+F or `/`) for PDFs that carry a text layer. `pdftotext` builds an index at conversion time (a `search.json` file, or inlined in single-file mode); matches list the page with a highlighted excerpt and jump there on click. Scans and comics keep the browser's native find.
|
|
196
|
+
- **Dark theme** that follows the system by default, with a ◐ button that toggles and remembers the choice. The reader now has a phone layout (the thumbnail column collapses to an overlay, full-width pages, larger tap targets), and the toolbar controls and thumbnails are real buttons reachable by keyboard.
|
|
197
|
+
- **Table of contents** built from the PDF outline via `mutool`, shown at the top of the thumbnail column; click an entry to jump to its page. Falls back to nothing when `mutool` or the outline is absent.
|
|
198
|
+
- **Double-page spread** (`--spread`) and **right-to-left manga order** (`--rtl`): two pages per row, page 1 on the right, the left arrow advances. Pairs wrap to one page per row on narrow screens.
|
|
199
|
+
- Tests now run in CI on GitHub and GitLab, and `--serve` opens a browser on Windows too.
|
|
200
|
+
- Internals: the single-file output is derived from `index.html` instead of duplicating the markup, and `buildManifest` takes an options object.
|
|
201
|
+
|
|
166
202
|
### v0.7.0 — Lighter vector text (2026-06-26)
|
|
167
203
|
|
|
168
204
|
- SVG page coordinates are now rounded to 1 decimal (0.1 pt) instead of 2 — measured ~19% lighter (gzipped) on glyph-heavy text PDFs, verified visually lossless even when zoomed (0.1 pt is ~0.13 px on screen).
|
package/dist/cli.js
CHANGED
|
@@ -8,9 +8,12 @@ import { VERSION } from './version.js';
|
|
|
8
8
|
import { convert } from './convert.js';
|
|
9
9
|
import { serve } from './serve.js';
|
|
10
10
|
function openBrowser(url) {
|
|
11
|
-
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
11
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
12
|
+
// Windows: `cmd /c start "" <url>`. The empty "" is start's title argument, so a
|
|
13
|
+
// url with spaces isn't mistaken for the window title.
|
|
14
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
12
15
|
try {
|
|
13
|
-
spawn(cmd,
|
|
16
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
14
17
|
}
|
|
15
18
|
catch { /* best-effort */ }
|
|
16
19
|
}
|
|
@@ -30,6 +33,9 @@ program
|
|
|
30
33
|
.option('--single-file [file]', 'output a single portable HTML file (double-click to read offline)')
|
|
31
34
|
.option('--image-format <fmt>', 'comic/raster page encoding: keep (as-is) or webp', 'keep')
|
|
32
35
|
.option('--quality <n>', 'WebP quality (1-100) for lossy raster pages', '80')
|
|
36
|
+
.option('--spread', 'lay pages out two-up (double-page spread)')
|
|
37
|
+
.option('--rtl', 'right-to-left reading order (manga); pairs with --spread')
|
|
38
|
+
.option('--paged', 'default to the paged view (one page per screen); reader can switch back')
|
|
33
39
|
.action(async (input, opts) => {
|
|
34
40
|
try {
|
|
35
41
|
if (!existsSync(input))
|
|
@@ -53,7 +59,7 @@ program
|
|
|
53
59
|
const htmlPath = typeof opts.singleFile === 'string'
|
|
54
60
|
? opts.singleFile
|
|
55
61
|
: basename(input).replace(/\.[^.]+$/, '') + '.html';
|
|
56
|
-
const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality });
|
|
62
|
+
const r = await convert(input, { outDir: '', title: opts.title, onProgress, singleFile: htmlPath, imageFormat, quality, spread: opts.spread, rtl: opts.rtl, paged: opts.paged });
|
|
57
63
|
if (isTTY)
|
|
58
64
|
process.stderr.write('\x1b[2K\r');
|
|
59
65
|
console.log(`✓ ${r.pageCount} pages → ${htmlPath}`);
|
|
@@ -65,7 +71,7 @@ program
|
|
|
65
71
|
if (existsSync(outDir) && (await readdir(outDir)).length > 0 && !opts.force) {
|
|
66
72
|
throw new Error(`Folder ${outDir} is not empty. Use --force to overwrite.`);
|
|
67
73
|
}
|
|
68
|
-
const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality });
|
|
74
|
+
const r = await convert(input, { outDir, title: opts.title, onProgress, imageFormat, quality, spread: opts.spread, rtl: opts.rtl, paged: opts.paged });
|
|
69
75
|
// Clear progress line before success message
|
|
70
76
|
if (isTTY)
|
|
71
77
|
process.stderr.write('\x1b[2K\r');
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AACpC,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAElC,SAAS,WAAW,CAAC,GAAW;IAC9B,MAAM,GAAG,GAAG,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/convert.js
CHANGED
|
@@ -9,6 +9,7 @@ import { cbrExtractor } from './extractors/cbr.js';
|
|
|
9
9
|
import { djvuExtractor } from './extractors/djvu.js';
|
|
10
10
|
import { processPages } from './pages.js';
|
|
11
11
|
import { buildManifest } from './manifest.js';
|
|
12
|
+
import { buildSearchIndex } from './search.js';
|
|
12
13
|
import { writeFolder } from './output/folder.js';
|
|
13
14
|
import { writeSingleFile } from './output/single-file.js';
|
|
14
15
|
const EXTRACTORS = [pdfExtractor, cbzExtractor, cb7Extractor, cbrExtractor, djvuExtractor];
|
|
@@ -31,12 +32,19 @@ export async function convert(input, opts) {
|
|
|
31
32
|
if (doc.pages.length === 0)
|
|
32
33
|
throw new Error('No pages extracted.');
|
|
33
34
|
const pages = await processPages(doc, bundleDir, { imageFormat: opts.imageFormat, quality: opts.quality }, opts.onProgress);
|
|
34
|
-
const
|
|
35
|
+
const search = buildSearchIndex(doc);
|
|
36
|
+
const manifest = buildManifest(doc.title, doc.kind, pages, {
|
|
37
|
+
searchable: search.length > 0,
|
|
38
|
+
outline: doc.outline,
|
|
39
|
+
spread: opts.spread,
|
|
40
|
+
rtl: opts.rtl,
|
|
41
|
+
paged: opts.paged,
|
|
42
|
+
});
|
|
35
43
|
if (opts.singleFile) {
|
|
36
|
-
await writeSingleFile(manifest, bundleDir, opts.singleFile);
|
|
44
|
+
await writeSingleFile(manifest, bundleDir, opts.singleFile, search);
|
|
37
45
|
return { outDir: opts.singleFile, pageCount: doc.pages.length };
|
|
38
46
|
}
|
|
39
|
-
await writeFolder(manifest, bundleDir);
|
|
47
|
+
await writeFolder(manifest, bundleDir, search);
|
|
40
48
|
return { outDir: opts.outDir, pageCount: doc.pages.length };
|
|
41
49
|
}
|
|
42
50
|
finally {
|
package/dist/convert.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convert.js","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACpD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAC7C,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAGzD,MAAM,UAAU,GAAgB,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"convert.js","sourceRoot":"","sources":["../src/convert.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { hasBinary } from '../tools.js';
|
|
2
|
+
import { run } from '../run.js';
|
|
3
|
+
// Parses the output of `mutool show <pdf> outline`. Each line looks like:
|
|
4
|
+
// <marker><TAB×(depth+1)>"<title>"<TAB>#page=<n>
|
|
5
|
+
// where marker is '-' (the entry has children) or '|' (leaf/sibling), and the depth is
|
|
6
|
+
// the number of tabs before the title minus one. Entries whose destination carries no
|
|
7
|
+
// page number (external links, unresolved named destinations) are dropped, since the
|
|
8
|
+
// reader can only jump to a page.
|
|
9
|
+
export function parseOutline(stdout) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const line of stdout.split('\n')) {
|
|
12
|
+
const m = line.match(/^[-|](\t+)"(.*)"\t#(.+)$/);
|
|
13
|
+
if (!m)
|
|
14
|
+
continue;
|
|
15
|
+
const page = m[3].match(/page=(\d+)/);
|
|
16
|
+
const title = m[2].trim();
|
|
17
|
+
if (!title || !page)
|
|
18
|
+
continue;
|
|
19
|
+
out.push({ title, page: Number(page[1]), depth: Math.max(0, m[1].length - 1) });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
// Extracts a PDF outline via mutool. Returns an empty list when mutool is absent or the
|
|
24
|
+
// document has no outline — the table of contents is a bonus, never a hard requirement.
|
|
25
|
+
export async function extractOutline(file) {
|
|
26
|
+
if (!(await hasBinary('mutool')))
|
|
27
|
+
return [];
|
|
28
|
+
try {
|
|
29
|
+
const { stdout } = await run('mutool', ['show', file, 'outline']);
|
|
30
|
+
return parseOutline(stdout);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=outline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outline.js","sourceRoot":"","sources":["../../src/extractors/outline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAA;AAG/B,0EAA0E;AAC1E,mDAAmD;AACnD,uFAAuF;AACvF,sFAAsF;AACtF,qFAAqF;AACrF,kCAAkC;AAClC,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAmB,EAAE,CAAA;IAC9B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,0BAA0B,CAAC,CAAA;QAChD,IAAI,CAAC,CAAC;YAAE,SAAQ;QAChB,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,YAAY,CAAC,CAAA;QACrC,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QACzB,IAAI,CAAC,KAAK,IAAI,CAAC,IAAI;YAAE,SAAQ;QAC7B,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,CAAA;IACjF,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,wFAAwF;AACxF,wFAAwF;AACxF,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY;IAC/C,IAAI,CAAC,CAAC,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC;QAAE,OAAO,EAAE,CAAA;IAC3C,IAAI,CAAC;QACH,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,CAAA;QACjE,OAAO,YAAY,CAAC,MAAM,CAAC,CAAA;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC"}
|
package/dist/extractors/pdf.js
CHANGED
|
@@ -2,13 +2,30 @@ import { readFile, unlink, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
3
|
import { DEFAULT_QUALITY } from './types.js';
|
|
4
4
|
import { detectKind } from './detect.js';
|
|
5
|
-
import { findPdfConverter } from '../tools.js';
|
|
5
|
+
import { findPdfConverter, hasBinary } from '../tools.js';
|
|
6
6
|
import { run } from '../run.js';
|
|
7
7
|
import sharp from 'sharp';
|
|
8
8
|
import { imageDims } from './images.js';
|
|
9
|
+
import { extractOutline } from './outline.js';
|
|
9
10
|
function pad(n, width) {
|
|
10
11
|
return String(n).padStart(width, '0');
|
|
11
12
|
}
|
|
13
|
+
// Extracts plain text for every page in one pdftotext pass. pdftotext separates
|
|
14
|
+
// pages with a form feed (\f), so the split lines up one chunk per page. Returns an
|
|
15
|
+
// array indexed by page-1, or an empty array if pdftotext is missing or fails — text
|
|
16
|
+
// is a bonus (powers search), never a reason to fail the conversion.
|
|
17
|
+
async function extractText(file, count) {
|
|
18
|
+
if (!(await hasBinary('pdftotext')))
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await run('pdftotext', ['-enc', 'UTF-8', file, '-']);
|
|
22
|
+
const chunks = stdout.split('\f');
|
|
23
|
+
return Array.from({ length: count }, (_, i) => (chunks[i] ?? '').replace(/\s+/g, ' ').trim());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
12
29
|
async function pageCount(file) {
|
|
13
30
|
// pdfinfo is part of poppler; fall back to mutool if absent.
|
|
14
31
|
try {
|
|
@@ -65,6 +82,7 @@ export const pdfExtractor = {
|
|
|
65
82
|
}
|
|
66
83
|
const count = await pageCount(file);
|
|
67
84
|
const width = Math.max(4, String(count).length);
|
|
85
|
+
const text = await extractText(file, count);
|
|
68
86
|
const pages = [];
|
|
69
87
|
for (let i = 1; i <= count; i++) {
|
|
70
88
|
const stem = pad(i, width);
|
|
@@ -86,20 +104,22 @@ export const pdfExtractor = {
|
|
|
86
104
|
await sharp(pngPath).webp({ quality, effort: 6 }).toFile(webpPath);
|
|
87
105
|
await unlink(svgPath);
|
|
88
106
|
await unlink(pngPath);
|
|
89
|
-
pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)) });
|
|
107
|
+
pages.push({ type: 'raster', imagePath: webpPath, ...(await imageDims(webpPath)), text: text[i - 1] || undefined });
|
|
90
108
|
}
|
|
91
109
|
else {
|
|
92
110
|
// Vector page: round coordinates to shrink SVG, then store.
|
|
93
111
|
const rounded = roundCoords(svg);
|
|
94
112
|
await writeFile(svgPath, rounded, 'utf8');
|
|
95
|
-
pages.push({ type: 'vector', svgPath, ...viewBox(rounded) });
|
|
113
|
+
pages.push({ type: 'vector', svgPath, ...viewBox(rounded), text: text[i - 1] || undefined });
|
|
96
114
|
}
|
|
97
115
|
onProgress?.(i, count, 'Converting');
|
|
98
116
|
}
|
|
117
|
+
const outline = await extractOutline(file);
|
|
99
118
|
return {
|
|
100
119
|
title: basename(file, extname(file)),
|
|
101
120
|
kind: 'pdf',
|
|
102
121
|
pages,
|
|
122
|
+
outline: outline.length ? outline : undefined,
|
|
103
123
|
};
|
|
104
124
|
},
|
|
105
125
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/extractors/pdf.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAC9D,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEnD,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"pdf.js","sourceRoot":"","sources":["../../src/extractors/pdf.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/extractors/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/extractors/types.ts"],"names":[],"mappings":"AAyBA,sFAAsF;AACtF,kFAAkF;AAClF,MAAM,CAAC,MAAM,eAAe,GAAG,EAAE,CAAA"}
|
package/dist/manifest.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
export function buildManifest(title, kind, pages) {
|
|
2
|
-
|
|
1
|
+
export function buildManifest(title, kind, pages, extras = {}) {
|
|
2
|
+
const m = { tojiru: 1, title, kind, pageCount: pages.length, pages };
|
|
3
|
+
if (extras.searchable)
|
|
4
|
+
m.searchable = true;
|
|
5
|
+
if (extras.outline && extras.outline.length)
|
|
6
|
+
m.outline = extras.outline;
|
|
7
|
+
if (extras.spread)
|
|
8
|
+
m.spread = true;
|
|
9
|
+
if (extras.rtl)
|
|
10
|
+
m.rtl = true;
|
|
11
|
+
if (extras.paged)
|
|
12
|
+
m.layout = 'paged';
|
|
13
|
+
return m;
|
|
3
14
|
}
|
|
4
15
|
//# sourceMappingURL=manifest.js.map
|
package/dist/manifest.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAiCA,MAAM,UAAU,aAAa,CAAC,KAAa,EAAE,IAAU,EAAE,KAAsB,EAAE,SAAyB,EAAE;IAC1G,MAAM,CAAC,GAAa,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,CAAA;IAC9E,IAAI,MAAM,CAAC,UAAU;QAAE,CAAC,CAAC,UAAU,GAAG,IAAI,CAAA;IAC1C,IAAI,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM;QAAE,CAAC,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IACvE,IAAI,MAAM,CAAC,MAAM;QAAE,CAAC,CAAC,MAAM,GAAG,IAAI,CAAA;IAClC,IAAI,MAAM,CAAC,GAAG;QAAE,CAAC,CAAC,GAAG,GAAG,IAAI,CAAA;IAC5B,IAAI,MAAM,CAAC,KAAK;QAAE,CAAC,CAAC,MAAM,GAAG,OAAO,CAAA;IACpC,OAAO,CAAC,CAAA;AACV,CAAC"}
|
package/dist/output/folder.js
CHANGED
|
@@ -6,9 +6,14 @@ import { join } from 'node:path';
|
|
|
6
6
|
export function readerDir() {
|
|
7
7
|
return fileURLToPath(new URL('../../reader/', import.meta.url));
|
|
8
8
|
}
|
|
9
|
-
export async function writeFolder(manifest, outDir) {
|
|
9
|
+
export async function writeFolder(manifest, outDir, search = []) {
|
|
10
10
|
await mkdir(outDir, { recursive: true });
|
|
11
11
|
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest));
|
|
12
|
+
// The search index is its own file so the reader fetches it lazily on first search,
|
|
13
|
+
// keeping the eagerly-loaded manifest small.
|
|
14
|
+
if (search.length > 0) {
|
|
15
|
+
await writeFile(join(outDir, 'search.json'), JSON.stringify(search));
|
|
16
|
+
}
|
|
12
17
|
const rd = readerDir();
|
|
13
18
|
for (const f of ['index.html', 'reader.js', 'reader.css']) {
|
|
14
19
|
await copyFile(join(rd, f), join(outDir, f));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"folder.js","sourceRoot":"","sources":["../../src/output/folder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"folder.js","sourceRoot":"","sources":["../../src/output/folder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAIhC,oFAAoF;AACpF,uBAAuB;AACvB,MAAM,UAAU,SAAS;IACvB,OAAO,aAAa,CAAC,IAAI,GAAG,CAAC,eAAe,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;AACjE,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,QAAkB,EAAE,MAAc,EAAE,SAAwB,EAAE;IAC9F,MAAM,KAAK,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,MAAM,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;IACxE,oFAAoF;IACpF,6CAA6C;IAC7C,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,SAAS,CAAC,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAA;IACtE,CAAC;IACD,MAAM,EAAE,GAAG,SAAS,EAAE,CAAA;IACtB,KAAK,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,CAAC,EAAE,CAAC;QAC1D,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,CAAC;AACH,CAAC"}
|
|
@@ -9,7 +9,7 @@ function escapeScript(s) {
|
|
|
9
9
|
function escapeHtml(s) {
|
|
10
10
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
11
|
}
|
|
12
|
-
export async function writeSingleFile(manifest, bundleDir, outFile) {
|
|
12
|
+
export async function writeSingleFile(manifest, bundleDir, outFile, search = []) {
|
|
13
13
|
// Collect all page + thumb file paths
|
|
14
14
|
const allFiles = [];
|
|
15
15
|
for (const p of manifest.pages) {
|
|
@@ -33,37 +33,30 @@ export async function writeSingleFile(manifest, bundleDir, outFile) {
|
|
|
33
33
|
for (const [f, buf] of buffers) {
|
|
34
34
|
pagesMap[f] = buf.toString('base64');
|
|
35
35
|
}
|
|
36
|
-
// Read reader assets from package
|
|
36
|
+
// Read reader assets from package. The single-file HTML is derived from the same
|
|
37
|
+
// index.html the folder output uses, so the body chrome lives in exactly one place:
|
|
38
|
+
// inline the stylesheet and swap the external module script for the bundled data.
|
|
37
39
|
const rd = readerDir();
|
|
40
|
+
const indexHtml = await readFile(join(rd, 'index.html'), 'utf8');
|
|
38
41
|
const css = await readFile(join(rd, 'reader.css'), 'utf8');
|
|
39
42
|
const js = await readFile(join(rd, 'reader.js'), 'utf8');
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
46
|
-
`<title>${escapeHtml(manifest.title)}</title>`,
|
|
47
|
-
'<style>',
|
|
48
|
-
css,
|
|
49
|
-
'</style>',
|
|
50
|
-
'</head>',
|
|
51
|
-
'<body>',
|
|
52
|
-
'<div id="reduce" title="Collapse / expand thumbnails">☰</div>',
|
|
53
|
-
'<nav id="menu" aria-label="Pages"></nav>',
|
|
54
|
-
'<div id="resize" title="Drag to resize"></div>',
|
|
55
|
-
'<main id="page"></main>',
|
|
43
|
+
const globals = [`window.__TOJIRU_PAGES = ${escapeScript(JSON.stringify(pagesMap))};`];
|
|
44
|
+
if (search.length > 0) {
|
|
45
|
+
globals.push(`window.__TOJIRU_SEARCH = ${escapeScript(JSON.stringify(search))};`);
|
|
46
|
+
}
|
|
47
|
+
const inlineScripts = [
|
|
56
48
|
`<script type="application/json" id="tojiru-manifest">${escapeScript(JSON.stringify(manifest))}</script>`,
|
|
57
49
|
'<script>',
|
|
58
|
-
|
|
50
|
+
globals.join('\n'),
|
|
59
51
|
'</script>',
|
|
60
52
|
'<script type="module">',
|
|
61
53
|
escapeScript(js),
|
|
62
54
|
'</script>',
|
|
63
|
-
'</body>',
|
|
64
|
-
'</html>',
|
|
65
|
-
'',
|
|
66
55
|
].join('\n');
|
|
56
|
+
const html = indexHtml
|
|
57
|
+
.replace('<title>tojiru</title>', `<title>${escapeHtml(manifest.title)}</title>`)
|
|
58
|
+
.replace('<link rel="stylesheet" href="reader.css" />', `<style>\n${css}\n</style>`)
|
|
59
|
+
.replace('<script type="module" src="reader.js"></script>', inlineScripts);
|
|
67
60
|
await writeFile(outFile, html);
|
|
68
61
|
}
|
|
69
62
|
//# sourceMappingURL=single-file.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"single-file.js","sourceRoot":"","sources":["../../src/output/single-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"single-file.js","sourceRoot":"","sources":["../../src/output/single-file.ts"],"names":[],"mappings":"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"}
|
package/dist/pages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, copyFile, rm } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, writeFile, copyFile, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import { gzipSync } from 'node:zlib';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import sharp from 'sharp';
|
|
@@ -30,12 +30,21 @@ export async function processPages(doc, outDir, opts = {}, onProgress) {
|
|
|
30
30
|
}
|
|
31
31
|
else {
|
|
32
32
|
const ext = (basename(page.imagePath).split('.').pop() ?? 'jpg').toLowerCase();
|
|
33
|
-
// --image-format webp re-encodes comic pages (often large lossless PNGs) to
|
|
34
|
-
//
|
|
33
|
+
// --image-format webp re-encodes comic pages (often large lossless PNGs) to lossy
|
|
34
|
+
// WebP. But an already-compressed source (a low-quality JPEG scan) can come out
|
|
35
|
+
// *bigger* as WebP, so we encode in memory and keep whichever is smaller — WebP
|
|
36
|
+
// never inflates a page. Sources already in WebP are copied (no re-encode).
|
|
35
37
|
let file;
|
|
36
38
|
if (imageFormat === 'webp' && ext !== 'webp') {
|
|
37
|
-
|
|
38
|
-
await
|
|
39
|
+
const webp = await sharp(page.imagePath).webp({ quality, effort: 6 }).toBuffer();
|
|
40
|
+
if (webp.length < (await stat(page.imagePath)).size) {
|
|
41
|
+
file = `pages/${stem}.webp`;
|
|
42
|
+
await writeFile(join(outDir, file), webp);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
file = `pages/${stem}.${ext}`;
|
|
46
|
+
await copyFile(page.imagePath, join(outDir, file));
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
else {
|
|
41
50
|
file = `pages/${stem}.${ext}`;
|
package/dist/pages.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pages.js","sourceRoot":"","sources":["../src/pages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"pages.js","sourceRoot":"","sources":["../src/pages.ts"],"names":[],"mappings":"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"}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Builds the client search index from a document's per-page text. Pages with no text
|
|
2
|
+
// (scans, comics, blank pages) are dropped, so an all-image document yields an empty
|
|
3
|
+
// index and the reader keeps the browser's native find.
|
|
4
|
+
export function buildSearchIndex(doc) {
|
|
5
|
+
const entries = [];
|
|
6
|
+
doc.pages.forEach((p, i) => {
|
|
7
|
+
const t = p.text?.trim();
|
|
8
|
+
if (t)
|
|
9
|
+
entries.push({ n: i + 1, t });
|
|
10
|
+
});
|
|
11
|
+
return entries;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"AAKA,qFAAqF;AACrF,qFAAqF;AACrF,wDAAwD;AACxD,MAAM,UAAU,gBAAgB,CAAC,GAAa;IAC5C,MAAM,OAAO,GAAkB,EAAE,CAAA;IACjC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;QACzB,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,CAAA;QACxB,IAAI,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,CAAA;IACtC,CAAC,CAAC,CAAA;IACF,OAAO,OAAO,CAAA;AAChB,CAAC"}
|
package/package.json
CHANGED
package/reader/index.html
CHANGED
|
@@ -4,13 +4,20 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>tojiru</title>
|
|
7
|
+
<script>try{var t=localStorage.getItem('tojiru:theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
|
7
8
|
<link rel="stylesheet" href="reader.css" />
|
|
8
9
|
</head>
|
|
9
10
|
<body>
|
|
10
|
-
<
|
|
11
|
+
<button id="reduce" type="button" title="Collapse / expand thumbnails" aria-label="Collapse or expand thumbnails">☰</button>
|
|
12
|
+
<button id="theme" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">◐</button>
|
|
13
|
+
<button id="layout" type="button" title="Reading layout: continuous / paged" aria-label="Toggle paged reading view">≣</button>
|
|
14
|
+
<div id="search" class="hidden" role="dialog" aria-label="Search the document">
|
|
15
|
+
<input id="search-input" type="search" placeholder="Search…" aria-label="Search text" autocomplete="off" spellcheck="false" />
|
|
16
|
+
<div id="search-results" aria-live="polite"></div>
|
|
17
|
+
</div>
|
|
11
18
|
<nav id="menu" aria-label="Pages"></nav>
|
|
12
|
-
<div id="resize" title="Drag to resize"></div>
|
|
13
|
-
<main id="page"></main>
|
|
19
|
+
<div id="resize" title="Drag to resize" aria-hidden="true"></div>
|
|
20
|
+
<main id="page" tabindex="0"></main>
|
|
14
21
|
<script type="module" src="reader.js"></script>
|
|
15
22
|
</body>
|
|
16
23
|
</html>
|
package/reader/reader.css
CHANGED
|
@@ -2,53 +2,144 @@
|
|
|
2
2
|
thumbnail column on the left, centered pages with a silver border and shadow,
|
|
3
3
|
pages/thumbnails dimmed in opacity, the current page at full opacity. */
|
|
4
4
|
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #fff;
|
|
7
|
+
--muted: #999;
|
|
8
|
+
--chrome-bg: rgba(255, 255, 255, .85);
|
|
9
|
+
--chrome-fg: #555;
|
|
10
|
+
--chrome-border: #bbb;
|
|
11
|
+
--divider-bg: #ececec;
|
|
12
|
+
--divider-border: #999;
|
|
13
|
+
--page-border: silver;
|
|
14
|
+
--page-shadow: #ccc;
|
|
15
|
+
--focus: #4a90d9;
|
|
16
|
+
color-scheme: light;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Dark theme: explicit override (toggle button writes data-theme), and the same
|
|
20
|
+
values applied automatically when the system is dark and no light override is set. */
|
|
21
|
+
:root[data-theme="dark"] { --bg: #1c1c1e; --muted: #888; --chrome-bg: rgba(44, 44, 46, .9); --chrome-fg: #ccc; --chrome-border: #555; --divider-bg: #2c2c2e; --divider-border: #666; --page-border: #555; --page-shadow: #000; color-scheme: dark; }
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root:not([data-theme="light"]) { --bg: #1c1c1e; --muted: #888; --chrome-bg: rgba(44, 44, 46, .9); --chrome-fg: #ccc; --chrome-border: #555; --divider-bg: #2c2c2e; --divider-border: #666; --page-border: #555; --page-shadow: #000; color-scheme: dark; }
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
* { box-sizing: border-box; }
|
|
6
27
|
html, body { margin: 0; height: 100%; }
|
|
7
|
-
body { font-family: verdana, sans-serif; background:
|
|
28
|
+
body { font-family: verdana, sans-serif; background: var(--bg); }
|
|
8
29
|
#page, #menu { user-select: none; }
|
|
9
30
|
|
|
10
|
-
#reduce {
|
|
11
|
-
position: fixed; top: 5px;
|
|
12
|
-
width: 20px; height: 20px; line-height: 18px; text-align: center;
|
|
13
|
-
background:
|
|
14
|
-
cursor: pointer; font-size: 12px; color:
|
|
31
|
+
#reduce, #theme, #layout {
|
|
32
|
+
position: fixed; top: 5px; z-index: 4;
|
|
33
|
+
width: 20px; height: 20px; line-height: 18px; text-align: center; padding: 0;
|
|
34
|
+
background: var(--chrome-bg); border: 1px solid var(--chrome-border); border-radius: 3px;
|
|
35
|
+
cursor: pointer; font-size: 12px; color: var(--chrome-fg); font-family: inherit;
|
|
15
36
|
}
|
|
37
|
+
#reduce { left: 5px; }
|
|
38
|
+
#theme { left: 30px; }
|
|
39
|
+
#layout { left: 55px; }
|
|
40
|
+
#reduce:focus-visible, #theme:focus-visible, #layout:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
41
|
+
|
|
42
|
+
/* Search panel: Ctrl+F (or /) opens it over the top of the page area. */
|
|
43
|
+
#search {
|
|
44
|
+
position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 6;
|
|
45
|
+
width: min(440px, calc(100vw - 24px));
|
|
46
|
+
background: var(--bg); border: 1px solid var(--chrome-border); border-radius: 6px;
|
|
47
|
+
box-shadow: 0 4px 18px rgba(0, 0, 0, .25); padding: 6px;
|
|
48
|
+
}
|
|
49
|
+
#search.hidden { display: none; }
|
|
50
|
+
#search-input {
|
|
51
|
+
width: 100%; box-sizing: border-box; padding: 7px 9px; font: inherit; font-size: 14px;
|
|
52
|
+
color: var(--chrome-fg); background: var(--bg);
|
|
53
|
+
border: 1px solid var(--chrome-border); border-radius: 4px;
|
|
54
|
+
}
|
|
55
|
+
#search-input:focus-visible { outline: 2px solid var(--focus); outline-offset: 0; }
|
|
56
|
+
#search-results { max-height: 50vh; overflow: auto; margin-top: 6px; }
|
|
57
|
+
#search-results:empty { display: none; }
|
|
58
|
+
#search-results .hit {
|
|
59
|
+
display: block; width: 100%; text-align: left; padding: 6px 8px; border: 0; border-radius: 4px;
|
|
60
|
+
background: none; color: var(--chrome-fg); font: inherit; font-size: 13px; cursor: pointer; line-height: 1.35;
|
|
61
|
+
}
|
|
62
|
+
#search-results .hit:hover, #search-results .hit:focus-visible { background: var(--divider-bg); outline: none; }
|
|
63
|
+
#search-results .hit .pno { color: var(--muted); font-size: 11px; margin-right: 6px; }
|
|
64
|
+
#search-results .hit mark { background: #ffe066; color: #000; border-radius: 2px; }
|
|
65
|
+
#search-results .empty { padding: 6px 8px; color: var(--muted); font-size: 13px; }
|
|
16
66
|
|
|
17
67
|
#menu {
|
|
18
68
|
position: fixed; top: 0; bottom: 0; left: 0; width: 150px;
|
|
19
|
-
overflow: auto; text-align: center; background:
|
|
69
|
+
overflow: auto; text-align: center; background: var(--bg); padding-top: 28px;
|
|
20
70
|
}
|
|
21
71
|
#menu.hidden { display: none; }
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
72
|
+
#toc { text-align: left; padding: 2px 0 8px; margin-bottom: 6px; border-bottom: 1px solid var(--chrome-border); }
|
|
73
|
+
#toc .toc-head { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: 2px 8px 5px; }
|
|
74
|
+
.toc-item {
|
|
75
|
+
display: block; width: 100%; text-align: left; border: 0; background: none; cursor: pointer;
|
|
76
|
+
font: inherit; font-size: 12px; color: var(--chrome-fg); padding: 4px 8px; line-height: 1.3;
|
|
77
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
78
|
+
}
|
|
79
|
+
.toc-item:hover, .toc-item:focus-visible { background: var(--divider-bg); outline: none; }
|
|
80
|
+
.toc-item.active { color: var(--focus); font-weight: 600; }
|
|
81
|
+
#menu .thumb {
|
|
82
|
+
display: block; width: 100%; padding: 0; margin: 0; border: 0;
|
|
83
|
+
background: none; cursor: pointer; text-align: center; font-family: inherit;
|
|
84
|
+
}
|
|
85
|
+
#menu .thumb img {
|
|
86
|
+
width: 100px; margin: 10px 0 2px; background: #fff;
|
|
87
|
+
opacity: .5; border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
|
|
25
88
|
transition: opacity .15s ease, width .1s ease;
|
|
26
89
|
}
|
|
27
|
-
#menu
|
|
28
|
-
#menu
|
|
29
|
-
#menu .
|
|
90
|
+
#menu .thumb:hover img { width: 104px; }
|
|
91
|
+
#menu .thumb.select img { opacity: 1; }
|
|
92
|
+
#menu .thumb:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
93
|
+
#menu .num { display: block; font-size: 11px; color: var(--muted); margin-bottom: 8px; }
|
|
30
94
|
|
|
31
95
|
#resize {
|
|
32
96
|
position: fixed; top: 0; bottom: 0; left: 150px; width: 4px;
|
|
33
|
-
border-right: 1px solid
|
|
97
|
+
border-right: 1px solid var(--divider-border); background: var(--divider-bg); cursor: col-resize; z-index: 1;
|
|
34
98
|
}
|
|
35
99
|
#resize.hidden { display: none; }
|
|
36
100
|
|
|
37
101
|
#page {
|
|
38
102
|
position: fixed; top: 0; bottom: 0; left: 155px; right: 0;
|
|
39
|
-
overflow: auto; text-align: center; scroll-behavior: smooth; background:
|
|
103
|
+
overflow: auto; text-align: center; scroll-behavior: smooth; background: var(--bg);
|
|
40
104
|
}
|
|
41
105
|
#page.full { left: 0; }
|
|
42
106
|
|
|
43
107
|
.page {
|
|
44
108
|
display: block; margin: 12px auto; width: calc(100% - 36px);
|
|
45
109
|
}
|
|
110
|
+
|
|
111
|
+
/* Double-page spread: two pages per row (the open-book look). rtl reverses each pair so
|
|
112
|
+
page 1 sits on the right, the manga reading order. Pages wrap to one per row when the
|
|
113
|
+
viewport is too narrow for a fair pair. */
|
|
114
|
+
#page.spread { display: flex; flex-wrap: wrap; align-content: flex-start; justify-content: center; gap: 12px; padding: 12px 0; }
|
|
115
|
+
#page.spread .page { width: calc(50% - 18px); margin: 0; }
|
|
116
|
+
#page.spread.rtl { direction: rtl; }
|
|
117
|
+
@media (max-width: 700px) {
|
|
118
|
+
#page.spread .page { width: calc(100% - 24px); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Paged view: one page per screen with horizontal scroll-snap, a reading mode toggled
|
|
122
|
+
at runtime (the ≣/▭ button). rtl pages it right-to-left. Takes precedence over the
|
|
123
|
+
spread layout when both are set. */
|
|
124
|
+
#page.paged {
|
|
125
|
+
display: flex; flex-direction: row; flex-wrap: nowrap; gap: 0; padding: 0;
|
|
126
|
+
overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory;
|
|
127
|
+
}
|
|
128
|
+
#page.paged .page {
|
|
129
|
+
flex: 0 0 100%; width: 100%; height: 100%; margin: 0; padding: 12px;
|
|
130
|
+
scroll-snap-align: center;
|
|
131
|
+
}
|
|
132
|
+
#page.paged .page object, #page.paged .page img {
|
|
133
|
+
width: 100%; height: 100%; object-fit: contain;
|
|
134
|
+
border: 0; box-shadow: none; opacity: 1;
|
|
135
|
+
}
|
|
136
|
+
#page.paged.rtl { direction: rtl; }
|
|
46
137
|
.page object, .page img {
|
|
47
138
|
display: block; width: 100%; height: 100%;
|
|
48
|
-
border: 1px solid
|
|
139
|
+
border: 1px solid var(--page-border); box-shadow: .2em .2em var(--page-shadow);
|
|
49
140
|
background: #fff; opacity: .7; transition: opacity .25s ease;
|
|
50
141
|
}
|
|
51
|
-
.page.select object, .page.select img { opacity: 1; border-color:
|
|
142
|
+
.page.select object, .page.select img { opacity: 1; border-color: var(--divider-border); }
|
|
52
143
|
|
|
53
144
|
/* Thin scrollbars, hidden until hovered. */
|
|
54
145
|
#menu, #page { scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
|
@@ -56,3 +147,17 @@ body { font-family: verdana, sans-serif; background: #fff; }
|
|
|
56
147
|
#menu::-webkit-scrollbar, #page::-webkit-scrollbar { width: 7px; }
|
|
57
148
|
#menu::-webkit-scrollbar-thumb, #page::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; }
|
|
58
149
|
#menu:hover::-webkit-scrollbar-thumb, #page:hover::-webkit-scrollbar-thumb { background: #c4c4c4; }
|
|
150
|
+
|
|
151
|
+
/* Phones: the thumbnail column overlays the page instead of pushing it, the page
|
|
152
|
+
is always full width, and tap targets are a little larger. reader.js collapses
|
|
153
|
+
the column by default on small screens. */
|
|
154
|
+
@media (max-width: 640px) {
|
|
155
|
+
#reduce, #theme, #layout { width: 30px; height: 30px; line-height: 28px; font-size: 16px; }
|
|
156
|
+
#theme { left: 40px; }
|
|
157
|
+
#layout { left: 75px; }
|
|
158
|
+
#menu { width: 72vw; max-width: 280px; z-index: 3; box-shadow: 2px 0 14px rgba(0, 0, 0, .35); }
|
|
159
|
+
#menu .thumb img { width: 120px; }
|
|
160
|
+
#menu .thumb:hover img { width: 120px; }
|
|
161
|
+
#resize { display: none; }
|
|
162
|
+
#page { left: 0; }
|
|
163
|
+
}
|
package/reader/reader.js
CHANGED
|
@@ -24,6 +24,17 @@ async function getPageBytes(key) {
|
|
|
24
24
|
return new Uint8Array(await res.arrayBuffer())
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Lazily resolves the search index: the inline global in single-file mode, otherwise
|
|
28
|
+
// search.json fetched once on first use. A missing/failed fetch yields an empty index.
|
|
29
|
+
let searchIndexPromise = null
|
|
30
|
+
function getSearchIndex() {
|
|
31
|
+
if (searchIndexPromise) return searchIndexPromise
|
|
32
|
+
searchIndexPromise = window.__TOJIRU_SEARCH
|
|
33
|
+
? Promise.resolve(window.__TOJIRU_SEARCH)
|
|
34
|
+
: fetch('search.json').then((r) => (r.ok ? r.json() : [])).catch(() => [])
|
|
35
|
+
return searchIndexPromise
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
// UTF-8-safe base64 of an SVG string (btoa is Latin1-only; accented text needs this).
|
|
28
39
|
function svgToBase64(text) {
|
|
29
40
|
const bytes = new TextEncoder().encode(text)
|
|
@@ -92,7 +103,50 @@ function init(manifest) {
|
|
|
92
103
|
const key = `tojiru:${manifest.title}`
|
|
93
104
|
let current = 0
|
|
94
105
|
|
|
106
|
+
// Table of contents, when the document carries an outline. It sits at the top of the
|
|
107
|
+
// thumbnail column; each entry jumps to its page (goTo is hoisted, defined below).
|
|
108
|
+
// tocEntries keeps each button with its page so the current section can be highlighted.
|
|
109
|
+
const tocEntries = []
|
|
110
|
+
if (manifest.outline && manifest.outline.length) {
|
|
111
|
+
const toc = document.createElement('div')
|
|
112
|
+
toc.id = 'toc'
|
|
113
|
+
const head = document.createElement('div')
|
|
114
|
+
head.className = 'toc-head'
|
|
115
|
+
head.textContent = 'Contents'
|
|
116
|
+
toc.append(head)
|
|
117
|
+
for (const e of manifest.outline) {
|
|
118
|
+
const item = document.createElement('button')
|
|
119
|
+
item.type = 'button'
|
|
120
|
+
item.className = 'toc-item'
|
|
121
|
+
item.textContent = e.title
|
|
122
|
+
item.title = e.title
|
|
123
|
+
item.style.paddingLeft = `${8 + Math.min(e.depth, 5) * 12}px`
|
|
124
|
+
item.addEventListener('click', () => goTo(e.page))
|
|
125
|
+
toc.append(item)
|
|
126
|
+
tocEntries.push({ el: item, page: e.page })
|
|
127
|
+
}
|
|
128
|
+
menu.append(toc)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Highlights the outline entry covering page n: the last one whose page is ≤ n.
|
|
132
|
+
let activeToc = -1
|
|
133
|
+
function updateTocActive(n) {
|
|
134
|
+
if (!tocEntries.length) return
|
|
135
|
+
let idx = -1
|
|
136
|
+
for (let i = 0; i < tocEntries.length; i++) if (tocEntries[i].page <= n) idx = i
|
|
137
|
+
if (idx === activeToc) return
|
|
138
|
+
tocEntries[activeToc]?.el.classList.remove('active')
|
|
139
|
+
activeToc = idx
|
|
140
|
+
tocEntries[idx]?.el.classList.add('active')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Each thumbnail is a real <button> so it can be reached and activated by
|
|
144
|
+
// keyboard, not just clicked. The .select class lives on the button.
|
|
95
145
|
const thumbs = manifest.pages.map((p) => {
|
|
146
|
+
const btn = document.createElement('button')
|
|
147
|
+
btn.type = 'button'
|
|
148
|
+
btn.className = 'thumb'
|
|
149
|
+
btn.setAttribute('aria-label', `Go to page ${p.n}`)
|
|
96
150
|
const t = document.createElement('img')
|
|
97
151
|
const inlinePages = window.__TOJIRU_PAGES
|
|
98
152
|
if (inlinePages && Object.prototype.hasOwnProperty.call(inlinePages, p.thumb)) {
|
|
@@ -101,16 +155,32 @@ function init(manifest) {
|
|
|
101
155
|
t.src = p.thumb
|
|
102
156
|
}
|
|
103
157
|
t.loading = 'lazy'
|
|
104
|
-
t.alt =
|
|
105
|
-
t.addEventListener('click', () => goTo(p.n))
|
|
158
|
+
t.alt = ''
|
|
106
159
|
const num = document.createElement('span')
|
|
107
160
|
num.className = 'num'
|
|
108
161
|
num.textContent = String(p.n)
|
|
109
|
-
|
|
110
|
-
|
|
162
|
+
btn.append(t, num)
|
|
163
|
+
btn.addEventListener('click', () => goTo(p.n))
|
|
164
|
+
menu.append(btn)
|
|
165
|
+
return btn
|
|
111
166
|
})
|
|
112
167
|
|
|
113
|
-
|
|
168
|
+
// Double-page spread / right-to-left layout, when the build asked for it. The classes
|
|
169
|
+
// drive the CSS; rtl also flips the horizontal navigation keys further down.
|
|
170
|
+
pageEl.classList.toggle('spread', !!manifest.spread)
|
|
171
|
+
pageEl.classList.toggle('rtl', !!manifest.rtl)
|
|
172
|
+
|
|
173
|
+
// Reading layout: 'scroll' (vertical continuous) or 'paged' (one page per screen). A
|
|
174
|
+
// saved choice wins; otherwise the build's default (manifest.layout) applies.
|
|
175
|
+
let layout = (() => {
|
|
176
|
+
try { const saved = localStorage.getItem('tojiru:layout'); if (saved === 'paged' || saved === 'scroll') return saved } catch {}
|
|
177
|
+
return manifest.layout === 'paged' ? 'paged' : 'scroll'
|
|
178
|
+
})()
|
|
179
|
+
const paged = () => layout === 'paged'
|
|
180
|
+
pageEl.classList.toggle('paged', paged())
|
|
181
|
+
|
|
182
|
+
// 800px on every side so the next page preloads whichever way the reader scrolls.
|
|
183
|
+
const io = new IntersectionObserver(onIntersect, { root: pageEl, rootMargin: '800px' })
|
|
114
184
|
const containers = manifest.pages.map((p) => {
|
|
115
185
|
const c = document.createElement('div')
|
|
116
186
|
c.className = 'page'
|
|
@@ -140,28 +210,138 @@ function init(manifest) {
|
|
|
140
210
|
thumbs[current - 1]?.classList.add('select')
|
|
141
211
|
containers[current - 1]?.classList.add('select')
|
|
142
212
|
thumbs[current - 1]?.scrollIntoView({ block: 'nearest' })
|
|
213
|
+
updateTocActive(n)
|
|
143
214
|
history.replaceState(null, '', `#page=${n}`)
|
|
144
215
|
try { localStorage.setItem(key, String(n)) } catch {}
|
|
145
216
|
}
|
|
146
217
|
|
|
147
218
|
function goTo(n) {
|
|
148
219
|
n = Math.min(Math.max(1, n), manifest.pages.length)
|
|
149
|
-
|
|
220
|
+
// Paged scrolls horizontally to centre the page; scroll mode aligns it to the top.
|
|
221
|
+
containers[n - 1].scrollIntoView(paged() ? { inline: 'center', block: 'nearest' } : undefined)
|
|
150
222
|
setCurrent(n)
|
|
151
223
|
}
|
|
152
224
|
|
|
153
225
|
pageEl.addEventListener('scroll', () => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
226
|
+
if (paged()) {
|
|
227
|
+
// Horizontal: the page whose box spans the viewport's centre x is current. Using
|
|
228
|
+
// getBoundingClientRect keeps this correct under rtl, where scrollLeft flips sign.
|
|
229
|
+
const r = pageEl.getBoundingClientRect()
|
|
230
|
+
const cx = r.left + r.width / 2
|
|
231
|
+
for (let i = 0; i < containers.length; i++) {
|
|
232
|
+
const cr = containers[i].getBoundingClientRect()
|
|
233
|
+
if (cr.left <= cx && cx < cr.right) { setCurrent(i + 1); break }
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
const mid = pageEl.scrollTop + pageEl.clientHeight / 2
|
|
237
|
+
for (let i = 0; i < containers.length; i++) {
|
|
238
|
+
const c = containers[i]
|
|
239
|
+
if (c.offsetTop <= mid && c.offsetTop + c.offsetHeight > mid) { setCurrent(i + 1); break }
|
|
240
|
+
}
|
|
158
241
|
}
|
|
159
242
|
}, { passive: true })
|
|
160
243
|
|
|
244
|
+
const isNarrow = () => matchMedia('(max-width: 640px)').matches
|
|
245
|
+
|
|
161
246
|
$('#reduce').addEventListener('click', () => {
|
|
162
247
|
const hidden = menu.classList.toggle('hidden')
|
|
163
|
-
|
|
164
|
-
|
|
248
|
+
// On phones the column overlays the page (CSS keeps #page full width), so the
|
|
249
|
+
// divider and the page offset only matter on wide screens.
|
|
250
|
+
if (!isNarrow()) {
|
|
251
|
+
resize.classList.toggle('hidden', hidden)
|
|
252
|
+
pageEl.classList.toggle('full', hidden)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// Dark-mode toggle. The current theme is data-theme on <html> (set early by the
|
|
257
|
+
// inline head script for saved overrides); with no override we follow the system.
|
|
258
|
+
$('#theme').addEventListener('click', () => {
|
|
259
|
+
const root = document.documentElement
|
|
260
|
+
const sysDark = matchMedia('(prefers-color-scheme: dark)').matches
|
|
261
|
+
const current = root.dataset.theme || (sysDark ? 'dark' : 'light')
|
|
262
|
+
const next = current === 'dark' ? 'light' : 'dark'
|
|
263
|
+
root.dataset.theme = next
|
|
264
|
+
try { localStorage.setItem('tojiru:theme', next) } catch {}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Reading-layout toggle: continuous scroll (≣) ↔ paged (▭). The icon shows the
|
|
268
|
+
// current mode; switching re-centres the current page in the new axis.
|
|
269
|
+
const layoutBtn = $('#layout')
|
|
270
|
+
const syncLayoutBtn = () => { layoutBtn.textContent = paged() ? '▭' : '≣' }
|
|
271
|
+
syncLayoutBtn()
|
|
272
|
+
layoutBtn.addEventListener('click', () => {
|
|
273
|
+
layout = paged() ? 'scroll' : 'paged'
|
|
274
|
+
pageEl.classList.toggle('paged', paged())
|
|
275
|
+
try { localStorage.setItem('tojiru:layout', layout) } catch {}
|
|
276
|
+
syncLayoutBtn()
|
|
277
|
+
if (current) goTo(current)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// --- Full-text search (only when the build shipped an index) ---
|
|
281
|
+
const searchBox = $('#search')
|
|
282
|
+
const searchInput = $('#search-input')
|
|
283
|
+
const searchResults = $('#search-results')
|
|
284
|
+
let searchTimer = 0
|
|
285
|
+
|
|
286
|
+
const openSearch = () => { searchBox.classList.remove('hidden'); searchInput.focus(); searchInput.select() }
|
|
287
|
+
// preventScroll: focusing the scroll container must not yank it back and cancel the
|
|
288
|
+
// goTo() jump that runs just before closing.
|
|
289
|
+
const closeSearch = () => { searchBox.classList.add('hidden'); pageEl.focus({ preventScroll: true }) }
|
|
290
|
+
|
|
291
|
+
// Builds a one-line excerpt around the first match in `text` for query `q` (lowercase).
|
|
292
|
+
function snippet(text, q) {
|
|
293
|
+
const idx = text.toLowerCase().indexOf(q)
|
|
294
|
+
if (idx < 0) return null
|
|
295
|
+
const start = Math.max(0, idx - 30)
|
|
296
|
+
const end = Math.min(text.length, idx + q.length + 60)
|
|
297
|
+
return {
|
|
298
|
+
pre: (start > 0 ? '… ' : '') + text.slice(start, idx),
|
|
299
|
+
match: text.slice(idx, idx + q.length),
|
|
300
|
+
post: text.slice(idx + q.length, end) + (end < text.length ? ' …' : ''),
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function runSearch() {
|
|
305
|
+
const q = searchInput.value.trim().toLowerCase()
|
|
306
|
+
searchResults.replaceChildren()
|
|
307
|
+
if (q.length < 2) return
|
|
308
|
+
const index = await getSearchIndex()
|
|
309
|
+
const hits = []
|
|
310
|
+
for (const e of index) {
|
|
311
|
+
const s = snippet(e.t, q)
|
|
312
|
+
if (s) hits.push({ n: e.n, s })
|
|
313
|
+
if (hits.length >= 60) break
|
|
314
|
+
}
|
|
315
|
+
if (hits.length === 0) {
|
|
316
|
+
const empty = document.createElement('div')
|
|
317
|
+
empty.className = 'empty'
|
|
318
|
+
empty.textContent = 'No matches'
|
|
319
|
+
searchResults.append(empty)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
for (const h of hits) {
|
|
323
|
+
const btn = document.createElement('button')
|
|
324
|
+
btn.type = 'button'
|
|
325
|
+
btn.className = 'hit'
|
|
326
|
+
const pno = document.createElement('span')
|
|
327
|
+
pno.className = 'pno'
|
|
328
|
+
pno.textContent = `p.${h.n}`
|
|
329
|
+
const mark = document.createElement('mark')
|
|
330
|
+
mark.textContent = h.s.match
|
|
331
|
+
// textContent/createTextNode keep page text inert — no HTML injection from the PDF.
|
|
332
|
+
btn.append(pno, document.createTextNode(h.s.pre), mark, document.createTextNode(h.s.post))
|
|
333
|
+
btn.addEventListener('click', () => { goTo(h.n); closeSearch() })
|
|
334
|
+
searchResults.append(btn)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
searchInput.addEventListener('input', () => {
|
|
339
|
+
clearTimeout(searchTimer)
|
|
340
|
+
searchTimer = setTimeout(runSearch, 120)
|
|
341
|
+
})
|
|
342
|
+
searchInput.addEventListener('keydown', (ev) => {
|
|
343
|
+
if (ev.key === 'Escape') { closeSearch(); ev.preventDefault() }
|
|
344
|
+
else if (ev.key === 'Enter') { searchResults.querySelector('.hit')?.click(); ev.preventDefault() }
|
|
165
345
|
})
|
|
166
346
|
|
|
167
347
|
// Draggable divider between the thumbnail column and the page area.
|
|
@@ -183,12 +363,31 @@ function init(manifest) {
|
|
|
183
363
|
})
|
|
184
364
|
|
|
185
365
|
document.addEventListener('keydown', (ev) => {
|
|
186
|
-
|
|
187
|
-
|
|
366
|
+
// Open search on Ctrl/Cmd+F or "/", but only when a search index shipped — without
|
|
367
|
+
// one, leave the browser's native find untouched.
|
|
368
|
+
if (manifest.searchable && ((ev.key === 'f' && (ev.ctrlKey || ev.metaKey)) ||
|
|
369
|
+
(ev.key === '/' && !(ev.target instanceof HTMLInputElement)))) {
|
|
370
|
+
openSearch(); ev.preventDefault(); return
|
|
371
|
+
}
|
|
372
|
+
// Don't let page navigation steal keys while typing in the search box.
|
|
373
|
+
if (ev.target instanceof HTMLInputElement) return
|
|
374
|
+
// In RTL (manga), left advances and right goes back; vertical keys are unchanged.
|
|
375
|
+
const fwd = manifest.rtl ? 'ArrowLeft' : 'ArrowRight'
|
|
376
|
+
const back = manifest.rtl ? 'ArrowRight' : 'ArrowLeft'
|
|
377
|
+
if (['ArrowDown', ' ', 'PageDown', 'n', fwd].includes(ev.key)) { goTo(current + 1); ev.preventDefault() }
|
|
378
|
+
else if (['ArrowUp', 'PageUp', 'p', back].includes(ev.key)) { goTo(current - 1); ev.preventDefault() }
|
|
188
379
|
else if (ev.key === 'Home') goTo(1)
|
|
189
380
|
else if (ev.key === 'End') goTo(manifest.pages.length)
|
|
190
381
|
})
|
|
191
382
|
|
|
383
|
+
// On phones, start with the thumbnail column collapsed so the page gets the full
|
|
384
|
+
// width; the ☰ button reveals it as an overlay.
|
|
385
|
+
if (isNarrow()) {
|
|
386
|
+
menu.classList.add('hidden')
|
|
387
|
+
resize.classList.add('hidden')
|
|
388
|
+
pageEl.classList.add('full')
|
|
389
|
+
}
|
|
390
|
+
|
|
192
391
|
const fromHash = location.hash.match(/page=(\d+)/)
|
|
193
392
|
const saved = (() => { try { return Number(localStorage.getItem(key)) } catch { return 0 } })()
|
|
194
393
|
goTo(fromHash ? Number(fromHash[1]) : saved || 1)
|