@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,508 @@
|
|
|
1
|
+
import { Page, Publication, ReadingProgression } from "@readium/shared";
|
|
2
|
+
import { VisualNavigatorViewport } from "../Navigator.ts";
|
|
3
|
+
import { isTypedOMSupported } from "../epub/fxl/FXLPeripherals.ts";
|
|
4
|
+
import { DivinaPageManager } from "./DivinaPageManager.ts";
|
|
5
|
+
import { DivinaManagerEventKey, DivinaPagedManager, DivinaPeripherals } from "./DivinaPeripherals.ts";
|
|
6
|
+
import { DivinaPage, DivinaSpreader } from "./DivinaSpreader.ts";
|
|
7
|
+
|
|
8
|
+
const UPPER_BOUNDARY = 8; // Unload pages further than this many items away
|
|
9
|
+
const LOWER_BOUNDARY = 4; // Load pages within this many items
|
|
10
|
+
const OFFSCREEN_LOAD_DELAY = 300; // Delay offscreen loads to avoid janking the slide animation
|
|
11
|
+
const SLIDE_FAST = 150;
|
|
12
|
+
const SLIDE_SLOW = 500;
|
|
13
|
+
|
|
14
|
+
export type DivinaPresenterListener = (key: DivinaManagerEventKey, data: unknown) => void;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Horizontal paged presenter for Divina publications: a sliding spine of
|
|
18
|
+
* page slots with synthetic spreads, ported from the FXLFramePoolManager
|
|
19
|
+
* slider core but rendering <img> pages instead of iframes.
|
|
20
|
+
*
|
|
21
|
+
* Slot model: in double-page mode every spread occupies exactly two slots
|
|
22
|
+
* (single pages get a double-width slot), so slide indices are always even
|
|
23
|
+
* and always aligned with spread boundaries.
|
|
24
|
+
*/
|
|
25
|
+
export class DivinaPagedPresenter implements DivinaPagedManager {
|
|
26
|
+
private readonly container: HTMLElement;
|
|
27
|
+
private readonly pub: Publication;
|
|
28
|
+
private readonly spreader: DivinaSpreader;
|
|
29
|
+
private readonly pool: Map<number, DivinaPageManager>;
|
|
30
|
+
|
|
31
|
+
public readonly bookElement: HTMLDivElement;
|
|
32
|
+
public readonly spineElement: HTMLDivElement;
|
|
33
|
+
public readonly peripherals: DivinaPeripherals;
|
|
34
|
+
|
|
35
|
+
public width = 0;
|
|
36
|
+
public height = 0;
|
|
37
|
+
public currentSlide = 0;
|
|
38
|
+
private transform: number | undefined;
|
|
39
|
+
private spread: boolean;
|
|
40
|
+
private orientationInternal = -1; // Portrait = 1, Landscape = 0, Unknown = -1
|
|
41
|
+
private containerHeightCached: number;
|
|
42
|
+
private destroyed = false;
|
|
43
|
+
private loadTimers: Map<number, number> = new Map();
|
|
44
|
+
|
|
45
|
+
public listener: DivinaPresenterListener = () => {};
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
container: HTMLElement,
|
|
49
|
+
pub: Publication,
|
|
50
|
+
spreader: DivinaSpreader,
|
|
51
|
+
pool: Map<number, DivinaPageManager>,
|
|
52
|
+
spreads: boolean
|
|
53
|
+
) {
|
|
54
|
+
this.container = container;
|
|
55
|
+
this.pub = pub;
|
|
56
|
+
this.spreader = spreader;
|
|
57
|
+
this.pool = pool;
|
|
58
|
+
this.spread = spreads;
|
|
59
|
+
this.containerHeightCached = container.clientHeight;
|
|
60
|
+
|
|
61
|
+
this.bookElement = document.createElement("div");
|
|
62
|
+
this.bookElement.ariaLabel = "Book";
|
|
63
|
+
this.bookElement.tabIndex = -1;
|
|
64
|
+
this.updateBookStyle(true);
|
|
65
|
+
|
|
66
|
+
this.spineElement = document.createElement("div");
|
|
67
|
+
this.spineElement.ariaLabel = "Spine";
|
|
68
|
+
|
|
69
|
+
this.bookElement.appendChild(this.spineElement);
|
|
70
|
+
this.container.appendChild(this.bookElement);
|
|
71
|
+
|
|
72
|
+
const fragment = document.createDocumentFragment();
|
|
73
|
+
this.spreader.pages.forEach((page) => {
|
|
74
|
+
const pm = this.pool.get(page.index)!;
|
|
75
|
+
pm.setMode("paged");
|
|
76
|
+
pm.wrapper.style.cssFloat = pm.wrapper.style.float = this.rtl ? "right" : "left";
|
|
77
|
+
pm.wrapper.style.contain = "strict";
|
|
78
|
+
fragment.appendChild(pm.wrapper);
|
|
79
|
+
});
|
|
80
|
+
this.spineElement.appendChild(fragment);
|
|
81
|
+
|
|
82
|
+
this.updateSpineStyle(false);
|
|
83
|
+
this.updateSlotSizes();
|
|
84
|
+
|
|
85
|
+
this.peripherals = new DivinaPeripherals(this);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public get rtl() {
|
|
89
|
+
return this.pub.metadata.effectiveReadingProgression === ReadingProgression.rtl;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private get single() {
|
|
93
|
+
return !this.spread || this.portrait;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public get perPage() {
|
|
97
|
+
return (this.spread && !this.portrait) ? 2 : 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get threshold(): number {
|
|
101
|
+
return 50;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get portrait(): boolean {
|
|
105
|
+
if(this.orientationInternal === -1) {
|
|
106
|
+
this.orientationInternal = this.containerHeightCached > this.container.clientWidth ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
return this.orientationInternal === 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get slength() {
|
|
112
|
+
return this.spreader.pages.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Total slot count in the current mode */
|
|
116
|
+
get length() {
|
|
117
|
+
return this.single ? this.slength : this.spreader.slotCount;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The x position of a slot boundary within the spine, snapped to device
|
|
122
|
+
* pixels. Fractional boundaries leave hairline gaps at the spread gutter
|
|
123
|
+
* (the background shows through between the two page images), varying
|
|
124
|
+
* with the window size.
|
|
125
|
+
*/
|
|
126
|
+
private slotEdge(slot: number): number {
|
|
127
|
+
const dpr = window.devicePixelRatio || 1;
|
|
128
|
+
return Math.round(slot * (this.width / this.perPage) * dpr) / dpr;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private get offset() {
|
|
132
|
+
return (this.rtl ? 1 : -1) * this.slotEdge(this.currentSlide);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get doNotDisturb() {
|
|
136
|
+
return this.peripherals.pan.touchID > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The reading-order index of the first page of the current spread */
|
|
140
|
+
get currentIndex(): number {
|
|
141
|
+
return this.single ?
|
|
142
|
+
Math.max(0, Math.min(this.currentSlide, this.slength - 1)) :
|
|
143
|
+
this.spreader.itemOfSlot(this.currentSlide);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The pages currently (meant to be) visible */
|
|
147
|
+
get currentSpread(): DivinaPage[] {
|
|
148
|
+
if(this.perPage < 2) {
|
|
149
|
+
return [this.spreader.pages[this.currentIndex]];
|
|
150
|
+
}
|
|
151
|
+
return this.spreader.spreadOfItem(this.currentIndex);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private updateDimensions() {
|
|
155
|
+
this.width = this.bookElement.clientWidth;
|
|
156
|
+
this.height = this.bookElement.clientHeight;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reused typed transform for the per-event zoom/pan write (see the
|
|
160
|
+
// matching note in DivinaPeripherals: mutation beats reallocation)
|
|
161
|
+
private bookScale: CSSScale | null = null;
|
|
162
|
+
private bookTranslate: CSSTranslate | null = null;
|
|
163
|
+
private bookTransform: CSSTransformValue | null = null;
|
|
164
|
+
|
|
165
|
+
public updateBookStyle(initial = false) {
|
|
166
|
+
if(initial) {
|
|
167
|
+
const bookStyle = {
|
|
168
|
+
overflow: "hidden",
|
|
169
|
+
direction: this.pub.metadata.effectiveReadingProgression,
|
|
170
|
+
cursor: "",
|
|
171
|
+
height: "100%",
|
|
172
|
+
width: "100%",
|
|
173
|
+
position: "relative",
|
|
174
|
+
outline: "none",
|
|
175
|
+
transition: this.peripherals?.dragState ? "none" : "transform .15s ease-in-out",
|
|
176
|
+
touchAction: "none",
|
|
177
|
+
} as unknown as CSSStyleDeclaration;
|
|
178
|
+
Object.assign(this.bookElement.style, bookStyle);
|
|
179
|
+
}
|
|
180
|
+
const scale = this.peripherals?.scale || 1;
|
|
181
|
+
const translateX = this.peripherals?.pan.translateX || 0;
|
|
182
|
+
const translateY = this.peripherals?.pan.translateY || 0;
|
|
183
|
+
if(isTypedOMSupported()) {
|
|
184
|
+
if(!this.bookTransform) {
|
|
185
|
+
this.bookScale = new CSSScale(1, 1);
|
|
186
|
+
this.bookTranslate = new CSSTranslate(CSS.px(0), CSS.px(0), CSS.px(0));
|
|
187
|
+
this.bookTransform = new CSSTransformValue([this.bookScale, this.bookTranslate]);
|
|
188
|
+
}
|
|
189
|
+
(this.bookScale!.x as CSSUnitValue).value = scale;
|
|
190
|
+
(this.bookScale!.y as CSSUnitValue).value = scale;
|
|
191
|
+
(this.bookTranslate!.x as CSSUnitValue).value = translateX;
|
|
192
|
+
(this.bookTranslate!.y as CSSUnitValue).value = translateY;
|
|
193
|
+
this.bookElement.attributeStyleMap.set("transform", this.bookTransform);
|
|
194
|
+
} else {
|
|
195
|
+
this.bookElement.style.transform = `scale(${scale}) translate3d(${translateX}px, ${translateY}px, 0px)`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
public updateSpineStyle(animate: boolean, fast = true) {
|
|
200
|
+
this.updateDimensions();
|
|
201
|
+
if(isTypedOMSupported()) {
|
|
202
|
+
this.spineElement.attributeStyleMap.set("transition", new CSSUnparsedValue([animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out"]));
|
|
203
|
+
this.spineElement.attributeStyleMap.set("width", CSS.px(this.slotEdge(this.length)));
|
|
204
|
+
this.spineElement.attributeStyleMap.set("height", CSS.percent(100));
|
|
205
|
+
this.transform !== undefined ? this.spineElement.attributeStyleMap.set("transform", new CSSTransformValue([
|
|
206
|
+
new CSSTranslate(CSS.px(this.transform || 0), CSS.px(0), CSS.px(0))
|
|
207
|
+
])) : this.spineElement.attributeStyleMap.delete("transform");
|
|
208
|
+
this.spineElement.attributeStyleMap.set("contain", new CSSUnparsedValue(["content"]));
|
|
209
|
+
} else {
|
|
210
|
+
const spineStyle = {
|
|
211
|
+
transition: animate ? `all ${fast ? SLIDE_FAST : SLIDE_SLOW}ms ease-out` : "all 0ms ease-out",
|
|
212
|
+
width: `${this.slotEdge(this.length)}px`,
|
|
213
|
+
height: "100%",
|
|
214
|
+
transform: this.transform !== undefined ? `translate3d(${this.transform}px, 0px, 0px)` : "",
|
|
215
|
+
contain: "content"
|
|
216
|
+
} as unknown as CSSStyleDeclaration;
|
|
217
|
+
Object.assign(this.spineElement.style, spineStyle);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Recompute every slot's width and height. Widths are derived from
|
|
223
|
+
* device-pixel-snapped slot boundaries so adjacent pages of a spread
|
|
224
|
+
* meet exactly at the gutter without subpixel seams.
|
|
225
|
+
*/
|
|
226
|
+
private updateSlotSizes() {
|
|
227
|
+
this.updateDimensions();
|
|
228
|
+
const typedOM = isTypedOMSupported();
|
|
229
|
+
this.spreader.pages.forEach((page) => {
|
|
230
|
+
const pm = this.pool.get(page.index)!;
|
|
231
|
+
const span = !this.single && this.spreader.isDouble(page.index) ? 2 : 1;
|
|
232
|
+
const start = this.single ? page.index : this.spreader.slotOfItem(page.index);
|
|
233
|
+
const width = this.slotEdge(start + span) - this.slotEdge(start);
|
|
234
|
+
if(typedOM) {
|
|
235
|
+
pm.wrapper.attributeStyleMap.set("width", CSS.px(width));
|
|
236
|
+
pm.wrapper.attributeStyleMap.set("height", CSS.px(this.height));
|
|
237
|
+
} else {
|
|
238
|
+
pm.wrapper.style.width = `${width}px`;
|
|
239
|
+
pm.wrapper.style.height = `${this.height}px`;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
reAlign(index: number = this.currentSlide) {
|
|
245
|
+
if (index % 2 && !this.single) // Prevent getting out of track
|
|
246
|
+
index++;
|
|
247
|
+
return index;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Moves the spine to the position of the currently active slide.
|
|
252
|
+
* Always commits: the spine transform may have been written directly by
|
|
253
|
+
* the peripherals during a drag (snap-back relies on this), and reading
|
|
254
|
+
* style.transform back for comparison is unreliable under CSS Typed OM.
|
|
255
|
+
*/
|
|
256
|
+
slideToCurrent(enableTransition?: boolean, fast = true) {
|
|
257
|
+
this.updateDimensions();
|
|
258
|
+
const commit = (animate: boolean) => {
|
|
259
|
+
this.transform = this.offset;
|
|
260
|
+
this.updateSpineStyle(animate, fast);
|
|
261
|
+
this.deselect();
|
|
262
|
+
};
|
|
263
|
+
if (enableTransition) {
|
|
264
|
+
// Double rAF guarantees the transition fires:
|
|
265
|
+
// https://youtu.be/cCOL7MC4Pl0
|
|
266
|
+
requestAnimationFrame(() => {
|
|
267
|
+
requestAnimationFrame(() => commit(true));
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
commit(false);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
bounce(rtl = false) {
|
|
275
|
+
requestAnimationFrame(() => {
|
|
276
|
+
this.transform = this.offset + (50 * (rtl ? 1 : -1));
|
|
277
|
+
this.updateSpineStyle(true, true);
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
if(this.destroyed) return;
|
|
280
|
+
this.transform = this.offset;
|
|
281
|
+
this.updateSpineStyle(true, true);
|
|
282
|
+
}, 100);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private onChange() {
|
|
287
|
+
this.peripherals.scale = 1;
|
|
288
|
+
this.peripherals.pan.translateX = 0;
|
|
289
|
+
this.peripherals.pan.translateY = 0;
|
|
290
|
+
this.updateBookStyle();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Go to the next spread/page.
|
|
295
|
+
* @returns whether moving forward was possible
|
|
296
|
+
*/
|
|
297
|
+
next(): boolean {
|
|
298
|
+
if (this.length <= this.perPage) return false;
|
|
299
|
+
|
|
300
|
+
const beforeChange = this.currentSlide;
|
|
301
|
+
this.currentSlide = Math.min(this.currentSlide + this.perPage, this.length - this.perPage);
|
|
302
|
+
this.currentSlide = this.reAlign();
|
|
303
|
+
|
|
304
|
+
if (beforeChange !== this.currentSlide) {
|
|
305
|
+
this.slideToCurrent(true);
|
|
306
|
+
this.onChange();
|
|
307
|
+
return true;
|
|
308
|
+
} else {
|
|
309
|
+
this.bounce(this.rtl);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Go to the previous spread/page.
|
|
316
|
+
* @returns whether moving backward was possible
|
|
317
|
+
*/
|
|
318
|
+
prev(): boolean {
|
|
319
|
+
if (this.length <= this.perPage) return false;
|
|
320
|
+
|
|
321
|
+
const beforeChange = this.currentSlide;
|
|
322
|
+
this.currentSlide = this.reAlign(Math.max(this.currentSlide - this.perPage, 0));
|
|
323
|
+
|
|
324
|
+
if (beforeChange !== this.currentSlide) {
|
|
325
|
+
this.slideToCurrent(true);
|
|
326
|
+
this.onChange();
|
|
327
|
+
return true;
|
|
328
|
+
} else {
|
|
329
|
+
this.bounce(!this.rtl);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Go to the slide showing the given reading order item.
|
|
336
|
+
* @returns whether the slide changed
|
|
337
|
+
*/
|
|
338
|
+
/**
|
|
339
|
+
* The slide showing the given item in the current mode: the item index
|
|
340
|
+
* itself in single mode, or the start slot of the item's spread in
|
|
341
|
+
* double-page mode (the item may occupy the spread's second slot).
|
|
342
|
+
*/
|
|
343
|
+
private slideForItem(itemIndex: number): number {
|
|
344
|
+
const clamped = Math.max(0, Math.min(itemIndex, this.slength - 1));
|
|
345
|
+
return this.single ?
|
|
346
|
+
clamped :
|
|
347
|
+
this.spreader.slotOfItem(this.spreader.spreadOfItem(clamped)[0].index);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
goToItem(itemIndex: number, animated = true): boolean {
|
|
351
|
+
const target = this.slideForItem(itemIndex);
|
|
352
|
+
if(target === this.currentSlide) return false;
|
|
353
|
+
this.currentSlide = target;
|
|
354
|
+
this.slideToCurrent(animated);
|
|
355
|
+
this.onChange();
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Loads pages within the load window around the current position,
|
|
361
|
+
* unloads distant ones, and positions the current spread.
|
|
362
|
+
*/
|
|
363
|
+
async update(): Promise<void> {
|
|
364
|
+
if(this.destroyed) return;
|
|
365
|
+
const i = this.currentIndex;
|
|
366
|
+
const spread = this.currentSpread;
|
|
367
|
+
const spreadIndexes = new Set(spread.map(p => p.index));
|
|
368
|
+
|
|
369
|
+
this.spreader.pages.forEach((page) => {
|
|
370
|
+
const pm = this.pool.get(page.index)!;
|
|
371
|
+
const distance = Math.abs(page.index - i);
|
|
372
|
+
if(distance > UPPER_BOUNDARY) {
|
|
373
|
+
const timer = this.loadTimers.get(page.index);
|
|
374
|
+
if(timer) {
|
|
375
|
+
clearTimeout(timer);
|
|
376
|
+
this.loadTimers.delete(page.index);
|
|
377
|
+
}
|
|
378
|
+
pm.unload();
|
|
379
|
+
} else if(distance <= LOWER_BOUNDARY && !spreadIndexes.has(page.index)) {
|
|
380
|
+
// Load offscreen neighbors after a delay so decoding doesn't
|
|
381
|
+
// jank the slide animation
|
|
382
|
+
if(!pm.loaded && !this.loadTimers.has(page.index)) {
|
|
383
|
+
const timer = window.setTimeout(() => {
|
|
384
|
+
this.loadTimers.delete(page.index);
|
|
385
|
+
if(!this.destroyed) pm.load();
|
|
386
|
+
}, OFFSCREEN_LOAD_DELAY);
|
|
387
|
+
this.loadTimers.set(page.index, timer);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Current spread: load immediately and position against the gutter
|
|
393
|
+
await Promise.all(spread.map((page) => {
|
|
394
|
+
const pm = this.pool.get(page.index)!;
|
|
395
|
+
const timer = this.loadTimers.get(page.index);
|
|
396
|
+
if(timer) {
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
this.loadTimers.delete(page.index);
|
|
399
|
+
}
|
|
400
|
+
this.placePage(spread, page);
|
|
401
|
+
return pm.load();
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Fit a page of the current spread within its slot. A lone portrait page
|
|
407
|
+
* with a left/right hint (shifted cover, orphaned spread half) is fitted
|
|
408
|
+
* into its half of the full-spread slot; everything else is fitted to the
|
|
409
|
+
* whole slot and anchored against the gutter (or centered).
|
|
410
|
+
*/
|
|
411
|
+
private placePage(spread: DivinaPage[], page: DivinaPage) {
|
|
412
|
+
const pm = this.pool.get(page.index);
|
|
413
|
+
if(!pm) return;
|
|
414
|
+
if(this.perPage > 1 && spread.length === 1 && !page.isLandscape && page.page !== Page.center) {
|
|
415
|
+
pm.fit(page.page, true);
|
|
416
|
+
} else {
|
|
417
|
+
pm.fit(this.spreader.spreadPosition(spread, page, this.perPage));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Union of the current spread's image bounds, used for zoom/pan clamping */
|
|
422
|
+
get currentBounds(): DOMRect {
|
|
423
|
+
const ret = {
|
|
424
|
+
x: 0, y: 0, width: 0, height: 0,
|
|
425
|
+
top: 0, right: 0, bottom: 0, left: 0,
|
|
426
|
+
toJSON() { return this; },
|
|
427
|
+
};
|
|
428
|
+
this.currentSpread.forEach(page => {
|
|
429
|
+
const pm = this.pool.get(page.index);
|
|
430
|
+
if(!pm) return;
|
|
431
|
+
const b = pm.img.getBoundingClientRect();
|
|
432
|
+
ret.x = Math.min(ret.x, b.x);
|
|
433
|
+
ret.y = Math.min(ret.y, b.y);
|
|
434
|
+
ret.width += b.width;
|
|
435
|
+
ret.height = Math.max(ret.height, b.height);
|
|
436
|
+
ret.top = Math.min(ret.top, b.top);
|
|
437
|
+
ret.right = Math.min(ret.right, b.right);
|
|
438
|
+
ret.bottom = Math.min(ret.bottom, b.bottom);
|
|
439
|
+
ret.left = Math.min(ret.left, b.left);
|
|
440
|
+
});
|
|
441
|
+
return ret as DOMRect;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
get viewport(): VisualNavigatorViewport {
|
|
445
|
+
const viewport: VisualNavigatorViewport = {
|
|
446
|
+
readingOrder: [],
|
|
447
|
+
progressions: new Map(),
|
|
448
|
+
positions: null
|
|
449
|
+
};
|
|
450
|
+
const positions: number[] = [];
|
|
451
|
+
this.currentSpread.forEach(page => {
|
|
452
|
+
viewport.readingOrder.push(page.link.href);
|
|
453
|
+
viewport.progressions.set(page.link.href, { start: 0, end: 1 }); // Paged always uses [0,1]
|
|
454
|
+
positions.push(page.number);
|
|
455
|
+
});
|
|
456
|
+
viewport.positions = positions;
|
|
457
|
+
return viewport;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* When the container resizes, resize slider components as well
|
|
462
|
+
*/
|
|
463
|
+
resizeHandler(slide = true, fast = true) {
|
|
464
|
+
// Capture the current item BEFORE invalidating the orientation cache:
|
|
465
|
+
// currentSlide's domain (item vs slot index) depends on the mode, which
|
|
466
|
+
// may flip with the orientation.
|
|
467
|
+
const item = this.currentIndex;
|
|
468
|
+
this.containerHeightCached = this.container.clientHeight;
|
|
469
|
+
this.orientationInternal = -1;
|
|
470
|
+
this.currentSlide = this.slideForItem(item);
|
|
471
|
+
|
|
472
|
+
this.updateSpineStyle(false);
|
|
473
|
+
this.updateSlotSizes();
|
|
474
|
+
if(slide) {
|
|
475
|
+
this.slideToCurrent(!fast, fast);
|
|
476
|
+
}
|
|
477
|
+
// Reposition the current spread against the gutter (perPage may have changed)
|
|
478
|
+
const spread = this.currentSpread;
|
|
479
|
+
spread.forEach((page) => this.placePage(spread, page));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Toggle double-page spreads (from preferences) */
|
|
483
|
+
setSpreads(spreads: boolean) {
|
|
484
|
+
if(this.spread === spreads) return;
|
|
485
|
+
const item = this.currentIndex;
|
|
486
|
+
this.spread = spreads;
|
|
487
|
+
this.currentSlide = this.slideForItem(item);
|
|
488
|
+
requestAnimationFrame(() => {
|
|
489
|
+
if(this.destroyed) return;
|
|
490
|
+
this.resizeHandler(true);
|
|
491
|
+
this.update();
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
deselect() {
|
|
496
|
+
this.container.ownerDocument.defaultView?.getSelection()?.removeAllRanges();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async destroy() {
|
|
500
|
+
this.destroyed = true;
|
|
501
|
+
this.loadTimers.forEach(t => clearTimeout(t));
|
|
502
|
+
this.loadTimers.clear();
|
|
503
|
+
this.peripherals.destroy();
|
|
504
|
+
// The page wrappers belong to the shared pool; detach them without destroying
|
|
505
|
+
this.spineElement.replaceChildren();
|
|
506
|
+
this.bookElement.remove();
|
|
507
|
+
}
|
|
508
|
+
}
|