plexora 0.0.1__py3-none-any.whl

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 (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1034 @@
1
+ /**
2
+ * FigureCaptureTool - the viewfinder, and turning what is inside it into a panel.
3
+ *
4
+ * The gesture used to be "drag a rectangle, get a panel". That is one action per
5
+ * panel, which is right for one capture and wrong for the thing people actually
6
+ * do: take the same field from four places in a slide. Each drag was a
7
+ * hand-drawn rectangle, so the four panels were four slightly different sizes,
8
+ * and nothing on screen said so until they were side by side in a figure.
9
+ *
10
+ * So the rectangle is now a persistent VIEWFINDER. It is defined once, stays put
11
+ * while capture mode is on, and every shot is taken through it:
12
+ *
13
+ * drag on the image redraw the viewfinder
14
+ * drag inside it move it
15
+ * drag a corner resize it
16
+ * Shift + drag square, in IMAGE pixels -- so a set of panels really is
17
+ * the same physical field, which a square in screen pixels
18
+ * on a non-square-pixel image would not be
19
+ * Space + drag pans, whatever is armed
20
+ * wheel always zooms, never intercepted
21
+ * S / the shutter takes the shot, and ends capture mode
22
+ * Esc cancels a drag, or leaves capture mode
23
+ *
24
+ * The box is held in SCREEN pixels, not image pixels, and that is the whole
25
+ * point of it: pan the image underneath and the frame stays where it is, so the
26
+ * four fields come out the same size. It is converted to image pixels at the
27
+ * moment the shutter fires, which is the only moment the answer matters, and
28
+ * what gets stored is still full-resolution image coordinates.
29
+ *
30
+ * ## Except while it is pinned
31
+ *
32
+ * Going back to a capture locks the frame onto that capture's region (lockOn),
33
+ * and while it is locked the shutter takes THAT region -- the stored numbers,
34
+ * not a fresh reading off the screen. Without the lock, "go back, change the
35
+ * channels, capture it again" gives two panels that are a pixel or two apart
36
+ * and nothing on screen says so.
37
+ *
38
+ * A locked frame FOLLOWS its region, because the lock is on the region and the
39
+ * frame is only how the region is shown: a frame sitting anywhere else would be
40
+ * the tool lying about what the next shot will be. It lets go when the user
41
+ * aims somewhere else by hand, or when the region can no longer be shown as a
42
+ * frame at all -- panned off, zoomed past, shrunk to a speck. That last rule is
43
+ * also what keeps a following frame inside the viewer, so "one frame, four
44
+ * places" survives: the moment the frame stops being locked to anything, it is
45
+ * a screen-anchored viewfinder again.
46
+ *
47
+ * Precedence is settled per event (`event.preventDefaultAction = true`) rather
48
+ * than with `viewer.setMouseNavEnabled(false)`, for the reason roiTools.js
49
+ * documents: the same drag redraws or pans depending on what is held, and a
50
+ * viewer-wide switch cannot express that without eventually being left in the
51
+ * wrong position.
52
+ *
53
+ * ## Where the viewfinder lives
54
+ *
55
+ * As a sibling of #openseadragon inside #openseadragon_wrapper, which is where
56
+ * miniMap.js puts the overview lens and for the same two reasons. OSD binds its
57
+ * mouse tracker to #openseadragon, so a drag on the frame never reaches the
58
+ * viewer and no stopPropagation is needed; and a plain <div> is not a canvas
59
+ * inside the viewer, so the frame can never be baked into the preview the way
60
+ * the old marquee could -- that hazard, and the synchronous clear() that
61
+ * answered it, are both gone.
62
+ *
63
+ * ## The preview
64
+ *
65
+ * What lands on the canvas is a crop of the canvases the viewer has ALREADY
66
+ * composited -- the tile drawer plus every overlay, in stacking order. That is
67
+ * what makes the preview WYSIWYG for free: whatever a plugin drew over the
68
+ * image is in it, without Figure Builder knowing that plugin exists, and no
69
+ * second rendering path can disagree with the first. Viewer chrome is excluded
70
+ * because it is not a canvas inside the viewer.
71
+ *
72
+ * The preview is Tier 0/1 only. Publication export re-reads the source pixels
73
+ * at the requested DPI from the scene snapshot; this raster exists so the panel
74
+ * appears instantly, so reopening a figure is fast, and so a panel whose source
75
+ * has gone still shows something.
76
+ */
77
+ class FigureCaptureTool {
78
+
79
+ /** Smallest capture worth keeping, in SCREEN pixels on a side. Below this
80
+ * it is a click that slipped, not a region anybody chose. */
81
+ static get MIN_SCREEN_SIZE() { return 12; }
82
+
83
+ /**
84
+ * The one key that fires the shutter.
85
+ *
86
+ * Enter was the obvious choice and the wrong one. Enter activates whatever
87
+ * button has focus, and by the time anybody presses it the focus is on the
88
+ * last thing they clicked -- the mode toggle, a thumbnail, "Figure Canvas".
89
+ * So it fired the shutter AND that button, or that button swallowed it and
90
+ * the shutter never fired, depending on which handler ran first and where
91
+ * the user had last put their hand. A bare letter is only ever the document
92
+ * handler's, which is why the dock's mode toggle is one too.
93
+ */
94
+ static get SHOOT_KEY() { return "s"; }
95
+
96
+ /**
97
+ * Is this keystroke the shutter?
98
+ *
99
+ * The same two guards the dock's own bare letter needs, for the same
100
+ * reasons: no modifier chord (Cmd-S is save and always will be), and
101
+ * nothing while the user is typing -- otherwise naming a figure "cross
102
+ * sections" takes three photographs.
103
+ */
104
+ static isShootKey(event) {
105
+ if (!event || event.metaKey || event.ctrlKey || event.altKey) return false;
106
+ if (typeof event.key !== "string") return false;
107
+ return event.key.toLowerCase() === FigureCaptureTool.SHOOT_KEY
108
+ && !FigureCaptureTool.isTyping();
109
+ }
110
+
111
+ /** Longest edge of a preview raster. A preview is not the master, and a
112
+ * hundred of them at full resolution is a document nobody can open. */
113
+ static get MAX_PREVIEW_EDGE() { return 1400; }
114
+
115
+ /** The frame capture mode opens with, as a fraction of the shorter side of
116
+ * the viewer, and its height as a fraction of its width. A frame that is
117
+ * already there is what makes the mode legible in the first second: the
118
+ * user sees what will be taken and adjusts it, rather than reading a hint
119
+ * about a gesture they have not made yet. */
120
+ static get DEFAULT_FRACTION() { return 0.46; }
121
+ static get DEFAULT_RATIO() { return 0.75; }
122
+
123
+ /**
124
+ * A frame in the middle of a viewer of this size.
125
+ *
126
+ * Pure and static so the arithmetic can be checked without a browser --
127
+ * see tests/js/figure_capture_probe.mjs.
128
+ */
129
+ static defaultBox(bounds) {
130
+ const width = Math.max(0, (bounds && bounds.width) || 0);
131
+ const height = Math.max(0, (bounds && bounds.height) || 0);
132
+ const w = Math.round(Math.min(width, height) * FigureCaptureTool.DEFAULT_FRACTION);
133
+ const h = Math.round(w * FigureCaptureTool.DEFAULT_RATIO);
134
+ return FigureCaptureTool.clampBox({
135
+ x: Math.round((width - w) / 2),
136
+ y: Math.round((height - h) / 2),
137
+ width: w,
138
+ height: h,
139
+ }, bounds);
140
+ }
141
+
142
+ /**
143
+ * Keep a frame inside the viewer, and no smaller than a frame can be.
144
+ *
145
+ * Fully inside rather than merely overlapping: the shutter and the corner
146
+ * handles live on the frame, and a frame half off the edge is one the user
147
+ * can see and cannot reach.
148
+ */
149
+ static clampBox(box, bounds) {
150
+ const limitW = Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, (bounds && bounds.width) || 0);
151
+ const limitH = Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, (bounds && bounds.height) || 0);
152
+ const width = Math.min(Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, box.width), limitW);
153
+ const height = Math.min(Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, box.height), limitH);
154
+ return {
155
+ x: Math.round(Math.min(Math.max(0, box.x), limitW - width)),
156
+ y: Math.round(Math.min(Math.max(0, box.y), limitH - height)),
157
+ width: Math.round(width),
158
+ height: Math.round(height),
159
+ };
160
+ }
161
+
162
+ /** The frame moved by a screen delta, still inside the viewer. */
163
+ static moveBox(box, dx, dy, bounds) {
164
+ return FigureCaptureTool.clampBox(
165
+ { x: box.x + dx, y: box.y + dy, width: box.width, height: box.height }, bounds);
166
+ }
167
+
168
+ /**
169
+ * The frame with one corner dragged.
170
+ *
171
+ * The opposite corner is the anchor and does not move, which is what makes
172
+ * a resize feel like a resize; dragging a corner past it stops at the
173
+ * minimum size rather than inverting the frame.
174
+ */
175
+ static resizeBox(box, corner, dx, dy, bounds) {
176
+ const west = corner === "nw" || corner === "sw";
177
+ const north = corner === "nw" || corner === "ne";
178
+ const right = box.x + box.width;
179
+ const bottom = box.y + box.height;
180
+
181
+ let x = west ? box.x + dx : box.x;
182
+ let y = north ? box.y + dy : box.y;
183
+ let width = west ? right - x : box.width + dx;
184
+ let height = north ? bottom - y : box.height + dy;
185
+
186
+ if (width < FigureCaptureTool.MIN_SCREEN_SIZE) {
187
+ width = FigureCaptureTool.MIN_SCREEN_SIZE;
188
+ if (west) x = right - width;
189
+ }
190
+ if (height < FigureCaptureTool.MIN_SCREEN_SIZE) {
191
+ height = FigureCaptureTool.MIN_SCREEN_SIZE;
192
+ if (north) y = bottom - height;
193
+ }
194
+ return FigureCaptureTool.clampBox({ x: x, y: y, width: width, height: height }, bounds);
195
+ }
196
+
197
+ constructor(ctx, options) {
198
+ this.ctx = ctx;
199
+ this.viewer = ctx.viewer?.viewer || null;
200
+ this.onCapture = (options && options.onCapture) || (() => {});
201
+ this.onStateChange = (options && options.onStateChange) || (() => {});
202
+ //: The frame let go of the capture it was locked to. The selection in
203
+ //: the strip and the highlight on the image are the same state as the
204
+ //: lock, so they go with it.
205
+ this.onUnpin = (options && options.onUnpin) || (() => {});
206
+
207
+ //: This plugin's name, for the one question the tool asks core: whether
208
+ //: it is the selected tool. See canRedraw().
209
+ this.toolName = (options && options.toolName) || "figure_builder";
210
+
211
+ this.armed = false;
212
+ this.dragging = false;
213
+ this.spaceHeld = false;
214
+ this.anchor = null;
215
+ //: The viewfinder, in CSS pixels relative to the viewer's canvas -- the
216
+ //: same origin toImage() and grabPreview() work in, so no conversion
217
+ //: sits between what is drawn and what is taken. Kept across a
218
+ //: disarm/arm so "same frame, four places" survives leaving the mode.
219
+ this.box = null;
220
+ //: The frame while a redraw drag is in flight, in IMAGE pixels. Kept in
221
+ //: image space until the drag ends rather than converted at the end, so
222
+ //: a pan or a zoom mid-drag cannot move the region under the pointer.
223
+ this.drawing = null;
224
+
225
+ //: The region the frame is LOCKED to, in full-resolution image pixels,
226
+ //: or null when the frame is aimed freely. A pin makes the shutter take
227
+ //: THIS region rather than whatever the frame happens to be over --
228
+ //: see pinTo().
229
+ this.pinned = null;
230
+ //: While a panel's view is on loan to the viewer, the frame is a
231
+ //: FRAMING outline for that panel rather than a shutter -- see
232
+ //: setFraming.
233
+ this.framing = false;
234
+ this.pinLabel = "";
235
+ this._pinWatch = null;
236
+
237
+
238
+ this.element = null;
239
+ this._pointer = null;
240
+ this._handlers = [];
241
+ this._onKeyDown = (event) => this.keyDown(event);
242
+ this._onKeyUp = (event) => this.keyUp(event);
243
+ // Space released outside the window never reaches keyup, so the class
244
+ // it added has to come off here too -- otherwise the frame stays
245
+ // unclickable and nothing on screen explains why.
246
+ this._onBlur = () => {
247
+ this.spaceHeld = false;
248
+ this.element?.classList.remove("is-transparent");
249
+ };
250
+ //: The viewer changes size when the canvas opens beside it, and a frame
251
+ //: that was centred is then hanging off the right-hand edge.
252
+ this._onResize = () => this.reflow();
253
+ }
254
+
255
+ get active() {
256
+ return this.armed;
257
+ }
258
+
259
+ /** The frame the next shot will be taken through, or null. */
260
+ get viewfinder() {
261
+ return this.armed ? this.box : null;
262
+ }
263
+
264
+ // -- lifecycle -------------------------------------------------------
265
+
266
+ arm() {
267
+ if (this.armed || !this.viewer) return;
268
+ this.armed = true;
269
+
270
+ const on = (name, fn) => {
271
+ this.viewer.addHandler(name, fn);
272
+ this._handlers.push([name, fn]);
273
+ };
274
+ on("canvas-press", (event) => this.press(event));
275
+ on("canvas-drag", (event) => this.drag(event));
276
+ on("canvas-drag-end", (event) => this.dragEnd(event));
277
+
278
+ document.addEventListener("keydown", this._onKeyDown);
279
+ document.addEventListener("keyup", this._onKeyUp);
280
+ window.addEventListener("blur", this._onBlur);
281
+ window.addEventListener("resize", this._onResize);
282
+
283
+ if (!this.box) this.box = FigureCaptureTool.defaultBox(this.bounds());
284
+ this.attachFrame();
285
+ this.paint();
286
+ // A pin set while the mode was off can be stale by now -- nothing stops
287
+ // the user panning away from it with the frame gone. Ask before showing
288
+ // a lock that is not one.
289
+ this.checkPin();
290
+ this.setCursor("crosshair");
291
+ this.onStateChange(true);
292
+ }
293
+
294
+ /**
295
+ * Stop capturing.
296
+ *
297
+ * Symmetrical with arm() and called on every switch away, because the
298
+ * listeners are on the VIEWER and the DOCUMENT -- neither of which goes
299
+ * away when this panel is hidden. Left attached, a drag meant for another
300
+ * tool would move a viewfinder the user cannot see.
301
+ *
302
+ * The frame's geometry is kept: leaving capture mode to adjust a channel
303
+ * and coming back should not cost the user the frame they set up.
304
+ */
305
+ disarm() {
306
+ if (!this.armed) return;
307
+ this.armed = false;
308
+ this.dragging = false;
309
+ this.drawing = null;
310
+ this.anchor = null;
311
+
312
+ for (const [name, fn] of this._handlers) this.viewer.removeHandler(name, fn);
313
+ this._handlers = [];
314
+ document.removeEventListener("keydown", this._onKeyDown);
315
+ document.removeEventListener("keyup", this._onKeyUp);
316
+ window.removeEventListener("blur", this._onBlur);
317
+ window.removeEventListener("resize", this._onResize);
318
+
319
+ this.detachFrame();
320
+ this.setCursor("");
321
+ this.onStateChange(false);
322
+ }
323
+
324
+ destroy() {
325
+ this.disarm();
326
+ this.unpin(true);
327
+ this.box = null;
328
+ }
329
+
330
+ setCursor(cursor) {
331
+ if (this.viewer?.canvas) this.viewer.canvas.style.cursor = cursor;
332
+ }
333
+
334
+ // -- the viewfinder --------------------------------------------------
335
+
336
+ /** Where plugin-owned chrome hangs: a sibling of the OSD element, never a
337
+ * child of it. See the class comment. */
338
+ host() {
339
+ return document.getElementById("openseadragon_wrapper");
340
+ }
341
+
342
+ /** The viewer's own drawing area, in CSS pixels. */
343
+ bounds() {
344
+ const rect = this.viewer?.canvas?.getBoundingClientRect?.();
345
+ return rect ? { width: rect.width, height: rect.height } : { width: 0, height: 0 };
346
+ }
347
+
348
+ /** The canvas's top-left corner within the host, so a box in canvas
349
+ * coordinates can be drawn by a sibling of the canvas. */
350
+ offset() {
351
+ const host = this.host();
352
+ const canvas = this.viewer?.canvas;
353
+ if (!host || !canvas?.getBoundingClientRect) return { x: 0, y: 0 };
354
+ const outer = host.getBoundingClientRect();
355
+ const inner = canvas.getBoundingClientRect();
356
+ return { x: inner.left - outer.left, y: inner.top - outer.top };
357
+ }
358
+
359
+ attachFrame() {
360
+ const host = this.host();
361
+ if (this.element || !host) return;
362
+
363
+ const frame = document.createElement("div");
364
+ frame.className = "fb-viewfinder";
365
+ frame.innerHTML =
366
+ '<div class="fb-viewfinder-caption"></div>'
367
+ + ["nw", "ne", "se", "sw"].map((corner) =>
368
+ `<span class="fb-vf-handle fb-vf-${corner}" data-corner="${corner}"></span>`).join("")
369
+ // Icon and key, no word. The button sits inside the frame, over the
370
+ // tissue being judged, and "Capture" written there is one more thing
371
+ // between the user and the picture -- while the key beside the icon
372
+ // is the only place the shortcut is written down at the moment
373
+ // anybody wants it. What the button DOES is on its title and its
374
+ // accessible name, which cost no pixels.
375
+ + '<button type="button" class="fb-shutter" data-role="shutter">'
376
+ + '<span class="fas fa-camera" aria-hidden="true"></span>'
377
+ + '<kbd class="fb-shutter-key">'
378
+ + FigureCaptureTool.SHOOT_KEY.toUpperCase() + '</kbd>'
379
+ + '<span class="fb-visually-hidden" data-role="shutterName">Capture</span>'
380
+ + '</button>';
381
+
382
+ frame.addEventListener("pointerdown", (event) => this.framePointerDown(event));
383
+ frame.addEventListener("pointermove", (event) => this.framePointerMove(event));
384
+ frame.addEventListener("pointerup", (event) => this.framePointerUp(event));
385
+ frame.addEventListener("pointercancel", (event) => this.framePointerUp(event));
386
+ frame.addEventListener("click", (event) => {
387
+ if (event.target.closest('[data-role="shutter"]')) this.shoot();
388
+ });
389
+
390
+ host.appendChild(frame);
391
+ this.element = frame;
392
+ }
393
+
394
+ detachFrame() {
395
+ this.element?.remove();
396
+ this.element = null;
397
+ this._pointer = null;
398
+ }
399
+
400
+ /** Put the frame where the numbers say it is. */
401
+ paint() {
402
+ if (!this.element) return;
403
+ const box = this.drawing ? this.toScreenRect(this.drawing) : this.box;
404
+ if (!box) return;
405
+ const offset = this.offset();
406
+ this.element.style.left = (box.x + offset.x) + "px";
407
+ this.element.style.top = (box.y + offset.y) + "px";
408
+ this.element.style.width = box.width + "px";
409
+ this.element.style.height = box.height + "px";
410
+ this.element.classList.toggle("is-drawing", Boolean(this.drawing));
411
+
412
+ // A pinned frame is about to take THAT region again rather than
413
+ // whatever the frame is over. It says so in colour, and in the caption
414
+ // naming the capture -- not in the button, which carries no words at
415
+ // all now. The title and the accessible name carry the difference for
416
+ // the two readers who need it spelled out.
417
+ const pinned = Boolean(this.pinned) && !this.drawing;
418
+ this.element.classList.toggle("is-pinned", pinned);
419
+ this.element.classList.toggle("is-framing", this.framing);
420
+ const shutter = this.element.querySelector(".fb-shutter");
421
+ if (shutter) shutter.hidden = this.framing;
422
+ if (shutter && !this.framing) {
423
+ const what = pinned ? "Capture this region again" : "Capture";
424
+ shutter.title = what + " (" + FigureCaptureTool.SHOOT_KEY.toUpperCase() + ")";
425
+ const name = shutter.querySelector('[data-role="shutterName"]');
426
+ if (name) name.textContent = what;
427
+ }
428
+
429
+ const caption = this.element.querySelector(".fb-viewfinder-caption");
430
+ if (caption) {
431
+ const rect = this.imageRectFor(box);
432
+ const size = rect ? Math.round(rect.w) + " × " + Math.round(rect.h) + " px" : "";
433
+ caption.textContent = pinned && this.pinLabel
434
+ ? this.pinLabel + " · " + size
435
+ : size;
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Turn the viewfinder into a framing outline, or back into a shutter.
441
+ *
442
+ * The frame does two jobs that look the same and are not. Normally it is a
443
+ * VIEWFINDER: whatever is inside it is what the next capture takes. While a
444
+ * panel's view is on loan to the viewer it is a FRAME: it shows where that
445
+ * panel's edges are, so the user can see what they are reframing, and
446
+ * taking a picture through it would create a second panel of somebody
447
+ * else's scene.
448
+ *
449
+ * So the shutter goes -- the button, and `shoot()` with it -- and only the
450
+ * outline is left. The dock disables its own shutter for the same reason;
451
+ * this is the keyboard's half and the frame's.
452
+ */
453
+ setFraming(on) {
454
+ this.framing = Boolean(on);
455
+ this.paint();
456
+ }
457
+
458
+ /** Re-clamp to a viewer that changed size, and redraw. */
459
+ reflow() {
460
+ if (!this.armed || !this.box) return;
461
+ this.box = FigureCaptureTool.clampBox(this.box, this.bounds());
462
+ this.paint();
463
+ }
464
+
465
+ setBox(box) {
466
+ this.unpin();
467
+ this.box = FigureCaptureTool.clampBox(box, this.bounds());
468
+ this.paint();
469
+ }
470
+
471
+ /**
472
+ * Put the frame exactly over a region of the IMAGE.
473
+ *
474
+ * The one place the two coordinate systems are deliberately joined. The
475
+ * frame is normally screen-anchored -- pan the image under it and it stays
476
+ * put -- but when the user goes back to a capture they took earlier, "the
477
+ * same field of view" has to mean the same image pixels, not a rectangle of
478
+ * the same size somewhere near them. Landing the frame on the region is
479
+ * what makes a second capture of it pixel-for-pixel concordant with the
480
+ * first, which is the whole reason to go back.
481
+ *
482
+ * Called after the viewer has finished moving, never during: mid-flight the
483
+ * region is somewhere it is only passing through.
484
+ */
485
+ aimAt(rect) {
486
+ const screenRect = rect ? this.toScreenRect(rect) : null;
487
+ if (!screenRect) return false;
488
+ this.box = FigureCaptureTool.clampBox(screenRect, this.bounds());
489
+ this.paint();
490
+ return true;
491
+ }
492
+
493
+ // -- pinning the frame to a capture ----------------------------------
494
+
495
+ /**
496
+ * How far the frame may sit from the region it is pinned to, in screen
497
+ * pixels, before the pin counts as broken. One, because the frame is
498
+ * clamped to whole pixels and the projection is not: half a pixel of
499
+ * rounding is not the user going somewhere else.
500
+ */
501
+ static get PIN_SLACK() { return 1; }
502
+
503
+ /** The viewer events that mean "the picture moved" -- miniMap.js's trio. */
504
+ static get VIEWPORT_EVENTS() { return ["animation", "animation-finish", "resize"]; }
505
+
506
+ /** Is this frame ON this region, rather than merely near it? */
507
+ static onTarget(box, screenRect) {
508
+ if (!box || !screenRect) return false;
509
+ const slack = FigureCaptureTool.PIN_SLACK;
510
+ return Math.abs(box.x - screenRect.x) <= slack
511
+ && Math.abs(box.y - screenRect.y) <= slack
512
+ && Math.abs(box.width - screenRect.width) <= slack
513
+ && Math.abs(box.height - screenRect.height) <= slack;
514
+ }
515
+
516
+ /**
517
+ * Lock the frame onto a capture's region.
518
+ *
519
+ * Selecting a capture and then pressing the shutter has to give back the
520
+ * SAME region rather than a fresh one near it -- that is what makes a
521
+ * second panel of a field comparable with the first. So while the frame is
522
+ * pinned the shutter reads the pin, and the frame stops being whatever
523
+ * rectangle the user happened to leave lying about.
524
+ *
525
+ * The pin holds through every rendering change -- channel, window, colour,
526
+ * overlay -- which is the entire reason to go back to a region. It also
527
+ * holds while the viewer MOVES: the lock is on a region, and the frame is
528
+ * only how that region is shown, so the frame follows it. It lets go when
529
+ * the region can no longer be shown as a frame at all -- panned off the
530
+ * edge, zoomed past, or shrunk to a speck -- which is also what stops a
531
+ * following frame from drifting outside the viewer and breaking "one frame,
532
+ * four places". See checkPin().
533
+ *
534
+ * Locks what the frame is ALREADY on, and never moves it. aimAt() is how
535
+ * the frame gets there; keeping the two apart is what lets a capture that
536
+ * has just been taken lock for free -- the frame is on it already -- while
537
+ * a frame the user set up is never quietly resized to the region that was
538
+ * actually saved when it hung over the edge of the slide.
539
+ *
540
+ * So it is refused whenever the frame is not on the region: because the
541
+ * region is off screen, larger than the viewer, or projects smaller than a
542
+ * frame can be -- all of which leave clampBox holding the frame somewhere
543
+ * the region is not, and a lock pointing at the wrong tissue is worse than
544
+ * no lock at all. Returns whether it took.
545
+ */
546
+ pinTo(rect, label) {
547
+ this.unpin(true);
548
+ if (!rect || !this.box) return false;
549
+ if (!FigureCaptureTool.onTarget(this.box, this.toScreenRect(rect))) return false;
550
+
551
+ this.pinned = { ...rect };
552
+ this.pinLabel = label || "";
553
+ this.watchPin();
554
+ this.paint();
555
+ return true;
556
+ }
557
+
558
+ /**
559
+ * Let go of the region.
560
+ *
561
+ * `silent` is for the one caller that is on its way to pinning somewhere
562
+ * else: the viewer moves to get there, and an unpin that announced itself
563
+ * would clear the very selection being made.
564
+ */
565
+ unpin(silent) {
566
+ if (!this.pinned) return;
567
+ this.pinned = null;
568
+ this.pinLabel = "";
569
+ this.stopWatchingPin();
570
+ this.paint();
571
+ if (!silent) this.onUnpin();
572
+ }
573
+
574
+ /**
575
+ * Put the frame on a region and lock the shutter to it, in one step.
576
+ *
577
+ * Going back to a capture wants both, and wants them atomically. Done as
578
+ * aimAt-then-pinTo by the caller, the pin was judged against a projection
579
+ * read a moment after the aim -- and OpenSeadragon is still settling for a
580
+ * while after it says it has finished moving, so the two readings differed
581
+ * by a few pixels, the lock was refused, and the capture the user had just
582
+ * clicked came back unselected. One reading, used for both.
583
+ *
584
+ * Refused when the region cannot be shown as a frame: off screen, larger
585
+ * than the viewer, or smaller than a frame can be. clampBox moving the
586
+ * rectangle is the test for all three.
587
+ */
588
+ lockOn(rect, label) {
589
+ const screenRect = rect ? this.toScreenRect(rect) : null;
590
+ if (!screenRect) return false;
591
+ const box = FigureCaptureTool.clampBox(screenRect, this.bounds());
592
+ if (!FigureCaptureTool.onTarget(box, screenRect)) return false;
593
+
594
+ this.unpin(true);
595
+ this.box = box;
596
+ this.pinned = { ...rect };
597
+ this.pinLabel = label || "";
598
+ this.watchPin();
599
+ this.paint();
600
+ return true;
601
+ }
602
+
603
+ /**
604
+ * The viewer moved. Keep the frame on the region, or let the region go.
605
+ *
606
+ * Following rather than releasing, because the lock is on a REGION: while a
607
+ * capture is selected the shutter takes that region, and a frame sitting
608
+ * anywhere else is the tool lying about what the next shot will be. It also
609
+ * makes the lock survive the movement the user did not ask for and cannot
610
+ * see -- OpenSeadragon's own settling after a flight, a nudge, a resize --
611
+ * which is what made "click a capture and it comes back unselected" happen
612
+ * at all.
613
+ *
614
+ * Releasing when the region can no longer be framed is what keeps the frame
615
+ * inside the viewer: #openseadragon_wrapper does not clip, so a frame that
616
+ * chased the tissue without this would end up drawn over the sidebar. The
617
+ * frame then stays exactly where it last sat, unlocked, and "one frame, four
618
+ * places" is back.
619
+ */
620
+ checkPin() {
621
+ if (!this.pinned || this.dragging) return;
622
+ const screenRect = this.toScreenRect(this.pinned);
623
+ if (FigureCaptureTool.onTarget(this.box, screenRect)) return;
624
+ if (!screenRect) {
625
+ this.unpin();
626
+ return;
627
+ }
628
+ const box = FigureCaptureTool.clampBox(screenRect, this.bounds());
629
+ if (!FigureCaptureTool.onTarget(box, screenRect)) {
630
+ this.unpin();
631
+ return;
632
+ }
633
+ this.box = box;
634
+ this.paint();
635
+ }
636
+
637
+ /** Watched only while something is pinned, and hung on the viewer rather
638
+ * than on arm/disarm: a pin survives leaving capture mode, so that coming
639
+ * back to it finds the frame still on the region. */
640
+ watchPin() {
641
+ if (this._pinWatch || !this.viewer) return;
642
+ const check = () => this.checkPin();
643
+ this._pinWatch = check;
644
+ for (const name of FigureCaptureTool.VIEWPORT_EVENTS) {
645
+ this.viewer.addHandler(name, check);
646
+ }
647
+ }
648
+
649
+ stopWatchingPin() {
650
+ if (!this._pinWatch || !this.viewer) return;
651
+ for (const name of FigureCaptureTool.VIEWPORT_EVENTS) {
652
+ this.viewer.removeHandler(name, this._pinWatch);
653
+ }
654
+ this._pinWatch = null;
655
+ }
656
+
657
+ // -- moving and resizing the frame -----------------------------------
658
+
659
+ framePointerDown(event) {
660
+ if (!this.armed || this.spaceHeld || !this.box) return;
661
+ if (event.target.closest('[data-role="shutter"]')) return;
662
+ const corner = event.target.closest(".fb-vf-handle")?.dataset.corner || null;
663
+ this._pointer = {
664
+ id: event.pointerId,
665
+ corner: corner,
666
+ startX: event.clientX,
667
+ startY: event.clientY,
668
+ box: { ...this.box },
669
+ };
670
+ this.element.setPointerCapture?.(event.pointerId);
671
+ event.preventDefault();
672
+ }
673
+
674
+ framePointerMove(event) {
675
+ const drag = this._pointer;
676
+ if (!drag || drag.id !== event.pointerId) return;
677
+ const dx = event.clientX - drag.startX;
678
+ const dy = event.clientY - drag.startY;
679
+ // Jitter under a finger is not a gesture, and unpinning on it would
680
+ // lose the lock to a press the user does not think they made.
681
+ if (!dx && !dy) return;
682
+ // Moving or resizing the frame by hand is aiming it somewhere else.
683
+ this.unpin();
684
+ const bounds = this.bounds();
685
+ this.box = drag.corner
686
+ ? FigureCaptureTool.resizeBox(drag.box, drag.corner, dx, dy, bounds)
687
+ : FigureCaptureTool.moveBox(drag.box, dx, dy, bounds);
688
+ this.paint();
689
+ }
690
+
691
+ framePointerUp(event) {
692
+ if (!this._pointer || this._pointer.id !== event.pointerId) return;
693
+ this.element?.releasePointerCapture?.(event.pointerId);
694
+ this._pointer = null;
695
+ }
696
+
697
+ // -- coordinates -----------------------------------------------------
698
+
699
+ /** An OpenSeadragon point, which viewport.pointFromPixel needs -- it does
700
+ * arithmetic through the point's own methods, so a bare {x, y} throws. */
701
+ point(x, y) {
702
+ if (typeof OpenSeadragon !== "undefined" && OpenSeadragon.Point) {
703
+ return new OpenSeadragon.Point(x, y);
704
+ }
705
+ return { x: x, y: y };
706
+ }
707
+
708
+ /**
709
+ * Screen position -> full-resolution image pixel.
710
+ *
711
+ * Through the tile source's own getImagePixel where it exists (viewerManager
712
+ * installs it on every channel), which already accounts for extraZoomLevels.
713
+ * The longhand is the same arithmetic for a source that predates it.
714
+ */
715
+ toImage(position) {
716
+ const item = this.viewer?.world?.getItemAt(0);
717
+ if (!item) return null;
718
+ if (item.source && typeof item.source.getImagePixel === "function") {
719
+ const [x, y] = item.source.getImagePixel(item, position);
720
+ return [x, y];
721
+ }
722
+ const viewportPoint = this.viewer.viewport.pointFromPixel(position);
723
+ const imagePoint = item.viewportToImageCoordinates(viewportPoint);
724
+ const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
725
+ return [imagePoint.x / scale, imagePoint.y / scale];
726
+ }
727
+
728
+ /** Full-resolution image rectangle -> the pixels it occupies on screen. */
729
+ toScreenRect(rect) {
730
+ const item = this.viewer?.world?.getItemAt(0);
731
+ if (!item) return null;
732
+ const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
733
+ const viewportRect = item.imageToViewportRectangle(new OpenSeadragon.Rect(
734
+ rect.x * scale, rect.y * scale, rect.w * scale, rect.h * scale));
735
+ const topLeft = this.viewer.viewport.pixelFromPoint(viewportRect.getTopLeft(), true);
736
+ const bottomRight = this.viewer.viewport.pixelFromPoint(viewportRect.getBottomRight(), true);
737
+ return {
738
+ x: topLeft.x,
739
+ y: topLeft.y,
740
+ width: Math.abs(bottomRight.x - topLeft.x),
741
+ height: Math.abs(bottomRight.y - topLeft.y),
742
+ };
743
+ }
744
+
745
+ /**
746
+ * The frame -> the region of the image inside it, trimmed to the image.
747
+ *
748
+ * Both corners are converted rather than one corner plus a scaled size:
749
+ * the second is only right while the viewer's mapping is a plain scale,
750
+ * and it would fail silently the day it is not.
751
+ */
752
+ imageRectFor(box) {
753
+ if (!box) return null;
754
+ const topLeft = this.toImage(this.point(box.x, box.y));
755
+ const bottomRight = this.toImage(this.point(box.x + box.width, box.y + box.height));
756
+ if (!topLeft || !bottomRight) return null;
757
+ return this.clamp(this.rectBetween(topLeft, bottomRight, false));
758
+ }
759
+
760
+ /** Keep a captured region inside the image it was drawn on. */
761
+ clamp(rect) {
762
+ const width = this.ctx.config?.width || 0;
763
+ const height = this.ctx.config?.height || 0;
764
+ if (!width || !height) return rect;
765
+ const x = Math.max(0, Math.min(rect.x, width));
766
+ const y = Math.max(0, Math.min(rect.y, height));
767
+ return {
768
+ x: x,
769
+ y: y,
770
+ w: Math.max(1, Math.min(rect.w, width - x)),
771
+ h: Math.max(1, Math.min(rect.h, height - y)),
772
+ };
773
+ }
774
+
775
+ // -- redrawing the frame on the image --------------------------------
776
+
777
+ /**
778
+ * May a drag on bare image redraw the frame right now?
779
+ *
780
+ * Only while Figure Builder is the tool core's shared controls point at.
781
+ * The dock outlives being switched away from -- it is on the image and the
782
+ * session's captures are in it -- so capture mode can be armed while ROI's
783
+ * pen is the selected tool, and one drag would then both draw a region and
784
+ * redraw this frame. Moving and resizing the frame by its own handles work
785
+ * either way: those never reach the viewer at all.
786
+ *
787
+ * Asked of the loader rather than of the other plugins: "am I the selected
788
+ * tool" is a question core already answers, and this file has no business
789
+ * knowing which other tools exist.
790
+ */
791
+ canRedraw() {
792
+ const selected = window.PlexoraToolLoader?.activeTool?.();
793
+ return !selected || selected === this.toolName;
794
+ }
795
+
796
+ press(event) {
797
+ if (!this.armed || this.spaceHeld || !this.canRedraw()) return;
798
+ const origin = this.toImage(event.position);
799
+ if (!origin) return;
800
+ event.preventDefaultAction = true;
801
+ this.dragging = true;
802
+ this.anchor = origin;
803
+ this.drawing = { x: origin[0], y: origin[1], w: 0, h: 0 };
804
+ this.paint();
805
+ }
806
+
807
+ drag(event) {
808
+ if (!this.armed) return;
809
+ if (this.spaceHeld || !this.dragging) return; // Space pans, always.
810
+ // A redraw in flight IS the user choosing a new region, and it is the
811
+ // one gesture allowed to take the frame off a capture it was locked to.
812
+ // Here rather than in press(), so a click that never moved does not
813
+ // quietly cost them the lock.
814
+ this.unpin();
815
+ event.preventDefaultAction = true;
816
+ const current = this.toImage(event.position);
817
+ if (!current) return;
818
+ this.drawing = this.rectBetween(this.anchor, current, event.shift);
819
+ this.paint();
820
+ }
821
+
822
+ dragEnd(event) {
823
+ if (!this.armed || !this.dragging) return;
824
+ event.preventDefaultAction = true;
825
+ this.dragging = false;
826
+
827
+ const drawn = this.drawing ? this.toScreenRect(this.clamp(this.drawing)) : null;
828
+ this.drawing = null;
829
+ const smallest = FigureCaptureTool.MIN_SCREEN_SIZE;
830
+ if (drawn && drawn.width >= smallest && drawn.height >= smallest) {
831
+ this.box = FigureCaptureTool.clampBox(drawn, this.bounds());
832
+ }
833
+ // Anything smaller is a click that slipped, not a frame anybody drew.
834
+ // The previous frame stands rather than collapsing to a dot -- which
835
+ // would cost the user the frame they had set up in exchange for a
836
+ // gesture they did not mean to make.
837
+ this.paint();
838
+ }
839
+
840
+ /**
841
+ * The rectangle between two image points, squared off if Shift is held.
842
+ *
843
+ * Squared in IMAGE pixels rather than screen pixels, so a row of square
844
+ * panels really does show the same physical field. On an image whose pixels
845
+ * are not square on screen the two differ, and the screen answer is the one
846
+ * that produces a figure whose panels quietly disagree about scale.
847
+ */
848
+ rectBetween(anchor, current, square) {
849
+ let dx = current[0] - anchor[0];
850
+ let dy = current[1] - anchor[1];
851
+ if (square) {
852
+ const side = Math.max(Math.abs(dx), Math.abs(dy));
853
+ dx = Math.sign(dx || 1) * side;
854
+ dy = Math.sign(dy || 1) * side;
855
+ }
856
+ return {
857
+ x: Math.min(anchor[0], anchor[0] + dx),
858
+ y: Math.min(anchor[1], anchor[1] + dy),
859
+ w: Math.abs(dx),
860
+ h: Math.abs(dy),
861
+ };
862
+ }
863
+
864
+ keyDown(event) {
865
+ if (!this.armed) return;
866
+ if (event.key === "Escape") {
867
+ // A drag first, the mode second: Esc during a redraw should put the
868
+ // frame back, not throw the user out of capture mode as well.
869
+ if (this.dragging || this.drawing) {
870
+ this.dragging = false;
871
+ this.drawing = null;
872
+ this.paint();
873
+ return;
874
+ }
875
+ this.disarm();
876
+ return;
877
+ }
878
+ if (FigureCaptureTool.isShootKey(event)) {
879
+ event.preventDefault();
880
+ this.shoot();
881
+ return;
882
+ }
883
+ if (event.code === "Space" && !this.spaceHeld) {
884
+ // Never mid-drag: pressing Space halfway through a redraw must not
885
+ // turn that redraw into a pan.
886
+ if (this.dragging) return;
887
+ this.spaceHeld = true;
888
+ this.setCursor("grab");
889
+ // The frame would otherwise swallow the pan that starts on top of
890
+ // it, which is most of the area anybody would start a pan in.
891
+ this.element?.classList.add("is-transparent");
892
+ }
893
+ }
894
+
895
+ keyUp(event) {
896
+ if (event.code === "Space") {
897
+ this.spaceHeld = false;
898
+ this.element?.classList.remove("is-transparent");
899
+ if (this.armed) this.setCursor("crosshair");
900
+ }
901
+ }
902
+
903
+ /** Is the user typing? Shared with the dock, which has the same question to
904
+ * ask about its own single-letter shortcut. */
905
+ static isTyping() {
906
+ const active = typeof document !== "undefined" ? document.activeElement : null;
907
+ return Boolean(active && (["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName)
908
+ || active.isContentEditable));
909
+ }
910
+
911
+ // -- taking the shot -------------------------------------------------
912
+
913
+ /**
914
+ * Capture what is inside the viewfinder.
915
+ *
916
+ * The image rectangle is trimmed to the image and the screen rectangle is
917
+ * then recomputed FROM IT, rather than the frame being cropped one way and
918
+ * the pixels another. A frame hanging over the edge of the slide otherwise
919
+ * produces a preview with a band of background in it and a scene that says
920
+ * there is none -- the two disagreeing about what the panel is.
921
+ *
922
+ * A PINNED frame shoots the region it is pinned to and does not ask the
923
+ * screen at all. Reading it back off the frame would round-trip the region
924
+ * through screen pixels and integer clamping, so the second capture of a
925
+ * field would land a pixel or two from the first -- and "go back, change
926
+ * the channels, capture it again" would be true to the eye and false in the
927
+ * file. Taken from the pin, the two panels' viewports are the same numbers.
928
+ *
929
+ * ## One shot per arming
930
+ *
931
+ * The mode ends here. Capture mode changes what a drag on the image does,
932
+ * so leaving it on after the shot meant the user went back to adjusting the
933
+ * picture -- which is the whole reason to take a second capture -- with the
934
+ * gesture for that still redrawing a viewfinder. Ending it makes the mode as
935
+ * long as the thing it is for, and pressing C again is one keystroke.
936
+ *
937
+ * The frame's geometry and its pin both survive (disarm keeps them), so
938
+ * arming again brings the frame back on the same region, still locked: the
939
+ * capture-adjust-capture loop costs one keystroke a lap and still lands on
940
+ * the same pixels.
941
+ */
942
+ shoot() {
943
+ if (!this.armed || !this.box) return null;
944
+ // Never while a panel's view is borrowed. A capture taken then would be
945
+ // a NEW panel of somebody else's scene, and nothing on screen would say
946
+ // so; the dock disables its own shutter for the same reason, and this
947
+ // is the keyboard's half of it.
948
+ if (this.framing) return null;
949
+ const rect = this.pinned ? this.clamp(this.pinned) : this.imageRectFor(this.box);
950
+ if (!rect) return null;
951
+
952
+ const screenRect = this.toScreenRect(rect);
953
+ const smallest = FigureCaptureTool.MIN_SCREEN_SIZE;
954
+ if (!screenRect || screenRect.width < smallest || screenRect.height < smallest) {
955
+ return null;
956
+ }
957
+ const preview = this.previewBlob(screenRect);
958
+ this.onCapture(rect, screenRect, preview);
959
+ // After the capture is recorded, so the pin onCapture sets is put on a
960
+ // frame that still exists. What tells the user it worked is the frame
961
+ // going and the thumbnail arriving in its place -- which is why the
962
+ // shutter blink this used to draw on the frame is gone rather than
963
+ // being drawn on an element about to be removed.
964
+ this.disarm();
965
+ return rect;
966
+ }
967
+
968
+ /**
969
+ * Crop what the viewer has already drawn.
970
+ *
971
+ * Every canvas inside the viewer's own element, in DOM order: the tile
972
+ * drawer first, then each overlay. Their backing stores can be at different
973
+ * scales -- an overlay is sized in CSS pixels, the drawer in device pixels
974
+ * -- so each one's crop is computed from its own bounding box rather than
975
+ * from a shared devicePixelRatio, which is what makes this correct on a
976
+ * mixed-DPI setup and on a browser mid-zoom.
977
+ */
978
+ grabPreview(screenRect) {
979
+ const container = this.viewer?.canvas;
980
+ if (!container) return null;
981
+ const containerRect = container.getBoundingClientRect();
982
+
983
+ const cssWidth = Math.max(1, screenRect.width);
984
+ const cssHeight = Math.max(1, screenRect.height);
985
+ const ratio = window.devicePixelRatio || 1;
986
+ const scale = Math.min(ratio, FigureCaptureTool.MAX_PREVIEW_EDGE
987
+ / Math.max(cssWidth, cssHeight));
988
+
989
+ const out = document.createElement("canvas");
990
+ out.width = Math.max(1, Math.round(cssWidth * scale));
991
+ out.height = Math.max(1, Math.round(cssHeight * scale));
992
+ const context = out.getContext("2d");
993
+ // Black, not transparent: the viewer composites channels additively over
994
+ // black, and a preview with an alpha hole in it would look like a hole
995
+ // rather than like the dark tissue it is.
996
+ context.fillStyle = "#000000";
997
+ context.fillRect(0, 0, out.width, out.height);
998
+
999
+ for (const element of container.querySelectorAll("canvas")) {
1000
+ const rect = element.getBoundingClientRect();
1001
+ if (!element.width || !element.height || !rect.width || !rect.height) continue;
1002
+ const scaleX = element.width / rect.width;
1003
+ const scaleY = element.height / rect.height;
1004
+ const sx = (screenRect.x - (rect.left - containerRect.left)) * scaleX;
1005
+ const sy = (screenRect.y - (rect.top - containerRect.top)) * scaleY;
1006
+ try {
1007
+ // drawImage clips a source rectangle that runs off the edge and
1008
+ // scales the destination to match, so a capture that overlaps
1009
+ // the edge of an overlay needs no special case.
1010
+ context.drawImage(element, sx, sy, cssWidth * scaleX, cssHeight * scaleY,
1011
+ 0, 0, out.width, out.height);
1012
+ } catch (error) {
1013
+ // A canvas that cannot be read must not cost the user their
1014
+ // capture: the scene snapshot is the master, and a panel with a
1015
+ // partial preview still re-renders correctly at export.
1016
+ console.error("figure_builder: a layer could not be previewed", error);
1017
+ }
1018
+ }
1019
+ return out;
1020
+ }
1021
+
1022
+ /** The preview as a WebP blob, or null. */
1023
+ previewBlob(screenRect) {
1024
+ const canvas = this.grabPreview(screenRect);
1025
+ if (!canvas) return Promise.resolve(null);
1026
+ return new Promise((resolve) => {
1027
+ // WebP over PNG: a 600x600 crop of tissue is roughly a tenth the
1028
+ // size, and these are stored in the figure's own database and sent
1029
+ // over the wire on every reopen.
1030
+ canvas.toBlob((blob) => resolve(blob ? { blob: blob, width: canvas.width, height: canvas.height } : null),
1031
+ "image/webp", 0.9);
1032
+ });
1033
+ }
1034
+ }