@readium/navigator 2.10.3 → 2.11.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/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/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/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/FrameManager.ts +7 -9
- package/src/epub/frame/FramePoolManager.ts +106 -48
- package/src/epub/fxl/FXLFrameManager.ts +69 -26
- package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
- package/src/epub/fxl/FXLPeripherals.ts +22 -1
- 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 +7 -9
- 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/FramePoolManager.d.ts +5 -1
- package/types/src/epub/fxl/FXLFrameManager.d.ts +2 -0
- package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
- package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
- package/types/src/webpub/WebPubNavigator.d.ts +2 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { Layout, Link, Locator, Publication, ReadingProgression, Timeline, TimelineItem } from "@readium/shared";
|
|
2
|
+
import {
|
|
3
|
+
ContextMenuEvent,
|
|
4
|
+
FrameClickEvent,
|
|
5
|
+
SuspiciousActivityEvent
|
|
6
|
+
} from "@readium/navigator-html-injectables";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
IContentProtectionConfig,
|
|
10
|
+
IKeyboardPeripheralsConfig,
|
|
11
|
+
KeyboardPeripheralEventData,
|
|
12
|
+
VisualNavigator,
|
|
13
|
+
VisualNavigatorViewport
|
|
14
|
+
} from "../Navigator.ts";
|
|
15
|
+
import { Configurable, ConfigurableSettings } from "../preferences/Configurable.ts";
|
|
16
|
+
import { getContentWidth } from "../helpers/dimensions.ts";
|
|
17
|
+
import { NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT } from "../protection/NavigatorProtector.ts";
|
|
18
|
+
import { KeyboardPeripherals, NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT } from "../peripherals/KeyboardPeripherals.ts";
|
|
19
|
+
import { KeyboardConditionBridge } from "../peripherals/KeyboardConditionBridge.ts";
|
|
20
|
+
|
|
21
|
+
import { DivinaSpreader } from "./DivinaSpreader.ts";
|
|
22
|
+
import { DivinaPageManager } from "./DivinaPageManager.ts";
|
|
23
|
+
import { DivinaPagedPresenter } from "./DivinaPagedPresenter.ts";
|
|
24
|
+
import { DivinaScrolledPresenter } from "./DivinaScrolledPresenter.ts";
|
|
25
|
+
import { DivinaPointerEvent } from "./DivinaPeripherals.ts";
|
|
26
|
+
import { isTypedOMSupported } from "../epub/fxl/FXLPeripherals.ts";
|
|
27
|
+
import { DivinaNavigatorProtector } from "./protection/DivinaNavigatorProtector.ts";
|
|
28
|
+
import { DivinaDefaults, IDivinaDefaults } from "./preferences/DivinaDefaults.ts";
|
|
29
|
+
import { DivinaPreferences, IDivinaPreferences } from "./preferences/DivinaPreferences.ts";
|
|
30
|
+
import { DivinaSettings } from "./preferences/DivinaSettings.ts";
|
|
31
|
+
import { DivinaPreferencesEditor } from "./preferences/DivinaPreferencesEditor.ts";
|
|
32
|
+
|
|
33
|
+
export interface DivinaNavigatorConfiguration {
|
|
34
|
+
preferences: IDivinaPreferences;
|
|
35
|
+
defaults: IDivinaDefaults;
|
|
36
|
+
contentProtection?: IContentProtectionConfig;
|
|
37
|
+
keyboardPeripherals?: IKeyboardPeripheralsConfig;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DivinaNavigatorListeners {
|
|
41
|
+
positionChanged: (locator: Locator) => void;
|
|
42
|
+
timelineItemChanged: (item: TimelineItem | undefined) => void;
|
|
43
|
+
tap: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
|
|
44
|
+
click: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
|
|
45
|
+
zoom: (scale: number) => void;
|
|
46
|
+
miscPointer: (amount: number) => void;
|
|
47
|
+
scroll: (delta: number) => void;
|
|
48
|
+
customEvent: (key: string, data: unknown) => void;
|
|
49
|
+
handleLocator: (locator: Locator) => boolean; // Return true to prevent handling here
|
|
50
|
+
contentProtection: (type: string, data: SuspiciousActivityEvent) => void;
|
|
51
|
+
contextMenu: (data: ContextMenuEvent) => void;
|
|
52
|
+
peripheral: (data: KeyboardPeripheralEventData) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultListeners = (listeners: Partial<DivinaNavigatorListeners>): DivinaNavigatorListeners => ({
|
|
56
|
+
positionChanged: listeners.positionChanged || (() => {}),
|
|
57
|
+
timelineItemChanged: listeners.timelineItemChanged || (() => {}),
|
|
58
|
+
tap: listeners.tap || (() => false),
|
|
59
|
+
click: listeners.click || (() => false),
|
|
60
|
+
zoom: listeners.zoom || (() => {}),
|
|
61
|
+
miscPointer: listeners.miscPointer || (() => {}),
|
|
62
|
+
scroll: listeners.scroll || (() => {}),
|
|
63
|
+
customEvent: listeners.customEvent || (() => {}),
|
|
64
|
+
handleLocator: listeners.handleLocator || (() => false),
|
|
65
|
+
contentProtection: listeners.contentProtection || (() => {}),
|
|
66
|
+
contextMenu: listeners.contextMenu || (() => {}),
|
|
67
|
+
peripheral: listeners.peripheral || (() => {}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Navigator for Divina (visual narrative) publications: bitmap pages rendered
|
|
72
|
+
* as <img> elements with blob sources, in either a horizontal paged view with
|
|
73
|
+
* synthetic spreads (comics/manga) or a continuous vertical scroll (webtoons).
|
|
74
|
+
*/
|
|
75
|
+
export class DivinaNavigator extends VisualNavigator implements Configurable<ConfigurableSettings, DivinaPreferences> {
|
|
76
|
+
private readonly pub: Publication;
|
|
77
|
+
private readonly container: HTMLElement;
|
|
78
|
+
private readonly listeners: DivinaNavigatorListeners;
|
|
79
|
+
private positions!: Locator[];
|
|
80
|
+
private currentLocation!: Locator;
|
|
81
|
+
private _destroyed = false;
|
|
82
|
+
private _isNavigating = false;
|
|
83
|
+
private _timelineAugmented = false;
|
|
84
|
+
private _wrappedTimeline: Timeline | undefined;
|
|
85
|
+
private _currentTimelineItem: TimelineItem | undefined;
|
|
86
|
+
|
|
87
|
+
private _preferences: DivinaPreferences;
|
|
88
|
+
private _defaults: DivinaDefaults;
|
|
89
|
+
private _settings: DivinaSettings;
|
|
90
|
+
private _preferencesEditor: DivinaPreferencesEditor | null = null;
|
|
91
|
+
|
|
92
|
+
private readonly spreader: DivinaSpreader;
|
|
93
|
+
private readonly pool: Map<number, DivinaPageManager> = new Map();
|
|
94
|
+
private pagedPresenter: DivinaPagedPresenter | null = null;
|
|
95
|
+
private scrolledPresenter: DivinaScrolledPresenter | null = null;
|
|
96
|
+
|
|
97
|
+
private readonly _contentProtection: IContentProtectionConfig;
|
|
98
|
+
private readonly _keyboardPeripherals: IKeyboardPeripheralsConfig;
|
|
99
|
+
private readonly _navigatorProtector: DivinaNavigatorProtector | null = null;
|
|
100
|
+
private _keyboardPeripheralsManager: KeyboardPeripherals | null = null;
|
|
101
|
+
private readonly _keyboardConditionBridge: KeyboardConditionBridge | null = null;
|
|
102
|
+
private readonly _suspiciousActivityListener: ((event: Event) => void) | null = null;
|
|
103
|
+
private readonly _keyboardPeripheralListener: ((event: Event) => void) | null = null;
|
|
104
|
+
|
|
105
|
+
private resizeObserver: ResizeObserver;
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
container: HTMLElement,
|
|
109
|
+
pub: Publication,
|
|
110
|
+
listeners: Partial<DivinaNavigatorListeners>,
|
|
111
|
+
positions: Locator[] = [],
|
|
112
|
+
initialPosition: Locator | undefined = undefined,
|
|
113
|
+
configuration: DivinaNavigatorConfiguration = { preferences: {}, defaults: {} }
|
|
114
|
+
) {
|
|
115
|
+
super();
|
|
116
|
+
this.pub = pub;
|
|
117
|
+
this.container = container;
|
|
118
|
+
this.listeners = defaultListeners(listeners);
|
|
119
|
+
this.currentLocation = initialPosition!;
|
|
120
|
+
if (positions.length)
|
|
121
|
+
this.positions = positions;
|
|
122
|
+
|
|
123
|
+
this._preferences = new DivinaPreferences(configuration.preferences);
|
|
124
|
+
this._defaults = new DivinaDefaults(configuration.defaults);
|
|
125
|
+
this._settings = new DivinaSettings(this._preferences, this._defaults, this.manifestScrolled);
|
|
126
|
+
|
|
127
|
+
this.spreader = new DivinaSpreader(pub);
|
|
128
|
+
|
|
129
|
+
this._contentProtection = configuration.contentProtection || {};
|
|
130
|
+
this._keyboardPeripherals = this.mergeKeyboardPeripherals(
|
|
131
|
+
this._contentProtection,
|
|
132
|
+
configuration.keyboardPeripherals || []
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Initialize navigator protection if any protection is configured.
|
|
136
|
+
// Unlike the iframe-based navigators, copy and drag-and-drop protection
|
|
137
|
+
// also runs host-side here, since the content is in the host DOM.
|
|
138
|
+
if (this._contentProtection.disableContextMenu ||
|
|
139
|
+
this._contentProtection.checkAutomation ||
|
|
140
|
+
this._contentProtection.checkIFrameEmbedding ||
|
|
141
|
+
this._contentProtection.monitorDevTools ||
|
|
142
|
+
this._contentProtection.protectPrinting?.disable ||
|
|
143
|
+
this._contentProtection.disableDragAndDrop ||
|
|
144
|
+
this._contentProtection.protectCopy) {
|
|
145
|
+
this._navigatorProtector = new DivinaNavigatorProtector(this._contentProtection);
|
|
146
|
+
|
|
147
|
+
this._suspiciousActivityListener = (event: Event) => {
|
|
148
|
+
const { type, ...activity } = (event as CustomEvent).detail;
|
|
149
|
+
if (type === "context_menu") {
|
|
150
|
+
this.listeners.contextMenu(activity as ContextMenuEvent);
|
|
151
|
+
} else {
|
|
152
|
+
this.listeners.contentProtection(type, activity);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
window.addEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Initialize keyboard peripherals separately (works independently of protection).
|
|
159
|
+
// Unlike the iframe-based navigators (whose frame managers bridge conditions
|
|
160
|
+
// into the injected modules), divina handles keys host-side, so observable
|
|
161
|
+
// conditions are resolved here by rebuilding the manager on change.
|
|
162
|
+
if (this._keyboardPeripherals.length > 0) {
|
|
163
|
+
this._keyboardConditionBridge = new KeyboardConditionBridge(
|
|
164
|
+
this._keyboardPeripherals,
|
|
165
|
+
(serializable) => {
|
|
166
|
+
this._keyboardPeripheralsManager?.destroy();
|
|
167
|
+
this._keyboardPeripheralsManager = serializable.length > 0
|
|
168
|
+
? new KeyboardPeripherals({ keyboardPeripherals: serializable })
|
|
169
|
+
: null;
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
this._keyboardConditionBridge.setup();
|
|
173
|
+
|
|
174
|
+
this._keyboardPeripheralListener = (event: Event) => {
|
|
175
|
+
const activity = (event as CustomEvent).detail;
|
|
176
|
+
this.listeners.peripheral(activity);
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Observe the container's parent, since we set the container's own width
|
|
182
|
+
this.resizeObserver = new ResizeObserver(() => this.ownerWindow.requestAnimationFrame(() => this.resizeHandler()));
|
|
183
|
+
this.resizeObserver.observe(this.container.parentElement || document.documentElement);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private get manifestScrolled(): boolean {
|
|
187
|
+
return this.pub.metadata.effectiveLayout === Layout.scrolled;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get ownerWindow(): Window {
|
|
191
|
+
return this.container.ownerDocument.defaultView || window;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get publication(): Publication {
|
|
195
|
+
return this.pub;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get timeline(): Timeline {
|
|
199
|
+
const t = this.pub.timeline;
|
|
200
|
+
if (!this._timelineAugmented && this.positions?.length) {
|
|
201
|
+
// Each Divina page is exactly one position — there's no intra-resource
|
|
202
|
+
// fragment/scroll granularity to resolve, unlike EPUB's reflowable text.
|
|
203
|
+
// This mirrors the EPUB fixed-layout case: one leaf toc entry per href
|
|
204
|
+
// gets that resource's single position.
|
|
205
|
+
const positions = this.positions;
|
|
206
|
+
const assignedHrefs = new Set<string>();
|
|
207
|
+
t.augment((item, link) => {
|
|
208
|
+
if (item.children?.length) return {};
|
|
209
|
+
if (assignedHrefs.has(link.href)) return {};
|
|
210
|
+
const entry = positions.find(p => p.href === link.href);
|
|
211
|
+
if (!entry) return {};
|
|
212
|
+
assignedHrefs.add(link.href);
|
|
213
|
+
return { position: entry.locations.position };
|
|
214
|
+
});
|
|
215
|
+
this._timelineAugmented = true;
|
|
216
|
+
}
|
|
217
|
+
// Divina pages have no in-resource fragments to anchor previous/next
|
|
218
|
+
// against, so unlike the HTML navigators, navigableFrom can pass straight
|
|
219
|
+
// through to the real Timeline without a visible-range-aware wrapper.
|
|
220
|
+
if (!this._wrappedTimeline) this._wrappedTimeline = t;
|
|
221
|
+
return this._wrappedTimeline;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private _notifyTimelineChange(locator: Locator): void {
|
|
225
|
+
const item = this.timeline.locate(locator);
|
|
226
|
+
if (item !== this._currentTimelineItem) {
|
|
227
|
+
this._currentTimelineItem = item;
|
|
228
|
+
this.listeners.timelineItemChanged(item);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
get layout(): Layout {
|
|
233
|
+
return this._settings.scrolled ? Layout.scrolled : Layout.fixed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get readingProgression(): ReadingProgression {
|
|
237
|
+
return this.pub.metadata.effectiveReadingProgression;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private get presenter(): DivinaPagedPresenter | DivinaScrolledPresenter | null {
|
|
241
|
+
return this.pagedPresenter ?? this.scrolledPresenter;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
public async load() {
|
|
245
|
+
if (!this.positions?.length)
|
|
246
|
+
this.positions = await this.pub.positionsFromManifest();
|
|
247
|
+
if (this._destroyed) return;
|
|
248
|
+
if (!this.positions?.length)
|
|
249
|
+
this.positions = this.synthesizePositions();
|
|
250
|
+
|
|
251
|
+
// Build the page pool
|
|
252
|
+
const fetchBlob = async (link: Link, signal?: AbortSignal): Promise<Blob> => {
|
|
253
|
+
const resource = this.pub.get(link);
|
|
254
|
+
const bytes = await resource.read(undefined, { signal });
|
|
255
|
+
if(!bytes) throw new Error(`Empty resource: ${link.href}`);
|
|
256
|
+
const served = await resource.link();
|
|
257
|
+
const mediaType = served.type !== undefined ? served.mediaType : link.mediaType;
|
|
258
|
+
return new Blob([bytes as BlobPart], { type: mediaType.string });
|
|
259
|
+
};
|
|
260
|
+
const getQuality = () => this._settings.quality;
|
|
261
|
+
this.spreader.pages.forEach((page) => {
|
|
262
|
+
this.pool.set(page.index, new DivinaPageManager(page, fetchBlob, getQuality));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
this.applyBackground();
|
|
266
|
+
this.createPresenter();
|
|
267
|
+
if (this._destroyed) return;
|
|
268
|
+
this.resizeHandler();
|
|
269
|
+
|
|
270
|
+
if(this.currentLocation === undefined)
|
|
271
|
+
this.currentLocation = this.positions[0];
|
|
272
|
+
else
|
|
273
|
+
this.currentLocation = this.completeLocator(this.currentLocation);
|
|
274
|
+
|
|
275
|
+
return new Promise<boolean>(res => {
|
|
276
|
+
if (this._destroyed) return res(false);
|
|
277
|
+
this.go(this.currentLocation, false, (ok) => res(ok));
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private synthesizePositions(): Locator[] {
|
|
282
|
+
const n = this.pub.readingOrder.items.length;
|
|
283
|
+
return this.pub.readingOrder.items.map((link, i) => link.locator.copyWithLocations({
|
|
284
|
+
progression: 0,
|
|
285
|
+
position: i + 1,
|
|
286
|
+
totalProgression: n > 0 ? i / n : 0
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private createPresenter() {
|
|
291
|
+
if(this._settings.scrolled) {
|
|
292
|
+
this.scrolledPresenter = new DivinaScrolledPresenter(
|
|
293
|
+
this.container, this.spreader, this.pool, this._settings.stripWidth
|
|
294
|
+
);
|
|
295
|
+
this.scrolledPresenter.listener = (key, data) => {
|
|
296
|
+
switch (key) {
|
|
297
|
+
case "scroll":
|
|
298
|
+
this.listeners.scroll(data as number);
|
|
299
|
+
break;
|
|
300
|
+
case "position":
|
|
301
|
+
this.updateLocationFromScroll(data as number);
|
|
302
|
+
break;
|
|
303
|
+
case "tap":
|
|
304
|
+
case "click":
|
|
305
|
+
this.handlePointer(key, data as DivinaPointerEvent);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
} else {
|
|
310
|
+
this.pagedPresenter = new DivinaPagedPresenter(
|
|
311
|
+
this.container, this.pub, this.spreader, this.pool, this._settings.spreads
|
|
312
|
+
);
|
|
313
|
+
this.pagedPresenter.listener = (key, data) => {
|
|
314
|
+
switch (key) {
|
|
315
|
+
case "no_more":
|
|
316
|
+
this.changeSpread(1);
|
|
317
|
+
break;
|
|
318
|
+
case "no_less":
|
|
319
|
+
this.changeSpread(-1);
|
|
320
|
+
break;
|
|
321
|
+
case "zoom":
|
|
322
|
+
this.listeners.zoom(data as number);
|
|
323
|
+
break;
|
|
324
|
+
case "tap":
|
|
325
|
+
case "click":
|
|
326
|
+
this.handlePointer(key, data as DivinaPointerEvent);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async destroyPresenter() {
|
|
334
|
+
if(this.pagedPresenter) {
|
|
335
|
+
await this.pagedPresenter.destroy();
|
|
336
|
+
this.pagedPresenter = null;
|
|
337
|
+
}
|
|
338
|
+
if(this.scrolledPresenter) {
|
|
339
|
+
await this.scrolledPresenter.destroy();
|
|
340
|
+
this.scrolledPresenter = null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private handlePointer(key: "tap" | "click", e: DivinaPointerEvent) {
|
|
345
|
+
const edata: FrameClickEvent = {
|
|
346
|
+
defaultPrevented: false,
|
|
347
|
+
doNotDisturb: e.doNotDisturb,
|
|
348
|
+
interactiveElement: undefined,
|
|
349
|
+
cssSelector: undefined,
|
|
350
|
+
targetElement: "",
|
|
351
|
+
targetFrameSrc: "",
|
|
352
|
+
x: e.x,
|
|
353
|
+
y: e.y
|
|
354
|
+
};
|
|
355
|
+
const handled = key === "click" ? this.listeners.click(edata) : this.listeners.tap(edata);
|
|
356
|
+
if(handled || e.doNotDisturb) return;
|
|
357
|
+
|
|
358
|
+
const dpr = window.devicePixelRatio || 1;
|
|
359
|
+
if(this._settings.scrolled) {
|
|
360
|
+
// Vertical zones: top quarter goes back, bottom quarter advances
|
|
361
|
+
const oneQuarter = (this.container.clientHeight * dpr) / 4;
|
|
362
|
+
if (e.y >= oneQuarter && e.y <= oneQuarter * 3) this.listeners.miscPointer(1);
|
|
363
|
+
else if (e.y < oneQuarter) this.goBackward(true, () => {});
|
|
364
|
+
else this.goForward(true, () => {});
|
|
365
|
+
} else {
|
|
366
|
+
// Horizontal zones: quarter edges flip pages, middle is UI
|
|
367
|
+
const oneQuarter = (this.container.clientWidth * dpr) / 4;
|
|
368
|
+
if (e.x >= oneQuarter && e.x <= oneQuarter * 3) this.listeners.miscPointer(1);
|
|
369
|
+
else if (e.x < oneQuarter) this.goLeft(false, () => {});
|
|
370
|
+
else this.goRight(false, () => {});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Paged mode: move by one spread and report the new position */
|
|
375
|
+
private changeSpread(relative: number): boolean {
|
|
376
|
+
const p = this.pagedPresenter;
|
|
377
|
+
if(!p) return false;
|
|
378
|
+
const moved = relative > 0 ? p.next() : p.prev();
|
|
379
|
+
if(!moved) return false;
|
|
380
|
+
this.setLocationToIndex(p.currentIndex);
|
|
381
|
+
p.update();
|
|
382
|
+
this.listeners.positionChanged(this.currentLocation);
|
|
383
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private setLocationToIndex(index: number) {
|
|
388
|
+
const link = this.pub.readingOrder.items[index];
|
|
389
|
+
if(!link) return;
|
|
390
|
+
const match = this.positions.find(pos => pos.href === link.href);
|
|
391
|
+
this.currentLocation = match ?? this.synthesizePositions()[index];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Scrolled mode: debounced position reports from the scroll handler */
|
|
395
|
+
private updateLocationFromScroll(index: number) {
|
|
396
|
+
const p = this.scrolledPresenter;
|
|
397
|
+
if(!p) return;
|
|
398
|
+
this.setLocationToIndex(index);
|
|
399
|
+
const n = this.pub.readingOrder.items.length;
|
|
400
|
+
const progression = p.currentPageProgression;
|
|
401
|
+
this.currentLocation = this.currentLocation.copyWithLocations({
|
|
402
|
+
progression,
|
|
403
|
+
totalProgression: n > 0 ? Math.min((index + progression) / n, 1) : 0
|
|
404
|
+
});
|
|
405
|
+
this.listeners.positionChanged(this.currentLocation);
|
|
406
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private applyBackground() {
|
|
410
|
+
const background = this._settings.backgroundColor;
|
|
411
|
+
if(background) {
|
|
412
|
+
this.container.style.background = background;
|
|
413
|
+
// color-scheme and scrollbar-color inherit into the scroller, so
|
|
414
|
+
// the scrollbar blends with the page background: the track matches
|
|
415
|
+
// it exactly and the thumb is a translucent contrast color
|
|
416
|
+
const dark = this.isDarkColor(background);
|
|
417
|
+
if(dark === null) {
|
|
418
|
+
this.container.style.removeProperty("color-scheme");
|
|
419
|
+
this.container.style.removeProperty("scrollbar-color");
|
|
420
|
+
} else {
|
|
421
|
+
this.container.style.colorScheme = dark ? "dark" : "light";
|
|
422
|
+
this.container.style.scrollbarColor =
|
|
423
|
+
`${dark ? "rgba(255, 255, 255, 0.3)" : "rgba(0, 0, 0, 0.3)"} ${background}`;
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
this.container.style.removeProperty("background");
|
|
427
|
+
this.container.style.removeProperty("color-scheme");
|
|
428
|
+
this.container.style.removeProperty("scrollbar-color");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Whether a CSS color is dark, via computed-style normalization; null if unparseable */
|
|
433
|
+
private isDarkColor(color: string): boolean | null {
|
|
434
|
+
const doc = this.container.ownerDocument;
|
|
435
|
+
const probe = doc.createElement("div");
|
|
436
|
+
probe.style.display = "none";
|
|
437
|
+
probe.style.color = color;
|
|
438
|
+
doc.body.appendChild(probe);
|
|
439
|
+
const rgb = (doc.defaultView || window).getComputedStyle(probe).color;
|
|
440
|
+
probe.remove();
|
|
441
|
+
const m = rgb.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
|
|
442
|
+
if(!m) return null;
|
|
443
|
+
const luminance = (0.2126 * +m[1] + 0.7152 * +m[2] + 0.0722 * +m[3]) / 255;
|
|
444
|
+
return luminance < 0.45;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
resizeHandler() {
|
|
448
|
+
const parentEl = this.container.parentElement || document.documentElement;
|
|
449
|
+
// Floor to device pixels: a fractional container width shifts the
|
|
450
|
+
// whole spine to fractional positions (hairline gaps at the gutter)
|
|
451
|
+
const dpr = window.devicePixelRatio || 1;
|
|
452
|
+
const width = Math.floor((getContentWidth(parentEl) - this._settings.constraint) * dpr) / dpr;
|
|
453
|
+
if(isTypedOMSupported()) {
|
|
454
|
+
this.container.attributeStyleMap.set("width", CSS.px(width));
|
|
455
|
+
} else {
|
|
456
|
+
this.container.style.width = `${width}px`;
|
|
457
|
+
}
|
|
458
|
+
this.pagedPresenter?.resizeHandler();
|
|
459
|
+
this.scrolledPresenter?.resizeHandler();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// === Navigation ===
|
|
463
|
+
|
|
464
|
+
private completeLocator(locator: Locator): Locator {
|
|
465
|
+
if(!locator.href) {
|
|
466
|
+
let fellback = false;
|
|
467
|
+
if(typeof locator.locations.position === "number") {
|
|
468
|
+
const match = this.positions.find(p => p.locations.position === locator.locations.position);
|
|
469
|
+
if (match) {
|
|
470
|
+
locator = match.copyWithLocations(locator.locations);
|
|
471
|
+
fellback = true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if(!fellback && typeof locator.locations?.totalProgression === "number") {
|
|
475
|
+
const targetProgression = locator.locations.totalProgression;
|
|
476
|
+
let closestIdx = 0;
|
|
477
|
+
let closestDist = Infinity;
|
|
478
|
+
for (let i = 0; i < this.positions.length; i++) {
|
|
479
|
+
const pos = this.positions[i];
|
|
480
|
+
const posProg = pos.locations.totalProgression ?? (i / this.positions.length);
|
|
481
|
+
const dist = Math.abs(posProg - targetProgression);
|
|
482
|
+
if (dist < closestDist) {
|
|
483
|
+
closestDist = dist;
|
|
484
|
+
closestIdx = i;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
locator = this.positions[closestIdx].copyWithLocations(locator.locations);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return locator;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The reading order index for a href, tolerating representation
|
|
495
|
+
* mismatches: a TOC can use absolute URLs while the reading order is
|
|
496
|
+
* relative (or vice versa), and it can reference a page through one of
|
|
497
|
+
* its alternate variants rather than the main href. Both sides are
|
|
498
|
+
* resolved against the publication base, and each page matches on its
|
|
499
|
+
* own href or any of its alternates.
|
|
500
|
+
*/
|
|
501
|
+
private findReadingOrderIndex(href: string): number {
|
|
502
|
+
const direct = this.pub.readingOrder.findIndexWithHref(href);
|
|
503
|
+
if(direct >= 0) return direct;
|
|
504
|
+
const base = this.pub.baseURL;
|
|
505
|
+
const target = new Link({ href }).toURL(base) ?? href;
|
|
506
|
+
const matches = (l: Link): boolean => (l.toURL(base) ?? l.href) === target;
|
|
507
|
+
return this.pub.readingOrder.items.findIndex(item =>
|
|
508
|
+
matches(item) || !!item.alternates?.items.some(matches));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
public go(locator: Locator, animated: boolean, cb: (ok: boolean) => void): void {
|
|
512
|
+
locator = this.completeLocator(locator);
|
|
513
|
+
const href = locator.href?.split("#")[0];
|
|
514
|
+
const index = href ? this.findReadingOrderIndex(href) : -1;
|
|
515
|
+
if(index < 0) {
|
|
516
|
+
return cb(this.listeners.handleLocator(locator));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if(this._isNavigating) { cb(false); return; }
|
|
520
|
+
this._isNavigating = true;
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
if(this.scrolledPresenter) {
|
|
524
|
+
const progression = locator.locations?.progression || 0;
|
|
525
|
+
this.scrolledPresenter.goToItem(index, animated, progression);
|
|
526
|
+
this.scrolledPresenter.update();
|
|
527
|
+
} else if(this.pagedPresenter) {
|
|
528
|
+
this.pagedPresenter.goToItem(index, animated);
|
|
529
|
+
this.pagedPresenter.update();
|
|
530
|
+
}
|
|
531
|
+
this.setLocationToIndex(this.pagedPresenter ? this.pagedPresenter.currentIndex : index);
|
|
532
|
+
this.listeners.positionChanged(this.currentLocation);
|
|
533
|
+
this._notifyTimelineChange(this.currentLocation);
|
|
534
|
+
cb(true);
|
|
535
|
+
} finally {
|
|
536
|
+
this._isNavigating = false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
public goLink(link: Link, animated: boolean, cb: (ok: boolean) => void): void {
|
|
541
|
+
return this.go(link.locator, animated, cb);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
public goForward(animated: boolean, cb: (ok: boolean) => void): void {
|
|
545
|
+
if(this.scrolledPresenter) {
|
|
546
|
+
cb(this.scrolledPresenter.next(animated));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
cb(this.changeSpread(1));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
public goBackward(animated: boolean, cb: (ok: boolean) => void): void {
|
|
553
|
+
if(this.scrolledPresenter) {
|
|
554
|
+
cb(this.scrolledPresenter.prev(animated));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
cb(this.changeSpread(-1));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Scrolled mode: scroll by a fixed pixel amount (e.g. a line step for
|
|
562
|
+
* arrow keys) instead of the viewport-sized go* step. Falls back to a
|
|
563
|
+
* spread turn in paged mode.
|
|
564
|
+
*/
|
|
565
|
+
public scrollBy(px: number, animated: boolean, cb: (ok: boolean) => void): void {
|
|
566
|
+
if(this.scrolledPresenter) {
|
|
567
|
+
cb(px >= 0
|
|
568
|
+
? this.scrolledPresenter.next(animated, px)
|
|
569
|
+
: this.scrolledPresenter.prev(animated, -px));
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
cb(this.changeSpread(px >= 0 ? 1 : -1));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// === Zoom (paged mode only) ===
|
|
576
|
+
|
|
577
|
+
public zoomIn() {
|
|
578
|
+
this.pagedPresenter?.peripherals.zoomIn();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
public zoomOut() {
|
|
582
|
+
this.pagedPresenter?.peripherals.zoomOut();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
public zoomReset() {
|
|
586
|
+
this.pagedPresenter?.peripherals.resetZoom();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
get currentLocator(): Locator {
|
|
590
|
+
return this.currentLocation;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
get viewport(): VisualNavigatorViewport {
|
|
594
|
+
return this.presenter?.viewport ?? { readingOrder: [], progressions: new Map(), positions: null };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
get isScrollStart(): boolean {
|
|
598
|
+
const firstHref = this.viewport.readingOrder[0];
|
|
599
|
+
const progression = firstHref ? this.viewport.progressions.get(firstHref) : undefined;
|
|
600
|
+
return progression?.start === 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
get isScrollEnd(): boolean {
|
|
604
|
+
const lastHref = this.viewport.readingOrder[this.viewport.readingOrder.length - 1];
|
|
605
|
+
const progression = lastHref ? this.viewport.progressions.get(lastHref) : undefined;
|
|
606
|
+
return progression?.end === 1;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
get canGoBackward(): boolean {
|
|
610
|
+
const firstResource = this.pub.readingOrder.items[0]?.href;
|
|
611
|
+
return !(this.viewport.progressions.has(firstResource) && this.viewport.progressions.get(firstResource)?.start === 0);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
get canGoForward(): boolean {
|
|
615
|
+
const lastResource = this.pub.readingOrder.items[this.pub.readingOrder.items.length - 1]?.href;
|
|
616
|
+
return !(this.viewport.progressions.has(lastResource) && this.viewport.progressions.get(lastResource)?.end === 1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// === Preferences ===
|
|
620
|
+
|
|
621
|
+
public get settings(): Readonly<DivinaSettings> {
|
|
622
|
+
return Object.freeze({ ...this._settings });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
public get preferencesEditor(): DivinaPreferencesEditor {
|
|
626
|
+
if (this._preferencesEditor === null) {
|
|
627
|
+
this._preferencesEditor = new DivinaPreferencesEditor(this._preferences, this.settings, this.pub.metadata);
|
|
628
|
+
}
|
|
629
|
+
return this._preferencesEditor;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private _applying: Promise<void> = Promise.resolve();
|
|
633
|
+
|
|
634
|
+
public async submitPreferences(preferences: DivinaPreferences) {
|
|
635
|
+
this._preferences = this._preferences.merging(preferences) as DivinaPreferences;
|
|
636
|
+
// Serialize applications: a concurrent scrolled-mode flip would
|
|
637
|
+
// otherwise interleave across the async presenter rebuild
|
|
638
|
+
this._applying = this._applying.then(() => this.applyPreferences());
|
|
639
|
+
await this._applying;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private async applyPreferences() {
|
|
643
|
+
const oldSettings = this._settings;
|
|
644
|
+
this._settings = new DivinaSettings(this._preferences, this._defaults, this.manifestScrolled);
|
|
645
|
+
|
|
646
|
+
if (this._preferencesEditor !== null) {
|
|
647
|
+
this._preferencesEditor = new DivinaPreferencesEditor(this._preferences, this.settings, this.pub.metadata);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (oldSettings.backgroundColor !== this._settings.backgroundColor) {
|
|
651
|
+
this.applyBackground();
|
|
652
|
+
}
|
|
653
|
+
if (oldSettings.constraint !== this._settings.constraint) {
|
|
654
|
+
this.resizeHandler();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (oldSettings.scrolled !== this._settings.scrolled) {
|
|
658
|
+
// The new mode is picked up by load() if it hasn't run yet
|
|
659
|
+
if (!this.presenter) return;
|
|
660
|
+
// Rebuild the presentation in the other mode, retaining the position
|
|
661
|
+
const locator = this.currentLocation;
|
|
662
|
+
await this.destroyPresenter();
|
|
663
|
+
if (this._destroyed) return;
|
|
664
|
+
this.createPresenter();
|
|
665
|
+
this.resizeHandler();
|
|
666
|
+
this.go(locator, false, () => {});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (oldSettings.spreads !== this._settings.spreads) {
|
|
671
|
+
this.pagedPresenter?.setSpreads(this._settings.spreads);
|
|
672
|
+
}
|
|
673
|
+
if (oldSettings.stripWidth !== this._settings.stripWidth) {
|
|
674
|
+
this.scrolledPresenter?.applyStripWidth(this._settings.stripWidth);
|
|
675
|
+
}
|
|
676
|
+
if (oldSettings.quality !== this._settings.quality) {
|
|
677
|
+
// Re-pick variants: drop everything and reload the current window
|
|
678
|
+
this.pool.forEach(pm => pm.unload());
|
|
679
|
+
this.pagedPresenter?.update();
|
|
680
|
+
this.scrolledPresenter?.update();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
public async destroy() {
|
|
685
|
+
// Flag synchronously so an in-flight load() bails
|
|
686
|
+
this._destroyed = true;
|
|
687
|
+
if (this._suspiciousActivityListener) {
|
|
688
|
+
window.removeEventListener(NAVIGATOR_SUSPICIOUS_ACTIVITY_EVENT, this._suspiciousActivityListener);
|
|
689
|
+
}
|
|
690
|
+
if (this._keyboardPeripheralListener) {
|
|
691
|
+
window.removeEventListener(NAVIGATOR_KEYBOARD_PERIPHERAL_EVENT, this._keyboardPeripheralListener);
|
|
692
|
+
}
|
|
693
|
+
this._navigatorProtector?.destroy();
|
|
694
|
+
this._keyboardConditionBridge?.destroy();
|
|
695
|
+
this._keyboardPeripheralsManager?.destroy();
|
|
696
|
+
this.resizeObserver.disconnect();
|
|
697
|
+
await this.destroyPresenter();
|
|
698
|
+
this.pool.forEach(pm => pm.destroy());
|
|
699
|
+
this.pool.clear();
|
|
700
|
+
Array.from(this.container.childNodes).forEach(v => {
|
|
701
|
+
if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove();
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|