diva.js 6.0.1 → 7.2.3

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 (133) 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 -108
  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/_site/diva.iml +0 -11
  40. package/build/diva.css +0 -554
  41. package/build/diva.css.map +0 -1
  42. package/build/diva.js +0 -9
  43. package/build/diva.js.map +0 -1
  44. package/build/plugins/download.js +0 -2
  45. package/build/plugins/download.js.map +0 -1
  46. package/build/plugins/manipulation.js +0 -2
  47. package/build/plugins/manipulation.js.map +0 -1
  48. package/build/plugins/metadata.js +0 -2
  49. package/build/plugins/metadata.js.map +0 -1
  50. package/diva.iml +0 -11
  51. package/index.html +0 -28
  52. package/karma.conf.js +0 -87
  53. package/source/css/_mixins.scss +0 -43
  54. package/source/css/_variables.scss +0 -50
  55. package/source/css/_viewer.scss +0 -462
  56. package/source/css/diva.scss +0 -15
  57. package/source/css/plugins/_manipulation.scss +0 -228
  58. package/source/css/plugins/_metadata.scss +0 -31
  59. package/source/img/adjust.svg +0 -11
  60. package/source/img/book-view.svg +0 -6
  61. package/source/img/close.svg +0 -6
  62. package/source/img/download.svg +0 -6
  63. package/source/img/from-fullscreen.svg +0 -8
  64. package/source/img/grid-fewer.svg +0 -6
  65. package/source/img/grid-more.svg +0 -6
  66. package/source/img/grid-view.svg +0 -6
  67. package/source/img/link.svg +0 -6
  68. package/source/img/metadata.svg +0 -9
  69. package/source/img/page-view.svg +0 -6
  70. package/source/img/to-fullscreen.svg +0 -11
  71. package/source/img/zoom-in.svg +0 -6
  72. package/source/img/zoom-out.svg +0 -7
  73. package/source/js/composite-image.js +0 -174
  74. package/source/js/diva-global.js +0 -7
  75. package/source/js/diva.js +0 -1543
  76. package/source/js/document-handler.js +0 -180
  77. package/source/js/document-layout.js +0 -286
  78. package/source/js/exceptions.js +0 -26
  79. package/source/js/gesture-events.js +0 -190
  80. package/source/js/grid-handler.js +0 -122
  81. package/source/js/iiif-source-adapter.js +0 -63
  82. package/source/js/image-cache.js +0 -113
  83. package/source/js/image-manifest.js +0 -157
  84. package/source/js/image-request-handler.js +0 -76
  85. package/source/js/interpolate-animation.js +0 -122
  86. package/source/js/page-layouts/book-layout.js +0 -161
  87. package/source/js/page-layouts/grid-layout.js +0 -97
  88. package/source/js/page-layouts/index.js +0 -38
  89. package/source/js/page-layouts/page-dimensions.js +0 -9
  90. package/source/js/page-layouts/singles-layout.js +0 -27
  91. package/source/js/page-overlay-manager.js +0 -102
  92. package/source/js/page-tools-overlay.js +0 -95
  93. package/source/js/parse-iiif-manifest.js +0 -302
  94. package/source/js/plugins/_filters.js +0 -679
  95. package/source/js/plugins/download.js +0 -83
  96. package/source/js/plugins/manipulation.js +0 -837
  97. package/source/js/plugins/metadata.js +0 -190
  98. package/source/js/renderer.js +0 -584
  99. package/source/js/settings-view.js +0 -30
  100. package/source/js/tile-coverage-map.js +0 -25
  101. package/source/js/toolbar.js +0 -572
  102. package/source/js/utils/dragscroll.js +0 -106
  103. package/source/js/utils/elt.js +0 -94
  104. package/source/js/utils/events.js +0 -190
  105. package/source/js/utils/get-scrollbar-width.js +0 -29
  106. package/source/js/utils/hash-params.js +0 -86
  107. package/source/js/utils/parse-label-value.js +0 -34
  108. package/source/js/utils/vanilla.kinetic.js +0 -527
  109. package/source/js/validation-runner.js +0 -177
  110. package/source/js/viewer-core.js +0 -1505
  111. package/source/js/viewport.js +0 -143
  112. package/test/_setup.js +0 -13
  113. package/test/composite-image_test.js +0 -94
  114. package/test/diva_test.js +0 -43
  115. package/test/hash-params_test.js +0 -221
  116. package/test/image-cache_test.js +0 -106
  117. package/test/main.js +0 -6
  118. package/test/manifests/beromunsterManifest.json +0 -15514
  119. package/test/manifests/iiifv2.json +0 -11032
  120. package/test/manifests/iiifv2pages.json +0 -30437
  121. package/test/manifests/iiifv3.json +0 -10965
  122. package/test/navigation_test.js +0 -355
  123. package/test/parse-iiif-manifest_test.js +0 -68
  124. package/test/public_test.js +0 -881
  125. package/test/settings_test.js +0 -487
  126. package/test/utils/book-layout_test.js +0 -148
  127. package/test/utils/elt_test.js +0 -102
  128. package/test/utils/events_test.js +0 -245
  129. package/test/utils/hash-params_test.js +0 -79
  130. package/test/utils/parse-label-value_test.js +0 -45
  131. package/test/z_plugins_test.js +0 -180
  132. package/webpack.config.js +0 -58
  133. package/webpack.config.test.js +0 -45
