retold-remote 0.0.23 → 0.0.26

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 (79) hide show
  1. package/css/retold-remote.css +343 -20
  2. package/docs/.nojekyll +0 -0
  3. package/docs/README.md +64 -12
  4. package/docs/_cover.md +6 -6
  5. package/docs/_sidebar.md +2 -0
  6. package/docs/_topbar.md +1 -1
  7. package/docs/_version.json +7 -0
  8. package/docs/collections.md +30 -0
  9. package/docs/css/docuserve.css +327 -0
  10. package/docs/ebook-reader.md +75 -1
  11. package/docs/image-explorer.md +62 -2
  12. package/docs/index.html +39 -0
  13. package/docs/retold-catalog.json +254 -0
  14. package/docs/retold-keyword-index.json +31216 -0
  15. package/docs/server-setup.md +122 -91
  16. package/docs/stack-launcher.md +218 -0
  17. package/docs/synology.md +585 -0
  18. package/docs/ultravisor-configuration.md +5 -5
  19. package/docs/ultravisor-integration.md +4 -2
  20. package/package.json +20 -14
  21. package/source/Pict-Application-RetoldRemote.js +22 -0
  22. package/source/RetoldRemote-ExtensionMaps.js +1 -1
  23. package/source/cli/RetoldRemote-Server-Setup.js +460 -7
  24. package/source/cli/RetoldRemote-Stack-Launcher.js +563 -0
  25. package/source/cli/RetoldRemote-Stack-Run.js +41 -0
  26. package/source/cli/commands/RetoldRemote-Command-Serve.js +129 -54
  27. package/source/providers/CollectionManager-AddItems.js +166 -0
  28. package/source/providers/Pict-Provider-GalleryNavigation.js +55 -0
  29. package/source/providers/Pict-Provider-OperationStatus.js +597 -0
  30. package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +20 -1
  31. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
  32. package/source/server/RetoldRemote-AudioWaveformService.js +49 -3
  33. package/source/server/RetoldRemote-CollectionExportService.js +763 -0
  34. package/source/server/RetoldRemote-CollectionService.js +5 -0
  35. package/source/server/RetoldRemote-EbookService.js +218 -3
  36. package/source/server/RetoldRemote-ImageService.js +221 -46
  37. package/source/server/RetoldRemote-MediaService.js +63 -4
  38. package/source/server/RetoldRemote-MetadataCache.js +25 -5
  39. package/source/server/RetoldRemote-OperationBroadcaster.js +363 -0
  40. package/source/server/RetoldRemote-SubimageService.js +680 -0
  41. package/source/server/RetoldRemote-ToolDetector.js +50 -0
  42. package/source/server/RetoldRemote-UltravisorBeacon.js +18 -3
  43. package/source/server/RetoldRemote-UltravisorDispatcher.js +65 -491
  44. package/source/server/RetoldRemote-UltravisorOperations.js +133 -20
  45. package/source/server/RetoldRemote-VideoFrameService.js +302 -9
  46. package/source/views/MediaViewer-EbookViewer.js +419 -1
  47. package/source/views/MediaViewer-PdfViewer.js +1050 -0
  48. package/source/views/PictView-Remote-AudioExplorer.js +77 -1
  49. package/source/views/PictView-Remote-CollectionsPanel.js +213 -0
  50. package/source/views/PictView-Remote-Gallery.js +365 -64
  51. package/source/views/PictView-Remote-ImageExplorer.js +1529 -44
  52. package/source/views/PictView-Remote-ImageViewer.js +2 -2
  53. package/source/views/PictView-Remote-Layout.js +58 -0
  54. package/source/views/PictView-Remote-MediaViewer.js +100 -25
  55. package/source/views/PictView-Remote-RegionsBrowser.js +554 -0
  56. package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
  57. package/source/views/PictView-Remote-TopBar.js +1 -0
  58. package/source/views/PictView-Remote-VideoExplorer.js +77 -1
  59. package/web-application/css/docuserve.css +277 -23
  60. package/web-application/css/retold-remote.css +343 -20
  61. package/web-application/docs/README.md +64 -12
  62. package/web-application/docs/_cover.md +6 -6
  63. package/web-application/docs/_sidebar.md +2 -0
  64. package/web-application/docs/_topbar.md +1 -1
  65. package/web-application/docs/collections.md +30 -0
  66. package/web-application/docs/ebook-reader.md +75 -1
  67. package/web-application/docs/image-explorer.md +62 -2
  68. package/web-application/docs/server-setup.md +122 -91
  69. package/web-application/docs/stack-launcher.md +218 -0
  70. package/web-application/docs/synology.md +585 -0
  71. package/web-application/docs/ultravisor-configuration.md +5 -5
  72. package/web-application/docs/ultravisor-integration.md +4 -2
  73. package/web-application/js/pict-docuserve.min.js +12 -12
  74. package/web-application/js/pict.min.js +2 -2
  75. package/web-application/js/pict.min.js.map +1 -1
  76. package/web-application/retold-remote.js +6596 -1784
  77. package/web-application/retold-remote.js.map +1 -1
  78. package/web-application/retold-remote.min.js +75 -23
  79. package/web-application/retold-remote.min.js.map +1 -1
@@ -23,6 +23,28 @@ class RetoldRemoteImageExplorerView extends libPictView
23
23
  this._dziData = null;
24
24
  this._osdLoaded = false;
25
25
  this._loading = false;
26
+
27
+ // Selection mode state
28
+ this._selectionMode = false;
29
+ this._selectionTracker = null;
30
+ this._selectionOverlay = null;
31
+ this._selectionRegion = null; // { X, Y, Width, Height } in image coords
32
+ this._selectionStart = null; // viewport point where drag began
33
+ this._selectionStartScreenPos = null; // screen-pixel position of press (for click-vs-drag filter)
34
+ this._savedRegions = []; // loaded from server
35
+
36
+ // Viewer-ready flag — set to true when the OSD viewer's 'open' event
37
+ // fires and the viewport coordinate math is safe to run. This MUST
38
+ // reset on every viewer destroy (including the DZI preview→tile swap)
39
+ // so the saved-region overlays get re-rendered against the new viewer.
40
+ this._viewerReady = false;
41
+
42
+ // Edit mode state (Part B)
43
+ this._editingRegionID = null;
44
+ this._editDragMode = null; // 'tl'|'tr'|'bl'|'br'|'t'|'r'|'b'|'l'|'body'|null
45
+ this._editDragStart = null; // viewport point where edit drag began
46
+ this._editOriginalRect = null; // OSD Rect captured at press time for delta math
47
+ this._editTracker = null;
26
48
  }
27
49
 
28
50
  /**
@@ -40,6 +62,36 @@ class RetoldRemoteImageExplorerView extends libPictView
40
62
  this._dziData = null;
41
63
  this._loading = false;
42
64
 
65
+ // Notify the layout so any active sidebar tab (Info, Regions, etc.)
66
+ // refreshes to this file instead of showing stale content from the
67
+ // previous file. See PictView-Remote-Layout.js#notifyCurrentFileChanged.
68
+ let tmpLayout = this.pict.views['ContentEditor-Layout'];
69
+ if (tmpLayout && typeof tmpLayout.notifyCurrentFileChanged === 'function')
70
+ {
71
+ tmpLayout.notifyCurrentFileChanged(pFilePath);
72
+ }
73
+
74
+ // Reset selection state
75
+ this._selectionMode = false;
76
+ this._selectionTracker = null;
77
+ this._selectionOverlay = null;
78
+ this._selectionRegion = null;
79
+ this._selectionStart = null;
80
+ this._selectionStartScreenPos = null;
81
+ this._savedRegions = [];
82
+ this._viewerReady = false;
83
+
84
+ // Reset any in-progress edit mode (Part B)
85
+ this._editingRegionID = null;
86
+ this._editDragMode = null;
87
+ this._editDragStart = null;
88
+ this._editOriginalRect = null;
89
+ if (this._editTracker)
90
+ {
91
+ try { this._editTracker.destroy(); } catch (pErr) { /* ignore */ }
92
+ this._editTracker = null;
93
+ }
94
+
43
95
  // Clean up existing viewer
44
96
  if (this._osdViewer)
45
97
  {
@@ -89,6 +141,7 @@ class RetoldRemoteImageExplorerView extends libPictView
89
141
  tmpHTML += '<button class="retold-remote-iex-nav-btn" onclick="pict.views[\'RetoldRemote-ImageExplorer\'].goBack()" title="Back (Esc)">&larr; Back</button>';
90
142
  tmpHTML += '<div class="retold-remote-iex-title">Image Explorer &mdash; ' + tmpFmt.escapeHTML(tmpFileName) + '</div>';
91
143
  tmpHTML += '<div class="retold-remote-iex-actions">';
144
+ tmpHTML += '<button class="retold-remote-iex-action-btn" id="RetoldRemote-IEX-SelectBtn" onclick="pict.views[\'RetoldRemote-ImageExplorer\'].toggleSelectionMode()" title="Select a region (s)">&#9986; Select</button>';
92
145
  tmpHTML += '<button class="retold-remote-iex-action-btn" onclick="pict.views[\'RetoldRemote-ImageExplorer\'].viewInBrowser()" title="View in standard viewer">&#128444; View</button>';
93
146
  tmpHTML += '</div>';
94
147
  tmpHTML += '</div>';
@@ -111,6 +164,11 @@ class RetoldRemoteImageExplorerView extends libPictView
111
164
  tmpHTML += '<button onclick="pict.views[\'RetoldRemote-ImageExplorer\'].zoomOut()" title="Zoom Out (-)">- Zoom Out</button>';
112
165
  tmpHTML += '<button onclick="pict.views[\'RetoldRemote-ImageExplorer\'].zoomHome()" title="Fit to view (0)">Fit</button>';
113
166
  tmpHTML += '<span style="flex:1;"></span>';
167
+ tmpHTML += '<span id="RetoldRemote-IEX-LabelInput" style="display:none;">';
168
+ tmpHTML += '<input type="text" id="RetoldRemote-IEX-LabelField" placeholder="Label this region\u2026" style="background:var(--retold-bg-input,#1e1e1e);color:var(--retold-text,#abb2bf);border:1px solid var(--retold-border,#3e4451);border-radius:4px;padding:2px 8px;font-size:0.78rem;width:180px;margin-right:4px;" onkeydown="if(event.key===\'Enter\'){pict.views[\'RetoldRemote-ImageExplorer\'].saveSelectionLabel();event.preventDefault();event.stopPropagation();}if(event.key===\'Escape\'){pict.views[\'RetoldRemote-ImageExplorer\'].cancelSelection();event.preventDefault();event.stopPropagation();}">';
169
+ tmpHTML += '<button onclick="pict.views[\'RetoldRemote-ImageExplorer\'].saveSelectionLabel()" style="font-size:0.75rem;padding:2px 8px;">Save</button>';
170
+ tmpHTML += '<button onclick="pict.views[\'RetoldRemote-ImageExplorer\'].cancelSelection()" style="font-size:0.75rem;padding:2px 8px;margin-left:2px;">Cancel</button>';
171
+ tmpHTML += '</span>';
114
172
  tmpHTML += '<span id="RetoldRemote-IEX-Coords" style="color:var(--retold-text-dim);font-size:0.72rem;"></span>';
115
173
  tmpHTML += '</div>';
116
174
 
@@ -129,9 +187,11 @@ class RetoldRemoteImageExplorerView extends libPictView
129
187
  }
