diva.js 6.0.2 → 7.2.4

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 (131) hide show
  1. package/.clang-format +7 -0
  2. package/.github/workflows/npm-publish.yml +45 -0
  3. package/LICENSE +55 -0
  4. package/Makefile +75 -0
  5. package/README.md +15 -114
  6. package/elm.json +32 -0
  7. package/package.json +12 -59
  8. package/review/elm.json +52 -0
  9. package/review/src/ReviewConfig.elm +87 -0
  10. package/scripts/elm-esm.sh +40 -0
  11. package/scripts/minify-css.mjs +31 -0
  12. package/src/Filters.elm +1044 -0
  13. package/src/Main.elm +1217 -0
  14. package/src/Model.elm +213 -0
  15. package/src/Msg.elm +59 -0
  16. package/src/Utilities.elm +46 -0
  17. package/src/View/CollectionExplorer.elm +172 -0
  18. package/src/View/Helpers.elm +86 -0
  19. package/src/View/HtmlRenderer.elm +136 -0
  20. package/src/View/Icons.elm +159 -0
  21. package/src/View/ManifestInfoModal.elm +363 -0
  22. package/src/View/PageViewModal.elm +1046 -0
  23. package/src/View/Sidebar.elm +786 -0
  24. package/src/View/Toolbar.elm +189 -0
  25. package/src/View.elm +244 -0
  26. package/src/diva.ts +802 -0
  27. package/src/filters.ts +1843 -0
  28. package/src/styles/app.css +328 -0
  29. package/src/styles/collection.css +75 -0
  30. package/src/styles/modal.css +388 -0
  31. package/src/styles/sidebar.css +215 -0
  32. package/src/styles/theme.css +39 -0
  33. package/src/styles/toolbar.css +154 -0
  34. package/src/viewer-element.ts +1307 -0
  35. package/testing/index.html +52 -0
  36. package/testing/testing.html +231 -0
  37. package/tsconfig.json +12 -0
  38. package/AUTHORS +0 -22
  39. package/build/diva.css +0 -554
  40. package/build/diva.css.map +0 -1
  41. package/build/diva.js +0 -9
  42. package/build/diva.js.map +0 -1
  43. package/build/plugins/download.js +0 -2
  44. package/build/plugins/download.js.map +0 -1
  45. package/build/plugins/manipulation.js +0 -2
  46. package/build/plugins/manipulation.js.map +0 -1
  47. package/build/plugins/metadata.js +0 -2
  48. package/build/plugins/metadata.js.map +0 -1
  49. package/index.html +0 -28
  50. package/karma.conf.js +0 -87
  51. package/source/css/_mixins.scss +0 -43
  52. package/source/css/_variables.scss +0 -50
  53. package/source/css/_viewer.scss +0 -462
  54. package/source/css/diva.scss +0 -15
  55. package/source/css/plugins/_manipulation.scss +0 -228
  56. package/source/css/plugins/_metadata.scss +0 -31
  57. package/source/img/adjust.svg +0 -11
  58. package/source/img/book-view.svg +0 -6
  59. package/source/img/close.svg +0 -6
  60. package/source/img/download.svg +0 -6
  61. package/source/img/from-fullscreen.svg +0 -8
  62. package/source/img/grid-fewer.svg +0 -6
  63. package/source/img/grid-more.svg +0 -6
  64. package/source/img/grid-view.svg +0 -6
  65. package/source/img/link.svg +0 -6
  66. package/source/img/metadata.svg +0 -9
  67. package/source/img/page-view.svg +0 -6
  68. package/source/img/to-fullscreen.svg +0 -11
  69. package/source/img/zoom-in.svg +0 -6
  70. package/source/img/zoom-out.svg +0 -7
  71. package/source/js/composite-image.js +0 -174
  72. package/source/js/diva-global.js +0 -7
  73. package/source/js/diva.js +0 -1543
  74. package/source/js/document-handler.js +0 -180
  75. package/source/js/document-layout.js +0 -286
  76. package/source/js/exceptions.js +0 -26
  77. package/source/js/gesture-events.js +0 -190
  78. package/source/js/grid-handler.js +0 -122
  79. package/source/js/iiif-source-adapter.js +0 -63
  80. package/source/js/image-cache.js +0 -113
  81. package/source/js/image-manifest.js +0 -157
  82. package/source/js/image-request-handler.js +0 -76
  83. package/source/js/interpolate-animation.js +0 -122
  84. package/source/js/page-layouts/book-layout.js +0 -161
  85. package/source/js/page-layouts/grid-layout.js +0 -97
  86. package/source/js/page-layouts/index.js +0 -38
  87. package/source/js/page-layouts/page-dimensions.js +0 -9
  88. package/source/js/page-layouts/singles-layout.js +0 -27
  89. package/source/js/page-overlay-manager.js +0 -102
  90. package/source/js/page-tools-overlay.js +0 -95
  91. package/source/js/parse-iiif-manifest.js +0 -302
  92. package/source/js/plugins/_filters.js +0 -679
  93. package/source/js/plugins/download.js +0 -83
  94. package/source/js/plugins/manipulation.js +0 -837
  95. package/source/js/plugins/metadata.js +0 -190
  96. package/source/js/renderer.js +0 -584
  97. package/source/js/settings-view.js +0 -30
  98. package/source/js/tile-coverage-map.js +0 -25
  99. package/source/js/toolbar.js +0 -573
  100. package/source/js/utils/dragscroll.js +0 -106
  101. package/source/js/utils/elt.js +0 -94
  102. package/source/js/utils/events.js +0 -190
  103. package/source/js/utils/get-scrollbar-width.js +0 -29
  104. package/source/js/utils/hash-params.js +0 -86
  105. package/source/js/utils/parse-label-value.js +0 -34
  106. package/source/js/utils/vanilla.kinetic.js +0 -527
  107. package/source/js/validation-runner.js +0 -177
  108. package/source/js/viewer-core.js +0 -1514
  109. package/source/js/viewport.js +0 -143
  110. package/test/_setup.js +0 -13
  111. package/test/composite-image_test.js +0 -94
  112. package/test/diva_test.js +0 -43
  113. package/test/hash-params_test.js +0 -221
  114. package/test/image-cache_test.js +0 -106
  115. package/test/main.js +0 -6
  116. package/test/manifests/beromunsterManifest.json +0 -15514
  117. package/test/manifests/iiifv2.json +0 -11032
  118. package/test/manifests/iiifv2pages.json +0 -30437
  119. package/test/manifests/iiifv3.json +0 -10965
  120. package/test/navigation_test.js +0 -355
  121. package/test/parse-iiif-manifest_test.js +0 -68
  122. package/test/public_test.js +0 -881
  123. package/test/settings_test.js +0 -487
  124. package/test/utils/book-layout_test.js +0 -148
  125. package/test/utils/elt_test.js +0 -102
  126. package/test/utils/events_test.js +0 -245
  127. package/test/utils/hash-params_test.js +0 -79
  128. package/test/utils/parse-label-value_test.js +0 -45
  129. package/test/z_plugins_test.js +0 -180
  130. package/webpack.config.js +0 -58
  131. package/webpack.config.test.js +0 -45
