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,993 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roiTools.js - what the pointer and the keyboard do.
|
|
3
|
+
*
|
|
4
|
+
* The whole file turns on one variable, `this.state`, holding one of:
|
|
5
|
+
*
|
|
6
|
+
* idle.select drawing.polygon editing.vertex
|
|
7
|
+
* panning.temporary drawing.freehand editing.move
|
|
8
|
+
* drawing.rectangle
|
|
9
|
+
*
|
|
10
|
+
* Written as a state machine rather than the obvious set of booleans
|
|
11
|
+
* (isDrawing, isDragging, isMoving...) because those combinations multiply and
|
|
12
|
+
* most of them are meaningless. "Dragging while drawing while panning" has no
|
|
13
|
+
* behaviour anyone intended, but a handler reading three flags has to have one.
|
|
14
|
+
*
|
|
15
|
+
* ## Sharing the pointer with the viewer
|
|
16
|
+
*
|
|
17
|
+
* OpenSeadragon wants the same events this does. The precedence is explicit
|
|
18
|
+
* rather than left to whichever handler happens to run first:
|
|
19
|
+
*
|
|
20
|
+
* wheel always zooms. Never intercepted, at any time -- a
|
|
21
|
+
* drawing tool that breaks zoom on a 100,000px slide
|
|
22
|
+
* is a drawing tool nobody can use.
|
|
23
|
+
* Space + drag always pans, whatever tool is selected.
|
|
24
|
+
* drag on empty image pans (Select tool) -- so navigation still works
|
|
25
|
+
* without switching tools.
|
|
26
|
+
* drag on a shape moves it.
|
|
27
|
+
* drag on a handle moves that vertex.
|
|
28
|
+
* drag with a draw tool draws.
|
|
29
|
+
*
|
|
30
|
+
* Suppression is per event (`event.preventDefaultAction = true`) rather than
|
|
31
|
+
* `viewer.setMouseNavEnabled(false)`, because the decision is per gesture: the
|
|
32
|
+
* same drag pans or draws depending on what is under it and what is held, and a
|
|
33
|
+
* viewer-wide switch cannot express that without being toggled from six places
|
|
34
|
+
* and eventually being left in the wrong position.
|
|
35
|
+
*
|
|
36
|
+
* The gesture is decided at press and does not change mid-drag. Pressing Space
|
|
37
|
+
* halfway through drawing a stroke does not turn that stroke into a pan.
|
|
38
|
+
*/
|
|
39
|
+
class RoiInteraction {
|
|
40
|
+
|
|
41
|
+
constructor(ctx, store, renderer) {
|
|
42
|
+
this.ctx = ctx;
|
|
43
|
+
this.store = store;
|
|
44
|
+
this.renderer = renderer;
|
|
45
|
+
this.viewer = ctx.viewer?.viewer || null;
|
|
46
|
+
|
|
47
|
+
this.tool = "select";
|
|
48
|
+
this.state = "idle.select";
|
|
49
|
+
this.armed = false;
|
|
50
|
+
|
|
51
|
+
this.draftPoints = [];
|
|
52
|
+
this.drag = null; // {id, origin|vertex, before} once a drag is committed
|
|
53
|
+
//: What a press landed on, before it is known whether this gesture is a
|
|
54
|
+
//: click or a drag. Resolved by whichever of the two arrives next.
|
|
55
|
+
this.pending = null;
|
|
56
|
+
this.stateBeforeSpace = null;
|
|
57
|
+
this.spaceHeld = false;
|
|
58
|
+
|
|
59
|
+
//: What the pointer is over, plus the frame handle throttling the moves
|
|
60
|
+
//: that decide it. Kept outside the state machine on purpose: hovering
|
|
61
|
+
//: never edits anything, so giving it states of its own would multiply
|
|
62
|
+
//: the combinations above for no behaviour anyone wanted.
|
|
63
|
+
this.hoverId = null;
|
|
64
|
+
this._hoverGeometry = null;
|
|
65
|
+
this._hoverPosition = null;
|
|
66
|
+
this._hoverFrame = 0;
|
|
67
|
+
this._anchorFrame = 0;
|
|
68
|
+
this._hoverTracker = null;
|
|
69
|
+
this._unsubscribeStore = null;
|
|
70
|
+
|
|
71
|
+
this._handlers = [];
|
|
72
|
+
this._onKeyDown = (event) => this.keyDown(event);
|
|
73
|
+
this._onKeyUp = (event) => this.keyUp(event);
|
|
74
|
+
this._onBlur = () => this.releaseSpace();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//: How close, in SCREEN pixels, counts as "on" a vertex or an edge.
|
|
78
|
+
//: Converted to image pixels per gesture, because a fixed image-pixel
|
|
79
|
+
//: tolerance is unusably tight zoomed out and absurdly loose zoomed in.
|
|
80
|
+
static get GRAB_RADIUS() { return 9; }
|
|
81
|
+
|
|
82
|
+
//: Smallest shape worth keeping, in SCREEN pixels squared. A stray click
|
|
83
|
+
//: sequence makes a region a few pixels across that is invisible, unhittable
|
|
84
|
+
//: and permanent. Measured on screen rather than in image pixels because
|
|
85
|
+
//: there is no such thing as a biologically-too-small region -- only one too
|
|
86
|
+
//: small to have been drawn on purpose at this zoom.
|
|
87
|
+
static get MIN_AREA() { return 24; }
|
|
88
|
+
|
|
89
|
+
//: Freehand simplification tolerance, in screen pixels. Roughly "keep the
|
|
90
|
+
//: shape within a pixel and a half of what was drawn, at the zoom it was
|
|
91
|
+
//: drawn at".
|
|
92
|
+
static get SIMPLIFY_EPSILON() { return 1.5; }
|
|
93
|
+
|
|
94
|
+
// -- lifecycle -------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/** Start listening. Called when the panel is shown, not when the plugin
|
|
97
|
+
* loads: a hidden panel whose shortcuts still fire is a tool acting on a
|
|
98
|
+
* window the user is not looking at. */
|
|
99
|
+
arm() {
|
|
100
|
+
if (this.armed || !this.viewer) return;
|
|
101
|
+
this.armed = true;
|
|
102
|
+
|
|
103
|
+
const on = (name, fn) => {
|
|
104
|
+
this.viewer.addHandler(name, fn);
|
|
105
|
+
this._handlers.push([name, fn]);
|
|
106
|
+
};
|
|
107
|
+
on("canvas-press", (e) => this.press(e));
|
|
108
|
+
on("canvas-drag", (e) => this.dragging(e));
|
|
109
|
+
on("canvas-drag-end", (e) => this.dragEnd(e));
|
|
110
|
+
on("canvas-click", (e) => this.click(e));
|
|
111
|
+
on("canvas-double-click", (e) => this.doubleClick(e));
|
|
112
|
+
|
|
113
|
+
// Whether the tools are usable depends on the world having an image in
|
|
114
|
+
// it, and that is not a fact about the annotations -- so no store change
|
|
115
|
+
// ever announces it. A panel opened while the first tile is still on the
|
|
116
|
+
// way would otherwise render its toolbar disabled and stay that way
|
|
117
|
+
// until some unrelated edit happened to repaint it, which on a project
|
|
118
|
+
// that already has categories is never.
|
|
119
|
+
this._onWorldChange = () => {
|
|
120
|
+
this.applyCursor();
|
|
121
|
+
this.store.changed();
|
|
122
|
+
};
|
|
123
|
+
this.viewer.world?.addHandler("add-item", this._onWorldChange);
|
|
124
|
+
this.viewer.world?.addHandler("remove-item", this._onWorldChange);
|
|
125
|
+
|
|
126
|
+
// Hover is tracked apart from the canvas-* gestures above because OSD
|
|
127
|
+
// does not report a plain move through them, and because a hover is an
|
|
128
|
+
// observation rather than an edit -- it never touches the store.
|
|
129
|
+
this._hoverTracker = new OpenSeadragon.MouseTracker({
|
|
130
|
+
element: this.viewer.canvas,
|
|
131
|
+
moveHandler: (event) => this.pointerMove(event),
|
|
132
|
+
leaveHandler: () => this.clearHover(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// A standing hover survives the picture moving under it, so the anchor
|
|
136
|
+
// that went out with it has to be re-sent -- see reanchorHover.
|
|
137
|
+
on("viewport-change", () => this.viewportMoved());
|
|
138
|
+
|
|
139
|
+
// A hovered ROI can be deleted, hidden or reshaped from the panel while
|
|
140
|
+
// the pointer sits perfectly still, and no pointer event will say so.
|
|
141
|
+
this._unsubscribeStore = this.store.onChange?.(() => this.revalidateHover());
|
|
142
|
+
|
|
143
|
+
document.addEventListener("keydown", this._onKeyDown);
|
|
144
|
+
document.addEventListener("keyup", this._onKeyUp);
|
|
145
|
+
window.addEventListener("blur", this._onBlur);
|
|
146
|
+
|
|
147
|
+
this.renderer.setEnabled(true);
|
|
148
|
+
this.applyCursor();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Stop listening and cancel anything half-drawn.
|
|
152
|
+
*
|
|
153
|
+
* Symmetrical with arm() and called on every switch away, because the
|
|
154
|
+
* listeners above are on the VIEWER and the DOCUMENT -- neither of which
|
|
155
|
+
* goes away when this plugin's panel is hidden. Left attached, ROI would go
|
|
156
|
+
* on drawing over another tool's session and swallowing its keystrokes. */
|
|
157
|
+
disarm() {
|
|
158
|
+
if (!this.armed) return;
|
|
159
|
+
this.armed = false;
|
|
160
|
+
|
|
161
|
+
this.cancelDraft();
|
|
162
|
+
for (const [name, fn] of this._handlers) this.viewer.removeHandler(name, fn);
|
|
163
|
+
this._handlers = [];
|
|
164
|
+
|
|
165
|
+
if (this._onWorldChange) {
|
|
166
|
+
this.viewer.world?.removeHandler("add-item", this._onWorldChange);
|
|
167
|
+
this.viewer.world?.removeHandler("remove-item", this._onWorldChange);
|
|
168
|
+
this._onWorldChange = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
document.removeEventListener("keydown", this._onKeyDown);
|
|
172
|
+
document.removeEventListener("keyup", this._onKeyUp);
|
|
173
|
+
window.removeEventListener("blur", this._onBlur);
|
|
174
|
+
|
|
175
|
+
// clearHover() before the tracker goes, so anything listening for the
|
|
176
|
+
// leave hears it -- a panel that closes with a summary card still
|
|
177
|
+
// floating over the image has left litter nothing else will clean up.
|
|
178
|
+
this.clearHover();
|
|
179
|
+
this._hoverTracker?.destroy?.();
|
|
180
|
+
this._hoverTracker = null;
|
|
181
|
+
this._unsubscribeStore?.();
|
|
182
|
+
this._unsubscribeStore = null;
|
|
183
|
+
|
|
184
|
+
this.releaseSpace();
|
|
185
|
+
this.state = "idle.select";
|
|
186
|
+
this.renderer.setEnabled(false);
|
|
187
|
+
this.setCursor("");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
destroy() {
|
|
191
|
+
this.disarm();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -- coordinates -----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Screen position -> full-resolution image pixel.
|
|
198
|
+
*
|
|
199
|
+
* Through the tile source's own getImagePixel where it exists (viewerManager
|
|
200
|
+
* installs it on every channel), which already accounts for extraZoomLevels
|
|
201
|
+
* -- the pyramid's extra levels, currently 0, so the divide is by 1 today
|
|
202
|
+
* and correct if that ever changes. The longhand below is the same
|
|
203
|
+
* arithmetic for a source that predates it.
|
|
204
|
+
*/
|
|
205
|
+
toImage(position) {
|
|
206
|
+
const item = this.viewer?.world?.getItemAt(0);
|
|
207
|
+
if (!item) return null;
|
|
208
|
+
if (item.source && typeof item.source.getImagePixel === "function") {
|
|
209
|
+
const [x, y] = item.source.getImagePixel(item, position);
|
|
210
|
+
return [x, y];
|
|
211
|
+
}
|
|
212
|
+
const viewportPoint = this.viewer.viewport.pointFromPixel(position);
|
|
213
|
+
const imagePoint = item.viewportToImageCoordinates(viewportPoint);
|
|
214
|
+
const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
|
|
215
|
+
return [imagePoint.x / scale, imagePoint.y / scale];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Screen pixels -> image pixels at the current zoom, for tolerances. */
|
|
219
|
+
imageDistance(screenPixels) {
|
|
220
|
+
try {
|
|
221
|
+
const item = this.viewer.world.getItemAt(0);
|
|
222
|
+
const zoom = item.viewportToImageZoom(this.viewer.viewport.getZoom(true));
|
|
223
|
+
return zoom > 0 ? screenPixels / zoom : screenPixels;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return screenPixels;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Confine a drawn point to the image.
|
|
230
|
+
*
|
|
231
|
+
* Read off config every time rather than cached: `__plexora.refreshDataset`
|
|
232
|
+
* mutates that object in place when the project's read spec changes, and a
|
|
233
|
+
* cached copy would silently go stale. */
|
|
234
|
+
clamp(point) {
|
|
235
|
+
return RoiGeometry.clamp(point, this.ctx.config?.width, this.ctx.config?.height);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Can the pointer do anything on the image right now? */
|
|
239
|
+
get ready() {
|
|
240
|
+
if (!this.store.editable) return false;
|
|
241
|
+
// No active channel means no tiled image, hence no coordinate frame to
|
|
242
|
+
// draw in. Drawing is disabled rather than silently producing garbage.
|
|
243
|
+
return Boolean(this.viewer?.world?.getItemCount());
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Can a NEW shape be made right now?
|
|
248
|
+
*
|
|
249
|
+
* Separate from `ready` because selecting, undoing and deleting must keep
|
|
250
|
+
* working with no categories -- undoing the deletion of the last one is
|
|
251
|
+
* exactly when a user needs them to. A project starts with none (the user
|
|
252
|
+
* names their own), so the draw tools wait for one instead of filing the
|
|
253
|
+
* shape under a label nobody chose.
|
|
254
|
+
*/
|
|
255
|
+
get canDraw() {
|
|
256
|
+
return this.ready && Boolean(this.store.activeCategory);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// -- tools -----------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
setTool(tool) {
|
|
262
|
+
if (this.tool === tool) return;
|
|
263
|
+
this.cancelDraft();
|
|
264
|
+
this.tool = tool;
|
|
265
|
+
this.state = tool === "select" ? "idle.select" : `drawing.${tool}`;
|
|
266
|
+
this.applyCursor();
|
|
267
|
+
this.store.changed();
|
|
268
|
+
if (tool !== "select" && !this.store.activeCategory) this.needCategory();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Say why nothing happens, once, rather than letting clicks vanish. */
|
|
272
|
+
needCategory() {
|
|
273
|
+
this.notify("Add a category first -- every region belongs to one.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
setCursor(cursor) {
|
|
277
|
+
if (this.viewer?.canvas) this.viewer.canvas.style.cursor = cursor;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
applyCursor() {
|
|
281
|
+
if (!this.armed) return;
|
|
282
|
+
if (this.spaceHeld) return this.setCursor("grab");
|
|
283
|
+
if (!this.ready) return this.setCursor("");
|
|
284
|
+
// A crosshair over an image that cannot take a shape is a promise the
|
|
285
|
+
// pointer does not keep.
|
|
286
|
+
this.setCursor(this.tool !== "select" && this.canDraw ? "crosshair" : "default");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// -- pointer ---------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
press(event) {
|
|
292
|
+
if (!this.ready) return;
|
|
293
|
+
if (this.spaceHeld) {
|
|
294
|
+
this.state = "panning.temporary";
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const point = this.toImage(event.position);
|
|
299
|
+
if (!point) return;
|
|
300
|
+
|
|
301
|
+
if (this.tool === "select") {
|
|
302
|
+
// Only RECORD what is under the pointer. Which gesture this is --
|
|
303
|
+
// a click that selects, or a drag that moves -- is not known yet,
|
|
304
|
+
// and committing to "move" here is what made a plain click leave
|
|
305
|
+
// the machine in editing.move: `canvas-click` is guarded on
|
|
306
|
+
// idle.select, so it never ran, and clicking a second shape or
|
|
307
|
+
// clicking empty space to deselect both silently did nothing.
|
|
308
|
+
// The gesture is decided on the first canvas-drag instead.
|
|
309
|
+
this.drag = null;
|
|
310
|
+
this.pending = { hit: this.hitTest(point), point };
|
|
311
|
+
this.state = "idle.select";
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!this.canDraw) {
|
|
316
|
+
// Back to a state a drag cannot act on. setTool put the machine in
|
|
317
|
+
// `drawing.<tool>` optimistically, and the rectangle case reads a
|
|
318
|
+
// drag origin this refused press never set.
|
|
319
|
+
this.state = "idle.select";
|
|
320
|
+
return this.needCategory();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.tool === "freehand") {
|
|
324
|
+
this.state = "drawing.freehand";
|
|
325
|
+
this.draftPoints = [this.clamp(point)];
|
|
326
|
+
this.showDraft("freehand");
|
|
327
|
+
} else if (this.tool === "rectangle") {
|
|
328
|
+
this.state = "drawing.rectangle";
|
|
329
|
+
this.drag = { origin: this.clamp(point) };
|
|
330
|
+
this.draftPoints = [];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
dragging(event) {
|
|
335
|
+
if (!this.armed) return;
|
|
336
|
+
if (this.spaceHeld || this.state === "panning.temporary") return; // viewer pans
|
|
337
|
+
|
|
338
|
+
const point = this.toImage(event.position);
|
|
339
|
+
if (!point) return;
|
|
340
|
+
|
|
341
|
+
// First movement of a press that landed on something: NOW it is a drag,
|
|
342
|
+
// so commit to which kind. A press that never moves stays idle.select
|
|
343
|
+
// and is handled as a click.
|
|
344
|
+
if (this.state === "idle.select" && this.pending) {
|
|
345
|
+
const { hit, point: origin } = this.pending;
|
|
346
|
+
this.pending = null;
|
|
347
|
+
if (hit && hit.vertex) {
|
|
348
|
+
this.state = "editing.vertex";
|
|
349
|
+
this.drag = { id: hit.feature.id, vertex: hit.vertex, before: hit.feature.geometry };
|
|
350
|
+
} else if (hit) {
|
|
351
|
+
this.state = "editing.move";
|
|
352
|
+
this.drag = { id: hit.feature.id, origin, before: hit.feature.geometry };
|
|
353
|
+
}
|
|
354
|
+
// Nothing under the pointer: left alone, so the viewer pans and
|
|
355
|
+
// navigation keeps working without leaving the Select tool.
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Three of the cases below read a gesture the press set up, and the
|
|
359
|
+
// gesture can be cancelled while the button is still down: Esc, a tool
|
|
360
|
+
// shortcut and a re-arm all call cancelDraft, which nulls `drag` but
|
|
361
|
+
// leaves `drawing.rectangle`/`editing.*` in place -- and the mouse goes
|
|
362
|
+
// on sending canvas-drag the whole time. Without this the next movement
|
|
363
|
+
// is a null dereference in a pointer handler.
|
|
364
|
+
const needsGesture = this.state === "editing.vertex"
|
|
365
|
+
|| this.state === "editing.move"
|
|
366
|
+
|| this.state === "drawing.rectangle";
|
|
367
|
+
if (needsGesture && !this.drag) return;
|
|
368
|
+
|
|
369
|
+
switch (this.state) {
|
|
370
|
+
case "editing.vertex": {
|
|
371
|
+
event.preventDefaultAction = true;
|
|
372
|
+
const feature = this.store.feature(this.drag.id);
|
|
373
|
+
if (!feature) return;
|
|
374
|
+
const rings = feature.geometry.coordinates.map((ring) => ring.slice());
|
|
375
|
+
const points = RoiGeometry.openRing(rings[this.drag.vertex.ring]);
|
|
376
|
+
points[this.drag.vertex.index] = this.clamp(point);
|
|
377
|
+
rings[this.drag.vertex.ring] = RoiGeometry.closeRing(points);
|
|
378
|
+
// Local only: the server hears about this once, at drag end.
|
|
379
|
+
feature.geometry = { type: feature.geometry.type, coordinates: rings };
|
|
380
|
+
this.renderer.invalidate(feature.id);
|
|
381
|
+
this.renderer.schedule();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
case "editing.move": {
|
|
385
|
+
event.preventDefaultAction = true;
|
|
386
|
+
const feature = this.store.feature(this.drag.id);
|
|
387
|
+
if (!feature) return;
|
|
388
|
+
feature.geometry = RoiGeometry.translate(
|
|
389
|
+
this.drag.before, point[0] - this.drag.origin[0], point[1] - this.drag.origin[1]);
|
|
390
|
+
this.renderer.invalidate(feature.id);
|
|
391
|
+
this.renderer.schedule();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
case "drawing.freehand": {
|
|
395
|
+
event.preventDefaultAction = true;
|
|
396
|
+
this.draftPoints.push(this.clamp(point));
|
|
397
|
+
this.showDraft("freehand");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
case "drawing.rectangle": {
|
|
401
|
+
event.preventDefaultAction = true;
|
|
402
|
+
const [x0, y0] = this.drag.origin;
|
|
403
|
+
const [x1, y1] = this.clamp(point);
|
|
404
|
+
this.draftPoints = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
|
405
|
+
this.showDraft("rectangle");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
case "drawing.polygon":
|
|
409
|
+
// A polygon is built from clicks; a drag while one is in
|
|
410
|
+
// progress is the user repositioning the view.
|
|
411
|
+
return;
|
|
412
|
+
default:
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
dragEnd(event) {
|
|
418
|
+
if (!this.armed) return;
|
|
419
|
+
|
|
420
|
+
switch (this.state) {
|
|
421
|
+
case "editing.vertex":
|
|
422
|
+
case "editing.move": {
|
|
423
|
+
event.preventDefaultAction = true; // no flick-momentum pan
|
|
424
|
+
const feature = this.store.feature(this.drag?.id);
|
|
425
|
+
const before = this.drag?.before;
|
|
426
|
+
this.drag = null;
|
|
427
|
+
this.state = "idle.select";
|
|
428
|
+
if (!feature || !before) return;
|
|
429
|
+
// One operation, and so one undo step, for a drag of any length.
|
|
430
|
+
this.commitGeometry(feature, before, feature.geometry);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
case "drawing.freehand": {
|
|
434
|
+
event.preventDefaultAction = true;
|
|
435
|
+
this.finishFreehand();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case "drawing.rectangle": {
|
|
439
|
+
event.preventDefaultAction = true;
|
|
440
|
+
const points = this.draftPoints.slice();
|
|
441
|
+
this.drag = null;
|
|
442
|
+
this.hideDraft();
|
|
443
|
+
this.state = "drawing.rectangle";
|
|
444
|
+
this.createFrom(points);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
default:
|
|
448
|
+
this.state = this.tool === "select" ? "idle.select" : `drawing.${this.tool}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
click(event) {
|
|
453
|
+
if (!this.ready || this.spaceHeld) return;
|
|
454
|
+
const point = this.toImage(event.position);
|
|
455
|
+
if (!point) return;
|
|
456
|
+
|
|
457
|
+
if (this.tool === "polygon") {
|
|
458
|
+
if (!this.canDraw) return this.needCategory();
|
|
459
|
+
event.preventDefaultAction = true;
|
|
460
|
+
this.state = "drawing.polygon";
|
|
461
|
+
this.draftPoints.push(this.clamp(point));
|
|
462
|
+
this.showDraft("polygon");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (this.tool === "select" && this.state === "idle.select") {
|
|
467
|
+
// Suppressed so a click to select does not also zoom the viewer.
|
|
468
|
+
// Double-click and the wheel still zoom, which is where the user
|
|
469
|
+
// reaches for zoom anyway.
|
|
470
|
+
event.preventDefaultAction = true;
|
|
471
|
+
// Reuse what the press already found -- same point, same answer --
|
|
472
|
+
// rather than hit-testing the whole image a second time.
|
|
473
|
+
const hit = this.pending ? this.pending.hit : this.hitTest(point);
|
|
474
|
+
this.pending = null;
|
|
475
|
+
this.store.select(hit ? hit.feature.id : null);
|
|
476
|
+
this.renderer.schedule();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
doubleClick(event) {
|
|
481
|
+
if (this.tool === "polygon" && this.draftPoints.length) {
|
|
482
|
+
event.preventDefaultAction = true;
|
|
483
|
+
this.finishPolygon();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// -- hit testing -----------------------------------------------------
|
|
488
|
+
|
|
489
|
+
/** What is under this image-space point: a vertex of the selection first,
|
|
490
|
+
* then a shape. Hidden categories are excluded because they are excluded
|
|
491
|
+
* from `visibleFeatures` -- there is one list, so an invisible shape can
|
|
492
|
+
* never be clicked by accident. */
|
|
493
|
+
hitTest(point) {
|
|
494
|
+
const tolerance = this.imageDistance(RoiInteraction.GRAB_RADIUS);
|
|
495
|
+
const selected = this.store.selected;
|
|
496
|
+
|
|
497
|
+
if (selected && this.store.isVisible(selected) && !this.store.isLocked(selected)
|
|
498
|
+
&& RoiGeometry.isVertexEditable(selected.geometry)) {
|
|
499
|
+
const vertex = RoiGeometry.nearestVertex(
|
|
500
|
+
selected.geometry, point[0], point[1], tolerance);
|
|
501
|
+
if (vertex) return { feature: selected, vertex };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const candidates = this.store.visibleFeatures();
|
|
505
|
+
// Back to front, so the shape drawn on top is the one picked -- which is
|
|
506
|
+
// what the user sees and therefore what they mean.
|
|
507
|
+
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
508
|
+
const feature = candidates[i];
|
|
509
|
+
if (RoiGeometry.containsPoint(feature.geometry, point[0], point[1])
|
|
510
|
+
|| RoiGeometry.distanceToBoundary(feature.geometry, point[0], point[1]) <= tolerance) {
|
|
511
|
+
return { feature, vertex: null };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// -- hover -------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Which ROI the pointer is over, published for whoever wants to say
|
|
521
|
+
* something about it.
|
|
522
|
+
*
|
|
523
|
+
* This plugin deliberately does not know what a composition summary is. It
|
|
524
|
+
* owns geometry, so it answers "which region, and where is it on screen";
|
|
525
|
+
* Cell Explorer's bridge answers "what is inside it". The two CustomEvents
|
|
526
|
+
* below are the entire seam between them -- which is why the payload
|
|
527
|
+
* carries geometry and an anchor rectangle rather than a live feature
|
|
528
|
+
* reference, so neither side ends up reaching into the other's state.
|
|
529
|
+
*
|
|
530
|
+
* Tracked with a MouseTracker rather than another viewer handler because
|
|
531
|
+
* OSD's canvas-* events have no plain move: canvas-drag is a gesture.
|
|
532
|
+
*/
|
|
533
|
+
pointerMove(event) {
|
|
534
|
+
if (!this.armed) return;
|
|
535
|
+
// Mid-gesture there is no such thing as hovering. A committed drag, a
|
|
536
|
+
// half-placed draft or a Space-pan all mean the pointer is busy, and
|
|
537
|
+
// lighting up whatever it crosses would be noise laid over the gesture.
|
|
538
|
+
if (this.drag || this.spaceHeld || this.draftPoints.length) {
|
|
539
|
+
this.clearHover();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!event || !event.position) return;
|
|
543
|
+
this._hoverPosition = event.position;
|
|
544
|
+
// One hit test per frame at most: pointer moves arrive far faster than
|
|
545
|
+
// anything downstream can redraw, and the answer cannot change more
|
|
546
|
+
// often than the picture does.
|
|
547
|
+
if (this._hoverFrame) return;
|
|
548
|
+
this._hoverFrame = requestAnimationFrame(() => {
|
|
549
|
+
this._hoverFrame = 0;
|
|
550
|
+
this.resolveHover();
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
resolveHover() {
|
|
555
|
+
if (!this.armed || !this._hoverPosition) return;
|
|
556
|
+
const point = this.toImage(this._hoverPosition);
|
|
557
|
+
if (!point) return;
|
|
558
|
+
const hit = this.hitTest(point);
|
|
559
|
+
this.setHover(hit ? hit.feature : null);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Enter and leave, deduplicated. Moving around inside one ROI is not a
|
|
563
|
+
* succession of hovers, and re-announcing it every frame is exactly the
|
|
564
|
+
* cursor-chasing an anchored card is meant to avoid. */
|
|
565
|
+
setHover(feature) {
|
|
566
|
+
const id = feature ? feature.id : null;
|
|
567
|
+
if (id === this.hoverId) return;
|
|
568
|
+
if (this.hoverId) this.dispatchUnhover(this.hoverId);
|
|
569
|
+
this.hoverId = id;
|
|
570
|
+
// The renderer already knows how to emphasise a hovered shape: a second
|
|
571
|
+
// stroke over the top, which leaves the saved colour and width alone.
|
|
572
|
+
this.renderer.hoverId = id;
|
|
573
|
+
this.renderer.schedule();
|
|
574
|
+
if (feature) this.dispatchHover(feature);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
clearHover() {
|
|
578
|
+
if (this._hoverFrame) {
|
|
579
|
+
cancelAnimationFrame(this._hoverFrame);
|
|
580
|
+
this._hoverFrame = 0;
|
|
581
|
+
}
|
|
582
|
+
if (this._anchorFrame) {
|
|
583
|
+
cancelAnimationFrame(this._anchorFrame);
|
|
584
|
+
this._anchorFrame = 0;
|
|
585
|
+
}
|
|
586
|
+
this._hoverPosition = null;
|
|
587
|
+
this.setHover(null);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The picture moved while the pointer was inside an ROI.
|
|
592
|
+
*
|
|
593
|
+
* Anchors go out in client pixels, so a pan or a zoom leaves whoever is
|
|
594
|
+
* showing something beside a region pointing at empty image. The obvious
|
|
595
|
+
* answer -- have them close on the first viewport change -- is the wrong
|
|
596
|
+
* one: the pointer has not moved, so no further enter is ever dispatched,
|
|
597
|
+
* and the region has to be left and re-entered before anything can be seen
|
|
598
|
+
* again. That reads as a hover the tool simply missed. Re-announcing the
|
|
599
|
+
* hover with a fresh anchor keeps the two together instead.
|
|
600
|
+
*
|
|
601
|
+
* One dispatch per frame at most, because OSD reports a viewport change on
|
|
602
|
+
* every frame of a spring and a hover cannot change more often than the
|
|
603
|
+
* picture does.
|
|
604
|
+
*/
|
|
605
|
+
viewportMoved() {
|
|
606
|
+
if (!this.hoverId || this._anchorFrame) return;
|
|
607
|
+
this._anchorFrame = requestAnimationFrame(() => {
|
|
608
|
+
this._anchorFrame = 0;
|
|
609
|
+
this.reanchorHover();
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
reanchorHover() {
|
|
614
|
+
if (!this.armed || !this.hoverId) return;
|
|
615
|
+
const before = this.hoverId;
|
|
616
|
+
// Zooming can slide a shape out from under a pointer that never moved,
|
|
617
|
+
// so what is under it is asked again rather than assumed. setHover
|
|
618
|
+
// announces the change itself, anchor included, when there is one.
|
|
619
|
+
if (this._hoverPosition) {
|
|
620
|
+
const point = this.toImage(this._hoverPosition);
|
|
621
|
+
if (point) this.setHover(this.hitTest(point)?.feature || null);
|
|
622
|
+
}
|
|
623
|
+
if (!this.hoverId || this.hoverId !== before) return;
|
|
624
|
+
const feature = this.store.feature(this.hoverId);
|
|
625
|
+
if (feature) this.dispatchHover(feature);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The store changed under a stationary pointer.
|
|
630
|
+
*
|
|
631
|
+
* Deleting, hiding or reshaping an ROI produces no pointer event, so
|
|
632
|
+
* without this a summary card would go on describing a region that has left
|
|
633
|
+
* the screen, or describe the old outline of one that just moved. Geometry
|
|
634
|
+
* objects are replaced rather than mutated on every edit, so object
|
|
635
|
+
* identity is all it takes to tell a reshape from a rename.
|
|
636
|
+
*/
|
|
637
|
+
revalidateHover() {
|
|
638
|
+
if (!this.hoverId) return;
|
|
639
|
+
const feature = this.store.feature(this.hoverId);
|
|
640
|
+
if (!feature || !this.store.isVisible(feature)) {
|
|
641
|
+
const gone = this.hoverId;
|
|
642
|
+
this.hoverId = null;
|
|
643
|
+
this.renderer.hoverId = null;
|
|
644
|
+
this.renderer.schedule();
|
|
645
|
+
this.dispatchUnhover(gone);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (feature.geometry !== this._hoverGeometry) this.dispatchHover(feature);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
dispatchHover(feature) {
|
|
652
|
+
this._hoverGeometry = feature.geometry;
|
|
653
|
+
this.emit("plexora:roi-hover", {
|
|
654
|
+
id: feature.id,
|
|
655
|
+
name: feature.name || "",
|
|
656
|
+
categoryId: feature.category_id === undefined ? null : feature.category_id,
|
|
657
|
+
geometry: feature.geometry,
|
|
658
|
+
anchorRect: this.screenRect(feature.geometry),
|
|
659
|
+
viewportRect: this.canvasRect(),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
dispatchUnhover(id) {
|
|
664
|
+
this._hoverGeometry = null;
|
|
665
|
+
this.emit("plexora:roi-unhover", { id });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
emit(name, detail) {
|
|
669
|
+
try {
|
|
670
|
+
window.dispatchEvent(new CustomEvent(name, { detail }));
|
|
671
|
+
} catch (error) {
|
|
672
|
+
console.error(`roiTools: could not dispatch ${name}`, error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The image canvas in client coordinates, so a listener can keep whatever
|
|
677
|
+
* it puts on screen inside the picture rather than inside the window. */
|
|
678
|
+
canvasRect() {
|
|
679
|
+
const box = this.viewer?.canvas?.getBoundingClientRect?.();
|
|
680
|
+
if (!box) return null;
|
|
681
|
+
return { left: box.left, top: box.top, right: box.right, bottom: box.bottom };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* An ROI's bounding box in CLIENT pixels.
|
|
686
|
+
*
|
|
687
|
+
* Client rather than container coordinates because a listener has no
|
|
688
|
+
* business knowing which element the viewer happens to be mounted in, and
|
|
689
|
+
* because whatever it anchors there can then be positioned outside the
|
|
690
|
+
* viewer's subtree, where no ancestor's overflow can clip it.
|
|
691
|
+
*
|
|
692
|
+
* Taken once, when the ROI is entered -- see setHover.
|
|
693
|
+
*/
|
|
694
|
+
screenRect(geometry) {
|
|
695
|
+
const item = this.viewer?.world?.getItemAt(0);
|
|
696
|
+
const box = geometry ? RoiGeometry.bounds(geometry) : null;
|
|
697
|
+
const canvas = this.canvasRect();
|
|
698
|
+
if (!item || !box || !canvas) return null;
|
|
699
|
+
const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
|
|
700
|
+
const rect = item.imageToViewportRectangle(
|
|
701
|
+
box.minX * scale, box.minY * scale,
|
|
702
|
+
(box.maxX - box.minX) * scale, (box.maxY - box.minY) * scale);
|
|
703
|
+
const topLeft = this.viewer.viewport.pixelFromPoint(
|
|
704
|
+
new OpenSeadragon.Point(rect.x, rect.y), true);
|
|
705
|
+
const bottomRight = this.viewer.viewport.pixelFromPoint(
|
|
706
|
+
new OpenSeadragon.Point(rect.x + rect.width, rect.y + rect.height), true);
|
|
707
|
+
return {
|
|
708
|
+
left: canvas.left + topLeft.x,
|
|
709
|
+
top: canvas.top + topLeft.y,
|
|
710
|
+
right: canvas.left + bottomRight.x,
|
|
711
|
+
bottom: canvas.top + bottomRight.y,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
// -- draft -----------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
showDraft(tool) {
|
|
719
|
+
this.renderer.draft = { points: this.draftPoints, tool };
|
|
720
|
+
this.renderer.schedule();
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
hideDraft() {
|
|
724
|
+
this.renderer.draft = null;
|
|
725
|
+
this.renderer.schedule();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Throw away whatever is half-drawn.
|
|
729
|
+
*
|
|
730
|
+
* An incomplete shape is never stored. Switching tool, pressing Escape,
|
|
731
|
+
* closing the panel or opening another one all land here, and all of them
|
|
732
|
+
* discard rather than "helpfully" completing a polygon the user was still
|
|
733
|
+
* placing points on. */
|
|
734
|
+
cancelDraft() {
|
|
735
|
+
const had = this.draftPoints.length > 0;
|
|
736
|
+
this.draftPoints = [];
|
|
737
|
+
this.drag = null;
|
|
738
|
+
this.pending = null;
|
|
739
|
+
this.hideDraft();
|
|
740
|
+
if (this.state.startsWith("drawing.")) {
|
|
741
|
+
this.state = this.tool === "select" ? "idle.select" : `drawing.${this.tool}`;
|
|
742
|
+
}
|
|
743
|
+
return had;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
finishPolygon() {
|
|
747
|
+
const points = this.draftPoints.slice();
|
|
748
|
+
this.draftPoints = [];
|
|
749
|
+
this.hideDraft();
|
|
750
|
+
this.createFrom(points);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
finishFreehand() {
|
|
754
|
+
const raw = this.draftPoints.slice();
|
|
755
|
+
this.draftPoints = [];
|
|
756
|
+
this.hideDraft();
|
|
757
|
+
this.state = "drawing.freehand";
|
|
758
|
+
|
|
759
|
+
// Simplified once, here, and never again. A stroke arrives as one point
|
|
760
|
+
// per pointer event -- thousands of them, mostly describing sub-pixel
|
|
761
|
+
// wobble along a straight line. Re-simplifying on later edits would
|
|
762
|
+
// erode the shape a little every time it was touched.
|
|
763
|
+
const epsilon = this.imageDistance(RoiInteraction.SIMPLIFY_EPSILON);
|
|
764
|
+
const deduped = RoiGeometry.dedupe(raw, epsilon / 2);
|
|
765
|
+
this.createFrom(RoiGeometry.simplify(deduped, epsilon));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// -- creating and committing ------------------------------------------
|
|
769
|
+
|
|
770
|
+
/** Turn a finished set of points into a stored region, or reject it. */
|
|
771
|
+
createFrom(rawPoints) {
|
|
772
|
+
const points = RoiGeometry.dedupe(rawPoints, 0);
|
|
773
|
+
if (RoiGeometry.distinctCount(points) < 3) {
|
|
774
|
+
// Two clicks and a double-click, or a stray press. Nothing was
|
|
775
|
+
// drawn, so nothing is created -- and nothing is said, because a
|
|
776
|
+
// dialog about a shape the user did not mean to draw is noise.
|
|
777
|
+
this.renderer.schedule();
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const minArea = this.imageDistance(1) ** 2 * RoiInteraction.MIN_AREA;
|
|
782
|
+
if (RoiGeometry.area(points) < minArea) {
|
|
783
|
+
this.notify("That shape was too small to keep.");
|
|
784
|
+
this.renderer.schedule();
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const category = this.store.activeCategory;
|
|
789
|
+
if (!category) {
|
|
790
|
+
// Belt and braces: the draw tools refuse to start without one, so
|
|
791
|
+
// getting here means the category went away mid-draft.
|
|
792
|
+
this.cancelDraft();
|
|
793
|
+
this.needCategory();
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const geometry = RoiGeometry.polygonFrom(points);
|
|
798
|
+
const categoryId = category.id;
|
|
799
|
+
const feature = {
|
|
800
|
+
id: RoiStore.newId("r"),
|
|
801
|
+
category_id: categoryId,
|
|
802
|
+
name: this.nextName(categoryId),
|
|
803
|
+
locked: false,
|
|
804
|
+
geometry,
|
|
805
|
+
flags: { self_intersecting: RoiGeometry.selfIntersects(geometry.coordinates[0]) },
|
|
806
|
+
source_roi_id: null,
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const image = this.store.image;
|
|
810
|
+
this.store.commit({
|
|
811
|
+
label: "Draw ROI",
|
|
812
|
+
redo: [{ op: "roi.create", image, feature }],
|
|
813
|
+
undo: [{ op: "roi.delete", image, id: feature.id }],
|
|
814
|
+
});
|
|
815
|
+
this.store.select(feature.id);
|
|
816
|
+
this.renderer.invalidate(feature.id);
|
|
817
|
+
this.renderer.schedule();
|
|
818
|
+
|
|
819
|
+
if (feature.flags.self_intersecting) {
|
|
820
|
+
this.notify("That outline crosses itself. It has been kept exactly as drawn.");
|
|
821
|
+
}
|
|
822
|
+
return feature;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** "Tumor 3" -- the category's name and the next free number in it. */
|
|
826
|
+
nextName(categoryId) {
|
|
827
|
+
const category = this.store.category(categoryId);
|
|
828
|
+
const base = category ? category.label : "ROI";
|
|
829
|
+
return `${base} ${this.store.countFor(categoryId) + 1}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
commitGeometry(feature, before, after) {
|
|
833
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return;
|
|
834
|
+
const image = this.store.image;
|
|
835
|
+
const flags = {
|
|
836
|
+
self_intersecting: RoiGeometry.isVertexEditable(after)
|
|
837
|
+
? RoiGeometry.selfIntersects(after.coordinates[0])
|
|
838
|
+
: Boolean(feature.flags && feature.flags.self_intersecting),
|
|
839
|
+
};
|
|
840
|
+
// Applied locally already -- the shape followed the cursor. Put it back
|
|
841
|
+
// first so commit()'s own applyLocal is not a no-op that leaves the undo
|
|
842
|
+
// entry describing a change that never appeared to happen.
|
|
843
|
+
feature.geometry = before;
|
|
844
|
+
this.store.commit({
|
|
845
|
+
label: "Edit ROI",
|
|
846
|
+
redo: [{ op: "roi.update_geometry", image, id: feature.id, geometry: after, flags }],
|
|
847
|
+
undo: [{
|
|
848
|
+
op: "roi.update_geometry", image, id: feature.id, geometry: before,
|
|
849
|
+
flags: feature.flags || { self_intersecting: false },
|
|
850
|
+
}],
|
|
851
|
+
});
|
|
852
|
+
this.renderer.invalidate(feature.id);
|
|
853
|
+
this.renderer.schedule();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
deleteSelected() {
|
|
857
|
+
const feature = this.store.selected;
|
|
858
|
+
if (!feature) return false;
|
|
859
|
+
if (this.store.isLocked(feature)) {
|
|
860
|
+
this.notify("That ROI is locked.");
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
const image = this.store.image;
|
|
864
|
+
// No confirmation: deleting one region is a thing users do constantly,
|
|
865
|
+
// and a dialog every time makes annotating miserable. Undo is the safety
|
|
866
|
+
// net, and it is a better one -- it restores the shape rather than
|
|
867
|
+
// asking about it in advance.
|
|
868
|
+
this.store.commit({
|
|
869
|
+
label: "Delete ROI",
|
|
870
|
+
redo: [{ op: "roi.delete", image, id: feature.id }],
|
|
871
|
+
undo: [{ op: "roi.create", image, feature: JSON.parse(JSON.stringify(feature)) }],
|
|
872
|
+
});
|
|
873
|
+
this.renderer.invalidate(feature.id);
|
|
874
|
+
this.renderer.schedule();
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// -- keyboard --------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Whether a keystroke belongs to this tool.
|
|
882
|
+
*
|
|
883
|
+
* The two ways it does not: the user is typing (a category named "Var" must
|
|
884
|
+
* not switch to Rectangle on the R), or this tool's panel is not the one on
|
|
885
|
+
* screen. Neither guard existed anywhere in Plexora before -- ROI is the
|
|
886
|
+
* first thing here to want document-level shortcuts -- so this is the
|
|
887
|
+
* convention rather than a copy of one.
|
|
888
|
+
*/
|
|
889
|
+
acceptsKeys() {
|
|
890
|
+
if (!this.armed || !this.ready) return false;
|
|
891
|
+
const active = document.activeElement;
|
|
892
|
+
if (active) {
|
|
893
|
+
const tag = active.tagName;
|
|
894
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return false;
|
|
895
|
+
if (active.isContentEditable) return false;
|
|
896
|
+
}
|
|
897
|
+
const loader = window.PlexoraToolLoader;
|
|
898
|
+
if (loader && typeof loader.activeTool === "function" && loader.activeTool() !== "roi") {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
keyDown(event) {
|
|
905
|
+
if (!this.acceptsKeys()) return;
|
|
906
|
+
|
|
907
|
+
const meta = event.ctrlKey || event.metaKey;
|
|
908
|
+
if (meta && event.key.toLowerCase() === "z") {
|
|
909
|
+
event.preventDefault();
|
|
910
|
+
if (event.shiftKey) this.store.redo();
|
|
911
|
+
else this.store.undo();
|
|
912
|
+
this.renderer.invalidate();
|
|
913
|
+
this.renderer.schedule();
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (meta && event.key.toLowerCase() === "y") {
|
|
917
|
+
event.preventDefault();
|
|
918
|
+
this.store.redo();
|
|
919
|
+
this.renderer.invalidate();
|
|
920
|
+
this.renderer.schedule();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (meta) return; // leave every other accelerator to the browser
|
|
924
|
+
|
|
925
|
+
switch (event.key) {
|
|
926
|
+
case " ":
|
|
927
|
+
if (!this.spaceHeld) {
|
|
928
|
+
this.spaceHeld = true;
|
|
929
|
+
this.stateBeforeSpace = this.state;
|
|
930
|
+
this.applyCursor();
|
|
931
|
+
}
|
|
932
|
+
event.preventDefault(); // Space scrolls the page otherwise
|
|
933
|
+
return;
|
|
934
|
+
case "Escape":
|
|
935
|
+
// Draft first, then selection: Escape means "back out of the
|
|
936
|
+
// most immediate thing", and a half-drawn polygon is more
|
|
937
|
+
// immediate than a selection made a minute ago.
|
|
938
|
+
if (!this.cancelDraft()) this.store.select(null);
|
|
939
|
+
this.renderer.schedule();
|
|
940
|
+
return;
|
|
941
|
+
case "Enter":
|
|
942
|
+
if (this.tool === "polygon" && this.draftPoints.length) {
|
|
943
|
+
event.preventDefault();
|
|
944
|
+
this.finishPolygon();
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
case "Backspace":
|
|
948
|
+
if (this.state === "drawing.polygon" && this.draftPoints.length) {
|
|
949
|
+
event.preventDefault();
|
|
950
|
+
this.draftPoints.pop();
|
|
951
|
+
this.showDraft("polygon");
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
event.preventDefault();
|
|
955
|
+
this.deleteSelected();
|
|
956
|
+
return;
|
|
957
|
+
case "Delete":
|
|
958
|
+
event.preventDefault();
|
|
959
|
+
this.deleteSelected();
|
|
960
|
+
return;
|
|
961
|
+
default:
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const shortcut = { v: "select", p: "polygon", f: "freehand", r: "rectangle" };
|
|
966
|
+
const tool = shortcut[event.key.toLowerCase()];
|
|
967
|
+
if (tool) {
|
|
968
|
+
event.preventDefault();
|
|
969
|
+
this.setTool(tool);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
keyUp(event) {
|
|
974
|
+
if (event.key === " ") this.releaseSpace();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Also called on window blur: alt-tabbing away while Space is down means
|
|
978
|
+
* the keyup lands in another window, and without this the viewer would be
|
|
979
|
+
* stuck in pan mode with no way to tell why. */
|
|
980
|
+
releaseSpace() {
|
|
981
|
+
if (!this.spaceHeld) return;
|
|
982
|
+
this.spaceHeld = false;
|
|
983
|
+
if (this.state === "panning.temporary") {
|
|
984
|
+
this.state = this.stateBeforeSpace || "idle.select";
|
|
985
|
+
}
|
|
986
|
+
this.stateBeforeSpace = null;
|
|
987
|
+
this.applyCursor();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
notify(message) {
|
|
991
|
+
if (this.onNotify) this.onNotify(message);
|
|
992
|
+
}
|
|
993
|
+
}
|