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,2030 @@
1
+ /**
2
+ * FigureWorkspace - the chrome around the canvas.
3
+ *
4
+ * The topbar, the tool rail, the panel tray, the pages and the save status. The
5
+ * canvas itself is FigureCanvas; this file is what surrounds it.
6
+ *
7
+ * ## The canvas is the workspace
8
+ *
9
+ * This used to be rendered in two places -- a figure's own page, and a split
10
+ * pane beside the live viewer -- and the split pane has gone. Composing a
11
+ * figure and looking down a microscope are different activities, and half a
12
+ * window was not enough room for either. So there is one home now, and it gets
13
+ * the whole page.
14
+ *
15
+ * The `options.state` seam is kept even though nothing shares a document today,
16
+ * because the rule it protects has not changed: two FigureDocumentStates on one
17
+ * figure in one tab would hold two revisions of it and conflict with EACH OTHER
18
+ * -- every save from one making the other stale, in the same window, with the
19
+ * user doing nothing wrong. Anything that wants to edit this figure alongside
20
+ * the canvas (Quick Edit) is handed THIS state rather than opening its own.
21
+ *
22
+ * ## Nothing permanent that acts on a selection
23
+ *
24
+ * There is no properties column. Every control that acts on what is selected
25
+ * lives in FigureContextBar, which appears beside the selection and nowhere
26
+ * else; every control that is rarely wanted lives in a menu. What is permanent
27
+ * is only what is always true: which figure this is, which page, how big, and
28
+ * whether it is saved.
29
+ */
30
+ class FigureWorkspace {
31
+
32
+ /** CSS pixels per millimetre at 100%. 96 dpi / 25.4 mm per inch. */
33
+ static get PX_PER_MM() { return 96 / 25.4; }
34
+
35
+ /** How long the tray has to be before finding something in it is work.
36
+ * Lower than it was: the tiles are two across and their names are behind a
37
+ * hover, so scanning for one by eye stops working sooner. */
38
+ static get SEARCH_THRESHOLD() { return 8; }
39
+
40
+ /** How many projects the Add menu lists before it hands over to the page
41
+ * built for choosing between many. */
42
+ static get ADD_MENU_PROJECTS() { return 8; }
43
+
44
+ /** Where the viewer says it sent the user here from. Written by
45
+ * figureSidebarController, read by the back arrow, and per-tab: two tabs
46
+ * can be on two figures reached two different ways. */
47
+ static get ORIGIN_KEY() { return "plexora:figure-builder-origin"; }
48
+
49
+ /** How long a toast stays. Long enough to read and act on, short enough not
50
+ * to sit over the figure. */
51
+ static get TOAST_MS() { return 9000; }
52
+
53
+ constructor(options) {
54
+ this.api = options.api || new FigureBuilderApi();
55
+ this.figureId = options.figureId;
56
+ //: Passed in when something else already owns the document -- see the
57
+ //: class comment on why sharing it is a correctness matter.
58
+ this.state = options.state
59
+ || new FigureDocumentState({ api: this.api, figureId: this.figureId });
60
+ this.ownsState = !options.state;
61
+ this.onEditPanel = options.onEditPanel || ((panelId) => this.editPanel(panelId));
62
+
63
+ this.root = document.getElementById("fb_workspace");
64
+ this.canvas = null;
65
+ this.contextBar = null;
66
+ this.tool = "select";
67
+
68
+ //: What the tray has selected, which is NOT what the canvas has
69
+ //: selected: a tray panel is not on the page, so the two selections
70
+ //: cannot be one. Insertion-ordered, which is the order a batch is
71
+ //: placed in.
72
+ this.traySelection = new Set();
73
+ //: The last tray item clicked without a modifier, so Shift has an
74
+ //: anchor to range from.
75
+ this.trayAnchor = null;
76
+ this.traySearch = "";
77
+ //: The menu currently open under a topbar button, if any.
78
+ this.menu = null;
79
+ //: The rich text editor, while a text annotation is being typed into.
80
+ this.textEditor = null;
81
+ //: Fit the page the first time it is drawn. An A4 at 100% is taller
82
+ //: than most windows, so opening at 100% shows the top third of an
83
+ //: empty page and reads as a figure that failed to load.
84
+ this._fitted = false;
85
+ //: Which secondary sidebar has the strip beside the rail -- see
86
+ //: `showSidebar`. "panels" to match the markup, which ships with the
87
+ //: tray open and the text panel hidden; set here rather than applied,
88
+ //: because applying it reads the document and the document has not
89
+ //: arrived yet.
90
+ this.sidebar = "panels";
91
+ //: ...and what to put back when a contextual panel has finished with it.
92
+ this.pinnedSidebar = "panels";
93
+ }
94
+
95
+ static boot() {
96
+ const root = document.getElementById("fb_workspace");
97
+ if (!root || !root.dataset.figureId) return null;
98
+ const workspace = new FigureWorkspace({ figureId: root.dataset.figureId });
99
+ workspace.setup();
100
+ workspace.state.load();
101
+ return workspace;
102
+ }
103
+
104
+ el(id) {
105
+ return document.getElementById(id);
106
+ }
107
+
108
+ setup() {
109
+ if (!this.root) return;
110
+ this.root.dataset.figureId = this.figureId;
111
+
112
+ this.canvas = new FigureCanvas({
113
+ state: this.state,
114
+ api: this.api,
115
+ figureId: this.figureId,
116
+ pageEl: this.el("fb_page"),
117
+ surfaceEl: this.el("fb_page_surface"),
118
+ guideEl: this.el("fb_page_guides"),
119
+ // Double-clicking a panel reaches for the CHEAP edit now that
120
+ // there is one: Quick Edit opens beside the canvas, where the
121
+ // expensive route is a page load into the viewer.
122
+ onEditPanel: (panelId) => this.quickEditPanel(panelId),
123
+ onSelectionChange: (ids) => this.selectionChanged(ids),
124
+ onGesture: (active) => this.contextBar?.suppress(active),
125
+ onToolFinished: () => this.setTool("select"),
126
+ onEditText: (annotationId) => this.editText(annotationId),
127
+ });
128
+ this.canvas.setup();
129
+
130
+ this.textEditor = new FigureTextEditor({
131
+ overlayEl: this.el("fb_overlay_layer"),
132
+ canvas: this.canvas,
133
+ state: this.state,
134
+ onCommit: (id, rich) => this.updateAnnotation(id, { rich: rich }),
135
+ });
136
+ this.textPanel = new FigureTextPanel({
137
+ root: this.el("fb_text_panel"),
138
+ canvas: this.canvas,
139
+ state: this.state,
140
+ editor: this.textEditor,
141
+ onStyle: (id, changes) => this.updateAnnotation(id, changes),
142
+ onClose: () => this.contextSidebar(false),
143
+ });
144
+ this.textPanel.setup();
145
+
146
+ this.contextBar = new FigureContextBar({
147
+ overlayEl: this.el("fb_overlay_layer"),
148
+ canvas: this.canvas,
149
+ state: this.state,
150
+ handlers: {
151
+ onEditPanel: (panelId) => this.onEditPanel(panelId),
152
+ onQuickEdit: (panelId) => this.quickEditPanel(panelId),
153
+ onArrange: (command) => this.arrange(command),
154
+ onSplit: (mode) => this.split(mode),
155
+ onRemoveFromPage: () => this.canvas.removeSelection(),
156
+ onDeleteFromFigure: (ids) => this.deleteFromFigure(ids),
157
+ onPanelChange: (panelId, changes) => this.updatePanel(panelId, changes),
158
+ onPanelsChange: (updates) => this.updatePanels(updates),
159
+ onSettingsChange: (settings) => this.updateSettings(settings),
160
+ onSetPixelSize: (sourceIds, value) => this.setPixelSize(sourceIds, value),
161
+ onShareLegendColours: (panelIds, colours) =>
162
+ this.shareLegendColours(panelIds, colours),
163
+ onAnnotationChange: (id, changes) => this.updateAnnotation(id, changes),
164
+ onEditText: (annotationId) => this.editText(annotationId),
165
+ onAcceptSource: (sourceId) => this.acceptChangedSource(sourceId),
166
+ onInsertSymbol: (id, glyph) => this.insertSymbol(id, glyph),
167
+ // The figure's unit lives in the View menu, and the Transform
168
+ // popover reads and writes THAT one rather than keeping a
169
+ // second copy of the same setting.
170
+ units: () => this.viewOptions?.prefs.units,
171
+ onUnits: (unit) => this.setUnits(unit),
172
+ },
173
+ });
174
+
175
+ this.contextMenu = new FigureContextMenu({
176
+ state: this.state,
177
+ canvas: this.canvas,
178
+ handlers: {
179
+ // Quick Edit where it can help, the viewer where it cannot: a
180
+ // panel from an imported PNG has no microscopy view to adjust.
181
+ onQuickEdit: (panelId) => this.quickEditPanel(panelId),
182
+ onEditPanel: (panelId) => this.onEditPanel(panelId),
183
+ onEditText: (annotationId) => this.editText(annotationId),
184
+ onDeleteFromFigure: (ids) => this.deleteFromFigure(ids),
185
+ // Both surfaces run the same registry, so the right-click menu
186
+ // needs every handler the floating bar's actions name.
187
+ onRemoveFromPage: () => this.canvas.removeSelection(),
188
+ onPageBackground: () => this.openPageBackground(),
189
+ onDuplicatePage: () => this.duplicatePage(),
190
+ onPlaceFromTray: () => this.placeFromTray(Array.from(this.traySelection)),
191
+ onDuplicateTray: () => this.duplicateTray(),
192
+ onDeleteTray: () => this.deleteFromFigure(Array.from(this.traySelection)),
193
+ onTrayContext: (panelId) => this.trayContext(panelId),
194
+ traySelection: () => Array.from(this.traySelection),
195
+ },
196
+ });
197
+ this.contextMenu.setup(this.el("fb_page_surface"), this.el("fb_tray_strip"));
198
+
199
+ //: Rulers, grid, margins, snapping and the unit -- all of them
200
+ //: statements about how the page is drawn for this person on this
201
+ //: machine, none of them in the document.
202
+ this.viewOptions = new FigureViewOptions({ workspace: this, canvas: this.canvas });
203
+ this.viewOptions.setup();
204
+
205
+ //: The slide-over that edits a panel's microscopy view in place. Handed
206
+ //: THIS document state -- never a second one; see the class comment.
207
+ this.quickEdit = new FigureQuickEdit({
208
+ workspace: this,
209
+ api: this.api,
210
+ state: this.state,
211
+ figureId: this.figureId,
212
+ onOpenInViewer: (panelId) => this.onEditPanel(panelId),
213
+ });
214
+ this.quickEdit.setup();
215
+
216
+ this.contextBar.setup();
217
+
218
+ this.state.on("change", () => this.render());
219
+ this.state.on("status", (payload) => this.renderStatus(payload));
220
+
221
+ this.applyBackLink();
222
+ this.bindTopbar();
223
+ this.bindToolRail();
224
+ this.bindTray();
225
+ this.bindCanvasHost();
226
+
227
+ this.exportUi = new FigureExportUi({
228
+ api: this.api, figureId: this.figureId, state: this.state,
229
+ });
230
+ this.exportUi.setup();
231
+
232
+ // Undo and redo are the application's, not this panel's: there is one
233
+ // Undo, and a second that only worked here would be a second answer to
234
+ // the same keystroke.
235
+ this._onKey = (event) => this.keyDown(event);
236
+ window.addEventListener("keydown", this._onKey);
237
+ this._onResize = () => this.contextBar?.position();
238
+ window.addEventListener("resize", this._onResize);
239
+ }
240
+
241
+ destroy() {
242
+ window.removeEventListener("keydown", this._onKey);
243
+ window.removeEventListener("resize", this._onResize);
244
+ this.closeMenu();
245
+ this.contextMenu?.destroy();
246
+ this.viewOptions?.destroy();
247
+ this.quickEdit?.destroy();
248
+ this.contextBar?.destroy();
249
+ this.canvas?.destroy();
250
+ }
251
+
252
+ // -- wiring ------------------------------------------------------------
253
+
254
+ bindTopbar() {
255
+ this.el("fb_title")?.addEventListener("change", () => this.commitTitle());
256
+ this.el("fb_title")?.addEventListener("keydown", (event) => {
257
+ // Enter commits and gets out of the way; blur commits too, so a
258
+ // title typed and then clicked away from is not silently discarded.
259
+ if (event.key === "Enter") event.target.blur();
260
+ if (event.key === "Escape") {
261
+ event.target.value = this.state.title;
262
+ event.target.blur();
263
+ }
264
+ });
265
+
266
+ this.el("fb_undo")?.addEventListener("click", () => this.state.undo());
267
+ this.el("fb_redo")?.addEventListener("click", () => this.state.redo());
268
+
269
+ this.el("fb_page_select")?.addEventListener("change", (event) => {
270
+ this.canvas.setPage(event.target.value);
271
+ this.contextBar?.update([]);
272
+ });
273
+ this.el("fb_page_menu")?.addEventListener("click", (event) => {
274
+ this.openMenu(event.currentTarget, this.pageMenuEntries(),
275
+ (act) => this.pageAction(act));
276
+ });
277
+
278
+ this.el("fb_zoom_in")?.addEventListener("click",
279
+ () => this.setScale(this.canvas.scale * 1.25));
280
+ this.el("fb_zoom_out")?.addEventListener("click",
281
+ () => this.setScale(this.canvas.scale * 0.8));
282
+ this.el("fb_zoom_readout")?.addEventListener("click", (event) => {
283
+ this.openMenu(event.currentTarget, this.zoomMenuEntries(),
284
+ (act) => this.zoomAction(act));
285
+ });
286
+ this.el("fb_zoom_fit")?.addEventListener("click", () => this.zoomToFit());
287
+
288
+ // Under the page rather than in a menu: adding one is the commonest
289
+ // thing anybody does to a figure that has run out of room, and it is
290
+ // the only page action worth a permanent button.
291
+ this.el("fb_page_add")?.addEventListener("click", () => this.addPage());
292
+
293
+ this.el("fb_view_menu")?.addEventListener("click", (event) => {
294
+ this.openMenu(event.currentTarget, this.viewOptions.menuEntries(),
295
+ (act) => this.viewOptions.pick(act));
296
+ });
297
+
298
+ this.el("fb_conflict_reload")?.addEventListener("click", () => window.location.reload());
299
+ }
300
+
301
+ /**
302
+ * Where the back arrow goes.
303
+ *
304
+ * Two ways in to this page, and they want different ways out. Arriving
305
+ * from a project's viewer, back is that viewer with the tool still open --
306
+ * the commonest trip there is is capture a few fields, look at the figure,
307
+ * go back to the slide for one more, and an arrow that always went to the
308
+ * library made the way back a search. Arriving from the library, back is
309
+ * the library, which is what the markup already says.
310
+ *
311
+ * The note is left by the viewer at the moment it sends the user here. It
312
+ * carries the figure id, so one left over from a different figure is
313
+ * ignored rather than followed back to a slide nobody asked for, and it is
314
+ * NOT consumed: reloading this page should not change where back goes.
315
+ */
316
+ applyBackLink() {
317
+ const link = this.el("fb_back");
318
+ if (!link) return;
319
+ let note = null;
320
+ try {
321
+ note = JSON.parse(window.sessionStorage.getItem(
322
+ FigureWorkspace.ORIGIN_KEY) || "null");
323
+ } catch (error) {
324
+ return; // private-browsing modes throw rather than answering null
325
+ }
326
+ // A path on this server and nothing else: this value ends up in an
327
+ // href, and sessionStorage is writable by anything else on the origin.
328
+ if (!note || note.figure_id !== this.figureId) return;
329
+ if (typeof note.href !== "string" || !note.href.startsWith("/")
330
+ || note.href.startsWith("//")) return;
331
+ link.href = note.href;
332
+ const label = note.label ? `Back to ${note.label}` : "Back to the viewer";
333
+ link.title = label;
334
+ link.setAttribute("aria-label", label);
335
+ }
336
+
337
+ bindToolRail() {
338
+ this.el("fb_tool_rail")?.addEventListener("click", (event) => {
339
+ const tool = event.target.closest("[data-tool]");
340
+ if (tool) this.setTool(tool.dataset.tool);
341
+ });
342
+ // Split where the controls differ rather than by how they are drawn: a
343
+ // rectangle and an ellipse have a fill and are dragged out as a box, a
344
+ // line and an arrow have two ends and are dragged out along one.
345
+ this.el("fb_tool_shapes")?.addEventListener("click", (event) => {
346
+ this.openMenu(event.currentTarget, [
347
+ { act: "rect", label: "Rectangle" },
348
+ { act: "ellipse", label: "Ellipse" },
349
+ ], (act) => this.setTool(act));
350
+ });
351
+ this.el("fb_tool_lines")?.addEventListener("click", (event) => {
352
+ this.openMenu(event.currentTarget, [
353
+ { act: "line", label: "Line" },
354
+ { act: "arrow", label: "Arrow" },
355
+ ], (act) => this.setTool(act));
356
+ });
357
+ }
358
+
359
+ /**
360
+ * The tray, entirely delegated.
361
+ *
362
+ * It is re-rendered on every document change, so handlers bound to its
363
+ * items would be rebound continuously and leak the ones that were replaced.
364
+ */
365
+ bindTray() {
366
+ const strip = this.el("fb_tray_strip");
367
+ if (!strip) return;
368
+
369
+ strip.addEventListener("pointerdown", (event) => {
370
+ const item = event.target.closest(".fb-tray-item");
371
+ if (item) this.trayPointerDown(item.dataset.panelId, event);
372
+ });
373
+ strip.addEventListener("dragstart", (event) => {
374
+ const item = event.target.closest(".fb-tray-item");
375
+ if (!item) return;
376
+ // The whole tray selection travels if the dragged item is part of
377
+ // it -- picking four panels and then dragging one of them plainly
378
+ // means all four.
379
+ const ids = this.traySelection.has(item.dataset.panelId)
380
+ ? Array.from(this.traySelection)
381
+ : [item.dataset.panelId];
382
+ event.dataTransfer.setData("text/x-plexora-panel", JSON.stringify(ids));
383
+ event.dataTransfer.effectAllowed = "move";
384
+ });
385
+ strip.addEventListener("dblclick", (event) => {
386
+ const item = event.target.closest(".fb-tray-item");
387
+ if (item) this.placeFromTray([item.dataset.panelId]);
388
+ });
389
+
390
+ // Filters as the characters arrive; nothing is submitted. Esc and the ×
391
+ // are the way back out -- a filter still on from five minutes ago looks
392
+ // exactly like a tray that has lost panels, and the names it would be
393
+ // recognised by are behind a hover now.
394
+ const search = this.el("fb_tray_search");
395
+ search?.addEventListener("input", (event) => {
396
+ this.traySearch = event.target.value || "";
397
+ this.renderTray();
398
+ });
399
+ search?.addEventListener("keydown", (event) => {
400
+ if (event.key !== "Escape" || !this.traySearch) return;
401
+ // Kept off the window: Escape out here also clears the canvas
402
+ // selection, and one key should not do two things at once.
403
+ event.stopPropagation();
404
+ this.clearTraySearch();
405
+ });
406
+ this.el("fb_tray_search_clear")?.addEventListener("click", () => {
407
+ this.clearTraySearch();
408
+ search?.focus();
409
+ });
410
+
411
+ // Two ways in and out of the same card: the rail item that opens it,
412
+ // and the card's own X. Both go through one method so the rail button
413
+ // and the aria state cannot disagree.
414
+ this.el("fb_tool_panels")?.addEventListener("click", () => this.showTray());
415
+ this.el("fb_tray_close")?.addEventListener("click", () => this.showTray(false));
416
+
417
+ // Where the next panels come from. Built when it is opened rather than
418
+ // at boot: the project list is a fetch, and a figure page that never
419
+ // adds a panel should never ask the server for one.
420
+ this.el("fb_tray_add")?.addEventListener("click", (event) => {
421
+ this.openAddMenu(event.currentTarget);
422
+ });
423
+
424
+ // The file input moved here from the rail with the button that opens
425
+ // it: importing a picture is a way of adding a panel, and this is where
426
+ // adding panels lives now.
427
+ this.el("fb_add_image_input")?.addEventListener("change", (event) => {
428
+ const files = Array.from(event.target.files || []);
429
+ // Cleared straight away, or choosing the same file twice in a row
430
+ // fires nothing the second time and reads as a broken button.
431
+ event.target.value = "";
432
+ if (files.length) this.importFiles(files);
433
+ });
434
+ }
435
+
436
+ // -- the strip beside the rail ------------------------------------------
437
+
438
+ /**
439
+ * One secondary sidebar at a time.
440
+ *
441
+ * The tray and the text panel occupy the SAME strip beside the rail, and
442
+ * they used to hide and show themselves independently: selecting a caption
443
+ * while the tray was open drew the text panel underneath it, where it was
444
+ * invisible and still took every click that landed on the overlap. Two
445
+ * panels each answering "am I open?" for themselves is two answers to a
446
+ * question that has room for one.
447
+ *
448
+ * So the strip has an owner, and it is here. `name` is the panel that
449
+ * should be in it, or null for none.
450
+ *
451
+ * The tray is opened BY HAND and the text panel appears with the selection,
452
+ * which is why the tray is remembered as `pinned` and the text panel is
453
+ * not: a contextual panel borrows the strip while it has something to say
454
+ * and gives it back afterwards, rather than leaving the user to go and
455
+ * reopen the drawer they never closed.
456
+ */
457
+ showSidebar(name) {
458
+ this.sidebar = name || null;
459
+ if (name !== "text") this.pinnedSidebar = this.sidebar;
460
+ this.applySidebar();
461
+ }
462
+
463
+ /** The contextual panel asking for the strip, or handing it back. */
464
+ contextSidebar(wants) {
465
+ this.showSidebar(wants ? "text" : this.pinnedSidebar);
466
+ }
467
+
468
+ applySidebar() {
469
+ for (const [name, id] of Object.entries(FigureWorkspace.SIDEBARS)) {
470
+ const el = this.el(id);
471
+ if (el) el.hidden = this.sidebar !== name;
472
+ }
473
+ // The tray's badge only shows while its card is shut, so it depends on
474
+ // what was just decided.
475
+ this.renderTray();
476
+ this.renderRail();
477
+ }
478
+
479
+ /** Which element each secondary sidebar is. The single place the two are
480
+ * named together, so adding a third is one line rather than a search. */
481
+ static get SIDEBARS() {
482
+ return { panels: "fb_tray_panel", text: "fb_text_panel" };
483
+ }
484
+
485
+ /** The unit the page is measured in, changed from wherever names it. */
486
+ setUnits(unit) {
487
+ this.viewOptions?.setUnit(unit);
488
+ }
489
+
490
+ /**
491
+ * Open or close the panel tray.
492
+ *
493
+ * Not remembered between sessions. The tray is where captured panels
494
+ * arrive, and a figure page that opened with it shut would look, on the day
495
+ * it is reopened, like a figure that had lost them.
496
+ *
497
+ * With no argument this toggles, so the rail button is a switch rather than
498
+ * a one-way door -- clicking the lit item is how anybody expects to put it
499
+ * out.
500
+ *
501
+ * Nothing else moves. The card lies over the desk beside the sheet and the
502
+ * canvas keeps its position: the tray used to push the page sideways, which
503
+ * meant looking at your panels moved the thing you were composing.
504
+ */
505
+ showTray(open) {
506
+ const want = open === undefined ? this.sidebar !== "panels" : !!open;
507
+ this.showSidebar(want ? "panels" : null);
508
+ }
509
+
510
+ /**
511
+ * Where the next panels come from.
512
+ *
513
+ * Two routes and one menu. A project is a page load into that project's
514
+ * viewer, with THIS figure remembered as the capture destination first --
515
+ * the same localStorage key the dock over there reads, because "which
516
+ * figure am I capturing into" is one setting and this is plainly the
517
+ * figure the user is looking at. Nothing is written to the figure by
518
+ * opening the menu or by opening a project: a slide somebody looks at and
519
+ * leaves, leaves no trace.
520
+ *
521
+ * An image file is the other kind of thing a figure is made of -- the
522
+ * schematic, the plot that came out of R -- and it goes into this figure
523
+ * without a project at all.
524
+ */
525
+ async openAddMenu(anchor) {
526
+ const projects = await this.projectList();
527
+ const shown = projects.slice(0, FigureWorkspace.ADD_MENU_PROJECTS);
528
+ const entries = shown.map((project) => ({
529
+ act: "open:" + project.name,
530
+ label: project.name,
531
+ }));
532
+ if (!shown.length) {
533
+ entries.push({ act: "none", label: "No projects on this server",
534
+ disabled: true });
535
+ }
536
+ entries.push({ separator: true });
537
+ // The rest of them on the page built for choosing between many. That
538
+ // page opens a project WITHOUT the tool, so the trip is two clicks
539
+ // longer at the far end -- the figure is remembered either way, so the
540
+ // captures still land here.
541
+ if (projects.length > shown.length) {
542
+ entries.push({ act: "browse", label: "All projects…" });
543
+ }
544
+ entries.push({ act: "file", label: "Image file…" });
545
+ this.openMenu(anchor, entries, (act) => this.addFrom(act));
546
+ }
547
+
548
+ /**
549
+ * The projects on this server, most recently opened first.
550
+ *
551
+ * Fetched once: the list changes when somebody creates a project, which is
552
+ * not something that happens while a figure page sits open, and a menu
553
+ * that re-fetched on every click would be a request per glance.
554
+ */
555
+ async projectList() {
556
+ if (this._projects) return this._projects;
557
+ this._projects = [];
558
+ try {
559
+ const response = await fetch(this.api.url("projects"));
560
+ const data = response.ok ? await response.json() : [];
561
+ if (Array.isArray(data)) {
562
+ this._projects = data
563
+ .filter((project) => project && project.name)
564
+ .sort((a, b) => String(b.lastOpenedAt || "")
565
+ .localeCompare(String(a.lastOpenedAt || "")));
566
+ }
567
+ } catch (error) {
568
+ // The menu still has the image-file route, which needs no server.
569
+ }
570
+ return this._projects;
571
+ }
572
+
573
+ addFrom(act) {
574
+ if (act === "file") {
575
+ this.el("fb_add_image_input")?.click();
576
+ return;
577
+ }
578
+ if (act === "browse") {
579
+ this.rememberDestination();
580
+ window.location.href = this.api.url("open_project");
581
+ return;
582
+ }
583
+ if (!act.startsWith("open:")) return;
584
+ this.rememberDestination();
585
+ // ?tool= so the capture dock is on the image when the page lands. The
586
+ // user asked to add panels; arriving at a viewer with no way to
587
+ // capture from it would be most of the way to nothing.
588
+ window.location.href = this.api.url(encodeURIComponent(act.slice(5)))
589
+ + "?tool=figure_builder";
590
+ }
591
+
592
+ /**
593
+ * Point the viewer's capture dock at THIS figure before leaving for it.
594
+ *
595
+ * The key is read off the sidebar controller rather than spelled again
596
+ * here: two copies of a storage key is how a rename turns into captures
597
+ * quietly landing in the figure somebody was working on last week.
598
+ */
599
+ rememberDestination() {
600
+ try {
601
+ window.localStorage.setItem(
602
+ FigureBuilderSidebarController.STORAGE_KEY, this.figureId);
603
+ } catch (error) {
604
+ /* Private-browsing modes throw. The trip is still worth making --
605
+ the dock asks which figure when it gets there. */
606
+ }
607
+ }
608
+
609
+ bindCanvasHost() {
610
+ // Dropping an image onto the page imports it into THIS FIGURE and
611
+ // nowhere else. Making the user create a project to put a schematic in
612
+ // a figure is exactly the setup step this plugin exists to remove.
613
+ const scroll = this.el("fb_canvas_scroll");
614
+ if (!scroll) return;
615
+ scroll.addEventListener("dragover", (event) => {
616
+ if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
617
+ });
618
+ scroll.addEventListener("drop", (event) => this.dropFiles(event));
619
+ // The floating bar is positioned from where the selection is on SCREEN,
620
+ // so scrolling the page moves it.
621
+ scroll.addEventListener("scroll", () => this.contextBar?.position(), { passive: true });
622
+
623
+ // Clicking off the page puts the pointer back to selecting. ON the
624
+ // page an armed tool is USED by the click and hands itself back the
625
+ // moment it has placed something; the desk around the sheet is where a
626
+ // tool armed by mistake is abandoned, and with no Select button in the
627
+ // rail this is the way out that does not need the keyboard.
628
+ //
629
+ // Not while space is held: that is a pan, and the pointer is out here
630
+ // because that is where the hand happened to be.
631
+ scroll.addEventListener("pointerdown", (event) => {
632
+ if (this.spaceHeld || this.tool === "select") return;
633
+ if (event.target.closest("#fb_page")) return;
634
+ this.setTool("select");
635
+ });
636
+
637
+ // Ctrl/Cmd + wheel zooms about the pointer. A trackpad pinch arrives as
638
+ // exactly this event with ctrlKey set, which is why one handler serves
639
+ // both and neither needs a gesture library.
640
+ scroll.addEventListener("wheel", (event) => {
641
+ if (!event.ctrlKey && !event.metaKey) return;
642
+ event.preventDefault();
643
+ // Exponential, so a fast scroll and several slow ones of the same
644
+ // total distance land in the same place.
645
+ this.zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.0035));
646
+ }, { passive: false });
647
+
648
+ this.bindSpacePan(scroll);
649
+ }
650
+
651
+ /**
652
+ * Hold space to pan.
653
+ *
654
+ * The listener is on the SCROLL container in the capture phase, so it runs
655
+ * before the canvas surface's own pointerdown and can stop it: without
656
+ * that, a space-drag would start a marquee under the hand that is trying to
657
+ * move the page.
658
+ */
659
+ bindSpacePan(scroll) {
660
+ this._onSpaceDown = (event) => {
661
+ if (event.code !== "Space" || this.spaceHeld) return;
662
+ const typing = document.activeElement
663
+ && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
664
+ if (typing) return;
665
+ event.preventDefault();
666
+ this.spaceHeld = true;
667
+ scroll.classList.add("is-pannable");
668
+ };
669
+ this._releaseSpace = () => {
670
+ this.spaceHeld = false;
671
+ this.panning = null;
672
+ scroll.classList.remove("is-pannable", "is-panning");
673
+ };
674
+ this._onSpaceUp = (event) => {
675
+ if (event.code === "Space") this._releaseSpace();
676
+ };
677
+ window.addEventListener("keydown", this._onSpaceDown);
678
+ window.addEventListener("keyup", this._onSpaceUp);
679
+ // A window that loses focus mid-pan never sees the keyup, and would
680
+ // come back still in pan mode with no key held down to explain it.
681
+ window.addEventListener("blur", this._releaseSpace);
682
+
683
+ scroll.addEventListener("pointerdown", (event) => {
684
+ if (!this.spaceHeld || event.button !== 0) return;
685
+ event.preventDefault();
686
+ event.stopPropagation();
687
+ scroll.classList.add("is-panning");
688
+ this.panning = {
689
+ x: event.clientX, y: event.clientY,
690
+ left: scroll.scrollLeft, top: scroll.scrollTop,
691
+ };
692
+ scroll.setPointerCapture?.(event.pointerId);
693
+ }, true);
694
+
695
+ scroll.addEventListener("pointermove", (event) => {
696
+ if (!this.panning) return;
697
+ scroll.scrollLeft = this.panning.left - (event.clientX - this.panning.x);
698
+ scroll.scrollTop = this.panning.top - (event.clientY - this.panning.y);
699
+ });
700
+ const stop = () => {
701
+ if (!this.panning) return;
702
+ this.panning = null;
703
+ scroll.classList.remove("is-panning");
704
+ };
705
+ scroll.addEventListener("pointerup", stop);
706
+ scroll.addEventListener("pointercancel", stop);
707
+ }
708
+
709
+ // -- tools -------------------------------------------------------------
710
+
711
+ /**
712
+ * Arm a rail tool, or put the pointer back to selecting.
713
+ *
714
+ * Tools are one-shot everywhere they place something: the rail disarms
715
+ * itself afterwards, so a lit button always means "the next click on the
716
+ * page does this" rather than a mode somebody has to remember to leave.
717
+ * That is why there is no Select button to go back to -- "select" is the
718
+ * state this page is in unless it has just been told otherwise, and the
719
+ * ways out of a tool are placing something with it, clicking the desk, and
720
+ * Escape.
721
+ */
722
+ setTool(name) {
723
+ this.tool = name || "select";
724
+ this.canvas.setTool(this.tool);
725
+ this.renderRail();
726
+ }
727
+
728
+ renderRail() {
729
+ const rail = this.el("fb_tool_rail");
730
+ rail?.querySelectorAll("[data-tool]").forEach((button) => {
731
+ const on = button.dataset.tool === this.tool;
732
+ button.classList.toggle("is-active", on);
733
+ button.setAttribute("aria-pressed", String(on));
734
+ });
735
+ // The two menu buttons have no data-tool of their own -- each stands
736
+ // for a pair -- so each is lit whenever either of its pair is armed.
737
+ for (const [id, tools] of [["fb_tool_shapes", ["rect", "ellipse"]],
738
+ ["fb_tool_lines", ["line", "arrow"]]]) {
739
+ const button = this.el(id);
740
+ if (!button) continue;
741
+ const on = tools.includes(this.tool);
742
+ button.classList.toggle("is-active", on);
743
+ button.setAttribute("aria-pressed", String(on));
744
+ }
745
+ // The tray's rail item is a switch, not a tool: it is lit while its
746
+ // card is open, and the card is the authority on that -- keeping a
747
+ // second copy of "is the tray open" in a field is how the button and
748
+ // the panel end up disagreeing after an undo or a reload.
749
+ const tray = this.el("fb_tray_panel");
750
+ const trayButton = this.el("fb_tool_panels");
751
+ if (tray && trayButton) {
752
+ trayButton.classList.toggle("is-active", !tray.hidden);
753
+ trayButton.setAttribute("aria-expanded", String(!tray.hidden));
754
+ }
755
+ }
756
+
757
+ // -- editing text in place -----------------------------------------------
758
+
759
+ /**
760
+ * Type into a text annotation where it sits.
761
+ *
762
+ * The editor itself is `FigureTextEditor` -- a contenteditable in the
763
+ * overlay layer, which is where it has to be: `FigureCanvas.render()`
764
+ * replaces the page surface wholesale on every change, and an editor
765
+ * mounted inside it would be destroyed by the autosave triggered by the
766
+ * last thing the user typed.
767
+ *
768
+ * This used to be a <textarea>, and the reason it no longer is, is the
769
+ * whole feature: a textarea holds one style for its entire contents, so an
770
+ * italic gene name inside a roman sentence was unreachable.
771
+ */
772
+ editText(annotationId) {
773
+ this.textEditor?.open(annotationId);
774
+ // Typing into a caption is exactly when its formatting is wanted, so
775
+ // this overrides a panel the user shut earlier for the same object.
776
+ this.textPanel?.reveal();
777
+ this.textPanel?.update([annotationId]);
778
+ this.contextSidebar(Boolean(this.textPanel?.wants));
779
+ }
780
+
781
+ /**
782
+ * Put a character in at the caret.
783
+ *
784
+ * Opens the editor if it is not open, with the caret at the END rather than
785
+ * over everything: the editor's own "select all on open" is right for
786
+ * double-click-and-retype, and wrong for a palette whose whole purpose is
787
+ * to ADD one character to what is already there.
788
+ */
789
+ insertSymbol(annotationId, glyph) {
790
+ if (!this.textEditor) return;
791
+ if (!this.textEditor.active || this.textEditor.annotationId !== annotationId) {
792
+ this.textEditor.open(annotationId);
793
+ const end = this.textEditor.plainLength();
794
+ this.textEditor.setOffsets({ start: end, end: end });
795
+ }
796
+ this.textEditor.replaceSelection(glyph);
797
+ }
798
+
799
+ closeTextEditor() {
800
+ this.textEditor?.close(true);
801
+ }
802
+
803
+ /** One annotation's style or text, from the context bar. */
804
+ updateAnnotation(annotationId, changes) {
805
+ const current = this.state.document.annotations[annotationId];
806
+ if (!current) return;
807
+ changes = this.withAutofit(current, changes);
808
+ this.state.commit(
809
+ [{ op: "update_annotation", annotation_id: annotationId, changes: changes }],
810
+ (draft) => {
811
+ const annotation = draft.annotations[annotationId];
812
+ if (changes.style) Object.assign(annotation.style, changes.style);
813
+ if (changes.geometry) Object.assign(annotation.geometry, changes.geometry);
814
+ if ("text" in changes) annotation.text = changes.text;
815
+ // `rich` is replaced whole, never merged -- half a line list
816
+ // means nothing -- and `text` is re-derived from it here so the
817
+ // optimistic draft matches what the server will send back.
818
+ if ("rich" in changes) {
819
+ annotation.rich = changes.rich;
820
+ annotation.text = FigureRichText.plainText(changes.rich);
821
+ }
822
+ });
823
+ }
824
+
825
+ /**
826
+ * Grow or shrink a text box to the height of what is in it.
827
+ *
828
+ * Computed HERE and sent as part of the same operation, rather than left to
829
+ * the optimistic draft: a height that only ever existed in the browser is a
830
+ * box that is the right size until the page is reloaded. Dragging the top or
831
+ * bottom handle is what turns this off -- the gesture is the opt-out, so
832
+ * there is no checkbox to find first.
833
+ */
834
+ withAutofit(annotation, changes) {
835
+ const style = { ...annotation.style, ...(changes.style || {}) };
836
+ if (annotation.type !== "text" || !style.autofit) return changes;
837
+ const merged = {
838
+ ...annotation, style: style,
839
+ geometry: { ...annotation.geometry, ...(changes.geometry || {}) },
840
+ rich: "rich" in changes ? changes.rich : annotation.rich,
841
+ };
842
+ const height = FigureCanvas.textLayout(merged).block_h_mm;
843
+ if (Math.abs(height - merged.geometry.h_mm) < 1e-6) return changes;
844
+ return { ...changes,
845
+ geometry: { ...(changes.geometry || {}), h_mm: height } };
846
+ }
847
+
848
+ // -- menus ---------------------------------------------------------------
849
+
850
+ /**
851
+ * A small menu under a topbar button.
852
+ *
853
+ * Appended to <body> with fixed positioning rather than inside the topbar:
854
+ * the topbar is a flex row whose children are clipped, and a menu inside it
855
+ * would be cut off by the button it hangs from.
856
+ */
857
+ openMenu(anchor, entries, onPick) {
858
+ const reopening = this.menu && this._menuAnchor === anchor;
859
+ this.closeMenu();
860
+ if (reopening) return;
861
+
862
+ const menu = document.createElement("div");
863
+ menu.className = "fb-menu";
864
+ // A checkable entry keeps the tick's WIDTH whether it is ticked or not,
865
+ // so the labels line up and the menu does not shuffle sideways as
866
+ // things are turned on.
867
+ menu.innerHTML = entries.map((entry) => entry.separator
868
+ ? '<span class="fb-menu-separator"></span>'
869
+ : `<button type="button" class="fb-menu-item" data-act="${entry.act}"
870
+ ${entry.disabled ? "disabled" : ""}>
871
+ ${"checked" in entry
872
+ ? `<span class="fb-menu-tick">${entry.checked ? "✓" : ""}</span>`
873
+ : ""}${FigureSchema.escapeHtml(entry.label)}</button>`).join("");
874
+ document.body.appendChild(menu);
875
+
876
+ const box = anchor.getBoundingClientRect();
877
+ // See FigureContextMenu.open -- .fb-menu animates in with a scale, so
878
+ // the layout box is the one to measure, not the transformed one.
879
+ const size = { width: menu.offsetWidth, height: menu.offsetHeight };
880
+ menu.style.top = Math.round(box.bottom + 6) + "px";
881
+ menu.style.left = Math.round(Math.max(8,
882
+ Math.min(box.left, window.innerWidth - size.width - 8))) + "px";
883
+
884
+ menu.addEventListener("click", (event) => {
885
+ const item = event.target.closest("[data-act]");
886
+ if (!item || item.disabled) return;
887
+ this.closeMenu();
888
+ onPick(item.dataset.act);
889
+ });
890
+ this._onMenuDismiss = (event) => {
891
+ if (menu.contains(event.target) || anchor.contains(event.target)) return;
892
+ this.closeMenu();
893
+ };
894
+ document.addEventListener("pointerdown", this._onMenuDismiss, true);
895
+ anchor.setAttribute("aria-expanded", "true");
896
+ this.menu = menu;
897
+ this._menuAnchor = anchor;
898
+ }
899
+
900
+ closeMenu() {
901
+ if (this._onMenuDismiss) {
902
+ document.removeEventListener("pointerdown", this._onMenuDismiss, true);
903
+ this._onMenuDismiss = null;
904
+ }
905
+ this._menuAnchor?.setAttribute("aria-expanded", "false");
906
+ this._menuAnchor = null;
907
+ this.menu?.remove();
908
+ this.menu = null;
909
+ }
910
+
911
+ pageMenuEntries() {
912
+ return [
913
+ { act: "add", label: "Add a page" },
914
+ { act: "duplicate", label: "Duplicate this page" },
915
+ { act: "remove", label: "Delete this page",
916
+ disabled: this.state.pages.length <= 1 },
917
+ { separator: true },
918
+ { act: "background", label: "Page background…" },
919
+ ];
920
+ }
921
+
922
+ pageAction(act) {
923
+ if (act === "add") this.addPage();
924
+ else if (act === "duplicate") this.duplicatePage();
925
+ else if (act === "remove") this.removePage();
926
+ else if (act === "background") this.openPageBackground();
927
+ }
928
+
929
+ /**
930
+ * The page background, as a second level of the page menu.
931
+ *
932
+ * Anchored to the same button whichever route got here, including the
933
+ * canvas right-click: a popover that appeared where the pointer happened to
934
+ * be would be a third place this figure's page settings live.
935
+ */
936
+ openPageBackground() {
937
+ const anchor = this.el("fb_page_menu");
938
+ if (!anchor) return;
939
+ this.openMenu(anchor, [
940
+ { act: "#ffffff", label: "White" },
941
+ { act: "#000000", label: "Black" },
942
+ { act: "custom", label: "Custom color…" },
943
+ { separator: true },
944
+ // Only PNG carries it through: the export dialog says so, and the
945
+ // canvas draws the conventional checkerboard so nobody discovers it
946
+ // at the file.
947
+ { act: FigureCanvas.TRANSPARENT, label: "Transparent" },
948
+ ], (act) => this.setPageBackground(act));
949
+ }
950
+
951
+ setPageBackground(act) {
952
+ if (act !== "custom") {
953
+ this.commitPageBackground(act);
954
+ return;
955
+ }
956
+ // The OS colour picker, through an input nothing ever sees. Building a
957
+ // colour wheel here would be a worse one that also has to be
958
+ // maintained.
959
+ const picker = document.createElement("input");
960
+ picker.type = "color";
961
+ picker.value = this.canvas.page?.background || "#ffffff";
962
+ picker.style.position = "fixed";
963
+ picker.style.opacity = "0";
964
+ picker.style.pointerEvents = "none";
965
+ document.body.appendChild(picker);
966
+ picker.addEventListener("change", () => {
967
+ this.commitPageBackground(picker.value);
968
+ picker.remove();
969
+ });
970
+ picker.addEventListener("blur", () => picker.remove());
971
+ picker.click();
972
+ }
973
+
974
+ commitPageBackground(background) {
975
+ const pageId = this.canvas.pageId;
976
+ if (!pageId) return;
977
+ this.state.commit(
978
+ [{ op: "update_page", page_id: pageId, changes: { background: background } }],
979
+ (draft) => {
980
+ const page = draft.pages.find((entry) => entry.page_id === pageId);
981
+ if (page) page.background = background;
982
+ });
983
+ }
984
+
985
+ /**
986
+ * A copy of this page, with everything on it, as ONE undo step.
987
+ *
988
+ * The commonest reason to want it is a second version of a figure that is
989
+ * nearly right -- which means the copy has to carry the panels, not just
990
+ * the page size, and has to arrive complete enough to compare the two.
991
+ */
992
+ duplicatePage() {
993
+ const current = this.canvas.page;
994
+ if (!current) return;
995
+ const page = {
996
+ ...JSON.parse(JSON.stringify(current)),
997
+ page_id: FigureSchema.newPageId(),
998
+ name: current.name + " copy",
999
+ };
1000
+ const panels = FigureSchema.panelsOnPage(this.state.document, current.page_id);
1001
+ const annotations = Object.values(this.state.document.annotations)
1002
+ .filter((annotation) => annotation.page_id === current.page_id);
1003
+ const made = this.canvas.copiesOf(panels, annotations, 0, 0, page.page_id);
1004
+
1005
+ // The page has to exist before anything can be placed on it, so it
1006
+ // leads the batch -- and the whole batch is one commit, or duplicating
1007
+ // a six-panel page would be seven undo steps.
1008
+ const done = this.canvas.commitCopies(made, {
1009
+ select: false,
1010
+ operations: [{ op: "add_page", page: page }],
1011
+ mutate: (draft) => { draft.pages.push(page); },
1012
+ });
1013
+ (done || Promise.resolve(false)).then((stored) => {
1014
+ if (stored !== false) this.canvas.setPage(page.page_id);
1015
+ });
1016
+ }
1017
+
1018
+ zoomMenuEntries() {
1019
+ return [
1020
+ { act: "fit", label: "Fit page ⌘0" },
1021
+ { act: "selection", label: "Fit selection",
1022
+ disabled: !this.canvas.selection.size },
1023
+ { act: "100", label: "100% ⌘1" },
1024
+ ];
1025
+ }
1026
+
1027
+ zoomAction(act) {
1028
+ if (act === "fit") this.zoomToFit();
1029
+ else if (act === "selection") this.zoomToSelection();
1030
+ else if (act === "100") this.setScale(FigureWorkspace.PX_PER_MM);
1031
+ }
1032
+
1033
+ // -- keyboard ------------------------------------------------------------
1034
+
1035
+ keyDown(event) {
1036
+ // A <dialog> traps focus, not keystrokes. With the delete confirmation
1037
+ // up, Escape reached this handler as well as the dialog and disarmed
1038
+ // the drawing tool behind it.
1039
+ if (FigureConfirm.modalOpen) return;
1040
+ const typing = document.activeElement
1041
+ && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
1042
+ if (typing) return;
1043
+
1044
+ if (event.key === "Escape") {
1045
+ this.closeMenu();
1046
+ this.contextBar?.closePopover();
1047
+ // Escape also disarms a drawing tool, which is the other thing on
1048
+ // this page that is waiting for a click nobody wants to make.
1049
+ if (this.tool !== "select") this.setTool("select");
1050
+ return;
1051
+ }
1052
+ if (!(event.metaKey || event.ctrlKey)) return;
1053
+
1054
+ // The zoom chords. Bound here rather than on the canvas because they
1055
+ // are about the window, not about the selection.
1056
+ const zoom = {
1057
+ "0": () => this.zoomToFit(),
1058
+ "1": () => this.setScale(FigureWorkspace.PX_PER_MM),
1059
+ "=": () => this.setScale(this.canvas.scale * 1.25),
1060
+ "+": () => this.setScale(this.canvas.scale * 1.25),
1061
+ "-": () => this.setScale(this.canvas.scale * 0.8),
1062
+ }[event.key];
1063
+ if (zoom) {
1064
+ event.preventDefault();
1065
+ zoom();
1066
+ return;
1067
+ }
1068
+ if (event.key === "z" && !event.shiftKey) {
1069
+ event.preventDefault();
1070
+ this.state.undo();
1071
+ } else if ((event.key === "z" && event.shiftKey) || event.key === "y") {
1072
+ event.preventDefault();
1073
+ this.state.redo();
1074
+ }
1075
+ }
1076
+
1077
+ // -- editing ---------------------------------------------------------
1078
+
1079
+ commitTitle() {
1080
+ const input = this.el("fb_title");
1081
+ const title = (input.value || "").trim();
1082
+ if (!title || title === this.state.title) {
1083
+ input.value = this.state.title;
1084
+ return;
1085
+ }
1086
+ this.state.commit([{ op: "set_meta", changes: { title: title } }],
1087
+ (draft) => { draft.title = title; });
1088
+ }
1089
+ /**
1090
+ * The same change to several panels, as ONE undo step.
1091
+ *
1092
+ * Turning scale bars on across a row of six is one thing the user did. Six
1093
+ * commits would be six saves and six presses of Cmd+Z to take back, and the
1094
+ * fourth press would leave the row half-changed.
1095
+ */
1096
+ updatePanels(updates) {
1097
+ const real = updates.filter((entry) => this.state.panel(entry.panel_id));
1098
+ if (!real.length) return Promise.resolve(false);
1099
+ return this.state.commit(
1100
+ real.map((entry) => ({
1101
+ op: "update_panel", panel_id: entry.panel_id, changes: entry.changes })),
1102
+ (draft) => {
1103
+ for (const entry of real) {
1104
+ Object.assign(draft.panels[entry.panel_id], entry.changes);
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ /** A figure-wide setting -- the label style, the gutter, the default DPI. */
1110
+ updateSettings(settings) {
1111
+ this.state.commit(
1112
+ [{ op: "set_meta", changes: { settings: settings } }],
1113
+ (draft) => { Object.assign(draft.settings, settings); });
1114
+ }
1115
+
1116
+ /**
1117
+ * Record a pixel size the user typed for images that never had one.
1118
+ *
1119
+ * On the SOURCE, because it is a fact about the image and every panel of it
1120
+ * is entitled to the same answer -- and marked `manual`, which the
1121
+ * provenance page prints. A number somebody typed is not the same evidence
1122
+ * as one the file stated, and a figure that could not tell the difference
1123
+ * would have scale bars nobody could check.
1124
+ */
1125
+ setPixelSize(sourceIds, value) {
1126
+ const real = sourceIds.filter((id) => this.state.source(id));
1127
+ if (!real.length || !(value > 0)) return;
1128
+ const pixelSize = { value: value, unit: "µm", source: "manual" };
1129
+ this.state.commit(
1130
+ real.map((sourceId) => ({
1131
+ op: "update_source", source_id: sourceId,
1132
+ changes: { pixel_size: pixelSize } })),
1133
+ (draft) => {
1134
+ for (const sourceId of real) draft.sources[sourceId].pixel_size = pixelSize;
1135
+ });
1136
+ }
1137
+
1138
+ /**
1139
+ * Make a set of panels draw each marker the same colour.
1140
+ *
1141
+ * Asked for explicitly, never inferred: two panels showing CD8 in different
1142
+ * colours may be deliberate, and quietly repainting one of them would
1143
+ * change what a figure asserts. The legend popover puts this behind a
1144
+ * button that says what it does, with "keep them separate" first.
1145
+ *
1146
+ * Every recoloured panel gets a new render revision, so its cached preview
1147
+ * is refetched and the export re-renders it -- a recolour that left the old
1148
+ * raster in place would be a panel whose picture and whose legend disagree.
1149
+ */
1150
+ shareLegendColours(panelIds, canonical) {
1151
+ const updates = [];
1152
+ for (const panelId of panelIds) {
1153
+ const panel = this.state.panel(panelId);
1154
+ if (!panel) continue;
1155
+ let touched = false;
1156
+ const channels = (panel.scene.channels || []).map((channel) => {
1157
+ const name = channel.fullname_at_capture || channel.key;
1158
+ const colour = canonical.get(name);
1159
+ if (!colour) return channel;
1160
+ if (channel.color.r === colour.r && channel.color.g === colour.g
1161
+ && channel.color.b === colour.b) {
1162
+ return channel;
1163
+ }
1164
+ touched = true;
1165
+ return { ...channel, color: { ...colour } };
1166
+ });
1167
+ if (!touched) continue;
1168
+ updates.push({
1169
+ panel_id: panelId,
1170
+ changes: {
1171
+ scene: { ...panel.scene, channels: channels },
1172
+ render_revision: panel.render_revision + 1,
1173
+ },
1174
+ });
1175
+ }
1176
+ if (!updates.length) return;
1177
+ this.updatePanels(updates);
1178
+ // The previews are now pictures of the old colours. Saying so beats
1179
+ // showing a row that looks unchanged until it is exported.
1180
+ this.toast("Colors matched. Reopen a panel to redraw its preview — "
1181
+ + "the export renders the new colors either way.");
1182
+ }
1183
+
1184
+
1185
+
1186
+ /** One panel's properties, from the context bar. */
1187
+ updatePanel(panelId, changes) {
1188
+ if (!this.state.panel(panelId)) return;
1189
+ this.state.commit(
1190
+ [{ op: "update_panel", panel_id: panelId, changes: changes }],
1191
+ (draft) => { Object.assign(draft.panels[panelId], changes); });
1192
+ }
1193
+ /**
1194
+ * Rearrange the selection, then check the labels still make sense.
1195
+ *
1196
+ * An arrange moves panels into a new reading order, and automatic labels
1197
+ * follow it -- but a label the user typed does not. So a row that was
1198
+ * A B C and is now C A B keeps its hand-written letters in the wrong
1199
+ * places, silently, in a figure whose whole job is to be referred to by
1200
+ * those letters. The offer is a toast rather than a prompt: it is a
1201
+ * suggestion about presentation, and interrupting a layout gesture with a
1202
+ * dialog would be worse than the problem.
1203
+ */
1204
+ arrange(command) {
1205
+ this.canvas.arrange(command);
1206
+ const manual = this.canvas.selectedPanels()
1207
+ .filter((panel) => panel.placement && panel.label.visible && !panel.label.auto);
1208
+ if (manual.length < 2) return;
1209
+ this.toast("Panel order changed. Some labels were typed in, so they have not"
1210
+ + " moved.", { label: "Reset them to automatic",
1211
+ act: () => this.resetLabels(manual.map((p) => p.panel_id)) });
1212
+ }
1213
+
1214
+ resetLabels(panelIds) {
1215
+ this.updatePanels(panelIds
1216
+ .map((panelId) => ({ panel: this.state.panel(panelId), panel_id: panelId }))
1217
+ .filter((entry) => entry.panel)
1218
+ .map((entry) => ({
1219
+ panel_id: entry.panel_id,
1220
+ changes: { label: { ...entry.panel.label, auto: true, text: "" } },
1221
+ })));
1222
+ }
1223
+
1224
+ /**
1225
+ * Something worth saying that is not worth stopping for.
1226
+ *
1227
+ * In the overlay layer, bottom centre, gone after a while. Everything it
1228
+ * carries is a suggestion -- reset these labels, reopen a panel to redraw
1229
+ * it -- and a modal for any of them would interrupt a gesture to report on
1230
+ * that same gesture.
1231
+ */
1232
+ toast(message, action) {
1233
+ const host = this.el("fb_overlay_layer");
1234
+ if (!host) return;
1235
+ host.querySelectorAll(".fb-toast").forEach((old) => old.remove());
1236
+
1237
+ const toast = document.createElement("div");
1238
+ toast.className = "fb-toast";
1239
+ toast.innerHTML = `<span>${FigureSchema.escapeHtml(message)}</span>`;
1240
+ if (action) {
1241
+ const button = document.createElement("button");
1242
+ button.type = "button";
1243
+ button.className = "fb-toast-action";
1244
+ button.textContent = action.label;
1245
+ button.addEventListener("click", () => {
1246
+ toast.remove();
1247
+ action.act();
1248
+ });
1249
+ toast.appendChild(button);
1250
+ }
1251
+ const dismiss = document.createElement("button");
1252
+ dismiss.type = "button";
1253
+ dismiss.className = "fb-toast-close";
1254
+ dismiss.innerHTML = '<span class="fas fa-xmark"></span>';
1255
+ dismiss.addEventListener("click", () => toast.remove());
1256
+ toast.appendChild(dismiss);
1257
+
1258
+ host.appendChild(toast);
1259
+ window.setTimeout(() => toast.remove(), FigureWorkspace.TOAST_MS);
1260
+ }
1261
+
1262
+
1263
+
1264
+ split(mode) {
1265
+ const panelId = Array.from(this.canvas.selection)[0];
1266
+ if (panelId) this.canvas.splitComposite(panelId, mode);
1267
+ }
1268
+
1269
+ addPage() {
1270
+ const template = this.state.pages[this.state.pages.length - 1];
1271
+ // A new page copies the last one's size rather than defaulting to A4: a
1272
+ // figure whose pages are not all the same size exports as a document
1273
+ // nobody meant to make.
1274
+ const page = {
1275
+ page_id: FigureSchema.newPageId(),
1276
+ name: "Page " + (this.state.pages.length + 1),
1277
+ preset: template ? template.preset : "a4",
1278
+ orientation: template ? template.orientation : "portrait",
1279
+ size_mm: template ? { ...template.size_mm } : { w: 210, h: 297 },
1280
+ margins_mm: template ? { ...template.margins_mm } : undefined,
1281
+ background: template ? template.background : "#ffffff",
1282
+ };
1283
+ this.state.commit([{ op: "add_page", page: page }],
1284
+ (draft) => { draft.pages.push(page); });
1285
+ this.canvas.pageId = page.page_id;
1286
+ }
1287
+
1288
+ async removePage() {
1289
+ if (this.state.pages.length <= 1) {
1290
+ FigureConfirm.tell({
1291
+ title: "A figure needs at least one page.",
1292
+ body: "Add another page first, or delete what is on this one.",
1293
+ });
1294
+ return;
1295
+ }
1296
+ const pageId = this.canvas.pageId;
1297
+ const panels = FigureSchema.panelsOnPage(this.state.document, pageId);
1298
+
1299
+ // Three answers, and it used to be asked with two.
1300
+ //
1301
+ // A page holding panels can go three ways -- take the panels with it,
1302
+ // leave them in the tray, or not go at all -- and `window.confirm` has
1303
+ // room for the first two. So Cancel was spent on "keep them in the
1304
+ // tray", the page went either way, and the one thing a user pressing
1305
+ // Escape on a delete confirmation means was the one thing they could
1306
+ // not say. Now Escape means what it says and the two real answers are
1307
+ // two buttons that name themselves.
1308
+ //
1309
+ // Never silently orphaned, and keeping them is the safe answer, because
1310
+ // a captured scene may be the only record of a view somebody spent an
1311
+ // hour finding.
1312
+ let destroy = false;
1313
+ if (panels.length) {
1314
+ const answer = await FigureConfirm.choose({
1315
+ title: "Delete this page?",
1316
+ body: [`It holds ${FigureSchema.countPhrase(panels.length, "panel")}.`,
1317
+ "Panels kept in the tray can be placed on another page. "
1318
+ + "Deleted, they go with their captured scenes."],
1319
+ choices: [
1320
+ { value: null, label: "Cancel", focus: true },
1321
+ { value: false, label: "Keep the panels", kind: "primary" },
1322
+ { value: true, label: "Delete them too", kind: "danger" },
1323
+ ],
1324
+ });
1325
+ if (answer === null) return;
1326
+ destroy = answer;
1327
+ }
1328
+
1329
+ this.state.commit(
1330
+ [{ op: "remove_page", page_id: pageId, panels: destroy ? "delete" : "tray" }],
1331
+ (draft) => {
1332
+ draft.pages = draft.pages.filter((page) => page.page_id !== pageId);
1333
+ panels.forEach((panel) => {
1334
+ if (destroy) delete draft.panels[panel.panel_id];
1335
+ else draft.panels[panel.panel_id].placement = null;
1336
+ });
1337
+ Object.keys(draft.annotations).forEach((id) => {
1338
+ if (draft.annotations[id].page_id === pageId) delete draft.annotations[id];
1339
+ });
1340
+ });
1341
+ this.canvas.pageId = null;
1342
+ }
1343
+
1344
+ // -- the tray ------------------------------------------------------------
1345
+
1346
+ /**
1347
+ * A click in the tray.
1348
+ *
1349
+ * On pointerdown rather than click so that a drag which begins on an
1350
+ * unselected item selects it first -- otherwise dragging a panel out of a
1351
+ * four-panel selection would carry four panels the user could no longer see
1352
+ * highlighted.
1353
+ */
1354
+ trayPointerDown(panelId, event) {
1355
+ const ids = this.trayVisibleIds();
1356
+ if (event.shiftKey && this.trayAnchor && ids.includes(this.trayAnchor)) {
1357
+ const from = ids.indexOf(this.trayAnchor);
1358
+ const to = ids.indexOf(panelId);
1359
+ const [low, high] = from < to ? [from, to] : [to, from];
1360
+ this.traySelection = new Set(ids.slice(low, high + 1));
1361
+ } else if (event.metaKey || event.ctrlKey) {
1362
+ if (this.traySelection.has(panelId)) this.traySelection.delete(panelId);
1363
+ else this.traySelection.add(panelId);
1364
+ this.trayAnchor = panelId;
1365
+ } else {
1366
+ this.traySelection = new Set([panelId]);
1367
+ this.trayAnchor = panelId;
1368
+ }
1369
+ this.renderTray();
1370
+ }
1371
+
1372
+ /** The tray panels currently on screen, in the order they are shown. */
1373
+ trayVisibleIds() {
1374
+ return this.trayPanels().map((panel) => panel.panel_id);
1375
+ }
1376
+
1377
+ /** Drop the filter and show the whole tray again. */
1378
+ clearTraySearch() {
1379
+ this.traySearch = "";
1380
+ const search = this.el("fb_tray_search");
1381
+ if (search) search.value = "";
1382
+ this.renderTray();
1383
+ }
1384
+
1385
+ trayPanels() {
1386
+ const panels = FigureSchema.panelsInTray(this.state.document);
1387
+ const needle = this.traySearch.trim().toLowerCase();
1388
+ if (!needle) return panels;
1389
+ return panels.filter((panel) => this.trayHaystack(panel).includes(needle));
1390
+ }
1391
+
1392
+ trayHaystack(panel) {
1393
+ const source = this.state.source(panel.source_id);
1394
+ return [panel.title, panel.label && panel.label.text,
1395
+ source && (source.display_name || source.datasource)]
1396
+ .filter(Boolean).join(" ").toLowerCase();
1397
+ }
1398
+
1399
+ /**
1400
+ * Put tray panels onto the page without dragging them.
1401
+ *
1402
+ * The same path a drag takes, so double-clicking four selected panels and
1403
+ * dragging four selected panels produce the same layout -- and neither
1404
+ * stacks them.
1405
+ */
1406
+ placeFromTray(panelIds) {
1407
+ const ids = panelIds.filter((id) => this.state.panel(id));
1408
+ const page = this.canvas.page;
1409
+ if (!ids.length || !page) return;
1410
+ this.canvas.placePanels(ids, this.canvas.traySizes(ids, page), null);
1411
+ ids.forEach((id) => this.traySelection.delete(id));
1412
+ }
1413
+
1414
+ /** Right-clicking a tray item that is not in the tray selection selects it
1415
+ * first, so the menu never acts on something else. */
1416
+ trayContext(panelId) {
1417
+ if (this.traySelection.has(panelId)) return;
1418
+ this.traySelection = new Set([panelId]);
1419
+ this.trayAnchor = panelId;
1420
+ this.renderTray();
1421
+ }
1422
+
1423
+ duplicateTray() {
1424
+ const panels = Array.from(this.traySelection)
1425
+ .map((id) => this.state.panel(id)).filter(Boolean);
1426
+ if (!panels.length) return;
1427
+ // No offset and no selection: these have no placement, so there is
1428
+ // nothing to offset and nothing on the page to select.
1429
+ this.canvas.commitCopies(this.canvas.copiesOf(panels, [], 0, 0), { select: false });
1430
+ }
1431
+
1432
+ /**
1433
+ * Destroy panels, and the sources nothing references any more.
1434
+ *
1435
+ * The OTHER delete, and the reason the two are worded differently. Removing
1436
+ * a panel from a page is a statement about the page and is one keystroke
1437
+ * away; this destroys the captured scene, which may be the only record of a
1438
+ * view somebody spent an hour finding. So it asks, it says how many, and it
1439
+ * says what else goes with them.
1440
+ *
1441
+ * A source left behind with no panels is not harmless: it is what the
1442
+ * provenance page lists and what "this source has changed" is checked
1443
+ * against, so a figure that accumulated one per deleted panel would warn
1444
+ * about images it no longer draws. They go in the SAME commit, so the whole
1445
+ * thing is one undo step -- and undoing it brings the source back too.
1446
+ */
1447
+ async deleteFromFigure(ids) {
1448
+ const panels = ids.map((id) => this.state.panel(id)).filter(Boolean);
1449
+ const annotationIds = ids.filter((id) => this.state.document.annotations[id]);
1450
+ if (!panels.length && !annotationIds.length) return;
1451
+
1452
+ const doomed = new Set(panels.map((panel) => panel.panel_id));
1453
+ const orphaned = this.sourcesLeftUnreferenced(doomed);
1454
+
1455
+ const parts = [];
1456
+ if (panels.length) parts.push(FigureSchema.countPhrase(panels.length, "panel"));
1457
+ if (annotationIds.length) {
1458
+ parts.push(FigureSchema.countPhrase(annotationIds.length, "annotation"));
1459
+ }
1460
+ const body = ["This cannot be undone by closing the page — the captured "
1461
+ + "scenes go with them."];
1462
+ if (orphaned.length) {
1463
+ body.push("The figure will also stop referencing "
1464
+ + `${FigureSchema.countPhrase(orphaned.length, "image")}, `
1465
+ + "which nothing else uses.");
1466
+ }
1467
+ const go = await FigureConfirm.ask({
1468
+ title: `Delete ${parts.join(" and ")} from this figure?`,
1469
+ body: body,
1470
+ confirm: "Delete",
1471
+ });
1472
+ if (!go) return;
1473
+
1474
+ const operations = [];
1475
+ if (panels.length) {
1476
+ operations.push({ op: "remove_panels", panel_ids: Array.from(doomed) });
1477
+ }
1478
+ if (annotationIds.length) {
1479
+ operations.push({ op: "remove_annotations", annotation_ids: annotationIds });
1480
+ }
1481
+ // "keep" rather than "delete": the panels are already going in the
1482
+ // operation above, and asking the server to delete them twice is how a
1483
+ // batch fails halfway.
1484
+ for (const sourceId of orphaned) {
1485
+ operations.push({ op: "remove_source", source_id: sourceId, panels: "keep" });
1486
+ }
1487
+
1488
+ for (const id of doomed) this.canvas.selection.delete(id);
1489
+ for (const id of annotationIds) this.canvas.selection.delete(id);
1490
+ for (const id of ids) this.traySelection.delete(id);
1491
+
1492
+ this.state.commit(operations, (draft) => {
1493
+ for (const id of doomed) delete draft.panels[id];
1494
+ for (const id of annotationIds) delete draft.annotations[id];
1495
+ for (const id of orphaned) delete draft.sources[id];
1496
+ for (const [groupId, group] of Object.entries(draft.groups || {})) {
1497
+ group.member_ids = group.member_ids.filter(
1498
+ (member) => !doomed.has(member) && !annotationIds.includes(member));
1499
+ if (group.member_ids.length < 2) delete draft.groups[groupId];
1500
+ }
1501
+ });
1502
+ this.canvas.onSelectionChange(Array.from(this.canvas.selection));
1503
+ }
1504
+
1505
+ /** The sources that would have no panels left once `doomed` has gone. */
1506
+ sourcesLeftUnreferenced(doomed) {
1507
+ const survivors = new Set();
1508
+ for (const panel of Object.values(this.state.document.panels)) {
1509
+ if (!doomed.has(panel.panel_id)) survivors.add(panel.source_id);
1510
+ }
1511
+ return Object.keys(this.state.document.sources)
1512
+ .filter((sourceId) => !survivors.has(sourceId));
1513
+ }
1514
+
1515
+
1516
+
1517
+ // -- importing files -----------------------------------------------------
1518
+
1519
+ dropFiles(event) {
1520
+ const files = Array.from(event.dataTransfer?.files || []);
1521
+ if (!files.length) return;
1522
+ event.preventDefault();
1523
+ this.importFiles(files, this.canvas.surfacePoint(event));
1524
+ }
1525
+
1526
+ /**
1527
+ * Files brought in from outside the project.
1528
+ *
1529
+ * Figure-only by design: a schematic or a supporting RGB image is not a
1530
+ * project, and the bytes land in this figure's own directory. Each panel
1531
+ * arrives at the image's own aspect ratio, and a batch is laid out rather
1532
+ * than stacked -- landing everything square, or all at one coordinate, is
1533
+ * work the user then has to undo.
1534
+ */
1535
+ async importFiles(files, atPoint) {
1536
+ const page = this.canvas.page;
1537
+ if (!page) return;
1538
+ const task = window.PlexoraStatus?.begin("Importing");
1539
+
1540
+ const added = [];
1541
+ for (const file of files) {
1542
+ const uploaded = await this.api.addAsset(this.figureId, file.name, file);
1543
+ if (!uploaded.ok) {
1544
+ task?.fail(uploaded.data.error || "That file could not be imported");
1545
+ return;
1546
+ }
1547
+ added.push({ asset: uploaded.data, dimensions: await this.imageSize(file) });
1548
+ }
1549
+
1550
+ const width = Math.min(60, page.size_mm.w / 3);
1551
+ const sizes = added.map((entry) => ({
1552
+ w_mm: width,
1553
+ h_mm: width * ((entry.dimensions.height / entry.dimensions.width) || 1),
1554
+ }));
1555
+ const origin = atPoint ? {
1556
+ x_mm: Math.max(page.margins_mm.left, atPoint.x - width / 2),
1557
+ y_mm: Math.max(page.margins_mm.top, atPoint.y - sizes[0].h_mm / 2),
1558
+ } : null;
1559
+ const boxes = FigureCanvas.freePlacements(
1560
+ sizes, page, this.canvas.occupiedBoxes(),
1561
+ this.state.document.settings.style.gutter_mm, origin);
1562
+
1563
+ let z = this.canvas.nextZ();
1564
+ const operations = [];
1565
+ const records = [];
1566
+ added.forEach((entry, index) => {
1567
+ const sourceId = FigureSchema.newSourceId();
1568
+ const panelId = FigureSchema.newPanelId();
1569
+ const source = {
1570
+ source_id: sourceId, kind: "imported_asset", asset_id: entry.asset.asset_id,
1571
+ display_name: entry.asset.filename,
1572
+ image: { width: entry.dimensions.width, height: entry.dimensions.height },
1573
+ // No calibration, and none invented: an imported PNG has no
1574
+ // physical scale, so its panels have no scale bar until
1575
+ // somebody types one in.
1576
+ pixel_size: null, channels: [], status: "ok",
1577
+ };
1578
+ const panel = {
1579
+ panel_id: panelId, source_id: sourceId,
1580
+ scene: { ...FigureSchema.emptyScene(sourceId),
1581
+ viewport: { x: 0, y: 0,
1582
+ w: entry.dimensions.width, h: entry.dimensions.height } },
1583
+ placement: { page_id: page.page_id, ...boxes[index], z: z++ },
1584
+ title: entry.asset.filename, label: { text: "", auto: true, visible: true },
1585
+ scalebar: { visible: false, target_um: null },
1586
+ legend: { channels: false, plugins: false }, render_revision: 1,
1587
+ };
1588
+ operations.push({ op: "add_source", source: source },
1589
+ { op: "add_panel", panel: panel });
1590
+ records.push({ source: source, panel: panel });
1591
+ });
1592
+
1593
+ // One commit: dropping four files is one thing the user did, and four
1594
+ // would be four undo steps for it.
1595
+ await this.state.commit(operations, (draft) => {
1596
+ for (const record of records) {
1597
+ draft.sources[record.source.source_id] = record.source;
1598
+ draft.panels[record.panel.panel_id] = record.panel;
1599
+ }
1600
+ });
1601
+ task?.done();
1602
+ }
1603
+
1604
+ imageSize(file) {
1605
+ return new Promise((resolve) => {
1606
+ const url = URL.createObjectURL(file);
1607
+ const image = new Image();
1608
+ image.onload = () => {
1609
+ resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 });
1610
+ URL.revokeObjectURL(url);
1611
+ };
1612
+ // A TIFF the browser cannot decode still imports; it simply lands
1613
+ // square until the user resizes it, which is better than refusing
1614
+ // a file the export renderer can read perfectly well.
1615
+ image.onerror = () => {
1616
+ resolve({ width: 1, height: 1 });
1617
+ URL.revokeObjectURL(url);
1618
+ };
1619
+ image.src = url;
1620
+ });
1621
+ }
1622
+
1623
+ // -- reopening a panel ---------------------------------------------------
1624
+
1625
+ /**
1626
+ * The cheap edit, where it is possible.
1627
+ *
1628
+ * Quick Edit opens beside the canvas and costs nothing but a few small
1629
+ * reads; the main viewer is a page load away from the figure. So this is
1630
+ * what a double-click and the bar's first button reach for, and the
1631
+ * expensive route is what "Open in Main Viewer" says on it.
1632
+ *
1633
+ * A panel with no project image behind it -- an imported PNG, a source
1634
+ * whose project has gone -- has nothing to quick-edit, and falls through to
1635
+ * the route that can at least explain itself.
1636
+ */
1637
+ quickEditPanel(panelId) {
1638
+ const panel = this.state.panel(panelId);
1639
+ if (this.quickEdit?.canEdit(panel)) {
1640
+ this.quickEdit.open(panelId);
1641
+ return;
1642
+ }
1643
+ this.onEditPanel(panelId);
1644
+ }
1645
+
1646
+ /**
1647
+ * Open a panel's view in the main viewer.
1648
+ *
1649
+ * There is no viewer on this page, so this navigates -- with the request
1650
+ * left in sessionStorage for the page that lands to pick up.
1651
+ *
1652
+ * The note carries three things the viewer cannot work out for itself: WHICH
1653
+ * panel, what SHAPE it is now (a square capture the user has since dragged
1654
+ * into a wide strip must be reframed as a wide strip), and that the user
1655
+ * expects to come back here when they are done. Without the last one the
1656
+ * round trip is a one-way trip and the user has to find the figure again.
1657
+ */
1658
+ editPanel(panelId) {
1659
+ const panel = this.state.panel(panelId);
1660
+ const source = panel && this.state.source(panel.source_id);
1661
+ if (!source || source.kind !== "plexora_project" || !source.datasource) {
1662
+ FigureConfirm.tell({
1663
+ title: "This panel has no project image to reopen.",
1664
+ body: "It was captured from an image the figure no longer references.",
1665
+ });
1666
+ return;
1667
+ }
1668
+ const place = panel.placement;
1669
+ const aspect = place && place.h_mm > 0
1670
+ ? place.w_mm / place.h_mm
1671
+ : (panel.scene.viewport.h > 0
1672
+ ? panel.scene.viewport.w / panel.scene.viewport.h : 1);
1673
+ try {
1674
+ window.sessionStorage.setItem("plexora:figure-builder-pending",
1675
+ JSON.stringify({
1676
+ figure_id: this.figureId,
1677
+ panel_id: panelId,
1678
+ mode: "update",
1679
+ aspect: aspect,
1680
+ return_to: "canvas",
1681
+ }));
1682
+ } catch (error) {
1683
+ /* Private-browsing modes throw; the navigation is still worth doing. */
1684
+ }
1685
+ window.location.href = this.api.url(encodeURIComponent(source.datasource))
1686
+ + "?tool=figure_builder";
1687
+ }
1688
+
1689
+ /**
1690
+ * Record that the user has looked at a changed source and accepted it.
1691
+ *
1692
+ * Only the fingerprint moves. No panel is re-rendered and no captured scene
1693
+ * is touched -- accepting the new image is a statement about what the
1694
+ * warning should say from now on, not a decision to redraw a figure from
1695
+ * data that has changed underneath it. Re-rendering a panel is done by
1696
+ * reopening it, deliberately, one panel at a time.
1697
+ */
1698
+ async acceptChangedSource(sourceId) {
1699
+ const source = this.state.source(sourceId);
1700
+ if (!source || !source.datasource) return;
1701
+ const described = await this.api.describeSource(source.datasource);
1702
+ if (!described.ok) return;
1703
+
1704
+ const changes = {
1705
+ image: described.data.source.image,
1706
+ channels: described.data.source.channels,
1707
+ fingerprint: described.data.source.fingerprint,
1708
+ status: "ok",
1709
+ };
1710
+ await this.state.commit(
1711
+ [{ op: "update_source", source_id: sourceId, changes: changes }],
1712
+ (draft) => { Object.assign(draft.sources[sourceId], changes); });
1713
+ // The status the context bar shows is computed by the server on read,
1714
+ // so the local copy has to be told too or the badge stays until a
1715
+ // reload.
1716
+ this.state.sourceStatus[sourceId] = { status: "ok", reasons: [] };
1717
+ this.render();
1718
+ }
1719
+
1720
+ // -- zoom and pan ----------------------------------------------------------
1721
+
1722
+ setScale(scale) {
1723
+ this.canvas.setScale(scale);
1724
+ this.afterZoom();
1725
+ }
1726
+
1727
+ zoomToFit() {
1728
+ this.canvas.zoomToFit(this.el("fb_canvas_scroll"));
1729
+ this.afterZoom();
1730
+ }
1731
+
1732
+ afterZoom() {
1733
+ this.renderZoom();
1734
+ this.viewOptions?.apply();
1735
+ this.contextBar?.position();
1736
+ }
1737
+
1738
+ /**
1739
+ * Zoom about a point on screen, keeping what is under it still.
1740
+ *
1741
+ * Measured before and after rather than computed from the scroll offset,
1742
+ * because the page is centred with `margin: auto` whenever it is narrower
1743
+ * than the viewport -- so its left edge is not a function of scrollLeft and
1744
+ * the arithmetic that assumes it is drifts by half the slack, which is most
1745
+ * of the window when a page is zoomed out.
1746
+ */
1747
+ zoomAt(clientX, clientY, factor) {
1748
+ const scroll = this.el("fb_canvas_scroll");
1749
+ const pageEl = this.el("fb_page");
1750
+ if (!scroll || !pageEl) return;
1751
+
1752
+ const before = pageEl.getBoundingClientRect();
1753
+ const mmX = (clientX - before.left) / this.canvas.scale;
1754
+ const mmY = (clientY - before.top) / this.canvas.scale;
1755
+
1756
+ const wanted = this.canvas.scale * factor;
1757
+ this.canvas.setScale(wanted);
1758
+ // setScale clamps, so the point is recomputed from where it actually
1759
+ // landed rather than from where it was asked to.
1760
+ const after = pageEl.getBoundingClientRect();
1761
+ scroll.scrollLeft += (after.left + mmX * this.canvas.scale) - clientX;
1762
+ scroll.scrollTop += (after.top + mmY * this.canvas.scale) - clientY;
1763
+ this.afterZoom();
1764
+ }
1765
+
1766
+ /**
1767
+ * Fill the window with what is selected.
1768
+ *
1769
+ * Padded rather than edge to edge: a selection that fills the viewport
1770
+ * exactly hides what is next to it, and "is this panel the same size as the
1771
+ * one beside it" is most of what anybody zooms in to check.
1772
+ */
1773
+ zoomToSelection() {
1774
+ const boxes = this.canvas.selectedPanels()
1775
+ .filter((panel) => panel.placement).map((panel) => panel.placement)
1776
+ .concat(this.canvas.selectedAnnotations().map((annotation) => ({
1777
+ x_mm: annotation.geometry.x_mm + Math.min(0, annotation.geometry.w_mm),
1778
+ y_mm: annotation.geometry.y_mm + Math.min(0, annotation.geometry.h_mm),
1779
+ w_mm: Math.abs(annotation.geometry.w_mm),
1780
+ h_mm: Math.abs(annotation.geometry.h_mm),
1781
+ })));
1782
+ const scroll = this.el("fb_canvas_scroll");
1783
+ if (!boxes.length || !scroll) {
1784
+ this.zoomToFit();
1785
+ return;
1786
+ }
1787
+ const left = Math.min(...boxes.map((box) => box.x_mm));
1788
+ const top = Math.min(...boxes.map((box) => box.y_mm));
1789
+ const right = Math.max(...boxes.map((box) => box.x_mm + box.w_mm));
1790
+ const bottom = Math.max(...boxes.map((box) => box.y_mm + box.h_mm));
1791
+
1792
+ const padding = 64;
1793
+ this.canvas.setScale(Math.min(
1794
+ (scroll.clientWidth - padding) / Math.max(1, right - left),
1795
+ (scroll.clientHeight - padding) / Math.max(1, bottom - top)));
1796
+
1797
+ const pageEl = this.el("fb_page");
1798
+ const paper = pageEl.getBoundingClientRect();
1799
+ const view = scroll.getBoundingClientRect();
1800
+ scroll.scrollLeft += (paper.left + this.canvas.toPx((left + right) / 2))
1801
+ - (view.left + view.width / 2);
1802
+ scroll.scrollTop += (paper.top + this.canvas.toPx((top + bottom) / 2))
1803
+ - (view.top + view.height / 2);
1804
+ this.afterZoom();
1805
+ }
1806
+
1807
+ // -- rendering -------------------------------------------------------
1808
+
1809
+ render() {
1810
+ if (!this.state.document || !this.root) return;
1811
+ const title = this.el("fb_title");
1812
+ if (title && title !== document.activeElement) title.value = this.state.title;
1813
+
1814
+ this.renderPageList();
1815
+ this.canvas.render();
1816
+ if (!this._fitted) {
1817
+ this._fitted = true;
1818
+ this.canvas.zoomToFit(this.el("fb_canvas_scroll"));
1819
+ }
1820
+ // Margins move with the page, the grid and the rulers move with the
1821
+ // zoom, and both can have changed by the time this runs.
1822
+ this.viewOptions?.apply();
1823
+ // The annotation being typed into is hidden behind its editor, and the
1824
+ // render above has just put it back. Re-hidden here rather than in the
1825
+ // canvas, which does not know an editor exists.
1826
+ if (this.textEditor?.active) {
1827
+ const element = this.canvas.surfaceEl.querySelector(
1828
+ `[data-annotation-id="${this.textEditor.annotationId}"]`);
1829
+ // Measured before it is hidden. `visibility: hidden` keeps the
1830
+ // layout box, so the order does not strictly matter -- but the
1831
+ // editor follows the annotation's new geometry, leading and
1832
+ // alignment here, and reading them off it first is the honest way
1833
+ // round.
1834
+ this.textEditor.reposition(element);
1835
+ if (element) element.style.visibility = "hidden";
1836
+ }
1837
+ this.renderZoom();
1838
+ this.renderTray();
1839
+ this.renderHistory();
1840
+ this.renderRail();
1841
+ this.renderPageMeta();
1842
+ this.contextBar?.update(Array.from(this.canvas.selection));
1843
+ this.textPanel?.update(Array.from(this.canvas.selection));
1844
+ this.contextSidebar(Boolean(this.textPanel?.wants));
1845
+ }
1846
+
1847
+ /**
1848
+ * The two captions that describe the page rather than change it.
1849
+ *
1850
+ * The size sits on the sheet, above its top-left corner, because it is a
1851
+ * property of the paper; the object count sits in the status bar, because
1852
+ * it is a property of the window's contents. Both are read-only -- the size
1853
+ * is changed from the page menu, and there is nothing to click on a count.
1854
+ */
1855
+ renderPageMeta() {
1856
+ const page = this.canvas.page;
1857
+ const caption = this.el("fb_sheet_caption");
1858
+ if (caption) {
1859
+ // Rounded to a tenth and stripped of a trailing zero: A4 is 210 mm,
1860
+ // not 210.0 mm, and a custom page of 148.5 keeps its half.
1861
+ const mm = (value) => String(Math.round(value * 10) / 10);
1862
+ caption.textContent = page
1863
+ ? `${mm(page.size_mm.w)} × ${mm(page.size_mm.h)} mm`
1864
+ : "";
1865
+ }
1866
+
1867
+ const count = this.el("fb_object_count");
1868
+ if (!count) return;
1869
+ if (!page) {
1870
+ count.textContent = "";
1871
+ return;
1872
+ }
1873
+ const placed = FigureSchema.panelsOnPage(this.state.document, page.page_id).length
1874
+ + Object.values(this.state.document.annotations)
1875
+ .filter((annotation) => annotation.page_id === page.page_id).length;
1876
+ count.textContent = placed === 1 ? "1 object" : `${placed} objects`;
1877
+ }
1878
+
1879
+ selectionChanged(ids) {
1880
+ // Selecting on the page is a statement about the page, so the tray's
1881
+ // own selection lets go: two highlighted sets meaning two different
1882
+ // things is what makes "which of these does Delete act on?"
1883
+ // unanswerable.
1884
+ if (ids.length && this.traySelection.size) {
1885
+ this.traySelection.clear();
1886
+ this.renderTray();
1887
+ }
1888
+ this.renderRail();
1889
+ this.contextBar?.update(ids);
1890
+ this.textPanel?.update(ids);
1891
+ this.contextSidebar(Boolean(this.textPanel?.wants));
1892
+ }
1893
+
1894
+ renderPageList() {
1895
+ const select = this.el("fb_page_select");
1896
+ const pages = this.state.pages;
1897
+ if (!pages.some((page) => page.page_id === this.canvas.pageId)) {
1898
+ this.canvas.pageId = pages.length ? pages[0].page_id : null;
1899
+ }
1900
+ if (!select) return;
1901
+ select.innerHTML = pages.map((page) =>
1902
+ `<option value="${FigureSchema.escapeHtml(page.page_id)}">${FigureSchema.escapeHtml(page.name)}</option>`
1903
+ ).join("");
1904
+ select.value = this.canvas.pageId || "";
1905
+ }
1906
+
1907
+ renderZoom() {
1908
+ const readout = this.el("fb_zoom_readout");
1909
+ if (readout) {
1910
+ readout.textContent =
1911
+ Math.round((this.canvas.scale / FigureWorkspace.PX_PER_MM) * 100) + "%";
1912
+ }
1913
+ }
1914
+
1915
+ renderHistory() {
1916
+ const undo = this.el("fb_undo");
1917
+ const redo = this.el("fb_redo");
1918
+ if (undo) undo.disabled = !this.state.canUndo;
1919
+ if (redo) redo.disabled = !this.state.canRedo;
1920
+ }
1921
+
1922
+ renderTray() {
1923
+ const all = FigureSchema.panelsInTray(this.state.document);
1924
+ const shown = this.trayPanels();
1925
+
1926
+ const wrap = this.el("fb_tray_search_wrap");
1927
+ if (wrap) wrap.hidden = all.length < FigureWorkspace.SEARCH_THRESHOLD;
1928
+ const clear = this.el("fb_tray_search_clear");
1929
+ if (clear) clear.hidden = !this.traySearch;
1930
+ const count = this.el("fb_tray_count");
1931
+ if (count) {
1932
+ count.textContent = all.length
1933
+ ? (shown.length === all.length
1934
+ ? String(all.length)
1935
+ : `${shown.length} / ${all.length}`)
1936
+ : "";
1937
+ }
1938
+ const empty = this.el("fb_tray_empty");
1939
+ if (empty) empty.hidden = all.length > 0;
1940
+
1941
+ // Only while the card is shut. Open, the count is already in its
1942
+ // heading, and two copies of the same number a hundred pixels apart
1943
+ // read as two different numbers.
1944
+ const badge = this.el("fb_tray_badge");
1945
+ const panel = this.el("fb_tray_panel");
1946
+ if (badge) {
1947
+ badge.textContent = (all.length && panel?.hidden) ? String(all.length) : "";
1948
+ }
1949
+
1950
+ const strip = this.el("fb_tray_strip");
1951
+ if (!strip) return;
1952
+ // Anything filtered out of view is out of the selection too: a batch
1953
+ // placed from a search box has to be the batch that was visible.
1954
+ const visible = new Set(shown.map((panel) => panel.panel_id));
1955
+ for (const id of Array.from(this.traySelection)) {
1956
+ if (!visible.has(id)) this.traySelection.delete(id);
1957
+ }
1958
+
1959
+ if (all.length && !shown.length) {
1960
+ strip.innerHTML = '<p class="fb-muted fb-tray-nomatch">No panel matches that.</p>';
1961
+ return;
1962
+ }
1963
+ strip.innerHTML = shown.map((panel) => {
1964
+ const source = this.state.source(panel.source_id);
1965
+ const span = FigureSchema.physicalWidthUm(source, panel.scene.viewport);
1966
+ const caption = panel.title
1967
+ || (source && (source.display_name || source.datasource))
1968
+ || "Untitled panel";
1969
+ const detail = span ? FigureSchema.formatMicrons(span) + " wide" : "";
1970
+ const selected = this.traySelection.has(panel.panel_id);
1971
+ // The tiles are two across and mostly picture now, so the caption
1972
+ // is behind a hover -- which makes this the accessible name as well
1973
+ // as the tooltip, and it has to carry both lines.
1974
+ const tip = detail ? `${caption} — ${detail}` : caption;
1975
+ // And the tile is the SHAPE of the region it shows, before the
1976
+ // picture arrives: the preview is this viewport, so the ratio is
1977
+ // known without waiting for the file. A grid that resized itself as
1978
+ // twelve lazy images loaded would move the tile out from under the
1979
+ // pointer that was reaching for it.
1980
+ const view = panel.scene && panel.scene.viewport;
1981
+ const ratio = (view && view.w > 0 && view.h > 0)
1982
+ ? ` style="aspect-ratio:${Number(view.w)} / ${Number(view.h)}"`
1983
+ : "";
1984
+ return `<div class="fb-tray-item${selected ? " is-selected" : ""}" draggable="true"
1985
+ role="option" aria-selected="${selected}"
1986
+ data-panel-id="${FigureSchema.escapeHtml(panel.panel_id)}"
1987
+ aria-label="${FigureSchema.escapeHtml(tip)}"
1988
+ title="${FigureSchema.escapeHtml(tip)}">
1989
+ <img src="${this.api.previewUrl(this.figureId, panel.panel_id, panel.render_revision)}"
1990
+ alt="" draggable="false" loading="lazy"${ratio}>
1991
+ <span class="fb-tray-item-text">
1992
+ <span class="fb-tray-item-name">${FigureSchema.escapeHtml(caption)}</span>
1993
+ <span class="fb-tray-item-meta">${FigureSchema.escapeHtml(detail)}</span>
1994
+ </span>
1995
+ </div>`;
1996
+ }).join("");
1997
+ }
1998
+
1999
+ renderStatus(payload) {
2000
+ const status = this.el("fb_save_status");
2001
+ if (status) {
2002
+ status.textContent = {
2003
+ loading: "Opening…",
2004
+ saving: "Saving…",
2005
+ saved: "Saved",
2006
+ unsaved: "Unsaved changes",
2007
+ failed: payload.detail || "Save failed",
2008
+ conflict: "Changed elsewhere",
2009
+ unreadable: "Cannot be opened",
2010
+ }[payload.status] || "";
2011
+ status.className = "fb-save-status fb-status-" + payload.status;
2012
+ }
2013
+ const conflict = this.el("fb_conflict_banner");
2014
+ if (conflict) conflict.hidden = payload.status !== "conflict";
2015
+ const unreadable = this.el("fb_unreadable_banner");
2016
+ if (unreadable) {
2017
+ unreadable.hidden = payload.status !== "unreadable";
2018
+ const detail = this.el("fb_unreadable_detail");
2019
+ if (detail) detail.textContent = payload.detail || "";
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ if (typeof document !== "undefined" && document.addEventListener) {
2025
+ if (document.readyState === "loading") {
2026
+ document.addEventListener("DOMContentLoaded", () => FigureWorkspace.boot());
2027
+ } else {
2028
+ FigureWorkspace.boot();
2029
+ }
2030
+ }