@readium/navigator 2.10.3 → 2.11.1
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/dist/audio/AudioNavigator.js +1 -1
- package/dist/audio/AudioPoolManager.js +1 -1
- package/dist/audio/engine/WebAudioEngine.js +1 -1
- package/dist/divina/DivinaNavigator.js +1 -0
- package/dist/divina/DivinaPageManager.js +1 -0
- package/dist/divina/DivinaPagedPresenter.js +1 -0
- package/dist/divina/DivinaPeripherals.js +1 -0
- package/dist/divina/DivinaScrolledPresenter.js +1 -0
- package/dist/divina/DivinaSpreader.js +1 -0
- package/dist/divina/DivinaVariantSelector.js +1 -0
- package/dist/divina/preferences/DivinaDefaults.js +1 -0
- package/dist/divina/preferences/DivinaPreferences.js +1 -0
- package/dist/divina/preferences/DivinaPreferencesEditor.js +1 -0
- package/dist/divina/preferences/DivinaSettings.js +1 -0
- package/dist/divina/protection/DivinaNavigatorProtector.js +1 -0
- package/dist/epub/EpubNavigator.js +1 -1
- package/dist/epub/frame/FrameBlobBuilder.js +3 -1
- package/dist/epub/frame/FrameComms.js +1 -1
- package/dist/epub/frame/FrameManager.js +1 -1
- package/dist/epub/frame/FramePoolManager.js +1 -1
- package/dist/epub/fxl/FXLFrameManager.js +1 -1
- package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
- package/dist/epub/fxl/FXLPeripherals.js +2 -2
- package/dist/helpers/serialize.js +1 -0
- package/dist/index.js +1 -1
- package/dist/peripherals/KeyboardConditionBridge.js +1 -1
- package/dist/peripherals/KeyboardPeripherals.js +1 -1
- package/dist/webpub/WebPubFrameManager.js +1 -1
- package/dist/webpub/WebPubFramePoolManager.js +1 -1
- package/dist/webpub/WebPubNavigator.js +1 -1
- package/package.json +5 -5
- package/src/audio/AudioNavigator.ts +107 -5
- package/src/audio/AudioPoolManager.ts +14 -8
- package/src/audio/engine/AudioEngine.ts +34 -1
- package/src/audio/engine/WebAudioEngine.ts +48 -3
- package/src/divina/DivinaNavigator.ts +704 -0
- package/src/divina/DivinaPageManager.ts +322 -0
- package/src/divina/DivinaPagedPresenter.ts +508 -0
- package/src/divina/DivinaPeripherals.ts +630 -0
- package/src/divina/DivinaScrolledPresenter.ts +516 -0
- package/src/divina/DivinaSpreader.ts +202 -0
- package/src/divina/DivinaVariantSelector.ts +77 -0
- package/src/divina/index.ts +8 -0
- package/src/divina/preferences/DivinaDefaults.ts +37 -0
- package/src/divina/preferences/DivinaPreferences.ts +77 -0
- package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
- package/src/divina/preferences/DivinaSettings.ts +38 -0
- package/src/divina/preferences/index.ts +4 -0
- package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
- package/src/epub/EpubNavigator.ts +73 -7
- package/src/epub/frame/FrameBlobBuilder.ts +144 -39
- package/src/epub/frame/FrameComms.ts +42 -7
- package/src/epub/frame/FrameManager.ts +27 -16
- package/src/epub/frame/FramePoolManager.ts +106 -48
- package/src/epub/fxl/FXLFrameManager.ts +99 -34
- package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
- package/src/epub/fxl/FXLPeripherals.ts +22 -1
- package/src/helpers/serialize.ts +9 -0
- package/src/index.ts +1 -0
- package/src/peripherals/KeyboardConditionBridge.ts +15 -5
- package/src/peripherals/KeyboardPeripherals.ts +13 -7
- package/src/webpub/WebPubFrameManager.ts +27 -16
- package/src/webpub/WebPubFramePoolManager.ts +5 -2
- package/src/webpub/WebPubNavigator.ts +44 -0
- package/types/src/audio/AudioNavigator.d.ts +29 -0
- package/types/src/audio/engine/AudioEngine.d.ts +27 -1
- package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
- package/types/src/divina/DivinaNavigator.d.ts +119 -0
- package/types/src/divina/DivinaPageManager.d.ts +66 -0
- package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
- package/types/src/divina/DivinaPeripherals.d.ts +121 -0
- package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
- package/types/src/divina/DivinaSpreader.d.ts +65 -0
- package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
- package/types/src/divina/index.d.ts +8 -0
- package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
- package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
- package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
- package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
- package/types/src/divina/preferences/index.d.ts +4 -0
- package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
- package/types/src/epub/EpubNavigator.d.ts +7 -1
- package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
- package/types/src/epub/frame/FrameComms.d.ts +8 -1
- package/types/src/epub/frame/FrameManager.d.ts +3 -0
- package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
- package/types/src/epub/fxl/FXLFrameManager.d.ts +7 -0
- package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
- package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
- package/types/src/helpers/serialize.d.ts +1 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
- package/types/src/webpub/WebPubFrameManager.d.ts +3 -0
- package/types/src/webpub/WebPubNavigator.d.ts +2 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { ProgressionRange, VisualNavigatorViewport } from "../Navigator.ts";
|
|
2
|
+
import { DivinaPageManager } from "./DivinaPageManager.ts";
|
|
3
|
+
import { DivinaSpreader } from "./DivinaSpreader.ts";
|
|
4
|
+
|
|
5
|
+
const UPPER_BOUNDARY = 10; // Unload pages further than this many items away
|
|
6
|
+
const LOWER_BOUNDARY = 5; // Load pages within this many items
|
|
7
|
+
const SCROLL_STEP_FACTOR = 0.8; // goForward/goBackward scroll this fraction of the viewport
|
|
8
|
+
const POSITION_DEBOUNCE = 150; // ms of scroll quiet before reporting the position
|
|
9
|
+
const PROGRAMMATIC_SCROLL_TIMEOUT = 1200; // Fallback for browsers without scrollend
|
|
10
|
+
// Owned scroll animation (native smooth scrolling restarts its easing curve on
|
|
11
|
+
// every retarget, which crawls under key autorepeat and then sprints the
|
|
12
|
+
// accumulated backlog on release)
|
|
13
|
+
const SCROLL_CRUISE_SPEED = 8; // Top speed, in viewports per second (held page keys reach it, line steps stay demand-limited)
|
|
14
|
+
const SCROLL_APPROACH_RATE = 20; // 1/s: fraction of the remaining distance per second (ease-out)
|
|
15
|
+
const SCROLL_TARGET_MAX_LEAD = 1.5; // Chained targets may lead the position by at most this many viewports
|
|
16
|
+
|
|
17
|
+
export type DivinaScrolledEventKey = "scroll" | "position" | "tap" | "click";
|
|
18
|
+
export type DivinaScrolledListener = (key: DivinaScrolledEventKey, data: unknown) => void;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Vertical scrolled ("webtoon") presenter for Divina publications: all pages
|
|
22
|
+
* stacked in a native scroll container with no gaps, constrained to a
|
|
23
|
+
* configurable strip width, based on xbreader but improved
|
|
24
|
+
*
|
|
25
|
+
* Page offsets are cached and looked up with binary search so per-frame
|
|
26
|
+
* scroll work stays constant regardless of publication size.
|
|
27
|
+
*/
|
|
28
|
+
export class DivinaScrolledPresenter {
|
|
29
|
+
private readonly container: HTMLElement;
|
|
30
|
+
private readonly spreader: DivinaSpreader;
|
|
31
|
+
private readonly pool: Map<number, DivinaPageManager>;
|
|
32
|
+
|
|
33
|
+
public readonly scrollerElement: HTMLDivElement;
|
|
34
|
+
private stripWidth: number;
|
|
35
|
+
private destroyed = false;
|
|
36
|
+
private scrollRAF = 0;
|
|
37
|
+
private restoreRAF = 0;
|
|
38
|
+
private positionTimer = 0;
|
|
39
|
+
private lastScrollTop = 0;
|
|
40
|
+
/** Position within the strip as a fraction of total strip height (resize retention) */
|
|
41
|
+
private fraction = 0;
|
|
42
|
+
|
|
43
|
+
// Cached layout metrics, refreshed on relayout and self-healed on drift
|
|
44
|
+
private pageTops: number[] = [];
|
|
45
|
+
private pageHeights: number[] = [];
|
|
46
|
+
private stripHeightCached = 0;
|
|
47
|
+
private viewportHeightCached = 0;
|
|
48
|
+
|
|
49
|
+
// Load-window bookkeeping
|
|
50
|
+
private loadedIndices: Set<number> = new Set();
|
|
51
|
+
private lastWindowIndex = -1;
|
|
52
|
+
|
|
53
|
+
// Programmatic scrolling (keyboard nav, goTo, restores): suppresses
|
|
54
|
+
// "scroll" listener emissions and accumulates chained smooth targets
|
|
55
|
+
private programmaticScroll = false;
|
|
56
|
+
private programmaticTimer = 0;
|
|
57
|
+
private scrollTarget: number | null = null;
|
|
58
|
+
|
|
59
|
+
// Owned smooth-scroll animation state
|
|
60
|
+
private animating = false;
|
|
61
|
+
private animRAF = 0;
|
|
62
|
+
private animLastTime = 0;
|
|
63
|
+
|
|
64
|
+
public listener: DivinaScrolledListener = () => {};
|
|
65
|
+
|
|
66
|
+
constructor(
|
|
67
|
+
container: HTMLElement,
|
|
68
|
+
spreader: DivinaSpreader,
|
|
69
|
+
pool: Map<number, DivinaPageManager>,
|
|
70
|
+
stripWidth: number
|
|
71
|
+
) {
|
|
72
|
+
this.container = container;
|
|
73
|
+
this.spreader = spreader;
|
|
74
|
+
this.pool = pool;
|
|
75
|
+
this.stripWidth = stripWidth;
|
|
76
|
+
|
|
77
|
+
this.scrollerElement = document.createElement("div");
|
|
78
|
+
this.scrollerElement.ariaLabel = "Book";
|
|
79
|
+
this.scrollerElement.tabIndex = -1;
|
|
80
|
+
Object.assign(this.scrollerElement.style, {
|
|
81
|
+
overflowY: "auto",
|
|
82
|
+
overflowX: "hidden",
|
|
83
|
+
height: "100%",
|
|
84
|
+
width: "100%",
|
|
85
|
+
position: "relative",
|
|
86
|
+
outline: "none",
|
|
87
|
+
overscrollBehavior: "contain",
|
|
88
|
+
} as unknown as CSSStyleDeclaration);
|
|
89
|
+
|
|
90
|
+
const fragment = document.createDocumentFragment();
|
|
91
|
+
this.spreader.pages.forEach((page) => {
|
|
92
|
+
const pm = this.pool.get(page.index)!;
|
|
93
|
+
pm.setMode("scrolled");
|
|
94
|
+
pm.wrapper.style.removeProperty("contain");
|
|
95
|
+
fragment.appendChild(pm.wrapper);
|
|
96
|
+
});
|
|
97
|
+
this.scrollerElement.appendChild(fragment);
|
|
98
|
+
this.container.appendChild(this.scrollerElement);
|
|
99
|
+
|
|
100
|
+
this.applyStripWidth(stripWidth);
|
|
101
|
+
|
|
102
|
+
this.scrollerElement.addEventListener("scroll", this.bscrollHandler, { passive: true });
|
|
103
|
+
this.scrollerElement.addEventListener("scrollend", this.bscrollendHandler);
|
|
104
|
+
this.scrollerElement.addEventListener("wheel", this.buserTakeoverHandler, { passive: true });
|
|
105
|
+
this.scrollerElement.addEventListener("mousedown", this.bmousedownHandler);
|
|
106
|
+
this.scrollerElement.addEventListener("mouseup", this.bclickHandler);
|
|
107
|
+
this.scrollerElement.addEventListener("touchend", this.btouchendHandler);
|
|
108
|
+
this.scrollerElement.addEventListener("touchstart", this.btouchstartHandler, { passive: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private readonly bscrollHandler = this.scrollHandler.bind(this);
|
|
112
|
+
private readonly bscrollendHandler = this.scrollendHandler.bind(this);
|
|
113
|
+
private readonly banimStep = this.animStep.bind(this);
|
|
114
|
+
private readonly buserTakeoverHandler = this.userTakeoverHandler.bind(this);
|
|
115
|
+
private readonly bmousedownHandler = this.mousedownHandler.bind(this);
|
|
116
|
+
private readonly bclickHandler = this.clickHandler.bind(this);
|
|
117
|
+
private readonly btouchendHandler = this.touchendHandler.bind(this);
|
|
118
|
+
private readonly btouchstartHandler = this.touchstartHandler.bind(this);
|
|
119
|
+
|
|
120
|
+
/** Display width of each page in the strip */
|
|
121
|
+
private get displayWidth(): number {
|
|
122
|
+
return Math.max(1, Math.min(this.stripWidth, this.scrollerElement.clientWidth || this.container.clientWidth));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Recomputes the cached page offsets and viewport metrics.
|
|
127
|
+
* Cheap relative to the per-frame layout reads it replaces, but still a
|
|
128
|
+
* forced layout: only call after actual layout changes.
|
|
129
|
+
*/
|
|
130
|
+
private refreshLayoutCache() {
|
|
131
|
+
this.viewportHeightCached = this.scrollerElement.clientHeight;
|
|
132
|
+
this.stripHeightCached = this.scrollerElement.scrollHeight;
|
|
133
|
+
const n = this.spreader.pages.length;
|
|
134
|
+
this.pageTops = new Array(n);
|
|
135
|
+
this.pageHeights = new Array(n);
|
|
136
|
+
for (const page of this.spreader.pages) {
|
|
137
|
+
const w = this.pool.get(page.index)!.wrapper;
|
|
138
|
+
this.pageTops[page.index] = w.offsetTop;
|
|
139
|
+
this.pageHeights[page.index] = Math.max(1, w.offsetHeight);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The index of the last page whose top is at or above the given strip offset */
|
|
144
|
+
private indexAtOffset(offset: number): number {
|
|
145
|
+
const tops = this.pageTops;
|
|
146
|
+
let lo = 0, hi = tops.length - 1, ans = 0;
|
|
147
|
+
while (lo <= hi) {
|
|
148
|
+
const mid = (lo + hi) >> 1;
|
|
149
|
+
if (tops[mid] <= offset) { ans = mid; lo = mid + 1; } else hi = mid - 1;
|
|
150
|
+
}
|
|
151
|
+
return ans;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
applyStripWidth(stripWidth: number) {
|
|
155
|
+
this.stripWidth = stripWidth;
|
|
156
|
+
const fraction = this.fraction;
|
|
157
|
+
const w = this.displayWidth;
|
|
158
|
+
this.spreader.pages.forEach((page) => {
|
|
159
|
+
this.pool.get(page.index)?.applyStripWidth(w);
|
|
160
|
+
});
|
|
161
|
+
this.refreshLayoutCache();
|
|
162
|
+
// Restore the relative position after the relayout
|
|
163
|
+
this.restoreFraction(fraction);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private restoreFraction(fraction: number) {
|
|
167
|
+
cancelAnimationFrame(this.restoreRAF);
|
|
168
|
+
this.restoreRAF = requestAnimationFrame(() => {
|
|
169
|
+
if(this.destroyed) return;
|
|
170
|
+
const target = fraction * this.stripHeightCached;
|
|
171
|
+
if(Math.abs(this.scrollerElement.scrollTop - target) >= 1) {
|
|
172
|
+
this.beginProgrammaticScroll();
|
|
173
|
+
this.scrollTarget = target;
|
|
174
|
+
this.scrollerElement.scrollTop = target;
|
|
175
|
+
}
|
|
176
|
+
this.updateFraction();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private updateFraction() {
|
|
181
|
+
const h = this.stripHeightCached;
|
|
182
|
+
this.fraction = h > 0 ? this.scrollerElement.scrollTop / h : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Top offset in pixels of the given page within the strip */
|
|
186
|
+
private pageTop(index: number): number {
|
|
187
|
+
return this.pageTops[index] ?? 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private pageHeight(index: number): number {
|
|
191
|
+
return this.pageHeights[index] ?? 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The reading-order index of the current page: the last page whose top
|
|
196
|
+
* is above the middle of the viewport.
|
|
197
|
+
*/
|
|
198
|
+
get currentIndex(): number {
|
|
199
|
+
if(!this.pageTops.length) return 0;
|
|
200
|
+
return this.indexAtOffset(this.scrollerElement.scrollTop + this.viewportHeightCached * 0.5);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Progression within the current page (0..1) */
|
|
204
|
+
get currentPageProgression(): number {
|
|
205
|
+
const i = this.currentIndex;
|
|
206
|
+
return Math.max(0, Math.min(1, (this.scrollerElement.scrollTop - this.pageTop(i)) / this.pageHeight(i)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Marks the start of a programmatic scroll: "scroll" events are not
|
|
211
|
+
* reported to the host until the scroll settles (scrollend or timeout),
|
|
212
|
+
* so navigation doesn't masquerade as user scrolling.
|
|
213
|
+
*/
|
|
214
|
+
private beginProgrammaticScroll() {
|
|
215
|
+
this.programmaticScroll = true;
|
|
216
|
+
window.clearTimeout(this.programmaticTimer);
|
|
217
|
+
this.programmaticTimer = window.setTimeout(() => {
|
|
218
|
+
this.programmaticScroll = false;
|
|
219
|
+
this.scrollTarget = null;
|
|
220
|
+
this.settleWindow();
|
|
221
|
+
}, PROGRAMMATIC_SCROLL_TIMEOUT);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private scrollendHandler() {
|
|
225
|
+
// Instant scrollTop writes fire scrollend per write, so while the
|
|
226
|
+
// owned animation runs this event says nothing about settling —
|
|
227
|
+
// acting on it would kill the animation one frame in
|
|
228
|
+
if(this.animating) return;
|
|
229
|
+
window.clearTimeout(this.programmaticTimer);
|
|
230
|
+
this.programmaticScroll = false;
|
|
231
|
+
this.scrollTarget = null;
|
|
232
|
+
this.settleWindow();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Load the window around wherever the scroll settled */
|
|
236
|
+
private settleWindow() {
|
|
237
|
+
if(this.destroyed || !this.pageTops.length) return;
|
|
238
|
+
const index = this.currentIndex;
|
|
239
|
+
this.lastWindowIndex = index;
|
|
240
|
+
this.updateWindow(index);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Direct user input takes over any in-flight programmatic scroll */
|
|
244
|
+
private userTakeoverHandler() {
|
|
245
|
+
window.clearTimeout(this.programmaticTimer);
|
|
246
|
+
this.programmaticScroll = false;
|
|
247
|
+
this.scrollTarget = null;
|
|
248
|
+
this.stopAnimation();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private scrollHandler() {
|
|
252
|
+
if(this.destroyed) return;
|
|
253
|
+
cancelAnimationFrame(this.scrollRAF);
|
|
254
|
+
this.scrollRAF = requestAnimationFrame(() => {
|
|
255
|
+
if(this.destroyed) return;
|
|
256
|
+
const st = this.scrollerElement.scrollTop;
|
|
257
|
+
const delta = st - this.lastScrollTop;
|
|
258
|
+
this.lastScrollTop = st;
|
|
259
|
+
|
|
260
|
+
// Self-heal the layout cache if something (e.g. an image decode
|
|
261
|
+
// correcting an unknown aspect ratio) changed the strip height
|
|
262
|
+
if(Math.abs(this.scrollerElement.scrollHeight - this.stripHeightCached) > 1)
|
|
263
|
+
this.refreshLayoutCache();
|
|
264
|
+
|
|
265
|
+
this.updateFraction();
|
|
266
|
+
if(!this.programmaticScroll && delta !== 0) {
|
|
267
|
+
this.listener("scroll", delta);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// While a programmatic scroll (e.g. a held navigation key) is in
|
|
271
|
+
// flight, defer loading: pages rushing by would only churn
|
|
272
|
+
// fetches. The window is loaded when the scroll settles.
|
|
273
|
+
const index = this.currentIndex;
|
|
274
|
+
if(index !== this.lastWindowIndex && !this.programmaticScroll) {
|
|
275
|
+
this.lastWindowIndex = index;
|
|
276
|
+
this.updateWindow(index);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
window.clearTimeout(this.positionTimer);
|
|
280
|
+
this.positionTimer = window.setTimeout(() => {
|
|
281
|
+
if(this.destroyed) return;
|
|
282
|
+
this.listener("position", this.currentIndex);
|
|
283
|
+
}, POSITION_DEBOUNCE);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Load nearby pages, unload distant ones */
|
|
288
|
+
private updateWindow(center: number) {
|
|
289
|
+
const n = this.spreader.pages.length;
|
|
290
|
+
for (let i = Math.max(0, center - LOWER_BOUNDARY); i <= Math.min(n - 1, center + LOWER_BOUNDARY); i++) {
|
|
291
|
+
this.pool.get(i)?.load();
|
|
292
|
+
this.loadedIndices.add(i);
|
|
293
|
+
}
|
|
294
|
+
for (const i of this.loadedIndices) {
|
|
295
|
+
if(Math.abs(i - center) > UPPER_BOUNDARY) {
|
|
296
|
+
this.pool.get(i)?.unload();
|
|
297
|
+
this.loadedIndices.delete(i);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private touchStartY: number | null = null;
|
|
303
|
+
private lastTouchEnd = 0;
|
|
304
|
+
|
|
305
|
+
private touchstartHandler(e: TouchEvent) {
|
|
306
|
+
this.userTakeoverHandler();
|
|
307
|
+
this.touchStartY = e.touches.length === 1 ? e.touches[0].clientY : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private touchendHandler(e: TouchEvent) {
|
|
311
|
+
if(this.touchStartY === null || e.changedTouches.length !== 1) return;
|
|
312
|
+
const dy = Math.abs(e.changedTouches[0].clientY - this.touchStartY);
|
|
313
|
+
this.touchStartY = null;
|
|
314
|
+
if(dy <= 5) {
|
|
315
|
+
// Suppress the compatibility mouse events for this tap
|
|
316
|
+
if(e.cancelable) e.preventDefault();
|
|
317
|
+
this.lastTouchEnd = performance.now();
|
|
318
|
+
this.emitPointer("tap", e.changedTouches[0].clientX, e.changedTouches[0].clientY);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private mouseDownPos: { x: number, y: number } | null = null;
|
|
323
|
+
|
|
324
|
+
private mousedownHandler(e: MouseEvent) {
|
|
325
|
+
this.mouseDownPos = e.button === 0 ? { x: e.clientX, y: e.clientY } : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private clickHandler(e: MouseEvent) {
|
|
329
|
+
// Mouseup alone can't distinguish a click from the end of a drag; compare with mousedown
|
|
330
|
+
if(e.button !== 0 || this.mouseDownPos === null) return;
|
|
331
|
+
const moved = Math.abs(e.clientX - this.mouseDownPos.x) + Math.abs(e.clientY - this.mouseDownPos.y);
|
|
332
|
+
this.mouseDownPos = null;
|
|
333
|
+
// Ignore compatibility mouse events synthesized after a touch tap
|
|
334
|
+
if(performance.now() - this.lastTouchEnd < 700) return;
|
|
335
|
+
if(moved <= 5) this.emitPointer("click", e.clientX, e.clientY);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private emitPointer(key: "tap" | "click", clientX: number, clientY: number) {
|
|
339
|
+
const rect = this.scrollerElement.getBoundingClientRect();
|
|
340
|
+
const dpr = window.devicePixelRatio || 1;
|
|
341
|
+
this.listener(key, {
|
|
342
|
+
x: (clientX - rect.left) * dpr,
|
|
343
|
+
y: (clientY - rect.top) * dpr,
|
|
344
|
+
clientX,
|
|
345
|
+
clientY,
|
|
346
|
+
doNotDisturb: false
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Scroll forward/backward by a fraction of the viewport.
|
|
352
|
+
* Repeated calls chain from the pending target, so rapid key presses
|
|
353
|
+
* (including autorepeat from a held key) extend one animation that keeps
|
|
354
|
+
* cruising instead of restarting; the target is clamped to stay within
|
|
355
|
+
* reach of the position so releasing the key stops promptly rather than
|
|
356
|
+
* replaying the queued distance.
|
|
357
|
+
* @returns whether scrolling was possible
|
|
358
|
+
*/
|
|
359
|
+
next(animated = true, stepPx?: number): boolean {
|
|
360
|
+
const max = this.scrollerElement.scrollHeight - this.scrollerElement.clientHeight;
|
|
361
|
+
const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
|
|
362
|
+
if(base >= max - 1) return false;
|
|
363
|
+
let target = Math.min(base + (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), max);
|
|
364
|
+
if(animated) target = Math.min(target,
|
|
365
|
+
this.scrollerElement.scrollTop + this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
|
|
366
|
+
this.scrollToTarget(target, animated);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
prev(animated = true, stepPx?: number): boolean {
|
|
371
|
+
const base = this.scrollTarget ?? this.scrollerElement.scrollTop;
|
|
372
|
+
if(base <= 1) return false;
|
|
373
|
+
let target = Math.max(base - (stepPx ?? this.viewportHeightCached * SCROLL_STEP_FACTOR), 0);
|
|
374
|
+
if(animated) target = Math.max(target,
|
|
375
|
+
this.scrollerElement.scrollTop - this.viewportHeightCached * SCROLL_TARGET_MAX_LEAD);
|
|
376
|
+
this.scrollToTarget(target, animated);
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private scrollToTarget(target: number, animated: boolean) {
|
|
381
|
+
this.beginProgrammaticScroll();
|
|
382
|
+
this.scrollTarget = target;
|
|
383
|
+
if(!animated) {
|
|
384
|
+
this.stopAnimation();
|
|
385
|
+
this.scrollerElement.scrollTo({ top: target, behavior: "auto" });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// A running animation just picks up the new target on its next frame
|
|
389
|
+
if(!this.animating) {
|
|
390
|
+
this.animating = true;
|
|
391
|
+
this.animLastTime = performance.now();
|
|
392
|
+
this.animRAF = requestAnimationFrame(this.banimStep);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* One frame of the owned scroll animation: exponential approach toward
|
|
398
|
+
* the (re)targetable scrollTarget, capped at a cruise speed. Under a held
|
|
399
|
+
* key the cap dominates and the scroll advances at constant speed; once
|
|
400
|
+
* input stops the exponential term eases it out into the target.
|
|
401
|
+
*/
|
|
402
|
+
private animStep(now: number) {
|
|
403
|
+
if(this.destroyed) return;
|
|
404
|
+
const target = this.scrollTarget;
|
|
405
|
+
if(target === null) { this.stopAnimation(); return; } // User took over
|
|
406
|
+
const dt = Math.min(Math.max(now - this.animLastTime, 0) / 1000, 0.1);
|
|
407
|
+
this.animLastTime = now;
|
|
408
|
+
|
|
409
|
+
const el = this.scrollerElement;
|
|
410
|
+
const delta = target - el.scrollTop;
|
|
411
|
+
if(Math.abs(delta) <= 1) {
|
|
412
|
+
el.scrollTop = target;
|
|
413
|
+
this.stopAnimation();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const maxStep = this.viewportHeightCached * SCROLL_CRUISE_SPEED * dt;
|
|
417
|
+
let step = delta * Math.min(1, SCROLL_APPROACH_RATE * dt);
|
|
418
|
+
if(Math.abs(step) > maxStep) step = Math.sign(delta) * maxStep;
|
|
419
|
+
el.scrollTop += step;
|
|
420
|
+
this.animRAF = requestAnimationFrame(this.banimStep);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private stopAnimation() {
|
|
424
|
+
this.animating = false;
|
|
425
|
+
cancelAnimationFrame(this.animRAF);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Go to the top of a page, optionally at a progression within it.
|
|
430
|
+
* @returns whether the position changed
|
|
431
|
+
*/
|
|
432
|
+
goToItem(itemIndex: number, animated = false, progression = 0): boolean {
|
|
433
|
+
// A pending layout-restore would stomp this explicit navigation
|
|
434
|
+
cancelAnimationFrame(this.restoreRAF);
|
|
435
|
+
const index = Math.max(0, Math.min(itemIndex, this.spreader.pages.length - 1));
|
|
436
|
+
const top = this.pageTop(index) + progression * this.pageHeight(index);
|
|
437
|
+
if(Math.abs(this.scrollerElement.scrollTop - top) < 1) return false;
|
|
438
|
+
this.scrollToTarget(top, animated);
|
|
439
|
+
if(!animated) this.updateFraction();
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async update(): Promise<void> {
|
|
444
|
+
if(this.destroyed) return;
|
|
445
|
+
const index = this.currentIndex;
|
|
446
|
+
this.lastWindowIndex = index;
|
|
447
|
+
this.updateWindow(index);
|
|
448
|
+
const pm = this.pool.get(index);
|
|
449
|
+
if(pm) await pm.load();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
get atStart(): boolean {
|
|
453
|
+
return this.scrollerElement.scrollTop <= 1;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
get atEnd(): boolean {
|
|
457
|
+
return this.scrollerElement.scrollTop >= this.stripHeightCached - this.viewportHeightCached - 1;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
get viewport(): VisualNavigatorViewport {
|
|
461
|
+
const viewport: VisualNavigatorViewport = {
|
|
462
|
+
readingOrder: [],
|
|
463
|
+
progressions: new Map(),
|
|
464
|
+
positions: null
|
|
465
|
+
};
|
|
466
|
+
if(!this.pageTops.length) return viewport;
|
|
467
|
+
const top = this.scrollerElement.scrollTop;
|
|
468
|
+
const bottom = top + this.viewportHeightCached;
|
|
469
|
+
const positions: number[] = [];
|
|
470
|
+
for (let i = this.indexAtOffset(top); i < this.spreader.pages.length; i++) {
|
|
471
|
+
const pTop = this.pageTop(i);
|
|
472
|
+
if(pTop >= bottom) break;
|
|
473
|
+
const h = this.pageHeight(i);
|
|
474
|
+
if(pTop + h <= top) continue;
|
|
475
|
+
const page = this.spreader.pages[i];
|
|
476
|
+
const range: ProgressionRange = {
|
|
477
|
+
start: Math.max(0, (top - pTop) / h),
|
|
478
|
+
end: Math.min(1, (bottom - pTop) / h)
|
|
479
|
+
};
|
|
480
|
+
viewport.readingOrder.push(page.link.href);
|
|
481
|
+
viewport.progressions.set(page.link.href, range);
|
|
482
|
+
positions.push(page.number);
|
|
483
|
+
}
|
|
484
|
+
viewport.positions = positions.length ? positions : null;
|
|
485
|
+
return viewport;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
resizeHandler() {
|
|
489
|
+
const fraction = this.fraction;
|
|
490
|
+
const w = this.displayWidth;
|
|
491
|
+
this.spreader.pages.forEach((page) => {
|
|
492
|
+
this.pool.get(page.index)?.applyStripWidth(w);
|
|
493
|
+
});
|
|
494
|
+
this.refreshLayoutCache();
|
|
495
|
+
this.restoreFraction(fraction);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async destroy() {
|
|
499
|
+
this.destroyed = true;
|
|
500
|
+
cancelAnimationFrame(this.scrollRAF);
|
|
501
|
+
cancelAnimationFrame(this.restoreRAF);
|
|
502
|
+
cancelAnimationFrame(this.animRAF);
|
|
503
|
+
window.clearTimeout(this.positionTimer);
|
|
504
|
+
window.clearTimeout(this.programmaticTimer);
|
|
505
|
+
this.scrollerElement.removeEventListener("scroll", this.bscrollHandler);
|
|
506
|
+
this.scrollerElement.removeEventListener("scrollend", this.bscrollendHandler);
|
|
507
|
+
this.scrollerElement.removeEventListener("wheel", this.buserTakeoverHandler);
|
|
508
|
+
this.scrollerElement.removeEventListener("mousedown", this.bmousedownHandler);
|
|
509
|
+
this.scrollerElement.removeEventListener("mouseup", this.bclickHandler);
|
|
510
|
+
this.scrollerElement.removeEventListener("touchend", this.btouchendHandler);
|
|
511
|
+
this.scrollerElement.removeEventListener("touchstart", this.btouchstartHandler);
|
|
512
|
+
// The page wrappers belong to the shared pool; detach without destroying
|
|
513
|
+
this.scrollerElement.replaceChildren();
|
|
514
|
+
this.scrollerElement.remove();
|
|
515
|
+
}
|
|
516
|
+
}
|