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,253 @@
1
+ /**
2
+ * FigureContextMenu - the right-click, on the page and in the tray.
3
+ *
4
+ * The third level of the interface. Level 1 is the tool rail and the topbar
5
+ * (always visible), level 2 is the floating bar (visible while something is
6
+ * selected), and this is everything else: the actions that are worth having and
7
+ * are not worth a permanent button, which is most of them.
8
+ *
9
+ * Having it is what lets the rest of the interface stay small. Bring to front,
10
+ * Replace image, Copy, Delete from figure and the rest are all one click away
11
+ * from the thing they act on, and none of them costs a pixel of chrome.
12
+ *
13
+ * ## Two deletes, and they are not the same delete
14
+ *
15
+ * "Remove from page" unplaces a panel: it goes back to the tray with its
16
+ * captured scene intact. "Delete from figure" destroys it. The distinction is
17
+ * load-bearing -- a captured scene can be the only record of a view somebody
18
+ * spent an hour finding -- so the destructive one is further away, worded as
19
+ * what it does, and asks first.
20
+ */
21
+ class FigureContextMenu {
22
+
23
+ constructor(options) {
24
+ this.state = options.state;
25
+ this.canvas = options.canvas;
26
+ this.handlers = options.handlers || {};
27
+ this.el = null;
28
+ //: Where the menu was opened, in page millimetres. Paste puts things
29
+ //: where the user right-clicked, which is the only place they could
30
+ //: have meant.
31
+ this.point = null;
32
+ }
33
+
34
+ setup(surfaceEl, trayEl) {
35
+ this._onSurface = (event) => this.openForCanvas(event);
36
+ surfaceEl?.addEventListener("contextmenu", this._onSurface);
37
+ this._onTray = (event) => this.openForTray(event);
38
+ trayEl?.addEventListener("contextmenu", this._onTray);
39
+
40
+ this._onDismiss = (event) => {
41
+ if (this.el && !this.el.contains(event.target)) this.close();
42
+ };
43
+ document.addEventListener("pointerdown", this._onDismiss, true);
44
+ this._onScroll = () => this.close();
45
+ window.addEventListener("scroll", this._onScroll, true);
46
+ }
47
+
48
+ destroy() {
49
+ document.removeEventListener("pointerdown", this._onDismiss, true);
50
+ window.removeEventListener("scroll", this._onScroll, true);
51
+ this.close();
52
+ }
53
+
54
+ // -- opening -----------------------------------------------------------
55
+
56
+ openForCanvas(event) {
57
+ event.preventDefault();
58
+ this.point = this.canvas.surfacePoint(event);
59
+
60
+ const panelEl = event.target.closest?.(".fb-panel");
61
+ const annotationEl = event.target.closest?.(".fb-annotation");
62
+ const id = panelEl ? panelEl.dataset.panelId
63
+ : annotationEl ? annotationEl.dataset.annotationId : null;
64
+
65
+ // Right-clicking something that is not in the selection selects it
66
+ // first. A menu that acted on a selection somewhere else on the page is
67
+ // the classic way to delete the wrong thing.
68
+ if (id && !this.canvas.selection.has(id)) this.canvas.select([id], false);
69
+ if (!id) this.canvas.select([], false);
70
+
71
+ const ids = Array.from(this.canvas.selection);
72
+ this.open(event.clientX, event.clientY,
73
+ ids.length ? this.selectionEntries(ids) : this.pageEntries());
74
+ }
75
+
76
+ openForTray(event) {
77
+ const item = event.target.closest?.(".fb-tray-item");
78
+ if (!item) return;
79
+ event.preventDefault();
80
+ this.point = null;
81
+ this.handlers.onTrayContext?.(item.dataset.panelId);
82
+ this.open(event.clientX, event.clientY,
83
+ this.trayEntries(this.handlers.traySelection?.() || [item.dataset.panelId]));
84
+ }
85
+
86
+ // -- what each menu holds ------------------------------------------------
87
+
88
+ /**
89
+ * The right-click rows, from the same registry the floating bar reads.
90
+ *
91
+ * It was a separate hand-built list, and the two had already drifted: this
92
+ * one honestly disabled "Bring to front" when nothing could be reordered
93
+ * while the bar offered it live on a text box, where it did nothing.
94
+ */
95
+ selectionEntries(ids) {
96
+ const context = {
97
+ ids: ids,
98
+ sel: FigureSelection.describe(ids, this.state, this.canvas),
99
+ canvas: this.canvas,
100
+ state: this.state,
101
+ handlers: this.handlers,
102
+ };
103
+ const entries = [];
104
+ let generic = null;
105
+ for (const action of FigureActions.forSurface("menu", context.sel, context)) {
106
+ if (generic !== null && Boolean(action.generic) !== generic) {
107
+ entries.push({ separator: true });
108
+ }
109
+ generic = Boolean(action.generic);
110
+ entries.push({ act: action.id, label: action.label,
111
+ icon: action.icon, shortcut: action.shortcut,
112
+ disabled: !action.isEnabled, danger: action.danger });
113
+ }
114
+ return entries;
115
+ }
116
+
117
+ pageEntries() {
118
+ return [
119
+ { act: "paste", label: "Paste", disabled: !FigureClipboard.hasContent() },
120
+ { separator: true },
121
+ { act: "select_all", label: "Select everything on this page" },
122
+ { separator: true },
123
+ { act: "page_background", label: "Page background…" },
124
+ { act: "page_duplicate", label: "Duplicate this page" },
125
+ ];
126
+ }
127
+
128
+ trayEntries(ids) {
129
+ return [
130
+ { act: "tray_place", label: ids.length > 1 ? "Place all on this page" : "Place on this page" },
131
+ { act: "tray_duplicate", label: "Duplicate" },
132
+ { separator: true },
133
+ { act: "tray_delete", label: "Delete from figure", danger: true },
134
+ ];
135
+ }
136
+
137
+ // -- the menu itself -----------------------------------------------------
138
+
139
+ open(clientX, clientY, entries) {
140
+ this.close();
141
+ this.el = document.createElement("div");
142
+ this.el.className = "fb-menu";
143
+ // The icon gutter is drawn only when SOME row in this menu has an icon.
144
+ // A fixed gutter is what stops a half-iconed list having a ragged left
145
+ // edge; in a list where no row has one at all -- the page menu, the tray
146
+ // menu -- it is fourteen pixels of indent that says nothing.
147
+ const gutter = entries.some((entry) => entry.icon);
148
+ this.el.innerHTML = entries.map((entry) => entry.separator
149
+ ? '<span class="fb-menu-separator"></span>'
150
+ : `<button type="button" class="fb-menu-item${entry.danger ? " is-danger" : ""}"
151
+ data-act="${entry.act}" ${entry.disabled ? "disabled" : ""}>${gutter
152
+ ? `<span class="fb-menu-icon" aria-hidden="true">${entry.icon
153
+ ? `<span class="fas fa-${entry.icon}"></span>` : ""}</span>` : ""}
154
+ <span class="fb-menu-text">${FigureSchema.escapeHtml(entry.label)}</span>${
155
+ entry.shortcut
156
+ ? `<span class="fb-menu-key" aria-hidden="true">${
157
+ FigureSchema.escapeHtml(entry.shortcut)}</span>` : ""}</button>`
158
+ ).join("");
159
+ document.body.appendChild(this.el);
160
+
161
+ // Flipped rather than clipped: a menu opened near the bottom right of
162
+ // the window has to open up and to the left, or its last item -- which
163
+ // is the destructive one -- is off screen.
164
+ // offsetWidth/Height and not getBoundingClientRect: the card animates
165
+ // in with a `scale(0.97)`, and a client rect is the TRANSFORMED box.
166
+ // Measuring it mid-animation would under-report the size by a few
167
+ // pixels and decide "does this fit below?" from a number that stops
168
+ // being true 120ms later. The offset pair is the layout box, which the
169
+ // animation never touches.
170
+ const size = { width: this.el.offsetWidth, height: this.el.offsetHeight };
171
+ const left = clientX + size.width > window.innerWidth - 8
172
+ ? Math.max(8, clientX - size.width) : clientX;
173
+ const top = clientY + size.height > window.innerHeight - 8
174
+ ? Math.max(8, clientY - size.height) : clientY;
175
+ this.el.style.left = Math.round(left) + "px";
176
+ this.el.style.top = Math.round(top) + "px";
177
+
178
+ this.el.addEventListener("click", (event) => {
179
+ const item = event.target.closest("[data-act]");
180
+ if (!item || item.disabled) return;
181
+ const act = item.dataset.act;
182
+ this.close();
183
+ this.run(act);
184
+ });
185
+ }
186
+
187
+ close() {
188
+ this.el?.remove();
189
+ this.el = null;
190
+ }
191
+
192
+ run(act) {
193
+ const ids = Array.from(this.canvas.selection);
194
+ const handlers = this.handlers;
195
+
196
+ // A selection action runs from the registry. Only the entries that are
197
+ // about the PAGE or the TRAY rather than about the selection are
198
+ // dispatched here, because those have no selection to describe.
199
+ const action = FigureActions.byId(act);
200
+ if (action && action.run) {
201
+ action.run({ ids: ids,
202
+ sel: FigureSelection.describe(ids, this.state, this.canvas),
203
+ canvas: this.canvas, state: this.state,
204
+ handlers: handlers });
205
+ return;
206
+ }
207
+ ({
208
+ paste: () => this.canvas.paste(this.point),
209
+ select_all: () => this.canvas.selectAllOnPage(),
210
+ page_background: () => handlers.onPageBackground?.(),
211
+ page_duplicate: () => handlers.onDuplicatePage?.(),
212
+ tray_place: () => handlers.onPlaceFromTray?.(),
213
+ tray_duplicate: () => handlers.onDuplicateTray?.(),
214
+ tray_delete: () => handlers.onDeleteTray?.(),
215
+ }[act] || (() => {}))();
216
+ }
217
+ }
218
+
219
+
220
+ /**
221
+ * The clipboard, for this tab.
222
+ *
223
+ * A module singleton rather than the OS clipboard. What is copied here is a
224
+ * captured SCENE -- a source id, a region in image pixels, a set of channels
225
+ * and windows -- and none of that means anything in another application, or in
226
+ * another figure that does not reference the same image. Writing it to the
227
+ * system clipboard would offer a paste everywhere that can only fail almost
228
+ * everywhere.
229
+ *
230
+ * Cross-tab paste is deliberately not built either: two tabs on one figure is
231
+ * the case the whole revision system exists to keep honest, and a paste that
232
+ * crossed between them would arrive against a revision it was not copied from.
233
+ */
234
+ const FigureClipboard = {
235
+ panels: [],
236
+ annotations: [],
237
+
238
+ hasContent() {
239
+ return this.panels.length > 0 || this.annotations.length > 0;
240
+ },
241
+
242
+ put(panels, annotations) {
243
+ this.panels = JSON.parse(JSON.stringify(panels || []));
244
+ this.annotations = JSON.parse(JSON.stringify(annotations || []));
245
+ },
246
+
247
+ take() {
248
+ return {
249
+ panels: JSON.parse(JSON.stringify(this.panels)),
250
+ annotations: JSON.parse(JSON.stringify(this.annotations)),
251
+ };
252
+ },
253
+ };
@@ -0,0 +1,275 @@
1
+ /**
2
+ * FigureDocumentState - the open figure, and everything about saving it.
3
+ *
4
+ * One object owns the document, the revision, the save queue and the undo
5
+ * stack, because all four are the same question asked from different angles and
6
+ * splitting them is how a client ends up saving a revision it no longer holds.
7
+ *
8
+ * Three decisions worth stating:
9
+ *
10
+ * **The server is the only interpreter of operations.** This side edits its own
11
+ * copy directly and sends the equivalent operation list; it does not reimplement
12
+ * `operations.py`. Two implementations of "what does add_panel mean" is two
13
+ * implementations that can disagree, and the disagreement would be invisible
14
+ * until a figure was reopened. If the server rejects a batch, the local copy is
15
+ * rolled back to the last confirmed one -- so the two can never drift, because
16
+ * only agreement survives.
17
+ *
18
+ * **Saves are chained, never concurrent**, and each reads the revision at the
19
+ * moment it is SENT rather than the moment the edit was made. Those two moments
20
+ * are not the same one: a commit made while an earlier save is still in flight
21
+ * would otherwise carry a revision the server has already moved past, and be
22
+ * answered with a 409 that no other session had anything to do with. Reading it
23
+ * late is safe precisely because the chain means nothing else can have written
24
+ * in between. Chaining also makes the outcome deterministic: without it, a slow
25
+ * earlier save can land after a newer one and quietly reinstate what the newer
26
+ * one changed.
27
+ *
28
+ * **Undo is a snapshot, not an inverse.** A figure document is a few hundred
29
+ * kilobytes at worst -- it holds references and geometry, never pixels -- so
30
+ * keeping N of them costs nothing next to writing and maintaining an inverse
31
+ * for every operation, and an inverse that is subtly wrong corrupts a figure
32
+ * silently. Undo is sent as a whole-document replace, which is a new revision:
33
+ * revisions only ever go forwards, and the conflict check depends on that.
34
+ */
35
+ class FigureDocumentState {
36
+
37
+ /** How many undo steps are kept. Past this, the oldest is dropped. */
38
+ static get HISTORY_LIMIT() { return 50; }
39
+
40
+ constructor(options) {
41
+ this.api = options.api;
42
+ this.figureId = options.figureId;
43
+
44
+ this.document = null;
45
+ this.sourceStatus = {};
46
+ /** "loading" | "saved" | "saving" | "unsaved" | "failed" | "conflict" | "unreadable" */
47
+ this.status = "loading";
48
+ this.statusDetail = "";
49
+
50
+ this._listeners = { change: [], status: [] };
51
+ this._chain = Promise.resolve();
52
+ this._undo = [];
53
+ this._redo = [];
54
+
55
+ ["change", "status"].forEach((name) => {
56
+ if (typeof options["on" + name.charAt(0).toUpperCase() + name.slice(1)] === "function") {
57
+ this._listeners[name].push(options["on" + name.charAt(0).toUpperCase() + name.slice(1)]);
58
+ }
59
+ });
60
+ }
61
+
62
+ on(event, handler) {
63
+ if (this._listeners[event]) this._listeners[event].push(handler);
64
+ return this;
65
+ }
66
+
67
+ _emit(event, payload) {
68
+ (this._listeners[event] || []).forEach((handler) => {
69
+ try {
70
+ handler(payload, this);
71
+ } catch (error) {
72
+ // One broken listener must not stop the others, and must not
73
+ // abort a save that has already happened.
74
+ console.error("figure_builder: listener failed", error);
75
+ }
76
+ });
77
+ }
78
+
79
+ _setStatus(status, detail) {
80
+ this.status = status;
81
+ this.statusDetail = detail || "";
82
+ this._emit("status", { status: status, detail: this.statusDetail });
83
+ }
84
+
85
+ get revision() {
86
+ return this.document ? this.document.revision : 0;
87
+ }
88
+
89
+ get canUndo() { return this._undo.length > 0; }
90
+ get canRedo() { return this._redo.length > 0; }
91
+
92
+ async load() {
93
+ const result = await this.api.getFigure(this.figureId);
94
+ if (result.status === 422) {
95
+ this._setStatus("unreadable", result.data.detail || result.data.error || "");
96
+ return false;
97
+ }
98
+ if (!result.ok) {
99
+ this._setStatus("failed", result.status === 404
100
+ ? "This figure no longer exists."
101
+ : "Could not open this figure.");
102
+ return false;
103
+ }
104
+ this.document = result.data.document;
105
+ this.sourceStatus = result.data.source_status || {};
106
+ this._undo = [];
107
+ this._redo = [];
108
+ this._setStatus("saved");
109
+ this._emit("change", this.document);
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Change the figure and save the change.
115
+ *
116
+ * `mutate(draft)` applies the edit to a copy of the document for immediate
117
+ * display; `operations` is the same edit expressed for the server. They
118
+ * describe one action, which is why they are passed together -- and one
119
+ * call here is one undo step, whatever it contains, because that is what
120
+ * makes a five-panel split undo as a split rather than as five deletions.
121
+ *
122
+ * Returns a promise for `true` if it was stored.
123
+ */
124
+ commit(operations, mutate) {
125
+ if (!this.document) return Promise.resolve(false);
126
+
127
+ const before = this._snapshot();
128
+ const draft = this._snapshot();
129
+ try {
130
+ if (typeof mutate === "function") mutate(draft);
131
+ } catch (error) {
132
+ console.error("figure_builder: local edit failed", error);
133
+ return Promise.resolve(false);
134
+ }
135
+
136
+ this.document = draft;
137
+ this._undo.push(before);
138
+ if (this._undo.length > FigureDocumentState.HISTORY_LIMIT) this._undo.shift();
139
+ // A new edit is a new branch: whatever was undone is no longer
140
+ // reachable, and offering a redo that would reinstate a state the user
141
+ // has since edited past is offering an action nobody can predict.
142
+ this._redo = [];
143
+ this._setStatus("unsaved");
144
+ this._emit("change", this.document);
145
+
146
+ return this._enqueue(async () => {
147
+ // Read HERE, not where the commit was made. The queue serialises
148
+ // saves, so an earlier one may have moved the figure on between the
149
+ // two moments -- and `before.revision` is then a revision the server
150
+ // has already superseded, which it answers with a 409 that has
151
+ // nothing to do with another session. Placing a text box and pressing
152
+ // Escape was enough: the add and the remove are two commits in the
153
+ // same breath, and the second carried the revision from before the
154
+ // first.
155
+ //
156
+ // Basing the operations on the latest CONFIRMED revision is exactly
157
+ // what the chain exists to make safe: nothing else can have written
158
+ // in between, because nothing else is allowed to be in flight.
159
+ const baseRevision = this.revision;
160
+ this._setStatus("saving");
161
+ let result;
162
+ try {
163
+ result = await this.api.patchFigure(this.figureId, baseRevision, operations);
164
+ } catch (error) {
165
+ // Never discard local state after a failed save: the network is
166
+ // the thing that failed, and the user's work is still here.
167
+ this._setStatus("failed", "Could not reach the server. Your changes are still here.");
168
+ return false;
169
+ }
170
+ return this._settle(result, before);
171
+ });
172
+ }
173
+
174
+ /** Roll back one committed action. */
175
+ undo() {
176
+ if (!this._undo.length) return Promise.resolve(false);
177
+ const target = this._undo.pop();
178
+ this._redo.push(this._snapshot());
179
+ return this._replaceWith(target);
180
+ }
181
+
182
+ redo() {
183
+ if (!this._redo.length) return Promise.resolve(false);
184
+ const target = this._redo.pop();
185
+ this._undo.push(this._snapshot());
186
+ return this._replaceWith(target);
187
+ }
188
+
189
+ _replaceWith(target) {
190
+ const current = this.document;
191
+ const draft = JSON.parse(JSON.stringify(target));
192
+ // The revision is the CURRENT one, not the one the snapshot was taken
193
+ // at: going back to an earlier state is a new edit, and rewinding the
194
+ // number would make every other tab's next save look valid when it is
195
+ // not.
196
+ draft.revision = current.revision;
197
+ this.document = draft;
198
+ this._setStatus("unsaved");
199
+ this._emit("change", this.document);
200
+
201
+ return this._enqueue(async () => {
202
+ // Same as `commit`: the revision is whatever is confirmed when the
203
+ // request goes out, not what was current when undo was pressed. Two
204
+ // undos in a row is the case that broke.
205
+ const baseRevision = this.revision;
206
+ draft.revision = baseRevision;
207
+ this._setStatus("saving");
208
+ let result;
209
+ try {
210
+ result = await this.api.replaceFigure(this.figureId, baseRevision, draft);
211
+ } catch (error) {
212
+ this._setStatus("failed", "Could not reach the server. Your changes are still here.");
213
+ return false;
214
+ }
215
+ return this._settle(result, current);
216
+ });
217
+ }
218
+
219
+ _settle(result, before) {
220
+ if (result.ok) {
221
+ this.document.revision = result.data.revision;
222
+ this._setStatus("saved");
223
+ return true;
224
+ }
225
+ if (result.status === 409) {
226
+ // Both sides have work. Nothing is discarded here -- the banner
227
+ // asks, and reloading is the user's decision.
228
+ this._setStatus("conflict", "This figure was modified in another session.");
229
+ return false;
230
+ }
231
+ if (result.status === 422) {
232
+ this._setStatus("unreadable", result.data.detail || "");
233
+ return false;
234
+ }
235
+ // The server refused the edit itself. The local copy is now describing
236
+ // something that was never stored, so it goes back to what was
237
+ // confirmed -- otherwise the next save would be built on a state the
238
+ // server has never seen.
239
+ this.document = before;
240
+ this._undo.pop();
241
+ this._setStatus("failed", result.data.error || "That change could not be saved.");
242
+ this._emit("change", this.document);
243
+ return false;
244
+ }
245
+
246
+ _enqueue(task) {
247
+ this._chain = this._chain.then(task, task);
248
+ return this._chain;
249
+ }
250
+
251
+ _snapshot() {
252
+ return JSON.parse(JSON.stringify(this.document));
253
+ }
254
+
255
+ // -- convenience readers, so callers do not reach into the shape ------
256
+
257
+ get title() { return this.document ? this.document.title : ""; }
258
+
259
+ get pages() { return this.document ? this.document.pages : []; }
260
+
261
+ panel(panelId) {
262
+ return (this.document && this.document.panels[panelId]) || null;
263
+ }
264
+
265
+ source(sourceId) {
266
+ return (this.document && this.document.sources[sourceId]) || null;
267
+ }
268
+
269
+ /** The source registered for a datasource, or null if it is not one yet. */
270
+ sourceForDatasource(datasource) {
271
+ if (!this.document) return null;
272
+ return Object.values(this.document.sources).find(
273
+ (source) => source.kind === "plexora_project" && source.datasource === datasource) || null;
274
+ }
275
+ }