@@ -0,0 +1,1307 @@
1
+ import type * as OpenSeadragonType from "openseadragon";
2
+
3
+ declare const OpenSeadragon: typeof OpenSeadragonType;
4
+
5
+ const ZOOM_IN_FACTOR = 1.6
6
+ const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR
7
+ const PAGE_LABEL_TOP_PADDING_PX = 28;
8
+ const PAGE_GAP_VIEWPORT_UNITS = 0.06;
9
+
10
+ class OsdViewer extends HTMLElement
11
+ {
12
+ private container: HTMLDivElement|null = null;
13
+ private viewer: OpenSeadragonType.Viewer|null = null;
14
+ private loadToken = 0;
15
+ private tileSources: string[] = [];
16
+ private pageLabels: string[] = [];
17
+ private pageAspects: number[] = [];
18
+ private pageOffsets: number[] = [];
19
+ private pageHeights: number[] = [];
20
+ private pageRowHeights: number[] = [];
21
+ private pageXOffsets: number[] = [];
22
+ private layoutMode: "single"|"spread"|"spread-shift" = "single";
23
+ private viewingDirection: "ltr"|"rtl" = "ltr";
24
+ private hasFitFirstPage = false;
25
+ private loadedIndexes: Set<number> = new Set();
26
+ private loadingIndexes: Set<number> = new Set();
27
+ private loadedItems: Map<number, any> = new Map();
28
+ private pageOverlayElements: Map<number, HTMLDivElement> = new Map();
29
+ private targetIndex: number|null = null;
30
+ private scrollPlaneItem: any = null;
31
+ private isViewportInitialized = false;
32
+ private lastReportedIndex: number|null = null;
33
+ private lastReportedZoom: number|null = null;
34
+ private loadingTimer: number|null = null;
35
+ private isLoading = false;
36
+ private isClamping = false;
37
+ private suppressPageChange = true;
38
+ private suppressZoomChange = true;
39
+ private readonly handleWheelBound: (event: WheelEvent) => void;
40
+ private readonly handleDoubleClickBound: (event: MouseEvent) => void;
41
+ private readonly handleViewportChangeBound: () => void;
42
+ private readonly handleAnimationFinishBound: () => void;
43
+ private scrollbarTrack: HTMLDivElement|null = null;
44
+ private scrollbarThumb: HTMLDivElement|null = null;
45
+ private isScrollbarDragging = false;
46
+ private scrollbarMouseMove: ((e: MouseEvent) => void)|null = null;
47
+ private scrollbarMouseUp: (() => void)|null = null;
48
+
49
+ constructor()
50
+ {
51
+ super();
52
+ this.handleWheelBound = this.handleWheel.bind(this);
53
+ this.handleDoubleClickBound = this.handleDoubleClick.bind(this);
54
+ this.handleViewportChangeBound = this.handleViewportChange.bind(this);
55
+ this.handleAnimationFinishBound = this.handleAnimationFinish.bind(this);
56
+ }
57
+
58
+ connectedCallback(): void
59
+ {
60
+ this.style.display = "block";
61
+ if (!this.container)
62
+ {
63
+ this.container = document.createElement("div");
64
+ this.container.className = "osd-container";
65
+ this.container.style.width = "100%";
66
+ this.container.style.height = "100%";
67
+ this.container.addEventListener("wheel", this.handleWheelBound, {passive : false, capture : true});
68
+ this.container.addEventListener("dblclick", this.handleDoubleClickBound);
69
+ this.appendChild(this.container);
70
+
71
+ this.createScrollbar();
72
+ }
73
+ this.syncViewer();
74
+ }
75
+
76
+ disconnectedCallback(): void
77
+ {
78
+ if (this.loadingTimer !== null)
79
+ {
80
+ window.clearTimeout(this.loadingTimer);
81
+ this.loadingTimer = null;
82
+ }
83
+ if (this.container)
84
+ {
85
+ this.container.removeEventListener("wheel", this.handleWheelBound);
86
+ this.container.removeEventListener("dblclick", this.handleDoubleClickBound);
87
+ }
88
+ if (this.scrollbarMouseMove)
89
+ {
90
+ document.removeEventListener("mousemove", this.scrollbarMouseMove);
91
+ this.scrollbarMouseMove = null;
92
+ }
93
+ if (this.scrollbarMouseUp)
94
+ {
95
+ document.removeEventListener("mouseup", this.scrollbarMouseUp);
96
+ this.scrollbarMouseUp = null;
97
+ }
98
+ if (this.viewer)
99
+ {
100
+ this.clearPageOverlays();
101
+ this.viewer.destroy();
102
+ this.viewer = null;
103
+ }
104
+ this.scrollbarTrack = null;
105
+ this.scrollbarThumb = null;
106
+ }
107
+
108
+ private syncViewer(): void
109
+ {
110
+ const hasOsd = Boolean((window as any).OpenSeadragon);
111
+ if (!hasOsd || !this.container)
112
+ {
113
+ return;
114
+ }
115
+
116
+ if (!this.viewer)
117
+ {
118
+ const options = {
119
+ element : this.container,
120
+ animationTime : 0.8,
121
+ showNavigationControl : false,
122
+ preserveViewport : true,
123
+ visibilityRatio : 0,
124
+ constrainDuringPan : false,
125
+ minZoomLevel : 0.1,
126
+ minZoomImageRatio : 0.1,
127
+ maxZoomPixelRatio : 2,
128
+ defaultZoomLevel : 0,
129
+ sequenceMode : false,
130
+ zoomPerScroll : 1,
131
+ crossOriginPolicy : "Anonymous",
132
+ loadTilesWithAjax : true,
133
+ ajaxWithCredentials : false,
134
+ gestureSettingsTrackpad :
135
+ {pinchToZoom : true, scrollToZoom : false, flickEnabled : true, dragToPan : true},
136
+ gestureSettingsMouse :
137
+ {scrollToZoom : false, clickToZoom : false, dblClickToZoom : false, dragToPan : true},
138
+ gestureSettingsTouch : {pinchToZoom : false, dragToPan : true}
139
+ } as any;
140
+ this.viewer = OpenSeadragon(options);
141
+ const viewer = this.viewer;
142
+ viewer.addHandler("pan", this.handleViewportChangeBound);
143
+ viewer.addHandler("zoom", this.handleViewportChangeBound);
144
+ viewer.addHandler("pan", () => this.updateScrollbar());
145
+ viewer.addHandler("zoom", () => this.updateScrollbar());
146
+ viewer.addHandler("animation-finish", this.handleAnimationFinishBound);
147
+ }
148
+ }
149
+
150
+ public setLayoutMode(mode: string): void
151
+ {
152
+ const nextMode = mode === "spread" || mode === "spread-shift" ? mode : "single";
153
+ this.applyLayoutChange({mode : nextMode});
154
+ }
155
+
156
+ public setViewingDirection(direction: string): void
157
+ {
158
+ const nextDirection = direction === "rtl" ? "rtl" : "ltr";
159
+ this.applyLayoutChange({direction : nextDirection});
160
+ }
161
+
162
+ public setLayoutConfig(mode: string, direction: string): void
163
+ {
164
+ const nextMode = mode === "spread" || mode === "spread-shift" ? mode : "single";
165
+ const nextDirection = direction === "rtl" ? "rtl" : "ltr";
166
+ this.applyLayoutChange({mode : nextMode, direction : nextDirection});
167
+ }
168
+
169
+ public setTileSources(tileSources: string[]): void
170
+ {
171
+ if (!Array.isArray(tileSources) || tileSources.length === 0)
172
+ {
173
+ return;
174
+ }
175
+
176
+ this.syncViewer();
177
+ this.resetTileSources(tileSources);
178
+ }
179
+
180
+ public setPageLabels(labels: string[]): void
181
+ {
182
+ if (!Array.isArray(labels))
183
+ {
184
+ return;
185
+ }
186
+
187
+ this.pageLabels = labels;
188
+ this.pageOverlayElements.forEach((_element, index) => {
189
+ this.addOrUpdatePageOverlay(index);
190
+ });
191
+ }
192
+
193
+ private resetTileSources(tileSources: string[]): void
194
+ {
195
+ if (!this.viewer)
196
+ {
197
+ return;
198
+ }
199
+ // OSD 6 keeps more internal loader/cache state; close() clears world +
200
+ // queues safely.
201
+ if (typeof this.viewer.close === "function")
202
+ {
203
+ this.viewer.close();
204
+ }
205
+ else
206
+ {
207
+ this.viewer.world.removeAll();
208
+ }
209
+ this.loadToken += 1;
210
+ this.tileSources = tileSources;
211
+ this.hasFitFirstPage = false;
212
+ this.isViewportInitialized = false;
213
+ this.loadedIndexes.clear();
214
+ this.loadingIndexes.clear();
215
+ this.loadedItems.clear();
216
+ this.clearPageOverlays();
217
+ this.targetIndex = null;
218
+ this.clearScrollPlane();
219
+ this.buildOffsets();
220
+ this.ensureScrollPlane();
221
+ this.resetLoadingState();
222
+ this.suppressPageChange = true;
223
+ this.suppressZoomChange = true;
224
+
225
+ this.maybeLoadMore();
226
+ }
227
+
228
+ private handleViewportChange(): void
229
+ {
230
+ if (this.isClamping)
231
+ {
232
+ return;
233
+ }
234
+ const viewport = this.viewer?.viewport;
235
+ if (!viewport)
236
+ {
237
+ return;
238
+ }
239
+ this.maybeLoadMore(viewport);
240
+ this.maybeEmitPageChange(viewport);
241
+ this.clampTop(viewport);
242
+ this.clampBottom(viewport);
243
+ }
244
+
245
+ private handleAnimationFinish(): void
246
+ {
247
+ this.updateScrollbar();
248
+ this.maybeEmitZoomChange();
249
+ }
250
+
251
+ private maybeLoadMore(viewport?: OpenSeadragonType.Viewport): void
252
+ {
253
+ if (this.tileSources.length === 0)
254
+ {
255
+ return;
256
+ }
257
+
258
+ const vp = viewport ?? this.viewer?.viewport;
259
+ if (!vp)
260
+ {
261
+ return;
262
+ }
263
+
264
+ const bounds = vp.getBounds(true);
265
+ const buffer = bounds.height * 1.5;
266
+ const viewTop = Math.max(0, bounds.y - buffer);
267
+ const viewBottom = bounds.y + bounds.height + buffer;
268
+
269
+ const range = this.indicesForRange(viewTop, viewBottom);
270
+ if (!range)
271
+ {
272
+ return;
273
+ }
274
+
275
+ const start = range[0];
276
+ const end = range[1];
277
+ let index = start;
278
+ while (index <= end)
279
+ {
280
+ this.ensurePageLoaded(index);
281
+ index += 1;
282
+ }
283
+
284
+ if (this.targetIndex !== null)
285
+ {
286
+ this.ensurePageLoaded(this.targetIndex);
287
+ }
288
+ }
289
+
290
+ private ensurePageLoaded(index: number): void
291
+ {
292
+ if (index < 0 || index >= this.tileSources.length || index >= this.pageOffsets.length)
293
+ {
294
+ return;
295
+ }
296
+
297
+ if (this.loadedIndexes.has(index) || this.loadingIndexes.has(index))
298
+ {
299
+ return;
300
+ }
301
+
302
+ this.loadTile(index);
303
+ }
304
+
305
+ private loadTile(index: number): void
306
+ {
307
+ if (!this.viewer)
308
+ {
309
+ return;
310
+ }
311
+
312
+ const token = this.loadToken;
313
+ const tileSource = this.tileSources[index];
314
+
315
+ this.loadingIndexes.add(index);
316
+ this.updateLoadingState();
317
+ const yOffset = this.pageOffsets[index] || 0;
318
+ const xOffset = this.pageXOffsets[index] || 0;
319
+ const height = this.pageHeights[index] || 1;
320
+
321
+ this.viewer.addTiledImage({
322
+ tileSource,
323
+ x : xOffset,
324
+ y : yOffset,
325
+ width : 1,
326
+ success : (event: any) => {
327
+ if (token !== this.loadToken)
328
+ {
329
+ return;
330
+ }
331
+ const item = event.item;
332
+ item.setPosition(new OpenSeadragon.Point(xOffset, yOffset), true);
333
+ item.setWidth(1, true);
334
+ item.setHeight(height, true);
335
+ this.loadedIndexes.add(index);
336
+ this.loadedItems.set(index, item);
337
+ this.addOrUpdatePageOverlay(index);
338
+ this.loadingIndexes.delete(index);
339
+ this.updateLoadingState();
340
+ if (!this.hasFitFirstPage)
341
+ {
342
+ this.hasFitFirstPage = true;
343
+ const viewer = this.viewer;
344
+ if (!viewer || !viewer.viewport)
345
+ {
346
+ return;
347
+ }
348
+ const isSingleCanvas = this.isSingleCanvasLayout();
349
+ const bounds = isSingleCanvas
350
+ ? item.getBounds()
351
+ : (this.isSpreadMode() ? this.getRowBounds(index) : item.getBounds());
352
+ viewer.viewport.fitBounds(bounds, true);
353
+ if (!isSingleCanvas)
354
+ {
355
+ viewer.viewport.zoomBy(0.95, viewer.viewport.getCenter(true), true);
356
+ }
357
+ this.alignTopAfterFit();
358
+ viewer.viewport.applyConstraints();
359
+ this.isViewportInitialized = true;
360
+ this.lockHorizontalPan();
361
+ this.recenterAfterFit();
362
+ this.flushInitialPageChange();
363
+ this.flushInitialZoomChange();
364
+ }
365
+ if (this.targetIndex === index)
366
+ {
367
+ this.targetIndex = null;
368
+ }
369
+ this.maybeLoadMore();
370
+ },
371
+ error : () => {
372
+ if (token !== this.loadToken)
373
+ {
374
+ return;
375
+ }
376
+ this.loadingIndexes.delete(index);
377
+ this.updateLoadingState();
378
+ this.maybeLoadMore();
379
+ }
380
+ });
381
+ }
382
+
383
+ public setPageAspects(aspects: number[]): void
384
+ {
385
+ if (!Array.isArray(aspects))
386
+ {
387
+ return;
388
+ }
389
+
390
+ this.pageAspects = aspects.filter((value) => value > 0);
391
+ this.buildOffsets();
392
+ this.updateLoadedItemPositions();
393
+ this.ensureScrollPlane();
394
+ this.maybeLoadMore();
395
+ }
396
+
397
+ private buildOffsets(): void
398
+ {
399
+ const count = this.tileSources.length > 0 ? this.tileSources.length : this.pageAspects.length;
400
+ this.pageOffsets = new Array(count);
401
+ this.pageHeights = new Array(count);
402
+ this.pageRowHeights = new Array(count);
403
+ this.pageXOffsets = new Array(count);
404
+
405
+ if (this.isSpreadMode())
406
+ {
407
+ this.buildSpreadOffsets(count);
408
+ }
409
+ else
410
+ {
411
+ this.buildSingleOffsets(count);
412
+ }
413
+ }
414
+
415
+ private buildSingleOffsets(count: number): void
416
+ {
417
+ const gap = PAGE_GAP_VIEWPORT_UNITS;
418
+ let current = 0;
419
+ let fallback = this.pageAspects[0] || 1;
420
+
421
+ for (let index = 0; index < count; index += 1)
422
+ {
423
+ const height = this.pageAspects[index] || fallback;
424
+ fallback = height;
425
+ this.pageOffsets[index] = current;
426
+ this.pageHeights[index] = height;
427
+ this.pageRowHeights[index] = height;
428
+ this.pageXOffsets[index] = 0;
429
+ current += height + gap;
430
+ }
431
+ }
432
+
433
+ private buildSpreadOffsets(count: number): void
434
+ {
435
+ const gap = PAGE_GAP_VIEWPORT_UNITS;
436
+ const isRtl = this.viewingDirection === "rtl";
437
+ let current = 0;
438
+ let index = 0;
439
+ let fallback = this.pageAspects[0] || 1;
440
+
441
+ if (this.layoutMode === "spread-shift" && count > 0)
442
+ {
443
+ const height = this.pageAspects[0] || fallback;
444
+ fallback = height;
445
+ this.pageOffsets[0] = current;
446
+ this.pageHeights[0] = height;
447
+ this.pageRowHeights[0] = height;
448
+ this.pageXOffsets[0] = isRtl ? 0 : 1;
449
+ current += height + gap;
450
+ index = 1;
451
+ }
452
+
453
+ while (index < count)
454
+ {
455
+ const leftHeight = this.pageAspects[index] || fallback;
456
+ const rightIndex = index + 1;
457
+ const rightHeight = rightIndex < count ? this.pageAspects[rightIndex] || leftHeight : leftHeight;
458
+ fallback = rightHeight;
459
+
460
+ const rowHeight = Math.max(leftHeight, rightHeight);
461
+
462
+ this.pageOffsets[index] = current;
463
+ this.pageHeights[index] = leftHeight;
464
+ this.pageRowHeights[index] = rowHeight;
465
+ this.pageXOffsets[index] = isRtl ? 1 : 0;
466
+
467
+ if (rightIndex < count)
468
+ {
469
+ this.pageOffsets[rightIndex] = current;
470
+ this.pageHeights[rightIndex] = rightHeight;
471
+ this.pageRowHeights[rightIndex] = rowHeight;
472
+ this.pageXOffsets[rightIndex] = isRtl ? 0 : 1;
473
+ }
474
+
475
+ current += rowHeight + gap;
476
+ index += 2;
477
+ }
478
+ }
479
+
480
+ private updateLoadedItemPositions(): void
481
+ {
482
+ if (!this.viewer)
483
+ {
484
+ return;
485
+ }
486
+
487
+ this.loadedItems.forEach((item, index) => {
488
+ const yOffset = this.pageOffsets[index];
489
+ const height = this.pageHeights[index];
490
+ const xOffset = this.pageXOffsets[index];
491
+ item.setPosition(new OpenSeadragon.Point(xOffset, yOffset), true);
492
+ item.setWidth(1, true);
493
+ item.setHeight(height, true);
494
+ this.addOrUpdatePageOverlay(index);
495
+ });
496
+ }
497
+
498
+ private addOrUpdatePageOverlay(index: number): void
499
+ {
500
+ if (!this.viewer)
501
+ {
502
+ return;
503
+ }
504
+
505
+ let element = this.pageOverlayElements.get(index);
506
+ const isRightAligned = index % 2 === 0;
507
+ if (!element)
508
+ {
509
+ element = document.createElement("div");
510
+ element.className = "diva-page-overlay-label";
511
+ element.style.pointerEvents = "none";
512
+ element.style.color = "#ffffff";
513
+ element.style.background = "transparent";
514
+ element.style.padding = "0";
515
+ element.style.borderRadius = "0";
516
+ element.style.fontSize = "12px";
517
+ element.style.fontWeight = "600";
518
+ element.style.lineHeight = "1.2";
519
+ element.style.whiteSpace = "nowrap";
520
+ element.style.paddingBottom = "6px";
521
+ element.style.paddingLeft = isRightAligned ? "0" : "12px";
522
+ element.style.paddingRight = isRightAligned ? "12px" : "0";
523
+ this.pageOverlayElements.set(index, element);
524
+ }
525
+ element.textContent = this.pageLabels[index] || `Page ${index + 1}`;
526
+
527
+ const xOffset = (this.pageXOffsets[index] || 0) + (isRightAligned ? 1 : 0);
528
+ const yOffset = this.pageOffsets[index] || 0;
529
+ try
530
+ {
531
+ this.viewer.removeOverlay(element);
532
+ }
533
+ catch (_error)
534
+ {
535
+ // overlay may not yet exist in viewer; safe to ignore.
536
+ }
537
+ this.viewer.addOverlay({
538
+ element,
539
+ location : new OpenSeadragon.Point(xOffset, yOffset),
540
+ placement : isRightAligned ? OpenSeadragon.Placement.BOTTOM_RIGHT : OpenSeadragon.Placement.BOTTOM_LEFT
541
+ });
542
+ }
543
+
544
+ private clearPageOverlays(): void
545
+ {
546
+ if (this.viewer)
547
+ {
548
+ this.pageOverlayElements.forEach((element) => {
549
+ try
550
+ {
551
+ this.viewer?.removeOverlay(element);
552
+ }
553
+ catch (_error)
554
+ {
555
+ // ignore missing overlay errors during teardown/reset.
556
+ }
557
+ element.remove();
558
+ });
559
+ }
560
+ this.pageOverlayElements.clear();
561
+ }
562
+
563
+ private ensureScrollPlane(): void
564
+ {
565
+ if (!this.viewer || this.pageOffsets.length === 0)
566
+ {
567
+ return;
568
+ }
569
+
570
+ const totalHeight = this.getTotalHeight();
571
+ const layoutWidth = this.isSpreadMode() ? 2 : 1;
572
+ if (this.scrollPlaneItem)
573
+ {
574
+ this.scrollPlaneItem.setPosition(new OpenSeadragon.Point(0, 0), true);
575
+ this.scrollPlaneItem.setWidth(layoutWidth, true);
576
+ this.scrollPlaneItem.setHeight(totalHeight, true);
577
+ return;
578
+ }
579
+
580
+ const svg =
581
+ `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="100%" height="100%" fill="transparent"/></svg>`;
582
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
583
+ this.viewer.addTiledImage({
584
+ tileSource : {type : "image", url},
585
+ x : 0,
586
+ y : 0,
587
+ width : layoutWidth,
588
+ success : (event: any) => {
589
+ const item = event.item;
590
+ item.setOpacity(0);
591
+ item.setPosition(new OpenSeadragon.Point(0, 0), true);
592
+ item.setWidth(layoutWidth, true);
593
+ item.setHeight(totalHeight, true);
594
+ this.scrollPlaneItem = item;
595
+ }
596
+ });
597
+ }
598
+
599
+ private clearScrollPlane(): void
600
+ {
601
+ if (!this.viewer || !this.scrollPlaneItem)
602
+ {
603
+ return;
604
+ }
605
+
606
+ this.viewer.world.removeItem(this.scrollPlaneItem);
607
+ this.scrollPlaneItem = null;
608
+ }
609
+
610
+ private indicesForRange(start: number, end: number): [ number, number ]|null
611
+ {
612
+ if (this.pageOffsets.length === 0)
613
+ {
614
+ return null;
615
+ }
616
+
617
+ const startIndex = this.findIndexForOffset(start);
618
+ const endIndex = this.getRowEndIndex(this.findIndexForOffset(end));
619
+ return [ startIndex, endIndex ];
620
+ }
621
+
622
+ private findIndexForOffset(offset: number): number
623
+ {
624
+ let low = 0;
625
+ let high = this.pageOffsets.length - 1;
626
+
627
+ while (low <= high)
628
+ {
629
+ const mid = Math.floor((low + high) / 2);
630
+ const midOffset = this.pageOffsets[mid];
631
+ const midHeight = this.pageRowHeights[mid] || this.pageHeights[mid] || 1;
632
+ if (offset < midOffset)
633
+ {
634
+ high = mid - 1;
635
+ }
636
+ else if (offset > midOffset + midHeight)
637
+ {
638
+ low = mid + 1;
639
+ }
640
+ else
641
+ {
642
+ return this.getRowStartIndex(mid);
643
+ }
644
+ }
645
+
646
+ if (low >= this.pageOffsets.length)
647
+ {
648
+ return this.pageOffsets.length - 1;
649
+ }
650
+
651
+ const clamped = Math.max(0, low);
652
+ return this.getRowStartIndex(clamped);
653
+ }
654
+
655
+ public scrollToOffset(offset: number): void
656
+ {
657
+ const viewport = this.viewer?.viewport;
658
+ if (!viewport)
659
+ {
660
+ return;
661
+ }
662
+
663
+ const bounds = viewport.getBounds(true);
664
+ const width = this.isSpreadMode() ? 2 : 1;
665
+ const rect = new OpenSeadragon.Rect(0, offset, width, bounds.height);
666
+
667
+ viewport.fitBounds(rect, true);
668
+ }
669
+
670
+ public scrollToIndex(index: number): void
671
+ {
672
+ if (index < 0 || index >= this.tileSources.length || index >= this.pageOffsets.length)
673
+ {
674
+ return;
675
+ }
676
+
677
+ const offset = this.pageOffsets[index];
678
+ this.scrollToOffset(offset);
679
+ this.targetIndex = index;
680
+ this.ensurePageLoaded(index);
681
+ this.lastReportedIndex = index;
682
+ this.emitCustomEvent("diva-page-change", {index});
683
+ }
684
+
685
+ public setZoomLevel(zoom: number): void
686
+ {
687
+ const viewport = this.viewer?.viewport;
688
+ if (!viewport)
689
+ {
690
+ return;
691
+ }
692
+
693
+ const center = viewport.getCenter(true);
694
+ viewport.zoomTo(zoom, center, false);
695
+ viewport.applyConstraints();
696
+ }
697
+
698
+ public zoomBy(factor: number): void
699
+ {
700
+ const viewport = this.viewer?.viewport;
701
+ if (!viewport)
702
+ {
703
+ return;
704
+ }
705
+
706
+ const center = viewport.getCenter(true);
707
+ viewport.zoomBy(factor, center, false);
708
+ viewport.applyConstraints();
709
+ }
710
+
711
+ private handleDoubleClick(event: MouseEvent): void
712
+ {
713
+ event.preventDefault();
714
+
715
+ const viewport = this.viewer?.viewport;
716
+ if (!viewport)
717
+ {
718
+ return;
719
+ }
720
+
721
+ // matches the factor set in the elm config.
722
+ const zoomBy = event.shiftKey ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR;
723
+ const webPoint = new OpenSeadragon.Point(event.clientX, event.clientY);
724
+ const viewportPoint = viewport.pointFromPixel(webPoint);
725
+ const nextZoom = viewport.getZoom() * zoomBy;
726
+
727
+ viewport.zoomTo(nextZoom, viewportPoint, false);
728
+ viewport.applyConstraints();
729
+ }
730
+
731
+ private handleWheel(event: WheelEvent): void
732
+ {
733
+ if (!this.container)
734
+ {
735
+ return;
736
+ }
737
+ event.preventDefault();
738
+ event.stopPropagation();
739
+ event.stopImmediatePropagation();
740
+
741
+ const viewport = this.viewer?.viewport;
742
+ if (!viewport)
743
+ {
744
+ return;
745
+ }
746
+
747
+ const rect = this.container.getBoundingClientRect();
748
+ const deltaY = event.deltaY || 0;
749
+ const speed = 0.75;
750
+ const panY = rect.height > 0 ? (deltaY / rect.height) * speed : 0;
751
+
752
+ const center = viewport.getCenter(true);
753
+ const nextCenter = new OpenSeadragon.Point(this.getCenterX(), center.y + panY);
754
+
755
+ viewport.panTo(nextCenter, true);
756
+ this.recenterBoundsX();
757
+ this.clampTop();
758
+ this.clampBottom();
759
+ this.maybeEmitPageChange();
760
+ }
761
+
762
+ private lockHorizontalPan(): void
763
+ {
764
+ if (!this.isViewportInitialized)
765
+ {
766
+ return;
767
+ }
768
+
769
+ const viewport = this.viewer?.viewport;
770
+ if (!viewport)
771
+ {
772
+ return;
773
+ }
774
+
775
+ const center = viewport.getCenter(true);
776
+ viewport.panTo(new OpenSeadragon.Point(this.getCenterX(), center.y), true);
777
+ viewport.applyConstraints();
778
+ }
779
+
780
+ private recenterAfterFit(): void
781
+ {
782
+ if (!this.viewer)
783
+ {
784
+ return;
785
+ }
786
+
787
+ const handler = () => {
788
+ if (this.viewer)
789
+ {
790
+ this.viewer.removeHandler("animation-finish", handler);
791
+ }
792
+ this.recenterBoundsX();
793
+ };
794
+
795
+ if (typeof this.viewer.addOnceHandler == "function")
796
+ {
797
+ this.viewer.addOnceHandler("animation-finish", handler);
798
+ }
799
+ else
800
+ {
801
+ this.viewer.addHandler("animation-finish", handler);
802
+ }
803
+ }
804
+
805
+ private recenterBoundsX(): void
806
+ {
807
+ const viewport = this.viewer?.viewport;
808
+ if (!viewport)
809
+ {
810
+ return;
811
+ }
812
+
813
+ const center = viewport.getCenter(true);
814
+ const targetX = this.getCenterX();
815
+ if (Math.abs(center.x - targetX) < 0.0005)
816
+ {
817
+ return;
818
+ }
819
+
820
+ viewport.panTo(new OpenSeadragon.Point(targetX, center.y), true);
821
+ viewport.applyConstraints();
822
+ }
823
+
824
+ private clampTop(viewport?: OpenSeadragonType.Viewport): void
825
+ {
826
+ const vp = viewport ?? this.viewer?.viewport;
827
+ if (!vp)
828
+ {
829
+ return;
830
+ }
831
+
832
+ const bounds = vp.getBounds(true);
833
+ const topPadding = this.getTopPaddingViewport(bounds.height);
834
+ const minTop = -topPadding;
835
+ if (bounds.y >= minTop)
836
+ {
837
+ return;
838
+ }
839
+
840
+ const clampedCenterY = (bounds.height / 2) + minTop;
841
+ this.isClamping = true;
842
+ vp.panTo(new OpenSeadragon.Point(this.getCenterX(), clampedCenterY), true);
843
+ vp.applyConstraints();
844
+ this.isClamping = false;
845
+ }
846
+
847
+ private clampBottom(viewport?: OpenSeadragonType.Viewport): void
848
+ {
849
+ const vp = viewport ?? this.viewer?.viewport;
850
+ if (!vp)
851
+ {
852
+ return;
853
+ }
854
+
855
+ if (this.pageOffsets.length === 0)
856
+ {
857
+ return;
858
+ }
859
+
860
+ const totalHeight = this.getTotalHeight();
861
+
862
+ const bounds = vp.getBounds(true);
863
+ const containerHeight = this.container?.getBoundingClientRect().height || 1;
864
+ const paddingViewport = (20 / containerHeight) * bounds.height;
865
+ const maxBottom = totalHeight + paddingViewport;
866
+
867
+ const bottomEdge = bounds.y + bounds.height;
868
+
869
+ if (bottomEdge <= maxBottom)
870
+ {
871
+ return;
872
+ }
873
+
874
+ const clampedCenterY = maxBottom - bounds.height / 2;
875
+ this.isClamping = true;
876
+ vp.panTo(new OpenSeadragon.Point(this.getCenterX(), clampedCenterY), true);
877
+ vp.applyConstraints();
878
+ this.isClamping = false;
879
+ }
880
+
881
+ private maybeEmitPageChange(viewport?: OpenSeadragonType.Viewport): void
882
+ {
883
+ if (this.suppressPageChange || this.isScrollbarDragging)
884
+ {
885
+ return;
886
+ }
887
+ if (this.pageOffsets.length === 0)
888
+ {
889
+ return;
890
+ }
891
+
892
+ const vp = viewport ?? this.viewer?.viewport;
893
+ if (!vp)
894
+ {
895
+ return;
896
+ }
897
+
898
+ const center = vp.getCenter(true);
899
+ const index = this.findIndexForOffset(center.y);
900
+ if (this.lastReportedIndex === index)
901
+ {
902
+ return;
903
+ }
904
+
905
+ this.lastReportedIndex = index;
906
+ this.emitCustomEvent("diva-page-change", {index});
907
+ }
908
+
909
+ private emitPageChangeInstant(): void
910
+ {
911
+ if (this.pageOffsets.length === 0)
912
+ {
913
+ return;
914
+ }
915
+
916
+ const viewport = this.viewer?.viewport;
917
+ if (!viewport)
918
+ {
919
+ return;
920
+ }
921
+
922
+ const center = viewport.getCenter(true);
923
+ const index = this.findIndexForOffset(center.y);
924
+ this.lastReportedIndex = index;
925
+ this.emitCustomEvent("diva-page-change", {index, instant : true});
926
+ }
927
+
928
+ private flushInitialPageChange(): void
929
+ {
930
+ if (this.pageOffsets.length === 0)
931
+ {
932
+ this.suppressPageChange = false;
933
+ return;
934
+ }
935
+
936
+ if (!this.viewer || !this.viewer.viewport)
937
+ {
938
+ return;
939
+ }
940
+
941
+ const center = this.viewer.viewport.getCenter(true);
942
+ const index = this.findIndexForOffset(center.y);
943
+ this.lastReportedIndex = index;
944
+ this.suppressPageChange = false;
945
+ this.emitCustomEvent("diva-page-change", {index});
946
+ }
947
+
948
+ private maybeEmitZoomChange(viewport?: OpenSeadragonType.Viewport): void
949
+ {
950
+ if (this.suppressZoomChange)
951
+ {
952
+ return;
953
+ }
954
+ const vp = viewport ?? this.viewer?.viewport;
955
+ if (!vp)
956
+ {
957
+ return;
958
+ }
959
+
960
+ const zoom = vp.getZoom(true);
961
+ if (this.lastReportedZoom !== null && Math.abs(this.lastReportedZoom - zoom) < 0.0001)
962
+ {
963
+ return;
964
+ }
965
+
966
+ this.lastReportedZoom = zoom;
967
+ this.emitCustomEvent("diva-zoom-change", {zoom});
968
+ }
969
+
970
+ private flushInitialZoomChange(): void
971
+ {
972
+ const viewport = this.viewer?.viewport;
973
+ if (!viewport)
974
+ {
975
+ return;
976
+ }
977
+
978
+ const zoom = viewport.getZoom(true);
979
+ this.lastReportedZoom = zoom;
980
+ this.suppressZoomChange = false;
981
+ this.emitCustomEvent("diva-zoom-change", {zoom});
982
+ }
983
+
984
+ private alignTopAfterFit(): void
985
+ {
986
+ if (!this.viewer)
987
+ {
988
+ return;
989
+ }
990
+
991
+ const viewport = this.viewer.viewport;
992
+ if (!viewport)
993
+ {
994
+ return;
995
+ }
996
+
997
+ const bounds = viewport.getBounds(true);
998
+ const topPadding = this.getTopPaddingViewport(bounds.height);
999
+ const minTop = -topPadding;
1000
+ if (bounds.y <= minTop)
1001
+ {
1002
+ return;
1003
+ }
1004
+
1005
+ const center = viewport.getCenter(true);
1006
+ viewport.panTo(new OpenSeadragon.Point(center.x, (bounds.height / 2) + minTop), true);
1007
+ viewport.applyConstraints();
1008
+ }
1009
+
1010
+ private getTopPaddingViewport(viewportHeight: number): number
1011
+ {
1012
+ const containerHeight = this.container?.getBoundingClientRect().height || 1;
1013
+ return (PAGE_LABEL_TOP_PADDING_PX / containerHeight) * viewportHeight;
1014
+ }
1015
+
1016
+ private resetLoadingState(): void
1017
+ {
1018
+ if (this.loadingTimer !== null)
1019
+ {
1020
+ window.clearTimeout(this.loadingTimer);
1021
+ this.loadingTimer = null;
1022
+ }
1023
+ this.isLoading = false;
1024
+ this.emitCustomEvent("diva-loading-change", {loading : false});
1025
+ }
1026
+
1027
+ private updateLoadingState(): void
1028
+ {
1029
+ const shouldLoad = this.loadingIndexes.size > 0;
1030
+
1031
+ if (shouldLoad)
1032
+ {
1033
+ if (this.isLoading || this.loadingTimer !== null)
1034
+ {
1035
+ return;
1036
+ }
1037
+ this.loadingTimer = window.setTimeout(() => {
1038
+ this.loadingTimer = null;
1039
+ if (this.loadingIndexes.size > 0 && !this.isLoading)
1040
+ {
1041
+ this.isLoading = true;
1042
+ this.emitCustomEvent("diva-loading-change", {loading : true});
1043
+ }
1044
+ }, 300);
1045
+ return;
1046
+ }
1047
+
1048
+ if (this.loadingTimer !== null)
1049
+ {
1050
+ window.clearTimeout(this.loadingTimer);
1051
+ this.loadingTimer = null;
1052
+ }
1053
+
1054
+ if (this.isLoading)
1055
+ {
1056
+ this.isLoading = false;
1057
+ this.emitCustomEvent("diva-loading-change", {loading : false});
1058
+ }
1059
+ }
1060
+
1061
+ private applyLayoutChange(next: {mode?: "single"|"spread"|"spread-shift"; direction?: "ltr" | "rtl"}): void
1062
+ {
1063
+ const nextMode = next.mode ?? this.layoutMode;
1064
+ const nextDirection = next.direction ?? this.viewingDirection;
1065
+ if (this.layoutMode === nextMode && this.viewingDirection === nextDirection)
1066
+ {
1067
+ return;
1068
+ }
1069
+
1070
+ let anchorIndex: number|null = null;
1071
+ const viewport = this.viewer?.viewport;
1072
+ if (viewport && this.pageOffsets.length > 0)
1073
+ {
1074
+ const bounds = viewport.getBounds(true);
1075
+ const anchorOffset = bounds.y + (bounds.height * 0.5);
1076
+ anchorIndex = this.findIndexForOffset(anchorOffset);
1077
+ }
1078
+ else if (this.lastReportedIndex !== null)
1079
+ {
1080
+ anchorIndex = this.lastReportedIndex;
1081
+ }
1082
+
1083
+ this.layoutMode = nextMode;
1084
+ this.viewingDirection = nextDirection;
1085
+ this.buildOffsets();
1086
+ this.updateLoadedItemPositions();
1087
+ this.ensureScrollPlane();
1088
+
1089
+ if (anchorIndex !== null)
1090
+ {
1091
+ const clampedAnchor = Math.max(0, Math.min(anchorIndex, this.pageOffsets.length - 1));
1092
+ this.scrollToIndex(clampedAnchor);
1093
+ }
1094
+ else
1095
+ {
1096
+ this.lockHorizontalPan();
1097
+ this.recenterAfterFit();
1098
+ }
1099
+
1100
+ this.maybeLoadMore();
1101
+ }
1102
+
1103
+ private emitCustomEvent(name: string, detail: Record<string, any>): void
1104
+ {
1105
+ this.dispatchEvent(new CustomEvent(name, {detail}));
1106
+ }
1107
+
1108
+ private getCenterX(): number { return this.isSpreadMode() ? 1 : 0.5; }
1109
+
1110
+ private isSingleCanvasLayout(): boolean
1111
+ {
1112
+ return this.tileSources.length === 1 && this.pageOffsets.length === 1;
1113
+ }
1114
+
1115
+ private getTotalHeight(): number
1116
+ {
1117
+ const lastIndex = this.pageOffsets.length - 1;
1118
+ return this.pageOffsets[lastIndex] + (this.pageRowHeights[lastIndex] || 1);
1119
+ }
1120
+
1121
+ private getRowBounds(index: number): OpenSeadragonType.Rect
1122
+ {
1123
+ const yOffset = this.pageOffsets[index] || 0;
1124
+ const rowHeight = this.pageRowHeights[index] || this.pageHeights[index] || 1;
1125
+ const width = this.isSpreadMode() ? 2 : 1;
1126
+ return new OpenSeadragon.Rect(0, yOffset, width, rowHeight);
1127
+ }
1128
+
1129
+ private isSpreadMode(): boolean { return this.layoutMode !== "single"; }
1130
+
1131
+ private getRowStartIndex(index: number): number
1132
+ {
1133
+ if (!this.isSpreadMode())
1134
+ {
1135
+ return index;
1136
+ }
1137
+
1138
+ if (this.layoutMode === "spread-shift")
1139
+ {
1140
+ if (index === 0)
1141
+ {
1142
+ return 0;
1143
+ }
1144
+ return index % 2 === 1 ? index : index - 1;
1145
+ }
1146
+
1147
+ return index - (index % 2);
1148
+ }
1149
+
1150
+ private getRowEndIndex(startIndex: number): number
1151
+ {
1152
+ const maxIndex = this.pageOffsets.length - 1;
1153
+ if (!this.isSpreadMode())
1154
+ {
1155
+ return startIndex;
1156
+ }
1157
+
1158
+ if (this.layoutMode === "spread-shift" && startIndex === 0)
1159
+ {
1160
+ return 0;
1161
+ }
1162
+
1163
+ return Math.min(startIndex + 1, maxIndex);
1164
+ }
1165
+
1166
+ private createScrollbar(): void
1167
+ {
1168
+ if (!this.container)
1169
+ {
1170
+ return;
1171
+ }
1172
+
1173
+ this.scrollbarTrack = document.createElement("div");
1174
+ this.scrollbarTrack.className = "diva-scrollbar-track";
1175
+
1176
+ this.scrollbarThumb = document.createElement("div");
1177
+ this.scrollbarThumb.className = "diva-scrollbar-thumb";
1178
+
1179
+ this.scrollbarTrack.appendChild(this.scrollbarThumb);
1180
+ this.container.appendChild(this.scrollbarTrack);
1181
+
1182
+ this.setupScrollbarDrag();
1183
+ this.setupScrollbarTrackClick();
1184
+ }
1185
+
1186
+ private updateScrollbar(): void
1187
+ {
1188
+ if (!this.viewer || !this.scrollbarThumb || !this.scrollbarTrack)
1189
+ {
1190
+ return;
1191
+ }
1192
+
1193
+ if (this.pageOffsets.length === 0)
1194
+ {
1195
+ return;
1196
+ }
1197
+
1198
+ const bounds = this.viewer.viewport.getBounds(true);
1199
+ const trackHeight = this.scrollbarTrack.clientHeight;
1200
+
1201
+ const totalHeight = this.getTotalHeight();
1202
+ const viewportHeight = bounds.height;
1203
+ const scrollTop = bounds.y;
1204
+
1205
+ const thumbHeight = Math.max(30, (viewportHeight / totalHeight) * trackHeight);
1206
+
1207
+ const maxScroll = totalHeight - viewportHeight;
1208
+ const scrollProgress = maxScroll > 0 ? Math.max(0, Math.min(1, scrollTop / maxScroll)) : 0;
1209
+ const thumbTop = scrollProgress * (trackHeight - thumbHeight);
1210
+
1211
+ this.scrollbarThumb.style.height = `${thumbHeight}px`;
1212
+ this.scrollbarThumb.style.top = `${Math.max(0, thumbTop)}px`;
1213
+ }
1214
+
1215
+ private setupScrollbarDrag(): void
1216
+ {
1217
+ if (!this.scrollbarThumb || !this.scrollbarTrack)
1218
+ {
1219
+ return;
1220
+ }
1221
+
1222
+ let isDragging = false;
1223
+ let startY = 0;
1224
+ let startThumbTop = 0;
1225
+
1226
+ const onMouseDown = (e: MouseEvent): void => {
1227
+ isDragging = true;
1228
+ this.isScrollbarDragging = true;
1229
+ startY = e.clientY;
1230
+ startThumbTop = this.scrollbarThumb?.offsetTop || 0;
1231
+ e.preventDefault();
1232
+ e.stopPropagation();
1233
+ };
1234
+
1235
+ const onMouseMove = (e: MouseEvent): void => {
1236
+ if (!isDragging || !this.scrollbarTrack || !this.scrollbarThumb || !this.viewer)
1237
+ {
1238
+ return;
1239
+ }
1240
+
1241
+ const deltaY = e.clientY - startY;
1242
+ const newThumbTop = startThumbTop + deltaY;
1243
+ const trackHeight = this.scrollbarTrack.clientHeight;
1244
+ const thumbHeight = this.scrollbarThumb.clientHeight;
1245
+
1246
+ const clampedThumbTop = Math.max(0, Math.min(newThumbTop, trackHeight - thumbHeight));
1247
+ const scrollProgress = (trackHeight - thumbHeight) > 0 ? clampedThumbTop / (trackHeight - thumbHeight) : 0;
1248
+
1249
+ const totalHeight = this.getTotalHeight();
1250
+ const viewportHeight = this.viewer.viewport.getBounds(true).height;
1251
+ const maxScroll = totalHeight - viewportHeight;
1252
+ const newScrollY = scrollProgress * maxScroll;
1253
+
1254
+ this.scrollToOffset(newScrollY);
1255
+ };
1256
+
1257
+ const onMouseUp = (): void => {
1258
+ if (isDragging)
1259
+ {
1260
+ isDragging = false;
1261
+ this.isScrollbarDragging = false;
1262
+ this.emitPageChangeInstant();
1263
+ }
1264
+ };
1265
+
1266
+ this.scrollbarMouseMove = onMouseMove;
1267
+ this.scrollbarMouseUp = onMouseUp;
1268
+ this.scrollbarThumb.addEventListener("mousedown", onMouseDown);
1269
+ document.addEventListener("mousemove", onMouseMove);
1270
+ document.addEventListener("mouseup", onMouseUp);
1271
+ }
1272
+
1273
+ private setupScrollbarTrackClick(): void
1274
+ {
1275
+ if (!this.scrollbarTrack)
1276
+ {
1277
+ return;
1278
+ }
1279
+
1280
+ this.scrollbarTrack.addEventListener("click", (e: MouseEvent) => {
1281
+ if (e.target === this.scrollbarThumb || !this.viewer)
1282
+ {
1283
+ return;
1284
+ }
1285
+
1286
+ const rect = this.scrollbarTrack?.getBoundingClientRect();
1287
+ if (!rect)
1288
+ {
1289
+ return;
1290
+ }
1291
+
1292
+ const clickY = e.clientY - rect.top;
1293
+ const trackHeight = rect.height;
1294
+
1295
+ const scrollProgress = Math.max(0, Math.min(1, clickY / trackHeight));
1296
+
1297
+ const totalHeight = this.getTotalHeight();
1298
+ const viewportHeight = this.viewer.viewport.getBounds(true).height;
1299
+ const maxScroll = totalHeight - viewportHeight;
1300
+ const newScrollY = scrollProgress * maxScroll;
1301
+
1302
+ this.scrollToOffset(newScrollY);
1303
+ });
1304
+ }
1305
+ }
1306
+
1307
+ customElements.define("osd-viewer", OsdViewer);