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,503 @@
1
+ /**
2
+ * Typing into a text annotation where it sits.
3
+ *
4
+ * Replaces the <textarea> this used to be, which could only ever hold one style
5
+ * for the whole box. The editor is a contenteditable, and the two things that
6
+ * usually go wrong with one are handled head on:
7
+ *
8
+ * **The model is the state; the DOM is a view of it.** Formatting commands
9
+ * change `this.rich` and re-render, rather than asking `execCommand` to mutate
10
+ * the DOM and hoping to recognise what it produced. Browsers disagree about
11
+ * whether bold is <b>, <strong> or a style attribute, and a document whose
12
+ * shape depends on which browser typed into it cannot be normalised into
13
+ * anything canonical.
14
+ *
15
+ * **Paste is plain text only.** `workspace_body.html` already warns that
16
+ * contenteditable accepts pasted markup; reading `text/plain` and nothing else
17
+ * answers that outright, and a paste out of a word processor arrives as words
18
+ * rather than as forty spans of Calibri.
19
+ *
20
+ * It mounts in `#fb_overlay_layer` and must stay there:
21
+ * `FigureCanvas.render()` replaces the whole page surface on every change, and
22
+ * an editor mounted inside it would be destroyed by the autosave triggered by
23
+ * the last thing the user typed.
24
+ */
25
+ class FigureTextEditor {
26
+
27
+ constructor({ overlayEl, canvas, state, onCommit }) {
28
+ this.overlayEl = overlayEl;
29
+ this.canvas = canvas;
30
+ this.state = state;
31
+ this.onCommit = onCommit || (() => {});
32
+ this.el = null;
33
+ this.annotationId = null;
34
+ this.rich = null;
35
+ this.started = null;
36
+ }
37
+
38
+ get active() { return Boolean(this.el); }
39
+
40
+ get annotation() {
41
+ return this.annotationId
42
+ ? this.state.document.annotations[this.annotationId] : null;
43
+ }
44
+
45
+ // -- opening and closing -----------------------------------------------
46
+
47
+ open(annotationId) {
48
+ const annotation = this.state.document.annotations[annotationId];
49
+ if (!annotation || annotation.type !== "text" || !this.overlayEl) return;
50
+ this.close(false);
51
+
52
+ const target = this.canvas.surfaceEl.querySelector(
53
+ `[data-annotation-id="${annotationId}"]`);
54
+ if (!target) return;
55
+
56
+ this.annotationId = annotationId;
57
+ this.rich = FigureRichText.normalize(annotation.text || "", annotation.rich);
58
+ this.started = JSON.stringify(this.rich);
59
+
60
+ this.el = document.createElement("div");
61
+ this.el.className = "fb-text-editor";
62
+ this.el.contentEditable = "true";
63
+ this.el.spellcheck = false;
64
+ this.position(target);
65
+ this.render();
66
+ this.overlayEl.appendChild(this.el);
67
+
68
+ // The annotation itself is hidden while its editor is open, so the two
69
+ // are never on screen at once and half a pixel of disagreement between
70
+ // them does not read as a ghost.
71
+ target.style.visibility = "hidden";
72
+
73
+ this.el.addEventListener("keydown", (event) => this.keyDown(event));
74
+ this.el.addEventListener("paste", (event) => this.paste(event));
75
+ this.el.addEventListener("input", () => this.readBack());
76
+ this.el.addEventListener("blur", () => this.commit());
77
+ this.el.focus();
78
+ // Everything, highlighted, the moment the editor opens -- the same thing
79
+ // double-clicking a caption does in every other tool, and the thing that
80
+ // makes "double-click, type" replace the caption rather than append to
81
+ // it.
82
+ //
83
+ // Asserted twice. `focus()` and the click that opened the editor are
84
+ // both still settling, and a browser that has its own idea of what the
85
+ // double-click selected applies it after this handler returns; whichever
86
+ // range lands last is the one the user sees, so this makes it ours. The
87
+ // guard is the annotation id, so a quick second double-click on a
88
+ // different box cannot be overwritten by the first one's frame.
89
+ this.selectAll();
90
+ const opened = annotationId;
91
+ //: Set AFTER the call above, because `selectAll` goes through
92
+ //: `setOffsets` and would clear it. Any later `setOffsets` clears it
93
+ //: too, which is the point: a caller that placed the caret on purpose
94
+ //: -- Insert Symbol opening the editor to add one character at the end
95
+ //: -- must not be overruled a frame later by a select-all it did not
96
+ //: ask for, leaving the whole caption highlighted and one keystroke
97
+ //: from being replaced.
98
+ this._reassert = true;
99
+ window.requestAnimationFrame?.(() => {
100
+ if (this._reassert && this.el && this.annotationId === opened) {
101
+ this.selectAll();
102
+ }
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Put the editor exactly over the annotation.
108
+ *
109
+ * Measured from the rendered element rather than computed from millimetres,
110
+ * because the annotation may be rotated -- and a rotated box's bounding
111
+ * rectangle is the only honest place to put an upright editor over it.
112
+ */
113
+ position(target) {
114
+ const box = target.getBoundingClientRect();
115
+ const host = this.overlayEl.getBoundingClientRect();
116
+ const annotation = this.annotation;
117
+ this.el.style.left = (box.left - host.left) + "px";
118
+ this.el.style.top = (box.top - host.top) + "px";
119
+ this.el.style.width = Math.max(40, box.width) + "px";
120
+ this.el.style.minHeight = Math.max(16, box.height) + "px";
121
+ this.el.style.textAlign =
122
+ annotation.style.align === "justify" ? "justify" : annotation.style.align;
123
+ this.applyBoxType(annotation.style);
124
+ }
125
+
126
+ /**
127
+ * The BOX's type, set on the editor itself, as the inherited default.
128
+ *
129
+ * This is what makes the size you type at the size that gets drawn.
130
+ *
131
+ * Every run renders inside a <span> carrying its own resolved font, so the
132
+ * type was right for every character that was already there -- and wrong
133
+ * for every character typed anywhere a span did not already exist. An empty
134
+ * box renders as `<div class="fb-text-editor-line"><br></div>` with no span
135
+ * in it at all, so the first thing typed into a caption went straight into
136
+ * the line div and inherited the APP's type: the workspace's 13 px UI font,
137
+ * where the canvas was about to draw 14 pt. Place a text box, type, press
138
+ * Escape, and the words changed size in front of you. The same happened for
139
+ * a character typed at the start of a line, or into a blank line mid-block.
140
+ *
141
+ * `.fb-text-editor-line` used to hard-code `line-height: 1.2` for the same
142
+ * reason and with the same flaw: 1.2 is the DEFAULT leading, not this box's,
143
+ * so a caption set to 1.5 was typed at 1.2 and drawn at 1.5. It comes from
144
+ * the style now, which is where `FigureRichText.lineMetrics` reads it.
145
+ *
146
+ * The size is `FigureCanvas.fontPx` -- the same function the SVG on the
147
+ * canvas is drawn with, not a second conversion that could round differently.
148
+ */
149
+ /**
150
+ * Follow the annotation after a re-render.
151
+ *
152
+ * The box can change under an open editor -- the sidebar's leading and
153
+ * alignment are box properties, and a commit re-lays the annotation out --
154
+ * and the editor was placed once, at `open`, and never looked again. So
155
+ * setting line spacing to 1.5 while typing moved the caption and left the
156
+ * editor over where it used to be, at the leading it used to have.
157
+ *
158
+ * Position and type only; the DOM is not re-rendered, because rebuilding it
159
+ * would put the caret back at the start in the middle of a sentence.
160
+ */
161
+ reposition(target) {
162
+ if (!this.el || !target || !this.annotation) return;
163
+ this.position(target);
164
+ }
165
+
166
+ applyBoxType(style) {
167
+ this.el.style.fontFamily = FigureRichText.cssStack(style.font_family);
168
+ this.el.style.fontSize = this.canvas.fontPx(style.font_size_pt) + "px";
169
+ this.el.style.lineHeight = String(
170
+ style.line_height || FigureRichText.LINE_HEIGHT);
171
+ this.el.style.color = style.color;
172
+ }
173
+
174
+ close(restore) {
175
+ this.overlayEl?.querySelectorAll(".fb-text-editor")
176
+ .forEach((editor) => editor.remove());
177
+ if (restore !== false && this.annotationId) {
178
+ const target = this.canvas.surfaceEl.querySelector(
179
+ `[data-annotation-id="${this.annotationId}"]`);
180
+ if (target) target.style.visibility = "";
181
+ }
182
+ this.el = null;
183
+ this.annotationId = null;
184
+ this.rich = null;
185
+ this.started = null;
186
+ }
187
+
188
+ // -- rendering the model ------------------------------------------------
189
+
190
+ render() {
191
+ const annotation = this.annotation;
192
+ if (!this.el || !annotation) return;
193
+ this.el.innerHTML = this.rich.lines.map((line) => {
194
+ const runs = line.runs.map((run) => this.runMarkup(run, annotation.style)).join("");
195
+ // A <br> keeps an empty line selectable: a <div> with nothing in it
196
+ // collapses to zero height and the caret cannot be put in it.
197
+ return `<div class="fb-text-editor-line">${runs || "<br>"}</div>`;
198
+ }).join("");
199
+ }
200
+
201
+ runMarkup(run, style) {
202
+ const resolved = FigureRichText.resolveRun(run, style);
203
+ const css = [
204
+ `font-family:${FigureRichText.cssStack(resolved.family)}`,
205
+ `font-size:${this.canvas.fontPx(resolved.size_pt)}px`,
206
+ `color:${resolved.color}`,
207
+ resolved.bold ? "font-weight:bold" : "",
208
+ resolved.italic ? "font-style:italic" : "",
209
+ (resolved.underline || resolved.strike)
210
+ ? `text-decoration:${resolved.underline ? "underline " : ""}`
211
+ + `${resolved.strike ? "line-through" : ""}`
212
+ : "",
213
+ ].filter(Boolean).join(";");
214
+ return `<span ${FigureRichText.runAttributes(run)} style="${css}"
215
+ >${FigureSchema.escapeHtml(run.text)}</span>`;
216
+ }
217
+
218
+ /** Take whatever the browser did to the DOM back into the model. */
219
+ readBack() {
220
+ if (!this.el) return;
221
+ this.rich = FigureRichText.normalize(
222
+ "", FigureRichText.linesFromDom(this.el));
223
+ }
224
+
225
+ // -- keys ----------------------------------------------------------------
226
+
227
+ keyDown(event) {
228
+ // Every canvas and workspace shortcut is off while this has focus. The
229
+ // old textarea relied on both of them checking for a focused INPUT or
230
+ // TEXTAREA first; a contenteditable is a DIV, so neither would.
231
+ event.stopPropagation();
232
+
233
+ const accel = event.metaKey || event.ctrlKey;
234
+ if (event.key === "Escape") {
235
+ event.preventDefault();
236
+ this.cancel();
237
+ return;
238
+ }
239
+ if (event.key === "Enter" && accel) {
240
+ event.preventDefault();
241
+ this.el.blur();
242
+ return;
243
+ }
244
+ if (event.key === "Enter" && !event.shiftKey) {
245
+ // Handled in the model rather than left to the browser: engines
246
+ // disagree about whether Enter makes a <div>, a <p> or a <br>, and
247
+ // a line break is the one thing here that must be unambiguous.
248
+ event.preventDefault();
249
+ this.splitLine();
250
+ return;
251
+ }
252
+ if (accel && !event.shiftKey && !event.altKey) {
253
+ const mark = { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()];
254
+ if (mark) {
255
+ event.preventDefault();
256
+ this.toggle(mark);
257
+ }
258
+ }
259
+ }
260
+
261
+ paste(event) {
262
+ event.preventDefault();
263
+ const text = (event.clipboardData || window.clipboardData)?.getData("text/plain");
264
+ if (!text) return;
265
+ this.replaceSelection(FigureRichText.normalizeBreaks(text));
266
+ }
267
+
268
+ // -- editing the model ---------------------------------------------------
269
+
270
+ /**
271
+ * Turn a mark on or off over the selection.
272
+ *
273
+ * Off when every character in the range already has it, on otherwise --
274
+ * which is what makes a second press of Cmd+B undo the first even when the
275
+ * selection started out half bold.
276
+ */
277
+ toggle(mark) {
278
+ const range = this.offsets();
279
+ if (!range) return;
280
+ const format = FigureRichText.formatOfRange(
281
+ this.rich, range.start, range.end, this.annotation.style);
282
+ const value = format && format[mark] === true ? null : true;
283
+ this.applyFormat({ [mark]: value }, range);
284
+ }
285
+
286
+ /** Set one or more properties over the selection, or over everything when
287
+ * nothing is selected -- which is what the sidebar's controls do. */
288
+ applyFormat(patch, range) {
289
+ const at = range || this.offsets();
290
+ if (!at) return;
291
+ const whole = at.start === at.end;
292
+ const span = whole ? { start: 0, end: this.plainLength() } : at;
293
+ this.rich = FigureRichText.applyToRange(this.rich, span.start, span.end, patch);
294
+ this.render();
295
+ this.setOffsets(at);
296
+ }
297
+
298
+ splitLine() {
299
+ const at = this.offsets();
300
+ if (!at) return;
301
+ if (at.end > at.start) this.replaceSelection("");
302
+ const cut = this.offsets().start;
303
+ const lines = [];
304
+ let offset = 0;
305
+ for (let index = 0; index < this.rich.lines.length; index += 1) {
306
+ const line = this.rich.lines[index];
307
+ const length = line.runs.reduce((total, run) => total + run.text.length, 0);
308
+ if (offset <= cut && cut <= offset + length) {
309
+ const local = cut - offset;
310
+ lines.push({ hard: line.hard, runs: this.sliceRuns(line.runs, 0, local) });
311
+ lines.push({ hard: true, runs: this.sliceRuns(line.runs, local, length) });
312
+ } else {
313
+ lines.push(line);
314
+ }
315
+ offset += length + 1;
316
+ }
317
+ this.rich = FigureRichText.normalize("", { lines: lines });
318
+ this.render();
319
+ this.setOffsets({ start: cut + 1, end: cut + 1 });
320
+ }
321
+
322
+ sliceRuns(runs, from, to) {
323
+ const out = [];
324
+ let offset = 0;
325
+ for (const run of runs) {
326
+ const start = Math.max(from, offset);
327
+ const end = Math.min(to, offset + run.text.length);
328
+ if (end > start) {
329
+ out.push(FigureRichText.orderRun(
330
+ { ...run, text: run.text.slice(start - offset, end - offset) }));
331
+ }
332
+ offset += run.text.length;
333
+ }
334
+ return FigureRichText.coalesce(out);
335
+ }
336
+
337
+ /** Replace the selection with plain text, keeping the marks at its start. */
338
+ replaceSelection(text) {
339
+ const at = this.offsets();
340
+ if (!at) return;
341
+ const pieces = text.split("\n");
342
+ const marks = FigureRichText.runsInRange(this.rich, at.start, at.start)[0] || {};
343
+ const carried = FigureRichText.orderRun({ ...marks, text: "" });
344
+ delete carried.text;
345
+
346
+ const lines = [];
347
+ let offset = 0;
348
+ let inserted = at.start;
349
+ for (const line of this.rich.lines) {
350
+ const length = line.runs.reduce((total, run) => total + run.text.length, 0);
351
+ const from = offset;
352
+ const to = offset + length;
353
+ if (at.end < from || at.start > to) {
354
+ lines.push(line);
355
+ } else if (at.start >= from && at.start <= to) {
356
+ const head = this.sliceRuns(line.runs, 0, at.start - from);
357
+ const tail = at.end <= to
358
+ ? this.sliceRuns(line.runs, at.end - from, length) : [];
359
+ if (pieces.length === 1) {
360
+ lines.push({ hard: line.hard, runs: FigureRichText.coalesce(
361
+ head.concat(pieces[0] ? [{ ...carried, text: pieces[0] }] : [], tail)) });
362
+ inserted = at.start + pieces[0].length;
363
+ } else {
364
+ lines.push({ hard: line.hard, runs: FigureRichText.coalesce(
365
+ head.concat([{ ...carried, text: pieces[0] }])) });
366
+ for (const middle of pieces.slice(1, -1)) {
367
+ lines.push({ hard: true, runs: [{ ...carried, text: middle }] });
368
+ }
369
+ const last = pieces[pieces.length - 1];
370
+ lines.push({ hard: true, runs: FigureRichText.coalesce(
371
+ [{ ...carried, text: last }].concat(tail)) });
372
+ inserted = at.start + text.length;
373
+ }
374
+ }
375
+ offset = to + 1;
376
+ }
377
+ this.rich = FigureRichText.normalize("", { lines: lines });
378
+ this.render();
379
+ this.setOffsets({ start: inserted, end: inserted });
380
+ }
381
+
382
+ plainLength() {
383
+ return FigureRichText.plainText(this.rich).length;
384
+ }
385
+
386
+ // -- selection <-> offsets ----------------------------------------------
387
+
388
+ /** Every text node in the editor, with the plain-text offset it starts at.
389
+ *
390
+ * A line contributes its own length plus one for the newline that follows
391
+ * it, so these offsets are the same ones `applyToRange` works in. */
392
+ textNodes() {
393
+ const out = [];
394
+ let offset = 0;
395
+ const lines = Array.from(this.el.querySelectorAll(".fb-text-editor-line"));
396
+ lines.forEach((line, index) => {
397
+ if (index > 0) offset += 1;
398
+ const walk = (node) => {
399
+ if (node.nodeType === 3) {
400
+ out.push({ node: node, start: offset });
401
+ offset += node.nodeValue.length;
402
+ return;
403
+ }
404
+ Array.from(node.childNodes || []).forEach(walk);
405
+ };
406
+ walk(line);
407
+ out.push({ node: line, start: offset, end: true });
408
+ });
409
+ return out;
410
+ }
411
+
412
+ offsets() {
413
+ if (!this.el) return null;
414
+ const selection = window.getSelection?.();
415
+ if (!selection || !selection.rangeCount) return { start: 0, end: 0 };
416
+ const range = selection.getRangeAt(0);
417
+ if (!this.el.contains(range.startContainer)) return { start: 0, end: 0 };
418
+ const start = this.offsetOf(range.startContainer, range.startOffset);
419
+ const end = this.offsetOf(range.endContainer, range.endOffset);
420
+ return { start: Math.min(start, end), end: Math.max(start, end) };
421
+ }
422
+
423
+ offsetOf(node, within) {
424
+ const nodes = this.textNodes();
425
+ for (const entry of nodes) {
426
+ if (entry.node === node && !entry.end) return entry.start + within;
427
+ }
428
+ // A caret in an element rather than a text node -- an empty line, or the
429
+ // gap after the last run. Take the line's own start.
430
+ for (const entry of nodes) {
431
+ if (entry.node === node) return entry.start;
432
+ }
433
+ return 0;
434
+ }
435
+
436
+ setOffsets(at) {
437
+ if (!this.el || !window.getSelection) return;
438
+ // Somebody has said where the caret goes. See `open`.
439
+ this._reassert = false;
440
+ const place = (offset) => {
441
+ const nodes = this.textNodes().filter((entry) => !entry.end);
442
+ for (const entry of nodes) {
443
+ if (offset <= entry.start + entry.node.nodeValue.length) {
444
+ return { node: entry.node,
445
+ offset: Math.max(0, offset - entry.start) };
446
+ }
447
+ }
448
+ const last = nodes[nodes.length - 1];
449
+ return last
450
+ ? { node: last.node, offset: last.node.nodeValue.length }
451
+ : { node: this.el, offset: 0 };
452
+ };
453
+ const from = place(at.start);
454
+ const to = place(at.end);
455
+ const range = document.createRange();
456
+ range.setStart(from.node, from.offset);
457
+ range.setEnd(to.node, to.offset);
458
+ const selection = window.getSelection();
459
+ selection.removeAllRanges();
460
+ selection.addRange(range);
461
+ }
462
+
463
+ selectAll() {
464
+ this.setOffsets({ start: 0, end: this.plainLength() });
465
+ }
466
+
467
+ // -- committing ----------------------------------------------------------
468
+
469
+ cancel() {
470
+ this.close(true);
471
+ this.canvas.render();
472
+ }
473
+
474
+ commit() {
475
+ const annotationId = this.annotationId;
476
+ const annotation = this.annotation;
477
+ if (!annotationId || !annotation) return;
478
+ this.readBack();
479
+ // Re-broken to the box's own width before it is stored: the lines ARE
480
+ // the document, so the wrap has to be resolved by the one thing that
481
+ // can measure a string before anything else reads them.
482
+ const rich = this.canvas.rewrapAnnotation({ ...annotation, rich: this.rich });
483
+ const started = this.started;
484
+ this.close(true);
485
+
486
+ // A text box placed and then left empty is invisible and unselectable,
487
+ // so cancelling out of a fresh one removes it rather than leaving a
488
+ // ghost on the page.
489
+ if (!FigureRichText.plainText(rich).trim()) {
490
+ if (!annotation.text) {
491
+ this.state.commit(
492
+ [{ op: "remove_annotations", annotation_ids: [annotationId] }],
493
+ (draft) => { delete draft.annotations[annotationId]; });
494
+ return;
495
+ }
496
+ }
497
+ if (JSON.stringify(rich) === started) {
498
+ this.canvas.render();
499
+ return;
500
+ }
501
+ this.onCommit(annotationId, rich);
502
+ }
503
+ }