package/src/diva.ts ADDED
@@ -0,0 +1,802 @@
1
+ import "./viewer-element";
2
+
3
+ // @ts-ignore
4
+ import divaCss from "../cache/diva.css";
5
+ // @ts-ignore
6
+ import {Elm} from "../cache/elm-esm.js";
7
+
8
+ import {Filters, setFilterOptions} from "./filters";
9
+
10
+ declare const OpenSeadragon: any;
11
+
12
+ const DIVA_STYLE_ID = "diva-inline-styles";
13
+
14
+ const injectStyles = (cssText: string) => {
15
+ if (typeof document === "undefined")
16
+ {
17
+ return;
18
+ }
19
+
20
+ if (document.getElementById(DIVA_STYLE_ID))
21
+ {
22
+ return;
23
+ }
24
+
25
+ const styleEl = document.createElement("style");
26
+ styleEl.id = DIVA_STYLE_ID;
27
+ styleEl.textContent = cssText;
28
+
29
+ const target = document.head || document.getElementsByTagName("head")[0] || document.documentElement;
30
+ target.appendChild(styleEl);
31
+ };
32
+
33
+ injectStyles(divaCss);
34
+
35
+ type FilterSettings = {
36
+ rotation?: number;
37
+ flip?: boolean;
38
+ thresholdEnabled?: boolean;
39
+ threshold?: number;
40
+ brightnessEnabled?: boolean;
41
+ brightness?: number;
42
+ saturationEnabled?: boolean;
43
+ saturation?: number;
44
+ vibranceEnabled?: boolean;
45
+ vibrance?: number;
46
+ hueEnabled?: boolean;
47
+ hue?: number;
48
+ ccRedEnabled?: boolean;
49
+ ccRed?: number;
50
+ ccGreenEnabled?: boolean;
51
+ ccGreen?: number;
52
+ ccBlueEnabled?: boolean;
53
+ ccBlue?: number;
54
+ contrastEnabled?: boolean;
55
+ contrast?: number;
56
+ gammaEnabled?: boolean;
57
+ gamma?: number;
58
+ grayscale?: boolean;
59
+ invert?: boolean;
60
+ morphEnabled?: boolean;
61
+ morphOperation?: string;
62
+ morphKernel?: number;
63
+ convolutionEnabled?: boolean;
64
+ convolutionPreset?: string;
65
+ colourmapEnabled?: boolean;
66
+ colourmapPreset?: string;
67
+ colourmapCenter?: number;
68
+ pseudoColourEnabled?: boolean;
69
+ pseudoColourMode?: string;
70
+ pseudoColourRed?: number;
71
+ pseudoColourGreen?: number;
72
+ pseudoColourBlue?: number;
73
+ globalPcaEnabled?: boolean;
74
+ pcaMode?: string;
75
+ pcaHue?: number;
76
+ colourReplaceEnabled?: boolean;
77
+ colourReplaceSource?: string;
78
+ colourReplaceTarget?: string;
79
+ colourReplaceTolerance?: number;
80
+ colourReplaceBlend?: number;
81
+ colourReplacePreserveLum?: boolean;
82
+ normalizeEnabled?: boolean;
83
+ normalizeStrength?: number;
84
+ unsharpEnabled?: boolean;
85
+ unsharpAmount?: number;
86
+ adaptiveEnabled?: boolean;
87
+ adaptiveWindow?: number;
88
+ adaptiveOffset?: number;
89
+ altRedGamma?: number;
90
+ altRedGammaEnabled?: boolean;
91
+ altRedSigmoid?: number;
92
+ altRedSigmoidEnabled?: boolean;
93
+ altRedHue?: number;
94
+ altRedHueEnabled?: boolean;
95
+ altRedHueWindow?: number;
96
+ altGreenGamma?: number;
97
+ altGreenGammaEnabled?: boolean;
98
+ altGreenSigmoid?: number;
99
+ altGreenSigmoidEnabled?: boolean;
100
+ altGreenHue?: number;
101
+ altGreenHueEnabled?: boolean;
102
+ altGreenHueWindow?: number;
103
+ altGreenVibranceEnabled?: boolean;
104
+ altGreenVibrance?: number;
105
+ altBlueGamma?: number;
106
+ altBlueGammaEnabled?: boolean;
107
+ altBlueSigmoid?: number;
108
+ altBlueSigmoidEnabled?: boolean;
109
+ altBlueHue?: number;
110
+ altBlueHueEnabled?: boolean;
111
+ altBlueHueWindow?: number;
112
+ altBlueVibranceEnabled?: boolean;
113
+ altBlueVibrance?: number;
114
+ altRedVibrance?: number;
115
+ altRedVibranceEnabled?: boolean;
116
+ };
117
+
118
+ type FilterPreviewPayload = {
119
+ tileSource: string; aspect : number;
120
+ filters?: FilterSettings;
121
+ };
122
+
123
+ type ElmPorts = {
124
+ tileSourcesUpdated: {subscribe: (callback: (tileSources: string[]) => void) => void};
125
+ pageAspectsUpdated : {subscribe : (callback: (aspects: number[]) => void) => void};
126
+ pageLabelsUpdated : {subscribe : (callback: (labels: string[]) => void) => void};
127
+ zoomLevelUpdated : {subscribe : (callback: (zoom: number) => void) => void};
128
+ zoomBy : {subscribe : (callback: (factor: number) => void) => void};
129
+ scrollToIndex : {subscribe : (callback: (index: number) => void) => void};
130
+ filterPreviewUpdated : {subscribe : (callback: (payload: FilterPreviewPayload|null) => void) => void};
131
+ setFullscreen : {subscribe : (callback: (enabled: boolean) => void) => void};
132
+ saveFilteredImage : {subscribe : (callback: () => void) => void};
133
+ layoutModeUpdated : {subscribe : (callback: (mode: string) => void) => void};
134
+ layoutConfigUpdated : {subscribe : (callback: (config: {mode: string; direction : string}) => void) => void};
135
+ pageIndexChanged : {send : (index: number) => void};
136
+ pageIndexChangedInstant : {send : (index: number) => void};
137
+ fullscreenChanged : {send : (enabled: boolean) => void};
138
+ zoomChanged : {send : (zoom: number) => void};
139
+ viewerLoadingChanged : {send : (loading: boolean) => void};
140
+ copyToClipboard : {subscribe : (callback: (text: string) => void) => void};
141
+ };
142
+
143
+ type ElmApp = {
144
+ ports: ElmPorts;
145
+ };
146
+
147
+ type DivaFlags = {
148
+ objectData: string;
149
+ acceptHeaders?: string[];
150
+ showSidebar?: boolean;
151
+ showTitle?: boolean;
152
+ setLanguage?: string;
153
+ };
154
+
155
+ type DivaRoot = HTMLElement&
156
+ {
157
+ elmTree?: unknown;
158
+ __divaInstance?: Diva;
159
+ };
160
+
161
+ class Diva
162
+ {
163
+ private readonly root: HTMLElement;
164
+ private app: ElmApp;
165
+ private mainViewer: any = null;
166
+ private filterViewer: any = null;
167
+ private filterViewerElement: HTMLElement|null = null;
168
+ private filterOptions: FilterSettings|null = null;
169
+ private filterViewerFlipped = false;
170
+ private currentFilterTileSource: string|null = null;
171
+ private pendingFilterPreview: FilterPreviewPayload|null = null;
172
+ private filterPreviewRetries = 0;
173
+ private filterPreviewRafId: number|null = null;
174
+ private isDestroyed = false;
175
+ private readonly handlePageChangeBound: (event: Event) => void;
176
+ private readonly handleZoomChangeBound: (event: Event) => void;
177
+ private readonly handleLoadingChangeBound: (event: Event) => void;
178
+ private readonly handleFullscreenChangeBound: () => void;
179
+
180
+ constructor(rootId: string, flags: DivaFlags)
181
+ {
182
+ const root = document.getElementById(rootId);
183
+ if (!root)
184
+ {
185
+ throw new Error(`Missing root element: ${rootId}`);
186
+ }
187
+ const rootAny = root as DivaRoot;
188
+
189
+ if (rootAny.__divaInstance)
190
+ {
191
+ rootAny.__divaInstance.destroy();
192
+ }
193
+
194
+ // if an elmTree instance is already defined on this element, destroy
195
+ // it.
196
+ if (rootAny.elmTree)
197
+ {
198
+ delete rootAny.elmTree;
199
+ root.innerHTML = "";
200
+ }
201
+
202
+ this.root = root;
203
+ this.isDestroyed = false;
204
+
205
+ this.handlePageChangeBound = this.handlePageChange.bind(this);
206
+ this.handleZoomChangeBound = this.handleZoomChange.bind(this);
207
+ this.handleLoadingChangeBound = this.handleLoadingChange.bind(this);
208
+ this.handleFullscreenChangeBound = this.handleFullscreenChange.bind(this);
209
+
210
+ let langCode = this.detectLanguage();
211
+
212
+ this.app = Elm.Main.init({
213
+ node : root,
214
+ flags : {
215
+ rootElementId : rootId,
216
+ objectData : flags.objectData,
217
+ acceptHeaders : flags.acceptHeaders || [],
218
+ showSidebar : flags.showSidebar !== false,
219
+ showTitle : flags.showTitle !== false,
220
+ userLanguage : flags.setLanguage || langCode
221
+ }
222
+ });
223
+ rootAny.__divaInstance = this;
224
+
225
+ this.bindPorts();
226
+ this.bindPageChange();
227
+ this.bindFullscreenChange();
228
+ this.bindZoomChange();
229
+ this.bindLoadingChange();
230
+ }
231
+
232
+ /**
233
+ * Detects the current locale of the browser,
234
+ * and return the first part of it.
235
+ *
236
+ * @returns {string}
237
+ */
238
+ private detectLanguage(): string { return navigator.language.split("-")[0]; }
239
+
240
+ private bindPorts(): void
241
+ {
242
+ this.getPort("tileSourcesUpdated")
243
+ .subscribe((tileSources: string[]) => { this.callViewerMethod("setTileSources", tileSources); });
244
+
245
+ this.getPort("pageAspectsUpdated")
246
+ .subscribe((aspects: number[]) => { this.callViewerMethod("setPageAspects", aspects); });
247
+
248
+ this.getPort("pageLabelsUpdated")
249
+ .subscribe((labels: string[]) => { this.callViewerMethod("setPageLabels", labels); });
250
+
251
+ this.getPort("zoomLevelUpdated").subscribe((zoom: number) => { this.callViewerMethod("setZoomLevel", zoom); });
252
+
253
+ this.getPort("zoomBy").subscribe((factor: number) => { this.callViewerMethod("zoomBy", factor); });
254
+
255
+ this.getPort("scrollToIndex").subscribe((index: number) => { this.callViewerMethod("scrollToIndex", index); });
256
+
257
+ this.getPort("filterPreviewUpdated").subscribe((payload: FilterPreviewPayload|null) => {
258
+ if (!payload)
259
+ {
260
+ return;
261
+ }
262
+ this.pendingFilterPreview = payload;
263
+ this.applyFilterPreview();
264
+ });
265
+
266
+ this.getPort("setFullscreen").subscribe((enabled: boolean) => { this.setFullscreen(enabled); });
267
+
268
+ this.getPort("saveFilteredImage").subscribe(() => { this.saveFilteredImage(); });
269
+
270
+ this.getPort("layoutConfigUpdated").subscribe((config: {mode: string; direction : string}) => {
271
+ if (this.callViewerMethod("setLayoutConfig", config.mode, config.direction))
272
+ {
273
+ return;
274
+ }
275
+ this.callViewerMethod("setViewingDirection", config.direction);
276
+ this.callViewerMethod("setLayoutMode", config.mode);
277
+ });
278
+
279
+ this.getPort("layoutModeUpdated")
280
+ .subscribe((mode: string) => { this.callViewerMethod("setLayoutMode", mode); });
281
+
282
+ this.getPort("copyToClipboard").subscribe((text: string) => { this.copyToClipboard(text); });
283
+ }
284
+
285
+ private ensureMainViewer(): any
286
+ {
287
+ if (!this.mainViewer)
288
+ {
289
+ this.mainViewer = document.getElementById("main-viewer");
290
+ }
291
+ return this.mainViewer;
292
+ }
293
+
294
+ private applyFilterPreview(): void
295
+ {
296
+ if (this.isDestroyed)
297
+ {
298
+ return;
299
+ }
300
+ if (!this.pendingFilterPreview)
301
+ {
302
+ return;
303
+ }
304
+ const element = this.ensureFilterViewerElement();
305
+ if (!element)
306
+ {
307
+ if (this.filterPreviewRetries < 10)
308
+ {
309
+ this.filterPreviewRetries += 1;
310
+ this.filterPreviewRafId = requestAnimationFrame(() => {
311
+ this.filterPreviewRafId = null;
312
+ this.applyFilterPreview();
313
+ });
314
+ }
315
+ return;
316
+ }
317
+
318
+ const payload = this.pendingFilterPreview;
319
+ this.pendingFilterPreview = null;
320
+ this.filterPreviewRetries = 0;
321
+ this.filterOptions = payload.filters || null;
322
+ this.ensureFilterViewer();
323
+ if (this.filterViewer)
324
+ {
325
+ const tileSourceChanged = this.currentFilterTileSource !== payload.tileSource;
326
+ if (tileSourceChanged)
327
+ {
328
+ this.currentFilterTileSource = payload.tileSource;
329
+ this.filterViewer.open(payload.tileSource);
330
+ }
331
+ else
332
+ {
333
+ this.applyFilterOptions();
334
+ }
335
+ }
336
+ }
337
+
338
+ private copyToClipboard(text: string): void
339
+ {
340
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === "function")
341
+ {
342
+ navigator.clipboard.writeText(text).catch(() => {});
343
+ return;
344
+ }
345
+ }
346
+
347
+ private bindPageChange(): void
348
+ {
349
+ this.bindViewerEvent("diva-page-change", this.handlePageChangeBound as EventListener);
350
+ }
351
+
352
+ private bindFullscreenChange(): void
353
+ {
354
+ document.addEventListener("fullscreenchange", this.handleFullscreenChangeBound);
355
+ }
356
+
357
+ private bindZoomChange(): void
358
+ {
359
+ this.bindViewerEvent("diva-zoom-change", this.handleZoomChangeBound as EventListener);
360
+ }
361
+
362
+ private bindLoadingChange(): void
363
+ {
364
+ this.bindViewerEvent("diva-loading-change", this.handleLoadingChangeBound as EventListener);
365
+ }
366
+
367
+ public destroy(): void
368
+ {
369
+ this.isDestroyed = true;
370
+ if (this.filterPreviewRafId !== null)
371
+ {
372
+ cancelAnimationFrame(this.filterPreviewRafId);
373
+ this.filterPreviewRafId = null;
374
+ }
375
+ this.removeViewerEvent("diva-page-change", this.handlePageChangeBound as EventListener);
376
+ this.removeViewerEvent("diva-zoom-change", this.handleZoomChangeBound as EventListener);
377
+ this.removeViewerEvent("diva-loading-change", this.handleLoadingChangeBound as EventListener);
378
+ document.removeEventListener("fullscreenchange", this.handleFullscreenChangeBound);
379
+ if (this.filterViewer && typeof this.filterViewer.destroy === "function")
380
+ {
381
+ this.filterViewer.destroy();
382
+ }
383
+ this.filterViewer = null;
384
+ this.filterViewerElement = null;
385
+ this.currentFilterTileSource = null;
386
+ this.pendingFilterPreview = null;
387
+
388
+ if (this.root)
389
+ {
390
+ const rootAny = this.root as DivaRoot;
391
+ if (rootAny.__divaInstance === this)
392
+ {
393
+ delete rootAny.__divaInstance;
394
+ }
395
+ if (rootAny.elmTree)
396
+ {
397
+ delete rootAny.elmTree;
398
+ }
399
+ this.root.innerHTML = "";
400
+ }
401
+ }
402
+
403
+ private setFullscreen(enabled: boolean): void
404
+ {
405
+ if (enabled)
406
+ {
407
+ if (document.fullscreenElement || !document.fullscreenEnabled)
408
+ {
409
+ return;
410
+ }
411
+ this.root.requestFullscreen().catch(() => {});
412
+ return;
413
+ }
414
+
415
+ if (!document.fullscreenElement)
416
+ {
417
+ return;
418
+ }
419
+ document.exitFullscreen().catch(() => {});
420
+ }
421
+
422
+ private handlePageChange(event: Event): void
423
+ {
424
+ const detail = (event as CustomEvent).detail;
425
+ if (!detail || typeof detail.index !== "number")
426
+ {
427
+ return;
428
+ }
429
+ if (detail.instant)
430
+ {
431
+ this.getPort("pageIndexChangedInstant").send(detail.index);
432
+ }
433
+ else
434
+ {
435
+ this.getPort("pageIndexChanged").send(detail.index);
436
+ }
437
+ }
438
+
439
+ private handleZoomChange(event: Event): void
440
+ {
441
+ const detail = (event as CustomEvent).detail;
442
+ if (!detail || typeof detail.zoom !== "number")
443
+ {
444
+ return;
445
+ }
446
+ this.getPort("zoomChanged").send(detail.zoom);
447
+ }
448
+
449
+ private handleLoadingChange(event: Event): void
450
+ {
451
+ const detail = (event as CustomEvent).detail;
452
+ if (!detail || typeof detail.loading !== "boolean")
453
+ {
454
+ return;
455
+ }
456
+ this.getPort("viewerLoadingChanged").send(detail.loading);
457
+ }
458
+
459
+ private handleFullscreenChange(): void
460
+ {
461
+ const isFullscreen = Boolean(document.fullscreenElement);
462
+ this.getPort("fullscreenChanged").send(isFullscreen);
463
+ }
464
+
465
+ private ensureFilterViewer(): void
466
+ {
467
+ if (this.filterViewer || !this.filterViewerElement)
468
+ {
469
+ return;
470
+ }
471
+
472
+ this.filterViewer = OpenSeadragon({
473
+ element : this.filterViewerElement,
474
+ showNavigationControl : false,
475
+ preserveViewport : true,
476
+ visibilityRatio : 0,
477
+ drawer : "canvas",
478
+ crossOriginPolicy : "Anonymous",
479
+ loadTilesWithAjax : true,
480
+ ajaxWithCredentials : false
481
+ });
482
+
483
+ this.filterViewer.addHandler("open", () => {
484
+ const drawer = this.filterViewer?.drawer as any;
485
+ const canvas = drawer && drawer.canvas ? drawer.canvas : null;
486
+ if (canvas && typeof canvas.getContext === "function")
487
+ {
488
+ const context = canvas.getContext("2d", {willReadFrequently : true});
489
+ if (context)
490
+ {
491
+ drawer.context = context;
492
+ }
493
+ }
494
+ if (this.filterViewer && this.filterViewer.viewport)
495
+ {
496
+ this.filterViewer.viewport.fitBounds(this.filterViewer.world.getHomeBounds(), true);
497
+ }
498
+ this.filterViewerFlipped = false;
499
+ this.applyFilterOptions();
500
+ });
501
+ }
502
+
503
+ private ensureFilterViewerElement(): HTMLElement|null
504
+ {
505
+ const element = document.getElementById("filter-viewer");
506
+ if (!element)
507
+ {
508
+ this.filterViewerElement = null;
509
+ return null;
510
+ }
511
+
512
+ if (this.filterViewerElement !== element)
513
+ {
514
+ if (this.filterViewer && typeof this.filterViewer.destroy === "function")
515
+ {
516
+ this.filterViewer.destroy();
517
+ }
518
+ this.filterViewer = null;
519
+ this.currentFilterTileSource = null;
520
+ this.filterViewerFlipped = false;
521
+ this.filterViewerElement = element;
522
+ }
523
+
524
+ return this.filterViewerElement;
525
+ }
526
+
527
+ private applyFilterOptions(): void
528
+ {
529
+ if (!this.filterViewer)
530
+ {
531
+ return;
532
+ }
533
+ const options = buildFilterOptions(this.filterOptions);
534
+ setFilterOptions(this.filterViewer, options);
535
+
536
+ if (this.filterViewer.viewport)
537
+ {
538
+ const rotation = this.filterOptions?.rotation || 0;
539
+ this.filterViewer.viewport.setRotation(rotation);
540
+
541
+ const shouldFlip = Boolean(this.filterOptions?.flip);
542
+ const viewport = this.filterViewer.viewport;
543
+ if (typeof viewport.toggleFlip === "function")
544
+ {
545
+ let isFlipped = this.filterViewerFlipped;
546
+ if (typeof viewport.getFlip === "function")
547
+ {
548
+ isFlipped = Boolean(viewport.getFlip());
549
+ }
550
+ if (shouldFlip != isFlipped)
551
+ {
552
+ viewport.toggleFlip();
553
+ this.filterViewerFlipped = shouldFlip;
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ private saveFilteredImage(): void
560
+ {
561
+ if (!this.filterViewer)
562
+ {
563
+ return;
564
+ }
565
+ const drawer = (this.filterViewer as any).drawer;
566
+ const canvas = drawer && drawer.canvas ? drawer.canvas as HTMLCanvasElement : null;
567
+ if (!canvas)
568
+ {
569
+ return;
570
+ }
571
+ try
572
+ {
573
+ const dataUrl = canvas.toDataURL("image/png");
574
+ const link = document.createElement("a");
575
+ link.href = dataUrl;
576
+ link.download = `diva-filtered-${Date.now()}.png`;
577
+ document.body.appendChild(link);
578
+ link.click();
579
+ link.remove();
580
+ }
581
+ catch (error)
582
+ {
583
+ console.error("Failed to save filtered image", error);
584
+ }
585
+ }
586
+
587
+ private getPort<Name extends keyof ElmPorts>(name: Name): ElmPorts[Name]
588
+ {
589
+ const ports = this.app.ports as Partial<ElmPorts>| undefined;
590
+ const port = ports ? ports[name] : undefined;
591
+ if (!port)
592
+ {
593
+ throw new Error(`Missing Elm port: ${String(name)}`);
594
+ }
595
+ return port as ElmPorts[Name];
596
+ }
597
+
598
+ private callViewerMethod(name: string, ...args: any[]): boolean
599
+ {
600
+ const viewer = this.ensureMainViewer();
601
+ if (!viewer)
602
+ {
603
+ return false;
604
+ }
605
+ const method = viewer[name];
606
+ if (typeof method !== "function")
607
+ {
608
+ return false;
609
+ }
610
+ method.apply(viewer, args);
611
+ return true;
612
+ }
613
+
614
+ private bindViewerEvent(name: string, handler: EventListener): void
615
+ {
616
+ const viewer = this.ensureMainViewer();
617
+ if (!viewer)
618
+ {
619
+ return;
620
+ }
621
+ viewer.removeEventListener(name, handler);
622
+ viewer.addEventListener(name, handler);
623
+ }
624
+
625
+ private removeViewerEvent(name: string, handler: EventListener): void
626
+ {
627
+ const viewer = this.ensureMainViewer();
628
+ if (!viewer)
629
+ {
630
+ return;
631
+ }
632
+ viewer.removeEventListener(name, handler);
633
+ }
634
+ }
635
+
636
+ type FilterMapping = {
637
+ enabled: keyof FilterSettings; filter : keyof typeof filterFunctions;
638
+ args?: (keyof FilterSettings)[];
639
+ defaults?: number[];
640
+ };
641
+
642
+ const filterFunctions = {
643
+ THRESHOLDING : Filters.THRESHOLDING,
644
+ BRIGHTNESS : Filters.BRIGHTNESS,
645
+ SATURATION : Filters.SATURATION,
646
+ VIBRANCE : Filters.VIBRANCE,
647
+ HUE : Filters.HUE,
648
+ CC_RED : Filters.CC_RED,
649
+ CC_GREEN : Filters.CC_GREEN,
650
+ CC_BLUE : Filters.CC_BLUE,
651
+ CONTRAST : Filters.CONTRAST,
652
+ GAMMA : Filters.GAMMA,
653
+ GREYSCALE : Filters.GREYSCALE,
654
+ INVERT : Filters.INVERT,
655
+ BACKGROUND_NORMALIZE : Filters.BACKGROUND_NORMALIZE,
656
+ UNSHARP_MASK : Filters.UNSHARP_MASK,
657
+ ALT_RED_GAMMA : Filters.ALT_RED_GAMMA,
658
+ ALT_GREEN_GAMMA : Filters.ALT_GREEN_GAMMA,
659
+ ALT_BLUE_GAMMA : Filters.ALT_BLUE_GAMMA,
660
+ ALT_RED_SIGMOID : Filters.ALT_RED_SIGMOID,
661
+ ALT_GREEN_SIGMOID : Filters.ALT_GREEN_SIGMOID,
662
+ ALT_BLUE_SIGMOID : Filters.ALT_BLUE_SIGMOID,
663
+ ALT_RED_HUE : Filters.ALT_RED_HUE,
664
+ ALT_GREEN_HUE : Filters.ALT_GREEN_HUE,
665
+ ALT_BLUE_HUE : Filters.ALT_BLUE_HUE,
666
+ ALT_RED_VIBRANCE : Filters.ALT_RED_VIBRANCE,
667
+ ALT_GREEN_VIBRANCE : Filters.ALT_GREEN_VIBRANCE,
668
+ ALT_BLUE_VIBRANCE : Filters.ALT_BLUE_VIBRANCE,
669
+ GLOBAL_PCA_COLOR : Filters.GLOBAL_PCA_COLOR,
670
+ ADAPTIVE_THRESHOLD : Filters.ADAPTIVE_THRESHOLD
671
+ };
672
+
673
+ const simpleFilterMappings: FilterMapping[] = [
674
+ {enabled : "thresholdEnabled", filter : "THRESHOLDING", args : [ "threshold" ]},
675
+ {enabled : "brightnessEnabled", filter : "BRIGHTNESS", args : [ "brightness" ]},
676
+ {enabled : "saturationEnabled", filter : "SATURATION", args : [ "saturation" ]},
677
+ {enabled : "vibranceEnabled", filter : "VIBRANCE", args : [ "vibrance" ]},
678
+ {enabled : "hueEnabled", filter : "HUE", args : [ "hue" ]},
679
+ {enabled : "ccRedEnabled", filter : "CC_RED", args : [ "ccRed" ]},
680
+ {enabled : "ccGreenEnabled", filter : "CC_GREEN", args : [ "ccGreen" ]},
681
+ {enabled : "ccBlueEnabled", filter : "CC_BLUE", args : [ "ccBlue" ]},
682
+ {enabled : "contrastEnabled", filter : "CONTRAST", args : [ "contrast" ]},
683
+ {enabled : "gammaEnabled", filter : "GAMMA", args : [ "gamma" ]},
684
+ {enabled : "grayscale", filter : "GREYSCALE"},
685
+ {enabled : "invert", filter : "INVERT"},
686
+ {enabled : "normalizeEnabled", filter : "BACKGROUND_NORMALIZE", args : [ "normalizeStrength" ]},
687
+ {enabled : "unsharpEnabled", filter : "UNSHARP_MASK", args : [ "unsharpAmount" ]},
688
+ ];
689
+
690
+ const altFilterMappings: FilterMapping[] = [
691
+ {enabled : "altRedGammaEnabled", filter : "ALT_RED_GAMMA", args : [ "altRedGamma" ]},
692
+ {enabled : "altGreenGammaEnabled", filter : "ALT_GREEN_GAMMA", args : [ "altGreenGamma" ]},
693
+ {enabled : "altBlueGammaEnabled", filter : "ALT_BLUE_GAMMA", args : [ "altBlueGamma" ]},
694
+ {enabled : "altRedSigmoidEnabled", filter : "ALT_RED_SIGMOID", args : [ "altRedSigmoid" ]},
695
+ {enabled : "altGreenSigmoidEnabled", filter : "ALT_GREEN_SIGMOID", args : [ "altGreenSigmoid" ]},
696
+ {enabled : "altBlueSigmoidEnabled", filter : "ALT_BLUE_SIGMOID", args : [ "altBlueSigmoid" ]},
697
+ {
698
+ enabled : "altRedHueEnabled",
699
+ filter : "ALT_RED_HUE",
700
+ args : [ "altRedHue", "altRedHueWindow" ],
701
+ defaults : [ 0, 8 ]
702
+ },
703
+ {
704
+ enabled : "altGreenHueEnabled",
705
+ filter : "ALT_GREEN_HUE",
706
+ args : [ "altGreenHue", "altGreenHueWindow" ],
707
+ defaults : [ 0, 8 ]
708
+ },
709
+ {
710
+ enabled : "altBlueHueEnabled",
711
+ filter : "ALT_BLUE_HUE",
712
+ args : [ "altBlueHue", "altBlueHueWindow" ],
713
+ defaults : [ 0, 8 ]
714
+ },
715
+ {enabled : "altRedVibranceEnabled", filter : "ALT_RED_VIBRANCE", args : [ "altRedVibrance" ]},
716
+ {enabled : "altGreenVibranceEnabled", filter : "ALT_GREEN_VIBRANCE", args : [ "altGreenVibrance" ]},
717
+ {enabled : "altBlueVibranceEnabled", filter : "ALT_BLUE_VIBRANCE", args : [ "altBlueVibrance" ]},
718
+ {
719
+ enabled : "adaptiveEnabled",
720
+ filter : "ADAPTIVE_THRESHOLD",
721
+ args : [ "adaptiveWindow", "adaptiveOffset" ],
722
+ defaults : [ 15, 10 ]
723
+ },
724
+ ];
725
+
726
+ const appendMappedProcessors = (processors: any[], filters: FilterSettings, mappings: FilterMapping[]): void => {
727
+ for (const mapping of mappings)
728
+ {
729
+ if (filters[mapping.enabled])
730
+ {
731
+ const filterArgs = mapping.args?.map((key, i) => filters[key] ?? (mapping.defaults?.[i] ?? 0)) ?? [];
732
+ const filterFn = filterFunctions[mapping.filter] as (...args: any[]) => any;
733
+ processors.push(filterFn(...(filterArgs as any[])));
734
+ }
735
+ }
736
+ };
737
+
738
+ const buildFilterOptions = (filters: FilterSettings|null): any => {
739
+ if (!filters)
740
+ {
741
+ return {filters : [], loadMode : "sync"};
742
+ }
743
+
744
+ const processors: any[] = [];
745
+
746
+ // Keep this order stable: simple -> special-case -> alt mappings.
747
+ appendMappedProcessors(processors, filters, simpleFilterMappings);
748
+
749
+ if (filters.morphEnabled)
750
+ {
751
+ const comparator = filters.morphOperation === "dilate" ? Math.max : Math.min;
752
+ processors.push(Filters.MORPHOLOGICAL_OPERATION(filters.morphKernel ?? 3, comparator));
753
+ }
754
+
755
+ if (filters.convolutionEnabled)
756
+ {
757
+ const kernel = Filters.CONVOLUTION_PRESET(filters.convolutionPreset || "");
758
+ if (kernel)
759
+ {
760
+ processors.push(Filters.CONVOLUTION(kernel));
761
+ }
762
+ }
763
+
764
+ if (filters.colourmapEnabled)
765
+ {
766
+ const colourmap = Filters.COLORMAP_PRESET(filters.colourmapPreset || "");
767
+ if (colourmap)
768
+ {
769
+ processors.push(Filters.COLORMAP(colourmap, filters.colourmapCenter ?? 128));
770
+ }
771
+ }
772
+
773
+ if (filters.pseudoColourEnabled)
774
+ {
775
+ processors.push(Filters.PSEUDOCOLOR(filters.pseudoColourMode || "", filters.pseudoColourRed ?? 1,
776
+ filters.pseudoColourGreen ?? 1, filters.pseudoColourBlue ?? 1));
777
+ }
778
+
779
+ if (filters.globalPcaEnabled)
780
+ {
781
+ processors.push(Filters.GLOBAL_PCA_COLOR(filters.pcaMode || "", filters.pcaHue ?? 0));
782
+ }
783
+
784
+ if (filters.colourReplaceEnabled)
785
+ {
786
+ processors.push(Filters.COLOR_REPLACE(filters.colourReplaceSource || "#ffffff",
787
+ filters.colourReplaceTarget || "#ffffff",
788
+ filters.colourReplaceTolerance ?? 24, filters.colourReplaceBlend ?? 1,
789
+ filters.colourReplacePreserveLum ?? false));
790
+ }
791
+
792
+ appendMappedProcessors(processors, filters, altFilterMappings);
793
+
794
+ if (processors.length === 0)
795
+ {
796
+ return {filters : [], loadMode : "sync"};
797
+ }
798
+
799
+ return {filters : {processors}, loadMode : "sync"};
800
+ };
801
+
802
+ (window as any).Diva = Diva;