wolfy-reader 0.1.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/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/core/book.d.ts +75 -0
- package/dist/core/book.d.ts.map +1 -0
- package/dist/core/book.js +2 -0
- package/dist/core/book.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.d.ts +13 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +2 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/graphemes.d.ts +36 -0
- package/dist/core/graphemes.d.ts.map +1 -0
- package/dist/core/graphemes.js +62 -0
- package/dist/core/graphemes.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/lookup.d.ts +10 -0
- package/dist/core/lookup.d.ts.map +1 -0
- package/dist/core/lookup.js +16 -0
- package/dist/core/lookup.js.map +1 -0
- package/dist/core/open.d.ts +12 -0
- package/dist/core/open.d.ts.map +1 -0
- package/dist/core/open.js +23 -0
- package/dist/core/open.js.map +1 -0
- package/dist/core/position.d.ts +121 -0
- package/dist/core/position.d.ts.map +1 -0
- package/dist/core/position.js +327 -0
- package/dist/core/position.js.map +1 -0
- package/dist/core/reading-text.d.ts +77 -0
- package/dist/core/reading-text.d.ts.map +1 -0
- package/dist/core/reading-text.js +144 -0
- package/dist/core/reading-text.js.map +1 -0
- package/dist/core/source.d.ts +17 -0
- package/dist/core/source.d.ts.map +1 -0
- package/dist/core/source.js +39 -0
- package/dist/core/source.js.map +1 -0
- package/dist/core/storage.d.ts +7 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +2 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +18 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +32 -0
- package/dist/core/text.js.map +1 -0
- package/dist/formats/epub/href.d.ts +14 -0
- package/dist/formats/epub/href.d.ts.map +1 -0
- package/dist/formats/epub/href.js +52 -0
- package/dist/formats/epub/href.js.map +1 -0
- package/dist/formats/epub/index.d.ts +3 -0
- package/dist/formats/epub/index.d.ts.map +1 -0
- package/dist/formats/epub/index.js +223 -0
- package/dist/formats/epub/index.js.map +1 -0
- package/dist/formats/epub/opf.d.ts +27 -0
- package/dist/formats/epub/opf.d.ts.map +1 -0
- package/dist/formats/epub/opf.js +110 -0
- package/dist/formats/epub/opf.js.map +1 -0
- package/dist/formats/epub/toc.d.ts +6 -0
- package/dist/formats/epub/toc.d.ts.map +1 -0
- package/dist/formats/epub/toc.js +88 -0
- package/dist/formats/epub/toc.js.map +1 -0
- package/dist/formats/fb2/index.d.ts +13 -0
- package/dist/formats/fb2/index.d.ts.map +1 -0
- package/dist/formats/fb2/index.js +317 -0
- package/dist/formats/fb2/index.js.map +1 -0
- package/dist/formats/index.d.ts +4 -0
- package/dist/formats/index.d.ts.map +1 -0
- package/dist/formats/index.js +4 -0
- package/dist/formats/index.js.map +1 -0
- package/dist/formats/text/index.d.ts +11 -0
- package/dist/formats/text/index.d.ts.map +1 -0
- package/dist/formats/text/index.js +201 -0
- package/dist/formats/text/index.js.map +1 -0
- package/dist/formats/xml.d.ts +33 -0
- package/dist/formats/xml.d.ts.map +1 -0
- package/dist/formats/xml.js +321 -0
- package/dist/formats/xml.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/chunk.d.ts +51 -0
- package/dist/layout/chunk.d.ts.map +1 -0
- package/dist/layout/chunk.js +131 -0
- package/dist/layout/chunk.js.map +1 -0
- package/dist/layout/index.d.ts +238 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +476 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/reader/index.d.ts +231 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +958 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/input.d.ts +69 -0
- package/dist/reader/input.d.ts.map +1 -0
- package/dist/reader/input.js +75 -0
- package/dist/reader/input.js.map +1 -0
- package/dist/search/extract.d.ts +51 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +401 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +11 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +11 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/matcher.d.ts +56 -0
- package/dist/search/matcher.d.ts.map +1 -0
- package/dist/search/matcher.js +128 -0
- package/dist/search/matcher.js.map +1 -0
- package/dist/search/normalize.d.ts +48 -0
- package/dist/search/normalize.d.ts.map +1 -0
- package/dist/search/normalize.js +103 -0
- package/dist/search/normalize.js.map +1 -0
- package/dist/view/allowlist.d.ts +59 -0
- package/dist/view/allowlist.d.ts.map +1 -0
- package/dist/view/allowlist.js +198 -0
- package/dist/view/allowlist.js.map +1 -0
- package/dist/view/appearance.d.ts +137 -0
- package/dist/view/appearance.d.ts.map +1 -0
- package/dist/view/appearance.js +274 -0
- package/dist/view/appearance.js.map +1 -0
- package/dist/view/css.d.ts +24 -0
- package/dist/view/css.d.ts.map +1 -0
- package/dist/view/css.js +153 -0
- package/dist/view/css.js.map +1 -0
- package/dist/view/frame.d.ts +54 -0
- package/dist/view/frame.d.ts.map +1 -0
- package/dist/view/frame.js +980 -0
- package/dist/view/frame.js.map +1 -0
- package/dist/view/host.d.ts +160 -0
- package/dist/view/host.d.ts.map +1 -0
- package/dist/view/host.js +385 -0
- package/dist/view/host.js.map +1 -0
- package/dist/view/index.d.ts +12 -0
- package/dist/view/index.d.ts.map +1 -0
- package/dist/view/index.js +7 -0
- package/dist/view/index.js.map +1 -0
- package/dist/view/protocol.d.ts +220 -0
- package/dist/view/protocol.d.ts.map +1 -0
- package/dist/view/protocol.js +246 -0
- package/dist/view/protocol.js.map +1 -0
- package/dist/view/reference.d.ts +18 -0
- package/dist/view/reference.d.ts.map +1 -0
- package/dist/view/reference.js +25 -0
- package/dist/view/reference.js.map +1 -0
- package/dist/view/resources.d.ts +51 -0
- package/dist/view/resources.d.ts.map +1 -0
- package/dist/view/resources.js +242 -0
- package/dist/view/resources.js.map +1 -0
- package/dist/view/sanitize.d.ts +32 -0
- package/dist/view/sanitize.d.ts.map +1 -0
- package/dist/view/sanitize.js +226 -0
- package/dist/view/sanitize.js.map +1 -0
- package/dist/zip/central.d.ts +12 -0
- package/dist/zip/central.d.ts.map +1 -0
- package/dist/zip/central.js +72 -0
- package/dist/zip/central.js.map +1 -0
- package/dist/zip/crc32.d.ts +3 -0
- package/dist/zip/crc32.d.ts.map +1 -0
- package/dist/zip/crc32.js +28 -0
- package/dist/zip/crc32.js.map +1 -0
- package/dist/zip/eocd.d.ts +9 -0
- package/dist/zip/eocd.d.ts.map +1 -0
- package/dist/zip/eocd.js +84 -0
- package/dist/zip/eocd.js.map +1 -0
- package/dist/zip/errors.d.ts +19 -0
- package/dist/zip/errors.d.ts.map +1 -0
- package/dist/zip/errors.js +37 -0
- package/dist/zip/errors.js.map +1 -0
- package/dist/zip/index.d.ts +18 -0
- package/dist/zip/index.d.ts.map +1 -0
- package/dist/zip/index.js +78 -0
- package/dist/zip/index.js.map +1 -0
- package/dist/zip/inflate.d.ts +2 -0
- package/dist/zip/inflate.d.ts.map +1 -0
- package/dist/zip/inflate.js +43 -0
- package/dist/zip/inflate.js.map +1 -0
- package/dist/zip/source.d.ts +15 -0
- package/dist/zip/source.d.ts.map +1 -0
- package/dist/zip/source.js +34 -0
- package/dist/zip/source.js.map +1 -0
- package/package.json +75 -0
- package/src/core/book.ts +80 -0
- package/src/core/errors.ts +31 -0
- package/src/core/format.ts +14 -0
- package/src/core/graphemes.ts +73 -0
- package/src/core/index.ts +27 -0
- package/src/core/lookup.ts +16 -0
- package/src/core/open.ts +31 -0
- package/src/core/position.ts +485 -0
- package/src/core/reading-text.ts +200 -0
- package/src/core/source.ts +61 -0
- package/src/core/storage.ts +6 -0
- package/src/core/text.ts +35 -0
- package/src/formats/epub/href.ts +54 -0
- package/src/formats/epub/index.ts +235 -0
- package/src/formats/epub/opf.ts +143 -0
- package/src/formats/epub/toc.ts +120 -0
- package/src/formats/fb2/index.ts +348 -0
- package/src/formats/index.ts +3 -0
- package/src/formats/text/index.ts +208 -0
- package/src/formats/xml.ts +326 -0
- package/src/index.ts +26 -0
- package/src/layout/chunk.ts +169 -0
- package/src/layout/index.ts +573 -0
- package/src/reader/index.ts +1206 -0
- package/src/reader/input.ts +122 -0
- package/src/search/extract.ts +403 -0
- package/src/search/index.ts +10 -0
- package/src/search/matcher.ts +187 -0
- package/src/search/normalize.ts +122 -0
- package/src/view/allowlist.ts +273 -0
- package/src/view/appearance.ts +380 -0
- package/src/view/css.ts +172 -0
- package/src/view/frame.ts +1018 -0
- package/src/view/host.ts +527 -0
- package/src/view/index.ts +24 -0
- package/src/view/protocol.ts +412 -0
- package/src/view/reference.ts +30 -0
- package/src/view/resources.ts +265 -0
- package/src/view/sanitize.ts +266 -0
- package/src/zip/central.ts +85 -0
- package/src/zip/crc32.ts +30 -0
- package/src/zip/eocd.ts +97 -0
- package/src/zip/errors.ts +41 -0
- package/src/zip/index.ts +114 -0
- package/src/zip/inflate.ts +41 -0
- package/src/zip/source.ts +51 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paginator engine: wraps a {@link ContentHost} over a container element and
|
|
3
|
+
* exposes page-oriented navigation, page↔`Position` mapping, and progress on top
|
|
4
|
+
* of the frame's chunked layout. This is the engine the reader facade (M2-4)
|
|
5
|
+
* consumes; it owns no host vocabulary and never fetches or persists.
|
|
6
|
+
*
|
|
7
|
+
* Layout, measurement, chunking, and eviction all live in the frame (Slice 1 —
|
|
8
|
+
* `ContentHost`); this module orchestrates them and bridges page geometry to the
|
|
9
|
+
* headless `Position` model in `src/core`. Core is *called*, never changed: a page
|
|
10
|
+
* maps to a `Position` through `offsetOfPage` → `capturePosition`, and a
|
|
11
|
+
* `Position` maps back through `resolvePosition` → `pageOfOffset`.
|
|
12
|
+
*/
|
|
13
|
+
import { capturePosition, resolvePosition, type Position, type Section } from '../core/index.ts';
|
|
14
|
+
import {
|
|
15
|
+
countGraphemes,
|
|
16
|
+
graphemeIndexToCodeUnitOffset as graphemeIndexToOffset,
|
|
17
|
+
segmentGraphemes,
|
|
18
|
+
type Grapheme,
|
|
19
|
+
} from '../core/graphemes.ts';
|
|
20
|
+
import { DEFAULT_CHUNK_CHARS } from './chunk.ts';
|
|
21
|
+
import { ContentHost, type ContentHostOptions } from '../view/host.ts';
|
|
22
|
+
import type { LayoutMode, PaginateOptions, PaginationState } from '../view/protocol.ts';
|
|
23
|
+
|
|
24
|
+
export type { LayoutMode, PaginateOptions, PaginationState } from '../view/protocol.ts';
|
|
25
|
+
|
|
26
|
+
/** How many chunks either side of the active one stay realized (eviction window). */
|
|
27
|
+
const DEFAULT_WINDOW_CHUNKS = 2;
|
|
28
|
+
/** Gap between text columns, in CSS px. */
|
|
29
|
+
const DEFAULT_COLUMN_GAP = 40;
|
|
30
|
+
/** Text columns per page. */
|
|
31
|
+
const DEFAULT_COLUMN_COUNT = 1;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The parts of {@link PaginateOptions} a caller may set without also supplying
|
|
35
|
+
* the viewport geometry, which the paginator measures from its container. Every
|
|
36
|
+
* field is optional; unset fields fall back to the paginator defaults.
|
|
37
|
+
*/
|
|
38
|
+
export interface PaginateRequest {
|
|
39
|
+
readonly mode?: LayoutMode;
|
|
40
|
+
/** Page box width in CSS px. Defaults to the container's client width. */
|
|
41
|
+
readonly pageWidth?: number;
|
|
42
|
+
/** Page box height in CSS px. Defaults to the container's client height. */
|
|
43
|
+
readonly pageHeight?: number;
|
|
44
|
+
readonly columnGap?: number;
|
|
45
|
+
readonly chunkChars?: number;
|
|
46
|
+
readonly windowChunks?: number;
|
|
47
|
+
/** Text columns per page (1 or 2). Defaults to 1. */
|
|
48
|
+
readonly columnCount?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Chapter-local layout state: the current page, the total (estimate or firm),
|
|
53
|
+
* and whether the total has been measured across every chunk yet.
|
|
54
|
+
*/
|
|
55
|
+
export interface ChapterProgress {
|
|
56
|
+
readonly mode: LayoutMode;
|
|
57
|
+
/** 0-based page currently shown. */
|
|
58
|
+
readonly page: number;
|
|
59
|
+
/** Total pages — an estimate until {@link firm}. */
|
|
60
|
+
readonly totalPages: number;
|
|
61
|
+
/** True once every chunk is measured; false while {@link totalPages} churns. */
|
|
62
|
+
readonly firm: boolean;
|
|
63
|
+
/** Fraction through the chapter, 0..1. */
|
|
64
|
+
readonly fraction: number;
|
|
65
|
+
readonly realizedChunks: number;
|
|
66
|
+
readonly totalChunks: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the reader is in the whole book. Section counts come from the facade;
|
|
71
|
+
* only the current section is laid out, so any span across other sections is an
|
|
72
|
+
* even-weight approximation — {@link approximate} says so.
|
|
73
|
+
*/
|
|
74
|
+
export interface BookProgress extends ChapterProgress {
|
|
75
|
+
/** 0-based index of the current section in reading order. */
|
|
76
|
+
readonly sectionIndex: number;
|
|
77
|
+
/** Total sections in the book. */
|
|
78
|
+
readonly sectionCount: number;
|
|
79
|
+
/** Fraction through the book, 0..1 — even-weight across sections. */
|
|
80
|
+
readonly bookFraction: number;
|
|
81
|
+
/** Always true: sibling sections are unlaid, so the book span is an estimate. */
|
|
82
|
+
readonly approximate: true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PaginatorDiagnostics {
|
|
86
|
+
readonly domNodes: number;
|
|
87
|
+
readonly realizedChunks: number;
|
|
88
|
+
readonly totalChunks: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Something went wrong driving the paginator; never a book decoding failure. */
|
|
92
|
+
export class PaginatorError extends Error {
|
|
93
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
94
|
+
super(message, options);
|
|
95
|
+
this.name = 'PaginatorError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Drives chunked pagination of one section at a time inside a sandboxed frame.
|
|
101
|
+
*
|
|
102
|
+
* Lifecycle: construct over a container, {@link paginate} a section, navigate
|
|
103
|
+
* with {@link goToPage}/{@link nextPage}/{@link previousPage}, map pages to
|
|
104
|
+
* `Position`s, and {@link destroy} to tear the frame down. A single instance
|
|
105
|
+
* paginates successive sections in turn; re-paginating replaces the active one.
|
|
106
|
+
*/
|
|
107
|
+
export class Paginator {
|
|
108
|
+
readonly #host: ContentHost;
|
|
109
|
+
readonly #container: HTMLElement;
|
|
110
|
+
#section: Section | null = null;
|
|
111
|
+
#options: PaginateOptions | null = null;
|
|
112
|
+
#state: PaginationState | null = null;
|
|
113
|
+
#page = 0;
|
|
114
|
+
/** Cached section text, invalidated on each {@link paginate}. */
|
|
115
|
+
#text: string | null = null;
|
|
116
|
+
/**
|
|
117
|
+
* Live decoration intent, held for the paginator lifetime so a decoration can be
|
|
118
|
+
* re-anchored and re-drawn after a re-layout (font-size, mode switch, appearance).
|
|
119
|
+
* Draw-only: this is the sole decoration state the engine keeps, and it never
|
|
120
|
+
* leaves memory — nothing is persisted, serialized, or fetched. Keyed by the
|
|
121
|
+
* caller's id; the `className` rides through to the overlay boxes.
|
|
122
|
+
*/
|
|
123
|
+
readonly #decorations = new Map<string, { position: Position; className: string }>();
|
|
124
|
+
|
|
125
|
+
constructor(container: HTMLElement, options: ContentHostOptions = {}) {
|
|
126
|
+
this.#container = container;
|
|
127
|
+
this.#host = new ContentHost(container, options);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The underlying host, for callers that need the raw frame surface. */
|
|
131
|
+
get host(): ContentHost {
|
|
132
|
+
return this.#host;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The section currently laid out, or `null` before the first {@link paginate}. */
|
|
136
|
+
get section(): Section | null {
|
|
137
|
+
return this.#section;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The effective options of the current layout, or `null` before {@link paginate}. */
|
|
141
|
+
get options(): PaginateOptions | null {
|
|
142
|
+
return this.#options;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The last pagination state, or `null` before {@link paginate}. */
|
|
146
|
+
get state(): PaginationState | null {
|
|
147
|
+
return this.#state;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The current 0-based page. */
|
|
151
|
+
get page(): number {
|
|
152
|
+
return this.#page;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Chunks and lays out `section` under `request` (merged with the paginator
|
|
157
|
+
* defaults and the container's measured viewport), resets to page 0, and
|
|
158
|
+
* returns the resulting state. The page count starts as an estimate; call
|
|
159
|
+
* {@link refine} to firm it up. Pass `mode: 'scrolled'` for scrolled layout.
|
|
160
|
+
*/
|
|
161
|
+
async paginate(section: Section, request: PaginateRequest = {}): Promise<PaginationState> {
|
|
162
|
+
const options = this.#resolveOptions(request);
|
|
163
|
+
// The text cache is keyed by section: a re-paginate of the same section (a
|
|
164
|
+
// mode switch, an appearance reflow) re-lays out the same text, so the cache
|
|
165
|
+
// survives; a different section invalidates it.
|
|
166
|
+
const sameSection = this.#section === section;
|
|
167
|
+
const state = await this.#host.paginate(section, options);
|
|
168
|
+
this.#section = section;
|
|
169
|
+
this.#options = options;
|
|
170
|
+
this.#state = state;
|
|
171
|
+
this.#page = 0;
|
|
172
|
+
if (!sameSection) this.#text = null;
|
|
173
|
+
await this.#redrawDecorations();
|
|
174
|
+
return state;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Re-lays the current section (after a viewport or typography change) and
|
|
179
|
+
* returns the fresh state. The caller preserving position across the relayout
|
|
180
|
+
* should {@link positionOfPage} before and {@link pageOfPosition} after. Text
|
|
181
|
+
* is unchanged, so its cache survives.
|
|
182
|
+
*/
|
|
183
|
+
async relayout(): Promise<PaginationState> {
|
|
184
|
+
this.#requireActive();
|
|
185
|
+
const state = await this.#host.relayout();
|
|
186
|
+
this.#state = state;
|
|
187
|
+
this.#page = clamp(this.#page, 0, Math.max(0, state.pageCount - 1));
|
|
188
|
+
return state;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Re-lays the current section in a different mode, preserving the reading
|
|
193
|
+
* position across the switch: it captures a `Position` for the current page,
|
|
194
|
+
* re-paginates, then seeks back to the page that `Position` now resolves to.
|
|
195
|
+
* Returns the new state. A resolution miss (rare — same text, same session)
|
|
196
|
+
* degrades to page 0.
|
|
197
|
+
*/
|
|
198
|
+
async switchMode(mode: LayoutMode): Promise<PaginationState> {
|
|
199
|
+
this.#requireActive();
|
|
200
|
+
if (this.#options!.mode === mode) return this.relayout();
|
|
201
|
+
const anchor = await this.positionOfPage(this.#page);
|
|
202
|
+
const state = await this.paginate(this.#section!, { ...this.#requestFromOptions(), mode });
|
|
203
|
+
const page = await this.pageOfPosition(anchor);
|
|
204
|
+
if (page >= 0) await this.goToPage(page);
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Re-paginates the current section at the container's *current* size, preserving
|
|
210
|
+
* the reading place. Page geometry is captured once at `paginate` time from the
|
|
211
|
+
* container, so a container that later resizes leaves the frame's content at the
|
|
212
|
+
* old size — it scrolls (shrunk) or leaves a gap (grown); this re-reads the size
|
|
213
|
+
* and re-flows. A no-op (returns `null`) when the size is unchanged or the
|
|
214
|
+
* container is unmeasurable (0×0, e.g. `display:none`), so a `ResizeObserver` may
|
|
215
|
+
* call it on every notification without churn. Mirrors {@link switchMode}: capture
|
|
216
|
+
* a `Position`, re-paginate, seek back (a resolution miss degrades to page 0).
|
|
217
|
+
*/
|
|
218
|
+
async resize(): Promise<PaginationState | null> {
|
|
219
|
+
this.#requireActive();
|
|
220
|
+
const pageWidth = this.#container.clientWidth;
|
|
221
|
+
const pageHeight = this.#container.clientHeight;
|
|
222
|
+
if (pageWidth === 0 || pageHeight === 0) return null;
|
|
223
|
+
if (this.#options!.pageWidth === pageWidth && this.#options!.pageHeight === pageHeight) return null;
|
|
224
|
+
const anchor = await this.positionOfPage(this.#page);
|
|
225
|
+
const state = await this.paginate(this.#section!, {
|
|
226
|
+
...this.#requestFromOptions(),
|
|
227
|
+
pageWidth,
|
|
228
|
+
pageHeight,
|
|
229
|
+
});
|
|
230
|
+
const page = await this.pageOfPosition(anchor);
|
|
231
|
+
if (page >= 0) await this.goToPage(page);
|
|
232
|
+
return state;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Drives the estimate toward the true count by re-measuring. In paginated mode
|
|
237
|
+
* the frame refines the count as chunks realize; a `relayout` forces that
|
|
238
|
+
* measurement. Returns the (possibly firm) state. Idempotent once firm.
|
|
239
|
+
*/
|
|
240
|
+
async refine(): Promise<PaginationState> {
|
|
241
|
+
this.#requireActive();
|
|
242
|
+
if (this.#state!.firm) return this.#state!;
|
|
243
|
+
return this.relayout();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Seeks to a 0-based page, clamping to range. Returns the page actually shown. */
|
|
247
|
+
async goToPage(page: number): Promise<number> {
|
|
248
|
+
this.#requireActive();
|
|
249
|
+
const shown = await this.#host.goToPage(page);
|
|
250
|
+
this.#page = shown;
|
|
251
|
+
return shown;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Turns one page forward, clamped at the last page. Returns the new page. */
|
|
255
|
+
async nextPage(): Promise<number> {
|
|
256
|
+
return this.goToPage(this.#page + 1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Turns one page back, clamped at page 0. Returns the new page. */
|
|
260
|
+
async previousPage(): Promise<number> {
|
|
261
|
+
return this.goToPage(this.#page - 1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The `Position` anchored at the start of `page`: page → character offset via
|
|
266
|
+
* `offsetOfPage`, then `capturePosition(text, offset, sectionId)` over the
|
|
267
|
+
* section text. An empty page (offset -1) captures at offset 0.
|
|
268
|
+
*/
|
|
269
|
+
async positionOfPage(page: number): Promise<Position> {
|
|
270
|
+
this.#requireActive();
|
|
271
|
+
const text = await this.#sectionText();
|
|
272
|
+
const offset = await this.#host.offsetOfPage(page);
|
|
273
|
+
return capturePosition(text, Math.max(0, offset), this.#section!.id);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The `Position` anchored over a frame-supplied UTF-16 offset range on the
|
|
278
|
+
* section text — the bridge behind selection reporting. The frame computes the
|
|
279
|
+
* range against the same tiled section text this measures against, so the
|
|
280
|
+
* `start` offset is captured directly with `capturePosition` (which takes a
|
|
281
|
+
* UTF-16 offset). The quote spans the whole selection (`start`..`end`), not a
|
|
282
|
+
* fixed default, so a host that highlights the returned Position via `decorate`
|
|
283
|
+
* paints the entire selection rather than only its first ~32 graphemes; resolving
|
|
284
|
+
* it back to a page still keys off the anchor's start.
|
|
285
|
+
*/
|
|
286
|
+
async positionOfOffsetRange(start: number, end: number): Promise<Position> {
|
|
287
|
+
this.#requireActive();
|
|
288
|
+
const text = await this.#sectionText();
|
|
289
|
+
const from = Math.max(0, start);
|
|
290
|
+
const to = Math.max(from, end);
|
|
291
|
+
return capturePosition(text, from, this.#section!.id, {
|
|
292
|
+
quoteLength: countGraphemes(text.slice(from, to)),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The 0-based page painting `position`: `resolvePosition` against the section
|
|
298
|
+
* text yields a character offset, then `pageOfOffset` maps it to a page.
|
|
299
|
+
* Returns -1 when the anchor no longer resolves (the caller degrades softly).
|
|
300
|
+
*
|
|
301
|
+
* Note the offset spaces differ: `resolvePosition` returns a *grapheme* index,
|
|
302
|
+
* while `pageOfOffset` speaks UTF-16 code units. They coincide for BMP text and
|
|
303
|
+
* for a text whose graphemes are all single code units; the conversion below
|
|
304
|
+
* maps grapheme index back to a code-unit offset so multi-unit clusters map
|
|
305
|
+
* correctly.
|
|
306
|
+
*/
|
|
307
|
+
async pageOfPosition(position: Position): Promise<number> {
|
|
308
|
+
this.#requireActive();
|
|
309
|
+
const text = await this.#sectionText();
|
|
310
|
+
const resolved = resolvePosition(position, text);
|
|
311
|
+
if (resolved === undefined) return -1;
|
|
312
|
+
const offset = graphemeIndexToCodeUnitOffset(text, resolved.offset);
|
|
313
|
+
return this.#host.pageOfOffset(offset);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The 0-based page painting the element carrying `elementId`, or -1 when no
|
|
318
|
+
* element in the section carries that id (the caller degrades softly). Bridges
|
|
319
|
+
* fragment anchoring: `offsetOfElementId` in the frame yields the element's
|
|
320
|
+
* character offset, then `pageOfOffset` maps it to a page.
|
|
321
|
+
*/
|
|
322
|
+
async pageOfElementId(elementId: string): Promise<number> {
|
|
323
|
+
this.#requireActive();
|
|
324
|
+
const offset = await this.#host.offsetOfElementId(elementId);
|
|
325
|
+
if (offset < 0) return -1;
|
|
326
|
+
return this.#host.pageOfOffset(offset);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Draws a decoration over the range `position` resolves to in the current section,
|
|
331
|
+
* and holds the intent so it survives a re-layout. Draw-only: the paginator keeps
|
|
332
|
+
* only the in-memory intent (id → position + class) and never persists it. The
|
|
333
|
+
* `position` is resolved against the frame-measured section text; its resolved
|
|
334
|
+
* grapheme span is converted to a UTF-16 offset range the frame paints over. A
|
|
335
|
+
* soft-miss — the anchor no longer resolves, or belongs to a section other than the
|
|
336
|
+
* one laid out — draws nothing and throws nothing; the intent is still retained so a
|
|
337
|
+
* later re-layout that brings the anchor back into view redraws it.
|
|
338
|
+
*/
|
|
339
|
+
async decorate(decorationId: string, position: Position, className: string): Promise<void> {
|
|
340
|
+
this.#requireActive();
|
|
341
|
+
this.#decorations.set(decorationId, { position, className });
|
|
342
|
+
await this.#drawDecoration(decorationId, position, className);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Removes a decoration and drops its intent. A no-op for an unknown id. */
|
|
346
|
+
async undecorate(decorationId: string): Promise<void> {
|
|
347
|
+
const existed = this.#decorations.delete(decorationId);
|
|
348
|
+
if (this.#section === null) return;
|
|
349
|
+
if (existed) await this.#host.undecorate(decorationId);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolves `position` to a UTF-16 offset range over the section text and sends the
|
|
354
|
+
* frame the draw. A resolution miss (or a `Position` from another section) is a
|
|
355
|
+
* soft-miss: the frame is told to draw nothing (an empty range), never an error.
|
|
356
|
+
*/
|
|
357
|
+
async #drawDecoration(decorationId: string, position: Position, className: string): Promise<void> {
|
|
358
|
+
const text = await this.#sectionText();
|
|
359
|
+
if (position.sectionId !== this.#section!.id) {
|
|
360
|
+
await this.#host.undecorate(decorationId);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const resolved = resolvePosition(position, text);
|
|
364
|
+
if (resolved === undefined) {
|
|
365
|
+
await this.#host.undecorate(decorationId);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const start = graphemeIndexToCodeUnitOffset(text, resolved.offset);
|
|
369
|
+
const end = graphemeIndexToCodeUnitOffset(text, resolved.offset + resolved.length);
|
|
370
|
+
await this.#host.decorate(decorationId, start, end, className);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Re-resolves and redraws every live decoration against the current section text —
|
|
375
|
+
* the M1-2 re-anchor pipeline, reused for decorations. Called after a re-layout so
|
|
376
|
+
* a highlight tracks the same text through a font-size or mode change. A decoration
|
|
377
|
+
* whose section is not the active one is simply not drawn (its intent is kept).
|
|
378
|
+
*/
|
|
379
|
+
async #redrawDecorations(): Promise<void> {
|
|
380
|
+
if (this.#decorations.size === 0) return;
|
|
381
|
+
for (const [decorationId, { position, className }] of this.#decorations) {
|
|
382
|
+
await this.#drawDecoration(decorationId, position, className);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Within-chapter progress: current page, total (estimate/firm), and fraction. */
|
|
387
|
+
chapterProgress(): ChapterProgress {
|
|
388
|
+
this.#requireActive();
|
|
389
|
+
const state = this.#state!;
|
|
390
|
+
const totalPages = Math.max(1, state.pageCount);
|
|
391
|
+
return {
|
|
392
|
+
mode: this.#options!.mode,
|
|
393
|
+
page: this.#page,
|
|
394
|
+
totalPages: state.pageCount,
|
|
395
|
+
firm: state.firm,
|
|
396
|
+
fraction: totalPages <= 1 ? 0 : this.#page / (totalPages - 1),
|
|
397
|
+
realizedChunks: state.realizedChunks,
|
|
398
|
+
totalChunks: state.totalChunks,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Within-book progress. The facade supplies the current section's index and the
|
|
404
|
+
* book's section count; only this section is laid out, so the book fraction is
|
|
405
|
+
* an even-weight approximation across sections and {@link BookProgress.approximate}
|
|
406
|
+
* is always true.
|
|
407
|
+
*/
|
|
408
|
+
bookProgress(sectionIndex: number, sectionCount: number): BookProgress {
|
|
409
|
+
const chapter = this.chapterProgress();
|
|
410
|
+
const sections = Math.max(1, sectionCount);
|
|
411
|
+
const bookFraction = clamp((sectionIndex + chapter.fraction) / sections, 0, 1);
|
|
412
|
+
return { ...chapter, sectionIndex, sectionCount, bookFraction, approximate: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Applies a new appearance theme stylesheet (see `themeStyleSheet`) to the
|
|
417
|
+
* current section, preserving the reading position across the swap. A theme is
|
|
418
|
+
* colours + background only, so chunk geometry is invariant under it: the same
|
|
419
|
+
* markup re-lays out to the same page count and the captured `Position` resolves
|
|
420
|
+
* back to the exact same page — the reader's place is held, not reset to 0 and
|
|
421
|
+
* not drifted, and the text does not reflow. The theme lives in the srcdoc (not
|
|
422
|
+
* over the wire), so it re-assembles the document; because geometry is invariant
|
|
423
|
+
* the anchor page is restored precisely. Before the first `paginate` it only
|
|
424
|
+
* records the theme for the next render.
|
|
425
|
+
*
|
|
426
|
+
* A no-op when the stylesheet is unchanged, so re-issuing the same appearance is
|
|
427
|
+
* free.
|
|
428
|
+
*/
|
|
429
|
+
async setThemeCss(themeCss: string | undefined): Promise<void> {
|
|
430
|
+
if (this.#host.themeCss === themeCss) return;
|
|
431
|
+
await this.#reapply(themeCss, this.#options?.columnCount, this.#options?.columnGap);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Applies a live appearance change that may re-flow the layout — a new
|
|
436
|
+
* appearance stylesheet (font/size/line-height/alignment/hyphenation) and/or a
|
|
437
|
+
* new `columnCount` — preserving the reading position across it. Unlike
|
|
438
|
+
* {@link setThemeCss} (colours only, geometry invariant), a reflowing knob
|
|
439
|
+
* changes chunk geometry, so this reuses the same capture → re-layout → resolve →
|
|
440
|
+
* restore machinery as {@link switchMode}: it captures a `Position` for the
|
|
441
|
+
* current page, re-paginates with the new stylesheet and column count, and seeks
|
|
442
|
+
* back to the page that `Position` now resolves to. A same-text resolution miss
|
|
443
|
+
* degrades to page 0. Before the first `paginate` it only records the stylesheet
|
|
444
|
+
* for the next render. A no-op (same stylesheet, same column count) short-
|
|
445
|
+
* circuits, so re-issuing the same appearance is free.
|
|
446
|
+
*/
|
|
447
|
+
async applyAppearance(
|
|
448
|
+
themeCss: string | undefined,
|
|
449
|
+
geometry: { columnCount?: number; columnGap?: number } = {},
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
const nextColumns = geometry.columnCount ?? this.#options?.columnCount;
|
|
452
|
+
const nextGap = geometry.columnGap ?? this.#options?.columnGap;
|
|
453
|
+
const sameCss = this.#host.themeCss === themeCss;
|
|
454
|
+
const sameColumns = this.#options === null || this.#options.columnCount === nextColumns;
|
|
455
|
+
const sameGap = this.#options === null || this.#options.columnGap === nextGap;
|
|
456
|
+
if (sameCss && sameColumns && sameGap) return;
|
|
457
|
+
await this.#reapply(themeCss, nextColumns, nextGap);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Sets the stylesheet and column geometry, re-paginates the current section, and
|
|
462
|
+
* restores the reading position by content anchor. Shared by {@link setThemeCss}
|
|
463
|
+
* (geometry invariant, exact page restored) and {@link applyAppearance} (geometry
|
|
464
|
+
* may change, nearest anchor page restored). Records the stylesheet and returns
|
|
465
|
+
* before the first `paginate` when no section is laid out yet.
|
|
466
|
+
*/
|
|
467
|
+
async #reapply(
|
|
468
|
+
themeCss: string | undefined,
|
|
469
|
+
columnCount: number | undefined,
|
|
470
|
+
columnGap: number | undefined,
|
|
471
|
+
): Promise<void> {
|
|
472
|
+
this.#host.setThemeCss(themeCss);
|
|
473
|
+
if (this.#section === null || this.#options === null) return;
|
|
474
|
+
const nextColumns = columnCount ?? this.#options.columnCount;
|
|
475
|
+
const nextGap = columnGap ?? this.#options.columnGap;
|
|
476
|
+
const options: PaginateOptions =
|
|
477
|
+
nextColumns === this.#options.columnCount && nextGap === this.#options.columnGap
|
|
478
|
+
? this.#options
|
|
479
|
+
: { ...this.#options, columnCount: nextColumns, columnGap: nextGap };
|
|
480
|
+
const anchor = await this.positionOfPage(this.#page);
|
|
481
|
+
const state = await this.#host.paginate(this.#section, options);
|
|
482
|
+
this.#options = options;
|
|
483
|
+
this.#state = state;
|
|
484
|
+
// Same section, same text: the section-text cache survives the reflow, so
|
|
485
|
+
// the restore leg below resolves without another sectionText round trip.
|
|
486
|
+
const page = await this.pageOfPosition(anchor);
|
|
487
|
+
await this.goToPage(page >= 0 ? page : 0);
|
|
488
|
+
await this.#redrawDecorations();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Frame-side counts, for eviction/memory checks. Eviction itself lives in-frame. */
|
|
492
|
+
async diagnostics(): Promise<PaginatorDiagnostics> {
|
|
493
|
+
this.#requireActive();
|
|
494
|
+
return this.#host.diagnostics();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The section's concatenated chunk text, as the frame measures it (cached). */
|
|
498
|
+
async sectionText(): Promise<string> {
|
|
499
|
+
this.#requireActive();
|
|
500
|
+
return this.#sectionText();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Tears down the underlying host and frame. Idempotent. */
|
|
504
|
+
destroy(): void {
|
|
505
|
+
this.#host.destroy();
|
|
506
|
+
this.#section = null;
|
|
507
|
+
this.#options = null;
|
|
508
|
+
this.#state = null;
|
|
509
|
+
this.#text = null;
|
|
510
|
+
this.#decorations.clear();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async #sectionText(): Promise<string> {
|
|
514
|
+
if (this.#text === null) this.#text = await this.#host.sectionText();
|
|
515
|
+
return this.#text;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#resolveOptions(request: PaginateRequest): PaginateOptions {
|
|
519
|
+
return {
|
|
520
|
+
mode: request.mode ?? 'paginated',
|
|
521
|
+
pageWidth: request.pageWidth ?? this.#container.clientWidth,
|
|
522
|
+
pageHeight: request.pageHeight ?? this.#container.clientHeight,
|
|
523
|
+
columnGap: request.columnGap ?? DEFAULT_COLUMN_GAP,
|
|
524
|
+
chunkChars: request.chunkChars ?? DEFAULT_CHUNK_CHARS,
|
|
525
|
+
windowChunks: request.windowChunks ?? DEFAULT_WINDOW_CHUNKS,
|
|
526
|
+
columnCount: request.columnCount ?? DEFAULT_COLUMN_COUNT,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#requestFromOptions(): PaginateRequest {
|
|
531
|
+
const options = this.#options!;
|
|
532
|
+
return {
|
|
533
|
+
pageWidth: options.pageWidth,
|
|
534
|
+
pageHeight: options.pageHeight,
|
|
535
|
+
columnGap: options.columnGap,
|
|
536
|
+
chunkChars: options.chunkChars,
|
|
537
|
+
windowChunks: options.windowChunks,
|
|
538
|
+
columnCount: options.columnCount,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#requireActive(): void {
|
|
543
|
+
if (this.#section === null || this.#options === null || this.#state === null) {
|
|
544
|
+
throw new PaginatorError('no section is paginated; call paginate() first');
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function clamp(value: number, low: number, high: number): number {
|
|
550
|
+
return Math.min(high, Math.max(low, value));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Maps a grapheme index (as `resolvePosition` returns) to a UTF-16 code-unit
|
|
555
|
+
* offset (as `pageOfOffset` and `offsetOfPage` speak) in `text`. `Intl.Segmenter`
|
|
556
|
+
* carries each grapheme's code-unit index, so the mapping is exact for surrogate
|
|
557
|
+
* pairs, combining sequences, and emoji clusters. An index at or past the end
|
|
558
|
+
* maps to the text length.
|
|
559
|
+
*
|
|
560
|
+
* The section text is segmented once and memoized (the paginator caches one
|
|
561
|
+
* section text at a time, so a single-entry memo suffices); each lookup is then a
|
|
562
|
+
* binary search over the shared grapheme-boundary helper, never a re-segmentation.
|
|
563
|
+
*/
|
|
564
|
+
let memoizedText: string | null = null;
|
|
565
|
+
let memoizedGraphemes: readonly Grapheme[] = [];
|
|
566
|
+
|
|
567
|
+
function graphemeIndexToCodeUnitOffset(text: string, graphemeIndex: number): number {
|
|
568
|
+
if (text !== memoizedText) {
|
|
569
|
+
memoizedText = text;
|
|
570
|
+
memoizedGraphemes = segmentGraphemes(text);
|
|
571
|
+
}
|
|
572
|
+
return graphemeIndexToOffset(memoizedGraphemes, text.length, graphemeIndex);
|
|
573
|
+
}
|