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,1206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public reader facade: opens a decoded {@link Book} into a container element
|
|
3
|
+
* and returns a {@link Reader} that turns pages and sections, navigates to any
|
|
4
|
+
* target, follows internal links with a back-stack, reports position, emits
|
|
5
|
+
* events, and switches paginated↔scrolled without losing the reader's place.
|
|
6
|
+
*
|
|
7
|
+
* This module lives *outside* `src/core` on purpose. Core is headless by rule —
|
|
8
|
+
* nothing reachable from `src/core/index.ts` may import `src/view` or `src/layout`
|
|
9
|
+
* (`npm run check:core`). The PLAN §4 sketch shows `book.render(...)`; that method
|
|
10
|
+
* cannot exist on the core `Book`, so the shape is relocated here as a free
|
|
11
|
+
* function `render(book, element, options)` keeping the sketched object surface
|
|
12
|
+
* (`next/prev`, `nextSection/prevSection`, `goTo`, `back`, `position`, `on`,
|
|
13
|
+
* `setMode`, `destroy`).
|
|
14
|
+
*
|
|
15
|
+
* The facade owns no format vocabulary and never fetches or persists: bytes came
|
|
16
|
+
* in as a `Book`, page geometry comes from the {@link Paginator}, and reading
|
|
17
|
+
* positions are the headless `Position` model from core. It drives the paginator
|
|
18
|
+
* and reads the book; it never reaches past the paginator into frame geometry.
|
|
19
|
+
*/
|
|
20
|
+
import type { Book, Position, ReadingDirection, Section, SentenceRange, TocItem } from '../core/index.ts';
|
|
21
|
+
import { parsePosition, segmentSentences } from '../core/index.ts';
|
|
22
|
+
import { Paginator, type BookProgress, type LayoutMode } from '../layout/index.ts';
|
|
23
|
+
import { searchBook, type SearchHit, type SearchOptions } from '../search/index.ts';
|
|
24
|
+
import {
|
|
25
|
+
isReflowingUpdate,
|
|
26
|
+
mergeAppearance,
|
|
27
|
+
themeStyleSheet,
|
|
28
|
+
type Appearance,
|
|
29
|
+
type TextAlign,
|
|
30
|
+
type ThemeName,
|
|
31
|
+
} from '../view/appearance.ts';
|
|
32
|
+
import {
|
|
33
|
+
keyIntent,
|
|
34
|
+
swipeIntent,
|
|
35
|
+
tapIntent,
|
|
36
|
+
type InputConfig,
|
|
37
|
+
type NavIntent,
|
|
38
|
+
type TapZones,
|
|
39
|
+
} from './input.ts';
|
|
40
|
+
|
|
41
|
+
export type { InputConfig, TapZones } from './input.ts';
|
|
42
|
+
export type { Appearance, TextAlign, ThemeName } from '../view/appearance.ts';
|
|
43
|
+
export type { SearchHit, SearchOptions } from '../search/index.ts';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Appearance and layout options for {@link render}. All optional. `mode` selects
|
|
47
|
+
* paginated (default) or scrolled layout. The theme and typography fields drive
|
|
48
|
+
* the appearance system and are applied at the first render; change any of them
|
|
49
|
+
* live afterward with {@link Reader.setAppearance}.
|
|
50
|
+
*/
|
|
51
|
+
export interface ReaderOptions {
|
|
52
|
+
/** 'paginated' (default) or 'scrolled'. */
|
|
53
|
+
readonly mode?: LayoutMode;
|
|
54
|
+
/**
|
|
55
|
+
* The theme applied to content: `'light' | 'dark' | 'sepia' | 'custom'`. Omit
|
|
56
|
+
* to follow the OS (`prefers-color-scheme`); a named theme overrides it. Change
|
|
57
|
+
* it live with {@link Reader.setAppearance}.
|
|
58
|
+
*/
|
|
59
|
+
readonly theme?: ThemeName;
|
|
60
|
+
/**
|
|
61
|
+
* Bespoke `--wr-*` CSS custom properties, merged over the resolved theme. The
|
|
62
|
+
* escape hatch for a custom theme; keys may omit the leading `--`.
|
|
63
|
+
*/
|
|
64
|
+
readonly customProperties?: Readonly<Record<string, string>>;
|
|
65
|
+
/** Body font size in CSS px. Applied at the first render; change it live with {@link Reader.setAppearance}. */
|
|
66
|
+
readonly fontSize?: number;
|
|
67
|
+
/** Body font family. Applied at the first render. */
|
|
68
|
+
readonly fontFamily?: string;
|
|
69
|
+
/** Body line height (unitless multiplier). Applied at the first render. */
|
|
70
|
+
readonly lineHeight?: number;
|
|
71
|
+
/** Page-edge margin in CSS px (also the inter-column gutter), threaded to the paginator as `columnGap`. */
|
|
72
|
+
readonly margin?: number;
|
|
73
|
+
/** Body text alignment: `'start'` (publisher default) or `'justify'`. */
|
|
74
|
+
readonly textAlign?: TextAlign;
|
|
75
|
+
/** Shorthand for `textAlign: 'justify'`. */
|
|
76
|
+
readonly justify?: boolean;
|
|
77
|
+
/** Whether the content root hyphenates. */
|
|
78
|
+
readonly hyphenate?: boolean;
|
|
79
|
+
/** Text columns per page: 1 (default) or 2. Applied at the first render. */
|
|
80
|
+
readonly columns?: 1 | 2;
|
|
81
|
+
/** Where to open. Any {@link GoToTarget}; defaults to the book's start. */
|
|
82
|
+
readonly start?: GoToTarget;
|
|
83
|
+
/**
|
|
84
|
+
* Human-input configuration: keyboard, swipe, tap zones. Additive and optional
|
|
85
|
+
* — omitting it enables all three with defaults. See {@link InputConfig}. Input
|
|
86
|
+
* is direction-aware: `Book.direction === 'rtl'` flips the horizontal axis.
|
|
87
|
+
*/
|
|
88
|
+
readonly input?: InputConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A navigation target for {@link Reader.goTo}. Six forms:
|
|
93
|
+
* - {@link TocItem} — its `sectionId` (+ optional `fragment`)
|
|
94
|
+
* - internal `href` string — resolved to a section (+ fragment), best-effort
|
|
95
|
+
* - {@link Position} — restored by content anchor
|
|
96
|
+
* - a number in `0..1` — a fraction through the whole book
|
|
97
|
+
* - `'start'` — the first section, first page
|
|
98
|
+
* - `'end'` — the last section, last page
|
|
99
|
+
*/
|
|
100
|
+
export type GoToTarget = TocItem | Position | string | number | 'start' | 'end';
|
|
101
|
+
|
|
102
|
+
/** The reader's place in the book, assembled from the paginator and the book. */
|
|
103
|
+
export interface ReaderPosition {
|
|
104
|
+
/** 0-based index of the active section in reading order. */
|
|
105
|
+
readonly section: number;
|
|
106
|
+
/** Fraction through the whole book, 0..1 — approximate across unlaid sections. */
|
|
107
|
+
readonly progress: number;
|
|
108
|
+
/** Fraction through the current chapter, 0..1. */
|
|
109
|
+
readonly chapterProgress: number;
|
|
110
|
+
/** 0-based current page within the section. */
|
|
111
|
+
readonly page: number;
|
|
112
|
+
/** Total pages in the section — an estimate until the layout is firm. */
|
|
113
|
+
readonly totalPages: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The events a {@link Reader} emits, with their payload types. */
|
|
117
|
+
export interface ReaderEventMap {
|
|
118
|
+
/** Fires once, after the first section paints. Payload: the initial position. */
|
|
119
|
+
readonly ready: ReaderPosition;
|
|
120
|
+
/** Fires after any navigation settles. Payload: the settled position. */
|
|
121
|
+
readonly positionchange: ReaderPosition;
|
|
122
|
+
/** Fires when the active section changes. Payload: the new section index + id. */
|
|
123
|
+
readonly sectionchange: SectionChange;
|
|
124
|
+
/** Fires when an in-frame link is clicked, before it is followed. Payload: the raw href. */
|
|
125
|
+
readonly linkclick: LinkClick;
|
|
126
|
+
/** Fires when the user selects text in the frame. Payload: the text + a resolvable Position. */
|
|
127
|
+
readonly selection: SelectionEvent;
|
|
128
|
+
/** Fires when a navigation or render fails. Payload: the error. */
|
|
129
|
+
readonly error: Error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface SectionChange {
|
|
133
|
+
/** 0-based index of the now-active section. */
|
|
134
|
+
readonly index: number;
|
|
135
|
+
/** The now-active section's id. */
|
|
136
|
+
readonly sectionId: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface LinkClick {
|
|
140
|
+
/** The raw authored href from the frame, exactly as reported. */
|
|
141
|
+
readonly href: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SelectionEvent {
|
|
145
|
+
/** The selected text, exactly as the frame read it. */
|
|
146
|
+
readonly text: string;
|
|
147
|
+
/**
|
|
148
|
+
* A `Position` anchored at the selection's start. Resolves back against the
|
|
149
|
+
* section via {@link Reader.goTo}/`resolvePosition`, so a host can persist it
|
|
150
|
+
* (a bookmark or highlight anchor) and navigate to it later.
|
|
151
|
+
*/
|
|
152
|
+
readonly position: Position;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type ReaderEvent = keyof ReaderEventMap;
|
|
156
|
+
export type ReaderEventHandler<E extends ReaderEvent> = (payload: ReaderEventMap[E]) => void;
|
|
157
|
+
/** Removes a previously-registered handler. Idempotent. */
|
|
158
|
+
export type Unsubscribe = () => void;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A live reader over one book. Navigation methods are async and settle once the
|
|
162
|
+
* paginator has moved and the corresponding events have fired. Reads of
|
|
163
|
+
* {@link position} are synchronous snapshots of the last settled state.
|
|
164
|
+
*/
|
|
165
|
+
export interface Reader {
|
|
166
|
+
/** Turn one page forward, rolling to the next section's first page at the end. */
|
|
167
|
+
next(): Promise<void>;
|
|
168
|
+
/** Turn one page back, rolling to the previous section's last page at the start. */
|
|
169
|
+
prev(): Promise<void>;
|
|
170
|
+
/** Jump to the next section's first page. No-op at the last section. */
|
|
171
|
+
nextSection(): Promise<void>;
|
|
172
|
+
/** Jump to the previous section's first page. No-op at the first section. */
|
|
173
|
+
prevSection(): Promise<void>;
|
|
174
|
+
/** Navigate to any of the six {@link GoToTarget} forms. Soft-misses degrade quietly. */
|
|
175
|
+
goTo(target: GoToTarget): Promise<void>;
|
|
176
|
+
/** Pop the internal-link back-stack and restore the pushed position. No-op when empty. */
|
|
177
|
+
back(): Promise<void>;
|
|
178
|
+
/** Switch paginated↔scrolled without reload, preserving the reading position. */
|
|
179
|
+
setMode(mode: LayoutMode): Promise<void>;
|
|
180
|
+
/**
|
|
181
|
+
* Update the live appearance without losing the reading place. Covers the theme
|
|
182
|
+
* half (colours / background) and the typography half (font family/size, line
|
|
183
|
+
* height, margin, text alignment, justification, hyphenation, 1-or-2 columns).
|
|
184
|
+
* Partial: only the fields present in `appearance` change; `customProperties`
|
|
185
|
+
* shallow-merges over the current set. A colour-only change repaints the current
|
|
186
|
+
* page in place with no reflow; a reflowing typography knob (font, size, line
|
|
187
|
+
* height, columns) re-lays out and restores the reading place by content anchor,
|
|
188
|
+
* so the place survives even though the page number may shift.
|
|
189
|
+
*/
|
|
190
|
+
setAppearance(appearance: Appearance): Promise<void>;
|
|
191
|
+
/**
|
|
192
|
+
* Full-text search over the whole book. Returns a **lazy async iterator** of
|
|
193
|
+
* {@link SearchHit}s, streaming each as its section is scanned — the book is never
|
|
194
|
+
* buffered whole, and stopping early (`break`) halts the scan (no later section is
|
|
195
|
+
* loaded). Each hit's `position` is jumpable: `await reader.goTo(hit.position)`
|
|
196
|
+
* lands on its page. Matching is normalized literal substring (case-insensitive,
|
|
197
|
+
* NFC, smart-punctuation- and whitespace-folded); it does not route through the
|
|
198
|
+
* frame — sections are decoded and scanned headlessly, independent of what is
|
|
199
|
+
* painted.
|
|
200
|
+
*/
|
|
201
|
+
search(query: string, options?: SearchOptions): AsyncIterableIterator<SearchHit>;
|
|
202
|
+
/**
|
|
203
|
+
* Draws a decoration — a styled overlay — over the range `position` resolves to,
|
|
204
|
+
* keyed by `id`. Re-issuing the same `id` replaces that overlay; the `className`
|
|
205
|
+
* on `opts` is applied to every overlay box so a host stylesheet can colour it.
|
|
206
|
+
*
|
|
207
|
+
* **Draw-only, never stored.** The reader holds the decoration *intent* (id →
|
|
208
|
+
* position + class) in memory only, so it can re-anchor the overlay across a
|
|
209
|
+
* re-layout (font-size, mode, appearance) — it never persists, serializes, or
|
|
210
|
+
* fetches annotation data. A host owns all annotation storage: to make a highlight
|
|
211
|
+
* durable, persist the `position` (or its serialized string) yourself and re-call
|
|
212
|
+
* `decorate` on the next open. The overlay is pointer-transparent and layout-
|
|
213
|
+
* neutral: it never eats a selection or a click and never changes page geometry.
|
|
214
|
+
*
|
|
215
|
+
* A soft-miss — the anchor no longer resolves, or it belongs to a section other
|
|
216
|
+
* than the one on screen — draws nothing and throws nothing; the intent is kept, so
|
|
217
|
+
* navigating to that section (or re-laying out) redraws it.
|
|
218
|
+
*/
|
|
219
|
+
decorate(id: string, position: Position, opts: { className: string }): Promise<void>;
|
|
220
|
+
/** Removes the decoration drawn for `id` and drops its intent. No-op for an unknown id. */
|
|
221
|
+
undecorate(id: string): Promise<void>;
|
|
222
|
+
/**
|
|
223
|
+
* Sentence ranges for the current section, each with a `Position` anchored over
|
|
224
|
+
* the whole sentence. The **TTS enabler**: pair a sentence's `position` with
|
|
225
|
+
* {@link goTo} to jump and {@link decorate} to highlight it, and a host can step
|
|
226
|
+
* a speech engine sentence-by-sentence over the reading view. The library speaks
|
|
227
|
+
* nothing and stores nothing — it exposes ranges only. Returns `[]` before the
|
|
228
|
+
* first paint or for an empty section.
|
|
229
|
+
*/
|
|
230
|
+
sentences(): Promise<readonly SentenceRange[]>;
|
|
231
|
+
/** A synchronous snapshot of the current place in the book. */
|
|
232
|
+
readonly position: ReaderPosition;
|
|
233
|
+
/** The layout mode currently in effect. */
|
|
234
|
+
readonly mode: LayoutMode;
|
|
235
|
+
/** Subscribe to an event; returns an unsubscribe function. */
|
|
236
|
+
on<E extends ReaderEvent>(event: E, handler: ReaderEventHandler<E>): Unsubscribe;
|
|
237
|
+
/** Tear everything down: paginator, frame, data: URLs, listeners, back-stack. Idempotent. */
|
|
238
|
+
destroy(): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Something went wrong driving the reader; never a book decoding failure. */
|
|
242
|
+
export class ReaderError extends Error {
|
|
243
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
244
|
+
super(message, options);
|
|
245
|
+
this.name = 'ReaderError';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Renders `book` into `element` and returns a live {@link Reader}. The reader is
|
|
251
|
+
* usable immediately; the first section paints asynchronously and a `ready` event
|
|
252
|
+
* fires once it does. Options are applied where they map today and otherwise
|
|
253
|
+
* retained for M3. Pass `options.start` to open somewhere other than the book's
|
|
254
|
+
* beginning.
|
|
255
|
+
*/
|
|
256
|
+
export function render(book: Book, element: HTMLElement, options: ReaderOptions = {}): Reader {
|
|
257
|
+
return new ReaderImpl(book, element, options);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Split a raw href into its path (fragment removed) and optional fragment. */
|
|
261
|
+
function splitFragment(href: string): { path: string; fragment?: string } {
|
|
262
|
+
const hash = href.indexOf('#');
|
|
263
|
+
if (hash === -1) return { path: href };
|
|
264
|
+
return { path: href.slice(0, hash), fragment: decodeFragment(href.slice(hash + 1)) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function decodeFragment(fragment: string): string {
|
|
268
|
+
if (!fragment.includes('%')) return fragment;
|
|
269
|
+
try {
|
|
270
|
+
return decodeURIComponent(fragment);
|
|
271
|
+
} catch {
|
|
272
|
+
return fragment;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The last path segment of a reference, e.g. '../text/c3.xhtml' → 'c3.xhtml'. */
|
|
277
|
+
function lastSegment(path: string): string {
|
|
278
|
+
const clean = path.replace(/[?].*$/, '');
|
|
279
|
+
const slash = clean.lastIndexOf('/');
|
|
280
|
+
return slash === -1 ? clean : clean.slice(slash + 1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Strip a single trailing file extension: 'c3.xhtml' → 'c3'. */
|
|
284
|
+
function stripExtension(name: string): string {
|
|
285
|
+
const dot = name.lastIndexOf('.');
|
|
286
|
+
return dot <= 0 ? name : name.slice(0, dot);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
class ReaderImpl implements Reader {
|
|
290
|
+
readonly #book: Book;
|
|
291
|
+
readonly #paginator: Paginator;
|
|
292
|
+
readonly #element: HTMLElement;
|
|
293
|
+
readonly #sections: readonly Section[];
|
|
294
|
+
readonly #options: ReaderOptions;
|
|
295
|
+
readonly #listeners: { [E in ReaderEvent]: Set<ReaderEventHandler<E>> } = {
|
|
296
|
+
ready: new Set(),
|
|
297
|
+
positionchange: new Set(),
|
|
298
|
+
sectionchange: new Set(),
|
|
299
|
+
linkclick: new Set(),
|
|
300
|
+
selection: new Set(),
|
|
301
|
+
error: new Set(),
|
|
302
|
+
};
|
|
303
|
+
/** Internal-link back-stack: positions to return to via {@link back}. */
|
|
304
|
+
#backStack: Position[] = [];
|
|
305
|
+
#sectionIndex = 0;
|
|
306
|
+
#mode: LayoutMode;
|
|
307
|
+
#destroyed = false;
|
|
308
|
+
#ready = false;
|
|
309
|
+
/** Live appearance state (theme half). Merged by {@link setAppearance}. */
|
|
310
|
+
#appearance: Appearance;
|
|
311
|
+
/** Reading direction; drives the RTL flip in every input mode. */
|
|
312
|
+
readonly #direction: ReadingDirection;
|
|
313
|
+
/** Resolved input config: which modes are on and the tap-zone geometry. */
|
|
314
|
+
readonly #keyboard: boolean;
|
|
315
|
+
readonly #swipe: boolean;
|
|
316
|
+
readonly #tapZones: TapZones | null;
|
|
317
|
+
/** Serializes navigation so overlapping calls settle in order. */
|
|
318
|
+
#queue: Promise<unknown> = Promise.resolve();
|
|
319
|
+
/** The host-side image-zoom overlay, mounted lazily on the first image tap. */
|
|
320
|
+
#zoom: ImageZoom | null = null;
|
|
321
|
+
/**
|
|
322
|
+
* Observes the container so a resize re-paginates: page geometry is captured at
|
|
323
|
+
* render time, so a container that later shrinks leaves the frame scrolling and
|
|
324
|
+
* one that grows leaves a gap. Null where `ResizeObserver` is absent.
|
|
325
|
+
*/
|
|
326
|
+
#resizeObserver: ResizeObserver | null = null;
|
|
327
|
+
/** Pending rAF handle coalescing a burst of resize notifications into one re-flow. */
|
|
328
|
+
#resizeFrame: number | null = null;
|
|
329
|
+
|
|
330
|
+
constructor(book: Book, element: HTMLElement, options: ReaderOptions) {
|
|
331
|
+
this.#book = book;
|
|
332
|
+
this.#element = element;
|
|
333
|
+
this.#sections = book.sections;
|
|
334
|
+
this.#options = options;
|
|
335
|
+
this.#mode = options.mode ?? 'paginated';
|
|
336
|
+
this.#direction = book.direction ?? 'ltr';
|
|
337
|
+
const input = options.input ?? {};
|
|
338
|
+
this.#keyboard = input.keyboard ?? true;
|
|
339
|
+
this.#swipe = input.swipe ?? true;
|
|
340
|
+
this.#tapZones = input.tapZones === false ? null : (input.tapZones ?? {});
|
|
341
|
+
// Seed the live appearance from the options. Only keys that were supplied are
|
|
342
|
+
// carried — `exactOptionalPropertyTypes` keeps "follow the OS" (theme absent)
|
|
343
|
+
// distinct from a forced theme, and an unset typography knob from a forced one.
|
|
344
|
+
// The typography half is applied at the first render (below), not merely
|
|
345
|
+
// retained: the seeded stylesheet rides the opening srcdoc and `columns` drives
|
|
346
|
+
// the opening paginate.
|
|
347
|
+
this.#appearance = {};
|
|
348
|
+
if (options.theme !== undefined) this.#appearance = { ...this.#appearance, theme: options.theme };
|
|
349
|
+
if (options.customProperties !== undefined) {
|
|
350
|
+
this.#appearance = { ...this.#appearance, customProperties: options.customProperties };
|
|
351
|
+
}
|
|
352
|
+
if (options.fontFamily !== undefined) this.#appearance = { ...this.#appearance, fontFamily: options.fontFamily };
|
|
353
|
+
if (options.fontSize !== undefined) this.#appearance = { ...this.#appearance, fontSize: options.fontSize };
|
|
354
|
+
if (options.lineHeight !== undefined) this.#appearance = { ...this.#appearance, lineHeight: options.lineHeight };
|
|
355
|
+
if (options.margin !== undefined) this.#appearance = { ...this.#appearance, margin: options.margin };
|
|
356
|
+
if (options.textAlign !== undefined) this.#appearance = { ...this.#appearance, textAlign: options.textAlign };
|
|
357
|
+
if (options.justify !== undefined) this.#appearance = { ...this.#appearance, justify: options.justify };
|
|
358
|
+
if (options.hyphenate !== undefined) this.#appearance = { ...this.#appearance, hyphenate: options.hyphenate };
|
|
359
|
+
if (options.columns !== undefined) this.#appearance = { ...this.#appearance, columns: options.columns };
|
|
360
|
+
this.#paginator = new Paginator(element, {
|
|
361
|
+
themeCss: themeStyleSheet(this.#appearance),
|
|
362
|
+
// Baked into the frame's coordination script: with keyboard input off the
|
|
363
|
+
// frame stops preventDefault'ing nav keys it would forward to a deaf host.
|
|
364
|
+
keyboardNav: this.#keyboard,
|
|
365
|
+
onLinkClick: (href) => {
|
|
366
|
+
void this.#onLinkClick(href);
|
|
367
|
+
},
|
|
368
|
+
onError: (message) => {
|
|
369
|
+
this.#emit('error', new ReaderError(message));
|
|
370
|
+
},
|
|
371
|
+
onKey: (key) => {
|
|
372
|
+
if (this.#keyboard) this.#dispatchIntent(keyIntent(key, this.#direction));
|
|
373
|
+
},
|
|
374
|
+
onSwipe: (dx, dy) => {
|
|
375
|
+
if (this.#swipe) this.#dispatchIntent(swipeIntent(dx, dy, this.#direction));
|
|
376
|
+
},
|
|
377
|
+
onTap: (tap) => {
|
|
378
|
+
if (this.#tapZones !== null) {
|
|
379
|
+
this.#dispatchIntent(tapIntent(tap, this.#tapZones, this.#direction));
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
onImageTap: (image) => {
|
|
383
|
+
this.#openZoom(image);
|
|
384
|
+
},
|
|
385
|
+
onSelection: (selection) => {
|
|
386
|
+
void this.#onSelection(selection);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
// Kick off the initial render; `ready` fires when it settles.
|
|
390
|
+
void this.#run(() => this.#open());
|
|
391
|
+
this.#observeResize();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Watches the container for size changes and re-paginates on each, so the frame
|
|
396
|
+
* never scrolls (shrunk container) or shows a gap (grown one). Notifications are
|
|
397
|
+
* coalesced onto the next animation frame, then the re-flow is enqueued behind any
|
|
398
|
+
* in-flight navigation so it settles in order and holds the reading place. The
|
|
399
|
+
* `ResizeObserver` fires once on `observe()` at the current size — a no-op, since
|
|
400
|
+
* `Paginator.resize()` short-circuits when the size has not actually changed.
|
|
401
|
+
*/
|
|
402
|
+
#observeResize(): void {
|
|
403
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
404
|
+
this.#resizeObserver = new ResizeObserver(() => this.#onResize());
|
|
405
|
+
this.#resizeObserver.observe(this.#element);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#onResize(): void {
|
|
409
|
+
if (this.#destroyed || this.#resizeFrame !== null) return;
|
|
410
|
+
this.#resizeFrame = requestAnimationFrame(() => {
|
|
411
|
+
this.#resizeFrame = null;
|
|
412
|
+
if (this.#destroyed || this.#paginator.section === null) return;
|
|
413
|
+
void this.#run(() => this.#reflowToContainer());
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async #reflowToContainer(): Promise<void> {
|
|
418
|
+
if (this.#destroyed || this.#paginator.section === null) return;
|
|
419
|
+
const state = await this.#paginator.resize();
|
|
420
|
+
// `null` means the size did not actually change (or the container is hidden):
|
|
421
|
+
// nothing re-laid out, so nothing to announce.
|
|
422
|
+
if (this.#destroyed || state === null) return;
|
|
423
|
+
this.#emit('positionchange', this.#snapshot());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
get position(): ReaderPosition {
|
|
427
|
+
return this.#snapshot();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
get mode(): LayoutMode {
|
|
431
|
+
return this.#mode;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
on<E extends ReaderEvent>(event: E, handler: ReaderEventHandler<E>): Unsubscribe {
|
|
435
|
+
const set = this.#listeners[event];
|
|
436
|
+
set.add(handler);
|
|
437
|
+
return () => {
|
|
438
|
+
set.delete(handler);
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
next(): Promise<void> {
|
|
443
|
+
return this.#run(() => this.#next());
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
prev(): Promise<void> {
|
|
447
|
+
return this.#run(() => this.#prev());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
nextSection(): Promise<void> {
|
|
451
|
+
return this.#run(() => this.#gotoSection(this.#sectionIndex + 1, 'first'));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
prevSection(): Promise<void> {
|
|
455
|
+
return this.#run(() => this.#gotoSection(this.#sectionIndex - 1, 'first'));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
goTo(target: GoToTarget): Promise<void> {
|
|
459
|
+
return this.#run(() => this.#goTo(target));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
back(): Promise<void> {
|
|
463
|
+
return this.#run(() => this.#back());
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
setMode(mode: LayoutMode): Promise<void> {
|
|
467
|
+
return this.#run(() => this.#setMode(mode));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
setAppearance(appearance: Appearance): Promise<void> {
|
|
471
|
+
return this.#run(() => this.#setAppearance(appearance));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Streams whole-book search hits. Not enqueued behind navigation — it reads the
|
|
476
|
+
* book's section bytes headlessly (decode → strip markup → match) and never drives
|
|
477
|
+
* the paginator or the frame, so it composes with reading rather than blocking it.
|
|
478
|
+
* Delegates to the headless `searchBook` generator; `goTo(hit.position)` jumps.
|
|
479
|
+
*/
|
|
480
|
+
search(query: string, options: SearchOptions = {}): AsyncIterableIterator<SearchHit> {
|
|
481
|
+
return searchBook(this.#book, query, options);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Draws (or replaces) a decoration over the range `position` resolves to. Enqueued
|
|
486
|
+
* behind navigation so it settles in issue order against a concurrent re-layout;
|
|
487
|
+
* the paginator holds the intent and re-anchors it across every later re-layout, so
|
|
488
|
+
* a highlight survives a font-size or mode change. Draw-only — no annotation data is
|
|
489
|
+
* ever stored, serialized, or fetched. A soft-miss draws nothing (no throw).
|
|
490
|
+
*/
|
|
491
|
+
decorate(id: string, position: Position, opts: { className: string }): Promise<void> {
|
|
492
|
+
return this.#run(() => this.#decorate(id, position, opts.className));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Removes the decoration for `id`. Enqueued so it orders against navigation. */
|
|
496
|
+
undecorate(id: string): Promise<void> {
|
|
497
|
+
return this.#run(() => this.#undecorate(id));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
sentences(): Promise<readonly SentenceRange[]> {
|
|
501
|
+
return this.#runResult<readonly SentenceRange[]>([], async () => {
|
|
502
|
+
const section = this.#paginator.section;
|
|
503
|
+
if (section === null) return [];
|
|
504
|
+
// Segment the same frame-measured section text decorations resolve against, so
|
|
505
|
+
// every sentence Position both jumps (goTo) and highlights (decorate) cleanly.
|
|
506
|
+
const text = await this.#paginator.sectionText();
|
|
507
|
+
return segmentSentences(text, section.id);
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async #decorate(id: string, position: Position, className: string): Promise<void> {
|
|
512
|
+
if (this.#destroyed || this.#paginator.section === null) return;
|
|
513
|
+
await this.#paginator.decorate(id, position, className);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async #undecorate(id: string): Promise<void> {
|
|
517
|
+
if (this.#destroyed) return;
|
|
518
|
+
await this.#paginator.undecorate(id);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
destroy(): void {
|
|
522
|
+
if (this.#destroyed) return;
|
|
523
|
+
this.#destroyed = true;
|
|
524
|
+
this.#resizeObserver?.disconnect();
|
|
525
|
+
this.#resizeObserver = null;
|
|
526
|
+
if (this.#resizeFrame !== null) {
|
|
527
|
+
cancelAnimationFrame(this.#resizeFrame);
|
|
528
|
+
this.#resizeFrame = null;
|
|
529
|
+
}
|
|
530
|
+
this.#zoom?.destroy();
|
|
531
|
+
this.#zoom = null;
|
|
532
|
+
this.#paginator.destroy();
|
|
533
|
+
for (const event of Object.keys(this.#listeners) as ReaderEvent[]) {
|
|
534
|
+
this.#listeners[event].clear();
|
|
535
|
+
}
|
|
536
|
+
this.#backStack = [];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// --- Input dispatch -------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Route a resolved {@link NavIntent} (from keyboard/swipe/tap, already made
|
|
543
|
+
* direction-aware in `input.ts`) to the matching public navigation method. Each
|
|
544
|
+
* goes through the same serialized queue as programmatic navigation, so hand
|
|
545
|
+
* input interleaves in issue order. `none` is a no-op. Input is never gated on
|
|
546
|
+
* `prefers-reduced-motion`: pages must always turn. The only motion in the reader
|
|
547
|
+
* is the zoom overlay's open/close transition, which the overlay gates itself.
|
|
548
|
+
*/
|
|
549
|
+
#dispatchIntent(intent: NavIntent): void {
|
|
550
|
+
switch (intent) {
|
|
551
|
+
case 'next':
|
|
552
|
+
void this.next();
|
|
553
|
+
return;
|
|
554
|
+
case 'prev':
|
|
555
|
+
void this.prev();
|
|
556
|
+
return;
|
|
557
|
+
case 'nextSection':
|
|
558
|
+
void this.nextSection();
|
|
559
|
+
return;
|
|
560
|
+
case 'prevSection':
|
|
561
|
+
void this.prevSection();
|
|
562
|
+
return;
|
|
563
|
+
case 'start':
|
|
564
|
+
void this.goTo('start');
|
|
565
|
+
return;
|
|
566
|
+
case 'end':
|
|
567
|
+
void this.goTo('end');
|
|
568
|
+
return;
|
|
569
|
+
case 'none':
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- Image zoom overlay ---------------------------------------------------
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Opens the host-side zoom overlay over the tapped image's already-served
|
|
578
|
+
* `data:` bytes. The overlay is a DOM element in the *host* document, outside the
|
|
579
|
+
* sandboxed frame — it mints no new frame resource and never a `blob:` URL. A
|
|
580
|
+
* non-`data:` src (a remote image the CSP already refuses in-frame) is ignored.
|
|
581
|
+
*/
|
|
582
|
+
#openZoom(image: { src: string; alt: string }): void {
|
|
583
|
+
if (this.#destroyed || !image.src.startsWith('data:')) return;
|
|
584
|
+
if (this.#zoom === null) this.#zoom = new ImageZoom(this.#element);
|
|
585
|
+
this.#zoom.open(image.src, image.alt);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- Navigation queue -----------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Runs `task` after all previously-queued navigation, so overlapping calls
|
|
592
|
+
* settle in the order they were issued. A task failure is reported through the
|
|
593
|
+
* `error` event and swallowed so it never breaks the chain; `destroy` short-
|
|
594
|
+
* circuits everything.
|
|
595
|
+
*/
|
|
596
|
+
#run(task: () => Promise<void>): Promise<void> {
|
|
597
|
+
const next = this.#queue.then(async () => {
|
|
598
|
+
if (this.#destroyed) return;
|
|
599
|
+
try {
|
|
600
|
+
await task();
|
|
601
|
+
} catch (error) {
|
|
602
|
+
if (!this.#destroyed) {
|
|
603
|
+
this.#emit('error', error instanceof Error ? error : new ReaderError(String(error)));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
this.#queue = next;
|
|
608
|
+
return next;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Like {@link #run} but returns the task's value, resolving to `fallback` when the
|
|
613
|
+
* reader is destroyed or the task throws (the error is reported through `error`).
|
|
614
|
+
* For read-only queries that must settle in order with navigation.
|
|
615
|
+
*/
|
|
616
|
+
#runResult<T>(fallback: T, task: () => Promise<T>): Promise<T> {
|
|
617
|
+
const run = this.#queue.then(async () => {
|
|
618
|
+
if (this.#destroyed) return fallback;
|
|
619
|
+
try {
|
|
620
|
+
return await task();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (!this.#destroyed) {
|
|
623
|
+
this.#emit('error', error instanceof Error ? error : new ReaderError(String(error)));
|
|
624
|
+
}
|
|
625
|
+
return fallback;
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
this.#queue = run.then(
|
|
629
|
+
() => undefined,
|
|
630
|
+
() => undefined,
|
|
631
|
+
);
|
|
632
|
+
return run;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// --- Lifecycle ------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
async #open(): Promise<void> {
|
|
638
|
+
const start = this.#options.start;
|
|
639
|
+
if (start === undefined) {
|
|
640
|
+
await this.#gotoSection(0, 'first');
|
|
641
|
+
} else {
|
|
642
|
+
await this.#goTo(start);
|
|
643
|
+
}
|
|
644
|
+
if (this.#destroyed) return;
|
|
645
|
+
this.#ready = true;
|
|
646
|
+
this.#emit('ready', this.#snapshot());
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// --- Section / page paint -------------------------------------------------
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Paginates the section at `index` and lands on its first or last page. Fires
|
|
653
|
+
* `sectionchange` when the active section actually changes, then always fires
|
|
654
|
+
* `positionchange` once the page settles. Out-of-range indices are no-ops.
|
|
655
|
+
*/
|
|
656
|
+
async #gotoSection(index: number, land: 'first' | 'last'): Promise<void> {
|
|
657
|
+
if (index < 0 || index >= this.#sections.length) return;
|
|
658
|
+
const section = this.#sections[index]!;
|
|
659
|
+
const changed = this.#paginator.section === null || this.#sectionIndex !== index;
|
|
660
|
+
// Re-paginating is the expensive step (sanitize + chunk + measure). Skip it
|
|
661
|
+
// when we are already on this section — back(), a same-section fragment jump,
|
|
662
|
+
// or a Position within the current chapter — and just seek within the layout
|
|
663
|
+
// already in place.
|
|
664
|
+
if (changed) {
|
|
665
|
+
await this.#paginator.paginate(section, { mode: this.#mode, ...this.#requestExtras() });
|
|
666
|
+
this.#sectionIndex = index;
|
|
667
|
+
}
|
|
668
|
+
// Always establish the landing page explicitly. A multi-chunk section paints
|
|
669
|
+
// its chunks stacked until a page is selected, so entering on 'first' must
|
|
670
|
+
// seek page 0, not assume paginate left it there.
|
|
671
|
+
if (land === 'last') {
|
|
672
|
+
await this.#paginator.refine();
|
|
673
|
+
const state = this.#paginator.state;
|
|
674
|
+
await this.#paginator.goToPage(state === null ? 0 : Math.max(0, state.pageCount - 1));
|
|
675
|
+
} else {
|
|
676
|
+
await this.#paginator.goToPage(0);
|
|
677
|
+
}
|
|
678
|
+
if (changed) this.#emit('sectionchange', { index, sectionId: section.id });
|
|
679
|
+
this.#emit('positionchange', this.#snapshot());
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The layout knobs the facade threads into every `paginate`: the page margin
|
|
684
|
+
* (`columnGap`) and the column count. Both are appearance-owned, so a section
|
|
685
|
+
* re-paginated after a mode switch or roll keeps the live typography rather than
|
|
686
|
+
* snapping back to the paginator defaults.
|
|
687
|
+
*/
|
|
688
|
+
#requestExtras(): { columnGap?: number; columnCount?: number } {
|
|
689
|
+
const extras: { columnGap?: number; columnCount?: number } = {};
|
|
690
|
+
if (this.#appearance.margin !== undefined) extras.columnGap = this.#appearance.margin;
|
|
691
|
+
if (this.#appearance.columns !== undefined) extras.columnCount = this.#appearance.columns;
|
|
692
|
+
return extras;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// --- next / prev ----------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async #next(): Promise<void> {
|
|
698
|
+
if (this.#paginator.section === null) return;
|
|
699
|
+
const before = this.#paginator.page;
|
|
700
|
+
const after = await this.#paginator.nextPage();
|
|
701
|
+
if (after !== before) {
|
|
702
|
+
this.#emit('positionchange', this.#snapshot());
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// At the section's last page: roll to the next section's first page.
|
|
706
|
+
await this.#gotoSection(this.#sectionIndex + 1, 'first');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async #prev(): Promise<void> {
|
|
710
|
+
if (this.#paginator.section === null) return;
|
|
711
|
+
const before = this.#paginator.page;
|
|
712
|
+
const after = await this.#paginator.previousPage();
|
|
713
|
+
if (after !== before) {
|
|
714
|
+
this.#emit('positionchange', this.#snapshot());
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
// At the section's first page: roll to the previous section's last page.
|
|
718
|
+
await this.#gotoSection(this.#sectionIndex - 1, 'last');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// --- goTo -----------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
async #goTo(target: GoToTarget): Promise<void> {
|
|
724
|
+
if (target === 'start') {
|
|
725
|
+
await this.#gotoSection(0, 'first');
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (target === 'end') {
|
|
729
|
+
await this.#gotoSection(this.#sections.length - 1, 'last');
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (typeof target === 'number') {
|
|
733
|
+
await this.#gotoFraction(target);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (typeof target === 'string') {
|
|
737
|
+
await this.#gotoHref(target);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (isPosition(target)) {
|
|
741
|
+
await this.#gotoPosition(target);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// TocItem.
|
|
745
|
+
await this.#gotoTocItem(target);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async #gotoFraction(fraction: number): Promise<void> {
|
|
749
|
+
const count = this.#sections.length;
|
|
750
|
+
if (count === 0) return;
|
|
751
|
+
const clamped = Math.min(1, Math.max(0, fraction));
|
|
752
|
+
// Even-weight the book across sections (matching BookProgress.bookFraction),
|
|
753
|
+
// then land on the page nearest the residual fraction within that section.
|
|
754
|
+
const scaled = clamped * count;
|
|
755
|
+
const index = Math.min(count - 1, Math.floor(scaled));
|
|
756
|
+
const within = scaled - index;
|
|
757
|
+
await this.#gotoSection(index, 'first');
|
|
758
|
+
if (this.#destroyed) return;
|
|
759
|
+
await this.#paginator.refine();
|
|
760
|
+
const state = this.#paginator.state;
|
|
761
|
+
if (state !== null && state.pageCount > 1) {
|
|
762
|
+
const page = Math.round(within * (state.pageCount - 1));
|
|
763
|
+
await this.#paginator.goToPage(page);
|
|
764
|
+
}
|
|
765
|
+
this.#emit('positionchange', this.#snapshot());
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async #gotoPosition(position: Position): Promise<void> {
|
|
769
|
+
const index = this.#indexOfSection(position.sectionId);
|
|
770
|
+
if (index === -1) return; // soft miss: unknown section
|
|
771
|
+
await this.#gotoSection(index, 'first');
|
|
772
|
+
if (this.#destroyed) return;
|
|
773
|
+
const page = await this.#paginator.pageOfPosition(position);
|
|
774
|
+
if (page >= 0) await this.#paginator.goToPage(page);
|
|
775
|
+
this.#emit('positionchange', this.#snapshot());
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async #gotoTocItem(item: TocItem): Promise<void> {
|
|
779
|
+
const index = this.#indexOfSection(item.sectionId);
|
|
780
|
+
if (index === -1) return; // soft miss
|
|
781
|
+
await this.#gotoSection(index, 'first');
|
|
782
|
+
if (this.#destroyed) return;
|
|
783
|
+
if (item.fragment !== undefined) await this.#seekFragment(item.fragment);
|
|
784
|
+
this.#emit('positionchange', this.#snapshot());
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Resolves a content-internal href to a section (+ fragment) using ONLY public
|
|
789
|
+
* Book data, then navigates. The core exposes no href→section seam (sections are
|
|
790
|
+
* id-addressed; `Section.resolve` yields a `Resource`, not a section target), so
|
|
791
|
+
* this is a best-effort, format-neutral heuristic that degrades quietly on a
|
|
792
|
+
* miss — consistent with the Position soft-miss philosophy. In order:
|
|
793
|
+
* 1. the path (or its last segment) equals a `section.id`;
|
|
794
|
+
* 2. the last segment, or that segment minus its extension, matches a
|
|
795
|
+
* `section.id` or a section id's own last segment / extension-less form;
|
|
796
|
+
* 3. the href carries a fragment that a `TocItem` also carries — borrow that
|
|
797
|
+
* item's `sectionId`.
|
|
798
|
+
* A pure-fragment href (`#note`) seeks within the current section.
|
|
799
|
+
*
|
|
800
|
+
* Returns whether the href resolved and navigation happened — `false` on a
|
|
801
|
+
* soft miss — so the link-click path can decide whether a back-stack entry is
|
|
802
|
+
* warranted. `goTo` ignores the value.
|
|
803
|
+
*/
|
|
804
|
+
async #gotoHref(href: string): Promise<boolean> {
|
|
805
|
+
const { path, fragment } = splitFragment(href);
|
|
806
|
+
if (path === '') {
|
|
807
|
+
// Pure fragment: within the current section.
|
|
808
|
+
if (fragment !== undefined) await this.#seekFragment(fragment);
|
|
809
|
+
this.#emit('positionchange', this.#snapshot());
|
|
810
|
+
return fragment !== undefined;
|
|
811
|
+
}
|
|
812
|
+
// Prefer the format's own section-relative resolver: it maps the raw href to a
|
|
813
|
+
// section by the same authority the TOC uses (EPUB's resolved-path → id table),
|
|
814
|
+
// which the id/filename heuristic below cannot match against opaque manifest ids.
|
|
815
|
+
// Fall back to the heuristic for formats that expose no resolver.
|
|
816
|
+
const viaFormat = this.#paginator.section?.resolveHref?.(href);
|
|
817
|
+
let index = viaFormat === undefined ? -1 : this.#indexOfSection(viaFormat.sectionId);
|
|
818
|
+
const targetFragment = (viaFormat?.fragment ?? fragment);
|
|
819
|
+
if (index === -1) index = this.#resolveHrefToSection(path, fragment);
|
|
820
|
+
if (index === -1) return false; // soft miss
|
|
821
|
+
await this.#gotoSection(index, 'first');
|
|
822
|
+
if (this.#destroyed) return false;
|
|
823
|
+
if (targetFragment !== undefined) await this.#seekFragment(targetFragment);
|
|
824
|
+
this.#emit('positionchange', this.#snapshot());
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Heuristic href-path → section index; -1 when nothing matches (soft miss). */
|
|
829
|
+
#resolveHrefToSection(path: string, fragment: string | undefined): number {
|
|
830
|
+
// 1. Direct id match on the full path or its last segment.
|
|
831
|
+
const segment = lastSegment(path);
|
|
832
|
+
for (const candidate of [path, segment]) {
|
|
833
|
+
const direct = this.#indexOfSection(candidate);
|
|
834
|
+
if (direct !== -1) return direct;
|
|
835
|
+
}
|
|
836
|
+
// 2. Filename / extension-less match against section ids and their tails.
|
|
837
|
+
const stem = stripExtension(segment);
|
|
838
|
+
for (let index = 0; index < this.#sections.length; index += 1) {
|
|
839
|
+
const id = this.#sections[index]!.id;
|
|
840
|
+
const idSegment = lastSegment(id);
|
|
841
|
+
if (
|
|
842
|
+
id === segment ||
|
|
843
|
+
id === stem ||
|
|
844
|
+
idSegment === segment ||
|
|
845
|
+
idSegment === stem ||
|
|
846
|
+
stripExtension(idSegment) === stem
|
|
847
|
+
) {
|
|
848
|
+
return index;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// 3. TOC fragment fallback: a toc item carrying this fragment names a section.
|
|
852
|
+
if (fragment !== undefined) {
|
|
853
|
+
const byFragment = this.#findTocByFragment(this.#book.toc, fragment);
|
|
854
|
+
if (byFragment !== undefined) return this.#indexOfSection(byFragment);
|
|
855
|
+
}
|
|
856
|
+
return -1;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
#findTocByFragment(items: readonly TocItem[], fragment: string): string | undefined {
|
|
860
|
+
for (const item of items) {
|
|
861
|
+
if (item.fragment === fragment) return item.sectionId;
|
|
862
|
+
const nested = this.#findTocByFragment(item.children, fragment);
|
|
863
|
+
if (nested !== undefined) return nested;
|
|
864
|
+
}
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Best-effort seek to a fragment within the already-paginated section. The frame
|
|
870
|
+
* maps an element id to its character offset (`offsetOfElementId`), which the
|
|
871
|
+
* paginator turns into a page via `pageOfElementId`. A resolved id lands on that
|
|
872
|
+
* page; an unresolvable id (no such element) is a soft miss that lands on the
|
|
873
|
+
* section's first page — never a throw. Kept as a seam so callers need not
|
|
874
|
+
* special-case fragment presence.
|
|
875
|
+
*/
|
|
876
|
+
async #seekFragment(fragment: string): Promise<void> {
|
|
877
|
+
const page = await this.#paginator.pageOfElementId(fragment);
|
|
878
|
+
// Soft miss (-1): the id is absent, so stay at the section's first page.
|
|
879
|
+
await this.#paginator.goToPage(page >= 0 ? page : 0);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// --- selection ------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Turns a frame-forwarded selection (UTF-16 offset range + text over the section
|
|
886
|
+
* text) into a `Position` anchored at the range start and emits `selection`. The
|
|
887
|
+
* frame already dropped collapsed and empty-rect selections, so a callback here
|
|
888
|
+
* always carries real text. Position capture reads the section text but moves
|
|
889
|
+
* nothing, so it is not enqueued behind navigation.
|
|
890
|
+
*/
|
|
891
|
+
async #onSelection(selection: { start: number; end: number; text: string }): Promise<void> {
|
|
892
|
+
if (this.#destroyed || this.#paginator.section === null) return;
|
|
893
|
+
const position = await this.#paginator.positionOfOffsetRange(selection.start, selection.end);
|
|
894
|
+
if (this.#destroyed) return;
|
|
895
|
+
this.#emit('selection', { text: selection.text, position });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// --- back-stack -----------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
async #onLinkClick(href: string): Promise<void> {
|
|
901
|
+
if (this.#destroyed) return;
|
|
902
|
+
this.#emit('linkclick', { href });
|
|
903
|
+
// Capture the pre-jump position, follow the link, and push only if the href
|
|
904
|
+
// actually resolved and navigated. An external (https:) or otherwise
|
|
905
|
+
// unresolvable href is a soft miss that moves nothing — pushing it would
|
|
906
|
+
// make the next back() a no-op "return" to where the user already is
|
|
907
|
+
// instead of the genuinely prior position. Queued so it settles in order
|
|
908
|
+
// with any concurrent navigation.
|
|
909
|
+
await this.#run(async () => {
|
|
910
|
+
const here = await this.#currentPosition();
|
|
911
|
+
const navigated = await this.#gotoHref(href);
|
|
912
|
+
if (navigated && here !== null) this.#backStack.push(here);
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async #back(): Promise<void> {
|
|
917
|
+
const position = this.#backStack.pop();
|
|
918
|
+
if (position === undefined) return;
|
|
919
|
+
await this.#gotoPosition(position);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// --- mode switch ----------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
async #setMode(mode: LayoutMode): Promise<void> {
|
|
925
|
+
if (mode === this.#mode) return;
|
|
926
|
+
if (this.#paginator.section === null) {
|
|
927
|
+
this.#mode = mode;
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// The paginator itself captures a Position, re-paginates, and seeks back,
|
|
931
|
+
// so position is preserved across the switch (M1-2 machinery).
|
|
932
|
+
await this.#paginator.switchMode(mode);
|
|
933
|
+
this.#mode = mode;
|
|
934
|
+
this.#emit('positionchange', this.#snapshot());
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// --- appearance -----------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Merges `appearance` over the live state, rebuilds the appearance stylesheet,
|
|
941
|
+
* and applies it to the current section preserving the reading place. Two paths,
|
|
942
|
+
* chosen by whether the *update* touches a reflowing typography knob:
|
|
943
|
+
*
|
|
944
|
+
* - **Non-reflowing** (theme colours / background only): `setThemeCss` re-assembles
|
|
945
|
+
* the srcdoc, captures a `Position`, and seeks back. Geometry is invariant, so
|
|
946
|
+
* the exact page is restored and the text does not reflow.
|
|
947
|
+
* - **Reflowing** (font family/size, line height, alignment, hyphenation, columns):
|
|
948
|
+
* `applyAppearance` reuses the same capture → re-layout → resolve → restore
|
|
949
|
+
* machinery as `setMode`, but geometry changes, so the anchor's *nearest* page is
|
|
950
|
+
* restored (the reading place survives the reflow, page number may shift).
|
|
951
|
+
*
|
|
952
|
+
* A no-op change short-circuits inside the paginator. Before the first paint it
|
|
953
|
+
* records the appearance for the opening render.
|
|
954
|
+
*/
|
|
955
|
+
async #setAppearance(appearance: Appearance): Promise<void> {
|
|
956
|
+
const reflows = isReflowingUpdate(appearance);
|
|
957
|
+
this.#appearance = mergeAppearance(this.#appearance, appearance);
|
|
958
|
+
const css = themeStyleSheet(this.#appearance);
|
|
959
|
+
if (reflows) {
|
|
960
|
+
const geometry: { columnCount?: number; columnGap?: number } = {};
|
|
961
|
+
if (this.#appearance.columns !== undefined) geometry.columnCount = this.#appearance.columns;
|
|
962
|
+
if (this.#appearance.margin !== undefined) geometry.columnGap = this.#appearance.margin;
|
|
963
|
+
await this.#paginator.applyAppearance(css, geometry);
|
|
964
|
+
} else {
|
|
965
|
+
await this.#paginator.setThemeCss(css);
|
|
966
|
+
}
|
|
967
|
+
if (this.#destroyed) return;
|
|
968
|
+
// Emitted after the change settles so a host can refresh anything keyed on the
|
|
969
|
+
// settled state (parity with setMode). A reflowing knob may have moved the page
|
|
970
|
+
// number even though the reading place is held.
|
|
971
|
+
this.#emit('positionchange', this.#snapshot());
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// --- helpers --------------------------------------------------------------
|
|
975
|
+
|
|
976
|
+
#indexOfSection(sectionId: string): number {
|
|
977
|
+
return this.#sections.findIndex((section) => section.id === sectionId);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The current reading position as a `Position`, or null before the first paint. */
|
|
981
|
+
async #currentPosition(): Promise<Position | null> {
|
|
982
|
+
if (this.#paginator.section === null) return null;
|
|
983
|
+
return this.#paginator.positionOfPage(this.#paginator.page);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
#snapshot(): ReaderPosition {
|
|
987
|
+
if (this.#paginator.section === null || this.#paginator.state === null) {
|
|
988
|
+
return { section: this.#sectionIndex, progress: 0, chapterProgress: 0, page: 0, totalPages: 0 };
|
|
989
|
+
}
|
|
990
|
+
const progress: BookProgress = this.#paginator.bookProgress(
|
|
991
|
+
this.#sectionIndex,
|
|
992
|
+
this.#sections.length,
|
|
993
|
+
);
|
|
994
|
+
return {
|
|
995
|
+
section: this.#sectionIndex,
|
|
996
|
+
progress: progress.bookFraction,
|
|
997
|
+
chapterProgress: progress.fraction,
|
|
998
|
+
page: progress.page,
|
|
999
|
+
totalPages: progress.totalPages,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
#emit<E extends ReaderEvent>(event: E, payload: ReaderEventMap[E]): void {
|
|
1004
|
+
// Copy so a handler unsubscribing mid-dispatch cannot skip a sibling.
|
|
1005
|
+
for (const handler of [...this.#listeners[event]]) {
|
|
1006
|
+
try {
|
|
1007
|
+
handler(payload);
|
|
1008
|
+
} catch {
|
|
1009
|
+
// A listener throwing must not derail the emit or the navigation.
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The host-side image-zoom overlay. It lives entirely in the *host* document,
|
|
1017
|
+
* outside the sandboxed frame: it renders the image's already-served `data:` bytes
|
|
1018
|
+
* in a host DOM element, so it mints no new frame resource and — crucially — never
|
|
1019
|
+
* a `blob:` URL (an opaque-origin frame cannot load a host blob URL; `data:` is the
|
|
1020
|
+
* boundary-crossing form the whole view stack already relies on).
|
|
1021
|
+
*
|
|
1022
|
+
* Accessibility: it is a modal dialog with a focus TRAP (Tab cycles the close
|
|
1023
|
+
* control and image), closes on Escape or a backdrop click, and restores focus to
|
|
1024
|
+
* whatever the reader element held before it opened. Its open/close transition —
|
|
1025
|
+
* the reader's only animation — is gated on `prefers-reduced-motion`; input and
|
|
1026
|
+
* page-turning are never gated on the preference.
|
|
1027
|
+
*/
|
|
1028
|
+
class ImageZoom {
|
|
1029
|
+
readonly #anchor: HTMLElement;
|
|
1030
|
+
readonly #doc: Document;
|
|
1031
|
+
#root: HTMLElement | null = null;
|
|
1032
|
+
#image: HTMLImageElement | null = null;
|
|
1033
|
+
#closeButton: HTMLButtonElement | null = null;
|
|
1034
|
+
#previouslyFocused: HTMLElement | null = null;
|
|
1035
|
+
readonly #onKeyDown = (event: KeyboardEvent): void => {
|
|
1036
|
+
if (event.key === 'Escape') {
|
|
1037
|
+
event.preventDefault();
|
|
1038
|
+
this.close();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (event.key === 'Tab') this.#trapTab(event);
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
constructor(anchor: HTMLElement) {
|
|
1045
|
+
this.#anchor = anchor;
|
|
1046
|
+
const doc = anchor.ownerDocument;
|
|
1047
|
+
if (doc === null) throw new ReaderError('the reader element is not in a document');
|
|
1048
|
+
this.#doc = doc;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
open(src: string, alt: string): void {
|
|
1052
|
+
if (this.#root !== null) {
|
|
1053
|
+
// Re-target an already-open overlay rather than stacking a second one.
|
|
1054
|
+
this.#image!.src = src;
|
|
1055
|
+
this.#image!.alt = alt;
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const active = this.#doc.activeElement;
|
|
1059
|
+
this.#previouslyFocused = active instanceof HTMLElement ? active : this.#anchor;
|
|
1060
|
+
|
|
1061
|
+
const root = this.#doc.createElement('div');
|
|
1062
|
+
root.setAttribute('role', 'dialog');
|
|
1063
|
+
root.setAttribute('aria-modal', 'true');
|
|
1064
|
+
root.setAttribute('aria-label', alt !== '' ? alt : 'Zoomed image');
|
|
1065
|
+
Object.assign(root.style, {
|
|
1066
|
+
position: 'fixed',
|
|
1067
|
+
inset: '0',
|
|
1068
|
+
display: 'flex',
|
|
1069
|
+
alignItems: 'center',
|
|
1070
|
+
justifyContent: 'center',
|
|
1071
|
+
background: 'rgba(0, 0, 0, 0.85)',
|
|
1072
|
+
zIndex: '2147483647',
|
|
1073
|
+
padding: '4vmin',
|
|
1074
|
+
boxSizing: 'border-box',
|
|
1075
|
+
opacity: this.#reducedMotion() ? '1' : '0',
|
|
1076
|
+
transition: this.#reducedMotion() ? 'none' : 'opacity 150ms ease',
|
|
1077
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
1078
|
+
root.addEventListener('pointerdown', (event) => {
|
|
1079
|
+
if (event.target === root) this.close();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const image = this.#doc.createElement('img');
|
|
1083
|
+
image.src = src;
|
|
1084
|
+
image.alt = alt;
|
|
1085
|
+
image.tabIndex = 0;
|
|
1086
|
+
Object.assign(image.style, {
|
|
1087
|
+
maxWidth: '100%',
|
|
1088
|
+
maxHeight: '100%',
|
|
1089
|
+
objectFit: 'contain',
|
|
1090
|
+
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.5)',
|
|
1091
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
1092
|
+
|
|
1093
|
+
const close = this.#doc.createElement('button');
|
|
1094
|
+
close.type = 'button';
|
|
1095
|
+
close.setAttribute('aria-label', 'Close');
|
|
1096
|
+
close.textContent = '×';
|
|
1097
|
+
Object.assign(close.style, {
|
|
1098
|
+
position: 'absolute',
|
|
1099
|
+
top: '2vmin',
|
|
1100
|
+
right: '2vmin',
|
|
1101
|
+
width: '2.5rem',
|
|
1102
|
+
height: '2.5rem',
|
|
1103
|
+
fontSize: '1.5rem',
|
|
1104
|
+
lineHeight: '1',
|
|
1105
|
+
cursor: 'pointer',
|
|
1106
|
+
border: '0',
|
|
1107
|
+
borderRadius: '50%',
|
|
1108
|
+
background: 'rgba(255, 255, 255, 0.9)',
|
|
1109
|
+
color: '#000',
|
|
1110
|
+
} satisfies Partial<CSSStyleDeclaration>);
|
|
1111
|
+
close.addEventListener('click', () => {
|
|
1112
|
+
this.close();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
root.append(image, close);
|
|
1116
|
+
this.#doc.body.append(root);
|
|
1117
|
+
this.#root = root;
|
|
1118
|
+
this.#image = image;
|
|
1119
|
+
this.#closeButton = close;
|
|
1120
|
+
this.#doc.addEventListener('keydown', this.#onKeyDown, true);
|
|
1121
|
+
// Fade in on the next frame so the transition has a start value to animate from.
|
|
1122
|
+
if (!this.#reducedMotion()) {
|
|
1123
|
+
const view = this.#doc.defaultView;
|
|
1124
|
+
if (view !== null) view.requestAnimationFrame(() => (root.style.opacity = '1'));
|
|
1125
|
+
}
|
|
1126
|
+
close.focus();
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
close(): void {
|
|
1130
|
+
const root = this.#root;
|
|
1131
|
+
if (root === null) return;
|
|
1132
|
+
this.#doc.removeEventListener('keydown', this.#onKeyDown, true);
|
|
1133
|
+
this.#root = null;
|
|
1134
|
+
this.#image = null;
|
|
1135
|
+
this.#closeButton = null;
|
|
1136
|
+
const restore = this.#previouslyFocused;
|
|
1137
|
+
this.#previouslyFocused = null;
|
|
1138
|
+
root.remove();
|
|
1139
|
+
this.#restoreFocus(restore);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Return focus to the reader region. The tap that opened the overlay came from
|
|
1144
|
+
* inside the sandboxed frame, so the host's active element is usually the frame's
|
|
1145
|
+
* body (not focusable from here) — fall back to the reader mount, made
|
|
1146
|
+
* programmatically focusable with a temporary `tabindex` so focus lands on the
|
|
1147
|
+
* reader rather than the document body.
|
|
1148
|
+
*/
|
|
1149
|
+
#restoreFocus(saved: HTMLElement | null): void {
|
|
1150
|
+
if (saved !== null && saved.isConnected && saved !== this.#doc.body) {
|
|
1151
|
+
saved.focus();
|
|
1152
|
+
if (this.#doc.activeElement === saved) return;
|
|
1153
|
+
}
|
|
1154
|
+
const anchor = this.#anchor;
|
|
1155
|
+
// A bare div is not focusable; a -1 tabindex makes it programmatically
|
|
1156
|
+
// focusable without adding it to the tab order. Idempotent and benign.
|
|
1157
|
+
if (!anchor.hasAttribute('tabindex')) anchor.tabIndex = -1;
|
|
1158
|
+
anchor.focus();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
destroy(): void {
|
|
1162
|
+
this.close();
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Keep Tab focus inside the overlay: the close control and image are the stops.
|
|
1167
|
+
* Tab is intercepted unconditionally and focus is moved explicitly so it can
|
|
1168
|
+
* never escape the overlay — the reader region behind it is inert while open.
|
|
1169
|
+
*/
|
|
1170
|
+
#trapTab(event: KeyboardEvent): void {
|
|
1171
|
+
event.preventDefault();
|
|
1172
|
+
const stops = this.#focusStops();
|
|
1173
|
+
if (stops.length === 0) return;
|
|
1174
|
+
const active = this.#doc.activeElement as HTMLElement | null;
|
|
1175
|
+
const current = active === null ? -1 : stops.indexOf(active);
|
|
1176
|
+
const step = event.shiftKey ? -1 : 1;
|
|
1177
|
+
const nextIndex = current === -1 ? 0 : (current + step + stops.length) % stops.length;
|
|
1178
|
+
stops[nextIndex]!.focus();
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
#focusStops(): HTMLElement[] {
|
|
1182
|
+
const stops: HTMLElement[] = [];
|
|
1183
|
+
if (this.#closeButton !== null) stops.push(this.#closeButton);
|
|
1184
|
+
if (this.#image !== null) stops.push(this.#image);
|
|
1185
|
+
return stops;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
#reducedMotion(): boolean {
|
|
1189
|
+
const view = this.#doc.defaultView;
|
|
1190
|
+
return view !== null && view.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Structural check for a `Position` (vs a TocItem, both objects). */
|
|
1195
|
+
function isPosition(value: TocItem | Position): value is Position {
|
|
1196
|
+
return (
|
|
1197
|
+
typeof (value as Position).serialized === 'string' &&
|
|
1198
|
+
typeof (value as Position).sectionId === 'string' &&
|
|
1199
|
+
(value as { anchor?: unknown }).anchor !== undefined &&
|
|
1200
|
+
(value as { children?: unknown }).children === undefined
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Re-exported so a caller can rebuild a Position from its serialized form before
|
|
1205
|
+
// handing it to goTo — the facade never persists, but goTo(Position) is a target.
|
|
1206
|
+
export { parsePosition };
|