130
188
 
131
189
  // Load OpenSeadragon, then decide whether to use simple image or DZI tiles
190
+ let tmpSelfShow = this;
132
191
  this._ensureOSDLoaded(() =>
133
192
  {
134
- this._probeAndShow(pFilePath);
193
+ tmpSelfShow._probeAndShow(pFilePath);
194
+ tmpSelfShow._loadSavedRegions(pFilePath);
135
195
  });
136
196
  }
137
197
 
@@ -392,6 +452,15 @@ class RetoldRemoteImageExplorerView extends libPictView
392
452
  }
393
453
  }
394
454
  tmpSelf._updateZoomLabel();
455
+
456
+ // Viewer is ready — mark the flag and re-render any saved regions
457
+ // that were loaded before the viewer was open. This is the single
458
+ // source of truth for "overlays render when ready" (see Part A).
459
+ tmpSelf._viewerReady = true;
460
+ if (tmpSelf._savedRegions && tmpSelf._savedRegions.length > 0)
461
+ {
462
+ tmpSelf._renderSavedRegionOverlays();
463
+ }
395
464
  });
396
465
 
397
466
  if (typeof OpenSeadragon.MouseTracker !== 'undefined')
@@ -435,8 +504,35 @@ class RetoldRemoteImageExplorerView extends libPictView
435
504
  this._initSimpleViewer(null, tmpPreviewURL);
436
505
  }
437
506
 
507
+ // Cancel any previous DZI fetch (fast navigation between big images)
508
+ this._cancelActiveDziOperation();
509
+
510
+ // Start operation tracking for the DZI generation
511
+ let tmpStatus = this.pict.providers['RetoldRemote-OperationStatus'];
512
+ let tmpOp = tmpStatus ? tmpStatus.startOperation(
513
+ {
514
+ Label: 'Generating deep-zoom tiles',
515
+ Phase: 'Reading image…',
516
+ Cancelable: true
517
+ }) : null;
518
+ if (tmpOp)
519
+ {
520
+ this._activeDziOperationId = tmpOp.OperationId;
521
+ this._activeDziAbortController = tmpOp.AbortController;
522
+ }
523
+
524
+ let tmpFetchOptions = {};
525
+ if (tmpOp && tmpOp.AbortController)
526
+ {
527
+ tmpFetchOptions.signal = tmpOp.AbortController.signal;
528
+ }
529
+ if (tmpOp)
530
+ {
531
+ tmpFetchOptions.headers = { 'X-Op-Id': tmpOp.OperationId };
532
+ }
533
+
438
534
  // 2. Generate DZI tiles in the background
439
- fetch('/api/media/dzi?path=' + pPathParam)
535
+ fetch('/api/media/dzi?path=' + pPathParam, tmpFetchOptions)
440
536
  .then((pResponse) => pResponse.json())
