@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.
Files changed (86) hide show
  1. package/dist/audio/AudioNavigator.js +1 -1
  2. package/dist/audio/AudioPoolManager.js +1 -1
  3. package/dist/audio/engine/WebAudioEngine.js +1 -1
  4. package/dist/divina/DivinaNavigator.js +1 -0
  5. package/dist/divina/DivinaPageManager.js +1 -0
  6. package/dist/divina/DivinaPagedPresenter.js +1 -0
  7. package/dist/divina/DivinaPeripherals.js +1 -0
  8. package/dist/divina/DivinaScrolledPresenter.js +1 -0
  9. package/dist/divina/DivinaSpreader.js +1 -0
  10. package/dist/divina/DivinaVariantSelector.js +1 -0
  11. package/dist/divina/preferences/DivinaDefaults.js +1 -0
  12. package/dist/divina/preferences/DivinaPreferences.js +1 -0
  13. package/dist/divina/preferences/DivinaPreferencesEditor.js +1 -0
  14. package/dist/divina/preferences/DivinaSettings.js +1 -0
  15. package/dist/divina/protection/DivinaNavigatorProtector.js +1 -0
  16. package/dist/epub/EpubNavigator.js +1 -1
  17. package/dist/epub/frame/FrameBlobBuilder.js +3 -1
  18. package/dist/epub/frame/FrameManager.js +1 -1
  19. package/dist/epub/frame/FramePoolManager.js +1 -1
  20. package/dist/epub/fxl/FXLFrameManager.js +1 -1
  21. package/dist/epub/fxl/FXLFramePoolManager.js +1 -1
  22. package/dist/epub/fxl/FXLPeripherals.js +2 -2
  23. package/dist/index.js +1 -1
  24. package/dist/peripherals/KeyboardConditionBridge.js +1 -1
  25. package/dist/peripherals/KeyboardPeripherals.js +1 -1
  26. package/dist/webpub/WebPubFrameManager.js +1 -1
  27. package/dist/webpub/WebPubFramePoolManager.js +1 -1
  28. package/dist/webpub/WebPubNavigator.js +1 -1
  29. package/package.json +5 -5
  30. package/src/audio/AudioNavigator.ts +107 -5
  31. package/src/audio/AudioPoolManager.ts +14 -8
  32. package/src/audio/engine/AudioEngine.ts +34 -1
  33. package/src/audio/engine/WebAudioEngine.ts +48 -3
  34. package/src/divina/DivinaNavigator.ts +704 -0
  35. package/src/divina/DivinaPageManager.ts +322 -0
  36. package/src/divina/DivinaPagedPresenter.ts +508 -0
  37. package/src/divina/DivinaPeripherals.ts +630 -0
  38. package/src/divina/DivinaScrolledPresenter.ts +516 -0
  39. package/src/divina/DivinaSpreader.ts +202 -0
  40. package/src/divina/DivinaVariantSelector.ts +77 -0
  41. package/src/divina/index.ts +8 -0
  42. package/src/divina/preferences/DivinaDefaults.ts +37 -0
  43. package/src/divina/preferences/DivinaPreferences.ts +77 -0
  44. package/src/divina/preferences/DivinaPreferencesEditor.ts +96 -0
  45. package/src/divina/preferences/DivinaSettings.ts +38 -0
  46. package/src/divina/preferences/index.ts +4 -0
  47. package/src/divina/protection/DivinaNavigatorProtector.ts +44 -0
  48. package/src/epub/EpubNavigator.ts +73 -7
  49. package/src/epub/frame/FrameBlobBuilder.ts +144 -39
  50. package/src/epub/frame/FrameManager.ts +7 -9
  51. package/src/epub/frame/FramePoolManager.ts +106 -48
  52. package/src/epub/fxl/FXLFrameManager.ts +69 -26
  53. package/src/epub/fxl/FXLFramePoolManager.ts +98 -53
  54. package/src/epub/fxl/FXLPeripherals.ts +22 -1
  55. package/src/index.ts +1 -0
  56. package/src/peripherals/KeyboardConditionBridge.ts +15 -5
  57. package/src/peripherals/KeyboardPeripherals.ts +13 -7
  58. package/src/webpub/WebPubFrameManager.ts +7 -9
  59. package/src/webpub/WebPubFramePoolManager.ts +5 -2
  60. package/src/webpub/WebPubNavigator.ts +44 -0
  61. package/types/src/audio/AudioNavigator.d.ts +29 -0
  62. package/types/src/audio/engine/AudioEngine.d.ts +27 -1
  63. package/types/src/audio/engine/WebAudioEngine.d.ts +14 -2
  64. package/types/src/divina/DivinaNavigator.d.ts +119 -0
  65. package/types/src/divina/DivinaPageManager.d.ts +66 -0
  66. package/types/src/divina/DivinaPagedPresenter.d.ts +122 -0
  67. package/types/src/divina/DivinaPeripherals.d.ts +121 -0
  68. package/types/src/divina/DivinaScrolledPresenter.d.ts +125 -0
  69. package/types/src/divina/DivinaSpreader.d.ts +65 -0
  70. package/types/src/divina/DivinaVariantSelector.d.ts +30 -0
  71. package/types/src/divina/index.d.ts +8 -0
  72. package/types/src/divina/preferences/DivinaDefaults.d.ts +18 -0
  73. package/types/src/divina/preferences/DivinaPreferences.d.ts +33 -0
  74. package/types/src/divina/preferences/DivinaPreferencesEditor.d.ts +20 -0
  75. package/types/src/divina/preferences/DivinaSettings.d.ts +26 -0
  76. package/types/src/divina/preferences/index.d.ts +4 -0
  77. package/types/src/divina/protection/DivinaNavigatorProtector.d.ts +14 -0
  78. package/types/src/epub/EpubNavigator.d.ts +7 -1
  79. package/types/src/epub/frame/FrameBlobBuilder.d.ts +19 -3
  80. package/types/src/epub/frame/FramePoolManager.d.ts +5 -1
  81. package/types/src/epub/fxl/FXLFrameManager.d.ts +2 -0
  82. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +8 -2
  83. package/types/src/epub/fxl/FXLPeripherals.d.ts +3 -0
  84. package/types/src/index.d.ts +1 -0
  85. package/types/src/peripherals/KeyboardPeripherals.d.ts +1 -0
  86. 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
+ }