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,120 @@
1
+ """Identifying a source image, and noticing when it has changed underneath.
2
+
3
+ A figure keeps a reference, not a copy. That is what makes it small and what
4
+ makes it fragile in one specific way: the project it points at can be re-imported
5
+ with a different slide, have its channels renamed, or be deleted. A panel drawn
6
+ on the old image renders perfectly plausibly on the new one, in the wrong place,
7
+ and nothing about the pixels says otherwise.
8
+
9
+ So every source carries a fingerprint taken at capture time, and it is compared
10
+ on open. The comparison never rerenders anything: it produces `ok`, `changed`
11
+ or `missing`, and a `changed` source keeps its cached preview until the user
12
+ says what should happen. Silently re-rendering a scientifically established
13
+ panel from materially different data is the one outcome this whole module
14
+ exists to prevent.
15
+
16
+ **Nothing here loads a datasource.** Everything is read from the project record
17
+ (config.json), which is why this can be asked about eight sources in a row
18
+ without evicting the one the user is actually looking at -- `data_model` holds a
19
+ single loaded datasource behind a lock, and touching it here would make opening
20
+ a figure library unload the viewer.
21
+
22
+ The one fact that is NOT in the record is the physical pixel size, which lives
23
+ in the OME metadata and only comes back through a loader. It is therefore
24
+ captured client-side, at the moment of capture, for the datasource already on
25
+ screen -- and stored on the source. See `figureSceneSnapshot.js`.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from plexora import api
31
+
32
+
33
+ def channel_key(channel) -> str:
34
+ """The stable id of one image channel.
35
+
36
+ The last segment of the tile URL (`/generated/data/<ds>/<key>/`), which is
37
+ generated at import from the file name and the channel's position. It
38
+ survives renaming, which `fullname` is precisely what does not: a user who
39
+ relabels "LSP20209_2" as "CD8a" has not changed which channel it is, and a
40
+ figure that identified it by name would lose it.
41
+ """
42
+ src = (channel or {}).get("src") or ""
43
+ return src.strip("/").rsplit("/", 1)[-1]
44
+
45
+
46
+ def describe(datasource) -> dict:
47
+ """What a figure records about a project image, ready to store as a source.
48
+
49
+ Raises KeyError if there is no such project.
50
+ """
51
+ handle = api.dataset(datasource)
52
+ width, height = handle.image.size
53
+ channels = [
54
+ {"key": channel_key(channel),
55
+ "fullname_at_capture": channel.get("fullname") or channel.get("name") or ""}
56
+ for channel in handle.image.channels
57
+ if channel_key(channel)
58
+ ]
59
+ return {
60
+ "kind": "plexora_project",
61
+ "datasource": datasource,
62
+ "display_name": datasource,
63
+ "image": {"width": int(width or 0), "height": int(height or 0)},
64
+ "channels": channels,
65
+ "fingerprint": fingerprint(datasource),
66
+ "status": "ok",
67
+ }
68
+
69
+
70
+ def fingerprint(datasource) -> dict:
71
+ """The cheap identity check: dimensions, channel keys, mask present.
72
+
73
+ Deliberately not a hash of the pixels. Hashing a 40 GB pyramid to answer
74
+ "is this still the same slide" is a check nobody runs twice, and these four
75
+ facts catch every re-import that would put a panel in the wrong place.
76
+ """
77
+ handle = api.dataset(datasource)
78
+ width, height = handle.image.size
79
+ return {
80
+ "image_width": int(width or 0),
81
+ "image_height": int(height or 0),
82
+ "channel_keys": [channel_key(c) for c in handle.image.channels if channel_key(c)],
83
+ "has_segmentation": bool(handle.segmentation.available),
84
+ }
85
+
86
+
87
+ def status_of(source) -> dict:
88
+ """Compare a stored source against the project it names.
89
+
90
+ Returns `{status, reasons, fingerprint}`. `reasons` is a list of short codes
91
+ -- never prose -- because the client branches on them and wording is not an
92
+ API.
93
+ """
94
+ if source.get("kind") != "plexora_project":
95
+ # An imported asset lives inside the figure's own directory; nothing
96
+ # outside can change it.
97
+ return {"status": "ok", "reasons": [], "fingerprint": source.get("fingerprint") or {}}
98
+
99
+ datasource = source.get("datasource") or ""
100
+ try:
101
+ current = fingerprint(datasource)
102
+ except KeyError:
103
+ return {"status": "missing", "reasons": ["no_such_project"], "fingerprint": {}}
104
+
105
+ stored = source.get("fingerprint") or {}
106
+ reasons = []
107
+ if stored.get("image_width") and (stored.get("image_width"), stored.get("image_height")) != (
108
+ current["image_width"], current["image_height"]):
109
+ reasons.append("dimensions_changed")
110
+ stored_keys = set(stored.get("channel_keys") or [])
111
+ if stored_keys and not stored_keys.issubset(set(current["channel_keys"])):
112
+ reasons.append("channels_removed")
113
+ if stored.get("has_segmentation") and not current["has_segmentation"]:
114
+ reasons.append("segmentation_removed")
115
+
116
+ return {
117
+ "status": "changed" if reasons else "ok",
118
+ "reasons": reasons,
119
+ "fingerprint": current,
120
+ }
@@ -0,0 +1,155 @@
1
+ """Typographic constants, in one table, for three renderers.
2
+
3
+ The canvas, the PDF writer and the raster writer all have to put a baseline in
4
+ the same place. They cannot share code -- one of them is JavaScript -- so they
5
+ share a TABLE instead, and `figureRichText.js` carries a copy that
6
+ `test_the_two_sides_agree_on_the_typographic_constants` holds equal to this one.
7
+ That is the same arrangement as `export._arrow_head` and
8
+ `FigureCanvas.arrowHeadPoints`: one rule, two languages, pinned by a test.
9
+
10
+ Only the twelve Adobe core fonts are offered, and that is a deliberate ceiling
11
+ rather than a first step. reportlab knows their metrics without a font file, so
12
+ nothing has to ship, nothing has to be licensed, and no export can fail because
13
+ a font did not load. The three families also have metric-compatible substitutes
14
+ on every desktop platform -- Arial for Helvetica, Liberation Serif for Times,
15
+ Courier New for Courier -- so a browser measuring a string gets the same answer
16
+ the PDF writer will, which is what makes "wrap as you type" safe to store.
17
+
18
+ Sizes here are in POINTS and positions in MILLIMETRES, because that is the split
19
+ the rest of the plugin already uses: type is specified in points and the page is
20
+ measured in millimetres.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ #: Millimetres in one typographic point. Computed rather than written out:
26
+ #: `FigureCanvas.PT_PER_MM` is the rounded literal 2.8346 and the two must not
27
+ #: be confused. Rounding here would put a floor under how exactly the canvas and
28
+ #: the exporter can be asserted to agree.
29
+ MM_PER_PT = 25.4 / 72.0
30
+
31
+ #: Line box height as a multiple of the type size. 1.2 is the usual default and
32
+ #: replaces the 1.25 that `.fb-annotation-text` used to set in CSS -- the canvas
33
+ #: no longer leaves leading to the browser, so the number has to live somewhere
34
+ #: both languages can read it.
35
+ LINE_HEIGHT = 1.2
36
+
37
+ #: The type size a text box starts at, in points.
38
+ #:
39
+ #: NOT `settings.style.font_size_pt`, which is 8 and has to stay 8: that one is
40
+ #: the size of the furniture drawn ON a panel -- legend rows and the scale bar's
41
+ #: caption, both of which sit inside the image and have to be small enough to
42
+ #: stay out of it (see compose._panel_furniture). A caption BESIDE a figure is
43
+ #: read at a normal reading size, and it was inheriting the panel-label size
44
+ #: instead -- `FigureCanvas.drawStyle` asked for `label_size_pt`, which is the
45
+ #: size of the letter "A" in the corner of a panel and has nothing to do with a
46
+ #: sentence.
47
+ #:
48
+ #: Overridable on every box, so this is only where a new one starts.
49
+ DEFAULT_TEXT_SIZE_PT = 14.0
50
+
51
+ #: Ascent and descent as a fraction of the em, from the Adobe core AFM files.
52
+ #: Used to centre a line inside its line box, which is what makes vertical
53
+ #: alignment and multi-size lines land in the same place on all three renderers.
54
+ ASCENT = {
55
+ "Helvetica": 0.718,
56
+ "Times-Roman": 0.683,
57
+ "Courier": 0.629,
58
+ }
59
+ DESCENT = {
60
+ "Helvetica": 0.207,
61
+ "Times-Roman": 0.217,
62
+ "Courier": 0.157,
63
+ }
64
+
65
+ #: Underline and strike-through, as a fraction of the em from the baseline.
66
+ #: Drawn explicitly by every renderer rather than delegated to `text-decoration`
67
+ #: or whatever the PDF viewer would do: SVG takes its underline position from
68
+ #: the font file actually in use, so a browser showing Arial and a PDF holding
69
+ #: Helvetica would put the rule in two different places.
70
+ UNDERLINE_OFFSET_EM = 0.12
71
+ UNDERLINE_THICKNESS_EM = 0.06
72
+ STRIKE_OFFSET_EM = 0.26
73
+
74
+ #: The families a document may name. These are PostScript base names rather than
75
+ #: abstract keys like "sans", because `settings.style.font_family` already holds
76
+ #: "Helvetica" and hands it straight to reportlab's `setFont`. Keeping that
77
+ #: vocabulary means a figure written by this build still renders in the right
78
+ #: typeface in a build that predates this module.
79
+ FAMILIES = ("Helvetica", "Times-Roman", "Courier")
80
+ DEFAULT_FAMILY = "Helvetica"
81
+
82
+ _POSTSCRIPT = {
83
+ ("Helvetica", False, False): "Helvetica",
84
+ ("Helvetica", True, False): "Helvetica-Bold",
85
+ ("Helvetica", False, True): "Helvetica-Oblique",
86
+ ("Helvetica", True, True): "Helvetica-BoldOblique",
87
+ ("Times-Roman", False, False): "Times-Roman",
88
+ ("Times-Roman", True, False): "Times-Bold",
89
+ ("Times-Roman", False, True): "Times-Italic",
90
+ ("Times-Roman", True, True): "Times-BoldItalic",
91
+ ("Courier", False, False): "Courier",
92
+ ("Courier", True, False): "Courier-Bold",
93
+ ("Courier", False, True): "Courier-Oblique",
94
+ ("Courier", True, True): "Courier-BoldOblique",
95
+ }
96
+
97
+ #: What the browser should ask for, per family. Every stack names substitutes
98
+ #: that are metric-compatible with the core font, in the order they are likely
99
+ #: to exist: Arial (Windows/macOS) and Liberation Sans (Linux) were both drawn
100
+ #: to Helvetica's widths, so a line that fits on screen fits in the PDF.
101
+ #:
102
+ #: This lives here and not in the stylesheet so that the family list is one
103
+ #: table. `figureRichText.js` carries the same three strings.
104
+ CSS_STACK = {
105
+ "Helvetica": 'Helvetica, Arial, "Liberation Sans", sans-serif',
106
+ "Times-Roman": '"Times New Roman", Times, "Liberation Serif", serif',
107
+ "Courier": '"Courier New", Courier, "Liberation Mono", monospace',
108
+ }
109
+
110
+
111
+ def family(value):
112
+ """A family this build can draw, or the default.
113
+
114
+ Never raises. An unknown family is a style problem, and the document it
115
+ arrived in still has words in it worth showing.
116
+ """
117
+ return value if value in FAMILIES else DEFAULT_FAMILY
118
+
119
+
120
+ def postscript_name(name, bold=False, italic=False):
121
+ """The PostScript font for one family and weight.
122
+
123
+ Raises `KeyError` on a family this module does not know, deliberately: by
124
+ the time a run reaches here it has been through `schema.normalize_annotation`
125
+ and its family is whitelisted, so a miss is a typo in `_POSTSCRIPT` or a
126
+ family added to the UI and not to this table. The export path catches it,
127
+ substitutes the default and says so on the provenance page rather than
128
+ silently shipping the wrong typeface.
129
+ """
130
+ return _POSTSCRIPT[(name, bool(bold), bool(italic))]
131
+
132
+
133
+ def em_mm(size_pt):
134
+ """One em of this type size, in millimetres."""
135
+ return size_pt * MM_PER_PT
136
+
137
+
138
+ def line_metrics(runs, line_height=LINE_HEIGHT):
139
+ """Height and baseline offset of the line box holding these runs.
140
+
141
+ The tallest run sets the line box, and the line is centred inside it -- half
142
+ the leading above, half below -- so that a line mixing an 8 pt caption with
143
+ a 6 pt superscript sits where a reader expects rather than riding on the
144
+ bottom of its box.
145
+
146
+ Returns `(lead_mm, ascent_mm, descent_mm)`, where the baseline of the line
147
+ is `lead_mm - ascent_mm - descent_mm` halved, plus `ascent_mm`, below the top
148
+ of the box. `text_layout` does that sum; this function only measures.
149
+ """
150
+ if not runs:
151
+ return (0.0, 0.0, 0.0)
152
+ tallest = max(runs, key=lambda run: run["size_pt"])
153
+ em = em_mm(tallest["size_pt"])
154
+ name = family(tallest.get("family"))
155
+ return (em * line_height, em * ASCENT[name], em * DESCENT[name])
@@ -0,0 +1,363 @@
1
+ /**
2
+ * What can be done to a selection, declared once.
3
+ *
4
+ * The bar that floats over the selection and the right-click menu are two
5
+ * surfaces onto ONE list. They were two hand-maintained lists, and they had
6
+ * already drifted: the menu marked "Bring to front" disabled when nothing in
7
+ * the selection could be reordered, while the bar offered it live on a text box
8
+ * where `reorderZ` filtered annotations out and did nothing at all. Two lists
9
+ * meant two answers to "can this be done", and only one of them was right.
10
+ *
11
+ * The rule the reference designs encode, and the one used here:
12
+ *
13
+ * * an action that only means something for one kind appears only for that
14
+ * kind -- "Split into channels" on a caption is not disabled, it is noise;
15
+ * * an action that applies to ANY object is always REACHABLE, but a generic
16
+ * one that cannot run right now is reachable through "More" rather than as
17
+ * a dead icon on the bar.
18
+ *
19
+ * That second half used to read "always present, and greyed when it cannot
20
+ * run", and on a panel it was fine. On a text box it was not: Align, Distribute,
21
+ * Match size, Layout and Group all need two objects, so selecting one caption
22
+ * gave a bar of five grey icons and one live one, with everything actually
23
+ * worth pressing hidden behind the overflow. A greyed icon with no label is not
24
+ * an affordance; a greyed ROW that says "Align" is, because it names itself and
25
+ * the menu has room to. So the action stays in the vocabulary either way and
26
+ * only its surface moves -- which is still one declaration per action, with
27
+ * nothing branching on the type of the selection.
28
+ *
29
+ * `applies` decides whether an action exists for this selection at all, and
30
+ * `enabled` decides both whether it is clickable and, for a generic action,
31
+ * which of the two surfaces it lands on.
32
+ *
33
+ * Text FORMATTING is deliberately not here. Font, size, colour and the rest
34
+ * live in the text sidebar, because they are properties of one kind of object
35
+ * rather than actions on any object -- and putting them here is what would turn
36
+ * this back into a per-type list.
37
+ */
38
+ class FigureSelection {
39
+
40
+ /**
41
+ * Everything the predicates below need, computed once per selection change.
42
+ *
43
+ * Pure: it reads the document and the canvas but changes nothing, so a
44
+ * probe can build one from a plain object.
45
+ */
46
+ static describe(ids, state, canvas) {
47
+ const annotations = ids
48
+ .map((id) => state.document.annotations[id]).filter(Boolean);
49
+ const panels = ids.map((id) => state.panel(id)).filter(Boolean);
50
+ const single = ids.length === 1
51
+ ? (panels[0] || annotations[0] || null) : null;
52
+ const kinds = new Set(panels.map(() => "panel")
53
+ .concat(annotations.map((annotation) => annotation.type)));
54
+
55
+ return {
56
+ ids: ids,
57
+ count: ids.length,
58
+ panels: panels,
59
+ annotations: annotations,
60
+ single: single,
61
+ kinds: kinds,
62
+ singlePanel: ids.length === 1 && panels.length === 1 ? panels[0] : null,
63
+ singleAnnotation:
64
+ ids.length === 1 && annotations.length === 1 ? annotations[0] : null,
65
+ isText: annotations.length === ids.length && ids.length > 0
66
+ && annotations.every((a) => a.type === "text"),
67
+ // A box, as opposed to a line or an arrow, whose geometry is a
68
+ // vector and which therefore has no corners to rotate about.
69
+ allBoxes: ids.length > 0 && annotations.length === ids.length
70
+ && annotations.every((a) => !["line", "arrow"].includes(a.type)),
71
+ grouped: ids.some((id) => canvas.groupFor(id)),
72
+ placed: panels.filter((panel) => panel.placement),
73
+ // How many of the selected objects have a RECTANGLE that Align,
74
+ // Distribute, Match size and Layout can act on, which is not the
75
+ // same as how many objects there are. `count > 1` was the test, and
76
+ // it was wrong twice over: it counted a panel still in the tray,
77
+ // which has no placement to move, and it counted a line, whose
78
+ // `w_mm`/`h_mm` are the two components of a vector rather than a
79
+ // size -- "same width" on one would flip its direction. Two
80
+ // captions now enable these; two arrows do not, and say so by being
81
+ // greyed in "More" rather than by doing nothing when pressed.
82
+ arrangeable: panels.filter((panel) => panel.placement).length
83
+ + annotations.filter(
84
+ (a) => !["line", "arrow"].includes(a.type)).length,
85
+ };
86
+ }
87
+ }
88
+
89
+
90
+ class FigureActions {
91
+
92
+ /**
93
+ * The vocabulary. `surface` says where an action can appear:
94
+ *
95
+ * bar a button on the floating bar
96
+ * overflow a row in the bar's "More" menu
97
+ * menu a row in the right-click menu
98
+ *
99
+ * An action may name several. `popover: true` means the bar button opens a
100
+ * popover whose body `FigureContextBar.popoverMarkup` builds -- the detail
101
+ * of a scale bar or a legend is panel-specific and belongs there; what
102
+ * belongs HERE is only whether the button exists at all.
103
+ *
104
+ * `label` is the full sentence a menu row can afford. `short` is what the
105
+ * bar prints under the icon, and it is a separate field rather than a
106
+ * truncation because "Title, label and numbering" cut to fit says nothing
107
+ * and "Titles" says the whole thing. An action with no `short` prints its
108
+ * `label`, which is right for the ones that are already one word.
109
+ *
110
+ * `shortcut` is a FIELD and not two spaces and a glyph on the end of the
111
+ * label, which is how it was written first. A menu row sets the label and
112
+ * the key in two columns -- the labels ranged left, the keys ranged right
113
+ * and greyed -- and that is only possible if the row is given them
114
+ * separately. Concatenated, the keys landed wherever each label happened to
115
+ * end, which is a ragged column of the least important text on screen; and
116
+ * the bar's tooltip read "Group ⌘G" with the double space in it.
117
+ */
118
+ static get ALL() {
119
+ const always = () => true;
120
+ return [
121
+ // -- type-specific, present only for what they mean something to --
122
+ { id: "edit_text", icon: "i-cursor", label: "Edit the text",
123
+ short: "Edit", surface: ["bar", "menu"],
124
+ applies: (sel) => Boolean(sel.singleAnnotation)
125
+ && sel.singleAnnotation.type === "text",
126
+ enabled: always,
127
+ run: (ctx) => ctx.handlers.onEditText?.(ctx.ids[0]) },
128
+
129
+ // A character palette rather than a search box: the ones a figure
130
+ // caption actually needs are two dozen, and a grid of two dozen is
131
+ // faster to read than any field is to type into. It is on the BAR
132
+ // and not only in the sidebar because the caret it inserts at is on
133
+ // the canvas, and walking away to a panel to reach it is what loses
134
+ // the caret.
135
+ { id: "symbol", icon: "icons", label: "Insert a symbol",
136
+ short: "Symbol", surface: ["bar"], popover: true,
137
+ applies: (sel) => Boolean(sel.singleAnnotation)
138
+ && sel.singleAnnotation.type === "text",
139
+ enabled: always },
140
+
141
+ { id: "quick_edit", icon: "sliders", label: "Quick Edit…",
142
+ short: "Quick Edit", surface: ["bar", "menu"],
143
+ applies: (sel) => Boolean(sel.singlePanel),
144
+ enabled: (sel, ctx) => FigureActions.reopenable(sel.singlePanel, ctx),
145
+ run: (ctx) => ctx.handlers.onQuickEdit?.(ctx.ids[0]) },
146
+
147
+ { id: "edit", icon: "arrow-up-right-from-square",
148
+ label: "Open in Main Viewer", short: "Viewer",
149
+ surface: ["bar", "menu"],
150
+ applies: (sel) => Boolean(sel.singlePanel),
151
+ enabled: (sel, ctx) => FigureActions.reopenable(sel.singlePanel, ctx),
152
+ run: (ctx) => ctx.handlers.onEditPanel?.(ctx.ids[0]) },
153
+
154
+ { id: "titles", icon: "font", label: "Title, label and numbering",
155
+ short: "Titles", surface: ["bar"], popover: true,
156
+ applies: (sel) => Boolean(sel.singlePanel), enabled: always },
157
+
158
+ { id: "split", icon: "table-columns", label: "Split into channels",
159
+ short: "Split", surface: ["bar"], popover: true,
160
+ applies: (sel) => Boolean(sel.singlePanel)
161
+ && (sel.singlePanel.scene.channels || []).length > 1,
162
+ enabled: always },
163
+
164
+ { id: "scalebar", icon: "ruler-horizontal", label: "Scale bar",
165
+ surface: ["bar"], popover: true,
166
+ applies: (sel) => sel.panels.length > 0, enabled: always,
167
+ pressed: (sel) => sel.panels.some((panel) => panel.scalebar.visible) },
168
+
169
+ { id: "legend", icon: "list", label: "Legend",
170
+ surface: ["bar"], popover: true,
171
+ applies: (sel) => sel.panels.length > 0, enabled: always,
172
+ pressed: (sel) => sel.panels.some(
173
+ (panel) => panel.legend.channels || panel.legend.plugins) },
174
+
175
+ { id: "stroke", icon: "pen", label: "Line and fill", short: "Line",
176
+ surface: ["bar"], popover: true,
177
+ applies: (sel) => Boolean(sel.singleAnnotation)
178
+ && sel.singleAnnotation.type !== "text",
179
+ enabled: always },
180
+
181
+ { id: "colour", icon: "palette", label: "Color",
182
+ surface: ["bar"], popover: true,
183
+ // Not for text: a caption's colour belongs with its font and its
184
+ // size, in the sidebar, and offering it in two places is how the
185
+ // two end up disagreeing about what "the colour" means when only
186
+ // half the sentence is red.
187
+ applies: (sel) => Boolean(sel.singleAnnotation)
188
+ && sel.singleAnnotation.type !== "text",
189
+ enabled: always },
190
+
191
+ // -- generic: on the bar when they can run, in "More" when not ----
192
+ { id: "align", icon: "align-center", label: "Align",
193
+ surface: ["bar"], popover: true, generic: true,
194
+ applies: always, enabled: (sel) => sel.arrangeable > 1 },
195
+
196
+ { id: "distribute", icon: "arrows-left-right", label: "Distribute",
197
+ surface: ["bar"], popover: true, generic: true,
198
+ applies: always, enabled: (sel) => sel.arrangeable > 2 },
199
+
200
+ // `expand`, not `vector-square`. The second is a Font Awesome 5/6
201
+ // name that FA 7 no longer ships, and an icon name the set does not
202
+ // have renders as nothing at all -- so this was a button captioned
203
+ // "Match" with a blank square above it. tests/test_tool_assets.py
204
+ // now checks every name in this tree against the set.
205
+ { id: "resize", icon: "expand", label: "Match size",
206
+ short: "Match", surface: ["bar"], popover: true, generic: true,
207
+ applies: always, enabled: (sel) => sel.arrangeable > 1 },
208
+
209
+ // "Layout", not "Arrange". Arrange is z-order in every design tool
210
+ // and in the reference this bar follows; the row/column/grid one
211
+ // needed the other name. `data-arrange` stays as it is -- it is the
212
+ // contract FigureCanvas answers to, and this is a label change.
213
+ { id: "layout", icon: "table-cells", label: "Layout",
214
+ surface: ["bar"], popover: true, generic: true,
215
+ applies: always, enabled: (sel) => sel.arrangeable > 1 },
216
+
217
+ // Z-order as one popover rather than four overflow rows. "Arrange"
218
+ // means z-order in every design tool, which is why the row/column
219
+ // /grid action above had to take the name "Layout" -- and it is the
220
+ // one generic action that is live on a selection of ONE, which is
221
+ // what makes it worth a button rather than a menu row.
222
+ { id: "arrange", icon: "layer-group", label: "Arrange",
223
+ surface: ["bar"], popover: true, generic: true,
224
+ applies: always,
225
+ enabled: (sel) => sel.placed.length > 0 || sel.annotations.length > 0 },
226
+
227
+ // Position and size as numbers, for the times the pointer cannot
228
+ // get there: a 0.5 mm nudge at 40% zoom, or two captions that have
229
+ // to be exactly the same width as each other.
230
+ { id: "transform", icon: "up-down-left-right", label: "Transform",
231
+ surface: ["bar"], popover: true, generic: true,
232
+ applies: always,
233
+ // One object, and one that HAS a width and a height. A field
234
+ // showing a single number for a selection of three is either
235
+ // lying or blank, and a line's `w_mm`/`h_mm` are the two
236
+ // components of a vector rather than a size -- a box to type a
237
+ // width into would be a box that means something else.
238
+ enabled: (sel) => sel.count === 1
239
+ && (Boolean(sel.singlePanel) || sel.allBoxes) },
240
+
241
+ { id: "group", icon: "object-group", label: "Group", shortcut: "⌘G",
242
+ short: "Group", surface: ["bar", "menu"], generic: true,
243
+ applies: (sel) => !sel.grouped, enabled: (sel) => sel.count > 1,
244
+ run: (ctx) => ctx.canvas.groupSelection() },
245
+
246
+ { id: "ungroup", icon: "object-ungroup", label: "Ungroup", shortcut: "⇧⌘G",
247
+ short: "Ungroup", surface: ["bar", "menu"], generic: true,
248
+ applies: (sel) => sel.grouped, enabled: always,
249
+ run: (ctx) => ctx.canvas.ungroupSelection() },
250
+
251
+ // Duplicate and Delete earn a button for the same reason Arrange
252
+ // does: they are the two things anyone does to a single object of
253
+ // any kind, and they were the reason the overflow had to be opened
254
+ // at all on a text selection.
255
+ { id: "duplicate", icon: "clone", label: "Duplicate", shortcut: "⌘D",
256
+ short: "Duplicate", surface: ["bar", "menu"],
257
+ generic: true, applies: always, enabled: (sel) => sel.count > 0,
258
+ run: (ctx) => ctx.canvas.duplicateSelection() },
259
+
260
+ // -- generic, in the overflow and the right-click menu ------------
261
+ { id: "copy", icon: "copy", label: "Copy", shortcut: "⌘C",
262
+ surface: ["overflow", "menu"],
263
+ generic: true, applies: always, enabled: (sel) => sel.count > 0,
264
+ run: (ctx) => ctx.canvas.copySelection() },
265
+
266
+ // The four z-order commands, reached on the bar through the Arrange
267
+ // popover -- which is why none of them is on it directly. One
268
+ // command in two places on the same toolbar is how the bar and the
269
+ // right-click menu ended up disagreeing before.
270
+ //
271
+ // Four rather than two: "to the front" and "one place forward" are
272
+ // different intents, and getting a panel BETWEEN two others is only
273
+ // possible with the relative pair.
274
+ { id: "front", icon: "angles-up", label: "Bring to front", shortcut: "⌘⇧]",
275
+ surface: ["menu"], generic: true, applies: always,
276
+ // Enabled for annotations too, now that `reorderZ` reorders them.
277
+ // This was the drift: the menu said no and the bar said yes, and
278
+ // the bar was wrong.
279
+ enabled: (sel) => sel.placed.length > 0 || sel.annotations.length > 0,
280
+ run: (ctx) => ctx.canvas.reorderZ("front") },
281
+
282
+ { id: "forward", icon: "angle-up", label: "Bring forward", shortcut: "⌘]",
283
+ surface: ["menu"], generic: true, applies: always,
284
+ enabled: (sel) => sel.placed.length > 0 || sel.annotations.length > 0,
285
+ run: (ctx) => ctx.canvas.reorderZ("forward") },
286
+
287
+ { id: "backward", icon: "angle-down", label: "Send backward", shortcut: "⌘[",
288
+ surface: ["menu"], generic: true, applies: always,
289
+ enabled: (sel) => sel.placed.length > 0 || sel.annotations.length > 0,
290
+ run: (ctx) => ctx.canvas.reorderZ("backward") },
291
+
292
+ { id: "back", icon: "angles-down", label: "Send to back", shortcut: "⌘⇧[",
293
+ surface: ["menu"], generic: true, applies: always,
294
+ enabled: (sel) => sel.placed.length > 0 || sel.annotations.length > 0,
295
+ run: (ctx) => ctx.canvas.reorderZ("back") },
296
+
297
+ // The tray, because that is where it goes: off the page and back
298
+ // into the figure's holding area, which is the whole difference
299
+ // between this row and Delete two rows down.
300
+ { id: "remove_page", icon: "inbox", label: "Remove from page",
301
+ surface: ["overflow", "menu"], generic: true,
302
+ applies: (sel) => sel.panels.length > 0, enabled: always,
303
+ run: (ctx) => ctx.handlers.onRemoveFromPage?.() },
304
+
305
+ { id: "delete", icon: "trash", label: "Delete from figure",
306
+ short: "Delete", shortcut: "⌫", danger: true,
307
+ surface: ["bar", "menu"],
308
+ generic: true, applies: always, enabled: (sel) => sel.count > 0,
309
+ run: (ctx) => ctx.handlers.onDeleteFromFigure?.(ctx.ids) },
310
+ ];
311
+ }
312
+
313
+ /** The four z-order commands, in the order they belong in a menu: nearest
314
+ * the viewer at the top. Named here rather than in the bar, so the popover
315
+ * and the right-click menu cannot end up listing different ones. */
316
+ static get ARRANGE() {
317
+ return ["front", "forward", "backward", "back"];
318
+ }
319
+
320
+ /** A panel can be reopened only if its source is a project that is still
321
+ * there -- an imported PNG has no viewer to go back to. */
322
+ static reopenable(panel, ctx) {
323
+ if (!panel) return false;
324
+ const source = ctx.state.source(panel.source_id);
325
+ return Boolean(source && source.kind === "plexora_project" && source.datasource);
326
+ }
327
+
328
+ /**
329
+ * The actions one surface should show for this selection, in order.
330
+ *
331
+ * The bar and the overflow are one list split in two by whether a generic
332
+ * action can currently run, so an action never falls out of reach: what the
333
+ * bar drops, "More" picks up, named and greyed. Type-specific actions are
334
+ * not moved -- they are on the bar because they are the point of having
335
+ * selected that kind of object, and `applies` has already decided they
336
+ * belong. The right-click menu takes everything its entries declare,
337
+ * enabled or not, because there every row carries its own label.
338
+ */
339
+ static forSurface(surface, sel, ctx) {
340
+ const available = FigureActions.ALL
341
+ .filter((action) => action.applies(sel, ctx))
342
+ .map((action) => ({
343
+ ...action,
344
+ isEnabled: Boolean(action.enabled(sel, ctx)),
345
+ isPressed: Boolean(action.pressed && action.pressed(sel, ctx)),
346
+ }));
347
+
348
+ if (surface === "bar") {
349
+ return available.filter((action) => action.surface.includes("bar")
350
+ && (action.isEnabled || !action.generic));
351
+ }
352
+ if (surface === "overflow") {
353
+ return available.filter((action) => action.surface.includes("overflow")
354
+ || (action.surface.includes("bar") && action.generic
355
+ && !action.isEnabled));
356
+ }
357
+ return available.filter((action) => action.surface.includes(surface));
358
+ }
359
+
360
+ static byId(id) {
361
+ return FigureActions.ALL.find((action) => action.id === id) || null;
362
+ }
363
+ }