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,1193 @@
1
+ /**
2
+ * FigureContextBar - the controls for whatever is selected, beside it.
3
+ *
4
+ * Figure Builder used to carry a 280px properties column down the right-hand
5
+ * side of the page. It was permanent and it was empty most of the time: the
6
+ * controls in it act on a selection, and the usual state of a figure being
7
+ * arranged is that nothing is selected. So it has gone, and this took its
8
+ * place -- a small bar that appears above what it acts on and nowhere else.
9
+ *
10
+ * ## Why it lives outside the canvas
11
+ *
12
+ * `FigureCanvas.render()` replaces the whole of #fb_page_surface on every
13
+ * document change, which is most of what happens here. Anything mounted inside
14
+ * it is destroyed mid-interaction: a popover open over a panel would vanish the
15
+ * moment the checkbox in it was ticked. So the bar is mounted in
16
+ * #fb_overlay_layer, a sibling the renderer never touches, and it is told where
17
+ * the selection is rather than living in it.
18
+ *
19
+ * ## Three levels, and only the first is visible
20
+ *
21
+ * Level 1 is the tool rail and the topbar (always). Level 2 is this bar, which
22
+ * exists only while something is selected. Level 3 is inside `More`, inside the
23
+ * popovers and on the right-click, which is where everything that is rarely
24
+ * wanted goes. The rule for deciding is not how useful an action is -- it is
25
+ * how often the answer to "do I want this right now?" is yes.
26
+ */
27
+ class FigureContextBar {
28
+
29
+ /** Gap in CSS pixels between the selection and the bar. */
30
+ static get OFFSET() { return 10; }
31
+
32
+ /** How close to the top of the workspace the bar may sit before it flips
33
+ * underneath the selection instead. */
34
+ static get FLIP_MARGIN() { return 48; }
35
+
36
+ constructor(options) {
37
+ this.overlayEl = options.overlayEl;
38
+ this.canvas = options.canvas;
39
+ this.state = options.state;
40
+ this.handlers = options.handlers || {};
41
+
42
+ this.el = null;
43
+ this.popover = null;
44
+ //: The ids the bar is currently drawn for. Kept so a re-render that
45
+ //: changes nothing about the selection does not close an open popover.
46
+ this.ids = [];
47
+ this.suppressed = false;
48
+ //: How far the user has dragged the bar from where it would otherwise
49
+ //: sit. Not persisted -- see `dragStart`.
50
+ this.offset = { x: 0, y: 0 };
51
+ }
52
+
53
+ setup() {
54
+ if (!this.overlayEl) return;
55
+ this.el = document.createElement("div");
56
+ this.el.className = "fb-context-bar";
57
+ this.el.id = "fb_context_bar";
58
+ this.el.hidden = true;
59
+ this.overlayEl.appendChild(this.el);
60
+ this.el.addEventListener("click", (event) => this.clicked(event));
61
+ this.el.addEventListener("change", (event) => this.changed(event));
62
+ this.el.addEventListener("pointerdown", (event) => this.barPointerDown(event));
63
+
64
+
65
+ // A click anywhere else closes an open popover. On the document rather
66
+ // than on the overlay: the click that dismisses it is usually on the
67
+ // canvas, which is not a descendant of this.
68
+ this._onDocDown = (event) => {
69
+ if (!this.popover) return;
70
+ if (this.popover.contains(event.target)) return;
71
+ if (this.el.contains(event.target)) return;
72
+ this.closePopover();
73
+ };
74
+ document.addEventListener("pointerdown", this._onDocDown, true);
75
+ }
76
+
77
+ destroy() {
78
+ document.removeEventListener("pointerdown", this._onDocDown, true);
79
+ this.closePopover();
80
+ this.el?.remove();
81
+ this.el = null;
82
+ }
83
+
84
+ // -- visibility ------------------------------------------------------
85
+
86
+ /**
87
+ * Out of the way while a drag is in flight.
88
+ *
89
+ * A bar that followed the panel would be a control panel moving under the
90
+ * pointer, and one that stayed put would be pointing at where the panel
91
+ * used to be. Neither is worth having for the second a drag lasts.
92
+ */
93
+ suppress(active) {
94
+ this.suppressed = Boolean(active);
95
+ if (this.el) this.el.hidden = this.suppressed || !this.ids.length;
96
+ if (this.suppressed) this.closePopover();
97
+ else this.position();
98
+ }
99
+
100
+ /** Redraw for this selection. Called on every selection change AND after
101
+ * every canvas render, because the elements it measures are new each time. */
102
+ update(ids) {
103
+ if (!this.el) return;
104
+ const same = ids.length === this.ids.length
105
+ && ids.every((id, index) => id === this.ids[index]);
106
+ this.ids = ids.slice();
107
+
108
+ if (!ids.length) {
109
+ this.el.hidden = true;
110
+ this.closePopover();
111
+ return;
112
+ }
113
+ if (!same) this.closePopover();
114
+ this.el.innerHTML = this.markup(ids);
115
+ this.el.hidden = this.suppressed;
116
+ this.position();
117
+ // The Transform popover shows the selection's width and height, and
118
+ // this runs after every canvas render -- so dragging a corner moves the
119
+ // numbers instead of leaving them describing where the box used to be.
120
+ this.refreshPopover();
121
+ }
122
+
123
+ /**
124
+ * Put the bar above the selection, or below it near the top of the page.
125
+ *
126
+ * Measured from the live elements rather than computed from millimetres:
127
+ * the bar has to sit beside what the user can SEE, and what they can see is
128
+ * the scrolled, zoomed rendering of those millimetres.
129
+ */
130
+ position() {
131
+ if (!this.el || this.el.hidden || !this.ids.length) return;
132
+ const box = this.selectionBox();
133
+ if (!box) {
134
+ this.el.hidden = true;
135
+ return;
136
+ }
137
+ const host = this.overlayEl.getBoundingClientRect();
138
+ const size = this.el.getBoundingClientRect();
139
+
140
+ // `this.offset` is whatever the user dragged the bar by, and it is
141
+ // added BEFORE the clamp -- so a bar pushed off the edge comes back to
142
+ // the edge rather than off the screen, and the drag stops where the
143
+ // window does.
144
+ let left = box.left + box.width / 2 - host.left - size.width / 2
145
+ + this.offset.x;
146
+ left = Math.max(8, Math.min(left, host.width - size.width - 8));
147
+
148
+ let top = box.top - host.top - size.height - FigureContextBar.OFFSET;
149
+ const below = box.bottom - host.top + FigureContextBar.OFFSET;
150
+ if (top < 8) top = Math.min(below, host.height - size.height - 8);
151
+ top = Math.max(8, Math.min(top + this.offset.y, host.height - size.height - 8));
152
+
153
+ this.el.style.left = Math.round(left) + "px";
154
+ this.el.style.top = Math.round(top) + "px";
155
+ this.positionPopover();
156
+ }
157
+ /** The union of the selected elements, in client coordinates. */
158
+ selectionBox() {
159
+ let box = null;
160
+ for (const id of this.ids) {
161
+ const element = this.canvas.surfaceEl.querySelector(
162
+ `[data-panel-id="${id}"], [data-annotation-id="${id}"]`);
163
+ if (!element) continue;
164
+ const rect = element.getBoundingClientRect();
165
+ box = box ? {
166
+ left: Math.min(box.left, rect.left),
167
+ top: Math.min(box.top, rect.top),
168
+ right: Math.max(box.right, rect.right),
169
+ bottom: Math.max(box.bottom, rect.bottom),
170
+ } : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
171
+ }
172
+ if (!box) return null;
173
+ box.width = box.right - box.left;
174
+ box.height = box.bottom - box.top;
175
+ return box;
176
+ }
177
+
178
+ // -- what the bar shows ----------------------------------------------
179
+
180
+ /**
181
+ * The buttons for this selection, from the registry.
182
+ *
183
+ * This used to be three hand-built lists -- one for a panel, one for an
184
+ * annotation with a branch inside it for text, one for a multiple selection
185
+ * -- and the right-click menu was a fourth. They drifted, which is what
186
+ * `figureActions.js` exists to stop: what an action is, when it appears and
187
+ * when it can run are declared once and both surfaces read them.
188
+ */
189
+ markup(ids) {
190
+ const context = this.context(ids);
191
+ const actions = FigureActions.forSurface("bar", context.sel, context);
192
+ if (!actions.length) return "";
193
+
194
+ // The handle the bar is picked up by. First, and drawn as a grip rather
195
+ // than as another button, so "this whole strip moves" is said by the
196
+ // one part of it that is not a control.
197
+ const parts = [`<span class="fb-context-grip" aria-hidden="true"></span>`];
198
+ let generic = null;
199
+ for (const action of actions) {
200
+ // One divider where the type-specific actions give way to the ones
201
+ // every object has. The gap is what says which controls belong
202
+ // together, so it is drawn from the registry rather than typed into
203
+ // each list by hand.
204
+ if (generic !== null && Boolean(action.generic) !== generic) {
205
+ parts.push(this.divider());
206
+ }
207
+ generic = Boolean(action.generic);
208
+ parts.push(this.button(action.id, action.icon, action.label, {
209
+ menu: Boolean(action.popover),
210
+ short: action.short,
211
+ shortcut: action.shortcut,
212
+ pressed: action.isPressed,
213
+ danger: Boolean(action.danger),
214
+ disabled: !action.isEnabled,
215
+ }));
216
+ }
217
+ parts.push(this.divider());
218
+ // The overflow is the one button with no word under it. A vertical
219
+ // ellipsis is already the universal name for "the rest of this menu",
220
+ // and captioning it "More" would put the least specific label on the
221
+ // bar at the same size as the ones that say what they do.
222
+ parts.push(this.button("more", "ellipsis-vertical", "More",
223
+ { menu: true, wordless: true }));
224
+ return parts.join("");
225
+ }
226
+
227
+ /** Everything an action's predicates and `run` need. */
228
+ context(ids) {
229
+ return {
230
+ ids: ids,
231
+ sel: FigureSelection.describe(ids, this.state, this.canvas),
232
+ canvas: this.canvas,
233
+ state: this.state,
234
+ handlers: this.handlers,
235
+ };
236
+ }
237
+ /**
238
+ * One button: icon ABOVE word.
239
+ *
240
+ * Icon-only was smaller, and it meant every control on this bar had to be
241
+ * hovered to find out what it was -- on a bar whose contents CHANGE with
242
+ * the selection, so the row learnt one week is not the row seen the next.
243
+ * A tooltip is not a label; it is a label you have to ask for.
244
+ *
245
+ * Stacked rather than side by side, which is the shape every object bar
246
+ * that carries words uses. Beside the icon, a word doubles the width of its
247
+ * button, and eleven of those is a bar wider than the page it floats over --
248
+ * it wrapped to two lines on a panel selection. Above it, the word costs
249
+ * fourteen pixels of HEIGHT once for the whole strip, and the button stays
250
+ * about as wide as its widest word. Both labels then start at the same
251
+ * baseline, so the row reads as a row.
252
+ *
253
+ * The word is the registry's `short`, which is why that field exists: the
254
+ * full label is a menu row's sentence and gets truncated to nothing here.
255
+ * `title` and `aria-label` keep the full one -- plus the shortcut, which is
256
+ * the one place on the bar there is room to name it -- so the tooltip and
257
+ * the screen reader still say "Title, label and numbering" where the button
258
+ * says "Titles".
259
+ */
260
+ button(act, icon, title, options) {
261
+ const flags = options || {};
262
+ const word = flags.short || title;
263
+ const name = flags.shortcut ? `${title} (${flags.shortcut})` : title;
264
+ return `<button type="button" class="fb-context-button${flags.pressed ? " is-on" : ""}${
265
+ flags.danger ? " is-danger" : ""}${
266
+ flags.wordless ? " is-wordless" : ""}"
267
+ data-act="${act}" title="${FigureSchema.escapeHtml(name)}"
268
+ aria-label="${FigureSchema.escapeHtml(name)}"
269
+ ${flags.disabled ? "disabled" : ""}
270
+ ${flags.menu ? 'aria-haspopup="true"' : ""}>
271
+ <span class="fas fa-${icon} fb-context-icon" aria-hidden="true"></span>${
272
+ flags.wordless ? ""
273
+ : `<span class="fb-context-label">${FigureSchema.escapeHtml(word)}</span>`}
274
+ </button>`;
275
+ }
276
+
277
+
278
+ divider() {
279
+ return '<span class="fb-context-divider"></span>';
280
+ }
281
+
282
+ // -- acting ------------------------------------------------------------
283
+
284
+ clicked(event) {
285
+ const button = event.target.closest("[data-act]");
286
+ if (!button || button.disabled) return;
287
+ const act = button.dataset.act;
288
+
289
+ // Anything the registry can run outright, runs. Only the actions whose
290
+ // detail needs a form fall through to a popover.
291
+ const action = FigureActions.byId(act);
292
+ if (action && action.run) {
293
+ this.closePopover();
294
+ action.run(this.context(this.ids.slice()));
295
+ return;
296
+ }
297
+ // Everything else opens a menu of its own, and a second click on the
298
+ // same button closes it -- the button is the toggle, so there is never
299
+ // a menu on screen whose opener does not look pressed.
300
+ if (this.popover && this.popover.dataset.act === act) {
301
+ this.closePopover();
302
+ return;
303
+ }
304
+ this.openPopover(button, act);
305
+ }
306
+
307
+ changed(event) {
308
+ const field = event.target.dataset?.field;
309
+ if (field) this.fieldChanged(field, event.target);
310
+ }
311
+
312
+ // -- popovers ----------------------------------------------------------
313
+
314
+ openPopover(button, act) {
315
+ this.closePopover();
316
+ const body = this.popoverMarkup(act);
317
+ if (!body) return;
318
+
319
+ this.popover = document.createElement("div");
320
+ this.popover.className = "fb-context-popover";
321
+ this.popover.dataset.act = act;
322
+ this.popover.innerHTML = body;
323
+ this.overlayEl.appendChild(this.popover);
324
+ this._anchor = button;
325
+
326
+ this.popover.addEventListener("click", (event) => this.popoverClicked(event));
327
+ this.popover.addEventListener("input", (event) => this.changed(event));
328
+ this.popover.addEventListener("change", (event) => this.changed(event));
329
+ this.popover.addEventListener(
330
+ "pointerdown", (event) => this.popoverPointerDown(event));
331
+ button.classList.add("is-open");
332
+ this.positionPopover();
333
+ // Not for the symbol palette. Focusing a tile takes the caret out of
334
+ // the text editor, and the caret is the place the symbol is going to be
335
+ // inserted at -- so the one popover that must not grab focus is the one
336
+ // whose whole purpose is to act on what has focus.
337
+ if (act !== "symbol") {
338
+ this.popover.querySelector("input, select, button")?.focus();
339
+ }
340
+ }
341
+
342
+ closePopover() {
343
+ this.popover?.remove();
344
+ this.popover = null;
345
+ this._anchor?.classList.remove("is-open");
346
+ this._anchor = null;
347
+ }
348
+
349
+ positionPopover() {
350
+ if (!this.popover || !this._anchor) return;
351
+ const host = this.overlayEl.getBoundingClientRect();
352
+ const anchor = this._anchor.getBoundingClientRect();
353
+ // See FigureContextMenu.open: the popover animates in with a scale, and
354
+ // a client rect would measure the transformed box rather than the one
355
+ // it is about to settle at.
356
+ const size = { width: this.popover.offsetWidth,
357
+ height: this.popover.offsetHeight };
358
+
359
+ let left = anchor.left - host.left + anchor.width / 2 - size.width / 2;
360
+ left = Math.max(8, Math.min(left, host.width - size.width - 8));
361
+ let top = anchor.bottom - host.top + 6;
362
+ // Above the bar when there is no room under it, which happens as soon
363
+ // as the selection is near the bottom of a tall page.
364
+ if (top + size.height > host.height - 8) {
365
+ top = Math.max(8, anchor.top - host.top - size.height - 6);
366
+ }
367
+ this.popover.style.left = Math.round(left) + "px";
368
+ this.popover.style.top = Math.round(top) + "px";
369
+ }
370
+
371
+ popoverMarkup(act) {
372
+ const panel = this.ids.length === 1 ? this.state.panel(this.ids[0]) : null;
373
+ const annotation = this.ids.length === 1
374
+ ? this.state.document.annotations[this.ids[0]] : null;
375
+ const panels = this.selectedPanels();
376
+ if (act === "titles" && panel) return this.titlesPopover(panel);
377
+ if (act === "scalebar" && panels.length) return this.scalebarPopover(panels);
378
+ if (act === "pixelsize" && panels.length) return this.pixelSizePopover(panels);
379
+ if (act === "legend" && panels.length) return this.legendPopover(panels);
380
+ if (act === "split" && panel) return this.splitPopover();
381
+ if (act === "type" && annotation) return this.typePopover(annotation);
382
+ if (act === "colour" && annotation) return this.colourPopover(annotation);
383
+ if (act === "stroke" && annotation) return this.strokePopover(annotation);
384
+ if (act === "more") return this.morePopover(panel);
385
+ if (act === "align") return this.menu([
386
+ ["left", "Align left", "align-left"],
387
+ ["center", "Align centers", "align-center"],
388
+ ["right", "Align right", "align-right"],
389
+ ["top", "Align top", "arrow-up"],
390
+ ["middle", "Align middles", "arrows-up-down"],
391
+ ["bottom", "Align bottom", "arrow-down"],
392
+ ]);
393
+ if (act === "distribute") return this.menu([
394
+ // Evenly spaced bars, which is what the command produces, rather
395
+ // than a pair of arrows -- the arrows are already the BUTTON that
396
+ // opened this, and repeating them here says nothing about which of
397
+ // the two axes each row is.
398
+ ["distribute_h", "Equal gaps across", "grip-lines-vertical"],
399
+ ["distribute_v", "Equal gaps down", "grip-lines"],
400
+ ]);
401
+ if (act === "resize") return this.menu([
402
+ ["same_width", "Same width", "left-right"],
403
+ ["same_height", "Same height", "up-down"],
404
+ ["same_size", "Same size", "expand"],
405
+ ]);
406
+ if (act === "layout") return this.menu([
407
+ ["row", "Row", "table-columns"],
408
+ ["column", "Column", "bars"],
409
+ ["grid", "Grid", "table-cells"],
410
+ ]);
411
+ if (act === "transform") return this.transformPopover();
412
+ if (act === "symbol") return this.symbolPopover();
413
+ // Rows rather than `menu()`: these four are registry actions with their
414
+ // own `run`, so they go out as `data-more` and are dispatched by the
415
+ // same code the overflow and the right-click menu already use. Taken
416
+ // from `FigureActions.ARRANGE` so this popover and the right-click menu
417
+ // cannot end up offering different commands under the same word.
418
+ if (act === "arrange") {
419
+ return FigureActions.ARRANGE.map((id) => FigureActions.byId(id))
420
+ .map((action) => this.menuItem(action.id, action.label,
421
+ { icon: action.icon,
422
+ shortcut: action.shortcut })).join("");
423
+ }
424
+ return "";
425
+ }
426
+
427
+ /**
428
+ * A list of arrange commands. `data-arrange` is the contract FigureCanvas
429
+ * already answers to, so this menu adds vocabulary rather than a path.
430
+ *
431
+ * Drawn through `menuItem`, which is the only reason these rows have icons:
432
+ * they were built here as bare labels while the Arrange popover next to them
433
+ * -- the same size, the same shape, opened from the button alongside --
434
+ * listed icon, label and key. So four menus on one toolbar looked like two
435
+ * different kinds of menu, and the difference was which function had
436
+ * happened to draw them rather than anything about the commands.
437
+ */
438
+ menu(entries) {
439
+ return entries.map(([command, label, icon]) =>
440
+ this.menuItem(command, label,
441
+ { icon: icon, attribute: "data-arrange" })).join("");
442
+ }
443
+
444
+ /**
445
+ * The characters a caption needs and a keyboard does not have.
446
+ *
447
+ * A grid rather than a search field: the useful set for a figure is about
448
+ * thirty characters, and thirty tiles are quicker to read than any box is
449
+ * to type into.
450
+ *
451
+ * Restricted to WinAnsi, which is the encoding the PDF core fonts are drawn
452
+ * with. Greek and the maths signs are left out for a reason worth writing
453
+ * down, because it is not the obvious one: reportlab does not refuse them.
454
+ * It silently SUBSTITUTES -- an alpha comes out of Helvetica-with-
455
+ * WinAnsiEncoding set in Symbol, and a dingbat in ZapfDingbats, both with
456
+ * that face's metrics rather than the document's. So the character is set
457
+ * in a typeface the caption did not choose, at a width the browser did not
458
+ * measure, and the line it is on can break in one place on screen and
459
+ * another in the PDF. Everything in the list below is drawn by the font the
460
+ * caption actually names.
461
+ *
462
+ * Nothing stops anybody PASTING an alpha; this is about what the tool
463
+ * proposes, and about not proposing the one character in the caption that
464
+ * will not match.
465
+ */
466
+ symbolPopover() {
467
+ return `<div class="fb-symbol-grid">${FigureContextBar.SYMBOLS.map((glyph) =>
468
+ `<button type="button" class="fb-symbol" data-symbol="${
469
+ FigureSchema.escapeHtml(glyph)}" aria-label="${
470
+ FigureSchema.escapeHtml(glyph)}">${
471
+ FigureSchema.escapeHtml(glyph)}</button>`).join("")}</div>`;
472
+ }
473
+
474
+ static get SYMBOLS() {
475
+ return ["µ", "°", "±", "×", "÷", "¹",
476
+ "²", "³", "½", "¼", "¾", "Å",
477
+ "–", "—", "·", "•", "…", "‰",
478
+ "†", "‡", "§", "¶", "‘", "’",
479
+ "“", "”", "«", "»", "™", "©"];
480
+ }
481
+
482
+ titlesPopover(panel) {
483
+ const escape = FigureSchema.escapeHtml.bind(FigureSchema);
484
+ const style = this.state.document.settings.label_style;
485
+ return `
486
+ <label class="control-label" for="fb_ctx_title">Title</label>
487
+ <input id="fb_ctx_title" class="fb-input" type="text" data-field="title"
488
+ value="${escape(panel.title || "")}" maxlength="200"
489
+ placeholder="No title">
490
+
491
+ <label class="control-label" for="fb_ctx_label">Label</label>
492
+ <div class="fb-figure-row">
493
+ <input id="fb_ctx_label" class="fb-input fb-input-tiny" type="text"
494
+ data-field="label" maxlength="8" placeholder="auto"
495
+ value="${escape(panel.label.auto ? "" : panel.label.text)}">
496
+ <label class="fb-check" title="Renumber when the panels are rearranged">
497
+ <input type="checkbox" data-field="label_auto"
498
+ ${panel.label.auto ? "checked" : ""}> Auto
499
+ </label>
500
+ </div>
501
+ <label class="fb-check">
502
+ <input type="checkbox" data-field="label_visible"
503
+ ${panel.label.visible ? "checked" : ""}> Show the label
504
+ </label>
505
+
506
+ <label class="control-label" for="fb_ctx_label_style">Numbering</label>
507
+ <select id="fb_ctx_label_style" class="fb-select" data-field="label_style">
508
+ <option value="A" ${style === "A" ? "selected" : ""}>A, B, C</option>
509
+ <option value="a" ${style === "a" ? "selected" : ""}>a, b, c</option>
510
+ <option value="A1" ${style === "A1" ? "selected" : ""}>A1, A2, A3</option>
511
+ </select>
512
+ <p class="fb-muted fb-popover-note">Numbering is the whole figure's, and follows
513
+ reading order &mdash; left to right, top to bottom.</p>`;
514
+ }
515
+
516
+ /**
517
+ * Scale bars, for one panel or for a whole selection.
518
+ *
519
+ * The length is EITHER automatic or an explicit number of microns, and the
520
+ * difference matters across several panels: automatic gives each image a
521
+ * round number that fits it, which for a row of different magnifications is
522
+ * several different bars; an explicit length is the same physical distance
523
+ * everywhere, which is what makes two panels comparable by eye. The popover
524
+ * says which is which rather than leaving it to be discovered.
525
+ */
526
+ scalebarPopover(panels) {
527
+ const uncalibrated = panels.filter(
528
+ (panel) => !FigureSchema.physicalWidthUm(
529
+ this.state.source(panel.source_id), panel.scene.viewport));
530
+ const shown = panels.every((panel) => panel.scalebar.visible);
531
+ const target = panels[0].scalebar.target_um;
532
+ const same = panels.every((panel) => panel.scalebar.target_um === target);
533
+ const lengths = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000];
534
+
535
+ return `
536
+ <label class="fb-check">
537
+ <input type="checkbox" data-field="scalebar" ${shown ? "checked" : ""}>
538
+ Show a scale bar${panels.length > 1
539
+ ? " on all " + panels.length : ""}
540
+ </label>
541
+
542
+ <label class="control-label" for="fb_ctx_bar_len">Length</label>
543
+ <select id="fb_ctx_bar_len" class="fb-select" data-field="scalebar_length">
544
+ <option value="" ${same && !target ? "selected" : ""}>Automatic</option>
545
+ ${lengths.map((value) =>
546
+ `<option value="${value}" ${same && target === value ? "selected" : ""}
547
+ >${FigureSchema.escapeHtml(FigureSchema.formatMicrons(value))}</option>`).join("")}
548
+ </select>
549
+ <p class="fb-muted fb-popover-note">${panels.length > 1
550
+ ? "Automatic gives each image a round number that fits it; a set length is "
551
+ + "the same physical distance on every panel."
552
+ : "Automatic picks a round number that fits this image."}</p>
553
+
554
+ ${uncalibrated.length ? `
555
+ <p class="fb-muted fb-popover-note">${FigureSchema.escapeHtml(
556
+ FigureSchema.countPhrase(uncalibrated.length, "image"))} here recorded no
557
+ pixel size, so ${uncalibrated.length === 1 ? "it has" : "they have"}
558
+ no bar.</p>
559
+ <button type="button" class="fb-menu-item" data-more="pixelsize">
560
+ Set the pixel size…</button>` : ""}`;
561
+ }
562
+
563
+ /**
564
+ * Type in a pixel size for images that never recorded one.
565
+ *
566
+ * Written to the SOURCE rather than to the panel, because it is a fact
567
+ * about the image and every panel of it is entitled to the same answer --
568
+ * and marked `manual`, which the provenance page prints. A number somebody
569
+ * typed is not the same evidence as one the file stated, and a figure that
570
+ * could not tell the difference would be a figure whose scale bars cannot
571
+ * be checked.
572
+ */
573
+ pixelSizePopover(panels) {
574
+ const sources = this.uncalibratedSources(panels);
575
+ return `
576
+ <label class="control-label" for="fb_ctx_mpp">Microns per pixel</label>
577
+ <input id="fb_ctx_mpp" class="fb-input" type="number" min="0"
578
+ step="0.0001" placeholder="e.g. 0.325">
579
+ <p class="fb-muted fb-popover-note">Applies to
580
+ ${FigureSchema.escapeHtml(FigureSchema.countPhrase(sources.length, "image"))}
581
+ across the whole figure, and is recorded in the provenance as a value you
582
+ supplied rather than one the file stated.</p>
583
+ <button type="button" class="fb-menu-item" data-more="apply_pixelsize">
584
+ Apply</button>`;
585
+ }
586
+
587
+ /** The sources behind these panels that have no calibration. */
588
+ uncalibratedSources(panels) {
589
+ const seen = new Map();
590
+ for (const panel of panels) {
591
+ const source = this.state.source(panel.source_id);
592
+ if (source && !(source.pixel_size && source.pixel_size.value > 0)) {
593
+ seen.set(source.source_id, source);
594
+ }
595
+ }
596
+ return Array.from(seen.values());
597
+ }
598
+
599
+ /**
600
+ * Legends, and the conflict that must never be resolved silently.
601
+ *
602
+ * Two panels can show the same marker in different colours -- deliberately,
603
+ * because they were captured in different sessions, or by accident. Turning
604
+ * on a legend across both would produce two legends disagreeing about what
605
+ * CD8 looks like, which is a figure that misleads a reader.
606
+ *
607
+ * So it is asked about. "Keep separate" is the safe answer and stays first;
608
+ * "use one shared style" recolours the panels, which changes what the image
609
+ * looks like and is therefore never the default. Nothing here alters a
610
+ * scientific colour without being told to.
611
+ */
612
+ legendPopover(panels) {
613
+ const channels = panels.every((panel) => panel.legend.channels);
614
+ const plugins = panels.every((panel) => panel.legend.plugins);
615
+ const clashes = FigureContextBar.colourConflicts(panels);
616
+
617
+ return `
618
+ <label class="fb-check">
619
+ <input type="checkbox" data-field="legend_channels"
620
+ ${channels ? "checked" : ""}> Channels
621
+ </label>
622
+ <label class="fb-check">
623
+ <input type="checkbox" data-field="legend_plugins"
624
+ ${plugins ? "checked" : ""}> Overlays
625
+ </label>
626
+ <p class="fb-muted fb-popover-note">Drawn from what was recorded when the panel
627
+ was captured, never from the plugins that happen to be open now.</p>
628
+ ${clashes.length ? `
629
+ <div class="fb-conflict">
630
+ <strong>Selected panels use different colors for
631
+ ${FigureSchema.escapeHtml(clashes.map((c) => c.name).join(", "))}.</strong>
632
+ <p class="fb-muted fb-popover-note">One legend for both would say something
633
+ the panels do not.</p>
634
+ <button type="button" class="fb-menu-item" data-more="legend_keep">
635
+ Keep them separate</button>
636
+ <button type="button" class="fb-menu-item" data-more="legend_share">
637
+ Use one shared style</button>
638
+ </div>` : ""}`;
639
+ }
640
+
641
+ /**
642
+ * Markers that are drawn in more than one colour across a selection.
643
+ *
644
+ * Pure and static, so the rule can be checked without a browser. Compared
645
+ * on the name the legend would PRINT rather than on the channel key: two
646
+ * images can key the same marker differently, and a reader compares the
647
+ * words.
648
+ */
649
+ static colourConflicts(panels) {
650
+ const seen = new Map();
651
+ for (const panel of panels) {
652
+ for (const channel of panel.scene.channels || []) {
653
+ if (channel.visible === false) continue;
654
+ const name = channel.fullname_at_capture || channel.key;
655
+ const colour = `${channel.color.r},${channel.color.g},${channel.color.b}`;
656
+ if (!seen.has(name)) seen.set(name, new Set());
657
+ seen.get(name).add(colour);
658
+ }
659
+ }
660
+ return Array.from(seen.entries())
661
+ .filter(([, colours]) => colours.size > 1)
662
+ .map(([name, colours]) => ({ name: name, colours: Array.from(colours) }));
663
+ }
664
+
665
+ typePopover(annotation) {
666
+ return `
667
+ <label class="control-label" for="fb_ctx_size">Size (pt)</label>
668
+ <input id="fb_ctx_size" class="fb-input fb-input-tiny" type="number"
669
+ min="4" max="96" step="0.5" data-field="font_size_pt"
670
+ value="${annotation.style.font_size_pt}">
671
+
672
+ <label class="control-label" for="fb_ctx_align">Alignment</label>
673
+ <select id="fb_ctx_align" class="fb-select" data-field="align">
674
+ ${["left", "center", "right"].map((value) =>
675
+ `<option value="${value}" ${annotation.style.align === value ? "selected" : ""}
676
+ >${value}</option>`).join("")}
677
+ </select>`;
678
+ }
679
+
680
+ /**
681
+ * Colour, as the OS picker plus the figure's own text colour.
682
+ *
683
+ * No palette of "nice" colours. An annotation on a scientific figure is
684
+ * usually meant to match something already in it, and offering a dozen
685
+ * suggestions invites picking one that nearly does.
686
+ */
687
+ colourPopover(annotation) {
688
+ return `
689
+ <label class="control-label" for="fb_ctx_colour">Color</label>
690
+ <input id="fb_ctx_colour" class="fb-input" type="color" data-field="color"
691
+ value="${FigureSchema.escapeHtml(annotation.style.color)}">`;
692
+ }
693
+
694
+ strokePopover(annotation) {
695
+ const fills = annotation.type === "rect" || annotation.type === "ellipse";
696
+ return `
697
+ <label class="control-label" for="fb_ctx_width">Line width (pt)</label>
698
+ <input id="fb_ctx_width" class="fb-input fb-input-tiny" type="number"
699
+ min="0" max="20" step="0.25" data-field="line_width_pt"
700
+ value="${annotation.style.line_width_pt}">
701
+ ${fills ? `
702
+ <label class="fb-check">
703
+ <input type="checkbox" data-field="fill_on"
704
+ ${annotation.style.fill ? "checked" : ""}> Filled
705
+ </label>
706
+ <input class="fb-input" type="color" data-field="fill"
707
+ value="${FigureSchema.escapeHtml(annotation.style.fill || "#ffffff")}">` : ""}`;
708
+ }
709
+
710
+ splitPopover() {
711
+ return `
712
+ <p class="fb-muted fb-popover-note">One panel per channel, sharing this panel's
713
+ exact crop and window &mdash; linked, so resizing one resizes the row.</p>
714
+ <button type="button" class="fb-menu-item" data-split="with_composite">
715
+ Composite + channels</button>
716
+ <button type="button" class="fb-menu-item" data-split="channels_only">
717
+ Channels only</button>`;
718
+ }
719
+
720
+ /**
721
+ * Level 3: everything worth having that is not worth a button.
722
+ *
723
+ * The two deletes are both here and are worded as what they do. "Remove
724
+ * from page" unplaces a panel and it comes back in the tray; "Delete from
725
+ * figure" destroys the captured scene. Naming them "Delete" and "Delete
726
+ * permanently" would put the whole distinction in an adverb.
727
+ */
728
+ /** The overflow menu: the generic actions that did not fit on the bar,
729
+ * from the same registry the bar reads. */
730
+ morePopover(panel) {
731
+ const context = this.context(this.ids.slice());
732
+ const rows = [];
733
+ if (panel) {
734
+ const source = this.state.source(panel.source_id);
735
+ const status = this.state.sourceStatus[panel.source_id]?.status || "ok";
736
+ if (status === "changed") {
737
+ rows.push(this.menuItem("accept_source", "Accept the new image"));
738
+ }
739
+ if (source) {
740
+ rows.push(`<div class="fb-menu-note">${FigureSchema.escapeHtml(
741
+ source.display_name || source.datasource || "no source")}</div>`);
742
+ }
743
+ }
744
+ for (const action of FigureActions.forSurface("overflow", context.sel, context)) {
745
+ rows.push(this.menuItem(action.id, action.label,
746
+ { danger: action.danger,
747
+ disabled: !action.isEnabled,
748
+ icon: action.icon,
749
+ shortcut: action.shortcut }));
750
+ }
751
+ // Only once the bar has actually been moved. A permanent "put it back"
752
+ // is a row that says nothing for the whole of the time nobody has
753
+ // picked the bar up, which is nearly all of it.
754
+ if (this.moved) {
755
+ rows.push(this.menuItem("reset_bar", "Move the toolbar back",
756
+ { icon: "rotate-left" }));
757
+ }
758
+ return rows.join("");
759
+ }
760
+ /**
761
+ * One row of a menu: icon, label, key -- in three columns, in that order.
762
+ *
763
+ * The icon is optional and is drawn in a fixed-width gutter, so a list
764
+ * where only some rows have one still reads as a column of labels rather
765
+ * than as a ragged left edge. The shortcut is pushed to the RIGHT edge and
766
+ * greyed, which is the arrangement every platform menu uses and the reason
767
+ * it works: the labels are what the eye scans down, and a key set in the
768
+ * same colour immediately after a label of unpredictable length puts the
769
+ * least important text in the middle of the scan.
770
+ */
771
+ menuItem(act, label, options) {
772
+ const flags = options || {};
773
+ // Which attribute carries the command is the caller's, because two
774
+ // vocabularies come through this one row. `data-more` is a registry
775
+ // action with its own `run`; `data-arrange` is a layout command that
776
+ // FigureCanvas answers to directly. They looked like different kinds of
777
+ // menu when only the first had icons -- and they are not: they are the
778
+ // same row with a different dispatch behind it.
779
+ const attribute = flags.attribute || "data-more";
780
+ return `<button type="button" class="fb-menu-item${
781
+ flags.danger ? " is-danger" : ""}"
782
+ ${attribute}="${act}" ${flags.disabled ? "disabled" : ""}>
783
+ <span class="fb-menu-icon" aria-hidden="true">${flags.icon
784
+ ? `<span class="fas fa-${flags.icon}"></span>` : ""}</span>
785
+ <span class="fb-menu-text">${FigureSchema.escapeHtml(label)}</span>${flags.shortcut
786
+ ? `<span class="fb-menu-key" aria-hidden="true">${
787
+ FigureSchema.escapeHtml(flags.shortcut)}</span>` : ""}</button>`;
788
+ }
789
+
790
+
791
+ /**
792
+ * A press inside the popover, before focus has had a chance to move.
793
+ *
794
+ * Inserting a symbol has to happen HERE and not on click: pressing a button
795
+ * blurs the contenteditable first, blur is what commits and closes the text
796
+ * editor, and by the time a click handler ran there would be no editor left
797
+ * to ask where the caret was. Cancelling the default of the pointerdown is
798
+ * the only thing that stops the focus moving at all -- so the palette can
799
+ * be used several times in a row without the caret ever leaving the caption.
800
+ */
801
+ popoverPointerDown(event) {
802
+ const symbol = event.target.closest("[data-symbol]");
803
+ if (!symbol) return;
804
+ event.preventDefault();
805
+ this.handlers.onInsertSymbol?.(this.ids[0], symbol.dataset.symbol);
806
+ }
807
+
808
+ popoverClicked(event) {
809
+ const arrange = event.target.closest("[data-arrange]");
810
+ if (arrange) {
811
+ this.handlers.onArrange?.(arrange.dataset.arrange);
812
+ this.closePopover();
813
+ return;
814
+ }
815
+ const split = event.target.closest("[data-split]");
816
+ if (split) {
817
+ this.handlers.onSplit?.(split.dataset.split);
818
+ this.closePopover();
819
+ return;
820
+ }
821
+ const more = event.target.closest("[data-more]");
822
+ if (!more) return;
823
+ const ids = this.ids.slice();
824
+ const act = more.dataset.more;
825
+
826
+ // The two that stay open: one moves to a second popover, and the other
827
+ // is a form that has not been filled in yet.
828
+ if (act === "pixelsize") {
829
+ this.openPopover(this._anchor, "pixelsize");
830
+ return;
831
+ }
832
+ if (act === "apply_pixelsize") {
833
+ this.applyPixelSize();
834
+ return;
835
+ }
836
+
837
+ // The generic actions run from the registry, which is also what the
838
+ // right-click menu runs -- so a row cannot mean one thing in one menu
839
+ // and something else in the other.
840
+ const action = FigureActions.byId(act);
841
+ if (action && action.run) {
842
+ action.run(this.context(ids));
843
+ this.closePopover();
844
+ return;
845
+ }
846
+
847
+ ({
848
+ legend_keep: () => this.applyLegend({ channels: true }),
849
+ legend_share: () => this.shareLegendColours(),
850
+ reset_bar: () => this.resetPosition(),
851
+ accept_source: () => {
852
+ const panel = this.state.panel(ids[0]);
853
+ if (panel) this.handlers.onAcceptSource?.(panel.source_id);
854
+ },
855
+ }[act] || (() => {}))();
856
+ this.closePopover();
857
+ }
858
+
859
+ selectedPanels() {
860
+ return this.ids.map((id) => this.state.panel(id)).filter(Boolean);
861
+ }
862
+
863
+ /**
864
+ * One field changed.
865
+ *
866
+ * Committed straight away rather than on a Done button: every one of these
867
+ * is a single reversible property, autosave is immediate, and a popover
868
+ * with an OK in it is a dialog pretending to be a control.
869
+ *
870
+ * The panel fields apply across the whole selection, which is the point of
871
+ * the multi-panel bar -- doing a row of six one at a time is where a figure
872
+ * picks up the inconsistencies it is then hard to spot.
873
+ */
874
+ fieldChanged(field, input) {
875
+ // Transform first, and before the panel/annotation fork: it is the one
876
+ // form on this bar that reads the same four numbers off either kind.
877
+ if (field.startsWith("tf_")) {
878
+ this.transformChanged(field.slice(3), input);
879
+ return;
880
+ }
881
+ if (field === "units") {
882
+ // The figure's unit, not this popover's. There is one answer to
883
+ // "what is this page measured in" and the rulers already show it;
884
+ // a second copy here is how the two end up disagreeing.
885
+ this.handlers.onUnits?.(input.value);
886
+ this.refreshPopover();
887
+ return;
888
+ }
889
+ const annotation = this.ids.length === 1
890
+ ? this.state.document.annotations[this.ids[0]] : null;
891
+ if (annotation) {
892
+ this.annotationFieldChanged(field, input, annotation);
893
+ return;
894
+ }
895
+ // Numbering is the figure's, not a panel's: every label on the page is
896
+ // drawn from it, so it goes to the document rather than to whichever
897
+ // panel happened to be selected.
898
+ if (field === "label_style") {
899
+ this.handlers.onSettingsChange?.({ label_style: input.value });
900
+ return;
901
+ }
902
+
903
+ const panels = this.selectedPanels();
904
+ if (!panels.length) return;
905
+ const single = panels.length === 1 ? panels[0] : null;
906
+
907
+ if (field === "scalebar") {
908
+ this.applyToPanels(panels, (panel) => ({
909
+ scalebar: { ...panel.scalebar, visible: input.checked } }));
910
+ return;
911
+ }
912
+ if (field === "scalebar_length") {
913
+ const target = input.value ? parseFloat(input.value) : null;
914
+ this.applyToPanels(panels, (panel) => ({
915
+ scalebar: { ...panel.scalebar, target_um: target } }));
916
+ return;
917
+ }
918
+ if (field === "legend_channels" || field === "legend_plugins") {
919
+ const key = field === "legend_channels" ? "channels" : "plugins";
920
+ this.applyToPanels(panels, (panel) => ({
921
+ legend: { ...panel.legend, [key]: input.checked } }));
922
+ return;
923
+ }
924
+
925
+ if (!single) return;
926
+ const changes = {};
927
+ if (field === "title") changes.title = input.value;
928
+ else if (field === "label") {
929
+ // Typing a label makes it the user's; it stops renumbering when the
930
+ // page is rearranged, which is the whole difference between the two.
931
+ changes.label = { ...single.label, text: input.value, auto: false };
932
+ const auto = this.popover?.querySelector('[data-field="label_auto"]');
933
+ if (auto) auto.checked = false;
934
+ } else if (field === "label_auto") {
935
+ changes.label = { ...single.label, auto: input.checked };
936
+ } else if (field === "label_visible") {
937
+ changes.label = { ...single.label, visible: input.checked };
938
+ } else return;
939
+
940
+ this.handlers.onPanelChange?.(single.panel_id, changes);
941
+ }
942
+
943
+ /** One change, across the selection, as ONE undo step. */
944
+ applyToPanels(panels, changesFor) {
945
+ this.handlers.onPanelsChange?.(
946
+ panels.map((panel) => ({ panel_id: panel.panel_id, changes: changesFor(panel) })));
947
+ }
948
+
949
+ applyLegend(which) {
950
+ const panels = this.selectedPanels();
951
+ this.applyToPanels(panels, (panel) => ({
952
+ legend: { ...panel.legend, ...which } }));
953
+ }
954
+
955
+ /**
956
+ * Make every selected panel draw a marker the same colour.
957
+ *
958
+ * The FIRST panel's colour wins, because it is the one at the top left and
959
+ * the one the user was looking at when they asked. Every other panel is
960
+ * recoloured and re-rendered, which is why this is a button and not a
961
+ * default: it changes what the images look like.
962
+ */
963
+ shareLegendColours() {
964
+ const panels = this.selectedPanels();
965
+ const canonical = new Map();
966
+ for (const panel of panels) {
967
+ for (const channel of panel.scene.channels || []) {
968
+ const name = channel.fullname_at_capture || channel.key;
969
+ if (!canonical.has(name)) canonical.set(name, { ...channel.color });
970
+ }
971
+ }
972
+ this.handlers.onShareLegendColours?.(
973
+ panels.map((panel) => panel.panel_id), canonical);
974
+ }
975
+
976
+ applyPixelSize() {
977
+ const input = this.popover?.querySelector("#fb_ctx_mpp");
978
+ const value = parseFloat(input && input.value);
979
+ if (!Number.isFinite(value) || value <= 0) return;
980
+ const sources = this.uncalibratedSources(this.selectedPanels());
981
+ this.handlers.onSetPixelSize?.(sources.map((source) => source.source_id), value);
982
+ this.closePopover();
983
+ }
984
+
985
+ annotationFieldChanged(field, input, annotation) {
986
+ const style = {};
987
+ if (field === "font_size_pt" || field === "line_width_pt") {
988
+ const value = parseFloat(input.value);
989
+ if (!Number.isFinite(value)) return;
990
+ style[field] = value;
991
+ } else if (field === "align" || field === "color") {
992
+ style[field] = input.value;
993
+ } else if (field === "fill") {
994
+ style.fill = input.value;
995
+ } else if (field === "fill_on") {
996
+ // An empty string is how the schema says "no fill"; the colour
997
+ // input keeps its value so unticking and re-ticking comes back to
998
+ // the same colour rather than to black.
999
+ style.fill = input.checked
1000
+ ? (this.popover?.querySelector('[data-field="fill"]')?.value || "#ffffff")
1001
+ : "";
1002
+ } else return;
1003
+
1004
+ this.handlers.onAnnotationChange?.(annotation.annotation_id, { style: style });
1005
+ }
1006
+
1007
+ // -- transform ---------------------------------------------------------
1008
+
1009
+ /**
1010
+ * Where the selected object is and how big it is, in the figure's unit.
1011
+ *
1012
+ * One reader for both kinds. A panel keeps its geometry in `placement` and
1013
+ * an annotation in `geometry`, which is a difference in where the numbers
1014
+ * live rather than in what they mean, and every caller past this point is
1015
+ * better off not knowing about it.
1016
+ *
1017
+ * `rotation` is null for a panel: panels are not rotated, and a field that
1018
+ * is always 0 and cannot be changed is worse than no field.
1019
+ */
1020
+ transformValues() {
1021
+ if (this.ids.length !== 1) return null;
1022
+ const id = this.ids[0];
1023
+ const panel = this.state.panel(id);
1024
+ const box = panel ? panel.placement
1025
+ : this.state.document.annotations[id]?.geometry;
1026
+ if (!box) return null;
1027
+ const per = FigureContextBar.UNITS[this.unit()].per;
1028
+ const show = (mm) => String(Math.round((mm / per) * 1000) / 1000);
1029
+ return {
1030
+ w: show(box.w_mm), h: show(box.h_mm),
1031
+ rotation: panel ? null : String(Math.round(box.rotation || 0)),
1032
+ };
1033
+ }
1034
+
1035
+ /** The unit the figure is being measured in, asked of whoever owns it --
1036
+ * the View menu -- rather than kept a second time here. */
1037
+ unit() {
1038
+ const name = this.handlers.units?.();
1039
+ return name in FigureContextBar.UNITS ? name : "mm";
1040
+ }
1041
+
1042
+ /** The same table `FigureViewOptions.UNITS` holds, narrowed to the one
1043
+ * field this needs. Duplicated rather than reached for, because the bar
1044
+ * must render on a page where the View menu has not been built yet -- and
1045
+ * it is pinned equal by the boot probe. */
1046
+ static get UNITS() {
1047
+ return { mm: { per: 1, label: "mm" }, in: { per: 25.4, label: "in" },
1048
+ pt: { per: 25.4 / 72, label: "pt" } };
1049
+ }
1050
+
1051
+ transformPopover() {
1052
+ const now = this.transformValues();
1053
+ if (!now) return "";
1054
+ const unit = this.unit();
1055
+ const units = Object.entries(FigureContextBar.UNITS).map(([name, spec]) =>
1056
+ `<option value="${name}"${name === unit ? " selected" : ""}>${spec.label}</option>`)
1057
+ .join("");
1058
+ const number = (field, label, value, step, suffix) => `
1059
+ <label class="fb-tf-row">
1060
+ <span class="control-label">${FigureSchema.escapeHtml(label)}</span>
1061
+ <span class="fb-input-suffixed">
1062
+ <input class="fb-input fb-input-tiny" type="number" step="${step}"
1063
+ data-field="tf_${field}" value="${value}">
1064
+ <span class="fb-input-suffix" aria-hidden="true">${suffix}</span>
1065
+ </span>
1066
+ </label>`;
1067
+
1068
+ return (now.rotation === null ? ""
1069
+ : number("rotation", "Rotation", now.rotation, "1", "&deg;"))
1070
+ + number("w", "Width", now.w, "0.1", FigureContextBar.UNITS[unit].label)
1071
+ + number("h", "Height", now.h, "0.1", FigureContextBar.UNITS[unit].label)
1072
+ + `<label class="fb-tf-row">
1073
+ <span class="control-label">Units</span>
1074
+ <select class="fb-input fb-input-tiny" data-field="units"
1075
+ >${units}</select>
1076
+ </label>`;
1077
+ }
1078
+
1079
+ transformChanged(field, input) {
1080
+ const value = parseFloat(input.value);
1081
+ if (!Number.isFinite(value) || this.ids.length !== 1) return;
1082
+ const id = this.ids[0];
1083
+ if (field === "rotation") {
1084
+ this.handlers.onAnnotationChange?.(
1085
+ id, { geometry: { rotation: ((value % 360) + 360) % 360 } });
1086
+ return;
1087
+ }
1088
+ const mm = Math.max(1, value * FigureContextBar.UNITS[this.unit()].per);
1089
+ const key = field === "w" ? "w_mm" : "h_mm";
1090
+ const panel = this.state.panel(id);
1091
+ if (panel) {
1092
+ this.handlers.onPanelChange?.(
1093
+ id, { placement: { ...panel.placement, [key]: mm } });
1094
+ return;
1095
+ }
1096
+ const annotation = this.state.document.annotations[id];
1097
+ if (key === "h_mm" && annotation?.type === "text") {
1098
+ // Typing a height says the same thing dragging the bottom edge
1099
+ // says: this box is this tall. `autofit` recomputes the height from
1100
+ // the text on the very next write, so without clearing it the
1101
+ // number would go in and come straight back out again.
1102
+ this.handlers.onAnnotationChange?.(
1103
+ id, { geometry: { h_mm: mm }, style: { autofit: false } });
1104
+ return;
1105
+ }
1106
+ this.handlers.onAnnotationChange?.(id, { geometry: { [key]: mm } });
1107
+ }
1108
+
1109
+ /**
1110
+ * Put the current numbers back into an open Transform popover.
1111
+ *
1112
+ * Called after every canvas render, so dragging a corner moves the fields.
1113
+ * The focused input is skipped: rewriting the box somebody is typing into
1114
+ * is how "12" becomes "12.0" under the caret half-way through "12.5".
1115
+ */
1116
+ refreshPopover() {
1117
+ if (!this.popover || this.popover.dataset.act !== "transform") return;
1118
+ const now = this.transformValues();
1119
+ if (!now) return;
1120
+ for (const [field, value] of Object.entries(now)) {
1121
+ if (value === null) continue;
1122
+ const input = this.popover.querySelector(`[data-field="tf_${field}"]`);
1123
+ if (input && input !== document.activeElement) input.value = value;
1124
+ }
1125
+ }
1126
+
1127
+ // -- moving the bar out of the way --------------------------------------
1128
+ /**
1129
+ * A press on the bar: either a button that must not steal the focus, or the
1130
+ * start of a drag.
1131
+ *
1132
+ * Pressing a button here keeps focus where it was. This bar acts on a
1133
+ * selection, and while a caption is being typed into, the focus IS that
1134
+ * selection: taking it away blurs the editor, and blur commits and closes
1135
+ * it -- so Insert Symbol would act on an object that stopped being edited
1136
+ * the instant the button went down. Cancelling the pointerdown's default
1137
+ * suppresses the focus change; the click still fires.
1138
+ */
1139
+ barPointerDown(event) {
1140
+ if (event.target.closest("button")) {
1141
+ event.preventDefault();
1142
+ return;
1143
+ }
1144
+ if (event.target.closest("input, select, label")) return;
1145
+ this.dragStart(event);
1146
+ }
1147
+
1148
+ /**
1149
+ * Drag the bar itself.
1150
+ *
1151
+ * It sits over the page, and over a caption near the top of a panel it sits
1152
+ * over the thing being edited -- which is the one moment the controls are
1153
+ * most in the way. So it can be picked up by its grip and put somewhere
1154
+ * else, and the displacement is kept while the selection moves around
1155
+ * underneath it.
1156
+ *
1157
+ * An OFFSET rather than an absolute position, so the bar still follows the
1158
+ * selection: a bar parked at fixed coordinates stops being "the controls
1159
+ * for this object" and becomes a floating palette, which is the thing this
1160
+ * replaced. Not persisted either -- it is a way out of a collision on one
1161
+ * page, not a preference.
1162
+ */
1163
+ dragStart(event) {
1164
+ if (event.button !== 0) return;
1165
+ const from = { x: event.clientX, y: event.clientY };
1166
+ const start = { ...this.offset };
1167
+ this.closePopover();
1168
+ this.el.classList.add("is-dragging");
1169
+
1170
+ const move = (moved) => {
1171
+ this.offset = { x: start.x + (moved.clientX - from.x),
1172
+ y: start.y + (moved.clientY - from.y) };
1173
+ this.position();
1174
+ };
1175
+ const up = () => {
1176
+ window.removeEventListener("pointermove", move);
1177
+ window.removeEventListener("pointerup", up);
1178
+ this.el.classList.remove("is-dragging");
1179
+ };
1180
+ window.addEventListener("pointermove", move);
1181
+ window.addEventListener("pointerup", up);
1182
+ event.preventDefault();
1183
+ }
1184
+
1185
+ get moved() {
1186
+ return this.offset.x !== 0 || this.offset.y !== 0;
1187
+ }
1188
+
1189
+ resetPosition() {
1190
+ this.offset = { x: 0, y: 0 };
1191
+ this.position();
1192
+ }
1193
+ }