diva.js 6.0.2 → 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 (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
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;