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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- plexora-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureContextBar - the controls for whatever is selected, beside it.
|
|
3
|
+
*
|
|
4
|
+
* Figure Builder used to carry a 280px properties column down the right-hand
|
|
5
|
+
* side of the page. It was permanent and it was empty most of the time: the
|
|
6
|
+
* controls in it act on a selection, and the usual state of a figure being
|
|
7
|
+
* arranged is that nothing is selected. So it has gone, and this took its
|
|
8
|
+
* place -- a small bar that appears above what it acts on and nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* ## Why it lives outside the canvas
|
|
11
|
+
*
|
|
12
|
+
* `FigureCanvas.render()` replaces the whole of #fb_page_surface on every
|
|
13
|
+
* document change, which is most of what happens here. Anything mounted inside
|
|
14
|
+
* it is destroyed mid-interaction: a popover open over a panel would vanish the
|
|
15
|
+
* moment the checkbox in it was ticked. So the bar is mounted in
|
|
16
|
+
* #fb_overlay_layer, a sibling the renderer never touches, and it is told where
|
|
17
|
+
* the selection is rather than living in it.
|
|
18
|
+
*
|
|
19
|
+
* ## Three levels, and only the first is visible
|
|
20
|
+
*
|
|
21
|
+
* Level 1 is the tool rail and the topbar (always). Level 2 is this bar, which
|
|
22
|
+
* exists only while something is selected. Level 3 is inside `More`, inside the
|
|
23
|
+
* popovers and on the right-click, which is where everything that is rarely
|
|
24
|
+
* wanted goes. The rule for deciding is not how useful an action is -- it is
|
|
25
|
+
* how often the answer to "do I want this right now?" is yes.
|
|
26
|
+
*/
|
|
27
|
+
class FigureContextBar {
|
|
28
|
+
|
|
29
|
+
/** Gap in CSS pixels between the selection and the bar. */
|
|
30
|
+
static get OFFSET() { return 10; }
|
|
31
|
+
|
|
32
|
+
/** How close to the top of the workspace the bar may sit before it flips
|
|
33
|
+
* underneath the selection instead. */
|
|
34
|
+
static get FLIP_MARGIN() { return 48; }
|
|
35
|
+
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.overlayEl = options.overlayEl;
|
|
38
|
+
this.canvas = options.canvas;
|
|
39
|
+
this.state = options.state;
|
|
40
|
+
this.handlers = options.handlers || {};
|
|
41
|
+
|
|
42
|
+
this.el = null;
|
|
43
|
+
this.popover = null;
|
|
44
|
+
//: The ids the bar is currently drawn for. Kept so a re-render that
|
|
45
|
+
//: changes nothing about the selection does not close an open popover.
|
|
46
|
+
this.ids = [];
|
|
47
|
+
this.suppressed = false;
|
|
48
|
+
//: How far the user has dragged the bar from where it would otherwise
|
|
49
|
+
//: sit. Not persisted -- see `dragStart`.
|
|
50
|
+
this.offset = { x: 0, y: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setup() {
|
|
54
|
+
if (!this.overlayEl) return;
|
|
55
|
+
this.el = document.createElement("div");
|
|
56
|
+
this.el.className = "fb-context-bar";
|
|
57
|
+
this.el.id = "fb_context_bar";
|
|
58
|
+
this.el.hidden = true;
|
|
59
|
+
this.overlayEl.appendChild(this.el);
|
|
60
|
+
this.el.addEventListener("click", (event) => this.clicked(event));
|
|
61
|
+
this.el.addEventListener("change", (event) => this.changed(event));
|
|
62
|
+
this.el.addEventListener("pointerdown", (event) => this.barPointerDown(event));
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// A click anywhere else closes an open popover. On the document rather
|
|
66
|
+
// than on the overlay: the click that dismisses it is usually on the
|
|
67
|
+
// canvas, which is not a descendant of this.
|
|
68
|
+
this._onDocDown = (event) => {
|
|
69
|
+
if (!this.popover) return;
|
|
70
|
+
if (this.popover.contains(event.target)) return;
|
|
71
|
+
if (this.el.contains(event.target)) return;
|
|
72
|
+
this.closePopover();
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener("pointerdown", this._onDocDown, true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
destroy() {
|
|
78
|
+
document.removeEventListener("pointerdown", this._onDocDown, true);
|
|
79
|
+
this.closePopover();
|
|
80
|
+
this.el?.remove();
|
|
81
|
+
this.el = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// -- visibility ------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Out of the way while a drag is in flight.
|
|
88
|
+
*
|
|
89
|
+
* A bar that followed the panel would be a control panel moving under the
|
|
90
|
+
* pointer, and one that stayed put would be pointing at where the panel
|
|
91
|
+
* used to be. Neither is worth having for the second a drag lasts.
|
|
92
|
+
*/
|
|
93
|
+
suppress(active) {
|
|
94
|
+
this.suppressed = Boolean(active);
|
|
95
|
+
if (this.el) this.el.hidden = this.suppressed || !this.ids.length;
|
|
96
|
+
if (this.suppressed) this.closePopover();
|
|
97
|
+
else this.position();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Redraw for this selection. Called on every selection change AND after
|
|
101
|
+
* every canvas render, because the elements it measures are new each time. */
|
|
102
|
+
update(ids) {
|
|
103
|
+
if (!this.el) return;
|
|
104
|
+
const same = ids.length === this.ids.length
|
|
105
|
+
&& ids.every((id, index) => id === this.ids[index]);
|
|
106
|
+
this.ids = ids.slice();
|
|
107
|
+
|
|
108
|
+
if (!ids.length) {
|
|
109
|
+
this.el.hidden = true;
|
|
110
|
+
this.closePopover();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!same) this.closePopover();
|
|
114
|
+
this.el.innerHTML = this.markup(ids);
|
|
115
|
+
this.el.hidden = this.suppressed;
|
|
116
|
+
this.position();
|
|
117
|
+
// The Transform popover shows the selection's width and height, and
|
|
118
|
+
// this runs after every canvas render -- so dragging a corner moves the
|
|
119
|
+
// numbers instead of leaving them describing where the box used to be.
|
|
120
|
+
this.refreshPopover();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Put the bar above the selection, or below it near the top of the page.
|
|
125
|
+
*
|
|
126
|
+
* Measured from the live elements rather than computed from millimetres:
|
|
127
|
+
* the bar has to sit beside what the user can SEE, and what they can see is
|
|
128
|
+
* the scrolled, zoomed rendering of those millimetres.
|
|
129
|
+
*/
|
|
130
|
+
position() {
|
|
131
|
+
if (!this.el || this.el.hidden || !this.ids.length) return;
|
|
132
|
+
const box = this.selectionBox();
|
|
133
|
+
if (!box) {
|
|
134
|
+
this.el.hidden = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const host = this.overlayEl.getBoundingClientRect();
|
|
138
|
+
const size = this.el.getBoundingClientRect();
|
|
139
|
+
|
|
140
|
+
// `this.offset` is whatever the user dragged the bar by, and it is
|
|
141
|
+
// added BEFORE the clamp -- so a bar pushed off the edge comes back to
|
|
142
|
+
// the edge rather than off the screen, and the drag stops where the
|
|
143
|
+
// window does.
|
|
144
|
+
let left = box.left + box.width / 2 - host.left - size.width / 2
|
|
145
|
+
+ this.offset.x;
|
|
146
|
+
left = Math.max(8, Math.min(left, host.width - size.width - 8));
|
|
147
|
+
|
|
148
|
+
let top = box.top - host.top - size.height - FigureContextBar.OFFSET;
|
|
149
|
+
const below = box.bottom - host.top + FigureContextBar.OFFSET;
|
|
150
|
+
if (top < 8) top = Math.min(below, host.height - size.height - 8);
|
|
151
|
+
top = Math.max(8, Math.min(top + this.offset.y, host.height - size.height - 8));
|
|
152
|
+
|
|
153
|
+
this.el.style.left = Math.round(left) + "px";
|
|
154
|
+
this.el.style.top = Math.round(top) + "px";
|
|
155
|
+
this.positionPopover();
|
|
156
|
+
}
|
|
157
|
+
/** The union of the selected elements, in client coordinates. */
|
|
158
|
+
selectionBox() {
|
|
159
|
+
let box = null;
|
|
160
|
+
for (const id of this.ids) {
|
|
161
|
+
const element = this.canvas.surfaceEl.querySelector(
|
|
162
|
+
`[data-panel-id="${id}"], [data-annotation-id="${id}"]`);
|
|
163
|
+
if (!element) continue;
|
|
164
|
+
const rect = element.getBoundingClientRect();
|
|
165
|
+
box = box ? {
|
|
166
|
+
left: Math.min(box.left, rect.left),
|
|
167
|
+
top: Math.min(box.top, rect.top),
|
|
168
|
+
right: Math.max(box.right, rect.right),
|
|
169
|
+
bottom: Math.max(box.bottom, rect.bottom),
|
|
170
|
+
} : { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
171
|
+
}
|
|
172
|
+
if (!box) return null;
|
|
173
|
+
box.width = box.right - box.left;
|
|
174
|
+
box.height = box.bottom - box.top;
|
|
175
|
+
return box;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -- what the bar shows ----------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The buttons for this selection, from the registry.
|
|
182
|
+
*
|
|
183
|
+
* This used to be three hand-built lists -- one for a panel, one for an
|
|
184
|
+
* annotation with a branch inside it for text, one for a multiple selection
|
|
185
|
+
* -- and the right-click menu was a fourth. They drifted, which is what
|
|
186
|
+
* `figureActions.js` exists to stop: what an action is, when it appears and
|
|
187
|
+
* when it can run are declared once and both surfaces read them.
|
|
188
|
+
*/
|
|
189
|
+
markup(ids) {
|
|
190
|
+
const context = this.context(ids);
|
|
191
|
+
const actions = FigureActions.forSurface("bar", context.sel, context);
|
|
192
|
+
if (!actions.length) return "";
|
|
193
|
+
|
|
194
|
+
// The handle the bar is picked up by. First, and drawn as a grip rather
|
|
195
|
+
// than as another button, so "this whole strip moves" is said by the
|
|
196
|
+
// one part of it that is not a control.
|
|
197
|
+
const parts = [`<span class="fb-context-grip" aria-hidden="true"></span>`];
|
|
198
|
+
let generic = null;
|
|
199
|
+
for (const action of actions) {
|
|
200
|
+
// One divider where the type-specific actions give way to the ones
|
|
201
|
+
// every object has. The gap is what says which controls belong
|
|
202
|
+
// together, so it is drawn from the registry rather than typed into
|
|
203
|
+
// each list by hand.
|
|
204
|
+
if (generic !== null && Boolean(action.generic) !== generic) {
|
|
205
|
+
parts.push(this.divider());
|
|
206
|
+
}
|
|
207
|
+
generic = Boolean(action.generic);
|
|
208
|
+
parts.push(this.button(action.id, action.icon, action.label, {
|
|
209
|
+
menu: Boolean(action.popover),
|
|
210
|
+
short: action.short,
|
|
211
|
+
shortcut: action.shortcut,
|
|
212
|
+
pressed: action.isPressed,
|
|
213
|
+
danger: Boolean(action.danger),
|
|
214
|
+
disabled: !action.isEnabled,
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
parts.push(this.divider());
|
|
218
|
+
// The overflow is the one button with no word under it. A vertical
|
|
219
|
+
// ellipsis is already the universal name for "the rest of this menu",
|
|
220
|
+
// and captioning it "More" would put the least specific label on the
|
|
221
|
+
// bar at the same size as the ones that say what they do.
|
|
222
|
+
parts.push(this.button("more", "ellipsis-vertical", "More",
|
|
223
|
+
{ menu: true, wordless: true }));
|
|
224
|
+
return parts.join("");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Everything an action's predicates and `run` need. */
|
|
228
|
+
context(ids) {
|
|
229
|
+
return {
|
|
230
|
+
ids: ids,
|
|
231
|
+
sel: FigureSelection.describe(ids, this.state, this.canvas),
|
|
232
|
+
canvas: this.canvas,
|
|
233
|
+
state: this.state,
|
|
234
|
+
handlers: this.handlers,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* One button: icon ABOVE word.
|
|
239
|
+
*
|
|
240
|
+
* Icon-only was smaller, and it meant every control on this bar had to be
|
|
241
|
+
* hovered to find out what it was -- on a bar whose contents CHANGE with
|
|
242
|
+
* the selection, so the row learnt one week is not the row seen the next.
|
|
243
|
+
* A tooltip is not a label; it is a label you have to ask for.
|
|
244
|
+
*
|
|
245
|
+
* Stacked rather than side by side, which is the shape every object bar
|
|
246
|
+
* that carries words uses. Beside the icon, a word doubles the width of its
|
|
247
|
+
* button, and eleven of those is a bar wider than the page it floats over --
|
|
248
|
+
* it wrapped to two lines on a panel selection. Above it, the word costs
|
|
249
|
+
* fourteen pixels of HEIGHT once for the whole strip, and the button stays
|
|
250
|
+
* about as wide as its widest word. Both labels then start at the same
|
|
251
|
+
* baseline, so the row reads as a row.
|
|
252
|
+
*
|
|
253
|
+
* The word is the registry's `short`, which is why that field exists: the
|
|
254
|
+
* full label is a menu row's sentence and gets truncated to nothing here.
|
|
255
|
+
* `title` and `aria-label` keep the full one -- plus the shortcut, which is
|
|
256
|
+
* the one place on the bar there is room to name it -- so the tooltip and
|
|
257
|
+
* the screen reader still say "Title, label and numbering" where the button
|
|
258
|
+
* says "Titles".
|
|
259
|
+
*/
|
|
260
|
+
button(act, icon, title, options) {
|
|
261
|
+
const flags = options || {};
|
|
262
|
+
const word = flags.short || title;
|
|
263
|
+
const name = flags.shortcut ? `${title} (${flags.shortcut})` : title;
|
|
264
|
+
return `<button type="button" class="fb-context-button${flags.pressed ? " is-on" : ""}${
|
|
265
|
+
flags.danger ? " is-danger" : ""}${
|
|
266
|
+
flags.wordless ? " is-wordless" : ""}"
|
|
267
|
+
data-act="${act}" title="${FigureSchema.escapeHtml(name)}"
|
|
268
|
+
aria-label="${FigureSchema.escapeHtml(name)}"
|
|
269
|
+
${flags.disabled ? "disabled" : ""}
|
|
270
|
+
${flags.menu ? 'aria-haspopup="true"' : ""}>
|
|
271
|
+
<span class="fas fa-${icon} fb-context-icon" aria-hidden="true"></span>${
|
|
272
|
+
flags.wordless ? ""
|
|
273
|
+
: `<span class="fb-context-label">${FigureSchema.escapeHtml(word)}</span>`}
|
|
274
|
+
</button>`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
divider() {
|
|
279
|
+
return '<span class="fb-context-divider"></span>';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// -- acting ------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
clicked(event) {
|
|
285
|
+
const button = event.target.closest("[data-act]");
|
|
286
|
+
if (!button || button.disabled) return;
|
|
287
|
+
const act = button.dataset.act;
|
|
288
|
+
|
|
289
|
+
// Anything the registry can run outright, runs. Only the actions whose
|
|
290
|
+
// detail needs a form fall through to a popover.
|
|
291
|
+
const action = FigureActions.byId(act);
|
|
292
|
+
if (action && action.run) {
|
|
293
|
+
this.closePopover();
|
|
294
|
+
action.run(this.context(this.ids.slice()));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Everything else opens a menu of its own, and a second click on the
|
|
298
|
+
// same button closes it -- the button is the toggle, so there is never
|
|
299
|
+
// a menu on screen whose opener does not look pressed.
|
|
300
|
+
if (this.popover && this.popover.dataset.act === act) {
|
|
301
|
+
this.closePopover();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.openPopover(button, act);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
changed(event) {
|
|
308
|
+
const field = event.target.dataset?.field;
|
|
309
|
+
if (field) this.fieldChanged(field, event.target);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// -- popovers ----------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
openPopover(button, act) {
|
|
315
|
+
this.closePopover();
|
|
316
|
+
const body = this.popoverMarkup(act);
|
|
317
|
+
if (!body) return;
|
|
318
|
+
|
|
319
|
+
this.popover = document.createElement("div");
|
|
320
|
+
this.popover.className = "fb-context-popover";
|
|
321
|
+
this.popover.dataset.act = act;
|
|
322
|
+
this.popover.innerHTML = body;
|
|
323
|
+
this.overlayEl.appendChild(this.popover);
|
|
324
|
+
this._anchor = button;
|
|
325
|
+
|
|
326
|
+
this.popover.addEventListener("click", (event) => this.popoverClicked(event));
|
|
327
|
+
this.popover.addEventListener("input", (event) => this.changed(event));
|
|
328
|
+
this.popover.addEventListener("change", (event) => this.changed(event));
|
|
329
|
+
this.popover.addEventListener(
|
|
330
|
+
"pointerdown", (event) => this.popoverPointerDown(event));
|
|
331
|
+
button.classList.add("is-open");
|
|
332
|
+
this.positionPopover();
|
|
333
|
+
// Not for the symbol palette. Focusing a tile takes the caret out of
|
|
334
|
+
// the text editor, and the caret is the place the symbol is going to be
|
|
335
|
+
// inserted at -- so the one popover that must not grab focus is the one
|
|
336
|
+
// whose whole purpose is to act on what has focus.
|
|
337
|
+
if (act !== "symbol") {
|
|
338
|
+
this.popover.querySelector("input, select, button")?.focus();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
closePopover() {
|
|
343
|
+
this.popover?.remove();
|
|
344
|
+
this.popover = null;
|
|
345
|
+
this._anchor?.classList.remove("is-open");
|
|
346
|
+
this._anchor = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
positionPopover() {
|
|
350
|
+
if (!this.popover || !this._anchor) return;
|
|
351
|
+
const host = this.overlayEl.getBoundingClientRect();
|
|
352
|
+
const anchor = this._anchor.getBoundingClientRect();
|
|
353
|
+
// See FigureContextMenu.open: the popover animates in with a scale, and
|
|
354
|
+
// a client rect would measure the transformed box rather than the one
|
|
355
|
+
// it is about to settle at.
|
|
356
|
+
const size = { width: this.popover.offsetWidth,
|
|
357
|
+
height: this.popover.offsetHeight };
|
|
358
|
+
|
|
359
|
+
let left = anchor.left - host.left + anchor.width / 2 - size.width / 2;
|
|
360
|
+
left = Math.max(8, Math.min(left, host.width - size.width - 8));
|
|
361
|
+
let top = anchor.bottom - host.top + 6;
|
|
362
|
+
// Above the bar when there is no room under it, which happens as soon
|
|
363
|
+
// as the selection is near the bottom of a tall page.
|
|
364
|
+
if (top + size.height > host.height - 8) {
|
|
365
|
+
top = Math.max(8, anchor.top - host.top - size.height - 6);
|
|
366
|
+
}
|
|
367
|
+
this.popover.style.left = Math.round(left) + "px";
|
|
368
|
+
this.popover.style.top = Math.round(top) + "px";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
popoverMarkup(act) {
|
|
372
|
+
const panel = this.ids.length === 1 ? this.state.panel(this.ids[0]) : null;
|
|
373
|
+
const annotation = this.ids.length === 1
|
|
374
|
+
? this.state.document.annotations[this.ids[0]] : null;
|
|
375
|
+
const panels = this.selectedPanels();
|
|
376
|
+
if (act === "titles" && panel) return this.titlesPopover(panel);
|
|
377
|
+
if (act === "scalebar" && panels.length) return this.scalebarPopover(panels);
|
|
378
|
+
if (act === "pixelsize" && panels.length) return this.pixelSizePopover(panels);
|
|
379
|
+
if (act === "legend" && panels.length) return this.legendPopover(panels);
|
|
380
|
+
if (act === "split" && panel) return this.splitPopover();
|
|
381
|
+
if (act === "type" && annotation) return this.typePopover(annotation);
|
|
382
|
+
if (act === "colour" && annotation) return this.colourPopover(annotation);
|
|
383
|
+
if (act === "stroke" && annotation) return this.strokePopover(annotation);
|
|
384
|
+
if (act === "more") return this.morePopover(panel);
|
|
385
|
+
if (act === "align") return this.menu([
|
|
386
|
+
["left", "Align left", "align-left"],
|
|
387
|
+
["center", "Align centers", "align-center"],
|
|
388
|
+
["right", "Align right", "align-right"],
|
|
389
|
+
["top", "Align top", "arrow-up"],
|
|
390
|
+
["middle", "Align middles", "arrows-up-down"],
|
|
391
|
+
["bottom", "Align bottom", "arrow-down"],
|
|
392
|
+
]);
|
|
393
|
+
if (act === "distribute") return this.menu([
|
|
394
|
+
// Evenly spaced bars, which is what the command produces, rather
|
|
395
|
+
// than a pair of arrows -- the arrows are already the BUTTON that
|
|
396
|
+
// opened this, and repeating them here says nothing about which of
|
|
397
|
+
// the two axes each row is.
|
|
398
|
+
["distribute_h", "Equal gaps across", "grip-lines-vertical"],
|
|
399
|
+
["distribute_v", "Equal gaps down", "grip-lines"],
|
|
400
|
+
]);
|
|
401
|
+
if (act === "resize") return this.menu([
|
|
402
|
+
["same_width", "Same width", "left-right"],
|
|
403
|
+
["same_height", "Same height", "up-down"],
|
|
404
|
+
["same_size", "Same size", "expand"],
|
|
405
|
+
]);
|
|
406
|
+
if (act === "layout") return this.menu([
|
|
407
|
+
["row", "Row", "table-columns"],
|
|
408
|
+
["column", "Column", "bars"],
|
|
409
|
+
["grid", "Grid", "table-cells"],
|
|
410
|
+
]);
|
|
411
|
+
if (act === "transform") return this.transformPopover();
|
|
412
|
+
if (act === "symbol") return this.symbolPopover();
|
|
413
|
+
// Rows rather than `menu()`: these four are registry actions with their
|
|
414
|
+
// own `run`, so they go out as `data-more` and are dispatched by the
|
|
415
|
+
// same code the overflow and the right-click menu already use. Taken
|
|
416
|
+
// from `FigureActions.ARRANGE` so this popover and the right-click menu
|
|
417
|
+
// cannot end up offering different commands under the same word.
|
|
418
|
+
if (act === "arrange") {
|
|
419
|
+
return FigureActions.ARRANGE.map((id) => FigureActions.byId(id))
|
|
420
|
+
.map((action) => this.menuItem(action.id, action.label,
|
|
421
|
+
{ icon: action.icon,
|
|
422
|
+
shortcut: action.shortcut })).join("");
|
|
423
|
+
}
|
|
424
|
+
return "";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* A list of arrange commands. `data-arrange` is the contract FigureCanvas
|
|
429
|
+
* already answers to, so this menu adds vocabulary rather than a path.
|
|
430
|
+
*
|
|
431
|
+
* Drawn through `menuItem`, which is the only reason these rows have icons:
|
|
432
|
+
* they were built here as bare labels while the Arrange popover next to them
|
|
433
|
+
* -- the same size, the same shape, opened from the button alongside --
|
|
434
|
+
* listed icon, label and key. So four menus on one toolbar looked like two
|
|
435
|
+
* different kinds of menu, and the difference was which function had
|
|
436
|
+
* happened to draw them rather than anything about the commands.
|
|
437
|
+
*/
|
|
438
|
+
menu(entries) {
|
|
439
|
+
return entries.map(([command, label, icon]) =>
|
|
440
|
+
this.menuItem(command, label,
|
|
441
|
+
{ icon: icon, attribute: "data-arrange" })).join("");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The characters a caption needs and a keyboard does not have.
|
|
446
|
+
*
|
|
447
|
+
* A grid rather than a search field: the useful set for a figure is about
|
|
448
|
+
* thirty characters, and thirty tiles are quicker to read than any box is
|
|
449
|
+
* to type into.
|
|
450
|
+
*
|
|
451
|
+
* Restricted to WinAnsi, which is the encoding the PDF core fonts are drawn
|
|
452
|
+
* with. Greek and the maths signs are left out for a reason worth writing
|
|
453
|
+
* down, because it is not the obvious one: reportlab does not refuse them.
|
|
454
|
+
* It silently SUBSTITUTES -- an alpha comes out of Helvetica-with-
|
|
455
|
+
* WinAnsiEncoding set in Symbol, and a dingbat in ZapfDingbats, both with
|
|
456
|
+
* that face's metrics rather than the document's. So the character is set
|
|
457
|
+
* in a typeface the caption did not choose, at a width the browser did not
|
|
458
|
+
* measure, and the line it is on can break in one place on screen and
|
|
459
|
+
* another in the PDF. Everything in the list below is drawn by the font the
|
|
460
|
+
* caption actually names.
|
|
461
|
+
*
|
|
462
|
+
* Nothing stops anybody PASTING an alpha; this is about what the tool
|
|
463
|
+
* proposes, and about not proposing the one character in the caption that
|
|
464
|
+
* will not match.
|
|
465
|
+
*/
|
|
466
|
+
symbolPopover() {
|
|
467
|
+
return `<div class="fb-symbol-grid">${FigureContextBar.SYMBOLS.map((glyph) =>
|
|
468
|
+
`<button type="button" class="fb-symbol" data-symbol="${
|
|
469
|
+
FigureSchema.escapeHtml(glyph)}" aria-label="${
|
|
470
|
+
FigureSchema.escapeHtml(glyph)}">${
|
|
471
|
+
FigureSchema.escapeHtml(glyph)}</button>`).join("")}</div>`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
static get SYMBOLS() {
|
|
475
|
+
return ["µ", "°", "±", "×", "÷", "¹",
|
|
476
|
+
"²", "³", "½", "¼", "¾", "Å",
|
|
477
|
+
"–", "—", "·", "•", "…", "‰",
|
|
478
|
+
"†", "‡", "§", "¶", "‘", "’",
|
|
479
|
+
"“", "”", "«", "»", "™", "©"];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
titlesPopover(panel) {
|
|
483
|
+
const escape = FigureSchema.escapeHtml.bind(FigureSchema);
|
|
484
|
+
const style = this.state.document.settings.label_style;
|
|
485
|
+
return `
|
|
486
|
+
<label class="control-label" for="fb_ctx_title">Title</label>
|
|
487
|
+
<input id="fb_ctx_title" class="fb-input" type="text" data-field="title"
|
|
488
|
+
value="${escape(panel.title || "")}" maxlength="200"
|
|
489
|
+
placeholder="No title">
|
|
490
|
+
|
|
491
|
+
<label class="control-label" for="fb_ctx_label">Label</label>
|
|
492
|
+
<div class="fb-figure-row">
|
|
493
|
+
<input id="fb_ctx_label" class="fb-input fb-input-tiny" type="text"
|
|
494
|
+
data-field="label" maxlength="8" placeholder="auto"
|
|
495
|
+
value="${escape(panel.label.auto ? "" : panel.label.text)}">
|
|
496
|
+
<label class="fb-check" title="Renumber when the panels are rearranged">
|
|
497
|
+
<input type="checkbox" data-field="label_auto"
|
|
498
|
+
${panel.label.auto ? "checked" : ""}> Auto
|
|
499
|
+
</label>
|
|
500
|
+
</div>
|
|
501
|
+
<label class="fb-check">
|
|
502
|
+
<input type="checkbox" data-field="label_visible"
|
|
503
|
+
${panel.label.visible ? "checked" : ""}> Show the label
|
|
504
|
+
</label>
|
|
505
|
+
|
|
506
|
+
<label class="control-label" for="fb_ctx_label_style">Numbering</label>
|
|
507
|
+
<select id="fb_ctx_label_style" class="fb-select" data-field="label_style">
|
|
508
|
+
<option value="A" ${style === "A" ? "selected" : ""}>A, B, C</option>
|
|
509
|
+
<option value="a" ${style === "a" ? "selected" : ""}>a, b, c</option>
|
|
510
|
+
<option value="A1" ${style === "A1" ? "selected" : ""}>A1, A2, A3</option>
|
|
511
|
+
</select>
|
|
512
|
+
<p class="fb-muted fb-popover-note">Numbering is the whole figure's, and follows
|
|
513
|
+
reading order — left to right, top to bottom.</p>`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Scale bars, for one panel or for a whole selection.
|
|
518
|
+
*
|
|
519
|
+
* The length is EITHER automatic or an explicit number of microns, and the
|
|
520
|
+
* difference matters across several panels: automatic gives each image a
|
|
521
|
+
* round number that fits it, which for a row of different magnifications is
|
|
522
|
+
* several different bars; an explicit length is the same physical distance
|
|
523
|
+
* everywhere, which is what makes two panels comparable by eye. The popover
|
|
524
|
+
* says which is which rather than leaving it to be discovered.
|
|
525
|
+
*/
|
|
526
|
+
scalebarPopover(panels) {
|
|
527
|
+
const uncalibrated = panels.filter(
|
|
528
|
+
(panel) => !FigureSchema.physicalWidthUm(
|
|
529
|
+
this.state.source(panel.source_id), panel.scene.viewport));
|
|
530
|
+
const shown = panels.every((panel) => panel.scalebar.visible);
|
|
531
|
+
const target = panels[0].scalebar.target_um;
|
|
532
|
+
const same = panels.every((panel) => panel.scalebar.target_um === target);
|
|
533
|
+
const lengths = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000];
|
|
534
|
+
|
|
535
|
+
return `
|
|
536
|
+
<label class="fb-check">
|
|
537
|
+
<input type="checkbox" data-field="scalebar" ${shown ? "checked" : ""}>
|
|
538
|
+
Show a scale bar${panels.length > 1
|
|
539
|
+
? " on all " + panels.length : ""}
|
|
540
|
+
</label>
|
|
541
|
+
|
|
542
|
+
<label class="control-label" for="fb_ctx_bar_len">Length</label>
|
|
543
|
+
<select id="fb_ctx_bar_len" class="fb-select" data-field="scalebar_length">
|
|
544
|
+
<option value="" ${same && !target ? "selected" : ""}>Automatic</option>
|
|
545
|
+
${lengths.map((value) =>
|
|
546
|
+
`<option value="${value}" ${same && target === value ? "selected" : ""}
|
|
547
|
+
>${FigureSchema.escapeHtml(FigureSchema.formatMicrons(value))}</option>`).join("")}
|
|
548
|
+
</select>
|
|
549
|
+
<p class="fb-muted fb-popover-note">${panels.length > 1
|
|
550
|
+
? "Automatic gives each image a round number that fits it; a set length is "
|
|
551
|
+
+ "the same physical distance on every panel."
|
|
552
|
+
: "Automatic picks a round number that fits this image."}</p>
|
|
553
|
+
|
|
554
|
+
${uncalibrated.length ? `
|
|
555
|
+
<p class="fb-muted fb-popover-note">${FigureSchema.escapeHtml(
|
|
556
|
+
FigureSchema.countPhrase(uncalibrated.length, "image"))} here recorded no
|
|
557
|
+
pixel size, so ${uncalibrated.length === 1 ? "it has" : "they have"}
|
|
558
|
+
no bar.</p>
|
|
559
|
+
<button type="button" class="fb-menu-item" data-more="pixelsize">
|
|
560
|
+
Set the pixel size…</button>` : ""}`;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Type in a pixel size for images that never recorded one.
|
|
565
|
+
*
|
|
566
|
+
* Written to the SOURCE rather than to the panel, because it is a fact
|
|
567
|
+
* about the image and every panel of it is entitled to the same answer --
|
|
568
|
+
* and marked `manual`, which the provenance page prints. A number somebody
|
|
569
|
+
* typed is not the same evidence as one the file stated, and a figure that
|
|
570
|
+
* could not tell the difference would be a figure whose scale bars cannot
|
|
571
|
+
* be checked.
|
|
572
|
+
*/
|
|
573
|
+
pixelSizePopover(panels) {
|
|
574
|
+
const sources = this.uncalibratedSources(panels);
|
|
575
|
+
return `
|
|
576
|
+
<label class="control-label" for="fb_ctx_mpp">Microns per pixel</label>
|
|
577
|
+
<input id="fb_ctx_mpp" class="fb-input" type="number" min="0"
|
|
578
|
+
step="0.0001" placeholder="e.g. 0.325">
|
|
579
|
+
<p class="fb-muted fb-popover-note">Applies to
|
|
580
|
+
${FigureSchema.escapeHtml(FigureSchema.countPhrase(sources.length, "image"))}
|
|
581
|
+
across the whole figure, and is recorded in the provenance as a value you
|
|
582
|
+
supplied rather than one the file stated.</p>
|
|
583
|
+
<button type="button" class="fb-menu-item" data-more="apply_pixelsize">
|
|
584
|
+
Apply</button>`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The sources behind these panels that have no calibration. */
|
|
588
|
+
uncalibratedSources(panels) {
|
|
589
|
+
const seen = new Map();
|
|
590
|
+
for (const panel of panels) {
|
|
591
|
+
const source = this.state.source(panel.source_id);
|
|
592
|
+
if (source && !(source.pixel_size && source.pixel_size.value > 0)) {
|
|
593
|
+
seen.set(source.source_id, source);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return Array.from(seen.values());
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Legends, and the conflict that must never be resolved silently.
|
|
601
|
+
*
|
|
602
|
+
* Two panels can show the same marker in different colours -- deliberately,
|
|
603
|
+
* because they were captured in different sessions, or by accident. Turning
|
|
604
|
+
* on a legend across both would produce two legends disagreeing about what
|
|
605
|
+
* CD8 looks like, which is a figure that misleads a reader.
|
|
606
|
+
*
|
|
607
|
+
* So it is asked about. "Keep separate" is the safe answer and stays first;
|
|
608
|
+
* "use one shared style" recolours the panels, which changes what the image
|
|
609
|
+
* looks like and is therefore never the default. Nothing here alters a
|
|
610
|
+
* scientific colour without being told to.
|
|
611
|
+
*/
|
|
612
|
+
legendPopover(panels) {
|
|
613
|
+
const channels = panels.every((panel) => panel.legend.channels);
|
|
614
|
+
const plugins = panels.every((panel) => panel.legend.plugins);
|
|
615
|
+
const clashes = FigureContextBar.colourConflicts(panels);
|
|
616
|
+
|
|
617
|
+
return `
|
|
618
|
+
<label class="fb-check">
|
|
619
|
+
<input type="checkbox" data-field="legend_channels"
|
|
620
|
+
${channels ? "checked" : ""}> Channels
|
|
621
|
+
</label>
|
|
622
|
+
<label class="fb-check">
|
|
623
|
+
<input type="checkbox" data-field="legend_plugins"
|
|
624
|
+
${plugins ? "checked" : ""}> Overlays
|
|
625
|
+
</label>
|
|
626
|
+
<p class="fb-muted fb-popover-note">Drawn from what was recorded when the panel
|
|
627
|
+
was captured, never from the plugins that happen to be open now.</p>
|
|
628
|
+
${clashes.length ? `
|
|
629
|
+
<div class="fb-conflict">
|
|
630
|
+
<strong>Selected panels use different colors for
|
|
631
|
+
${FigureSchema.escapeHtml(clashes.map((c) => c.name).join(", "))}.</strong>
|
|
632
|
+
<p class="fb-muted fb-popover-note">One legend for both would say something
|
|
633
|
+
the panels do not.</p>
|
|
634
|
+
<button type="button" class="fb-menu-item" data-more="legend_keep">
|
|
635
|
+
Keep them separate</button>
|
|
636
|
+
<button type="button" class="fb-menu-item" data-more="legend_share">
|
|
637
|
+
Use one shared style</button>
|
|
638
|
+
</div>` : ""}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Markers that are drawn in more than one colour across a selection.
|
|
643
|
+
*
|
|
644
|
+
* Pure and static, so the rule can be checked without a browser. Compared
|
|
645
|
+
* on the name the legend would PRINT rather than on the channel key: two
|
|
646
|
+
* images can key the same marker differently, and a reader compares the
|
|
647
|
+
* words.
|
|
648
|
+
*/
|
|
649
|
+
static colourConflicts(panels) {
|
|
650
|
+
const seen = new Map();
|
|
651
|
+
for (const panel of panels) {
|
|
652
|
+
for (const channel of panel.scene.channels || []) {
|
|
653
|
+
if (channel.visible === false) continue;
|
|
654
|
+
const name = channel.fullname_at_capture || channel.key;
|
|
655
|
+
const colour = `${channel.color.r},${channel.color.g},${channel.color.b}`;
|
|
656
|
+
if (!seen.has(name)) seen.set(name, new Set());
|
|
657
|
+
seen.get(name).add(colour);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return Array.from(seen.entries())
|
|
661
|
+
.filter(([, colours]) => colours.size > 1)
|
|
662
|
+
.map(([name, colours]) => ({ name: name, colours: Array.from(colours) }));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
typePopover(annotation) {
|
|
666
|
+
return `
|
|
667
|
+
<label class="control-label" for="fb_ctx_size">Size (pt)</label>
|
|
668
|
+
<input id="fb_ctx_size" class="fb-input fb-input-tiny" type="number"
|
|
669
|
+
min="4" max="96" step="0.5" data-field="font_size_pt"
|
|
670
|
+
value="${annotation.style.font_size_pt}">
|
|
671
|
+
|
|
672
|
+
<label class="control-label" for="fb_ctx_align">Alignment</label>
|
|
673
|
+
<select id="fb_ctx_align" class="fb-select" data-field="align">
|
|
674
|
+
${["left", "center", "right"].map((value) =>
|
|
675
|
+
`<option value="${value}" ${annotation.style.align === value ? "selected" : ""}
|
|
676
|
+
>${value}</option>`).join("")}
|
|
677
|
+
</select>`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Colour, as the OS picker plus the figure's own text colour.
|
|
682
|
+
*
|
|
683
|
+
* No palette of "nice" colours. An annotation on a scientific figure is
|
|
684
|
+
* usually meant to match something already in it, and offering a dozen
|
|
685
|
+
* suggestions invites picking one that nearly does.
|
|
686
|
+
*/
|
|
687
|
+
colourPopover(annotation) {
|
|
688
|
+
return `
|
|
689
|
+
<label class="control-label" for="fb_ctx_colour">Color</label>
|
|
690
|
+
<input id="fb_ctx_colour" class="fb-input" type="color" data-field="color"
|
|
691
|
+
value="${FigureSchema.escapeHtml(annotation.style.color)}">`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
strokePopover(annotation) {
|
|
695
|
+
const fills = annotation.type === "rect" || annotation.type === "ellipse";
|
|
696
|
+
return `
|
|
697
|
+
<label class="control-label" for="fb_ctx_width">Line width (pt)</label>
|
|
698
|
+
<input id="fb_ctx_width" class="fb-input fb-input-tiny" type="number"
|
|
699
|
+
min="0" max="20" step="0.25" data-field="line_width_pt"
|
|
700
|
+
value="${annotation.style.line_width_pt}">
|
|
701
|
+
${fills ? `
|
|
702
|
+
<label class="fb-check">
|
|
703
|
+
<input type="checkbox" data-field="fill_on"
|
|
704
|
+
${annotation.style.fill ? "checked" : ""}> Filled
|
|
705
|
+
</label>
|
|
706
|
+
<input class="fb-input" type="color" data-field="fill"
|
|
707
|
+
value="${FigureSchema.escapeHtml(annotation.style.fill || "#ffffff")}">` : ""}`;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
splitPopover() {
|
|
711
|
+
return `
|
|
712
|
+
<p class="fb-muted fb-popover-note">One panel per channel, sharing this panel's
|
|
713
|
+
exact crop and window — linked, so resizing one resizes the row.</p>
|
|
714
|
+
<button type="button" class="fb-menu-item" data-split="with_composite">
|
|
715
|
+
Composite + channels</button>
|
|
716
|
+
<button type="button" class="fb-menu-item" data-split="channels_only">
|
|
717
|
+
Channels only</button>`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Level 3: everything worth having that is not worth a button.
|
|
722
|
+
*
|
|
723
|
+
* The two deletes are both here and are worded as what they do. "Remove
|
|
724
|
+
* from page" unplaces a panel and it comes back in the tray; "Delete from
|
|
725
|
+
* figure" destroys the captured scene. Naming them "Delete" and "Delete
|
|
726
|
+
* permanently" would put the whole distinction in an adverb.
|
|
727
|
+
*/
|
|
728
|
+
/** The overflow menu: the generic actions that did not fit on the bar,
|
|
729
|
+
* from the same registry the bar reads. */
|
|
730
|
+
morePopover(panel) {
|
|
731
|
+
const context = this.context(this.ids.slice());
|
|
732
|
+
const rows = [];
|
|
733
|
+
if (panel) {
|
|
734
|
+
const source = this.state.source(panel.source_id);
|
|
735
|
+
const status = this.state.sourceStatus[panel.source_id]?.status || "ok";
|
|
736
|
+
if (status === "changed") {
|
|
737
|
+
rows.push(this.menuItem("accept_source", "Accept the new image"));
|
|
738
|
+
}
|
|
739
|
+
if (source) {
|
|
740
|
+
rows.push(`<div class="fb-menu-note">${FigureSchema.escapeHtml(
|
|
741
|
+
source.display_name || source.datasource || "no source")}</div>`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const action of FigureActions.forSurface("overflow", context.sel, context)) {
|
|
745
|
+
rows.push(this.menuItem(action.id, action.label,
|
|
746
|
+
{ danger: action.danger,
|
|
747
|
+
disabled: !action.isEnabled,
|
|
748
|
+
icon: action.icon,
|
|
749
|
+
shortcut: action.shortcut }));
|
|
750
|
+
}
|
|
751
|
+
// Only once the bar has actually been moved. A permanent "put it back"
|
|
752
|
+
// is a row that says nothing for the whole of the time nobody has
|
|
753
|
+
// picked the bar up, which is nearly all of it.
|
|
754
|
+
if (this.moved) {
|
|
755
|
+
rows.push(this.menuItem("reset_bar", "Move the toolbar back",
|
|
756
|
+
{ icon: "rotate-left" }));
|
|
757
|
+
}
|
|
758
|
+
return rows.join("");
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* One row of a menu: icon, label, key -- in three columns, in that order.
|
|
762
|
+
*
|
|
763
|
+
* The icon is optional and is drawn in a fixed-width gutter, so a list
|
|
764
|
+
* where only some rows have one still reads as a column of labels rather
|
|
765
|
+
* than as a ragged left edge. The shortcut is pushed to the RIGHT edge and
|
|
766
|
+
* greyed, which is the arrangement every platform menu uses and the reason
|
|
767
|
+
* it works: the labels are what the eye scans down, and a key set in the
|
|
768
|
+
* same colour immediately after a label of unpredictable length puts the
|
|
769
|
+
* least important text in the middle of the scan.
|
|
770
|
+
*/
|
|
771
|
+
menuItem(act, label, options) {
|
|
772
|
+
const flags = options || {};
|
|
773
|
+
// Which attribute carries the command is the caller's, because two
|
|
774
|
+
// vocabularies come through this one row. `data-more` is a registry
|
|
775
|
+
// action with its own `run`; `data-arrange` is a layout command that
|
|
776
|
+
// FigureCanvas answers to directly. They looked like different kinds of
|
|
777
|
+
// menu when only the first had icons -- and they are not: they are the
|
|
778
|
+
// same row with a different dispatch behind it.
|
|
779
|
+
const attribute = flags.attribute || "data-more";
|
|
780
|
+
return `<button type="button" class="fb-menu-item${
|
|
781
|
+
flags.danger ? " is-danger" : ""}"
|
|
782
|
+
${attribute}="${act}" ${flags.disabled ? "disabled" : ""}>
|
|
783
|
+
<span class="fb-menu-icon" aria-hidden="true">${flags.icon
|
|
784
|
+
? `<span class="fas fa-${flags.icon}"></span>` : ""}</span>
|
|
785
|
+
<span class="fb-menu-text">${FigureSchema.escapeHtml(label)}</span>${flags.shortcut
|
|
786
|
+
? `<span class="fb-menu-key" aria-hidden="true">${
|
|
787
|
+
FigureSchema.escapeHtml(flags.shortcut)}</span>` : ""}</button>`;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* A press inside the popover, before focus has had a chance to move.
|
|
793
|
+
*
|
|
794
|
+
* Inserting a symbol has to happen HERE and not on click: pressing a button
|
|
795
|
+
* blurs the contenteditable first, blur is what commits and closes the text
|
|
796
|
+
* editor, and by the time a click handler ran there would be no editor left
|
|
797
|
+
* to ask where the caret was. Cancelling the default of the pointerdown is
|
|
798
|
+
* the only thing that stops the focus moving at all -- so the palette can
|
|
799
|
+
* be used several times in a row without the caret ever leaving the caption.
|
|
800
|
+
*/
|
|
801
|
+
popoverPointerDown(event) {
|
|
802
|
+
const symbol = event.target.closest("[data-symbol]");
|
|
803
|
+
if (!symbol) return;
|
|
804
|
+
event.preventDefault();
|
|
805
|
+
this.handlers.onInsertSymbol?.(this.ids[0], symbol.dataset.symbol);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
popoverClicked(event) {
|
|
809
|
+
const arrange = event.target.closest("[data-arrange]");
|
|
810
|
+
if (arrange) {
|
|
811
|
+
this.handlers.onArrange?.(arrange.dataset.arrange);
|
|
812
|
+
this.closePopover();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const split = event.target.closest("[data-split]");
|
|
816
|
+
if (split) {
|
|
817
|
+
this.handlers.onSplit?.(split.dataset.split);
|
|
818
|
+
this.closePopover();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const more = event.target.closest("[data-more]");
|
|
822
|
+
if (!more) return;
|
|
823
|
+
const ids = this.ids.slice();
|
|
824
|
+
const act = more.dataset.more;
|
|
825
|
+
|
|
826
|
+
// The two that stay open: one moves to a second popover, and the other
|
|
827
|
+
// is a form that has not been filled in yet.
|
|
828
|
+
if (act === "pixelsize") {
|
|
829
|
+
this.openPopover(this._anchor, "pixelsize");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (act === "apply_pixelsize") {
|
|
833
|
+
this.applyPixelSize();
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// The generic actions run from the registry, which is also what the
|
|
838
|
+
// right-click menu runs -- so a row cannot mean one thing in one menu
|
|
839
|
+
// and something else in the other.
|
|
840
|
+
const action = FigureActions.byId(act);
|
|
841
|
+
if (action && action.run) {
|
|
842
|
+
action.run(this.context(ids));
|
|
843
|
+
this.closePopover();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
({
|
|
848
|
+
legend_keep: () => this.applyLegend({ channels: true }),
|
|
849
|
+
legend_share: () => this.shareLegendColours(),
|
|
850
|
+
reset_bar: () => this.resetPosition(),
|
|
851
|
+
accept_source: () => {
|
|
852
|
+
const panel = this.state.panel(ids[0]);
|
|
853
|
+
if (panel) this.handlers.onAcceptSource?.(panel.source_id);
|
|
854
|
+
},
|
|
855
|
+
}[act] || (() => {}))();
|
|
856
|
+
this.closePopover();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
selectedPanels() {
|
|
860
|
+
return this.ids.map((id) => this.state.panel(id)).filter(Boolean);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* One field changed.
|
|
865
|
+
*
|
|
866
|
+
* Committed straight away rather than on a Done button: every one of these
|
|
867
|
+
* is a single reversible property, autosave is immediate, and a popover
|
|
868
|
+
* with an OK in it is a dialog pretending to be a control.
|
|
869
|
+
*
|
|
870
|
+
* The panel fields apply across the whole selection, which is the point of
|
|
871
|
+
* the multi-panel bar -- doing a row of six one at a time is where a figure
|
|
872
|
+
* picks up the inconsistencies it is then hard to spot.
|
|
873
|
+
*/
|
|
874
|
+
fieldChanged(field, input) {
|
|
875
|
+
// Transform first, and before the panel/annotation fork: it is the one
|
|
876
|
+
// form on this bar that reads the same four numbers off either kind.
|
|
877
|
+
if (field.startsWith("tf_")) {
|
|
878
|
+
this.transformChanged(field.slice(3), input);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (field === "units") {
|
|
882
|
+
// The figure's unit, not this popover's. There is one answer to
|
|
883
|
+
// "what is this page measured in" and the rulers already show it;
|
|
884
|
+
// a second copy here is how the two end up disagreeing.
|
|
885
|
+
this.handlers.onUnits?.(input.value);
|
|
886
|
+
this.refreshPopover();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const annotation = this.ids.length === 1
|
|
890
|
+
? this.state.document.annotations[this.ids[0]] : null;
|
|
891
|
+
if (annotation) {
|
|
892
|
+
this.annotationFieldChanged(field, input, annotation);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
// Numbering is the figure's, not a panel's: every label on the page is
|
|
896
|
+
// drawn from it, so it goes to the document rather than to whichever
|
|
897
|
+
// panel happened to be selected.
|
|
898
|
+
if (field === "label_style") {
|
|
899
|
+
this.handlers.onSettingsChange?.({ label_style: input.value });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const panels = this.selectedPanels();
|
|
904
|
+
if (!panels.length) return;
|
|
905
|
+
const single = panels.length === 1 ? panels[0] : null;
|
|
906
|
+
|
|
907
|
+
if (field === "scalebar") {
|
|
908
|
+
this.applyToPanels(panels, (panel) => ({
|
|
909
|
+
scalebar: { ...panel.scalebar, visible: input.checked } }));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (field === "scalebar_length") {
|
|
913
|
+
const target = input.value ? parseFloat(input.value) : null;
|
|
914
|
+
this.applyToPanels(panels, (panel) => ({
|
|
915
|
+
scalebar: { ...panel.scalebar, target_um: target } }));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (field === "legend_channels" || field === "legend_plugins") {
|
|
919
|
+
const key = field === "legend_channels" ? "channels" : "plugins";
|
|
920
|
+
this.applyToPanels(panels, (panel) => ({
|
|
921
|
+
legend: { ...panel.legend, [key]: input.checked } }));
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (!single) return;
|
|
926
|
+
const changes = {};
|
|
927
|
+
if (field === "title") changes.title = input.value;
|
|
928
|
+
else if (field === "label") {
|
|
929
|
+
// Typing a label makes it the user's; it stops renumbering when the
|
|
930
|
+
// page is rearranged, which is the whole difference between the two.
|
|
931
|
+
changes.label = { ...single.label, text: input.value, auto: false };
|
|
932
|
+
const auto = this.popover?.querySelector('[data-field="label_auto"]');
|
|
933
|
+
if (auto) auto.checked = false;
|
|
934
|
+
} else if (field === "label_auto") {
|
|
935
|
+
changes.label = { ...single.label, auto: input.checked };
|
|
936
|
+
} else if (field === "label_visible") {
|
|
937
|
+
changes.label = { ...single.label, visible: input.checked };
|
|
938
|
+
} else return;
|
|
939
|
+
|
|
940
|
+
this.handlers.onPanelChange?.(single.panel_id, changes);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** One change, across the selection, as ONE undo step. */
|
|
944
|
+
applyToPanels(panels, changesFor) {
|
|
945
|
+
this.handlers.onPanelsChange?.(
|
|
946
|
+
panels.map((panel) => ({ panel_id: panel.panel_id, changes: changesFor(panel) })));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
applyLegend(which) {
|
|
950
|
+
const panels = this.selectedPanels();
|
|
951
|
+
this.applyToPanels(panels, (panel) => ({
|
|
952
|
+
legend: { ...panel.legend, ...which } }));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Make every selected panel draw a marker the same colour.
|
|
957
|
+
*
|
|
958
|
+
* The FIRST panel's colour wins, because it is the one at the top left and
|
|
959
|
+
* the one the user was looking at when they asked. Every other panel is
|
|
960
|
+
* recoloured and re-rendered, which is why this is a button and not a
|
|
961
|
+
* default: it changes what the images look like.
|
|
962
|
+
*/
|
|
963
|
+
shareLegendColours() {
|
|
964
|
+
const panels = this.selectedPanels();
|
|
965
|
+
const canonical = new Map();
|
|
966
|
+
for (const panel of panels) {
|
|
967
|
+
for (const channel of panel.scene.channels || []) {
|
|
968
|
+
const name = channel.fullname_at_capture || channel.key;
|
|
969
|
+
if (!canonical.has(name)) canonical.set(name, { ...channel.color });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
this.handlers.onShareLegendColours?.(
|
|
973
|
+
panels.map((panel) => panel.panel_id), canonical);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
applyPixelSize() {
|
|
977
|
+
const input = this.popover?.querySelector("#fb_ctx_mpp");
|
|
978
|
+
const value = parseFloat(input && input.value);
|
|
979
|
+
if (!Number.isFinite(value) || value <= 0) return;
|
|
980
|
+
const sources = this.uncalibratedSources(this.selectedPanels());
|
|
981
|
+
this.handlers.onSetPixelSize?.(sources.map((source) => source.source_id), value);
|
|
982
|
+
this.closePopover();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
annotationFieldChanged(field, input, annotation) {
|
|
986
|
+
const style = {};
|
|
987
|
+
if (field === "font_size_pt" || field === "line_width_pt") {
|
|
988
|
+
const value = parseFloat(input.value);
|
|
989
|
+
if (!Number.isFinite(value)) return;
|
|
990
|
+
style[field] = value;
|
|
991
|
+
} else if (field === "align" || field === "color") {
|
|
992
|
+
style[field] = input.value;
|
|
993
|
+
} else if (field === "fill") {
|
|
994
|
+
style.fill = input.value;
|
|
995
|
+
} else if (field === "fill_on") {
|
|
996
|
+
// An empty string is how the schema says "no fill"; the colour
|
|
997
|
+
// input keeps its value so unticking and re-ticking comes back to
|
|
998
|
+
// the same colour rather than to black.
|
|
999
|
+
style.fill = input.checked
|
|
1000
|
+
? (this.popover?.querySelector('[data-field="fill"]')?.value || "#ffffff")
|
|
1001
|
+
: "";
|
|
1002
|
+
} else return;
|
|
1003
|
+
|
|
1004
|
+
this.handlers.onAnnotationChange?.(annotation.annotation_id, { style: style });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// -- transform ---------------------------------------------------------
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Where the selected object is and how big it is, in the figure's unit.
|
|
1011
|
+
*
|
|
1012
|
+
* One reader for both kinds. A panel keeps its geometry in `placement` and
|
|
1013
|
+
* an annotation in `geometry`, which is a difference in where the numbers
|
|
1014
|
+
* live rather than in what they mean, and every caller past this point is
|
|
1015
|
+
* better off not knowing about it.
|
|
1016
|
+
*
|
|
1017
|
+
* `rotation` is null for a panel: panels are not rotated, and a field that
|
|
1018
|
+
* is always 0 and cannot be changed is worse than no field.
|
|
1019
|
+
*/
|
|
1020
|
+
transformValues() {
|
|
1021
|
+
if (this.ids.length !== 1) return null;
|
|
1022
|
+
const id = this.ids[0];
|
|
1023
|
+
const panel = this.state.panel(id);
|
|
1024
|
+
const box = panel ? panel.placement
|
|
1025
|
+
: this.state.document.annotations[id]?.geometry;
|
|
1026
|
+
if (!box) return null;
|
|
1027
|
+
const per = FigureContextBar.UNITS[this.unit()].per;
|
|
1028
|
+
const show = (mm) => String(Math.round((mm / per) * 1000) / 1000);
|
|
1029
|
+
return {
|
|
1030
|
+
w: show(box.w_mm), h: show(box.h_mm),
|
|
1031
|
+
rotation: panel ? null : String(Math.round(box.rotation || 0)),
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** The unit the figure is being measured in, asked of whoever owns it --
|
|
1036
|
+
* the View menu -- rather than kept a second time here. */
|
|
1037
|
+
unit() {
|
|
1038
|
+
const name = this.handlers.units?.();
|
|
1039
|
+
return name in FigureContextBar.UNITS ? name : "mm";
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** The same table `FigureViewOptions.UNITS` holds, narrowed to the one
|
|
1043
|
+
* field this needs. Duplicated rather than reached for, because the bar
|
|
1044
|
+
* must render on a page where the View menu has not been built yet -- and
|
|
1045
|
+
* it is pinned equal by the boot probe. */
|
|
1046
|
+
static get UNITS() {
|
|
1047
|
+
return { mm: { per: 1, label: "mm" }, in: { per: 25.4, label: "in" },
|
|
1048
|
+
pt: { per: 25.4 / 72, label: "pt" } };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
transformPopover() {
|
|
1052
|
+
const now = this.transformValues();
|
|
1053
|
+
if (!now) return "";
|
|
1054
|
+
const unit = this.unit();
|
|
1055
|
+
const units = Object.entries(FigureContextBar.UNITS).map(([name, spec]) =>
|
|
1056
|
+
`<option value="${name}"${name === unit ? " selected" : ""}>${spec.label}</option>`)
|
|
1057
|
+
.join("");
|
|
1058
|
+
const number = (field, label, value, step, suffix) => `
|
|
1059
|
+
<label class="fb-tf-row">
|
|
1060
|
+
<span class="control-label">${FigureSchema.escapeHtml(label)}</span>
|
|
1061
|
+
<span class="fb-input-suffixed">
|
|
1062
|
+
<input class="fb-input fb-input-tiny" type="number" step="${step}"
|
|
1063
|
+
data-field="tf_${field}" value="${value}">
|
|
1064
|
+
<span class="fb-input-suffix" aria-hidden="true">${suffix}</span>
|
|
1065
|
+
</span>
|
|
1066
|
+
</label>`;
|
|
1067
|
+
|
|
1068
|
+
return (now.rotation === null ? ""
|
|
1069
|
+
: number("rotation", "Rotation", now.rotation, "1", "°"))
|
|
1070
|
+
+ number("w", "Width", now.w, "0.1", FigureContextBar.UNITS[unit].label)
|
|
1071
|
+
+ number("h", "Height", now.h, "0.1", FigureContextBar.UNITS[unit].label)
|
|
1072
|
+
+ `<label class="fb-tf-row">
|
|
1073
|
+
<span class="control-label">Units</span>
|
|
1074
|
+
<select class="fb-input fb-input-tiny" data-field="units"
|
|
1075
|
+
>${units}</select>
|
|
1076
|
+
</label>`;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
transformChanged(field, input) {
|
|
1080
|
+
const value = parseFloat(input.value);
|
|
1081
|
+
if (!Number.isFinite(value) || this.ids.length !== 1) return;
|
|
1082
|
+
const id = this.ids[0];
|
|
1083
|
+
if (field === "rotation") {
|
|
1084
|
+
this.handlers.onAnnotationChange?.(
|
|
1085
|
+
id, { geometry: { rotation: ((value % 360) + 360) % 360 } });
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const mm = Math.max(1, value * FigureContextBar.UNITS[this.unit()].per);
|
|
1089
|
+
const key = field === "w" ? "w_mm" : "h_mm";
|
|
1090
|
+
const panel = this.state.panel(id);
|
|
1091
|
+
if (panel) {
|
|
1092
|
+
this.handlers.onPanelChange?.(
|
|
1093
|
+
id, { placement: { ...panel.placement, [key]: mm } });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const annotation = this.state.document.annotations[id];
|
|
1097
|
+
if (key === "h_mm" && annotation?.type === "text") {
|
|
1098
|
+
// Typing a height says the same thing dragging the bottom edge
|
|
1099
|
+
// says: this box is this tall. `autofit` recomputes the height from
|
|
1100
|
+
// the text on the very next write, so without clearing it the
|
|
1101
|
+
// number would go in and come straight back out again.
|
|
1102
|
+
this.handlers.onAnnotationChange?.(
|
|
1103
|
+
id, { geometry: { h_mm: mm }, style: { autofit: false } });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
this.handlers.onAnnotationChange?.(id, { geometry: { [key]: mm } });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Put the current numbers back into an open Transform popover.
|
|
1111
|
+
*
|
|
1112
|
+
* Called after every canvas render, so dragging a corner moves the fields.
|
|
1113
|
+
* The focused input is skipped: rewriting the box somebody is typing into
|
|
1114
|
+
* is how "12" becomes "12.0" under the caret half-way through "12.5".
|
|
1115
|
+
*/
|
|
1116
|
+
refreshPopover() {
|
|
1117
|
+
if (!this.popover || this.popover.dataset.act !== "transform") return;
|
|
1118
|
+
const now = this.transformValues();
|
|
1119
|
+
if (!now) return;
|
|
1120
|
+
for (const [field, value] of Object.entries(now)) {
|
|
1121
|
+
if (value === null) continue;
|
|
1122
|
+
const input = this.popover.querySelector(`[data-field="tf_${field}"]`);
|
|
1123
|
+
if (input && input !== document.activeElement) input.value = value;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// -- moving the bar out of the way --------------------------------------
|
|
1128
|
+
/**
|
|
1129
|
+
* A press on the bar: either a button that must not steal the focus, or the
|
|
1130
|
+
* start of a drag.
|
|
1131
|
+
*
|
|
1132
|
+
* Pressing a button here keeps focus where it was. This bar acts on a
|
|
1133
|
+
* selection, and while a caption is being typed into, the focus IS that
|
|
1134
|
+
* selection: taking it away blurs the editor, and blur commits and closes
|
|
1135
|
+
* it -- so Insert Symbol would act on an object that stopped being edited
|
|
1136
|
+
* the instant the button went down. Cancelling the pointerdown's default
|
|
1137
|
+
* suppresses the focus change; the click still fires.
|
|
1138
|
+
*/
|
|
1139
|
+
barPointerDown(event) {
|
|
1140
|
+
if (event.target.closest("button")) {
|
|
1141
|
+
event.preventDefault();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (event.target.closest("input, select, label")) return;
|
|
1145
|
+
this.dragStart(event);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Drag the bar itself.
|
|
1150
|
+
*
|
|
1151
|
+
* It sits over the page, and over a caption near the top of a panel it sits
|
|
1152
|
+
* over the thing being edited -- which is the one moment the controls are
|
|
1153
|
+
* most in the way. So it can be picked up by its grip and put somewhere
|
|
1154
|
+
* else, and the displacement is kept while the selection moves around
|
|
1155
|
+
* underneath it.
|
|
1156
|
+
*
|
|
1157
|
+
* An OFFSET rather than an absolute position, so the bar still follows the
|
|
1158
|
+
* selection: a bar parked at fixed coordinates stops being "the controls
|
|
1159
|
+
* for this object" and becomes a floating palette, which is the thing this
|
|
1160
|
+
* replaced. Not persisted either -- it is a way out of a collision on one
|
|
1161
|
+
* page, not a preference.
|
|
1162
|
+
*/
|
|
1163
|
+
dragStart(event) {
|
|
1164
|
+
if (event.button !== 0) return;
|
|
1165
|
+
const from = { x: event.clientX, y: event.clientY };
|
|
1166
|
+
const start = { ...this.offset };
|
|
1167
|
+
this.closePopover();
|
|
1168
|
+
this.el.classList.add("is-dragging");
|
|
1169
|
+
|
|
1170
|
+
const move = (moved) => {
|
|
1171
|
+
this.offset = { x: start.x + (moved.clientX - from.x),
|
|
1172
|
+
y: start.y + (moved.clientY - from.y) };
|
|
1173
|
+
this.position();
|
|
1174
|
+
};
|
|
1175
|
+
const up = () => {
|
|
1176
|
+
window.removeEventListener("pointermove", move);
|
|
1177
|
+
window.removeEventListener("pointerup", up);
|
|
1178
|
+
this.el.classList.remove("is-dragging");
|
|
1179
|
+
};
|
|
1180
|
+
window.addEventListener("pointermove", move);
|
|
1181
|
+
window.addEventListener("pointerup", up);
|
|
1182
|
+
event.preventDefault();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
get moved() {
|
|
1186
|
+
return this.offset.x !== 0 || this.offset.y !== 0;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
resetPosition() {
|
|
1190
|
+
this.offset = { x: 0, y: 0 };
|
|
1191
|
+
this.position();
|
|
1192
|
+
}
|
|
1193
|
+
}
|