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,1034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureCaptureTool - the viewfinder, and turning what is inside it into a panel.
|
|
3
|
+
*
|
|
4
|
+
* The gesture used to be "drag a rectangle, get a panel". That is one action per
|
|
5
|
+
* panel, which is right for one capture and wrong for the thing people actually
|
|
6
|
+
* do: take the same field from four places in a slide. Each drag was a
|
|
7
|
+
* hand-drawn rectangle, so the four panels were four slightly different sizes,
|
|
8
|
+
* and nothing on screen said so until they were side by side in a figure.
|
|
9
|
+
*
|
|
10
|
+
* So the rectangle is now a persistent VIEWFINDER. It is defined once, stays put
|
|
11
|
+
* while capture mode is on, and every shot is taken through it:
|
|
12
|
+
*
|
|
13
|
+
* drag on the image redraw the viewfinder
|
|
14
|
+
* drag inside it move it
|
|
15
|
+
* drag a corner resize it
|
|
16
|
+
* Shift + drag square, in IMAGE pixels -- so a set of panels really is
|
|
17
|
+
* the same physical field, which a square in screen pixels
|
|
18
|
+
* on a non-square-pixel image would not be
|
|
19
|
+
* Space + drag pans, whatever is armed
|
|
20
|
+
* wheel always zooms, never intercepted
|
|
21
|
+
* S / the shutter takes the shot, and ends capture mode
|
|
22
|
+
* Esc cancels a drag, or leaves capture mode
|
|
23
|
+
*
|
|
24
|
+
* The box is held in SCREEN pixels, not image pixels, and that is the whole
|
|
25
|
+
* point of it: pan the image underneath and the frame stays where it is, so the
|
|
26
|
+
* four fields come out the same size. It is converted to image pixels at the
|
|
27
|
+
* moment the shutter fires, which is the only moment the answer matters, and
|
|
28
|
+
* what gets stored is still full-resolution image coordinates.
|
|
29
|
+
*
|
|
30
|
+
* ## Except while it is pinned
|
|
31
|
+
*
|
|
32
|
+
* Going back to a capture locks the frame onto that capture's region (lockOn),
|
|
33
|
+
* and while it is locked the shutter takes THAT region -- the stored numbers,
|
|
34
|
+
* not a fresh reading off the screen. Without the lock, "go back, change the
|
|
35
|
+
* channels, capture it again" gives two panels that are a pixel or two apart
|
|
36
|
+
* and nothing on screen says so.
|
|
37
|
+
*
|
|
38
|
+
* A locked frame FOLLOWS its region, because the lock is on the region and the
|
|
39
|
+
* frame is only how the region is shown: a frame sitting anywhere else would be
|
|
40
|
+
* the tool lying about what the next shot will be. It lets go when the user
|
|
41
|
+
* aims somewhere else by hand, or when the region can no longer be shown as a
|
|
42
|
+
* frame at all -- panned off, zoomed past, shrunk to a speck. That last rule is
|
|
43
|
+
* also what keeps a following frame inside the viewer, so "one frame, four
|
|
44
|
+
* places" survives: the moment the frame stops being locked to anything, it is
|
|
45
|
+
* a screen-anchored viewfinder again.
|
|
46
|
+
*
|
|
47
|
+
* Precedence is settled per event (`event.preventDefaultAction = true`) rather
|
|
48
|
+
* than with `viewer.setMouseNavEnabled(false)`, for the reason roiTools.js
|
|
49
|
+
* documents: the same drag redraws or pans depending on what is held, and a
|
|
50
|
+
* viewer-wide switch cannot express that without eventually being left in the
|
|
51
|
+
* wrong position.
|
|
52
|
+
*
|
|
53
|
+
* ## Where the viewfinder lives
|
|
54
|
+
*
|
|
55
|
+
* As a sibling of #openseadragon inside #openseadragon_wrapper, which is where
|
|
56
|
+
* miniMap.js puts the overview lens and for the same two reasons. OSD binds its
|
|
57
|
+
* mouse tracker to #openseadragon, so a drag on the frame never reaches the
|
|
58
|
+
* viewer and no stopPropagation is needed; and a plain <div> is not a canvas
|
|
59
|
+
* inside the viewer, so the frame can never be baked into the preview the way
|
|
60
|
+
* the old marquee could -- that hazard, and the synchronous clear() that
|
|
61
|
+
* answered it, are both gone.
|
|
62
|
+
*
|
|
63
|
+
* ## The preview
|
|
64
|
+
*
|
|
65
|
+
* What lands on the canvas is a crop of the canvases the viewer has ALREADY
|
|
66
|
+
* composited -- the tile drawer plus every overlay, in stacking order. That is
|
|
67
|
+
* what makes the preview WYSIWYG for free: whatever a plugin drew over the
|
|
68
|
+
* image is in it, without Figure Builder knowing that plugin exists, and no
|
|
69
|
+
* second rendering path can disagree with the first. Viewer chrome is excluded
|
|
70
|
+
* because it is not a canvas inside the viewer.
|
|
71
|
+
*
|
|
72
|
+
* The preview is Tier 0/1 only. Publication export re-reads the source pixels
|
|
73
|
+
* at the requested DPI from the scene snapshot; this raster exists so the panel
|
|
74
|
+
* appears instantly, so reopening a figure is fast, and so a panel whose source
|
|
75
|
+
* has gone still shows something.
|
|
76
|
+
*/
|
|
77
|
+
class FigureCaptureTool {
|
|
78
|
+
|
|
79
|
+
/** Smallest capture worth keeping, in SCREEN pixels on a side. Below this
|
|
80
|
+
* it is a click that slipped, not a region anybody chose. */
|
|
81
|
+
static get MIN_SCREEN_SIZE() { return 12; }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The one key that fires the shutter.
|
|
85
|
+
*
|
|
86
|
+
* Enter was the obvious choice and the wrong one. Enter activates whatever
|
|
87
|
+
* button has focus, and by the time anybody presses it the focus is on the
|
|
88
|
+
* last thing they clicked -- the mode toggle, a thumbnail, "Figure Canvas".
|
|
89
|
+
* So it fired the shutter AND that button, or that button swallowed it and
|
|
90
|
+
* the shutter never fired, depending on which handler ran first and where
|
|
91
|
+
* the user had last put their hand. A bare letter is only ever the document
|
|
92
|
+
* handler's, which is why the dock's mode toggle is one too.
|
|
93
|
+
*/
|
|
94
|
+
static get SHOOT_KEY() { return "s"; }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is this keystroke the shutter?
|
|
98
|
+
*
|
|
99
|
+
* The same two guards the dock's own bare letter needs, for the same
|
|
100
|
+
* reasons: no modifier chord (Cmd-S is save and always will be), and
|
|
101
|
+
* nothing while the user is typing -- otherwise naming a figure "cross
|
|
102
|
+
* sections" takes three photographs.
|
|
103
|
+
*/
|
|
104
|
+
static isShootKey(event) {
|
|
105
|
+
if (!event || event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
106
|
+
if (typeof event.key !== "string") return false;
|
|
107
|
+
return event.key.toLowerCase() === FigureCaptureTool.SHOOT_KEY
|
|
108
|
+
&& !FigureCaptureTool.isTyping();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Longest edge of a preview raster. A preview is not the master, and a
|
|
112
|
+
* hundred of them at full resolution is a document nobody can open. */
|
|
113
|
+
static get MAX_PREVIEW_EDGE() { return 1400; }
|
|
114
|
+
|
|
115
|
+
/** The frame capture mode opens with, as a fraction of the shorter side of
|
|
116
|
+
* the viewer, and its height as a fraction of its width. A frame that is
|
|
117
|
+
* already there is what makes the mode legible in the first second: the
|
|
118
|
+
* user sees what will be taken and adjusts it, rather than reading a hint
|
|
119
|
+
* about a gesture they have not made yet. */
|
|
120
|
+
static get DEFAULT_FRACTION() { return 0.46; }
|
|
121
|
+
static get DEFAULT_RATIO() { return 0.75; }
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A frame in the middle of a viewer of this size.
|
|
125
|
+
*
|
|
126
|
+
* Pure and static so the arithmetic can be checked without a browser --
|
|
127
|
+
* see tests/js/figure_capture_probe.mjs.
|
|
128
|
+
*/
|
|
129
|
+
static defaultBox(bounds) {
|
|
130
|
+
const width = Math.max(0, (bounds && bounds.width) || 0);
|
|
131
|
+
const height = Math.max(0, (bounds && bounds.height) || 0);
|
|
132
|
+
const w = Math.round(Math.min(width, height) * FigureCaptureTool.DEFAULT_FRACTION);
|
|
133
|
+
const h = Math.round(w * FigureCaptureTool.DEFAULT_RATIO);
|
|
134
|
+
return FigureCaptureTool.clampBox({
|
|
135
|
+
x: Math.round((width - w) / 2),
|
|
136
|
+
y: Math.round((height - h) / 2),
|
|
137
|
+
width: w,
|
|
138
|
+
height: h,
|
|
139
|
+
}, bounds);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Keep a frame inside the viewer, and no smaller than a frame can be.
|
|
144
|
+
*
|
|
145
|
+
* Fully inside rather than merely overlapping: the shutter and the corner
|
|
146
|
+
* handles live on the frame, and a frame half off the edge is one the user
|
|
147
|
+
* can see and cannot reach.
|
|
148
|
+
*/
|
|
149
|
+
static clampBox(box, bounds) {
|
|
150
|
+
const limitW = Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, (bounds && bounds.width) || 0);
|
|
151
|
+
const limitH = Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, (bounds && bounds.height) || 0);
|
|
152
|
+
const width = Math.min(Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, box.width), limitW);
|
|
153
|
+
const height = Math.min(Math.max(FigureCaptureTool.MIN_SCREEN_SIZE, box.height), limitH);
|
|
154
|
+
return {
|
|
155
|
+
x: Math.round(Math.min(Math.max(0, box.x), limitW - width)),
|
|
156
|
+
y: Math.round(Math.min(Math.max(0, box.y), limitH - height)),
|
|
157
|
+
width: Math.round(width),
|
|
158
|
+
height: Math.round(height),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The frame moved by a screen delta, still inside the viewer. */
|
|
163
|
+
static moveBox(box, dx, dy, bounds) {
|
|
164
|
+
return FigureCaptureTool.clampBox(
|
|
165
|
+
{ x: box.x + dx, y: box.y + dy, width: box.width, height: box.height }, bounds);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The frame with one corner dragged.
|
|
170
|
+
*
|
|
171
|
+
* The opposite corner is the anchor and does not move, which is what makes
|
|
172
|
+
* a resize feel like a resize; dragging a corner past it stops at the
|
|
173
|
+
* minimum size rather than inverting the frame.
|
|
174
|
+
*/
|
|
175
|
+
static resizeBox(box, corner, dx, dy, bounds) {
|
|
176
|
+
const west = corner === "nw" || corner === "sw";
|
|
177
|
+
const north = corner === "nw" || corner === "ne";
|
|
178
|
+
const right = box.x + box.width;
|
|
179
|
+
const bottom = box.y + box.height;
|
|
180
|
+
|
|
181
|
+
let x = west ? box.x + dx : box.x;
|
|
182
|
+
let y = north ? box.y + dy : box.y;
|
|
183
|
+
let width = west ? right - x : box.width + dx;
|
|
184
|
+
let height = north ? bottom - y : box.height + dy;
|
|
185
|
+
|
|
186
|
+
if (width < FigureCaptureTool.MIN_SCREEN_SIZE) {
|
|
187
|
+
width = FigureCaptureTool.MIN_SCREEN_SIZE;
|
|
188
|
+
if (west) x = right - width;
|
|
189
|
+
}
|
|
190
|
+
if (height < FigureCaptureTool.MIN_SCREEN_SIZE) {
|
|
191
|
+
height = FigureCaptureTool.MIN_SCREEN_SIZE;
|
|
192
|
+
if (north) y = bottom - height;
|
|
193
|
+
}
|
|
194
|
+
return FigureCaptureTool.clampBox({ x: x, y: y, width: width, height: height }, bounds);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
constructor(ctx, options) {
|
|
198
|
+
this.ctx = ctx;
|
|
199
|
+
this.viewer = ctx.viewer?.viewer || null;
|
|
200
|
+
this.onCapture = (options && options.onCapture) || (() => {});
|
|
201
|
+
this.onStateChange = (options && options.onStateChange) || (() => {});
|
|
202
|
+
//: The frame let go of the capture it was locked to. The selection in
|
|
203
|
+
//: the strip and the highlight on the image are the same state as the
|
|
204
|
+
//: lock, so they go with it.
|
|
205
|
+
this.onUnpin = (options && options.onUnpin) || (() => {});
|
|
206
|
+
|
|
207
|
+
//: This plugin's name, for the one question the tool asks core: whether
|
|
208
|
+
//: it is the selected tool. See canRedraw().
|
|
209
|
+
this.toolName = (options && options.toolName) || "figure_builder";
|
|
210
|
+
|
|
211
|
+
this.armed = false;
|
|
212
|
+
this.dragging = false;
|
|
213
|
+
this.spaceHeld = false;
|
|
214
|
+
this.anchor = null;
|
|
215
|
+
//: The viewfinder, in CSS pixels relative to the viewer's canvas -- the
|
|
216
|
+
//: same origin toImage() and grabPreview() work in, so no conversion
|
|
217
|
+
//: sits between what is drawn and what is taken. Kept across a
|
|
218
|
+
//: disarm/arm so "same frame, four places" survives leaving the mode.
|
|
219
|
+
this.box = null;
|
|
220
|
+
//: The frame while a redraw drag is in flight, in IMAGE pixels. Kept in
|
|
221
|
+
//: image space until the drag ends rather than converted at the end, so
|
|
222
|
+
//: a pan or a zoom mid-drag cannot move the region under the pointer.
|
|
223
|
+
this.drawing = null;
|
|
224
|
+
|
|
225
|
+
//: The region the frame is LOCKED to, in full-resolution image pixels,
|
|
226
|
+
//: or null when the frame is aimed freely. A pin makes the shutter take
|
|
227
|
+
//: THIS region rather than whatever the frame happens to be over --
|
|
228
|
+
//: see pinTo().
|
|
229
|
+
this.pinned = null;
|
|
230
|
+
//: While a panel's view is on loan to the viewer, the frame is a
|
|
231
|
+
//: FRAMING outline for that panel rather than a shutter -- see
|
|
232
|
+
//: setFraming.
|
|
233
|
+
this.framing = false;
|
|
234
|
+
this.pinLabel = "";
|
|
235
|
+
this._pinWatch = null;
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
this.element = null;
|
|
239
|
+
this._pointer = null;
|
|
240
|
+
this._handlers = [];
|
|
241
|
+
this._onKeyDown = (event) => this.keyDown(event);
|
|
242
|
+
this._onKeyUp = (event) => this.keyUp(event);
|
|
243
|
+
// Space released outside the window never reaches keyup, so the class
|
|
244
|
+
// it added has to come off here too -- otherwise the frame stays
|
|
245
|
+
// unclickable and nothing on screen explains why.
|
|
246
|
+
this._onBlur = () => {
|
|
247
|
+
this.spaceHeld = false;
|
|
248
|
+
this.element?.classList.remove("is-transparent");
|
|
249
|
+
};
|
|
250
|
+
//: The viewer changes size when the canvas opens beside it, and a frame
|
|
251
|
+
//: that was centred is then hanging off the right-hand edge.
|
|
252
|
+
this._onResize = () => this.reflow();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
get active() {
|
|
256
|
+
return this.armed;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The frame the next shot will be taken through, or null. */
|
|
260
|
+
get viewfinder() {
|
|
261
|
+
return this.armed ? this.box : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// -- lifecycle -------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
arm() {
|
|
267
|
+
if (this.armed || !this.viewer) return;
|
|
268
|
+
this.armed = true;
|
|
269
|
+
|
|
270
|
+
const on = (name, fn) => {
|
|
271
|
+
this.viewer.addHandler(name, fn);
|
|
272
|
+
this._handlers.push([name, fn]);
|
|
273
|
+
};
|
|
274
|
+
on("canvas-press", (event) => this.press(event));
|
|
275
|
+
on("canvas-drag", (event) => this.drag(event));
|
|
276
|
+
on("canvas-drag-end", (event) => this.dragEnd(event));
|
|
277
|
+
|
|
278
|
+
document.addEventListener("keydown", this._onKeyDown);
|
|
279
|
+
document.addEventListener("keyup", this._onKeyUp);
|
|
280
|
+
window.addEventListener("blur", this._onBlur);
|
|
281
|
+
window.addEventListener("resize", this._onResize);
|
|
282
|
+
|
|
283
|
+
if (!this.box) this.box = FigureCaptureTool.defaultBox(this.bounds());
|
|
284
|
+
this.attachFrame();
|
|
285
|
+
this.paint();
|
|
286
|
+
// A pin set while the mode was off can be stale by now -- nothing stops
|
|
287
|
+
// the user panning away from it with the frame gone. Ask before showing
|
|
288
|
+
// a lock that is not one.
|
|
289
|
+
this.checkPin();
|
|
290
|
+
this.setCursor("crosshair");
|
|
291
|
+
this.onStateChange(true);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Stop capturing.
|
|
296
|
+
*
|
|
297
|
+
* Symmetrical with arm() and called on every switch away, because the
|
|
298
|
+
* listeners are on the VIEWER and the DOCUMENT -- neither of which goes
|
|
299
|
+
* away when this panel is hidden. Left attached, a drag meant for another
|
|
300
|
+
* tool would move a viewfinder the user cannot see.
|
|
301
|
+
*
|
|
302
|
+
* The frame's geometry is kept: leaving capture mode to adjust a channel
|
|
303
|
+
* and coming back should not cost the user the frame they set up.
|
|
304
|
+
*/
|
|
305
|
+
disarm() {
|
|
306
|
+
if (!this.armed) return;
|
|
307
|
+
this.armed = false;
|
|
308
|
+
this.dragging = false;
|
|
309
|
+
this.drawing = null;
|
|
310
|
+
this.anchor = null;
|
|
311
|
+
|
|
312
|
+
for (const [name, fn] of this._handlers) this.viewer.removeHandler(name, fn);
|
|
313
|
+
this._handlers = [];
|
|
314
|
+
document.removeEventListener("keydown", this._onKeyDown);
|
|
315
|
+
document.removeEventListener("keyup", this._onKeyUp);
|
|
316
|
+
window.removeEventListener("blur", this._onBlur);
|
|
317
|
+
window.removeEventListener("resize", this._onResize);
|
|
318
|
+
|
|
319
|
+
this.detachFrame();
|
|
320
|
+
this.setCursor("");
|
|
321
|
+
this.onStateChange(false);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
destroy() {
|
|
325
|
+
this.disarm();
|
|
326
|
+
this.unpin(true);
|
|
327
|
+
this.box = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
setCursor(cursor) {
|
|
331
|
+
if (this.viewer?.canvas) this.viewer.canvas.style.cursor = cursor;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// -- the viewfinder --------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Where plugin-owned chrome hangs: a sibling of the OSD element, never a
|
|
337
|
+
* child of it. See the class comment. */
|
|
338
|
+
host() {
|
|
339
|
+
return document.getElementById("openseadragon_wrapper");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The viewer's own drawing area, in CSS pixels. */
|
|
343
|
+
bounds() {
|
|
344
|
+
const rect = this.viewer?.canvas?.getBoundingClientRect?.();
|
|
345
|
+
return rect ? { width: rect.width, height: rect.height } : { width: 0, height: 0 };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** The canvas's top-left corner within the host, so a box in canvas
|
|
349
|
+
* coordinates can be drawn by a sibling of the canvas. */
|
|
350
|
+
offset() {
|
|
351
|
+
const host = this.host();
|
|
352
|
+
const canvas = this.viewer?.canvas;
|
|
353
|
+
if (!host || !canvas?.getBoundingClientRect) return { x: 0, y: 0 };
|
|
354
|
+
const outer = host.getBoundingClientRect();
|
|
355
|
+
const inner = canvas.getBoundingClientRect();
|
|
356
|
+
return { x: inner.left - outer.left, y: inner.top - outer.top };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
attachFrame() {
|
|
360
|
+
const host = this.host();
|
|
361
|
+
if (this.element || !host) return;
|
|
362
|
+
|
|
363
|
+
const frame = document.createElement("div");
|
|
364
|
+
frame.className = "fb-viewfinder";
|
|
365
|
+
frame.innerHTML =
|
|
366
|
+
'<div class="fb-viewfinder-caption"></div>'
|
|
367
|
+
+ ["nw", "ne", "se", "sw"].map((corner) =>
|
|
368
|
+
`<span class="fb-vf-handle fb-vf-${corner}" data-corner="${corner}"></span>`).join("")
|
|
369
|
+
// Icon and key, no word. The button sits inside the frame, over the
|
|
370
|
+
// tissue being judged, and "Capture" written there is one more thing
|
|
371
|
+
// between the user and the picture -- while the key beside the icon
|
|
372
|
+
// is the only place the shortcut is written down at the moment
|
|
373
|
+
// anybody wants it. What the button DOES is on its title and its
|
|
374
|
+
// accessible name, which cost no pixels.
|
|
375
|
+
+ '<button type="button" class="fb-shutter" data-role="shutter">'
|
|
376
|
+
+ '<span class="fas fa-camera" aria-hidden="true"></span>'
|
|
377
|
+
+ '<kbd class="fb-shutter-key">'
|
|
378
|
+
+ FigureCaptureTool.SHOOT_KEY.toUpperCase() + '</kbd>'
|
|
379
|
+
+ '<span class="fb-visually-hidden" data-role="shutterName">Capture</span>'
|
|
380
|
+
+ '</button>';
|
|
381
|
+
|
|
382
|
+
frame.addEventListener("pointerdown", (event) => this.framePointerDown(event));
|
|
383
|
+
frame.addEventListener("pointermove", (event) => this.framePointerMove(event));
|
|
384
|
+
frame.addEventListener("pointerup", (event) => this.framePointerUp(event));
|
|
385
|
+
frame.addEventListener("pointercancel", (event) => this.framePointerUp(event));
|
|
386
|
+
frame.addEventListener("click", (event) => {
|
|
387
|
+
if (event.target.closest('[data-role="shutter"]')) this.shoot();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
host.appendChild(frame);
|
|
391
|
+
this.element = frame;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
detachFrame() {
|
|
395
|
+
this.element?.remove();
|
|
396
|
+
this.element = null;
|
|
397
|
+
this._pointer = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Put the frame where the numbers say it is. */
|
|
401
|
+
paint() {
|
|
402
|
+
if (!this.element) return;
|
|
403
|
+
const box = this.drawing ? this.toScreenRect(this.drawing) : this.box;
|
|
404
|
+
if (!box) return;
|
|
405
|
+
const offset = this.offset();
|
|
406
|
+
this.element.style.left = (box.x + offset.x) + "px";
|
|
407
|
+
this.element.style.top = (box.y + offset.y) + "px";
|
|
408
|
+
this.element.style.width = box.width + "px";
|
|
409
|
+
this.element.style.height = box.height + "px";
|
|
410
|
+
this.element.classList.toggle("is-drawing", Boolean(this.drawing));
|
|
411
|
+
|
|
412
|
+
// A pinned frame is about to take THAT region again rather than
|
|
413
|
+
// whatever the frame is over. It says so in colour, and in the caption
|
|
414
|
+
// naming the capture -- not in the button, which carries no words at
|
|
415
|
+
// all now. The title and the accessible name carry the difference for
|
|
416
|
+
// the two readers who need it spelled out.
|
|
417
|
+
const pinned = Boolean(this.pinned) && !this.drawing;
|
|
418
|
+
this.element.classList.toggle("is-pinned", pinned);
|
|
419
|
+
this.element.classList.toggle("is-framing", this.framing);
|
|
420
|
+
const shutter = this.element.querySelector(".fb-shutter");
|
|
421
|
+
if (shutter) shutter.hidden = this.framing;
|
|
422
|
+
if (shutter && !this.framing) {
|
|
423
|
+
const what = pinned ? "Capture this region again" : "Capture";
|
|
424
|
+
shutter.title = what + " (" + FigureCaptureTool.SHOOT_KEY.toUpperCase() + ")";
|
|
425
|
+
const name = shutter.querySelector('[data-role="shutterName"]');
|
|
426
|
+
if (name) name.textContent = what;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const caption = this.element.querySelector(".fb-viewfinder-caption");
|
|
430
|
+
if (caption) {
|
|
431
|
+
const rect = this.imageRectFor(box);
|
|
432
|
+
const size = rect ? Math.round(rect.w) + " × " + Math.round(rect.h) + " px" : "";
|
|
433
|
+
caption.textContent = pinned && this.pinLabel
|
|
434
|
+
? this.pinLabel + " · " + size
|
|
435
|
+
: size;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Turn the viewfinder into a framing outline, or back into a shutter.
|
|
441
|
+
*
|
|
442
|
+
* The frame does two jobs that look the same and are not. Normally it is a
|
|
443
|
+
* VIEWFINDER: whatever is inside it is what the next capture takes. While a
|
|
444
|
+
* panel's view is on loan to the viewer it is a FRAME: it shows where that
|
|
445
|
+
* panel's edges are, so the user can see what they are reframing, and
|
|
446
|
+
* taking a picture through it would create a second panel of somebody
|
|
447
|
+
* else's scene.
|
|
448
|
+
*
|
|
449
|
+
* So the shutter goes -- the button, and `shoot()` with it -- and only the
|
|
450
|
+
* outline is left. The dock disables its own shutter for the same reason;
|
|
451
|
+
* this is the keyboard's half and the frame's.
|
|
452
|
+
*/
|
|
453
|
+
setFraming(on) {
|
|
454
|
+
this.framing = Boolean(on);
|
|
455
|
+
this.paint();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Re-clamp to a viewer that changed size, and redraw. */
|
|
459
|
+
reflow() {
|
|
460
|
+
if (!this.armed || !this.box) return;
|
|
461
|
+
this.box = FigureCaptureTool.clampBox(this.box, this.bounds());
|
|
462
|
+
this.paint();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
setBox(box) {
|
|
466
|
+
this.unpin();
|
|
467
|
+
this.box = FigureCaptureTool.clampBox(box, this.bounds());
|
|
468
|
+
this.paint();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Put the frame exactly over a region of the IMAGE.
|
|
473
|
+
*
|
|
474
|
+
* The one place the two coordinate systems are deliberately joined. The
|
|
475
|
+
* frame is normally screen-anchored -- pan the image under it and it stays
|
|
476
|
+
* put -- but when the user goes back to a capture they took earlier, "the
|
|
477
|
+
* same field of view" has to mean the same image pixels, not a rectangle of
|
|
478
|
+
* the same size somewhere near them. Landing the frame on the region is
|
|
479
|
+
* what makes a second capture of it pixel-for-pixel concordant with the
|
|
480
|
+
* first, which is the whole reason to go back.
|
|
481
|
+
*
|
|
482
|
+
* Called after the viewer has finished moving, never during: mid-flight the
|
|
483
|
+
* region is somewhere it is only passing through.
|
|
484
|
+
*/
|
|
485
|
+
aimAt(rect) {
|
|
486
|
+
const screenRect = rect ? this.toScreenRect(rect) : null;
|
|
487
|
+
if (!screenRect) return false;
|
|
488
|
+
this.box = FigureCaptureTool.clampBox(screenRect, this.bounds());
|
|
489
|
+
this.paint();
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// -- pinning the frame to a capture ----------------------------------
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* How far the frame may sit from the region it is pinned to, in screen
|
|
497
|
+
* pixels, before the pin counts as broken. One, because the frame is
|
|
498
|
+
* clamped to whole pixels and the projection is not: half a pixel of
|
|
499
|
+
* rounding is not the user going somewhere else.
|
|
500
|
+
*/
|
|
501
|
+
static get PIN_SLACK() { return 1; }
|
|
502
|
+
|
|
503
|
+
/** The viewer events that mean "the picture moved" -- miniMap.js's trio. */
|
|
504
|
+
static get VIEWPORT_EVENTS() { return ["animation", "animation-finish", "resize"]; }
|
|
505
|
+
|
|
506
|
+
/** Is this frame ON this region, rather than merely near it? */
|
|
507
|
+
static onTarget(box, screenRect) {
|
|
508
|
+
if (!box || !screenRect) return false;
|
|
509
|
+
const slack = FigureCaptureTool.PIN_SLACK;
|
|
510
|
+
return Math.abs(box.x - screenRect.x) <= slack
|
|
511
|
+
&& Math.abs(box.y - screenRect.y) <= slack
|
|
512
|
+
&& Math.abs(box.width - screenRect.width) <= slack
|
|
513
|
+
&& Math.abs(box.height - screenRect.height) <= slack;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Lock the frame onto a capture's region.
|
|
518
|
+
*
|
|
519
|
+
* Selecting a capture and then pressing the shutter has to give back the
|
|
520
|
+
* SAME region rather than a fresh one near it -- that is what makes a
|
|
521
|
+
* second panel of a field comparable with the first. So while the frame is
|
|
522
|
+
* pinned the shutter reads the pin, and the frame stops being whatever
|
|
523
|
+
* rectangle the user happened to leave lying about.
|
|
524
|
+
*
|
|
525
|
+
* The pin holds through every rendering change -- channel, window, colour,
|
|
526
|
+
* overlay -- which is the entire reason to go back to a region. It also
|
|
527
|
+
* holds while the viewer MOVES: the lock is on a region, and the frame is
|
|
528
|
+
* only how that region is shown, so the frame follows it. It lets go when
|
|
529
|
+
* the region can no longer be shown as a frame at all -- panned off the
|
|
530
|
+
* edge, zoomed past, or shrunk to a speck -- which is also what stops a
|
|
531
|
+
* following frame from drifting outside the viewer and breaking "one frame,
|
|
532
|
+
* four places". See checkPin().
|
|
533
|
+
*
|
|
534
|
+
* Locks what the frame is ALREADY on, and never moves it. aimAt() is how
|
|
535
|
+
* the frame gets there; keeping the two apart is what lets a capture that
|
|
536
|
+
* has just been taken lock for free -- the frame is on it already -- while
|
|
537
|
+
* a frame the user set up is never quietly resized to the region that was
|
|
538
|
+
* actually saved when it hung over the edge of the slide.
|
|
539
|
+
*
|
|
540
|
+
* So it is refused whenever the frame is not on the region: because the
|
|
541
|
+
* region is off screen, larger than the viewer, or projects smaller than a
|
|
542
|
+
* frame can be -- all of which leave clampBox holding the frame somewhere
|
|
543
|
+
* the region is not, and a lock pointing at the wrong tissue is worse than
|
|
544
|
+
* no lock at all. Returns whether it took.
|
|
545
|
+
*/
|
|
546
|
+
pinTo(rect, label) {
|
|
547
|
+
this.unpin(true);
|
|
548
|
+
if (!rect || !this.box) return false;
|
|
549
|
+
if (!FigureCaptureTool.onTarget(this.box, this.toScreenRect(rect))) return false;
|
|
550
|
+
|
|
551
|
+
this.pinned = { ...rect };
|
|
552
|
+
this.pinLabel = label || "";
|
|
553
|
+
this.watchPin();
|
|
554
|
+
this.paint();
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Let go of the region.
|
|
560
|
+
*
|
|
561
|
+
* `silent` is for the one caller that is on its way to pinning somewhere
|
|
562
|
+
* else: the viewer moves to get there, and an unpin that announced itself
|
|
563
|
+
* would clear the very selection being made.
|
|
564
|
+
*/
|
|
565
|
+
unpin(silent) {
|
|
566
|
+
if (!this.pinned) return;
|
|
567
|
+
this.pinned = null;
|
|
568
|
+
this.pinLabel = "";
|
|
569
|
+
this.stopWatchingPin();
|
|
570
|
+
this.paint();
|
|
571
|
+
if (!silent) this.onUnpin();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Put the frame on a region and lock the shutter to it, in one step.
|
|
576
|
+
*
|
|
577
|
+
* Going back to a capture wants both, and wants them atomically. Done as
|
|
578
|
+
* aimAt-then-pinTo by the caller, the pin was judged against a projection
|
|
579
|
+
* read a moment after the aim -- and OpenSeadragon is still settling for a
|
|
580
|
+
* while after it says it has finished moving, so the two readings differed
|
|
581
|
+
* by a few pixels, the lock was refused, and the capture the user had just
|
|
582
|
+
* clicked came back unselected. One reading, used for both.
|
|
583
|
+
*
|
|
584
|
+
* Refused when the region cannot be shown as a frame: off screen, larger
|
|
585
|
+
* than the viewer, or smaller than a frame can be. clampBox moving the
|
|
586
|
+
* rectangle is the test for all three.
|
|
587
|
+
*/
|
|
588
|
+
lockOn(rect, label) {
|
|
589
|
+
const screenRect = rect ? this.toScreenRect(rect) : null;
|
|
590
|
+
if (!screenRect) return false;
|
|
591
|
+
const box = FigureCaptureTool.clampBox(screenRect, this.bounds());
|
|
592
|
+
if (!FigureCaptureTool.onTarget(box, screenRect)) return false;
|
|
593
|
+
|
|
594
|
+
this.unpin(true);
|
|
595
|
+
this.box = box;
|
|
596
|
+
this.pinned = { ...rect };
|
|
597
|
+
this.pinLabel = label || "";
|
|
598
|
+
this.watchPin();
|
|
599
|
+
this.paint();
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The viewer moved. Keep the frame on the region, or let the region go.
|
|
605
|
+
*
|
|
606
|
+
* Following rather than releasing, because the lock is on a REGION: while a
|
|
607
|
+
* capture is selected the shutter takes that region, and a frame sitting
|
|
608
|
+
* anywhere else is the tool lying about what the next shot will be. It also
|
|
609
|
+
* makes the lock survive the movement the user did not ask for and cannot
|
|
610
|
+
* see -- OpenSeadragon's own settling after a flight, a nudge, a resize --
|
|
611
|
+
* which is what made "click a capture and it comes back unselected" happen
|
|
612
|
+
* at all.
|
|
613
|
+
*
|
|
614
|
+
* Releasing when the region can no longer be framed is what keeps the frame
|
|
615
|
+
* inside the viewer: #openseadragon_wrapper does not clip, so a frame that
|
|
616
|
+
* chased the tissue without this would end up drawn over the sidebar. The
|
|
617
|
+
* frame then stays exactly where it last sat, unlocked, and "one frame, four
|
|
618
|
+
* places" is back.
|
|
619
|
+
*/
|
|
620
|
+
checkPin() {
|
|
621
|
+
if (!this.pinned || this.dragging) return;
|
|
622
|
+
const screenRect = this.toScreenRect(this.pinned);
|
|
623
|
+
if (FigureCaptureTool.onTarget(this.box, screenRect)) return;
|
|
624
|
+
if (!screenRect) {
|
|
625
|
+
this.unpin();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const box = FigureCaptureTool.clampBox(screenRect, this.bounds());
|
|
629
|
+
if (!FigureCaptureTool.onTarget(box, screenRect)) {
|
|
630
|
+
this.unpin();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.box = box;
|
|
634
|
+
this.paint();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Watched only while something is pinned, and hung on the viewer rather
|
|
638
|
+
* than on arm/disarm: a pin survives leaving capture mode, so that coming
|
|
639
|
+
* back to it finds the frame still on the region. */
|
|
640
|
+
watchPin() {
|
|
641
|
+
if (this._pinWatch || !this.viewer) return;
|
|
642
|
+
const check = () => this.checkPin();
|
|
643
|
+
this._pinWatch = check;
|
|
644
|
+
for (const name of FigureCaptureTool.VIEWPORT_EVENTS) {
|
|
645
|
+
this.viewer.addHandler(name, check);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
stopWatchingPin() {
|
|
650
|
+
if (!this._pinWatch || !this.viewer) return;
|
|
651
|
+
for (const name of FigureCaptureTool.VIEWPORT_EVENTS) {
|
|
652
|
+
this.viewer.removeHandler(name, this._pinWatch);
|
|
653
|
+
}
|
|
654
|
+
this._pinWatch = null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// -- moving and resizing the frame -----------------------------------
|
|
658
|
+
|
|
659
|
+
framePointerDown(event) {
|
|
660
|
+
if (!this.armed || this.spaceHeld || !this.box) return;
|
|
661
|
+
if (event.target.closest('[data-role="shutter"]')) return;
|
|
662
|
+
const corner = event.target.closest(".fb-vf-handle")?.dataset.corner || null;
|
|
663
|
+
this._pointer = {
|
|
664
|
+
id: event.pointerId,
|
|
665
|
+
corner: corner,
|
|
666
|
+
startX: event.clientX,
|
|
667
|
+
startY: event.clientY,
|
|
668
|
+
box: { ...this.box },
|
|
669
|
+
};
|
|
670
|
+
this.element.setPointerCapture?.(event.pointerId);
|
|
671
|
+
event.preventDefault();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
framePointerMove(event) {
|
|
675
|
+
const drag = this._pointer;
|
|
676
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
677
|
+
const dx = event.clientX - drag.startX;
|
|
678
|
+
const dy = event.clientY - drag.startY;
|
|
679
|
+
// Jitter under a finger is not a gesture, and unpinning on it would
|
|
680
|
+
// lose the lock to a press the user does not think they made.
|
|
681
|
+
if (!dx && !dy) return;
|
|
682
|
+
// Moving or resizing the frame by hand is aiming it somewhere else.
|
|
683
|
+
this.unpin();
|
|
684
|
+
const bounds = this.bounds();
|
|
685
|
+
this.box = drag.corner
|
|
686
|
+
? FigureCaptureTool.resizeBox(drag.box, drag.corner, dx, dy, bounds)
|
|
687
|
+
: FigureCaptureTool.moveBox(drag.box, dx, dy, bounds);
|
|
688
|
+
this.paint();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
framePointerUp(event) {
|
|
692
|
+
if (!this._pointer || this._pointer.id !== event.pointerId) return;
|
|
693
|
+
this.element?.releasePointerCapture?.(event.pointerId);
|
|
694
|
+
this._pointer = null;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// -- coordinates -----------------------------------------------------
|
|
698
|
+
|
|
699
|
+
/** An OpenSeadragon point, which viewport.pointFromPixel needs -- it does
|
|
700
|
+
* arithmetic through the point's own methods, so a bare {x, y} throws. */
|
|
701
|
+
point(x, y) {
|
|
702
|
+
if (typeof OpenSeadragon !== "undefined" && OpenSeadragon.Point) {
|
|
703
|
+
return new OpenSeadragon.Point(x, y);
|
|
704
|
+
}
|
|
705
|
+
return { x: x, y: y };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Screen position -> full-resolution image pixel.
|
|
710
|
+
*
|
|
711
|
+
* Through the tile source's own getImagePixel where it exists (viewerManager
|
|
712
|
+
* installs it on every channel), which already accounts for extraZoomLevels.
|
|
713
|
+
* The longhand is the same arithmetic for a source that predates it.
|
|
714
|
+
*/
|
|
715
|
+
toImage(position) {
|
|
716
|
+
const item = this.viewer?.world?.getItemAt(0);
|
|
717
|
+
if (!item) return null;
|
|
718
|
+
if (item.source && typeof item.source.getImagePixel === "function") {
|
|
719
|
+
const [x, y] = item.source.getImagePixel(item, position);
|
|
720
|
+
return [x, y];
|
|
721
|
+
}
|
|
722
|
+
const viewportPoint = this.viewer.viewport.pointFromPixel(position);
|
|
723
|
+
const imagePoint = item.viewportToImageCoordinates(viewportPoint);
|
|
724
|
+
const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
|
|
725
|
+
return [imagePoint.x / scale, imagePoint.y / scale];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Full-resolution image rectangle -> the pixels it occupies on screen. */
|
|
729
|
+
toScreenRect(rect) {
|
|
730
|
+
const item = this.viewer?.world?.getItemAt(0);
|
|
731
|
+
if (!item) return null;
|
|
732
|
+
const scale = 2 ** (this.ctx.config?.extraZoomLevels || 0);
|
|
733
|
+
const viewportRect = item.imageToViewportRectangle(new OpenSeadragon.Rect(
|
|
734
|
+
rect.x * scale, rect.y * scale, rect.w * scale, rect.h * scale));
|
|
735
|
+
const topLeft = this.viewer.viewport.pixelFromPoint(viewportRect.getTopLeft(), true);
|
|
736
|
+
const bottomRight = this.viewer.viewport.pixelFromPoint(viewportRect.getBottomRight(), true);
|
|
737
|
+
return {
|
|
738
|
+
x: topLeft.x,
|
|
739
|
+
y: topLeft.y,
|
|
740
|
+
width: Math.abs(bottomRight.x - topLeft.x),
|
|
741
|
+
height: Math.abs(bottomRight.y - topLeft.y),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* The frame -> the region of the image inside it, trimmed to the image.
|
|
747
|
+
*
|
|
748
|
+
* Both corners are converted rather than one corner plus a scaled size:
|
|
749
|
+
* the second is only right while the viewer's mapping is a plain scale,
|
|
750
|
+
* and it would fail silently the day it is not.
|
|
751
|
+
*/
|
|
752
|
+
imageRectFor(box) {
|
|
753
|
+
if (!box) return null;
|
|
754
|
+
const topLeft = this.toImage(this.point(box.x, box.y));
|
|
755
|
+
const bottomRight = this.toImage(this.point(box.x + box.width, box.y + box.height));
|
|
756
|
+
if (!topLeft || !bottomRight) return null;
|
|
757
|
+
return this.clamp(this.rectBetween(topLeft, bottomRight, false));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Keep a captured region inside the image it was drawn on. */
|
|
761
|
+
clamp(rect) {
|
|
762
|
+
const width = this.ctx.config?.width || 0;
|
|
763
|
+
const height = this.ctx.config?.height || 0;
|
|
764
|
+
if (!width || !height) return rect;
|
|
765
|
+
const x = Math.max(0, Math.min(rect.x, width));
|
|
766
|
+
const y = Math.max(0, Math.min(rect.y, height));
|
|
767
|
+
return {
|
|
768
|
+
x: x,
|
|
769
|
+
y: y,
|
|
770
|
+
w: Math.max(1, Math.min(rect.w, width - x)),
|
|
771
|
+
h: Math.max(1, Math.min(rect.h, height - y)),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// -- redrawing the frame on the image --------------------------------
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* May a drag on bare image redraw the frame right now?
|
|
779
|
+
*
|
|
780
|
+
* Only while Figure Builder is the tool core's shared controls point at.
|
|
781
|
+
* The dock outlives being switched away from -- it is on the image and the
|
|
782
|
+
* session's captures are in it -- so capture mode can be armed while ROI's
|
|
783
|
+
* pen is the selected tool, and one drag would then both draw a region and
|
|
784
|
+
* redraw this frame. Moving and resizing the frame by its own handles work
|
|
785
|
+
* either way: those never reach the viewer at all.
|
|
786
|
+
*
|
|
787
|
+
* Asked of the loader rather than of the other plugins: "am I the selected
|
|
788
|
+
* tool" is a question core already answers, and this file has no business
|
|
789
|
+
* knowing which other tools exist.
|
|
790
|
+
*/
|
|
791
|
+
canRedraw() {
|
|
792
|
+
const selected = window.PlexoraToolLoader?.activeTool?.();
|
|
793
|
+
return !selected || selected === this.toolName;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
press(event) {
|
|
797
|
+
if (!this.armed || this.spaceHeld || !this.canRedraw()) return;
|
|
798
|
+
const origin = this.toImage(event.position);
|
|
799
|
+
if (!origin) return;
|
|
800
|
+
event.preventDefaultAction = true;
|
|
801
|
+
this.dragging = true;
|
|
802
|
+
this.anchor = origin;
|
|
803
|
+
this.drawing = { x: origin[0], y: origin[1], w: 0, h: 0 };
|
|
804
|
+
this.paint();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
drag(event) {
|
|
808
|
+
if (!this.armed) return;
|
|
809
|
+
if (this.spaceHeld || !this.dragging) return; // Space pans, always.
|
|
810
|
+
// A redraw in flight IS the user choosing a new region, and it is the
|
|
811
|
+
// one gesture allowed to take the frame off a capture it was locked to.
|
|
812
|
+
// Here rather than in press(), so a click that never moved does not
|
|
813
|
+
// quietly cost them the lock.
|
|
814
|
+
this.unpin();
|
|
815
|
+
event.preventDefaultAction = true;
|
|
816
|
+
const current = this.toImage(event.position);
|
|
817
|
+
if (!current) return;
|
|
818
|
+
this.drawing = this.rectBetween(this.anchor, current, event.shift);
|
|
819
|
+
this.paint();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
dragEnd(event) {
|
|
823
|
+
if (!this.armed || !this.dragging) return;
|
|
824
|
+
event.preventDefaultAction = true;
|
|
825
|
+
this.dragging = false;
|
|
826
|
+
|
|
827
|
+
const drawn = this.drawing ? this.toScreenRect(this.clamp(this.drawing)) : null;
|
|
828
|
+
this.drawing = null;
|
|
829
|
+
const smallest = FigureCaptureTool.MIN_SCREEN_SIZE;
|
|
830
|
+
if (drawn && drawn.width >= smallest && drawn.height >= smallest) {
|
|
831
|
+
this.box = FigureCaptureTool.clampBox(drawn, this.bounds());
|
|
832
|
+
}
|
|
833
|
+
// Anything smaller is a click that slipped, not a frame anybody drew.
|
|
834
|
+
// The previous frame stands rather than collapsing to a dot -- which
|
|
835
|
+
// would cost the user the frame they had set up in exchange for a
|
|
836
|
+
// gesture they did not mean to make.
|
|
837
|
+
this.paint();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The rectangle between two image points, squared off if Shift is held.
|
|
842
|
+
*
|
|
843
|
+
* Squared in IMAGE pixels rather than screen pixels, so a row of square
|
|
844
|
+
* panels really does show the same physical field. On an image whose pixels
|
|
845
|
+
* are not square on screen the two differ, and the screen answer is the one
|
|
846
|
+
* that produces a figure whose panels quietly disagree about scale.
|
|
847
|
+
*/
|
|
848
|
+
rectBetween(anchor, current, square) {
|
|
849
|
+
let dx = current[0] - anchor[0];
|
|
850
|
+
let dy = current[1] - anchor[1];
|
|
851
|
+
if (square) {
|
|
852
|
+
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
853
|
+
dx = Math.sign(dx || 1) * side;
|
|
854
|
+
dy = Math.sign(dy || 1) * side;
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
x: Math.min(anchor[0], anchor[0] + dx),
|
|
858
|
+
y: Math.min(anchor[1], anchor[1] + dy),
|
|
859
|
+
w: Math.abs(dx),
|
|
860
|
+
h: Math.abs(dy),
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
keyDown(event) {
|
|
865
|
+
if (!this.armed) return;
|
|
866
|
+
if (event.key === "Escape") {
|
|
867
|
+
// A drag first, the mode second: Esc during a redraw should put the
|
|
868
|
+
// frame back, not throw the user out of capture mode as well.
|
|
869
|
+
if (this.dragging || this.drawing) {
|
|
870
|
+
this.dragging = false;
|
|
871
|
+
this.drawing = null;
|
|
872
|
+
this.paint();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
this.disarm();
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (FigureCaptureTool.isShootKey(event)) {
|
|
879
|
+
event.preventDefault();
|
|
880
|
+
this.shoot();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (event.code === "Space" && !this.spaceHeld) {
|
|
884
|
+
// Never mid-drag: pressing Space halfway through a redraw must not
|
|
885
|
+
// turn that redraw into a pan.
|
|
886
|
+
if (this.dragging) return;
|
|
887
|
+
this.spaceHeld = true;
|
|
888
|
+
this.setCursor("grab");
|
|
889
|
+
// The frame would otherwise swallow the pan that starts on top of
|
|
890
|
+
// it, which is most of the area anybody would start a pan in.
|
|
891
|
+
this.element?.classList.add("is-transparent");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
keyUp(event) {
|
|
896
|
+
if (event.code === "Space") {
|
|
897
|
+
this.spaceHeld = false;
|
|
898
|
+
this.element?.classList.remove("is-transparent");
|
|
899
|
+
if (this.armed) this.setCursor("crosshair");
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Is the user typing? Shared with the dock, which has the same question to
|
|
904
|
+
* ask about its own single-letter shortcut. */
|
|
905
|
+
static isTyping() {
|
|
906
|
+
const active = typeof document !== "undefined" ? document.activeElement : null;
|
|
907
|
+
return Boolean(active && (["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName)
|
|
908
|
+
|| active.isContentEditable));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// -- taking the shot -------------------------------------------------
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Capture what is inside the viewfinder.
|
|
915
|
+
*
|
|
916
|
+
* The image rectangle is trimmed to the image and the screen rectangle is
|
|
917
|
+
* then recomputed FROM IT, rather than the frame being cropped one way and
|
|
918
|
+
* the pixels another. A frame hanging over the edge of the slide otherwise
|
|
919
|
+
* produces a preview with a band of background in it and a scene that says
|
|
920
|
+
* there is none -- the two disagreeing about what the panel is.
|
|
921
|
+
*
|
|
922
|
+
* A PINNED frame shoots the region it is pinned to and does not ask the
|
|
923
|
+
* screen at all. Reading it back off the frame would round-trip the region
|
|
924
|
+
* through screen pixels and integer clamping, so the second capture of a
|
|
925
|
+
* field would land a pixel or two from the first -- and "go back, change
|
|
926
|
+
* the channels, capture it again" would be true to the eye and false in the
|
|
927
|
+
* file. Taken from the pin, the two panels' viewports are the same numbers.
|
|
928
|
+
*
|
|
929
|
+
* ## One shot per arming
|
|
930
|
+
*
|
|
931
|
+
* The mode ends here. Capture mode changes what a drag on the image does,
|
|
932
|
+
* so leaving it on after the shot meant the user went back to adjusting the
|
|
933
|
+
* picture -- which is the whole reason to take a second capture -- with the
|
|
934
|
+
* gesture for that still redrawing a viewfinder. Ending it makes the mode as
|
|
935
|
+
* long as the thing it is for, and pressing C again is one keystroke.
|
|
936
|
+
*
|
|
937
|
+
* The frame's geometry and its pin both survive (disarm keeps them), so
|
|
938
|
+
* arming again brings the frame back on the same region, still locked: the
|
|
939
|
+
* capture-adjust-capture loop costs one keystroke a lap and still lands on
|
|
940
|
+
* the same pixels.
|
|
941
|
+
*/
|
|
942
|
+
shoot() {
|
|
943
|
+
if (!this.armed || !this.box) return null;
|
|
944
|
+
// Never while a panel's view is borrowed. A capture taken then would be
|
|
945
|
+
// a NEW panel of somebody else's scene, and nothing on screen would say
|
|
946
|
+
// so; the dock disables its own shutter for the same reason, and this
|
|
947
|
+
// is the keyboard's half of it.
|
|
948
|
+
if (this.framing) return null;
|
|
949
|
+
const rect = this.pinned ? this.clamp(this.pinned) : this.imageRectFor(this.box);
|
|
950
|
+
if (!rect) return null;
|
|
951
|
+
|
|
952
|
+
const screenRect = this.toScreenRect(rect);
|
|
953
|
+
const smallest = FigureCaptureTool.MIN_SCREEN_SIZE;
|
|
954
|
+
if (!screenRect || screenRect.width < smallest || screenRect.height < smallest) {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
const preview = this.previewBlob(screenRect);
|
|
958
|
+
this.onCapture(rect, screenRect, preview);
|
|
959
|
+
// After the capture is recorded, so the pin onCapture sets is put on a
|
|
960
|
+
// frame that still exists. What tells the user it worked is the frame
|
|
961
|
+
// going and the thumbnail arriving in its place -- which is why the
|
|
962
|
+
// shutter blink this used to draw on the frame is gone rather than
|
|
963
|
+
// being drawn on an element about to be removed.
|
|
964
|
+
this.disarm();
|
|
965
|
+
return rect;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Crop what the viewer has already drawn.
|
|
970
|
+
*
|
|
971
|
+
* Every canvas inside the viewer's own element, in DOM order: the tile
|
|
972
|
+
* drawer first, then each overlay. Their backing stores can be at different
|
|
973
|
+
* scales -- an overlay is sized in CSS pixels, the drawer in device pixels
|
|
974
|
+
* -- so each one's crop is computed from its own bounding box rather than
|
|
975
|
+
* from a shared devicePixelRatio, which is what makes this correct on a
|
|
976
|
+
* mixed-DPI setup and on a browser mid-zoom.
|
|
977
|
+
*/
|
|
978
|
+
grabPreview(screenRect) {
|
|
979
|
+
const container = this.viewer?.canvas;
|
|
980
|
+
if (!container) return null;
|
|
981
|
+
const containerRect = container.getBoundingClientRect();
|
|
982
|
+
|
|
983
|
+
const cssWidth = Math.max(1, screenRect.width);
|
|
984
|
+
const cssHeight = Math.max(1, screenRect.height);
|
|
985
|
+
const ratio = window.devicePixelRatio || 1;
|
|
986
|
+
const scale = Math.min(ratio, FigureCaptureTool.MAX_PREVIEW_EDGE
|
|
987
|
+
/ Math.max(cssWidth, cssHeight));
|
|
988
|
+
|
|
989
|
+
const out = document.createElement("canvas");
|
|
990
|
+
out.width = Math.max(1, Math.round(cssWidth * scale));
|
|
991
|
+
out.height = Math.max(1, Math.round(cssHeight * scale));
|
|
992
|
+
const context = out.getContext("2d");
|
|
993
|
+
// Black, not transparent: the viewer composites channels additively over
|
|
994
|
+
// black, and a preview with an alpha hole in it would look like a hole
|
|
995
|
+
// rather than like the dark tissue it is.
|
|
996
|
+
context.fillStyle = "#000000";
|
|
997
|
+
context.fillRect(0, 0, out.width, out.height);
|
|
998
|
+
|
|
999
|
+
for (const element of container.querySelectorAll("canvas")) {
|
|
1000
|
+
const rect = element.getBoundingClientRect();
|
|
1001
|
+
if (!element.width || !element.height || !rect.width || !rect.height) continue;
|
|
1002
|
+
const scaleX = element.width / rect.width;
|
|
1003
|
+
const scaleY = element.height / rect.height;
|
|
1004
|
+
const sx = (screenRect.x - (rect.left - containerRect.left)) * scaleX;
|
|
1005
|
+
const sy = (screenRect.y - (rect.top - containerRect.top)) * scaleY;
|
|
1006
|
+
try {
|
|
1007
|
+
// drawImage clips a source rectangle that runs off the edge and
|
|
1008
|
+
// scales the destination to match, so a capture that overlaps
|
|
1009
|
+
// the edge of an overlay needs no special case.
|
|
1010
|
+
context.drawImage(element, sx, sy, cssWidth * scaleX, cssHeight * scaleY,
|
|
1011
|
+
0, 0, out.width, out.height);
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
// A canvas that cannot be read must not cost the user their
|
|
1014
|
+
// capture: the scene snapshot is the master, and a panel with a
|
|
1015
|
+
// partial preview still re-renders correctly at export.
|
|
1016
|
+
console.error("figure_builder: a layer could not be previewed", error);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** The preview as a WebP blob, or null. */
|
|
1023
|
+
previewBlob(screenRect) {
|
|
1024
|
+
const canvas = this.grabPreview(screenRect);
|
|
1025
|
+
if (!canvas) return Promise.resolve(null);
|
|
1026
|
+
return new Promise((resolve) => {
|
|
1027
|
+
// WebP over PNG: a 600x600 crop of tissue is roughly a tenth the
|
|
1028
|
+
// size, and these are stored in the figure's own database and sent
|
|
1029
|
+
// over the wire on every reopen.
|
|
1030
|
+
canvas.toBlob((blob) => resolve(blob ? { blob: blob, width: canvas.width, height: canvas.height } : null),
|
|
1031
|
+
"image/webp", 0.9);
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|