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,2362 @@
1
+ /**
2
+ * FigureCanvas - laying panels out on a page.
3
+ *
4
+ * Everything positional is in MILLIMETRES, converted to CSS pixels by exactly
5
+ * one number (`scale`) at the moment of drawing. Nothing is ever stored in
6
+ * pixels. That is what makes zooming free, what makes the same figure lay out
7
+ * identically on a different monitor, and what makes "4 mm gutter" mean 4 mm in
8
+ * the exported PDF rather than 4 mm on whichever screen it was nudged into
9
+ * place on.
10
+ *
11
+ * Panels are cached preview rasters -- <img> elements, one HTTP request each.
12
+ * Never live viewers: a figure with a hundred panels would otherwise be a
13
+ * hundred WebGL contexts and a hundred tile queues, and the browser stops
14
+ * responding long before the figure is finished. Editing a panel's SCENE hands
15
+ * off to the one live viewer (see onEditPanel).
16
+ *
17
+ * ## Drags are provisional until they are released
18
+ *
19
+ * A pointer move writes inline styles and nothing else. One `move_panels`
20
+ * operation is committed on release, which is what makes a drag of five
21
+ * selected panels one undo step, and what keeps a save out of the pointer path
22
+ * -- a request per mousemove would be hundreds of writes for one gesture whose
23
+ * intermediate positions nobody wants.
24
+ *
25
+ * ## Snapping
26
+ *
27
+ * Candidate lines come from the page (edges, margins, centre) and from every
28
+ * other panel (its three horizontal and three vertical lines). The threshold is
29
+ * in SCREEN pixels, converted to mm per gesture: a fixed mm threshold is
30
+ * unusably sticky zoomed in and useless zoomed out.
31
+ */
32
+ class FigureCanvas {
33
+
34
+ /** How close, in SCREEN pixels, snaps. */
35
+ static get SNAP_PIXELS() { return 6; }
36
+
37
+ /** Nudge distance in mm, and the coarse one with Shift. */
38
+ static get NUDGE_MM() { return 0.5; }
39
+ static get NUDGE_COARSE_MM() { return 5; }
40
+
41
+ /** Smallest panel, in mm. Below this the handles overlap and it cannot be
42
+ * grabbed to make it bigger again. */
43
+ static get MIN_SIZE_MM() { return 5; }
44
+
45
+ /** The floor for a TEXT box, which is not the same problem.
46
+ *
47
+ * A single 8 pt line is 8 x 1.2 x 25.4/72 = 3.39mm tall, so the 5mm floor
48
+ * would inflate every one-line caption by nearly half and make `autofit`
49
+ * fight the clamp on every commit. */
50
+ static get MIN_TEXT_MM() { return 1; }
51
+
52
+ /** How far a duplicate or a paste lands from the original. Enough to see
53
+ * that there are two of something, small enough to still be next to it. */
54
+ static get PASTE_OFFSET_MM() { return 4; }
55
+
56
+ /** What `page.background` says instead of a colour. Matches
57
+ * `server/schema.TRANSPARENT`. */
58
+ static get TRANSPARENT() { return "transparent"; }
59
+
60
+ constructor(options) {
61
+ this.state = options.state;
62
+ this.api = options.api;
63
+ this.figureId = options.figureId;
64
+ this.onEditPanel = options.onEditPanel || (() => {});
65
+ this.onSelectionChange = options.onSelectionChange || (() => {});
66
+ //: Told when a drag starts and stops, so chrome that floats over the
67
+ //: page can get out of the way of it. Not an event bus: there is one
68
+ //: listener and it is the workspace that built this.
69
+ this.onGesture = options.onGesture || (() => {});
70
+ //: A drawing tool has placed something and the rail should stand down.
71
+ this.onToolFinished = options.onToolFinished || (() => {});
72
+ //: Open the in-place editor on a text annotation. The editor lives in
73
+ //: the overlay layer, which this does not own -- render() would destroy
74
+ //: it mid-keystroke.
75
+ this.onEditText = options.onEditText || (() => {});
76
+
77
+ this.pageEl = options.pageEl;
78
+ this.surfaceEl = options.surfaceEl;
79
+ this.guideEl = options.guideEl || null;
80
+
81
+ this.scale = 96 / 25.4;
82
+ this.pageId = null;
83
+ this.selection = new Set();
84
+ //: {kind, items:[{kind, id, start}], origin, handle} while a gesture is
85
+ //: in flight. Null the rest of the time, which is what every handler
86
+ //: below tests instead of a set of booleans.
87
+ this.gesture = null;
88
+ //: The armed drawing tool -- "text", "rect", "ellipse", "line",
89
+ //: "arrow" -- or null for select. One-shot: see setTool.
90
+ this.tool = null;
91
+ //: What a drag may snap onto, from the View menu. Held here rather than
92
+ //: read from a preference store on every pointer move.
93
+ this.snapping = { guides: true, grid: false, gridMm: 5 };
94
+ }
95
+
96
+ // -- units -----------------------------------------------------------
97
+
98
+ toPx(mm) { return mm * this.scale; }
99
+
100
+ toMm(px) { return px / this.scale; }
101
+
102
+ /**
103
+ * The page being edited, or null when there is not one yet.
104
+ *
105
+ * Null is a real answer here rather than a defensive shrug: the canvas is
106
+ * constructed and wired before `state.load()` has returned, and the View
107
+ * menu draws the margins from this during that window. Every caller already
108
+ * tests it -- `render`, `zoomToFit`, `drawMargins` -- so the one place that
109
+ * has to know a document can be absent is this getter.
110
+ */
111
+ get page() {
112
+ if (!this.state.document) return null;
113
+ return FigureSchema.pageById(this.state.document, this.pageId);
114
+ }
115
+
116
+ // -- lifecycle -------------------------------------------------------
117
+
118
+ setup() {
119
+ this.surfaceEl.addEventListener("pointerdown", (event) => this.pointerDown(event));
120
+ // On the window rather than on the surface: a fast drag leaves the
121
+ // element behind, and a move handler bound to the panel stops firing
122
+ // the moment the pointer outruns it.
123
+ this._onMove = (event) => this.pointerMove(event);
124
+ this._onUp = (event) => this.pointerUp(event);
125
+ window.addEventListener("pointermove", this._onMove);
126
+ window.addEventListener("pointerup", this._onUp);
127
+ this._onKey = (event) => this.keyDown(event);
128
+ window.addEventListener("keydown", this._onKey);
129
+
130
+ this.surfaceEl.addEventListener("dragover", (event) => {
131
+ if (event.dataTransfer?.types.includes("text/x-plexora-panel")) event.preventDefault();
132
+ });
133
+ this.surfaceEl.addEventListener("drop", (event) => this.dropFromTray(event));
134
+ }
135
+
136
+ destroy() {
137
+ window.removeEventListener("pointermove", this._onMove);
138
+ window.removeEventListener("pointerup", this._onUp);
139
+ window.removeEventListener("keydown", this._onKey);
140
+ }
141
+
142
+ // -- rendering -------------------------------------------------------
143
+
144
+ setPage(pageId) {
145
+ this.pageId = pageId;
146
+ this.render();
147
+ }
148
+
149
+ setScale(scale) {
150
+ this.scale = Math.max(0.4, Math.min(20, scale));
151
+ this.render();
152
+ }
153
+
154
+ zoomToFit(viewportEl) {
155
+ const page = this.page;
156
+ if (!page || !viewportEl) return;
157
+ const margin = 48;
158
+ // clientWidth INCLUDES the padding, and the scroll surface's left
159
+ // padding is what holds the sheet clear of the floating rail and tray
160
+ // -- two hundred and eighty pixels of it with the tray open. Fitting to
161
+ // the padding box put the page that far off the right edge of the
162
+ // window. Guarded because the layout probes drive this class with stub
163
+ // elements that have no owner document.
164
+ const view = viewportEl.ownerDocument?.defaultView;
165
+ const box = view?.getComputedStyle ? view.getComputedStyle(viewportEl) : null;
166
+ const pad = (name) => (box ? parseFloat(box[name]) || 0 : 0);
167
+ const width = viewportEl.clientWidth - pad("paddingLeft") - pad("paddingRight");
168
+ const height = viewportEl.clientHeight - pad("paddingTop") - pad("paddingBottom");
169
+ this.setScale(Math.min(
170
+ (width - margin) / page.size_mm.w,
171
+ (height - margin) / page.size_mm.h));
172
+ }
173
+
174
+ render() {
175
+ const page = this.page;
176
+ if (!page) return;
177
+
178
+ this.pageEl.style.width = this.toPx(page.size_mm.w) + "px";
179
+ this.pageEl.style.height = this.toPx(page.size_mm.h) + "px";
180
+ // A transparent page is drawn as the conventional checkerboard, from a
181
+ // class rather than an inline colour: "transparent" as a CSS background
182
+ // would show the dark app surface behind it, which reads as a black
183
+ // page rather than as no page.
184
+ const clear = page.background === FigureCanvas.TRANSPARENT;
185
+ this.pageEl.classList.toggle("is-transparent", clear);
186
+ this.pageEl.style.background = clear ? "" : page.background;
187
+
188
+ const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId);
189
+ const labelStyle = this.state.document.settings.label_style;
190
+ const annotations = Object.values(this.state.document.annotations)
191
+ .filter((annotation) => annotation.page_id === this.pageId);
192
+
193
+ this.surfaceEl.innerHTML =
194
+ panels.map((panel, index) => this.panelMarkup(panel, index, labelStyle)).join("")
195
+ + annotations.map((annotation) => this.annotationMarkup(annotation)).join("");
196
+ this.clearGuides();
197
+ }
198
+
199
+ panelMarkup(panel, index, labelStyle) {
200
+ const place = panel.placement;
201
+ const selected = this.selection.has(panel.panel_id);
202
+ const label = panel.label.auto ? FigureSchema.labelFor(index, labelStyle) : panel.label.text;
203
+ const source = this.state.source(panel.source_id);
204
+ const status = this.state.sourceStatus[panel.source_id]?.status || "ok";
205
+
206
+ return `<div class="fb-panel${selected ? " is-selected" : ""}"
207
+ data-panel-id="${FigureSchema.escapeHtml(panel.panel_id)}"
208
+ style="left:${this.toPx(place.x_mm)}px;top:${this.toPx(place.y_mm)}px;
209
+ width:${this.toPx(place.w_mm)}px;height:${this.toPx(place.h_mm)}px;
210
+ z-index:${place.z}">
211
+ <img class="fb-panel-image" draggable="false"
212
+ src="${this.panelImageUrl(panel, source)}"
213
+ alt="" onerror="this.classList.add('fb-panel-image-missing')">
214
+ ${this.legendMarkup(panel)}
215
+ ${this.scaleBarMarkup(panel, source, place)}
216
+ ${panel.label.visible && label
217
+ ? `<span class="fb-panel-label">${FigureSchema.escapeHtml(label)}</span>` : ""}
218
+ ${panel.title ? `<span class="fb-panel-title">${FigureSchema.escapeHtml(panel.title)}</span>` : ""}
219
+ ${status !== "ok"
220
+ ? `<span class="fb-panel-badge fb-panel-badge-${status}"
221
+ title="This panel's source has ${status === "missing" ? "gone" : "changed"}">
222
+ <span class="fas fa-triangle-exclamation"></span></span>` : ""}
223
+ ${selected ? this.handlesMarkup() : ""}
224
+ </div>`;
225
+ }
226
+
227
+ /**
228
+ * A scale bar, or nothing at all.
229
+ *
230
+ * Nothing, specifically, when the source has no physical calibration --
231
+ * never a bar drawn from an assumed pixel size, which is wrong and looks
232
+ * exactly like one that is right.
233
+ */
234
+ scaleBarMarkup(panel, source, place) {
235
+ if (!panel.scalebar.visible) return "";
236
+ const span = FigureSchema.physicalWidthUm(source, panel.scene.viewport);
237
+ if (!span) return "";
238
+ const length = panel.scalebar.target_um || FigureSchema.scaleBarLength(span);
239
+ const fraction = length / span;
240
+ if (!(fraction > 0) || fraction > 1) return "";
241
+ return `<span class="fb-panel-scalebar" style="width:${(fraction * 100).toFixed(2)}%">
242
+ <span class="fb-panel-scalebar-label">${FigureSchema.escapeHtml(
243
+ FigureSchema.formatMicrons(length))}</span>
244
+ </span>`;
245
+ }
246
+
247
+ /**
248
+ * Where a panel's picture comes from on screen.
249
+ *
250
+ * An imported asset is served straight from the figure's own directory:
251
+ * there is nothing to preview because the file IS the panel, and rendering
252
+ * a preview of it would be storing a worse copy of something already here.
253
+ * Everything else is the cached capture raster.
254
+ */
255
+ panelImageUrl(panel, source) {
256
+ if (source && source.kind === "imported_asset" && source.asset_id) {
257
+ return this.api.assetUrl(this.figureId, source.asset_id);
258
+ }
259
+ return this.api.previewUrl(this.figureId, panel.panel_id, panel.render_revision);
260
+ }
261
+
262
+ /**
263
+ * The panel's legend, drawn from what was recorded at capture time.
264
+ *
265
+ * Never from the live plugins. A legend regenerated from a palette that has
266
+ * since changed is a legend that disagrees with the panel above it -- and
267
+ * on a figure whose plugin is not even installed there would be nothing to
268
+ * regenerate it from. Each plugin computes its rows once, at capture, and
269
+ * they travel with the panel; see the capture bridge.
270
+ */
271
+ legendMarkup(panel) {
272
+ const rows = [];
273
+ if (panel.legend.channels) {
274
+ for (const channel of panel.scene.channels || []) {
275
+ const color = `rgb(${channel.color.r},${channel.color.g},${channel.color.b})`;
276
+ rows.push(this.legendRow(color, channel.fullname_at_capture || channel.key));
277
+ }
278
+ }
279
+ if (panel.legend.plugins) {
280
+ for (const contribution of Object.values(panel.scene.plugins || {})) {
281
+ for (const entry of contribution.legend || []) {
282
+ if (entry.kind === "continuous") {
283
+ rows.push(this.legendRamp(entry));
284
+ } else {
285
+ rows.push(this.legendRow(entry.color, entry.label));
286
+ }
287
+ }
288
+ }
289
+ }
290
+ if (!rows.length) return "";
291
+ return `<div class="fb-panel-legend">${rows.join("")}</div>`;
292
+ }
293
+
294
+ legendRow(color, label) {
295
+ return `<span class="fb-legend-row">
296
+ <span class="fb-legend-swatch" style="background:${FigureSchema.escapeHtml(color)}"></span>
297
+ <span>${FigureSchema.escapeHtml(label)}</span>
298
+ </span>`;
299
+ }
300
+
301
+ legendRamp(entry) {
302
+ const stops = (entry.ramp || []).map((color) => FigureSchema.escapeHtml(color)).join(",");
303
+ const [low, high] = entry.domain || [0, 1];
304
+ return `<span class="fb-legend-row">
305
+ <span class="fb-legend-ramp" style="background:linear-gradient(to right,${stops})"></span>
306
+ <span>${FigureSchema.escapeHtml(this.formatNumber(low))}&ndash;${FigureSchema.escapeHtml(this.formatNumber(high))}</span>
307
+ </span>`;
308
+ }
309
+
310
+ formatNumber(value) {
311
+ if (!Number.isFinite(value)) return "";
312
+ const magnitude = Math.abs(value);
313
+ if (magnitude >= 1000 || (magnitude > 0 && magnitude < 0.01)) return value.toExponential(1);
314
+ return String(Math.round(value * 100) / 100);
315
+ }
316
+
317
+ handlesMarkup(rotatable) {
318
+ const handles = ["nw", "ne", "se", "sw", "n", "e", "s", "w"].map((handle) =>
319
+ `<span class="fb-handle fb-handle-${handle}" data-handle="${handle}"></span>`).join("");
320
+ // The rotate handle stands OFF the top edge rather than sitting on it:
321
+ // on the outline it would land under the `n` handle, and at a caption's
322
+ // size those are the same few pixels.
323
+ return rotatable
324
+ ? handles + '<span class="fb-handle fb-handle-rotate" data-handle="rotate"></span>'
325
+ : handles;
326
+ }
327
+
328
+ /** Points per millimetre. Annotation stroke and font sizes are stored in
329
+ * points because that is what the PDF exporter draws in. */
330
+ static get PT_PER_MM() { return 2.8346; }
331
+
332
+ annotationMarkup(annotation) {
333
+ if (annotation.type === "line" || annotation.type === "arrow") {
334
+ return this.strokeMarkup(annotation);
335
+ }
336
+ const geometry = annotation.geometry;
337
+ const selected = this.selection.has(annotation.annotation_id) ? " is-selected" : "";
338
+ // A rotation turns the whole box about its own centre, AFTER the layout
339
+ // inside it is settled -- the same rule the PDF and raster writers use.
340
+ // CSS carries hit-testing through a transform, so `closest()` keeps
341
+ // working and only `resizedBox` has to know the angle.
342
+ const rotation = geometry.rotation
343
+ ? `transform:rotate(${geometry.rotation}deg);transform-origin:center;` : "";
344
+ const style = [
345
+ `left:${this.toPx(geometry.x_mm)}px`,
346
+ `top:${this.toPx(geometry.y_mm)}px`,
347
+ `width:${this.toPx(geometry.w_mm)}px`,
348
+ `height:${this.toPx(geometry.h_mm)}px`,
349
+ `z-index:${1000 + annotation.z}`,
350
+ `color:${annotation.style.color}`,
351
+ ].join(";");
352
+
353
+ if (annotation.type === "text") {
354
+ return `<div class="fb-annotation fb-annotation-text${selected}"
355
+ style="${style};${rotation}"
356
+ data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}"
357
+ >${this.textMarkup(annotation)}${
358
+ selected ? this.handlesMarkup(true) : ""}</div>`;
359
+ }
360
+ const fill = annotation.style.fill
361
+ ? `background:${annotation.style.fill};` : "";
362
+ return `<div class="fb-annotation fb-annotation-${annotation.type}${selected}"
363
+ style="${style};${rotation}${fill}border-color:${annotation.style.color};
364
+ border-width:${Math.max(1,
365
+ annotation.style.line_width_pt * this.scale / FigureCanvas.PT_PER_MM)}px"
366
+ data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}"
367
+ >${selected ? this.handlesMarkup(true) : ""}</div>`;
368
+ }
369
+
370
+ // -- text ------------------------------------------------------------
371
+
372
+ /**
373
+ * Every line of a text annotation, positioned. Pure arithmetic, no DOM.
374
+ *
375
+ * The JavaScript half of `compose._text_layout`, and
376
+ * `test_the_canvas_and_the_exporter_put_the_baseline_in_the_same_place`
377
+ * asserts the two agree to a nanometre. If they drift, the caption sits in
378
+ * one place on screen and another in the PDF -- and nothing says so until
379
+ * somebody opens the export.
380
+ *
381
+ * Where the lines BREAK is not decided here. Only a browser can measure a
382
+ * string, so the browser breaks them once (`FigureRichText.rewrap`) and the
383
+ * break is stored; this only stacks lines that already exist.
384
+ *
385
+ * Baselines come back in millimetres from the PAGE top, matching the
386
+ * exporter. `textMarkup` subtracts the box's own origin.
387
+ */
388
+ static textLayout(annotation) {
389
+ const style = annotation.style;
390
+ const geometry = annotation.geometry;
391
+ // Falls back to normalising the flat string, because the draft this
392
+ // draws from is OPTIMISTIC: `state.commit` applies the change locally
393
+ // and the server's normaliser -- which is what puts `rich` on an
394
+ // annotation -- has not run yet. Without this a text box drawn a moment
395
+ // ago renders as nothing at all until the page is reloaded.
396
+ const rich = annotation.rich && annotation.rich.lines
397
+ ? annotation.rich
398
+ : FigureRichText.normalize(annotation.text || "", null);
399
+ const lines = rich.lines;
400
+
401
+ const measured = lines.map((line) => {
402
+ const metrics = FigureRichText.lineMetrics(line.runs, style);
403
+ return { runs: line.runs, hard: line.hard !== false, ...metrics };
404
+ });
405
+ const block = measured.reduce((total, line) => total + line.lead, 0);
406
+
407
+ let top = geometry.y_mm;
408
+ if (style.valign === "middle") top += (geometry.h_mm - block) / 2;
409
+ else if (style.valign === "bottom") top += geometry.h_mm - block;
410
+
411
+ let cursor = top;
412
+ const out = measured.map((line, index) => {
413
+ // The line sits centred in its own box, half the leading above and
414
+ // half below, so a line mixing an 8 pt caption with a 6 pt
415
+ // superscript lands where a reader expects rather than riding the
416
+ // bottom of the box.
417
+ const halfLead = (line.lead - (line.ascent + line.descent)) / 2;
418
+ const next = measured[index + 1];
419
+ const entry = {
420
+ baseline_mm: cursor + halfLead + line.ascent,
421
+ lead_mm: line.lead,
422
+ runs: line.runs,
423
+ last_of_paragraph: !next || next.hard,
424
+ };
425
+ cursor += line.lead;
426
+ return entry;
427
+ });
428
+ return { block_h_mm: block, lines: out };
429
+ }
430
+
431
+ /**
432
+ * A text annotation's words, as SVG inside its box.
433
+ *
434
+ * SVG rather than a styled <div> because `<text y>` places the BASELINE
435
+ * exactly at y. A div's baseline comes from whatever font file the browser
436
+ * actually resolved -- Arial where the PDF holds Helvetica -- and those
437
+ * differ by about 0.09 em, which at 8 pt is a quarter of a millimetre of
438
+ * disagreement that no test could reach. Putting the baseline in the markup
439
+ * takes the browser's guess out of the loop entirely.
440
+ *
441
+ * Each run is its own <text> at a computed pen position, which is the same
442
+ * walk both exporters do, so a line of mixed styling cannot be spaced one
443
+ * way here and another way there. The precedent is `strokeMarkup`, which
444
+ * already draws lines and arrows as inline SVG.
445
+ */
446
+ textMarkup(annotation) {
447
+ const layout = FigureCanvas.textLayout(annotation);
448
+ const style = annotation.style;
449
+ const boxWidth = this.toPx(annotation.geometry.w_mm);
450
+ const parts = [];
451
+
452
+ for (const line of layout.lines) {
453
+ if (!line.runs.length) continue;
454
+ const runs = line.runs.map((run) => FigureRichText.resolveRun(run, style));
455
+ const widths = runs.map((run) => this.measureRun(run));
456
+ let lineWidth = widths.reduce((total, width) => total + width, 0);
457
+ // Trailing whitespace is excluded from the width so a wrapped line
458
+ // centres on its words. The space the break landed on is kept in
459
+ // the run -- that is what makes the break reversible -- and would
460
+ // otherwise pull every centred line slightly left.
461
+ const last = runs[runs.length - 1];
462
+ if (last.text !== last.text.replace(/\s+$/, "")) {
463
+ lineWidth -= widths[widths.length - 1]
464
+ - this.measureRun({ ...last, text: last.text.replace(/\s+$/, "") });
465
+ }
466
+
467
+ let pen = 0;
468
+ if (style.align === "center") pen = (boxWidth - lineWidth) / 2;
469
+ else if (style.align === "right") pen = boxWidth - lineWidth;
470
+ let extra = 0;
471
+ if (style.align === "justify" && !line.last_of_paragraph) {
472
+ const gaps = runs.reduce(
473
+ (total, run) => total + (run.text.split(" ").length - 1), 0);
474
+ if (gaps && boxWidth > lineWidth) extra = (boxWidth - lineWidth) / gaps;
475
+ }
476
+
477
+ const baseline = this.toPx(line.baseline_mm - annotation.geometry.y_mm);
478
+ for (let index = 0; index < runs.length; index += 1) {
479
+ const run = runs[index];
480
+ const spread = extra * (run.text.split(" ").length - 1);
481
+ const width = widths[index] + spread;
482
+ parts.push(this.runMarkup(run, pen, baseline, extra));
483
+ parts.push(this.decorationMarkup(run, pen, baseline, width));
484
+ pen += width;
485
+ }
486
+ }
487
+ // overflow visible: a box narrower than a word shows the word running
488
+ // past its edge rather than clipping it, which is the honest picture --
489
+ // the export will do the same.
490
+ return `<svg class="fb-text-svg" width="100%" height="100%" overflow="visible"
491
+ aria-hidden="true">${parts.join("")}</svg>`;
492
+ }
493
+
494
+ runMarkup(run, x, baseline, wordSpacing) {
495
+ const escape = FigureSchema.escapeHtml.bind(FigureSchema);
496
+ return `<text x="${x}" y="${baseline}" xml:space="preserve"
497
+ font-family='${escape(FigureRichText.cssStack(run.family))}'
498
+ font-size="${this.fontPx(run.size_pt)}"
499
+ ${run.bold ? 'font-weight="bold"' : ""}
500
+ ${run.italic ? 'font-style="italic"' : ""}
501
+ ${wordSpacing ? `word-spacing="${wordSpacing}"` : ""}
502
+ fill="${escape(run.color)}">${escape(run.text)}</text>`;
503
+ }
504
+
505
+ /**
506
+ * Underline and strike, as drawn rules.
507
+ *
508
+ * Not `text-decoration`: SVG takes the underline's position from the font
509
+ * file the browser resolved, and the PDF has no underline of its own at all
510
+ * -- it is always a drawn rule. Both sides use the same fraction of the em
511
+ * instead, so the mark lands in the same place in both.
512
+ */
513
+ decorationMarkup(run, x, baseline, width) {
514
+ const em = this.fontPx(run.size_pt);
515
+ const thickness = Math.max(1, em * FigureRichText.UNDERLINE_THICKNESS_EM);
516
+ const rules = [];
517
+ if (run.underline) rules.push(baseline + em * FigureRichText.UNDERLINE_OFFSET_EM);
518
+ if (run.strike) rules.push(baseline - em * FigureRichText.STRIKE_OFFSET_EM);
519
+ return rules.map((y) =>
520
+ `<rect x="${x}" y="${y}" width="${Math.max(0, width)}" height="${thickness}"
521
+ fill="${FigureSchema.escapeHtml(run.color)}"></rect>`).join("");
522
+ }
523
+
524
+ /** A run's type size in CSS pixels at the current zoom. */
525
+ fontPx(sizePt) {
526
+ return sizePt * FigureRichText.MM_PER_PT * this.scale;
527
+ }
528
+
529
+ /**
530
+ * How wide a run is, in CSS pixels.
531
+ *
532
+ * The one genuinely browser-bound piece of text layout, which is why
533
+ * `FigureRichText.rewrap` takes a measuring function rather than calling
534
+ * this: node has no text engine, and a probe that could not measure could
535
+ * not test the breaking algorithm at all.
536
+ *
537
+ * Returns 0 without a real canvas, which is the probe's case -- a fake
538
+ * measurement would be worse than none, because it would look like layout.
539
+ */
540
+ measureRun(run) {
541
+ if (!this._measureCtx) {
542
+ const canvas = document.createElement("canvas");
543
+ this._measureCtx = canvas.getContext ? canvas.getContext("2d") : null;
544
+ }
545
+ if (!this._measureCtx) return 0;
546
+ this._measureCtx.font = `${run.italic ? "italic " : ""}${run.bold ? "bold " : ""}`
547
+ + `${this.fontPx(run.size_pt)}px ${FigureRichText.cssStack(run.family)}`;
548
+ return this._measureCtx.measureText(run.text).width;
549
+ }
550
+
551
+ /** Re-break a text annotation to its own box width, in millimetres. */
552
+ rewrapAnnotation(annotation) {
553
+ return FigureRichText.rewrap(
554
+ annotation.rich, annotation.geometry.w_mm, annotation.style,
555
+ (text, run) => this.toMm(this.measureRun({ ...run, text: text })));
556
+ }
557
+
558
+ /**
559
+ * A line or an arrow, as real SVG.
560
+ *
561
+ * These used to render as empty bordered boxes -- the schema, the
562
+ * operations and the PDF exporter all understood them and the canvas drew a
563
+ * rectangle, so an arrow looked like a rectangle right up until it was
564
+ * exported.
565
+ *
566
+ * Two things make this fiddlier than the other four types:
567
+ *
568
+ * **The geometry is a vector, not a box.** `w_mm` and `h_mm` are legally
569
+ * negative -- they are the offset from the start point to the end point --
570
+ * and a div cannot have a negative width. The element is therefore the
571
+ * NORMALISED bounds, padded, with the line drawn inside it in local
572
+ * coordinates.
573
+ *
574
+ * **A diagonal's bounding box is mostly empty.** A long arrow across a page
575
+ * has a bounding box covering a quarter of it, and a box that took clicks
576
+ * would be a place where selecting panels underneath quietly stopped
577
+ * working. So the container takes no pointer events and a fat transparent
578
+ * line under the visible one does, which is the standard trick and the only
579
+ * one that puts the hit area on the ink.
580
+ */
581
+ strokeMarkup(annotation) {
582
+ const geometry = annotation.geometry;
583
+ const selected = this.selection.has(annotation.annotation_id);
584
+ const width = this.toPx(geometry.w_mm);
585
+ const height = this.toPx(geometry.h_mm);
586
+ const stroke = Math.max(1,
587
+ annotation.style.line_width_pt * this.scale / FigureCanvas.PT_PER_MM);
588
+ const head = this.arrowHeadPx(annotation.style.line_width_pt);
589
+ const pad = head + stroke + 2;
590
+
591
+ const x1 = pad + (width < 0 ? -width : 0);
592
+ const y1 = pad + (height < 0 ? -height : 0);
593
+ const x2 = pad + (width < 0 ? 0 : width);
594
+ const y2 = pad + (height < 0 ? 0 : height);
595
+
596
+ const parts = [
597
+ `<line class="fb-stroke-hit" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`,
598
+ `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
599
+ + ` stroke="${FigureSchema.escapeHtml(annotation.style.color)}"`
600
+ + ` stroke-width="${stroke}" stroke-linecap="round"/>`,
601
+ ];
602
+ if (annotation.type === "arrow") {
603
+ for (const [hx, hy] of FigureCanvas.arrowHeadPoints(x1, y1, x2, y2, head)) {
604
+ parts.push(`<line x1="${x2}" y1="${y2}" x2="${hx}" y2="${hy}"`
605
+ + ` stroke="${FigureSchema.escapeHtml(annotation.style.color)}"`
606
+ + ` stroke-width="${stroke}" stroke-linecap="round"/>`);
607
+ }
608
+ }
609
+
610
+ const boxWidth = Math.abs(width) + pad * 2;
611
+ const boxHeight = Math.abs(height) + pad * 2;
612
+ const style = [
613
+ `left:${this.toPx(geometry.x_mm + Math.min(0, geometry.w_mm)) - pad}px`,
614
+ `top:${this.toPx(geometry.y_mm + Math.min(0, geometry.h_mm)) - pad}px`,
615
+ `width:${boxWidth}px`,
616
+ `height:${boxHeight}px`,
617
+ `z-index:${1000 + annotation.z}`,
618
+ ].join(";");
619
+
620
+ return `<div class="fb-annotation fb-annotation-stroke${selected ? " is-selected" : ""}"
621
+ style="${style}"
622
+ data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}">
623
+ <svg width="${boxWidth}" height="${boxHeight}">${parts.join("")}</svg>
624
+ ${selected
625
+ ? `<span class="fb-handle fb-handle-point" data-handle="p1"
626
+ style="left:${x1 - 5}px;top:${y1 - 5}px"></span>
627
+ <span class="fb-handle fb-handle-point" data-handle="p2"
628
+ style="left:${x2 - 5}px;top:${y2 - 5}px"></span>` : ""}
629
+ </div>`;
630
+ }
631
+
632
+ /**
633
+ * The arrowhead's length, in screen pixels.
634
+ *
635
+ * Deliberately the same rule as `export._arrow_head`, which uses
636
+ * `max(3, line_width * 4)` POINTS. An arrowhead sized by some other rule
637
+ * here would make every arrow look different in the PDF from the way it
638
+ * looked while it was being placed -- and the PDF is the deliverable.
639
+ */
640
+ arrowHeadPx(lineWidthPt) {
641
+ return Math.max(3, lineWidthPt * 4) * this.scale / FigureCanvas.PT_PER_MM;
642
+ }
643
+
644
+ /** The two barb endpoints, spread 160 degrees from the shaft -- the angle
645
+ * `export._arrow_head` uses. Pure, so the parity can be checked. */
646
+ static arrowHeadPoints(x1, y1, x2, y2, size) {
647
+ const angle = Math.atan2(y2 - y1, x2 - x1);
648
+ return [-1, 1].map((direction) => {
649
+ const spread = angle + direction * (160 * Math.PI / 180);
650
+ return [x2 + size * Math.cos(spread), y2 + size * Math.sin(spread)];
651
+ });
652
+ }
653
+
654
+ // -- selection -------------------------------------------------------
655
+
656
+ /**
657
+ * Set or extend the selection.
658
+ *
659
+ * Every id is expanded to its whole visual group on the way in, so a group
660
+ * cannot be half-selected from anywhere -- not from a click, not from a
661
+ * marquee, not from a context menu. Half a group would drag apart under
662
+ * the pointer, which is the one thing grouping exists to prevent.
663
+ */
664
+ select(ids, additive) {
665
+ if (!additive) this.selection.clear();
666
+ for (const id of this.expandToGroups(ids)) {
667
+ if (additive && this.selection.has(id)) this.selection.delete(id);
668
+ else this.selection.add(id);
669
+ }
670
+ this.render();
671
+ this.onSelectionChange(Array.from(this.selection));
672
+ }
673
+
674
+ /** The visual group holding this panel or annotation, or null. */
675
+ groupFor(id) {
676
+ const groups = this.state.document.groups || {};
677
+ for (const group of Object.values(groups)) {
678
+ if (group.member_ids.includes(id)) return group;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ expandToGroups(ids) {
684
+ const out = [];
685
+ const seen = new Set();
686
+ for (const id of ids) {
687
+ const members = this.groupFor(id)?.member_ids || [id];
688
+ for (const member of members) {
689
+ if (seen.has(member)) continue;
690
+ seen.add(member);
691
+ out.push(member);
692
+ }
693
+ }
694
+ return out;
695
+ }
696
+
697
+ selectedPanels() {
698
+ return Array.from(this.selection)
699
+ .map((id) => this.state.panel(id))
700
+ .filter(Boolean);
701
+ }
702
+
703
+ selectedAnnotations() {
704
+ return Array.from(this.selection)
705
+ .map((id) => this.state.document.annotations[id])
706
+ .filter(Boolean);
707
+ }
708
+
709
+ selectAllOnPage() {
710
+ const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId)
711
+ .map((panel) => panel.panel_id);
712
+ const annotations = Object.values(this.state.document.annotations)
713
+ .filter((annotation) => annotation.page_id === this.pageId)
714
+ .map((annotation) => annotation.annotation_id);
715
+ this.select(panels.concat(annotations), false);
716
+ }
717
+
718
+ // -- gestures --------------------------------------------------------
719
+
720
+ surfacePoint(event) {
721
+ const rect = this.surfaceEl.getBoundingClientRect();
722
+ return { x: this.toMm(event.clientX - rect.left), y: this.toMm(event.clientY - rect.top) };
723
+ }
724
+
725
+ pointerDown(event) {
726
+ if (event.button !== 0) return;
727
+
728
+ // A drawing tool takes the whole surface: while one is armed, a press
729
+ // starts a shape rather than selecting whatever is under it. Tools are
730
+ // one-shot, so this state lasts exactly one gesture.
731
+ if (this.tool && this.tool !== "select") {
732
+ event.preventDefault();
733
+ this.select([], false);
734
+ this.beginGesture("draw", event, {});
735
+ return;
736
+ }
737
+
738
+ const handle = event.target.closest?.(".fb-handle");
739
+ const panelEl = event.target.closest?.(".fb-panel");
740
+ const annotationEl = event.target.closest?.(".fb-annotation");
741
+
742
+ // The second press comes first, ahead of the handle, and ahead of the
743
+ // move: a one-line caption at 14 pt is about twenty pixels tall, so its
744
+ // handles cover most of it, and "wherever in the object you pressed
745
+ // twice" is the only rule that opens it every time.
746
+ const id = panelEl ? panelEl.dataset.panelId
747
+ : (annotationEl ? annotationEl.dataset.annotationId : null);
748
+ const opens = annotationEl?.classList.contains("fb-annotation-text")
749
+ ? () => this.onEditText(id)
750
+ : (panelEl ? () => this.onEditPanel(id) : null);
751
+ if (this.secondPress(event, id) && opens) {
752
+ event.preventDefault();
753
+ this._press = null;
754
+ opens();
755
+ return;
756
+ }
757
+
758
+ if (handle && (panelEl || annotationEl)) {
759
+ event.preventDefault();
760
+ const kind = handle.dataset.handle === "rotate" ? "rotate" : "resize";
761
+ this.beginGesture(kind, event, { handle: handle.dataset.handle });
762
+ return;
763
+ }
764
+ if (panelEl || annotationEl) {
765
+ if (!this.selection.has(id)) this.select([id], event.shiftKey);
766
+ else if (event.shiftKey) { this.select([id], true); return; }
767
+ event.preventDefault();
768
+ this.beginGesture("move", event, {});
769
+ return;
770
+ }
771
+ // Empty page: a marquee, or a click that clears the selection.
772
+ this.select([], false);
773
+ this.beginGesture("marquee", event, {});
774
+ }
775
+
776
+ /**
777
+ * Is this press the second half of a double-click on the same object?
778
+ *
779
+ * Asked here rather than answered by a `dblclick` listener, and that is the
780
+ * whole of the fix. Three separate things were breaking that listener, all
781
+ * of them still true of this file:
782
+ *
783
+ * * the first press SELECTS, selecting re-renders, and `render()` rewrites
784
+ * the surface -- so the two clicks have different targets and the event
785
+ * is retargeted to the surface, where `closest` finds nothing;
786
+ * * the recovery for that was `elementFromPoint`, which answers with the
787
+ * topmost element at the point -- and the floating bar the first click
788
+ * just opened is in the overlay ABOVE the page, so whenever it lands
789
+ * over the object the handler bailed;
790
+ * * `pointerdown` is default-prevented on every press that lands on an
791
+ * object (see below), which suppresses the compatibility mouse events a
792
+ * double-click is derived from.
793
+ *
794
+ * A press knows what it hit, before any of that: `event.target` at
795
+ * `pointerdown` is the element that was pressed, the surface has not been
796
+ * rewritten yet, and nothing has been drawn over it. What it does not know
797
+ * is that a press happened here a moment ago, which is the one thing kept
798
+ * here.
799
+ *
800
+ * The identity is the OBJECT, not the element -- the element the second
801
+ * press lands on is a different one from the first, freshly rendered, and
802
+ * comparing elements is exactly what does not work.
803
+ */
804
+ secondPress(event, id) {
805
+ const previous = this._press;
806
+ this._press = id
807
+ ? { id: id, at: event.timeStamp, x: event.clientX, y: event.clientY }
808
+ : null;
809
+ return Boolean(id && previous && previous.id === id
810
+ && event.timeStamp - previous.at < FigureCanvas.DOUBLE_PRESS_MS
811
+ && Math.abs(event.clientX - previous.x) <= FigureCanvas.DOUBLE_PRESS_PX
812
+ && Math.abs(event.clientY - previous.y) <= FigureCanvas.DOUBLE_PRESS_PX);
813
+ }
814
+
815
+ /** The platform's own double-click interval is not readable from a page, so
816
+ * this is the usual default. The distance is what keeps a drag that
817
+ * happened to end where it started from reading as a double-click. */
818
+ static get DOUBLE_PRESS_MS() { return 400; }
819
+ static get DOUBLE_PRESS_PX() { return 4; }
820
+
821
+ beginGesture(kind, event, extra) {
822
+ this.onGesture(true);
823
+ const origin = this.surfacePoint(event);
824
+ this.gesture = {
825
+ kind: kind,
826
+ origin: origin,
827
+ current: origin,
828
+ moved: false,
829
+ handle: extra.handle || null,
830
+ // The starting geometry of everything being moved, captured once:
831
+ // reading it back off the DOM each frame would compound rounding
832
+ // and make a long drag drift.
833
+ items: this.gestureItems(),
834
+ };
835
+ }
836
+
837
+ gestureItems() {
838
+ const items = [];
839
+ for (const id of this.selection) {
840
+ const panel = this.state.panel(id);
841
+ if (panel && panel.placement) {
842
+ items.push({ kind: "panel", id: id, start: { ...panel.placement } });
843
+ continue;
844
+ }
845
+ const annotation = this.state.document.annotations[id];
846
+ if (annotation) {
847
+ items.push({ kind: "annotation", id: id, start: { ...annotation.geometry } });
848
+ }
849
+ }
850
+ return items;
851
+ }
852
+
853
+ pointerMove(event) {
854
+ if (!this.gesture) return;
855
+ this.gesture.current = this.surfacePoint(event);
856
+ const dx = this.gesture.current.x - this.gesture.origin.x;
857
+ const dy = this.gesture.current.y - this.gesture.origin.y;
858
+ if (Math.abs(dx) > 0.2 || Math.abs(dy) > 0.2) this.gesture.moved = true;
859
+
860
+ if (this.gesture.kind === "move") this.previewMove(dx, dy, event.shiftKey);
861
+ else if (this.gesture.kind === "resize") this.previewResize(dx, dy, event.shiftKey);
862
+ else if (this.gesture.kind === "marquee") this.previewMarquee();
863
+ else if (this.gesture.kind === "draw") this.previewDraw(event.shiftKey);
864
+ else if (this.gesture.kind === "rotate") this.previewRotate(event.shiftKey);
865
+ }
866
+
867
+ pointerUp() {
868
+ const gesture = this.gesture;
869
+ this.gesture = null;
870
+ if (!gesture) return;
871
+ this.onGesture(false);
872
+ this.clearGuides();
873
+ // A press that turned into a drag cannot be the first half of a double
874
+ // click, however quickly the next one follows it.
875
+ if (gesture.moved) this._press = null;
876
+
877
+ if (gesture.kind === "draw") {
878
+ this.finishDraw(gesture);
879
+ return;
880
+ }
881
+ if (gesture.kind === "marquee") {
882
+ this.finishMarquee(gesture);
883
+ return;
884
+ }
885
+ if (!gesture.moved) {
886
+ this.render();
887
+ return;
888
+ }
889
+ this.commitGesture(gesture);
890
+ }
891
+
892
+ /**
893
+ * Write the provisional positions straight onto the elements.
894
+ *
895
+ * Inline styles rather than a re-render: a re-render per pointer move
896
+ * rebuilds every <img> in the page, which makes the browser re-decode the
897
+ * previews and turns a smooth drag into a slideshow.
898
+ */
899
+ previewMove(dx, dy, disableSnap) {
900
+ const snapped = disableSnap ? { dx, dy } : this.snapMove(dx, dy);
901
+ for (const item of this.gesture.items) {
902
+ this.previewBox(item, {
903
+ ...item.start,
904
+ x_mm: item.start.x_mm + snapped.dx,
905
+ y_mm: item.start.y_mm + snapped.dy,
906
+ });
907
+ }
908
+ this.gesture.delta = snapped;
909
+ }
910
+
911
+ /** Angles a rotation snaps to with Shift held, in degrees. */
912
+ static get ROTATE_STEP() { return 15; }
913
+
914
+ /**
915
+ * Turn the selection to follow the pointer.
916
+ *
917
+ * The angle is measured from the box's CENTRE to the pointer, not from the
918
+ * drag's start -- so grabbing the handle and swinging round puts the top of
919
+ * the box under the pointer, which is the thing every drawing tool does and
920
+ * the only reading that survives dragging past 180 degrees.
921
+ */
922
+ previewRotate(snap) {
923
+ for (const item of this.gesture.items) {
924
+ if (item.kind === "panel") continue;
925
+ const centre = { x: item.start.x_mm + item.start.w_mm / 2,
926
+ y: item.start.y_mm + item.start.h_mm / 2 };
927
+ const point = this.gesture.current;
928
+ // +90 because the handle stands ABOVE the box: with the pointer
929
+ // straight up from the centre the box is at rest, not at -90.
930
+ let degrees = Math.atan2(point.y - centre.y, point.x - centre.x)
931
+ * 180 / Math.PI + 90;
932
+ if (snap) {
933
+ degrees = Math.round(degrees / FigureCanvas.ROTATE_STEP)
934
+ * FigureCanvas.ROTATE_STEP;
935
+ }
936
+ degrees = Math.round(((degrees % 360) + 360) % 360 * 10) / 10;
937
+ item.rotation = degrees;
938
+ const element = this.elementFor(item);
939
+ if (element) {
940
+ element.style.transform = `rotate(${degrees}deg)`;
941
+ element.style.transformOrigin = "center";
942
+ }
943
+ }
944
+ }
945
+
946
+ previewResize(dx, dy, keepAspect) {
947
+ const handle = this.gesture.handle;
948
+ for (const item of this.gesture.items) {
949
+ this.previewBox(item, this.resizedBox(
950
+ item.start, handle, dx, dy, keepAspect, this.annotationFor(item)));
951
+ }
952
+ }
953
+
954
+ /** Whether this resize is the user taking a text box's height into their
955
+ * own hands -- any handle that changes the height, on a box that was
956
+ * following its contents. */
957
+ clearsAutofit(item, gesture) {
958
+ const annotation = this.annotationFor(item);
959
+ return Boolean(annotation) && annotation.type === "text"
960
+ && annotation.style.autofit
961
+ && /[ns]/.test(gesture.handle || "");
962
+ }
963
+
964
+ /** The annotation an in-flight gesture item refers to, or null. */
965
+ annotationFor(item) {
966
+ return item.kind === "panel"
967
+ ? null : (this.state.document.annotations[item.id] || null);
968
+ }
969
+
970
+ /**
971
+ * Show one item at a provisional box, and remember it.
972
+ *
973
+ * The box is stored on the gesture item as well as written to the DOM, so
974
+ * the commit reads a number rather than parsing a style back -- which
975
+ * matters for lines, whose element is not their geometry (see
976
+ * strokeMarkup).
977
+ *
978
+ * Ordinary items get inline styles rather than a re-render: re-rendering
979
+ * per pointer move rebuilds every <img> on the page and turns a smooth drag
980
+ * into a slideshow. A line has no <img> and cannot be expressed as four
981
+ * style properties, so it is the one thing redrawn each frame.
982
+ */
983
+ previewBox(item, box) {
984
+ item.box = box;
985
+ const element = this.elementFor(item);
986
+ if (!element) return;
987
+ if (element.classList.contains("fb-annotation-stroke")) {
988
+ const annotation = this.state.document.annotations[item.id];
989
+ if (!annotation) return;
990
+ element.outerHTML = this.strokeMarkup({ ...annotation, geometry: box });
991
+ return;
992
+ }
993
+ element.style.left = this.toPx(box.x_mm) + "px";
994
+ element.style.top = this.toPx(box.y_mm) + "px";
995
+ if (this.gesture.kind === "resize") {
996
+ element.style.width = this.toPx(box.w_mm) + "px";
997
+ element.style.height = this.toPx(box.h_mm) + "px";
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * The box a resize produces.
1003
+ *
1004
+ * Corner handles keep the aspect ratio by DEFAULT and free it with Shift,
1005
+ * which is the opposite of most drawing tools and the right way round here:
1006
+ * a panel's aspect ratio is the shape of the region it shows, and changing
1007
+ * it silently squashes the tissue. Edge handles are single-axis by
1008
+ * definition and ignore the modifier.
1009
+ *
1010
+ * `p1` and `p2` are a line's two ENDS, and they are a different kind of
1011
+ * handle: the geometry of a line is a start point and an offset, so w and h
1012
+ * are legally negative and the minimum-size clamp below must not apply --
1013
+ * a line is allowed to be a hair thick, and clamping it to 5mm would stop
1014
+ * anyone drawing a horizontal one.
1015
+ */
1016
+ resizedBox(start, handle, dx, dy, freeAspect, annotation) {
1017
+ let { x_mm: x, y_mm: y, w_mm: w, h_mm: h } = start;
1018
+ const isText = Boolean(annotation) && annotation.type === "text";
1019
+ const rotation = (annotation && annotation.geometry.rotation) || 0;
1020
+
1021
+ // A rotated box resizes along ITS OWN axes, so the pointer's movement is
1022
+ // turned back through the angle before any of the arithmetic below sees
1023
+ // it. Without this, dragging the corner of a box rotated 45 degrees
1024
+ // moves it diagonally instead of widening it.
1025
+ if (rotation) {
1026
+ const turned = FigureCanvas.turn(dx, dy, -rotation);
1027
+ dx = turned.x;
1028
+ dy = turned.y;
1029
+ }
1030
+
1031
+ if (handle === "p1") {
1032
+ return { ...start, x_mm: x + dx, y_mm: y + dy, w_mm: w - dx, h_mm: h - dy };
1033
+ }
1034
+ if (handle === "p2") {
1035
+ return { ...start, w_mm: w + dx, h_mm: h + dy };
1036
+ }
1037
+
1038
+ // Text inverts the modifier. The aspect lock exists because a panel's
1039
+ // shape is the shape of the region it shows and squashing it is a
1040
+ // scientific error -- a text box has no such invariant, and locking it
1041
+ // would mean widening a caption also made it taller, which `autofit`
1042
+ // then immediately undoes.
1043
+ const corner = handle.length === 2;
1044
+ if (corner && (isText ? freeAspect : !freeAspect)) {
1045
+ // Drive both axes from whichever the pointer moved further along,
1046
+ // so the shape follows the gesture rather than snapping between
1047
+ // two interpretations of it.
1048
+ const aspect = start.w_mm / start.h_mm;
1049
+ const signX = handle.includes("w") ? -1 : 1;
1050
+ const signY = handle.includes("n") ? -1 : 1;
1051
+ const byWidth = signX * dx;
1052
+ const byHeight = signY * dy * aspect;
1053
+ const grow = Math.abs(byWidth) >= Math.abs(byHeight) ? byWidth : byHeight;
1054
+ dx = signX * grow;
1055
+ dy = signY * (grow / aspect);
1056
+ }
1057
+
1058
+ if (handle.includes("w")) { x = start.x_mm + dx; w = start.w_mm - dx; }
1059
+ if (handle.includes("e")) { w = start.w_mm + dx; }
1060
+ if (handle.includes("n")) { y = start.y_mm + dy; h = start.h_mm - dy; }
1061
+ if (handle.includes("s")) { h = start.h_mm + dy; }
1062
+
1063
+ const smallest = isText ? FigureCanvas.MIN_TEXT_MM : FigureCanvas.MIN_SIZE_MM;
1064
+ if (w < smallest) { if (handle.includes("w")) x -= smallest - w; w = smallest; }
1065
+ if (h < smallest) { if (handle.includes("n")) y -= smallest - h; h = smallest; }
1066
+ if (!rotation) return { ...start, x_mm: x, y_mm: y, w_mm: w, h_mm: h };
1067
+
1068
+ // The box is drawn from its top-left and then turned about its own
1069
+ // CENTRE, so a resize that moves the centre also swings the corner the
1070
+ // user is not touching. Put the centre where the rotated geometry says
1071
+ // it belongs, and the anchored corner stays under the pointer's
1072
+ // opposite number instead of sliding away as the box grows.
1073
+ const shift = FigureCanvas.turn(
1074
+ (x - start.x_mm) + (w - start.w_mm) / 2,
1075
+ (y - start.y_mm) + (h - start.h_mm) / 2, rotation);
1076
+ return { ...start,
1077
+ x_mm: start.x_mm + (start.w_mm - w) / 2 + shift.x,
1078
+ y_mm: start.y_mm + (start.h_mm - h) / 2 + shift.y,
1079
+ w_mm: w, h_mm: h };
1080
+ }
1081
+
1082
+ /** A vector turned clockwise by `degrees`, in page coordinates (y down). */
1083
+ static turn(x, y, degrees) {
1084
+ const radians = degrees * Math.PI / 180;
1085
+ const cos = Math.cos(radians);
1086
+ const sin = Math.sin(radians);
1087
+ return { x: x * cos - y * sin, y: x * sin + y * cos };
1088
+ }
1089
+
1090
+ previewMarquee() {
1091
+ const { origin, current } = this.gesture;
1092
+ this.showMarquee({
1093
+ x: Math.min(origin.x, current.x), y: Math.min(origin.y, current.y),
1094
+ w: Math.abs(current.x - origin.x), h: Math.abs(current.y - origin.y),
1095
+ });
1096
+ }
1097
+
1098
+ finishMarquee(gesture) {
1099
+ this.showMarquee(null);
1100
+ if (!gesture.moved) return;
1101
+ const box = {
1102
+ x: Math.min(gesture.origin.x, gesture.current.x),
1103
+ y: Math.min(gesture.origin.y, gesture.current.y),
1104
+ w: Math.abs(gesture.current.x - gesture.origin.x),
1105
+ h: Math.abs(gesture.current.y - gesture.origin.y),
1106
+ };
1107
+ // Intersecting rather than fully-enclosed: on a page where panels butt
1108
+ // up against each other, "fully enclosed" means a marquee has to be
1109
+ // drawn outside the page to catch the edge ones.
1110
+ // Intersecting rather than fully-enclosed: on a page where panels butt
1111
+ // up against each other, "fully enclosed" means a marquee has to be
1112
+ // drawn outside the page to catch the edge ones.
1113
+ const hits = FigureSchema.panelsOnPage(this.state.document, this.pageId)
1114
+ .filter((panel) => {
1115
+ const place = panel.placement;
1116
+ return place.x_mm < box.x + box.w && place.x_mm + place.w_mm > box.x
1117
+ && place.y_mm < box.y + box.h && place.y_mm + place.h_mm > box.y;
1118
+ })
1119
+ .map((panel) => panel.panel_id);
1120
+ // Annotations are caught too. They were not, and a marquee that swept
1121
+ // over an arrow and left it behind made grouping an image with its
1122
+ // label impossible to do by dragging.
1123
+ const annotations = Object.values(this.state.document.annotations)
1124
+ .filter((annotation) => {
1125
+ if (annotation.page_id !== this.pageId) return false;
1126
+ const g = annotation.geometry;
1127
+ const left = g.x_mm + Math.min(0, g.w_mm);
1128
+ const top = g.y_mm + Math.min(0, g.h_mm);
1129
+ return left < box.x + box.w && left + Math.abs(g.w_mm) > box.x
1130
+ && top < box.y + box.h && top + Math.abs(g.h_mm) > box.y;
1131
+ })
1132
+ .map((annotation) => annotation.annotation_id);
1133
+ this.select(hits.concat(annotations), false);
1134
+ }
1135
+
1136
+ // -- drawing -----------------------------------------------------------
1137
+
1138
+ /**
1139
+ * Arm a drawing tool, or go back to selecting.
1140
+ *
1141
+ * One-shot: `finishDraw` puts it back to "select" the moment something has
1142
+ * been placed. A mode that persisted would be a canvas where the next click
1143
+ * on a panel drew a rectangle on top of it, and the only clue would be a
1144
+ * pressed button in the rail 200 pixels away.
1145
+ */
1146
+ setTool(name) {
1147
+ this.tool = name && name !== "select" ? name : null;
1148
+ this.surfaceEl.classList.toggle("is-drawing", Boolean(this.tool));
1149
+ }
1150
+
1151
+ /** Default size for a shape placed with a click rather than a drag, in mm. */
1152
+ static get DRAW_DEFAULT_MM() { return { w: 30, h: 18 }; }
1153
+
1154
+ previewDraw(constrain) {
1155
+ const type = this.tool;
1156
+ const box = this.drawBox(constrain, this.gesture, type);
1157
+ if (!this.guideEl) return;
1158
+ // Into the guides layer rather than the surface: the surface is what
1159
+ // render() replaces, and the provisional shape is not part of the
1160
+ // document until the pointer comes up.
1161
+ if (type === "line" || type === "arrow") {
1162
+ this.guideEl.innerHTML = this.strokeMarkup({
1163
+ annotation_id: "__draft", type: type, z: 999,
1164
+ geometry: { ...box, rotation: 0 },
1165
+ style: this.drawStyle(),
1166
+ });
1167
+ } else {
1168
+ this.guideEl.innerHTML = `<span class="fb-draft fb-draft-${type}"
1169
+ style="left:${this.toPx(box.x_mm)}px;top:${this.toPx(box.y_mm)}px;
1170
+ width:${this.toPx(box.w_mm)}px;height:${this.toPx(box.h_mm)}px"></span>`;
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * The geometry a draw gesture describes.
1176
+ *
1177
+ * Rectangles and ellipses are normalised -- dragging up and left gives a
1178
+ * box, not a negative one. Lines and arrows are NOT: their w/h is the
1179
+ * offset to the far end, and normalising it would point every arrow down
1180
+ * and to the right whichever way it was drawn.
1181
+ *
1182
+ * Both the gesture and the tool are ARGUMENTS rather than `this.gesture`
1183
+ * and `this.tool`, because the one caller that matters has already cleared
1184
+ * both: `pointerUp` nulls the gesture before it hands it on, and
1185
+ * `finishDraw` releases the tool before it asks for the geometry. Reading
1186
+ * the fields here threw on every shape drawn, and would have pointed every
1187
+ * arrow down-and-right if it had not.
1188
+ */
1189
+ drawBox(constrain, gesture, type) {
1190
+ const { origin, current } = gesture;
1191
+ let dx = current.x - origin.x;
1192
+ let dy = current.y - origin.y;
1193
+ if (constrain) {
1194
+ // Shift gives a square, or an axis-aligned line -- the two things
1195
+ // the modifier means in every drawing tool anyone has used.
1196
+ if (type === "line" || type === "arrow") {
1197
+ if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
1198
+ else dx = 0;
1199
+ } else {
1200
+ const size = Math.max(Math.abs(dx), Math.abs(dy));
1201
+ dx = Math.sign(dx || 1) * size;
1202
+ dy = Math.sign(dy || 1) * size;
1203
+ }
1204
+ }
1205
+ if (type === "line" || type === "arrow") {
1206
+ return { x_mm: origin.x, y_mm: origin.y, w_mm: dx, h_mm: dy };
1207
+ }
1208
+ return {
1209
+ x_mm: Math.min(origin.x, origin.x + dx),
1210
+ y_mm: Math.min(origin.y, origin.y + dy),
1211
+ w_mm: Math.abs(dx),
1212
+ h_mm: Math.abs(dy),
1213
+ };
1214
+ }
1215
+
1216
+ /**
1217
+ * The style a newly drawn annotation starts with.
1218
+ *
1219
+ * The FAMILY and the colour come from the document's own defaults, so a
1220
+ * figure set in Times does not place a Helvetica caption. The SIZE does
1221
+ * not, and that is the correction: this asked for `label_size_pt`, which is
1222
+ * the size of the letter "A" in the corner of a panel. A caption inherited
1223
+ * it and came out at 10 pt -- typeset for the inside of an image, while
1224
+ * sitting beside one. The document's `font_size_pt` would have been no
1225
+ * better: it is 8 pt, and it is the legend and scale-bar type, small for
1226
+ * the same reason.
1227
+ *
1228
+ * So a text box starts at a reading size of its own, and whoever wants
1229
+ * something else changes it in the sidebar -- which is where the number now
1230
+ * is, in a stepper, rather than nowhere.
1231
+ */
1232
+ drawStyle() {
1233
+ const style = this.state.document.settings.style;
1234
+ return {
1235
+ color: style.text_color || "#000000",
1236
+ fill: "",
1237
+ line_width_pt: 0.75,
1238
+ font_size_pt: FigureRichText.DEFAULT_SIZE_PT,
1239
+ font_family: FigureRichText.family(style.font_family),
1240
+ align: "left",
1241
+ valign: "top",
1242
+ autofit: true,
1243
+ };
1244
+ }
1245
+
1246
+ /**
1247
+ * Commit whatever was drawn.
1248
+ *
1249
+ * A click with no drag still places something, at a default size: a tool
1250
+ * that silently did nothing unless the pointer travelled far enough is a
1251
+ * tool people press twice and then give up on.
1252
+ */
1253
+ finishDraw(gesture) {
1254
+ const type = this.tool;
1255
+ this.setTool(null);
1256
+ this.onToolFinished();
1257
+ this.clearGuides();
1258
+ if (!type || !this.pageId) return;
1259
+
1260
+ let box = this.drawBox(false, gesture, type);
1261
+ if (!gesture.moved) {
1262
+ const size = FigureCanvas.DRAW_DEFAULT_MM;
1263
+ box = (type === "line" || type === "arrow")
1264
+ ? { x_mm: gesture.origin.x, y_mm: gesture.origin.y, w_mm: size.w, h_mm: 0 }
1265
+ : { x_mm: gesture.origin.x, y_mm: gesture.origin.y,
1266
+ w_mm: size.w,
1267
+ // One line at the default size, computed rather than the
1268
+ // 8 mm literal this used to be: that was two lines of 8 pt
1269
+ // type, and at 14 pt it is not quite one -- so the box a
1270
+ // click placed was shorter than the text it was about to
1271
+ // hold. `autofit` grows it as soon as there are words in
1272
+ // it; this only has to be right for the empty one.
1273
+ h_mm: type === "text"
1274
+ ? FigureRichText.DEFAULT_SIZE_PT * FigureRichText.MM_PER_PT
1275
+ * FigureRichText.LINE_HEIGHT
1276
+ : size.h };
1277
+ }
1278
+
1279
+ const annotation = {
1280
+ annotation_id: FigureSchema.newAnnotationId(),
1281
+ type: type,
1282
+ page_id: this.pageId,
1283
+ geometry: { ...box, rotation: 0 },
1284
+ text: "",
1285
+ // Written here rather than left for the server so that the local
1286
+ // draft is already in the shape the canvas draws from.
1287
+ ...(type === "text" ? { rich: FigureRichText.normalize("", null) } : {}),
1288
+ style: this.drawStyle(),
1289
+ z: this.nextAnnotationZ(),
1290
+ };
1291
+ this.state.commit(
1292
+ [{ op: "add_annotation", annotation: annotation }],
1293
+ (draft) => { draft.annotations[annotation.annotation_id] = annotation; });
1294
+ this.select([annotation.annotation_id], false);
1295
+ // A text box placed with nothing in it is invisible, so the editor opens
1296
+ // on it straight away -- placing text and typing it are one action.
1297
+ if (type === "text") this.onEditText(annotation.annotation_id);
1298
+ }
1299
+
1300
+ nextAnnotationZ() {
1301
+ return Object.values(this.state.document.annotations)
1302
+ .filter((annotation) => annotation.page_id === this.pageId)
1303
+ .reduce((top, annotation) => Math.max(top, annotation.z), 0) + 1;
1304
+ }
1305
+
1306
+ commitGesture(gesture) {
1307
+ const moves = [];
1308
+ const annotationOps = [];
1309
+ for (const item of gesture.items) {
1310
+ // The provisional box the preview computed, if there was one. Read
1311
+ // in preference to the DOM because a line's element is its
1312
+ // NORMALISED bounds plus padding -- measuring that back would turn
1313
+ // every arrow into a rectangle a few pixels bigger than itself.
1314
+ let box = item.box;
1315
+ if (!box) {
1316
+ const element = this.elementFor(item);
1317
+ if (!element) continue;
1318
+ box = {
1319
+ x_mm: this.toMm(parseFloat(element.style.left)),
1320
+ y_mm: this.toMm(parseFloat(element.style.top)),
1321
+ w_mm: this.toMm(parseFloat(element.style.width) || this.toPx(item.start.w_mm)),
1322
+ h_mm: this.toMm(parseFloat(element.style.height) || this.toPx(item.start.h_mm)),
1323
+ };
1324
+ }
1325
+ if (item.kind === "panel") {
1326
+ moves.push({ panel_id: item.id, placement: { ...item.start, ...box } });
1327
+ if (gesture.kind === "resize") {
1328
+ // A linked row shares a box. Only on a resize: sharing a
1329
+ // POSITION would mean dragging one panel dragged them all
1330
+ // onto each other, and the row could never be a row.
1331
+ moves.push(...this._linkedSizeMoves(this.state.panel(item.id), box));
1332
+ }
1333
+ } else if (gesture.kind === "resize" && this.clearsAutofit(item, gesture)) {
1334
+ // Dragging the top or bottom edge of a text box is the user
1335
+ // saying how tall it should be, so it stops following its
1336
+ // contents. The gesture IS the opt-out -- a checkbox they had
1337
+ // to find first would make the drag do nothing.
1338
+ annotationOps.push({
1339
+ op: "update_annotation", annotation_id: item.id,
1340
+ changes: { geometry: box, style: { autofit: false } },
1341
+ });
1342
+ } else if (gesture.kind === "rotate") {
1343
+ // Only the angle: a rotation must not also write back the box,
1344
+ // or a preview transform read off the DOM would be committed as
1345
+ // a resize.
1346
+ annotationOps.push({
1347
+ op: "update_annotation", annotation_id: item.id,
1348
+ changes: { geometry: { rotation: item.rotation || 0 } },
1349
+ });
1350
+ } else {
1351
+ annotationOps.push({
1352
+ op: "update_annotation", annotation_id: item.id, changes: { geometry: box },
1353
+ });
1354
+ }
1355
+ }
1356
+ const operations = moves.length ? [{ op: "move_panels", moves: moves }] : [];
1357
+ operations.push(...annotationOps);
1358
+ if (!operations.length) return;
1359
+
1360
+ // One commit for the whole gesture: dragging five selected panels is
1361
+ // one thing the user did and must be one thing they can undo.
1362
+ this.state.commit(operations, (draft) => {
1363
+ for (const move of moves) {
1364
+ draft.panels[move.panel_id].placement = move.placement;
1365
+ }
1366
+ for (const op of annotationOps) {
1367
+ Object.assign(draft.annotations[op.annotation_id].geometry, op.changes.geometry);
1368
+ }
1369
+ });
1370
+ }
1371
+
1372
+ elementFor(item) {
1373
+ const selector = item.kind === "panel"
1374
+ ? `.fb-panel[data-panel-id="${item.id}"]`
1375
+ : `.fb-annotation[data-annotation-id="${item.id}"]`;
1376
+ return this.surfaceEl.querySelector(selector);
1377
+ }
1378
+
1379
+ // -- snapping --------------------------------------------------------
1380
+
1381
+ /**
1382
+ * Nudge a move onto a nearby edge, centre or margin.
1383
+ *
1384
+ * The threshold is in screen pixels: a fixed millimetre threshold is
1385
+ * unusably sticky zoomed in and does nothing at all zoomed out.
1386
+ */
1387
+ snapMove(dx, dy) {
1388
+ const tolerance = this.toMm(FigureCanvas.SNAP_PIXELS);
1389
+ const targets = this.snapTargets();
1390
+ // Annotations both snap and are snapped to. They were neither, so a
1391
+ // caption could not be lined up with the panel above it by dragging --
1392
+ // which is the one alignment on a figure page that always matters.
1393
+ const moving = this.gesture.items;
1394
+ if (!moving.length) return { dx, dy };
1395
+
1396
+ let bestX = { distance: tolerance, delta: dx, line: null };
1397
+ let bestY = { distance: tolerance, delta: dy, line: null };
1398
+
1399
+ for (const item of moving) {
1400
+ const edgesX = [item.start.x_mm + dx,
1401
+ item.start.x_mm + dx + item.start.w_mm / 2,
1402
+ item.start.x_mm + dx + item.start.w_mm];
1403
+ const edgesY = [item.start.y_mm + dy,
1404
+ item.start.y_mm + dy + item.start.h_mm / 2,
1405
+ item.start.y_mm + dy + item.start.h_mm];
1406
+ for (const edge of edgesX) {
1407
+ for (const line of targets.x) {
1408
+ const distance = Math.abs(edge - line);
1409
+ if (distance < bestX.distance) {
1410
+ bestX = { distance: distance, delta: dx + (line - edge), line: line };
1411
+ }
1412
+ }
1413
+ }
1414
+ for (const edge of edgesY) {
1415
+ for (const line of targets.y) {
1416
+ const distance = Math.abs(edge - line);
1417
+ if (distance < bestY.distance) {
1418
+ bestY = { distance: distance, delta: dy + (line - edge), line: line };
1419
+ }
1420
+ }
1421
+ }
1422
+ }
1423
+ this.showGuides(bestX.line, bestY.line);
1424
+ return { dx: bestX.delta, dy: bestY.delta };
1425
+ }
1426
+
1427
+ /**
1428
+ * Every line a move may snap onto.
1429
+ *
1430
+ * The page's own lines and every other panel's three are always here --
1431
+ * that is what "smart guides" means, and turning it off in the View menu
1432
+ * empties this rather than changing the signature, because `snapMove` is
1433
+ * pinned by a probe and the point of it is the arithmetic, not the source
1434
+ * of the candidates.
1435
+ */
1436
+ snapTargets() {
1437
+ const page = this.page;
1438
+ const moving = new Set(this.gesture.items.map((item) => item.id));
1439
+ const x = [];
1440
+ const y = [];
1441
+
1442
+ if (this.snapping.guides) {
1443
+ x.push(0, page.size_mm.w / 2, page.size_mm.w,
1444
+ page.margins_mm.left, page.size_mm.w - page.margins_mm.right);
1445
+ y.push(0, page.size_mm.h / 2, page.size_mm.h,
1446
+ page.margins_mm.top, page.size_mm.h - page.margins_mm.bottom);
1447
+ for (const panel of FigureSchema.panelsOnPage(this.state.document, this.pageId)) {
1448
+ if (moving.has(panel.panel_id)) continue;
1449
+ const place = panel.placement;
1450
+ x.push(place.x_mm, place.x_mm + place.w_mm / 2, place.x_mm + place.w_mm);
1451
+ y.push(place.y_mm, place.y_mm + place.h_mm / 2, place.y_mm + place.h_mm);
1452
+ }
1453
+ for (const annotation of Object.values(this.state.document.annotations)) {
1454
+ if (annotation.page_id !== this.pageId) continue;
1455
+ if (moving.has(annotation.annotation_id)) continue;
1456
+ // Normalised, because a line's w/h are legally negative: they
1457
+ // are an offset from its start point, not a box.
1458
+ const g = annotation.geometry;
1459
+ const left = g.x_mm + Math.min(0, g.w_mm);
1460
+ const top = g.y_mm + Math.min(0, g.h_mm);
1461
+ const width = Math.abs(g.w_mm);
1462
+ const height = Math.abs(g.h_mm);
1463
+ x.push(left, left + width / 2, left + width);
1464
+ y.push(top, top + height / 2, top + height);
1465
+ }
1466
+ }
1467
+ if (this.snapping.grid && this.snapping.gridMm > 0) {
1468
+ const step = this.snapping.gridMm;
1469
+ for (let value = 0; value <= page.size_mm.w + 1e-6; value += step) x.push(value);
1470
+ for (let value = 0; value <= page.size_mm.h + 1e-6; value += step) y.push(value);
1471
+ }
1472
+ return { x: x, y: y };
1473
+ }
1474
+
1475
+ showGuides(lineX, lineY) {
1476
+ if (!this.guideEl) return;
1477
+ const parts = [];
1478
+ if (lineX !== null && lineX !== undefined) {
1479
+ parts.push(`<span class="fb-guide fb-guide-v" style="left:${this.toPx(lineX)}px"></span>`);
1480
+ }
1481
+ if (lineY !== null && lineY !== undefined) {
1482
+ parts.push(`<span class="fb-guide fb-guide-h" style="top:${this.toPx(lineY)}px"></span>`);
1483
+ }
1484
+ this.guideEl.innerHTML = parts.join("");
1485
+ }
1486
+
1487
+ showMarquee(box) {
1488
+ if (!this.guideEl) return;
1489
+ this.guideEl.innerHTML = box
1490
+ ? `<span class="fb-marquee" style="left:${this.toPx(box.x)}px;top:${this.toPx(box.y)}px;
1491
+ width:${this.toPx(box.w)}px;height:${this.toPx(box.h)}px"></span>`
1492
+ : "";
1493
+ }
1494
+
1495
+ clearGuides() {
1496
+ if (this.guideEl) this.guideEl.innerHTML = "";
1497
+ }
1498
+
1499
+ // -- keyboard --------------------------------------------------------
1500
+
1501
+ keyDown(event) {
1502
+ // A <dialog> traps focus, not keystrokes: with the delete confirmation
1503
+ // up and its Cancel button focused, pressing Delete again arrived here
1504
+ // and asked the same question a second time. The tag guard below does
1505
+ // not catch it, because a BUTTON is not one of the tags it names.
1506
+ if (FigureConfirm.modalOpen) return;
1507
+ // `isContentEditable` is checked too: the text editor is a DIV, not a
1508
+ // TEXTAREA, so the tag list alone would let every canvas shortcut fire
1509
+ // while somebody is typing a caption.
1510
+ const active = document.activeElement;
1511
+ const typing = active
1512
+ && (["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName)
1513
+ || active.isContentEditable);
1514
+ if (typing) return;
1515
+
1516
+ // The standard chords, bound here because this is what owns the
1517
+ // selection they act on. Undo and redo are the workspace's -- they are
1518
+ // the document's, not the page's.
1519
+ if (event.metaKey || event.ctrlKey) {
1520
+ // Z-order, keyed off `event.code` rather than `event.key`: with
1521
+ // Shift held, the bracket keys report "{" and "}" on a US layout
1522
+ // and something else again on most others, so the physical key is
1523
+ // the only stable name for them.
1524
+ const bracket = { BracketRight: "up", BracketLeft: "down" }[event.code];
1525
+ if (bracket && this.selection.size) {
1526
+ event.preventDefault();
1527
+ this.reorderZ(bracket === "up"
1528
+ ? (event.shiftKey ? "front" : "forward")
1529
+ : (event.shiftKey ? "back" : "backward"));
1530
+ return;
1531
+ }
1532
+ const chord = {
1533
+ c: () => this.copySelection(),
1534
+ v: () => this.paste(null),
1535
+ d: () => this.duplicateSelection(),
1536
+ a: () => this.selectAllOnPage(),
1537
+ g: () => (event.shiftKey ? this.ungroupSelection() : this.groupSelection()),
1538
+ }[event.key.toLowerCase()];
1539
+ if (!chord) return;
1540
+ // Cmd+A on an empty page is still "select everything", so the
1541
+ // empty-selection guard comes after the chords that do not need one.
1542
+ if (event.key.toLowerCase() !== "a" && event.key.toLowerCase() !== "v"
1543
+ && !this.selection.size) {
1544
+ return;
1545
+ }
1546
+ event.preventDefault();
1547
+ chord();
1548
+ return;
1549
+ }
1550
+ if (!this.selection.size) return;
1551
+
1552
+ if (event.key === "Delete" || event.key === "Backspace") {
1553
+ event.preventDefault();
1554
+ this.removeSelection();
1555
+ return;
1556
+ }
1557
+ const step = event.shiftKey ? FigureCanvas.NUDGE_COARSE_MM : FigureCanvas.NUDGE_MM;
1558
+ const deltas = {
1559
+ ArrowLeft: [-step, 0], ArrowRight: [step, 0],
1560
+ ArrowUp: [0, -step], ArrowDown: [0, step],
1561
+ }[event.key];
1562
+ if (!deltas) return;
1563
+ event.preventDefault();
1564
+ this.nudge(deltas[0], deltas[1]);
1565
+ }
1566
+
1567
+ /**
1568
+ * Move the selection by a step.
1569
+ *
1570
+ * Annotations move too. They did not, and the filter to panels made the
1571
+ * arrow keys silently inert on a caption -- the commonest thing on a page
1572
+ * to want a half-millimetre nudge on, and the one object that had no way to
1573
+ * get one.
1574
+ */
1575
+ nudge(dx, dy) {
1576
+ const moves = this.selectedPanels()
1577
+ .filter((panel) => panel.placement)
1578
+ .map((panel) => ({
1579
+ panel_id: panel.panel_id,
1580
+ placement: { ...panel.placement,
1581
+ x_mm: panel.placement.x_mm + dx,
1582
+ y_mm: panel.placement.y_mm + dy },
1583
+ }));
1584
+ const shifts = this.selectedAnnotations().map((annotation) => ({
1585
+ op: "update_annotation", annotation_id: annotation.annotation_id,
1586
+ changes: { geometry: { x_mm: annotation.geometry.x_mm + dx,
1587
+ y_mm: annotation.geometry.y_mm + dy } },
1588
+ }));
1589
+ if (!moves.length && !shifts.length) return;
1590
+ // One batch, so a nudge of an image and its caption is one undo step
1591
+ // rather than two.
1592
+ const ops = moves.length ? [{ op: "move_panels", moves: moves }] : [];
1593
+ this.state.commit(ops.concat(shifts), (draft) => {
1594
+ for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
1595
+ for (const shift of shifts) {
1596
+ Object.assign(draft.annotations[shift.annotation_id].geometry,
1597
+ shift.changes.geometry);
1598
+ }
1599
+ });
1600
+ }
1601
+
1602
+ /**
1603
+ * Delete what is selected.
1604
+ *
1605
+ * A panel goes back to the TRAY rather than being destroyed: the captured
1606
+ * scene may be the only record of a view somebody spent an hour finding,
1607
+ * and Delete on a layout is a statement about the layout. Removing it for
1608
+ * good is done from the tray, where the thing being destroyed is what is
1609
+ * under the pointer.
1610
+ */
1611
+ removeSelection() {
1612
+ const panels = this.selectedPanels().filter((panel) => panel.placement);
1613
+ const annotationIds = Array.from(this.selection)
1614
+ .filter((id) => this.state.document.annotations[id]);
1615
+
1616
+ const operations = [];
1617
+ if (panels.length) {
1618
+ operations.push({
1619
+ op: "move_panels",
1620
+ moves: panels.map((panel) => ({ panel_id: panel.panel_id, placement: null })),
1621
+ });
1622
+ }
1623
+ if (annotationIds.length) {
1624
+ operations.push({ op: "remove_annotations", annotation_ids: annotationIds });
1625
+ }
1626
+ if (!operations.length) return;
1627
+
1628
+ this.selection.clear();
1629
+ this.state.commit(operations, (draft) => {
1630
+ for (const panel of panels) draft.panels[panel.panel_id].placement = null;
1631
+ for (const id of annotationIds) delete draft.annotations[id];
1632
+ });
1633
+ this.onSelectionChange([]);
1634
+ }
1635
+ // -- duplicating, copying, grouping, stacking --------------------------
1636
+
1637
+ /**
1638
+ * Copy the selection, offset a little, in ONE commit.
1639
+ *
1640
+ * The scene is deep-copied and the render revision is carried across
1641
+ * unchanged, which is what makes the copy show a picture immediately: its
1642
+ * preview is the original's bytes, uploaded under the new panel's id. A
1643
+ * copy whose preview had to be re-rendered would arrive as an empty frame
1644
+ * and stay that way until the user reopened it in the viewer.
1645
+ */
1646
+ duplicateSelection() {
1647
+ const made = this.copiesOf(this.selectedPanels(), this.selectedAnnotations(),
1648
+ FigureCanvas.PASTE_OFFSET_MM, FigureCanvas.PASTE_OFFSET_MM);
1649
+ this.commitCopies(made);
1650
+ }
1651
+
1652
+ copySelection() {
1653
+ FigureClipboard.put(this.selectedPanels(), this.selectedAnnotations());
1654
+ }
1655
+
1656
+ /** Paste at a point, or offset from where the originals were. */
1657
+ paste(point) {
1658
+ const held = FigureClipboard.take();
1659
+ if (!held.panels.length && !held.annotations.length) return;
1660
+
1661
+ let dx = FigureCanvas.PASTE_OFFSET_MM;
1662
+ let dy = FigureCanvas.PASTE_OFFSET_MM;
1663
+ if (point) {
1664
+ const boxes = held.panels.map((panel) => panel.placement)
1665
+ .concat(held.annotations.map((annotation) => annotation.geometry))
1666
+ .filter(Boolean);
1667
+ if (boxes.length) {
1668
+ dx = point.x - Math.min(...boxes.map((box) => box.x_mm));
1669
+ dy = point.y - Math.min(...boxes.map((box) => box.y_mm));
1670
+ }
1671
+ }
1672
+ this.commitCopies(this.copiesOf(held.panels, held.annotations, dx, dy));
1673
+ }
1674
+
1675
+ /**
1676
+ * New panels and annotations, offset, ready to commit.
1677
+ *
1678
+ * `pageId` is where the copies land, defaulting to the page being looked
1679
+ * at; page duplication passes the new page. A panel with no placement (one
1680
+ * sitting in the tray) keeps none -- duplicating a tray panel gives another
1681
+ * tray panel, not one that jumps onto whatever page happened to be open.
1682
+ */
1683
+ copiesOf(panels, annotations, dx, dy, pageId) {
1684
+ const page = pageId || this.pageId;
1685
+ const madePanels = panels.map((panel) => {
1686
+ const copy = JSON.parse(JSON.stringify(panel));
1687
+ copy.panel_id = FigureSchema.newPanelId();
1688
+ // Never a member of the original's link group: a duplicate is a new
1689
+ // panel that happens to look the same, and joining the row would
1690
+ // make resizing the original resize it too.
1691
+ copy.link_group = null;
1692
+ copy.derived_from = { panel_id: panel.panel_id, operation: "duplicate", layer: "" };
1693
+ if (copy.placement) {
1694
+ copy.placement = { ...copy.placement, page_id: page,
1695
+ x_mm: copy.placement.x_mm + dx,
1696
+ y_mm: copy.placement.y_mm + dy };
1697
+ }
1698
+ return { copy: copy, from: panel };
1699
+ });
1700
+ const madeAnnotations = annotations.map((annotation) => {
1701
+ const copy = JSON.parse(JSON.stringify(annotation));
1702
+ copy.annotation_id = FigureSchema.newAnnotationId();
1703
+ copy.page_id = page;
1704
+ copy.geometry = { ...copy.geometry,
1705
+ x_mm: copy.geometry.x_mm + dx, y_mm: copy.geometry.y_mm + dy };
1706
+ return copy;
1707
+ });
1708
+ return { panels: madePanels, annotations: madeAnnotations };
1709
+ }
1710
+
1711
+ /**
1712
+ * Store a set of copies and give each one the original's preview.
1713
+ *
1714
+ * `options.select` is false when the copies are not on the page being
1715
+ * looked at -- duplicating a tray panel, or a whole page -- because
1716
+ * selecting something the user cannot see puts a context bar on screen
1717
+ * pointing at nothing. `options.operations` lets a caller put the copies
1718
+ * inside a larger batch, which is how page duplication stays one undo step.
1719
+ */
1720
+ commitCopies(made, options) {
1721
+ const flags = options || {};
1722
+ if (!made.panels.length && !made.annotations.length) return Promise.resolve(false);
1723
+ let z = this.nextZ();
1724
+ for (const entry of made.panels) {
1725
+ if (entry.copy.placement && flags.select !== false) entry.copy.placement.z = z++;
1726
+ }
1727
+ const operations = (flags.operations || []).slice();
1728
+ operations.push(...made.panels.map((entry) => ({ op: "add_panel", panel: entry.copy })));
1729
+ operations.push(...made.annotations.map((a) => ({ op: "add_annotation", annotation: a })));
1730
+
1731
+ const stored = this.state.commit(operations, (draft) => {
1732
+ if (typeof flags.mutate === "function") flags.mutate(draft);
1733
+ for (const entry of made.panels) draft.panels[entry.copy.panel_id] = entry.copy;
1734
+ for (const a of made.annotations) draft.annotations[a.annotation_id] = a;
1735
+ }).then((ok) => {
1736
+ if (ok) this.copyPreviews(made.panels);
1737
+ return ok;
1738
+ });
1739
+
1740
+ if (flags.select !== false) {
1741
+ this.select(made.panels.map((entry) => entry.copy.panel_id)
1742
+ .concat(made.annotations.map((a) => a.annotation_id)), false);
1743
+ }
1744
+ return stored;
1745
+ }
1746
+
1747
+ /**
1748
+ * Give each copy the original's preview bytes.
1749
+ *
1750
+ * After the document commit, not before: a preview uploaded for a panel the
1751
+ * server has not accepted yet is a file with no owner. Failures are silent
1752
+ * on purpose -- the copy is already real, and an empty frame is a smaller
1753
+ * problem than an error about a raster.
1754
+ */
1755
+ async copyPreviews(entries) {
1756
+ for (const entry of entries) {
1757
+ const source = this.state.source(entry.from.source_id);
1758
+ // An imported asset is drawn straight from the figure's own
1759
+ // directory, so the copy already has its picture.
1760
+ if (source && source.kind === "imported_asset") continue;
1761
+ try {
1762
+ const response = await fetch(this.api.previewUrl(
1763
+ this.figureId, entry.from.panel_id, entry.from.render_revision));
1764
+ if (!response.ok) continue;
1765
+ const blob = await response.blob();
1766
+ await this.api.putPreview(this.figureId, entry.copy.panel_id,
1767
+ entry.copy.render_revision, blob, {});
1768
+ } catch (error) {
1769
+ /* see the docstring */
1770
+ }
1771
+ }
1772
+ this.render();
1773
+ }
1774
+
1775
+ groupSelection() {
1776
+ const ids = Array.from(this.selection);
1777
+ if (ids.length < 2) return;
1778
+ // Already grouped members are dissolved into the new group rather than
1779
+ // refused: selecting a group plus one more thing and pressing Cmd+G
1780
+ // plainly means "all of these".
1781
+ const dissolve = new Set();
1782
+ for (const id of ids) {
1783
+ const group = this.groupFor(id);
1784
+ if (group) dissolve.add(group.group_id);
1785
+ }
1786
+ const groupId = FigureSchema.newGroupId();
1787
+ const group = { group_id: groupId, member_ids: ids };
1788
+ const operations = Array.from(dissolve)
1789
+ .map((id) => ({ op: "ungroup_items", group_id: id }));
1790
+ operations.push({ op: "group_items", group: group });
1791
+
1792
+ this.state.commit(operations, (draft) => {
1793
+ for (const id of dissolve) delete draft.groups[id];
1794
+ draft.groups[groupId] = group;
1795
+ });
1796
+ }
1797
+
1798
+ ungroupSelection() {
1799
+ const ids = new Set();
1800
+ for (const id of this.selection) {
1801
+ const group = this.groupFor(id);
1802
+ if (group) ids.add(group.group_id);
1803
+ }
1804
+ if (!ids.size) return;
1805
+ this.state.commit(
1806
+ Array.from(ids).map((id) => ({ op: "ungroup_items", group_id: id })),
1807
+ (draft) => { for (const id of ids) delete draft.groups[id]; });
1808
+ }
1809
+
1810
+ /**
1811
+ * Move the selection through the z-order.
1812
+ *
1813
+ * Four commands, not two. "To the front" and "one place forward" are
1814
+ * different intents, and a stack of overlapping panels is exactly where the
1815
+ * second is the only one that gets you where you meant to go -- with only
1816
+ * the two absolute commands, putting a panel between two others meant
1817
+ * fronting it and then fronting each of the ones that had to end up over it.
1818
+ *
1819
+ * Written as absolute z values rather than as +1/-1, because "bring to
1820
+ * front" past a panel already at the front has to be a no-op rather than a
1821
+ * number that climbs for ever.
1822
+ *
1823
+ * Panels and annotations are reordered in separate stacks. Annotations draw
1824
+ * at `1000 + z` and so are always above every panel; "bring to front" on a
1825
+ * caption means the front of the captions, and always did.
1826
+ */
1827
+ reorderZ(command) {
1828
+ const annotations = this.selectedAnnotations();
1829
+ if (annotations.length) this.reorderAnnotationZ(command, annotations);
1830
+ const selected = this.selectedPanels().filter((panel) => panel.placement);
1831
+ if (!selected.length) return;
1832
+
1833
+ const chosen = new Set(selected.map((panel) => panel.panel_id));
1834
+ // Sorted by z here, not taken in `panelsOnPage`'s order: that one sorts
1835
+ // into READING order for numbering (A B C / D E F), and renumbering z
1836
+ // from it would rewrite the stacking of every panel the user did not
1837
+ // touch. One press of "Bring to front" would reshuffle the whole page.
1838
+ const ordered = FigureCanvas.reordered(
1839
+ FigureSchema.panelsOnPage(this.state.document, this.pageId)
1840
+ .slice().sort((a, b) => a.placement.z - b.placement.z),
1841
+ chosen, command, (panel) => panel.panel_id);
1842
+ if (!ordered) return;
1843
+
1844
+ const moves = ordered.map((panel, index) => ({
1845
+ panel_id: panel.panel_id,
1846
+ placement: { ...panel.placement, z: index },
1847
+ }));
1848
+ this.state.commit([{ op: "move_panels", moves: moves }], (draft) => {
1849
+ for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
1850
+ });
1851
+ }
1852
+
1853
+ /**
1854
+ * Reorder annotations among themselves.
1855
+ *
1856
+ * They can be ordered against EACH OTHER at all only since the offset above
1857
+ * was separated from the stack below it -- before that, "Bring to front" on
1858
+ * a text box was a menu item that did nothing, in two different menus.
1859
+ */
1860
+ reorderAnnotationZ(command, selected) {
1861
+ const chosen = new Set(selected.map((annotation) => annotation.annotation_id));
1862
+ const ordered = FigureCanvas.reordered(
1863
+ Object.values(this.state.document.annotations)
1864
+ .filter((annotation) => annotation.page_id === this.pageId)
1865
+ .sort((a, b) => a.z - b.z),
1866
+ chosen, command, (annotation) => annotation.annotation_id);
1867
+ if (!ordered) return;
1868
+
1869
+ const ops = ordered.map((annotation, index) => ({
1870
+ op: "update_annotation", annotation_id: annotation.annotation_id,
1871
+ changes: { z: index },
1872
+ }));
1873
+ this.state.commit(ops, (draft) => {
1874
+ ops.forEach((op) => { draft.annotations[op.annotation_id].z = op.changes.z; });
1875
+ });
1876
+ }
1877
+
1878
+ /**
1879
+ * `ordered` with the chosen members moved as `command` asks, or null when
1880
+ * there is nothing for the command to do.
1881
+ *
1882
+ * `ordered` runs back to front. Pure and static, which is the point: this
1883
+ * is the only part of z-ordering that is arithmetic rather than DOM, and it
1884
+ * is the part that is easy to get subtly wrong.
1885
+ *
1886
+ * A multiple selection moves as a BLOCK. Stepping each member on its own
1887
+ * reverses a pair of adjacent objects on the second press -- the classic
1888
+ * bug in every "send backward" written as a loop over the selection -- and
1889
+ * it also lets a selection come apart across a gap it should have jumped.
1890
+ */
1891
+ static reordered(ordered, chosen, command, idOf) {
1892
+ const picked = ordered.filter((item) => chosen.has(idOf(item)));
1893
+ const rest = ordered.filter((item) => !chosen.has(idOf(item)));
1894
+ if (!picked.length || !rest.length) return null;
1895
+ if (command === "front") return rest.concat(picked);
1896
+ if (command === "back") return picked.concat(rest);
1897
+
1898
+ const forward = command === "forward";
1899
+ // The edge of the block on the side it is moving towards, gaps in the
1900
+ // selection included -- so a split selection jumps whatever sits
1901
+ // between its two halves rather than closing up around it.
1902
+ const edge = forward
1903
+ ? ordered.length - 1 - [...ordered].reverse()
1904
+ .findIndex((item) => chosen.has(idOf(item)))
1905
+ : ordered.findIndex((item) => chosen.has(idOf(item)));
1906
+ const neighbour = ordered[edge + (forward ? 1 : -1)];
1907
+ if (!neighbour) return null;
1908
+
1909
+ const out = rest.slice();
1910
+ out.splice(out.indexOf(neighbour) + (forward ? 1 : 0), 0, ...picked);
1911
+ return out;
1912
+ }
1913
+
1914
+ // -- layout commands -------------------------------------------------
1915
+
1916
+ /**
1917
+ * Align, distribute or equalise the selection.
1918
+ *
1919
+ * Everything compiles to ONE commit, so each command is one undo step --
1920
+ * the same rule the drag follows, for the same reason.
1921
+ *
1922
+ * ANNOTATIONS TOO. This read `selectedPanels()` and nothing else, so
1923
+ * selecting two captions and pressing Align left ran the whole arithmetic
1924
+ * over an empty list and returned without touching either of them -- a menu
1925
+ * with six live rows in it, none of which did anything. It is the same bug
1926
+ * `reorderZ` and `nudge` had, in the last place it was left.
1927
+ *
1928
+ * A caption's rectangle is its `geometry` and a panel's is its `placement`;
1929
+ * they hold the same four numbers under the same names, which is what makes
1930
+ * one list of boxes possible at all. What differs is the fifth key -- a
1931
+ * panel carries `z` and an annotation carries `rotation` -- so each box is
1932
+ * spread from its own object and handed back to it whole.
1933
+ *
1934
+ * Lines and arrows are left out, by `arrangeItems` rather than here. See
1935
+ * FigureSelection.describe: their `w_mm`/`h_mm` are a vector, and "same
1936
+ * width" on one would reverse its direction rather than resize it.
1937
+ */
1938
+ arrange(command) {
1939
+ const items = this.arrangeItems();
1940
+ if (items.length < 2) return;
1941
+ const boxes = items.map((item) => ({ ...item.box }));
1942
+
1943
+ const left = Math.min(...boxes.map((b) => b.x_mm));
1944
+ const right = Math.max(...boxes.map((b) => b.x_mm + b.w_mm));
1945
+ const top = Math.min(...boxes.map((b) => b.y_mm));
1946
+ const bottom = Math.max(...boxes.map((b) => b.y_mm + b.h_mm));
1947
+
1948
+ if (command === "left") boxes.forEach((b) => { b.x_mm = left; });
1949
+ else if (command === "right") boxes.forEach((b) => { b.x_mm = right - b.w_mm; });
1950
+ else if (command === "center") boxes.forEach((b) => { b.x_mm = (left + right - b.w_mm) / 2; });
1951
+ else if (command === "top") boxes.forEach((b) => { b.y_mm = top; });
1952
+ else if (command === "bottom") boxes.forEach((b) => { b.y_mm = bottom - b.h_mm; });
1953
+ else if (command === "middle") boxes.forEach((b) => { b.y_mm = (top + bottom - b.h_mm) / 2; });
1954
+ else if (command === "same_width") boxes.forEach((b) => { b.w_mm = boxes[0].w_mm; });
1955
+ else if (command === "same_height") boxes.forEach((b) => { b.h_mm = boxes[0].h_mm; });
1956
+ else if (command === "same_size") {
1957
+ boxes.forEach((b) => { b.w_mm = boxes[0].w_mm; b.h_mm = boxes[0].h_mm; });
1958
+ } else if (command === "distribute_h") this.distribute(boxes, "x_mm", "w_mm", left, right);
1959
+ else if (command === "distribute_v") this.distribute(boxes, "y_mm", "h_mm", top, bottom);
1960
+ else if (command === "row") this.pack(boxes, "row");
1961
+ else if (command === "column") this.pack(boxes, "column");
1962
+ else if (command === "grid") this.pack(boxes, "grid");
1963
+ else return;
1964
+
1965
+ this.commitBoxes(items, boxes);
1966
+ }
1967
+
1968
+ /**
1969
+ * The selection as one list of rectangles, whatever kind each object is.
1970
+ *
1971
+ * In selection order rather than in page order, because `same_width` and
1972
+ * friends take their answer from `boxes[0]` -- so "the first one I picked"
1973
+ * is the one the others match, which is the only reading of it a user can
1974
+ * predict.
1975
+ */
1976
+ arrangeItems() {
1977
+ const items = [];
1978
+ for (const id of this.selection) {
1979
+ const panel = this.state.panel(id);
1980
+ if (panel && panel.placement) {
1981
+ items.push({ kind: "panel", id: id, box: { ...panel.placement } });
1982
+ continue;
1983
+ }
1984
+ const annotation = this.state.document.annotations[id];
1985
+ if (annotation && !["line", "arrow"].includes(annotation.type)) {
1986
+ items.push({ kind: "annotation", id: id,
1987
+ box: { ...annotation.geometry } });
1988
+ }
1989
+ }
1990
+ return items;
1991
+ }
1992
+
1993
+ /** Equal GAPS, not equal centres. Equal centres leaves visibly uneven space
1994
+ * the moment the panels are not all the same size, which for a figure of
1995
+ * mixed crops is most of the time. */
1996
+ distribute(boxes, axis, size, low, high) {
1997
+ const ordered = boxes.slice().sort((a, b) => a[axis] - b[axis]);
1998
+ const total = ordered.reduce((sum, box) => sum + box[size], 0);
1999
+ const gap = (high - low - total) / (ordered.length - 1);
2000
+ let cursor = low;
2001
+ for (const box of ordered) {
2002
+ box[axis] = cursor;
2003
+ cursor += box[size] + gap;
2004
+ }
2005
+ }
2006
+
2007
+ /** Row, column or smart grid, inside the bounding box of the selection and
2008
+ * using the document's gutter. The result is ordinary geometry the user can
2009
+ * then drag -- an arrangement, not a layout mode that has to be maintained. */
2010
+ pack(boxes, shape) {
2011
+ const gutter = this.state.document.settings.style.gutter_mm;
2012
+ const left = Math.min(...boxes.map((b) => b.x_mm));
2013
+ const top = Math.min(...boxes.map((b) => b.y_mm));
2014
+ const columns = shape === "row" ? boxes.length
2015
+ : shape === "column" ? 1
2016
+ : Math.max(1, Math.round(Math.sqrt(boxes.length)));
2017
+
2018
+ const width = Math.max(...boxes.map((b) => b.w_mm));
2019
+ const height = Math.max(...boxes.map((b) => b.h_mm));
2020
+ boxes.forEach((box, index) => {
2021
+ box.x_mm = left + (index % columns) * (width + gutter);
2022
+ box.y_mm = top + Math.floor(index / columns) * (height + gutter);
2023
+ box.w_mm = width;
2024
+ box.h_mm = height;
2025
+ });
2026
+ }
2027
+
2028
+ /**
2029
+ * Put the arranged boxes back, panels and annotations together, as ONE
2030
+ * commit.
2031
+ *
2032
+ * One commit and not two: aligning a caption to a panel is a single thing
2033
+ * the user did, and two commits would be two presses of Ctrl+Z to undo it,
2034
+ * with the figure sitting in a half-aligned state in between. The operation
2035
+ * vocabulary already batches panels; the annotations ride along in the same
2036
+ * list, which is what `splitComposite` does with panels and links.
2037
+ */
2038
+ commitBoxes(items, boxes) {
2039
+ const moves = [];
2040
+ const operations = [];
2041
+ items.forEach((item, index) => {
2042
+ if (item.kind === "panel") {
2043
+ moves.push({ panel_id: item.id, placement: boxes[index] });
2044
+ return;
2045
+ }
2046
+ operations.push({ op: "update_annotation", annotation_id: item.id,
2047
+ changes: { geometry: boxes[index] } });
2048
+ });
2049
+ if (moves.length) operations.unshift({ op: "move_panels", moves: moves });
2050
+ if (!operations.length) return;
2051
+
2052
+ this.state.commit(operations, (draft) => {
2053
+ for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
2054
+ for (const operation of operations) {
2055
+ if (operation.op !== "update_annotation") continue;
2056
+ draft.annotations[operation.annotation_id].geometry =
2057
+ operation.changes.geometry;
2058
+ }
2059
+ });
2060
+ }
2061
+ // -- split composite -------------------------------------------------
2062
+
2063
+ /**
2064
+ * Turn one composite panel into a row of single-channel panels.
2065
+ *
2066
+ * The move this whole plugin is worth building for. Making the same figure
2067
+ * by hand is: find the field again, turn off every channel but one,
2068
+ * screenshot, repeat, then line five images up and hope they are the same
2069
+ * crop. Here the crop is not hoped for -- every derived panel carries the
2070
+ * SAME viewport, because it is copied rather than re-found.
2071
+ *
2072
+ * `mode` is "with_composite" (the original stays, first) or "channels_only".
2073
+ *
2074
+ * Everything arrives in ONE commit: N panels, their layout, and the link
2075
+ * between them. That is what makes a five-channel split one Ctrl+Z rather
2076
+ * than five, and it is why the operation vocabulary has batch forms at all.
2077
+ */
2078
+ splitComposite(panelId, mode) {
2079
+ const panel = this.state.panel(panelId);
2080
+ if (!panel || !panel.placement) return null;
2081
+ const channels = (panel.scene.channels || []).filter((c) => c.visible !== false);
2082
+ if (channels.length < 2) return null;
2083
+
2084
+ const gutter = this.state.document.settings.style.gutter_mm;
2085
+ const place = panel.placement;
2086
+ const page = this.page;
2087
+ const keepComposite = mode !== "channels_only";
2088
+
2089
+ const derived = channels.map((channel) => ({
2090
+ panel_id: FigureSchema.newPanelId(),
2091
+ source_id: panel.source_id,
2092
+ scene: {
2093
+ ...JSON.parse(JSON.stringify(panel.scene)),
2094
+ // One channel each. The windows, the colours and the region stay
2095
+ // the composite's -- a split that re-auto-levelled each channel
2096
+ // would produce a row nobody could compare.
2097
+ channels: [JSON.parse(JSON.stringify(channel))],
2098
+ captured_at: new Date().toISOString(),
2099
+ },
2100
+ placement: null,
2101
+ // Named from the channel, because that is what the panel now shows
2102
+ // and typing five titles is the tax this feature exists to remove.
2103
+ title: channel.fullname_at_capture || channel.key,
2104
+ label: { text: "", auto: true, visible: panel.label.visible },
2105
+ scalebar: { visible: false, target_um: panel.scalebar.target_um },
2106
+ legend: { channels: false, plugins: false },
2107
+ render_revision: 1,
2108
+ derived_from: { panel_id: panelId, operation: "split_channel",
2109
+ layer: channel.key },
2110
+ }));
2111
+
2112
+ const row = keepComposite
2113
+ ? [panelId, ...derived.map((entry) => entry.panel_id)]
2114
+ : derived.map((entry) => entry.panel_id);
2115
+ const placements = this._rowPlacements(row, place, gutter, page);
2116
+ const groupId = FigureSchema.newGroupId();
2117
+
2118
+ const operations = derived.map((entry) => ({ op: "add_panel", panel: entry }));
2119
+ operations.push({
2120
+ op: "move_panels",
2121
+ moves: row.map((id) => ({ panel_id: id, placement: placements[id] })),
2122
+ });
2123
+ if (!keepComposite) {
2124
+ // Removing the original is part of the same action, so it rides in
2125
+ // the same batch -- and therefore in the same undo step.
2126
+ operations.push({ op: "remove_panels", panel_ids: [panelId] });
2127
+ }
2128
+ operations.push({
2129
+ op: "link_panels",
2130
+ group: {
2131
+ group_id: groupId, panel_ids: row,
2132
+ // The crop and the box, not the channels: a split row shares a
2133
+ // field of view and emphatically does not share what is drawn
2134
+ // in it, which is the entire point of it.
2135
+ sync: ["viewport", "size"],
2136
+ },
2137
+ });
2138
+
2139
+ this.state.commit(operations, (draft) => {
2140
+ for (const entry of derived) draft.panels[entry.panel_id] = entry;
2141
+ for (const id of row) {
2142
+ if (draft.panels[id]) draft.panels[id].placement = placements[id];
2143
+ }
2144
+ if (!keepComposite) delete draft.panels[panelId];
2145
+ draft.link_groups[groupId] = {
2146
+ group_id: groupId, panel_ids: row.slice(), sync: ["viewport", "size"],
2147
+ };
2148
+ for (const id of row) {
2149
+ if (draft.panels[id]) draft.panels[id].link_group = groupId;
2150
+ }
2151
+ });
2152
+ this.select(derived.map((entry) => entry.panel_id), false);
2153
+ return groupId;
2154
+ }
2155
+
2156
+ /**
2157
+ * Lay a set of panels out in a row from where the original sat, wrapping
2158
+ * onto further rows when the page runs out.
2159
+ *
2160
+ * Wrapping rather than shrinking: a row of nine channels squeezed to an A4
2161
+ * width is nine panels too small to read, and the user can always drag them
2162
+ * afterwards. The result is ordinary geometry, not a layout mode that has
2163
+ * to be maintained.
2164
+ */
2165
+ _rowPlacements(ids, origin, gutter, page) {
2166
+ const available = page ? page.size_mm.w - origin.x_mm : Infinity;
2167
+ const perRow = Math.max(1, Math.floor((available + gutter) / (origin.w_mm + gutter)));
2168
+ const out = {};
2169
+ ids.forEach((id, index) => {
2170
+ out[id] = {
2171
+ ...origin,
2172
+ x_mm: origin.x_mm + (index % perRow) * (origin.w_mm + gutter),
2173
+ y_mm: origin.y_mm + Math.floor(index / perRow) * (origin.h_mm + gutter),
2174
+ z: origin.z + index,
2175
+ };
2176
+ });
2177
+ return out;
2178
+ }
2179
+
2180
+ /**
2181
+ * The extra moves a resize owes to everyone linked to this panel.
2182
+ *
2183
+ * Only on a RESIZE, never on a move. Sharing a size is what keeps a split
2184
+ * row comparable; sharing a POSITION would mean the row could never be a
2185
+ * row, because dragging one panel would drag them all onto each other.
2186
+ */
2187
+ _linkedSizeMoves(panel, box) {
2188
+ const groupId = panel.link_group;
2189
+ const group = groupId && this.state.document.link_groups[groupId];
2190
+ if (!group || !group.sync.includes("size")) return [];
2191
+ return group.panel_ids
2192
+ .filter((id) => id !== panel.panel_id)
2193
+ .map((id) => this.state.panel(id))
2194
+ .filter((other) => other && other.placement)
2195
+ .map((other) => ({
2196
+ panel_id: other.panel_id,
2197
+ placement: { ...other.placement, w_mm: box.w_mm, h_mm: box.h_mm },
2198
+ }));
2199
+ }
2200
+
2201
+
2202
+
2203
+ // -- the tray --------------------------------------------------------
2204
+
2205
+ /**
2206
+ * Panels dragged in from the tray -- one, or a whole multiple selection.
2207
+ *
2208
+ * Sized from the region each one shows rather than to a fixed box, so a
2209
+ * wide field arrives wide: landing every panel as a square and making the
2210
+ * user fix the aspect ratio afterwards is squashed tissue waiting to be
2211
+ * exported.
2212
+ *
2213
+ * Several at once are laid out from the drop point rather than dropped on
2214
+ * top of each other. A pile at one coordinate looks like ONE panel, and the
2215
+ * others are found only by dragging the top one off -- which is a way to
2216
+ * lose work that looks exactly like a bug.
2217
+ */
2218
+ dropFromTray(event) {
2219
+ const panelIds = this.readTrayPayload(event.dataTransfer);
2220
+ if (!panelIds.length) return;
2221
+ event.preventDefault();
2222
+ const page = this.page;
2223
+ if (!page) return;
2224
+
2225
+ const point = this.surfacePoint(event);
2226
+ const sizes = this.traySizes(panelIds, page);
2227
+ // The first panel lands centred under the pointer, which is where the
2228
+ // user aimed; the rest flow from there.
2229
+ const origin = {
2230
+ x_mm: Math.max(page.margins_mm.left, point.x - (sizes[0]?.w_mm || 0) / 2),
2231
+ y_mm: Math.max(page.margins_mm.top, point.y - (sizes[0]?.h_mm || 0) / 2),
2232
+ };
2233
+ this.placePanels(panelIds, sizes, origin);
2234
+ }
2235
+
2236
+ /** The dragged panel ids. A JSON array is what the tray writes now; the
2237
+ * bare id is still read, because a drag started before a reload would
2238
+ * otherwise arrive as an unexplained no-op. */
2239
+ readTrayPayload(transfer) {
2240
+ const raw = transfer?.getData("text/x-plexora-panel");
2241
+ if (!raw) return [];
2242
+ try {
2243
+ const parsed = JSON.parse(raw);
2244
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
2245
+ } catch (error) {
2246
+ return [raw];
2247
+ }
2248
+ }
2249
+
2250
+ /** The size each tray panel wants, from the shape of the region it shows. */
2251
+ traySizes(panelIds, page) {
2252
+ const width = Math.min(60, page.size_mm.w / 3);
2253
+ return panelIds.map((panelId) => {
2254
+ const panel = this.state.panel(panelId);
2255
+ const viewport = panel?.scene?.viewport;
2256
+ const aspect = (viewport && viewport.w) ? viewport.h / viewport.w : 1;
2257
+ return { w_mm: width, h_mm: width * (aspect || 1) };
2258
+ });
2259
+ }
2260
+
2261
+ /**
2262
+ * Put a batch of tray panels onto the page, in ONE commit.
2263
+ *
2264
+ * One commit because placing four panels is one thing the user did: four
2265
+ * would be four undo steps and four saves for a single drag.
2266
+ */
2267
+ placePanels(panelIds, sizes, origin) {
2268
+ const page = this.page;
2269
+ if (!page || !panelIds.length) return;
2270
+ const boxes = FigureCanvas.freePlacements(
2271
+ sizes, page, this.occupiedBoxes(),
2272
+ this.state.document.settings.style.gutter_mm, origin);
2273
+
2274
+ let z = this.nextZ();
2275
+ const moves = panelIds.map((panelId, index) => ({
2276
+ panel_id: panelId,
2277
+ placement: { page_id: this.pageId, ...boxes[index], z: z++ },
2278
+ })).filter((move) => this.state.panel(move.panel_id));
2279
+ if (!moves.length) return;
2280
+
2281
+ this.state.commit([{ op: "move_panels", moves: moves }], (draft) => {
2282
+ for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
2283
+ });
2284
+ this.select(moves.map((move) => move.panel_id), false);
2285
+ }
2286
+
2287
+ /** Everything already standing on this page, as plain boxes. */
2288
+ occupiedBoxes() {
2289
+ return FigureSchema.panelsOnPage(this.state.document, this.pageId)
2290
+ .map((panel) => ({ ...panel.placement }));
2291
+ }
2292
+
2293
+ static overlaps(a, b) {
2294
+ return a.x_mm < b.x_mm + b.w_mm && a.x_mm + a.w_mm > b.x_mm
2295
+ && a.y_mm < b.y_mm + b.h_mm && a.y_mm + a.h_mm > b.y_mm;
2296
+ }
2297
+
2298
+ /**
2299
+ * Where a batch of new boxes should go on a page that already has things
2300
+ * on it.
2301
+ *
2302
+ * A suggestion, not a template: the result is ordinary geometry the user
2303
+ * drags immediately afterwards. What it guarantees is only the thing that
2304
+ * cannot be recovered from by dragging -- that nothing lands exactly on top
2305
+ * of something else, which hides it.
2306
+ *
2307
+ * Left to right from the origin, wrapping down a row when the right margin
2308
+ * is reached and stepping past anything already in the way. When the page
2309
+ * genuinely has no room left the remainder cascades from the origin, offset
2310
+ * by a gutter each: still visible, still obviously new, and honest about
2311
+ * the page being full.
2312
+ *
2313
+ * Pure and static so the arithmetic can be checked without a browser.
2314
+ */
2315
+ static freePlacements(sizes, page, occupied, gutter, origin) {
2316
+ const left = origin ? origin.x_mm : page.margins_mm.left;
2317
+ const top = origin ? origin.y_mm : page.margins_mm.top;
2318
+ const right = page.size_mm.w - page.margins_mm.right;
2319
+ const floor = page.size_mm.h - page.margins_mm.bottom;
2320
+ const taken = (occupied || []).map((box) => ({ ...box }));
2321
+
2322
+ const out = [];
2323
+ let x = left;
2324
+ let y = top;
2325
+ let rowHeight = 0;
2326
+
2327
+ for (const size of sizes) {
2328
+ let box = null;
2329
+ // Bounded rather than "until it fits": a page crowded with narrow
2330
+ // panels can otherwise step forward a fraction of a millimetre at a
2331
+ // time, and a layout helper must not be able to hang the tab.
2332
+ for (let attempt = 0; attempt < 400 && !box; attempt += 1) {
2333
+ if (x + size.w_mm > right + 0.001 && x > left) {
2334
+ x = left;
2335
+ y += (rowHeight || size.h_mm) + gutter;
2336
+ rowHeight = 0;
2337
+ }
2338
+ if (y + size.h_mm > floor + 0.001 && y > top) break;
2339
+ const candidate = { x_mm: x, y_mm: y, w_mm: size.w_mm, h_mm: size.h_mm };
2340
+ const clash = taken.find((other) => FigureCanvas.overlaps(candidate, other));
2341
+ if (!clash) box = candidate;
2342
+ else x = clash.x_mm + clash.w_mm + gutter;
2343
+ }
2344
+ if (!box) {
2345
+ const step = gutter * (out.length + 1);
2346
+ box = { x_mm: left + step, y_mm: top + step, w_mm: size.w_mm, h_mm: size.h_mm };
2347
+ }
2348
+ out.push(box);
2349
+ taken.push(box);
2350
+ rowHeight = Math.max(rowHeight, box.h_mm);
2351
+ x = box.x_mm + box.w_mm + gutter;
2352
+ y = box.y_mm;
2353
+ }
2354
+ return out;
2355
+ }
2356
+
2357
+ nextZ() {
2358
+ const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId);
2359
+ return panels.reduce((top, panel) => Math.max(top, panel.placement.z), 0) + 1;
2360
+ }
2361
+
2362
+ }