441
537
  .then((pResult) =>
442
538
  {
@@ -444,6 +540,10 @@ class RetoldRemoteImageExplorerView extends libPictView
444
540
 
445
541
  if (!pResult || !pResult.Success)
446
542
  {
543
+ if (tmpOp && tmpStatus)
544
+ {
545
+ tmpStatus.errorOperation(tmpOp.OperationId, { message: (pResult && pResult.Error) || 'DZI generation failed' });
546
+ }
447
547
  // DZI generation failed — the preview is already showing
448
548
  if (!tmpPreviewURL)
449
549
  {
@@ -452,16 +552,29 @@ class RetoldRemoteImageExplorerView extends libPictView
452
552
  return;
453
553
  }
454
554
 
555
+ if (tmpOp && tmpStatus)
556
+ {
557
+ tmpStatus.completeOperation(tmpOp.OperationId);
558
+ }
559
+ tmpSelf._activeDziOperationId = null;
560
+ tmpSelf._activeDziAbortController = null;
561
+
455
562
  // 3. Swap the preview for the full DZI tile viewer
456
563
  tmpSelf._dziData = pResult;
457
564
  tmpSelf._showInfo(pResult);
458
565
 
459
- // Destroy the preview viewer and replace with tile viewer
566
+ // Destroy the preview viewer and replace with tile viewer.
567
+ // Reset _viewerReady so the new viewer's open handler
568
+ // re-renders saved overlays against the new viewer instance.
569
+ // Any overlays attached to the preview viewer are gone once
570
+ // it's destroyed, so we MUST re-render from _savedRegions
571
+ // against the new viewer.
460
572
  if (tmpSelf._osdViewer)
461
573
  {
462
574
  try { tmpSelf._osdViewer.destroy(); } catch (e) { /* ignore */ }
463
575
  tmpSelf._osdViewer = null;
464
576
  }
577
+ tmpSelf._viewerReady = false;
465
578
 
466
579
  // Clear the viewer div for fresh OSD init
467
580
  let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
@@ -475,6 +588,14 @@ class RetoldRemoteImageExplorerView extends libPictView
475
588
  .catch((pError) =>
476
589
  {
477
590
  tmpSelf._loading = false;
591
+ if (pError && pError.name === 'AbortError')
592
+ {
593
+ return;
594
+ }
595
+ if (tmpOp && tmpStatus)
596
+ {
597
+ tmpStatus.errorOperation(tmpOp.OperationId, pError);
598
+ }
478
599
  // Tiles failed — the preview is already showing, leave it
479
600
  if (!tmpPreviewURL)
480
601
  {
@@ -483,6 +604,25 @@ class RetoldRemoteImageExplorerView extends libPictView
483
604
  });
484
605
  }
485
606
 
607
+ /**
608
+ * Cancel any in-flight DZI generation. Called on navigate-away and
609
+ * when launching a new explorer session.
610
+ */
611
+ _cancelActiveDziOperation()
612
+ {
613
+ if (this._activeDziAbortController)
614
+ {
615
+ try { this._activeDziAbortController.abort(); } catch (pErr) { /* ignore */ }
616
+ }
617
+ let tmpStatus = this.pict.providers['RetoldRemote-OperationStatus'];
618
+ if (this._activeDziOperationId && tmpStatus)
619
+ {
620
+ tmpStatus.cancelOperation(this._activeDziOperationId);
621
+ }
622
+ this._activeDziOperationId = null;
623
+ this._activeDziAbortController = null;
624
+ }
625
+
486
626
  /**
487
627
  * Show the info bar while the preview is displayed and tiles are generating.
488
628
  *
@@ -623,6 +763,15 @@ class RetoldRemoteImageExplorerView extends libPictView
623
763
  this._osdViewer.addHandler('open', function ()
624
764
  {
625
765
  tmpSelf._updateZoomLabel();
766
+
767
+ // Viewer is ready — mark the flag and re-render any saved regions
768
+ // that may have loaded before the tile viewer finished opening
769
+ // (or before the preview→tile swap completed). See Part A notes.
770
+ tmpSelf._viewerReady = true;
771
+ if (tmpSelf._savedRegions && tmpSelf._savedRegions.length > 0)
772
+ {
773
+ tmpSelf._renderSavedRegionOverlays();
774
+ }
626
775
  });
627
776
 
628
777
  // Mouse tracker for coordinates
@@ -736,80 +885,1416 @@ class RetoldRemoteImageExplorerView extends libPictView
736
885
  }
737
886
  }
738
887
 
888
+ // -----------------------------------------------------------------
889
+ // Selection mode — draw rectangles to create labeled subimage regions
890
+ // -----------------------------------------------------------------
891
+
739
892
  /**
740
- * Navigate back to the gallery / file listing.
893
+ * Toggle selection mode on/off.
741
894
  */
742
- goBack()
895
+ toggleSelectionMode()
743
896
  {
744
- // Destroy the OSD viewer
745
- if (this._osdViewer)
897
+ if (this._selectionMode)
746
898
  {
747
- try
899
+ this._exitSelectionMode();
900
+ }
901
+ else
902
+ {
903
+ this._enterSelectionMode();
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Enter selection mode: disable panning, install drag tracker.
909
+ */
910
+ _enterSelectionMode()
911
+ {
912
+ if (!this._osdViewer)
913
+ {
914
+ return;
915
+ }
916
+
917
+ this._selectionMode = true;
918
+
919
+ // Highlight the Select button
920
+ let tmpBtn = document.getElementById('RetoldRemote-IEX-SelectBtn');
921
+ if (tmpBtn)
922
+ {
923
+ tmpBtn.style.background = 'rgba(97, 175, 239, 0.4)';
924
+ tmpBtn.style.color = '#fff';
925
+ }
926
+
927
+ // Disable OSD panning so drag draws a selection instead
928
+ this._osdViewer.setMouseNavEnabled(false);
929
+
930
+ let tmpSelf = this;
931
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
932
+ if (!tmpViewerDiv)
933
+ {
934
+ return;
935
+ }
936
+ tmpViewerDiv.style.cursor = 'crosshair';
937
+
938
+ this._selectionTracker = new OpenSeadragon.MouseTracker(
939
+ {
940
+ element: tmpViewerDiv,
941
+ pressHandler: function (pEvent)
748
942
  {
749
- this._osdViewer.destroy();
750
- }
751
- catch (pErr)
943
+ tmpSelf._onSelectionPress(pEvent);
944
+ },
945
+ dragHandler: function (pEvent)
752
946
  {
753
- // ignore
947
+ tmpSelf._onSelectionDrag(pEvent);
948
+ },
949
+ releaseHandler: function (pEvent)
950
+ {
951
+ tmpSelf._onSelectionRelease(pEvent);
754
952
  }
755
- this._osdViewer = null;
953
+ });
954
+ }
955
+
956
+ /**
957
+ * Exit selection mode: re-enable panning, remove drag tracker.
958
+ */
959
+ _exitSelectionMode()
960
+ {
961
+ this._selectionMode = false;
962
+
963
+ let tmpBtn = document.getElementById('RetoldRemote-IEX-SelectBtn');
964
+ if (tmpBtn)
965
+ {
966
+ tmpBtn.style.background = '';
967
+ tmpBtn.style.color = '';
756
968
  }
757
969
 
758
- let tmpNav = this.pict.providers['RetoldRemote-GalleryNavigation'];
759
- if (tmpNav)
970
+ if (this._osdViewer)
760
971
  {
761
- tmpNav.closeViewer();
972
+ this._osdViewer.setMouseNavEnabled(true);
973
+ }
974
+
975
+ if (this._selectionTracker)
976
+ {
977
+ this._selectionTracker.destroy();
978
+ this._selectionTracker = null;
979
+ }
980
+
981
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
982
+ if (tmpViewerDiv)
983
+ {
984
+ tmpViewerDiv.style.cursor = '';
985
+ }
986
+
987
+ // Remove in-progress selection overlay (keep saved ones)
988
+ this._removeActiveSelectionOverlay();
989
+ this._selectionRegion = null;
990
+ this._selectionStart = null;
991
+ this._selectionStartScreenPos = null;
992
+
993
+ // Hide label input
994
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
995
+ if (tmpLabelWrap)
996
+ {
997
+ tmpLabelWrap.style.display = 'none';
998
+ }
999
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1000
+ if (tmpCoords)
1001
+ {
1002
+ tmpCoords.style.display = '';
762
1003
  }
763
1004
  }
764
1005
 
765
1006
  /**
766
- * Leave the image explorer and view the image in the standard viewer.
1007
+ * Handle the start of a selection drag.
767
1008
  */
768
- viewInBrowser()
1009
+ _onSelectionPress(pEvent)
769
1010
  {
770
- // Destroy the OSD viewer
771
- if (this._osdViewer)
1011
+ if (!this._osdViewer)
772
1012
  {
773
- try
774
- {
775
- this._osdViewer.destroy();
776
- }
777
- catch (pErr)
1013
+ return;
1014
+ }
1015
+
1016
+ // Remove any previous in-progress selection overlay
1017
+ this._removeActiveSelectionOverlay();
1018
+
1019
+ // Capture BOTH the screen-pixel position (for the click-vs-drag
1020
+ // test on release) and the viewport-space point (for the overlay
1021
+ // math during the drag). We need the screen-pixel reference to
1022
+ // filter out accidental clicks — on a high-res image the viewport
1023
+ // pixel math would mis-classify sensor-jitter as a real drag.
1024
+ this._selectionStartScreenPos = { x: pEvent.position.x, y: pEvent.position.y };
1025
+ this._selectionStart = this._osdViewer.viewport.pointFromPixel(pEvent.position);
1026
+
1027
+ // Create the selection rectangle overlay element
1028
+ let tmpOverlay = document.createElement('div');
1029
+ tmpOverlay.id = 'RetoldRemote-IEX-ActiveSelection';
1030
+ tmpOverlay.style.cssText = 'border: 2px solid rgba(97, 175, 239, 0.9); background: rgba(97, 175, 239, 0.15); pointer-events: none;';
1031
+ this._selectionOverlay = tmpOverlay;
1032
+
1033
+ // Add the overlay at zero size, will expand during drag
1034
+ this._osdViewer.addOverlay(
1035
+ {
1036
+ element: tmpOverlay,
1037
+ location: new OpenSeadragon.Rect(
1038
+ this._selectionStart.x, this._selectionStart.y, 0, 0)
1039
+ });
1040
+ }
1041
+
1042
+ /**
1043
+ * Handle selection dragging — update the rectangle size.
1044
+ */
1045
+ _onSelectionDrag(pEvent)
1046
+ {
1047
+ if (!this._osdViewer || !this._selectionStart || !this._selectionOverlay)
1048
+ {
1049
+ return;
1050
+ }
1051
+
1052
+ let tmpCurrent = this._osdViewer.viewport.pointFromPixel(pEvent.position);
1053
+ let tmpX = Math.min(this._selectionStart.x, tmpCurrent.x);
1054
+ let tmpY = Math.min(this._selectionStart.y, tmpCurrent.y);
1055
+ let tmpW = Math.abs(tmpCurrent.x - this._selectionStart.x);
1056
+ let tmpH = Math.abs(tmpCurrent.y - this._selectionStart.y);
1057
+
1058
+ this._osdViewer.updateOverlay(
1059
+ this._selectionOverlay,
1060
+ new OpenSeadragon.Rect(tmpX, tmpY, tmpW, tmpH));
1061
+ }
1062
+
1063
+ /**
1064
+ * Handle selection release — compute image-coordinate region and show label input.
1065
+ */
1066
+ _onSelectionRelease(pEvent)
1067
+ {
1068
+ if (!this._osdViewer || !this._selectionStart)
1069
+ {
1070
+ return;
1071
+ }
1072
+
1073
+ // Screen-pixel click-vs-drag filter. If the mouse moved less than
1074
+ // CLICK_THRESHOLD screen pixels between press and release, treat
1075
+ // this as a click (not a selection) and bail. This MUST be checked
1076
+ // in screen pixels, not image pixels, because on a high-resolution
1077
+ // image a 1-screen-pixel jitter can translate to 6+ image pixels,
1078
+ // which would otherwise slip past the image-pixel min-size guard
1079
+ // below and pop the label dialog on a single click.
1080
+ const CLICK_THRESHOLD = 5;
1081
+ if (this._selectionStartScreenPos)
1082
+ {
1083
+ let tmpDx = Math.abs(pEvent.position.x - this._selectionStartScreenPos.x);
1084
+ let tmpDy = Math.abs(pEvent.position.y - this._selectionStartScreenPos.y);
1085
+ if (tmpDx < CLICK_THRESHOLD && tmpDy < CLICK_THRESHOLD)
778
1086
  {
779
- // ignore
1087
+ this._removeActiveSelectionOverlay();
1088
+ this._selectionStart = null;
1089
+ this._selectionStartScreenPos = null;
1090
+ return;
780
1091
  }
781
- this._osdViewer = null;
782
1092
  }
783
1093
 
784
- let tmpViewer = this.pict.views['RetoldRemote-MediaViewer'];
785
- if (tmpViewer)
1094
+ let tmpEnd = this._osdViewer.viewport.pointFromPixel(pEvent.position);
1095
+
1096
+ // Convert viewport rectangle to image pixel coordinates
1097
+ let tmpVpX = Math.min(this._selectionStart.x, tmpEnd.x);
1098
+ let tmpVpY = Math.min(this._selectionStart.y, tmpEnd.y);
1099
+ let tmpVpW = Math.abs(tmpEnd.x - this._selectionStart.x);
1100
+ let tmpVpH = Math.abs(tmpEnd.y - this._selectionStart.y);
1101
+
1102
+ let tmpTopLeft = this._osdViewer.viewport.viewportToImageCoordinates(
1103
+ new OpenSeadragon.Point(tmpVpX, tmpVpY));
1104
+ let tmpBottomRight = this._osdViewer.viewport.viewportToImageCoordinates(
1105
+ new OpenSeadragon.Point(tmpVpX + tmpVpW, tmpVpY + tmpVpH));
1106
+
1107
+ let tmpRegion =
786
1108
  {
787
- tmpViewer.showMedia(this._currentPath, 'image');
1109
+ X: Math.max(0, Math.round(tmpTopLeft.x)),
1110
+ Y: Math.max(0, Math.round(tmpTopLeft.y)),
1111
+ Width: Math.round(tmpBottomRight.x - tmpTopLeft.x),
1112
+ Height: Math.round(tmpBottomRight.y - tmpTopLeft.y)
1113
+ };
1114
+
1115
+ // Clamp to image dimensions
1116
+ this._clampRegionToImage(tmpRegion);
1117
+
1118
+ // Defensive net: ignore tiny selections in image pixels. The
1119
+ // screen-pixel CLICK_THRESHOLD guard above catches accidental
1120
+ // clicks; this check covers the remaining case of a drag that's
1121
+ // large in screen pixels but degenerate in image pixels (e.g.
1122
+ // the user somehow ended up dragging entirely outside the image
1123
+ // bounds so everything got clamped away).
1124
+ if (tmpRegion.Width < 5 || tmpRegion.Height < 5)
1125
+ {
1126
+ this._removeActiveSelectionOverlay();
1127
+ this._selectionStart = null;
1128
+ this._selectionStartScreenPos = null;
1129
+ return;
1130
+ }
1131
+
1132
+ this._selectionRegion = tmpRegion;
1133
+
1134
+ // Show the inline label input in the controls bar
1135
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
1136
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1137
+ if (tmpLabelWrap)
1138
+ {
1139
+ tmpLabelWrap.style.display = '';
1140
+ }
1141
+ if (tmpCoords)
1142
+ {
1143
+ tmpCoords.style.display = 'none';
1144
+ }
1145
+
1146
+ // Focus the label field
1147
+ let tmpField = document.getElementById('RetoldRemote-IEX-LabelField');
1148
+ if (tmpField)
1149
+ {
1150
+ tmpField.value = '';
1151
+ tmpField.focus();
788
1152
  }
789
1153
  }
790
1154
 
791
1155
  /**
792
- * Show an error message.
793
- *
794
- * @param {string} pMessage - Error message
1156
+ * Save a NEW selection (drawn by the user) with the entered label.
1157
+ * Renamed from saveSelectionLabel so the public saveSelectionLabel()
1158
+ * can dispatch to either this or the edit-mode PUT helper based on
1159
+ * whether _editingRegionID is set.
795
1160
  */
796
- _showError(pMessage)
1161
+ _saveNewRegionLabel()
797
1162
  {
798
- let tmpLoading = document.getElementById('RetoldRemote-IEX-Loading');
799
- if (tmpLoading)
1163
+ if (!this._selectionRegion)
800
1164
  {
801
- tmpLoading.style.display = 'none';
1165
+ return;
802
1166
  }
803
1167
 
804
- let tmpBody = document.getElementById('RetoldRemote-IEX-Body');
805
- if (tmpBody)
1168
+ let tmpField = document.getElementById('RetoldRemote-IEX-LabelField');
1169
+ let tmpLabel = tmpField ? tmpField.value.trim() : '';
1170
+
1171
+ let tmpSelf = this;
1172
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
1173
+ let tmpPathParam = tmpProvider ? tmpProvider._getPathParam(this._currentPath) : encodeURIComponent(this._currentPath);
1174
+
1175
+ fetch('/api/media/subimage-regions',
806
1176
  {
807
- let tmpFmt = this.pict.providers['RetoldRemote-FormattingUtilities'];
808
- tmpBody.innerHTML = '<div class="retold-remote-iex-error">'
809
- + '<div class="retold-remote-iex-error-message">' + tmpFmt.escapeHTML(pMessage || 'An error occurred.') + '</div>'
810
- + '<button class="retold-remote-iex-nav-btn" onclick="pict.views[\'RetoldRemote-ImageExplorer\'].goBack()">Back to Image</button>'
811
- + '</div>';
1177
+ method: 'POST',
1178
+ headers: { 'Content-Type': 'application/json' },
1179
+ body: JSON.stringify(
1180
+ {
1181
+ Path: this._currentPath,
1182
+ Region:
1183
+ {
1184
+ Label: tmpLabel,
1185
+ X: this._selectionRegion.X,
1186
+ Y: this._selectionRegion.Y,
1187
+ Width: this._selectionRegion.Width,
1188
+ Height: this._selectionRegion.Height
1189
+ }
1190
+ })
1191
+ })
1192
+ .then((pResponse) => pResponse.json())
1193
+ .then((pResult) =>
1194
+ {
1195
+ if (pResult && pResult.Success)
1196
+ {
1197
+ tmpSelf._savedRegions = pResult.Regions || [];
1198
+
1199
+ // Remove the active selection overlay and render persistent ones
1200
+ tmpSelf._removeActiveSelectionOverlay();
1201
+ tmpSelf._renderSavedRegionOverlays();
1202
+
1203
+ // Notify the user
1204
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
1205
+ if (tmpToast)
1206
+ {
1207
+ tmpToast.showToast('Subimage region saved' + (tmpLabel ? ': ' + tmpLabel : ''));
1208
+ }
1209
+
1210
+ // Update the sidebar panel if visible
1211
+ let tmpSubPanel = tmpSelf.pict.views['RetoldRemote-SubimagesPanel'];
1212
+ if (tmpSubPanel)
1213
+ {
1214
+ tmpSubPanel.render();
1215
+ }
1216
+ }
1217
+ })
1218
+ .catch((pErr) =>
1219
+ {
1220
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
1221
+ if (tmpToast)
1222
+ {
1223
+ tmpToast.showToast('Failed to save region: ' + pErr.message);
1224
+ }
1225
+ });
1226
+
1227
+ // Hide label input, show coords
1228
+ this._selectionRegion = null;
1229
+ this._selectionStart = null;
1230
+ this._selectionStartScreenPos = null;
1231
+
1232
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
1233
+ if (tmpLabelWrap)
1234
+ {
1235
+ tmpLabelWrap.style.display = 'none';
1236
+ }
1237
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1238
+ if (tmpCoords)
1239
+ {
1240
+ tmpCoords.style.display = '';
1241
+ }
1242
+ }
1243
+
1244
+ /**
1245
+ * Cancel a new in-progress selection. Renamed from cancelSelection
1246
+ * so the public cancelSelection() can dispatch to either this or
1247
+ * _exitRegionEditMode based on state.
1248
+ */
1249
+ _cancelNewSelection()
1250
+ {
1251
+ this._removeActiveSelectionOverlay();
1252
+ this._selectionRegion = null;
1253
+ this._selectionStart = null;
1254
+ this._selectionStartScreenPos = null;
1255
+
1256
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
1257
+ if (tmpLabelWrap)
1258
+ {
1259
+ tmpLabelWrap.style.display = 'none';
1260
+ }
1261
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1262
+ if (tmpCoords)
1263
+ {
1264
+ tmpCoords.style.display = '';
1265
+ }
1266
+ }
1267
+
1268
+ /**
1269
+ * Remove the active (in-progress) selection overlay.
1270
+ */
1271
+ _removeActiveSelectionOverlay()
1272
+ {
1273
+ let tmpActive = document.getElementById('RetoldRemote-IEX-ActiveSelection');
1274
+ if (tmpActive && this._osdViewer)
1275
+ {
1276
+ try
1277
+ {
1278
+ this._osdViewer.removeOverlay(tmpActive);
1279
+ }
1280
+ catch (pErr)
1281
+ {
1282
+ // May not be an overlay; just remove from DOM
1283
+ if (tmpActive.parentElement)
1284
+ {
1285
+ tmpActive.parentElement.removeChild(tmpActive);
1286
+ }
1287
+ }
1288
+ }
1289
+ this._selectionOverlay = null;
1290
+ }
1291
+
1292
+ // -----------------------------------------------------------------
1293
+ // Saved region overlays
1294
+ // -----------------------------------------------------------------
1295
+
1296
+ /**
1297
+ * Load saved subimage regions from the server.
1298
+ *
1299
+ * Stores the regions in `_savedRegions` and triggers a render IF the
1300
+ * viewer is already open. Otherwise the open handler will call the
1301
+ * render once it fires — this is the single source of truth for
1302
+ * "render when ready", avoiding the race between the regions fetch
1303
+ * and the OSD 'open' event.
1304
+ *
1305
+ * Captures pFilePath in closure so a stale async response from an
1306
+ * earlier file doesn't overwrite the regions of a later-opened file
1307
+ * during rapid navigation.
1308
+ *
1309
+ * @param {string} pFilePath - Relative file path
1310
+ */
1311
+ _loadSavedRegions(pFilePath)
1312
+ {
1313
+ let tmpSelf = this;
1314
+ let tmpRequestedPath = pFilePath;
1315
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
1316
+ let tmpPathParam = tmpProvider ? tmpProvider._getPathParam(pFilePath) : encodeURIComponent(pFilePath);
1317
+
1318
+ fetch('/api/media/subimage-regions?path=' + tmpPathParam)
1319
+ .then((pResponse) => pResponse.json())
1320
+ .then((pResult) =>
1321
+ {
1322
+ // Abort if the user has already navigated to a different file.
1323
+ if (tmpSelf._currentPath !== tmpRequestedPath)
1324
+ {
1325
+ return;
1326
+ }
1327
+ if (pResult && pResult.Success && Array.isArray(pResult.Regions))
1328
+ {
1329
+ tmpSelf._savedRegions = pResult.Regions;
1330
+ if (tmpSelf._viewerReady)
1331
+ {
1332
+ tmpSelf._renderSavedRegionOverlays();
1333
+ }
1334
+ // If not ready yet, the viewer's 'open' handler will
1335
+ // detect _savedRegions.length > 0 and render.
1336
+ }
1337
+ })
1338
+ .catch(() =>
1339
+ {
1340
+ // Silently ignore — regions are optional
1341
+ });
1342
+ }
1343
+
1344
+ /**
1345
+ * Render all saved regions as OSD overlays with colored borders and labels.
1346
+ *
1347
+ * Idempotent: safely removes any existing saved-region overlays (scoped
1348
+ * to the current viewer's container to avoid clobbering overlays in
1349
+ * other viewer instances during rapid swaps) before re-adding each
1350
+ * region. Can be called any number of times after the viewer's 'open'
1351
+ * event has fired.
1352
+ */
1353
+ _renderSavedRegionOverlays()
1354
+ {
1355
+ if (!this._osdViewer)
1356
+ {
1357
+ return;
1358
+ }
1359
+
1360
+ // Remove existing saved-region overlays scoped to this viewer's
1361
+ // container (NOT the whole document — during the DZI preview→tile
1362
+ // swap two viewer instances may briefly coexist).
1363
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
1364
+ if (tmpViewerDiv)
1365
+ {
1366
+ let tmpExisting = tmpViewerDiv.querySelectorAll('.retold-remote-iex-region-overlay');
1367
+ for (let i = 0; i < tmpExisting.length; i++)
1368
+ {
1369
+ try
1370
+ {
1371
+ this._osdViewer.removeOverlay(tmpExisting[i]);
1372
+ }
1373
+ catch (pErr)
1374
+ {
1375
+ if (tmpExisting[i].parentElement)
1376
+ {
1377
+ tmpExisting[i].parentElement.removeChild(tmpExisting[i]);
1378
+ }
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ // Render each saved region
1384
+ for (let i = 0; i < this._savedRegions.length; i++)
1385
+ {
1386
+ let tmpRegion = this._savedRegions[i];
1387
+ this._addRegionOverlay(tmpRegion);
1388
+ }
1389
+
1390
+ // If an edit is in progress, re-append drag handles to the
1391
+ // freshly-rendered overlay element. (The old overlay element was
1392
+ // destroyed above, so the handles went with it.)
1393
+ if (this._editingRegionID && tmpViewerDiv)
1394
+ {
1395
+ let tmpEditingEl = tmpViewerDiv.querySelector(
1396
+ '.retold-remote-iex-region-overlay[data-region-id="' + this._editingRegionID + '"]');
1397
+ if (tmpEditingEl)
1398
+ {
1399
+ this._appendEditHandles(tmpEditingEl);
1400
+ }
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * Add a single region overlay to the OSD viewer.
1406
+ *
1407
+ * Saved overlays use `pointer-events: auto` so the double-click edit
1408
+ * handler can fire; a `mousedown` listener calls `stopPropagation()`
1409
+ * so single-click pan still works outside the overlay rectangles.
1410
+ * See Part B (edit mode) for the full interaction model.
1411
+ *
1412
+ * @param {object} pRegion - { ID, Label, X, Y, Width, Height }
1413
+ */
1414
+ _addRegionOverlay(pRegion)
1415
+ {
1416
+ if (!this._osdViewer)
1417
+ {
1418
+ return;
1419
+ }
1420
+
1421
+ let tmpSelf = this;
1422
+ let tmpEl = document.createElement('div');
1423
+ tmpEl.className = 'retold-remote-iex-region-overlay';
1424
+ tmpEl.setAttribute('data-region-id', pRegion.ID);
1425
+ tmpEl.style.cssText = 'border: 2px solid rgba(229, 192, 123, 0.85); background: rgba(229, 192, 123, 0.08); pointer-events: auto; position: relative; cursor: pointer;';
1426
+
1427
+ // Label badge
1428
+ if (pRegion.Label)
1429
+ {
1430
+ let tmpLabelEl = document.createElement('span');
1431
+ tmpLabelEl.className = 'retold-remote-iex-region-label';
1432
+ tmpLabelEl.style.cssText = 'position:absolute;top:-1px;left:-1px;background:rgba(229,192,123,0.9);color:#282c34;font-size:0.65rem;padding:1px 5px;border-radius:0 0 3px 0;white-space:nowrap;pointer-events:none;';
1433
+ tmpLabelEl.textContent = pRegion.Label;
1434
+ tmpEl.appendChild(tmpLabelEl);
1435
+ }
1436
+
1437
+ // Highlight the currently-edited region and dim others.
1438
+ if (this._editingRegionID && this._editingRegionID === pRegion.ID)
1439
+ {
1440
+ tmpEl.style.border = '2px solid rgba(97, 175, 239, 0.95)';
1441
+ tmpEl.style.background = 'rgba(97, 175, 239, 0.15)';
1442
+ tmpEl.style.opacity = '1';
1443
+ }
1444
+ else if (this._editingRegionID)
1445
+ {
1446
+ tmpEl.style.opacity = '0.35';
1447
+ }
1448
+
1449
+ // Single mousedown on a saved overlay does NOT enter edit mode
1450
+ // (that would steal pan). Just stop propagation so OSD's click-to-
1451
+ // zoom / pan-drag doesn't happen inside the rectangle. Actual edit-
1452
+ // mode entry happens on double-click (see below).
1453
+ tmpEl.addEventListener('mousedown', function (pEvent)
1454
+ {
1455
+ // Only block propagation when NOT already in edit mode on this region.
1456
+ // When editing this region, the viewer-level edit tracker handles
1457
+ // press/drag/release on the overlay and its handles.
1458
+ if (tmpSelf._editingRegionID !== pRegion.ID)
1459
+ {
1460
+ pEvent.stopPropagation();
1461
+ }
1462
+ });
1463
+ tmpEl.addEventListener('dblclick', function (pEvent)
1464
+ {
1465
+ pEvent.stopPropagation();
1466
+ pEvent.preventDefault();
1467
+ tmpSelf._enterRegionEditMode(pRegion.ID);
1468
+ });
1469
+
1470
+ // Convert image coordinates to viewport coordinates
1471
+ let tmpImageRect = new OpenSeadragon.Rect(pRegion.X, pRegion.Y, pRegion.Width, pRegion.Height);
1472
+ let tmpViewportRect = this._osdViewer.viewport.imageToViewportRectangle(tmpImageRect);
1473
+
1474
+ this._osdViewer.addOverlay(
1475
+ {
1476
+ element: tmpEl,
1477
+ location: tmpViewportRect
1478
+ });
1479
+ }
1480
+
1481
+ /**
1482
+ * Navigate to (zoom into) a specific saved region by ID.
1483
+ *
1484
+ * @param {string} pRegionID - The region ID to navigate to
1485
+ */
1486
+ zoomToRegion(pRegionID)
1487
+ {
1488
+ if (!this._osdViewer || !this._dziData)
1489
+ {
1490
+ return;
1491
+ }
1492
+
1493
+ let tmpRegion = null;
1494
+ for (let i = 0; i < this._savedRegions.length; i++)
1495
+ {
1496
+ if (this._savedRegions[i].ID === pRegionID)
1497
+ {
1498
+ tmpRegion = this._savedRegions[i];
1499
+ break;
1500
+ }
1501
+ }
1502
+
1503
+ if (!tmpRegion)
1504
+ {
1505
+ return;
1506
+ }
1507
+
1508
+ // Convert image rect to viewport rect and fit to it
1509
+ let tmpImageRect = new OpenSeadragon.Rect(tmpRegion.X, tmpRegion.Y, tmpRegion.Width, tmpRegion.Height);
1510
+ let tmpViewportRect = this._osdViewer.viewport.imageToViewportRectangle(tmpImageRect);
1511
+ this._osdViewer.viewport.fitBounds(tmpViewportRect);
1512
+ }
1513
+
1514
+ /**
1515
+ * Delete a saved region by ID.
1516
+ *
1517
+ * @param {string} pRegionID - The region ID to delete
1518
+ */
1519
+ deleteRegion(pRegionID)
1520
+ {
1521
+ let tmpSelf = this;
1522
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
1523
+ let tmpPathParam = tmpProvider ? tmpProvider._getPathParam(this._currentPath) : encodeURIComponent(this._currentPath);
1524
+
1525
+ fetch('/api/media/subimage-regions/' + encodeURIComponent(pRegionID) + '?path=' + tmpPathParam,
1526
+ {
1527
+ method: 'DELETE'
1528
+ })
1529
+ .then((pResponse) => pResponse.json())
1530
+ .then((pResult) =>
1531
+ {
1532
+ if (pResult && pResult.Success)
1533
+ {
1534
+ tmpSelf._savedRegions = pResult.Regions || [];
1535
+ tmpSelf._renderSavedRegionOverlays();
1536
+
1537
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
1538
+ if (tmpToast)
1539
+ {
1540
+ tmpToast.showToast('Region deleted');
1541
+ }
1542
+
1543
+ // Update sidebar
1544
+ let tmpSubPanel = tmpSelf.pict.views['RetoldRemote-SubimagesPanel'];
1545
+ if (tmpSubPanel)
1546
+ {
1547
+ tmpSubPanel.render();
1548
+ }
1549
+ }
1550
+ })
1551
+ .catch(() =>
1552
+ {
1553
+ // ignore
1554
+ });
1555
+ }
1556
+
1557
+ /**
1558
+ * Get the current selection region (for use by collection add).
1559
+ *
1560
+ * @returns {object|null} The current selection or null
1561
+ */
1562
+ getActiveSelection()
1563
+ {
1564
+ return this._selectionRegion;
1565
+ }
1566
+
1567
+ /**
1568
+ * Get the saved regions array.
1569
+ *
1570
+ * @returns {Array}
1571
+ */
1572
+ getSavedRegions()
1573
+ {
1574
+ return this._savedRegions;
1575
+ }
1576
+
1577
+ // -----------------------------------------------------------------
1578
+ // Navigation
1579
+ // -----------------------------------------------------------------
1580
+
1581
+ /**
1582
+ * Navigate back to the gallery / file listing.
1583
+ */
1584
+ goBack()
1585
+ {
1586
+ // Cancel any in-flight DZI generation
1587
+ this._cancelActiveDziOperation();
1588
+
1589
+ // Clean up selection mode
1590
+ if (this._selectionMode)
1591
+ {
1592
+ this._exitSelectionMode();
1593
+ }
1594
+
1595
+ // Destroy the OSD viewer
1596
+ if (this._osdViewer)
1597
+ {
1598
+ try
1599
+ {
1600
+ this._osdViewer.destroy();
1601
+ }
1602
+ catch (pErr)
1603
+ {
1604
+ // ignore
1605
+ }
1606
+ this._osdViewer = null;
1607
+ }
1608
+
1609
+ let tmpNav = this.pict.providers['RetoldRemote-GalleryNavigation'];
1610
+ if (tmpNav)
1611
+ {
1612
+ tmpNav.closeViewer();
1613
+ }
1614
+ }
1615
+
1616
+ /**
1617
+ * Leave the image explorer and view the image in the standard viewer.
1618
+ */
1619
+ viewInBrowser()
1620
+ {
1621
+ // Cancel any in-flight DZI generation
1622
+ this._cancelActiveDziOperation();
1623
+
1624
+ // Destroy the OSD viewer
1625
+ if (this._osdViewer)
1626
+ {
1627
+ try
1628
+ {
1629
+ this._osdViewer.destroy();
1630
+ }
1631
+ catch (pErr)
1632
+ {
1633
+ // ignore
1634
+ }
1635
+ this._osdViewer = null;
1636
+ }
1637
+
1638
+ let tmpViewer = this.pict.views['RetoldRemote-MediaViewer'];
1639
+ if (tmpViewer)
1640
+ {
1641
+ tmpViewer.showMedia(this._currentPath, 'image');
1642
+ }
1643
+ }
1644
+
1645
+ /**
1646
+ * Show an error message.
1647
+ *
1648
+ * @param {string} pMessage - Error message
1649
+ */
1650
+ _showError(pMessage)
1651
+ {
1652
+ let tmpLoading = document.getElementById('RetoldRemote-IEX-Loading');
1653
+ if (tmpLoading)
1654
+ {
1655
+ tmpLoading.style.display = 'none';
1656
+ }
1657
+
1658
+ let tmpBody = document.getElementById('RetoldRemote-IEX-Body');
1659
+ if (tmpBody)
1660
+ {
1661
+ let tmpFmt = this.pict.providers['RetoldRemote-FormattingUtilities'];
1662
+ tmpBody.innerHTML = '<div class="retold-remote-iex-error">'
1663
+ + '<div class="retold-remote-iex-error-message">' + tmpFmt.escapeHTML(pMessage || 'An error occurred.') + '</div>'
1664
+ + '<button class="retold-remote-iex-nav-btn" onclick="pict.views[\'RetoldRemote-ImageExplorer\'].goBack()">Back to Image</button>'
1665
+ + '</div>';
1666
+ }
1667
+ }
1668
+
1669
+ // -----------------------------------------------------------------
1670
+ // Shared helpers
1671
+ // -----------------------------------------------------------------
1672
+
1673
+ /**
1674
+ * Clamp a region's X/Y/Width/Height to the image dimensions,
1675
+ * mutating the passed object in place. No-op if the image dimensions
1676
+ * aren't known yet.
1677
+ *
1678
+ * Reused by both the new-region release handler and the edit-mode
1679
+ * drag/resize logic so bounds-checking stays consistent.
1680
+ *
1681
+ * @param {object} pRegion - { X, Y, Width, Height } mutated in place
1682
+ */
1683
+ _clampRegionToImage(pRegion)
1684
+ {
1685
+ if (!pRegion) return;
1686
+
1687
+ // Non-negative origin
1688
+ if (pRegion.X < 0)
1689
+ {
1690
+ pRegion.Width += pRegion.X;
1691
+ pRegion.X = 0;
1692
+ }
1693
+ if (pRegion.Y < 0)
1694
+ {
1695
+ pRegion.Height += pRegion.Y;
1696
+ pRegion.Y = 0;
1697
+ }
1698
+
1699
+ // Fit within image width/height
1700
+ if (this._dziData)
1701
+ {
1702
+ if (pRegion.X + pRegion.Width > this._dziData.Width)
1703
+ {
1704
+ pRegion.Width = this._dziData.Width - pRegion.X;
1705
+ }
1706
+ if (pRegion.Y + pRegion.Height > this._dziData.Height)
1707
+ {
1708
+ pRegion.Height = this._dziData.Height - pRegion.Y;
1709
+ }
1710
+ }
1711
+
1712
+ // Note: do NOT enforce a minimum size here. Callers must do their
1713
+ // own "ignore accidental click" check in SCREEN pixels before
1714
+ // calling this helper. Enforcing a minimum here would bump a
1715
+ // zero-size selection up to the minimum and cause the caller's
1716
+ // image-pixel min-size guard to silently pass, which created the
1717
+ // bug where a single click on a high-res image popped up the
1718
+ // label dialog as if the user had dragged.
1719
+ }
1720
+
1721
+ // -----------------------------------------------------------------
1722
+ // Edit mode for saved regions (Part B)
1723
+ // -----------------------------------------------------------------
1724
+
1725
+ /**
1726
+ * Enter edit mode for a specific saved region. Highlights the region,
1727
+ * dims the others, appends drag handles, and installs drag listeners
1728
+ * for move/resize. Also populates the inline label input with the
1729
+ * region's current label so the user can edit it in place.
1730
+ *
1731
+ * Entering edit mode automatically exits new-region selection mode
1732
+ * (they're mutually exclusive).
1733
+ *
1734
+ * @param {string} pRegionID - ID of the region to edit
1735
+ */
1736
+ _enterRegionEditMode(pRegionID)
1737
+ {
1738
+ // Find the region
1739
+ let tmpRegion = null;
1740
+ for (let i = 0; i < this._savedRegions.length; i++)
1741
+ {
1742
+ if (this._savedRegions[i].ID === pRegionID)
1743
+ {
1744
+ tmpRegion = this._savedRegions[i];
1745
+ break;
1746
+ }
1747
+ }
1748
+ if (!tmpRegion)
1749
+ {
1750
+ return;
1751
+ }
1752
+
1753
+ // Mutually exclusive with new-region selection mode
1754
+ if (this._selectionMode)
1755
+ {
1756
+ this._exitSelectionMode();
1757
+ }
1758
+
1759
+ // If already editing a different region, exit that first
1760
+ if (this._editingRegionID && this._editingRegionID !== pRegionID)
1761
+ {
1762
+ this._exitRegionEditMode();
1763
+ }
1764
+
1765
+ this._editingRegionID = pRegionID;
1766
+ this._editDragMode = null;
1767
+ this._editDragStart = null;
1768
+ this._editOriginalRect = null;
1769
+
1770
+ // Disable OSD panning while in edit mode so drags on the handles
1771
+ // don't also pan the viewer.
1772
+ if (this._osdViewer)
1773
+ {
1774
+ this._osdViewer.setMouseNavEnabled(false);
1775
+ }
1776
+
1777
+ // Re-render overlays so the selected one gets highlight/other dimmed.
1778
+ // _addRegionOverlay checks this._editingRegionID for styling, and
1779
+ // _renderSavedRegionOverlays re-appends the drag handles via
1780
+ // _appendEditHandles now that this._editingRegionID is set.
1781
+ this._renderSavedRegionOverlays();
1782
+
1783
+ // Show the inline label input with the current label pre-filled
1784
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
1785
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1786
+ let tmpField = document.getElementById('RetoldRemote-IEX-LabelField');
1787
+ if (tmpLabelWrap) tmpLabelWrap.style.display = '';
1788
+ if (tmpCoords) tmpCoords.style.display = 'none';
1789
+ if (tmpField)
1790
+ {
1791
+ tmpField.value = tmpRegion.Label || '';
1792
+ // Repurpose the Save button to save the edited region rather
1793
+ // than create a new one. We swap the onclick via a flag.
1794
+ tmpField.setAttribute('data-edit-mode', '1');
1795
+ tmpField.focus();
1796
+ tmpField.select();
1797
+ }
1798
+ }
1799
+
1800
+ /**
1801
+ * Exit edit mode, cleaning up handles, restoring overlay styles,
1802
+ * and re-enabling OSD panning.
1803
+ */
1804
+ _exitRegionEditMode()
1805
+ {
1806
+ this._editingRegionID = null;
1807
+ this._editDragMode = null;
1808
+ this._editDragStart = null;
1809
+ this._editOriginalRect = null;
1810
+
1811
+ // Remove any lingering document-level drag listeners (should be
1812
+ // gone already, but be defensive)
1813
+ if (this._editDocMoveHandler)
1814
+ {
1815
+ document.removeEventListener('mousemove', this._editDocMoveHandler);
1816
+ this._editDocMoveHandler = null;
1817
+ }
1818
+ if (this._editDocUpHandler)
1819
+ {
1820
+ document.removeEventListener('mouseup', this._editDocUpHandler);
1821
+ this._editDocUpHandler = null;
1822
+ }
1823
+
1824
+ // Re-enable OSD panning
1825
+ if (this._osdViewer)
1826
+ {
1827
+ this._osdViewer.setMouseNavEnabled(true);
1828
+ }
1829
+
1830
+ // Re-render overlays to restore normal styling (no highlight/dim)
1831
+ this._renderSavedRegionOverlays();
1832
+
1833
+ // Hide the label input
1834
+ let tmpLabelWrap = document.getElementById('RetoldRemote-IEX-LabelInput');
1835
+ let tmpCoords = document.getElementById('RetoldRemote-IEX-Coords');
1836
+ let tmpField = document.getElementById('RetoldRemote-IEX-LabelField');
1837
+ if (tmpLabelWrap) tmpLabelWrap.style.display = 'none';
1838
+ if (tmpCoords) tmpCoords.style.display = '';
1839
+ if (tmpField)
1840
+ {
1841
+ tmpField.removeAttribute('data-edit-mode');
1842
+ tmpField.value = '';
1843
+ }
1844
+ }
1845
+
1846
+ /**
1847
+ * Append 8 drag handles (4 corners + 4 edges) to a region overlay.
1848
+ * Each handle has a 16×16 invisible hit area over a 6×6 visual dot
1849
+ * so they're easy to grab on both mouse and touch.
1850
+ *
1851
+ * @param {HTMLElement} pOverlayEl - The overlay element to decorate
1852
+ */
1853
+ _appendEditHandles(pOverlayEl)
1854
+ {
1855
+ let tmpSelf = this;
1856
+ let tmpHandles =
1857
+ [
1858
+ { key: 'tl', css: 'top:-8px;left:-8px;cursor:nwse-resize;' },
1859
+ { key: 'tr', css: 'top:-8px;right:-8px;cursor:nesw-resize;' },
1860
+ { key: 'bl', css: 'bottom:-8px;left:-8px;cursor:nesw-resize;' },
1861
+ { key: 'br', css: 'bottom:-8px;right:-8px;cursor:nwse-resize;' },
1862
+ { key: 't', css: 'top:-8px;left:50%;transform:translateX(-50%);cursor:ns-resize;' },
1863
+ { key: 'b', css: 'bottom:-8px;left:50%;transform:translateX(-50%);cursor:ns-resize;' },
1864
+ { key: 'l', css: 'top:50%;left:-8px;transform:translateY(-50%);cursor:ew-resize;' },
1865
+ { key: 'r', css: 'top:50%;right:-8px;transform:translateY(-50%);cursor:ew-resize;' }
1866
+ ];
1867
+
1868
+ for (let i = 0; i < tmpHandles.length; i++)
1869
+ {
1870
+ let tmpHandleInfo = tmpHandles[i];
1871
+ let tmpH = document.createElement('div');
1872
+ tmpH.className = 'retold-remote-iex-edit-handle';
1873
+ tmpH.setAttribute('data-handle', tmpHandleInfo.key);
1874
+ tmpH.style.cssText = 'position:absolute;width:16px;height:16px;pointer-events:auto;z-index:10;' + tmpHandleInfo.css;
1875
+
1876
+ // Visible dot inside the hit area
1877
+ let tmpDot = document.createElement('div');
1878
+ tmpDot.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:8px;height:8px;background:#61afef;border:1.5px solid #fff;border-radius:50%;box-shadow:0 1px 3px rgba(0,0,0,0.5);';
1879
+ tmpH.appendChild(tmpDot);
1880
+
1881
+ tmpH.addEventListener('mousedown', function (pEvent)
1882
+ {
1883
+ pEvent.stopPropagation();
1884
+ pEvent.preventDefault();
1885
+ tmpSelf._onEditHandlePress(tmpHandleInfo.key, pEvent);
1886
+ });
1887
+
1888
+ pOverlayEl.appendChild(tmpH);
1889
+ }
1890
+
1891
+ // Body drag (move) — the overlay element itself
1892
+ pOverlayEl.addEventListener('mousedown', function (pEvent)
1893
+ {
1894
+ // Only handle body clicks when clicking the overlay itself (not a child handle)
1895
+ if (pEvent.target !== pOverlayEl)
1896
+ {
1897
+ return;
1898
+ }
1899
+ if (tmpSelf._editingRegionID !== pOverlayEl.getAttribute('data-region-id'))
1900
+ {
1901
+ return;
1902
+ }
1903
+ pEvent.stopPropagation();
1904
+ pEvent.preventDefault();
1905
+ tmpSelf._onEditHandlePress('body', pEvent);
1906
+ });
1907
+
1908
+ // Make the overlay element display cursor:move while in edit mode
1909
+ pOverlayEl.style.cursor = 'move';
1910
+ }
1911
+
1912
+ /**
1913
+ * Start an edit-mode drag. Captures the starting point and the
1914
+ * overlay's current rect, installs document-level move/up listeners,
1915
+ * and tracks which handle (or body) the drag is operating on.
1916
+ *
1917
+ * @param {string} pHandleKey - 'tl'|'tr'|'bl'|'br'|'t'|'r'|'b'|'l'|'body'
1918
+ * @param {MouseEvent} pEvent
1919
+ */
1920
+ _onEditHandlePress(pHandleKey, pEvent)
1921
+ {
1922
+ if (!this._osdViewer) return;
1923
+ if (!this._editingRegionID) return;
1924
+
1925
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
1926
+ if (!tmpViewerDiv) return;
1927
+
1928
+ this._editDragMode = pHandleKey;
1929
+
1930
+ // Capture starting viewport point (converted from client coords)
1931
+ let tmpRect = tmpViewerDiv.getBoundingClientRect();
1932
+ let tmpLocalX = pEvent.clientX - tmpRect.left;
1933
+ let tmpLocalY = pEvent.clientY - tmpRect.top;
1934
+ this._editDragStart = this._osdViewer.viewport.pointFromPixel(
1935
+ new OpenSeadragon.Point(tmpLocalX, tmpLocalY));
1936
+
1937
+ // Capture the overlay's current viewport rect (from the saved region)
1938
+ let tmpRegion = this._findSavedRegion(this._editingRegionID);
1939
+ if (!tmpRegion)
1940
+ {
1941
+ this._editDragMode = null;
1942
+ return;
1943
+ }
1944
+ let tmpImageRect = new OpenSeadragon.Rect(
1945
+ tmpRegion.X, tmpRegion.Y, tmpRegion.Width, tmpRegion.Height);
1946
+ this._editOriginalRect = this._osdViewer.viewport.imageToViewportRectangle(tmpImageRect);
1947
+
1948
+ // Install document-level move + up listeners. Using native DOM
1949
+ // listeners rather than OSD MouseTracker so the drag isn't
1950
+ // constrained to the viewer div — the user can drag past the edge.
1951
+ let tmpSelf = this;
1952
+ this._editDocMoveHandler = function (pMoveEvent)
1953
+ {
1954
+ tmpSelf._onEditHandleMove(pMoveEvent);
1955
+ };
1956
+ this._editDocUpHandler = function (pUpEvent)
1957
+ {
1958
+ tmpSelf._onEditHandleRelease(pUpEvent);
1959
+ };
1960
+ document.addEventListener('mousemove', this._editDocMoveHandler);
1961
+ document.addEventListener('mouseup', this._editDocUpHandler);
1962
+ }
1963
+
1964
+ /**
1965
+ * Update the overlay rect during an edit drag.
1966
+ *
1967
+ * @param {MouseEvent} pEvent
1968
+ */
1969
+ _onEditHandleMove(pEvent)
1970
+ {
1971
+ if (!this._osdViewer || !this._editDragStart || !this._editOriginalRect || !this._editDragMode)
1972
+ {
1973
+ return;
1974
+ }
1975
+
1976
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
1977
+ if (!tmpViewerDiv) return;
1978
+
1979
+ let tmpRect = tmpViewerDiv.getBoundingClientRect();
1980
+ let tmpLocalX = pEvent.clientX - tmpRect.left;
1981
+ let tmpLocalY = pEvent.clientY - tmpRect.top;
1982
+ let tmpCurrent = this._osdViewer.viewport.pointFromPixel(
1983
+ new OpenSeadragon.Point(tmpLocalX, tmpLocalY));
1984
+
1985
+ let tmpDx = tmpCurrent.x - this._editDragStart.x;
1986
+ let tmpDy = tmpCurrent.y - this._editDragStart.y;
1987
+
1988
+ // Apply the delta to the original rect based on drag mode
1989
+ let tmpX = this._editOriginalRect.x;
1990
+ let tmpY = this._editOriginalRect.y;
1991
+ let tmpW = this._editOriginalRect.width;
1992
+ let tmpH = this._editOriginalRect.height;
1993
+
1994
+ switch (this._editDragMode)
1995
+ {
1996
+ case 'body':
1997
+ tmpX += tmpDx;
1998
+ tmpY += tmpDy;
1999
+ break;
2000
+ case 'tl':
2001
+ tmpX += tmpDx; tmpY += tmpDy; tmpW -= tmpDx; tmpH -= tmpDy;
2002
+ break;
2003
+ case 'tr':
2004
+ tmpY += tmpDy; tmpW += tmpDx; tmpH -= tmpDy;
2005
+ break;
2006
+ case 'bl':
2007
+ tmpX += tmpDx; tmpW -= tmpDx; tmpH += tmpDy;
2008
+ break;
2009
+ case 'br':
2010
+ tmpW += tmpDx; tmpH += tmpDy;
2011
+ break;
2012
+ case 't':
2013
+ tmpY += tmpDy; tmpH -= tmpDy;
2014
+ break;
2015
+ case 'b':
2016
+ tmpH += tmpDy;
2017
+ break;
2018
+ case 'l':
2019
+ tmpX += tmpDx; tmpW -= tmpDx;
2020
+ break;
2021
+ case 'r':
2022
+ tmpW += tmpDx;
2023
+ break;
2024
+ }
2025
+
2026
+ // Handle inversion: if width or height went negative, flip
2027
+ if (tmpW < 0)
2028
+ {
2029
+ tmpX += tmpW;
2030
+ tmpW = -tmpW;
2031
+ }
2032
+ if (tmpH < 0)
2033
+ {
2034
+ tmpY += tmpH;
2035
+ tmpH = -tmpH;
2036
+ }
2037
+
2038
+ // Get the overlay element and update its position
2039
+ let tmpOverlayEl = tmpViewerDiv.querySelector(
2040
+ '.retold-remote-iex-region-overlay[data-region-id="' + this._editingRegionID + '"]');
2041
+ if (tmpOverlayEl)
2042
+ {
2043
+ this._osdViewer.updateOverlay(
2044
+ tmpOverlayEl,
2045
+ new OpenSeadragon.Rect(tmpX, tmpY, tmpW, tmpH));
2046
+ }
2047
+ }
2048
+
2049
+ /**
2050
+ * Finalize an edit drag: convert viewport rect back to image coords,
2051
+ * clamp, mutate the saved region optimistically, and PUT to the server.
2052
+ */
2053
+ _onEditHandleRelease(pEvent)
2054
+ {
2055
+ // Remove document-level listeners
2056
+ if (this._editDocMoveHandler)
2057
+ {
2058
+ document.removeEventListener('mousemove', this._editDocMoveHandler);
2059
+ this._editDocMoveHandler = null;
2060
+ }
2061
+ if (this._editDocUpHandler)
2062
+ {
2063
+ document.removeEventListener('mouseup', this._editDocUpHandler);
2064
+ this._editDocUpHandler = null;
2065
+ }
2066
+
2067
+ if (!this._osdViewer || !this._editingRegionID || !this._editDragMode)
2068
+ {
2069
+ this._editDragMode = null;
2070
+ return;
2071
+ }
2072
+
2073
+ let tmpViewerDiv = document.getElementById('RetoldRemote-IEX-Viewer');
2074
+ if (!tmpViewerDiv)
2075
+ {
2076
+ this._editDragMode = null;
2077
+ return;
2078
+ }
2079
+
2080
+ let tmpOverlayEl = tmpViewerDiv.querySelector(
2081
+ '.retold-remote-iex-region-overlay[data-region-id="' + this._editingRegionID + '"]');
2082
+ if (!tmpOverlayEl)
2083
+ {
2084
+ this._editDragMode = null;
2085
+ return;
2086
+ }
2087
+
2088
+ // Get the current viewport rect from the overlay registry
2089
+ let tmpOverlayRec = this._osdViewer.getOverlayById(tmpOverlayEl);
2090
+ if (!tmpOverlayRec || !tmpOverlayRec.location)
2091
+ {
2092
+ this._editDragMode = null;
2093
+ return;
2094
+ }
2095
+
2096
+ let tmpVpRect = tmpOverlayRec.location;
2097
+ let tmpTopLeft = this._osdViewer.viewport.viewportToImageCoordinates(
2098
+ new OpenSeadragon.Point(tmpVpRect.x, tmpVpRect.y));
2099
+ let tmpBottomRight = this._osdViewer.viewport.viewportToImageCoordinates(
2100
+ new OpenSeadragon.Point(tmpVpRect.x + tmpVpRect.width, tmpVpRect.y + tmpVpRect.height));
2101
+
2102
+ let tmpNewRegion =
2103
+ {
2104
+ X: Math.round(tmpTopLeft.x),
2105
+ Y: Math.round(tmpTopLeft.y),
2106
+ Width: Math.round(tmpBottomRight.x - tmpTopLeft.x),
2107
+ Height: Math.round(tmpBottomRight.y - tmpTopLeft.y)
2108
+ };
2109
+ this._clampRegionToImage(tmpNewRegion);
2110
+
2111
+ // Find the saved region and store the previous rect for revert-on-fail
2112
+ let tmpRegion = this._findSavedRegion(this._editingRegionID);
2113
+ if (!tmpRegion)
2114
+ {
2115
+ this._editDragMode = null;
2116
+ return;
2117
+ }
2118
+ let tmpPrevious =
2119
+ {
2120
+ X: tmpRegion.X, Y: tmpRegion.Y, Width: tmpRegion.Width, Height: tmpRegion.Height
2121
+ };
2122
+
2123
+ // Only PUT if something actually changed
2124
+ if (tmpPrevious.X === tmpNewRegion.X && tmpPrevious.Y === tmpNewRegion.Y
2125
+ && tmpPrevious.Width === tmpNewRegion.Width && tmpPrevious.Height === tmpNewRegion.Height)
2126
+ {
2127
+ this._editDragMode = null;
2128
+ return;
2129
+ }
2130
+
2131
+ // Optimistic update: mutate local state and PUT in background
2132
+ tmpRegion.X = tmpNewRegion.X;
2133
+ tmpRegion.Y = tmpNewRegion.Y;
2134
+ tmpRegion.Width = tmpNewRegion.Width;
2135
+ tmpRegion.Height = tmpNewRegion.Height;
2136
+
2137
+ this._editDragMode = null;
2138
+ this._putRegionUpdate(tmpRegion.ID, {
2139
+ X: tmpNewRegion.X,
2140
+ Y: tmpNewRegion.Y,
2141
+ Width: tmpNewRegion.Width,
2142
+ Height: tmpNewRegion.Height
2143
+ }, tmpPrevious);
2144
+ }
2145
+
2146
+ /**
2147
+ * Save the label being edited (called from the Save button and Enter key
2148
+ * when data-edit-mode is set on the label field). Dispatches to either
2149
+ * the new-region saver or the edit-mode PUT based on state.
2150
+ */
2151
+ saveSelectionLabel()
2152
+ {
2153
+ // If we're in edit mode, save the label via PUT instead of creating
2154
+ // a new region.
2155
+ if (this._editingRegionID)
2156
+ {
2157
+ let tmpField = document.getElementById('RetoldRemote-IEX-LabelField');
2158
+ let tmpNewLabel = tmpField ? tmpField.value.trim() : '';
2159
+
2160
+ let tmpRegion = this._findSavedRegion(this._editingRegionID);
2161
+ if (!tmpRegion)
2162
+ {
2163
+ this._exitRegionEditMode();
2164
+ return;
2165
+ }
2166
+ let tmpPreviousLabel = tmpRegion.Label || '';
2167
+ if (tmpNewLabel === tmpPreviousLabel)
2168
+ {
2169
+ // No change — just exit edit mode
2170
+ this._exitRegionEditMode();
2171
+ return;
2172
+ }
2173
+
2174
+ // Optimistic update
2175
+ tmpRegion.Label = tmpNewLabel;
2176
+ let tmpRegionID = this._editingRegionID;
2177
+ this._exitRegionEditMode();
2178
+ this._putRegionUpdate(tmpRegionID, { Label: tmpNewLabel }, { Label: tmpPreviousLabel });
2179
+ return;
2180
+ }
2181
+
2182
+ // Not in edit mode — fall through to the original new-region save
2183
+ return this._saveNewRegionLabel();
2184
+ }
2185
+
2186
+ /**
2187
+ * PUT a partial update for a region (label or geometry). Reverts the
2188
+ * passed `pPrevious` fields on failure and shows a toast.
2189
+ *
2190
+ * @param {string} pRegionID - Region ID
2191
+ * @param {object} pFields - Fields to update (subset of Label/X/Y/Width/Height)
2192
+ * @param {object} pPrevious - Previous values for revert-on-failure
2193
+ */
2194
+ _putRegionUpdate(pRegionID, pFields, pPrevious)
2195
+ {
2196
+ let tmpSelf = this;
2197
+ let tmpBody = Object.assign({ Path: this._currentPath }, pFields);
2198
+
2199
+ fetch('/api/media/subimage-regions/' + encodeURIComponent(pRegionID),
2200
+ {
2201
+ method: 'PUT',
2202
+ headers: { 'Content-Type': 'application/json' },
2203
+ body: JSON.stringify(tmpBody)
2204
+ })
2205
+ .then((pResponse) => pResponse.json())
2206
+ .then((pResult) =>
2207
+ {
2208
+ if (!pResult || !pResult.Success)
2209
+ {
2210
+ // Revert optimistic changes
2211
+ tmpSelf._revertRegion(pRegionID, pPrevious);
2212
+ tmpSelf._renderSavedRegionOverlays();
2213
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
2214
+ if (tmpToast)
2215
+ {
2216
+ let tmpMsg = pResult && pResult.Error
2217
+ ? ('Failed to save: ' + pResult.Error)
2218
+ : 'Failed to save region update (file may have been modified).';
2219
+ tmpToast.showToast(tmpMsg);
2220
+ }
2221
+ return;
2222
+ }
2223
+ // Success — the server response contains the authoritative list
2224
+ if (Array.isArray(pResult.Regions))
2225
+ {
2226
+ tmpSelf._savedRegions = pResult.Regions;
2227
+ }
2228
+ tmpSelf._renderSavedRegionOverlays();
2229
+
2230
+ // Update sidebar — use refresh() to force a re-fetch so the
2231
+ // panel picks up the updated geometry/label instead of using
2232
+ // its stale cached _regions array.
2233
+ let tmpSubPanel = tmpSelf.pict.views['RetoldRemote-SubimagesPanel'];
2234
+ if (tmpSubPanel && typeof tmpSubPanel.refresh === 'function')
2235
+ {
2236
+ tmpSubPanel.refresh();
2237
+ }
2238
+
2239
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
2240
+ if (tmpToast)
2241
+ {
2242
+ tmpToast.showToast('Region updated');
2243
+ }
2244
+ })
2245
+ .catch((pError) =>
2246
+ {
2247
+ // Revert optimistic changes
2248
+ tmpSelf._revertRegion(pRegionID, pPrevious);
2249
+ tmpSelf._renderSavedRegionOverlays();
2250
+ let tmpToast = tmpSelf.pict.providers['RetoldRemote-ToastNotification'];
2251
+ if (tmpToast)
2252
+ {
2253
+ tmpToast.showToast('Network error saving region: ' + pError.message);
2254
+ }
2255
+ });
2256
+ }
2257
+
2258
+ /**
2259
+ * Revert in-memory region fields to a previous snapshot.
2260
+ */
2261
+ _revertRegion(pRegionID, pPrevious)
2262
+ {
2263
+ if (!pPrevious) return;
2264
+ let tmpRegion = this._findSavedRegion(pRegionID);
2265
+ if (!tmpRegion) return;
2266
+ for (let tmpKey in pPrevious)
2267
+ {
2268
+ tmpRegion[tmpKey] = pPrevious[tmpKey];
2269
+ }
2270
+ }
2271
+
2272
+ /**
2273
+ * Look up a saved region by ID.
2274
+ */
2275
+ _findSavedRegion(pRegionID)
2276
+ {
2277
+ for (let i = 0; i < this._savedRegions.length; i++)
2278
+ {
2279
+ if (this._savedRegions[i].ID === pRegionID)
2280
+ {
2281
+ return this._savedRegions[i];
2282
+ }
2283
+ }
2284
+ return null;
2285
+ }
2286
+
2287
+ /**
2288
+ * Cancel handler — also exits edit mode if active.
2289
+ */
2290
+ cancelSelection()
2291
+ {
2292
+ if (this._editingRegionID)
2293
+ {
2294
+ this._exitRegionEditMode();
2295
+ return;
812
2296
  }
2297
+ return this._cancelNewSelection();
813
2298
  }
814
2299
  }
815
2300