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,2030 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureWorkspace - the chrome around the canvas.
|
|
3
|
+
*
|
|
4
|
+
* The topbar, the tool rail, the panel tray, the pages and the save status. The
|
|
5
|
+
* canvas itself is FigureCanvas; this file is what surrounds it.
|
|
6
|
+
*
|
|
7
|
+
* ## The canvas is the workspace
|
|
8
|
+
*
|
|
9
|
+
* This used to be rendered in two places -- a figure's own page, and a split
|
|
10
|
+
* pane beside the live viewer -- and the split pane has gone. Composing a
|
|
11
|
+
* figure and looking down a microscope are different activities, and half a
|
|
12
|
+
* window was not enough room for either. So there is one home now, and it gets
|
|
13
|
+
* the whole page.
|
|
14
|
+
*
|
|
15
|
+
* The `options.state` seam is kept even though nothing shares a document today,
|
|
16
|
+
* because the rule it protects has not changed: two FigureDocumentStates on one
|
|
17
|
+
* figure in one tab would hold two revisions of it and conflict with EACH OTHER
|
|
18
|
+
* -- every save from one making the other stale, in the same window, with the
|
|
19
|
+
* user doing nothing wrong. Anything that wants to edit this figure alongside
|
|
20
|
+
* the canvas (Quick Edit) is handed THIS state rather than opening its own.
|
|
21
|
+
*
|
|
22
|
+
* ## Nothing permanent that acts on a selection
|
|
23
|
+
*
|
|
24
|
+
* There is no properties column. Every control that acts on what is selected
|
|
25
|
+
* lives in FigureContextBar, which appears beside the selection and nowhere
|
|
26
|
+
* else; every control that is rarely wanted lives in a menu. What is permanent
|
|
27
|
+
* is only what is always true: which figure this is, which page, how big, and
|
|
28
|
+
* whether it is saved.
|
|
29
|
+
*/
|
|
30
|
+
class FigureWorkspace {
|
|
31
|
+
|
|
32
|
+
/** CSS pixels per millimetre at 100%. 96 dpi / 25.4 mm per inch. */
|
|
33
|
+
static get PX_PER_MM() { return 96 / 25.4; }
|
|
34
|
+
|
|
35
|
+
/** How long the tray has to be before finding something in it is work.
|
|
36
|
+
* Lower than it was: the tiles are two across and their names are behind a
|
|
37
|
+
* hover, so scanning for one by eye stops working sooner. */
|
|
38
|
+
static get SEARCH_THRESHOLD() { return 8; }
|
|
39
|
+
|
|
40
|
+
/** How many projects the Add menu lists before it hands over to the page
|
|
41
|
+
* built for choosing between many. */
|
|
42
|
+
static get ADD_MENU_PROJECTS() { return 8; }
|
|
43
|
+
|
|
44
|
+
/** Where the viewer says it sent the user here from. Written by
|
|
45
|
+
* figureSidebarController, read by the back arrow, and per-tab: two tabs
|
|
46
|
+
* can be on two figures reached two different ways. */
|
|
47
|
+
static get ORIGIN_KEY() { return "plexora:figure-builder-origin"; }
|
|
48
|
+
|
|
49
|
+
/** How long a toast stays. Long enough to read and act on, short enough not
|
|
50
|
+
* to sit over the figure. */
|
|
51
|
+
static get TOAST_MS() { return 9000; }
|
|
52
|
+
|
|
53
|
+
constructor(options) {
|
|
54
|
+
this.api = options.api || new FigureBuilderApi();
|
|
55
|
+
this.figureId = options.figureId;
|
|
56
|
+
//: Passed in when something else already owns the document -- see the
|
|
57
|
+
//: class comment on why sharing it is a correctness matter.
|
|
58
|
+
this.state = options.state
|
|
59
|
+
|| new FigureDocumentState({ api: this.api, figureId: this.figureId });
|
|
60
|
+
this.ownsState = !options.state;
|
|
61
|
+
this.onEditPanel = options.onEditPanel || ((panelId) => this.editPanel(panelId));
|
|
62
|
+
|
|
63
|
+
this.root = document.getElementById("fb_workspace");
|
|
64
|
+
this.canvas = null;
|
|
65
|
+
this.contextBar = null;
|
|
66
|
+
this.tool = "select";
|
|
67
|
+
|
|
68
|
+
//: What the tray has selected, which is NOT what the canvas has
|
|
69
|
+
//: selected: a tray panel is not on the page, so the two selections
|
|
70
|
+
//: cannot be one. Insertion-ordered, which is the order a batch is
|
|
71
|
+
//: placed in.
|
|
72
|
+
this.traySelection = new Set();
|
|
73
|
+
//: The last tray item clicked without a modifier, so Shift has an
|
|
74
|
+
//: anchor to range from.
|
|
75
|
+
this.trayAnchor = null;
|
|
76
|
+
this.traySearch = "";
|
|
77
|
+
//: The menu currently open under a topbar button, if any.
|
|
78
|
+
this.menu = null;
|
|
79
|
+
//: The rich text editor, while a text annotation is being typed into.
|
|
80
|
+
this.textEditor = null;
|
|
81
|
+
//: Fit the page the first time it is drawn. An A4 at 100% is taller
|
|
82
|
+
//: than most windows, so opening at 100% shows the top third of an
|
|
83
|
+
//: empty page and reads as a figure that failed to load.
|
|
84
|
+
this._fitted = false;
|
|
85
|
+
//: Which secondary sidebar has the strip beside the rail -- see
|
|
86
|
+
//: `showSidebar`. "panels" to match the markup, which ships with the
|
|
87
|
+
//: tray open and the text panel hidden; set here rather than applied,
|
|
88
|
+
//: because applying it reads the document and the document has not
|
|
89
|
+
//: arrived yet.
|
|
90
|
+
this.sidebar = "panels";
|
|
91
|
+
//: ...and what to put back when a contextual panel has finished with it.
|
|
92
|
+
this.pinnedSidebar = "panels";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static boot() {
|
|
96
|
+
const root = document.getElementById("fb_workspace");
|
|
97
|
+
if (!root || !root.dataset.figureId) return null;
|
|
98
|
+
const workspace = new FigureWorkspace({ figureId: root.dataset.figureId });
|
|
99
|
+
workspace.setup();
|
|
100
|
+
workspace.state.load();
|
|
101
|
+
return workspace;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
el(id) {
|
|
105
|
+
return document.getElementById(id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
setup() {
|
|
109
|
+
if (!this.root) return;
|
|
110
|
+
this.root.dataset.figureId = this.figureId;
|
|
111
|
+
|
|
112
|
+
this.canvas = new FigureCanvas({
|
|
113
|
+
state: this.state,
|
|
114
|
+
api: this.api,
|
|
115
|
+
figureId: this.figureId,
|
|
116
|
+
pageEl: this.el("fb_page"),
|
|
117
|
+
surfaceEl: this.el("fb_page_surface"),
|
|
118
|
+
guideEl: this.el("fb_page_guides"),
|
|
119
|
+
// Double-clicking a panel reaches for the CHEAP edit now that
|
|
120
|
+
// there is one: Quick Edit opens beside the canvas, where the
|
|
121
|
+
// expensive route is a page load into the viewer.
|
|
122
|
+
onEditPanel: (panelId) => this.quickEditPanel(panelId),
|
|
123
|
+
onSelectionChange: (ids) => this.selectionChanged(ids),
|
|
124
|
+
onGesture: (active) => this.contextBar?.suppress(active),
|
|
125
|
+
onToolFinished: () => this.setTool("select"),
|
|
126
|
+
onEditText: (annotationId) => this.editText(annotationId),
|
|
127
|
+
});
|
|
128
|
+
this.canvas.setup();
|
|
129
|
+
|
|
130
|
+
this.textEditor = new FigureTextEditor({
|
|
131
|
+
overlayEl: this.el("fb_overlay_layer"),
|
|
132
|
+
canvas: this.canvas,
|
|
133
|
+
state: this.state,
|
|
134
|
+
onCommit: (id, rich) => this.updateAnnotation(id, { rich: rich }),
|
|
135
|
+
});
|
|
136
|
+
this.textPanel = new FigureTextPanel({
|
|
137
|
+
root: this.el("fb_text_panel"),
|
|
138
|
+
canvas: this.canvas,
|
|
139
|
+
state: this.state,
|
|
140
|
+
editor: this.textEditor,
|
|
141
|
+
onStyle: (id, changes) => this.updateAnnotation(id, changes),
|
|
142
|
+
onClose: () => this.contextSidebar(false),
|
|
143
|
+
});
|
|
144
|
+
this.textPanel.setup();
|
|
145
|
+
|
|
146
|
+
this.contextBar = new FigureContextBar({
|
|
147
|
+
overlayEl: this.el("fb_overlay_layer"),
|
|
148
|
+
canvas: this.canvas,
|
|
149
|
+
state: this.state,
|
|
150
|
+
handlers: {
|
|
151
|
+
onEditPanel: (panelId) => this.onEditPanel(panelId),
|
|
152
|
+
onQuickEdit: (panelId) => this.quickEditPanel(panelId),
|
|
153
|
+
onArrange: (command) => this.arrange(command),
|
|
154
|
+
onSplit: (mode) => this.split(mode),
|
|
155
|
+
onRemoveFromPage: () => this.canvas.removeSelection(),
|
|
156
|
+
onDeleteFromFigure: (ids) => this.deleteFromFigure(ids),
|
|
157
|
+
onPanelChange: (panelId, changes) => this.updatePanel(panelId, changes),
|
|
158
|
+
onPanelsChange: (updates) => this.updatePanels(updates),
|
|
159
|
+
onSettingsChange: (settings) => this.updateSettings(settings),
|
|
160
|
+
onSetPixelSize: (sourceIds, value) => this.setPixelSize(sourceIds, value),
|
|
161
|
+
onShareLegendColours: (panelIds, colours) =>
|
|
162
|
+
this.shareLegendColours(panelIds, colours),
|
|
163
|
+
onAnnotationChange: (id, changes) => this.updateAnnotation(id, changes),
|
|
164
|
+
onEditText: (annotationId) => this.editText(annotationId),
|
|
165
|
+
onAcceptSource: (sourceId) => this.acceptChangedSource(sourceId),
|
|
166
|
+
onInsertSymbol: (id, glyph) => this.insertSymbol(id, glyph),
|
|
167
|
+
// The figure's unit lives in the View menu, and the Transform
|
|
168
|
+
// popover reads and writes THAT one rather than keeping a
|
|
169
|
+
// second copy of the same setting.
|
|
170
|
+
units: () => this.viewOptions?.prefs.units,
|
|
171
|
+
onUnits: (unit) => this.setUnits(unit),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.contextMenu = new FigureContextMenu({
|
|
176
|
+
state: this.state,
|
|
177
|
+
canvas: this.canvas,
|
|
178
|
+
handlers: {
|
|
179
|
+
// Quick Edit where it can help, the viewer where it cannot: a
|
|
180
|
+
// panel from an imported PNG has no microscopy view to adjust.
|
|
181
|
+
onQuickEdit: (panelId) => this.quickEditPanel(panelId),
|
|
182
|
+
onEditPanel: (panelId) => this.onEditPanel(panelId),
|
|
183
|
+
onEditText: (annotationId) => this.editText(annotationId),
|
|
184
|
+
onDeleteFromFigure: (ids) => this.deleteFromFigure(ids),
|
|
185
|
+
// Both surfaces run the same registry, so the right-click menu
|
|
186
|
+
// needs every handler the floating bar's actions name.
|
|
187
|
+
onRemoveFromPage: () => this.canvas.removeSelection(),
|
|
188
|
+
onPageBackground: () => this.openPageBackground(),
|
|
189
|
+
onDuplicatePage: () => this.duplicatePage(),
|
|
190
|
+
onPlaceFromTray: () => this.placeFromTray(Array.from(this.traySelection)),
|
|
191
|
+
onDuplicateTray: () => this.duplicateTray(),
|
|
192
|
+
onDeleteTray: () => this.deleteFromFigure(Array.from(this.traySelection)),
|
|
193
|
+
onTrayContext: (panelId) => this.trayContext(panelId),
|
|
194
|
+
traySelection: () => Array.from(this.traySelection),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
this.contextMenu.setup(this.el("fb_page_surface"), this.el("fb_tray_strip"));
|
|
198
|
+
|
|
199
|
+
//: Rulers, grid, margins, snapping and the unit -- all of them
|
|
200
|
+
//: statements about how the page is drawn for this person on this
|
|
201
|
+
//: machine, none of them in the document.
|
|
202
|
+
this.viewOptions = new FigureViewOptions({ workspace: this, canvas: this.canvas });
|
|
203
|
+
this.viewOptions.setup();
|
|
204
|
+
|
|
205
|
+
//: The slide-over that edits a panel's microscopy view in place. Handed
|
|
206
|
+
//: THIS document state -- never a second one; see the class comment.
|
|
207
|
+
this.quickEdit = new FigureQuickEdit({
|
|
208
|
+
workspace: this,
|
|
209
|
+
api: this.api,
|
|
210
|
+
state: this.state,
|
|
211
|
+
figureId: this.figureId,
|
|
212
|
+
onOpenInViewer: (panelId) => this.onEditPanel(panelId),
|
|
213
|
+
});
|
|
214
|
+
this.quickEdit.setup();
|
|
215
|
+
|
|
216
|
+
this.contextBar.setup();
|
|
217
|
+
|
|
218
|
+
this.state.on("change", () => this.render());
|
|
219
|
+
this.state.on("status", (payload) => this.renderStatus(payload));
|
|
220
|
+
|
|
221
|
+
this.applyBackLink();
|
|
222
|
+
this.bindTopbar();
|
|
223
|
+
this.bindToolRail();
|
|
224
|
+
this.bindTray();
|
|
225
|
+
this.bindCanvasHost();
|
|
226
|
+
|
|
227
|
+
this.exportUi = new FigureExportUi({
|
|
228
|
+
api: this.api, figureId: this.figureId, state: this.state,
|
|
229
|
+
});
|
|
230
|
+
this.exportUi.setup();
|
|
231
|
+
|
|
232
|
+
// Undo and redo are the application's, not this panel's: there is one
|
|
233
|
+
// Undo, and a second that only worked here would be a second answer to
|
|
234
|
+
// the same keystroke.
|
|
235
|
+
this._onKey = (event) => this.keyDown(event);
|
|
236
|
+
window.addEventListener("keydown", this._onKey);
|
|
237
|
+
this._onResize = () => this.contextBar?.position();
|
|
238
|
+
window.addEventListener("resize", this._onResize);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
destroy() {
|
|
242
|
+
window.removeEventListener("keydown", this._onKey);
|
|
243
|
+
window.removeEventListener("resize", this._onResize);
|
|
244
|
+
this.closeMenu();
|
|
245
|
+
this.contextMenu?.destroy();
|
|
246
|
+
this.viewOptions?.destroy();
|
|
247
|
+
this.quickEdit?.destroy();
|
|
248
|
+
this.contextBar?.destroy();
|
|
249
|
+
this.canvas?.destroy();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -- wiring ------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
bindTopbar() {
|
|
255
|
+
this.el("fb_title")?.addEventListener("change", () => this.commitTitle());
|
|
256
|
+
this.el("fb_title")?.addEventListener("keydown", (event) => {
|
|
257
|
+
// Enter commits and gets out of the way; blur commits too, so a
|
|
258
|
+
// title typed and then clicked away from is not silently discarded.
|
|
259
|
+
if (event.key === "Enter") event.target.blur();
|
|
260
|
+
if (event.key === "Escape") {
|
|
261
|
+
event.target.value = this.state.title;
|
|
262
|
+
event.target.blur();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
this.el("fb_undo")?.addEventListener("click", () => this.state.undo());
|
|
267
|
+
this.el("fb_redo")?.addEventListener("click", () => this.state.redo());
|
|
268
|
+
|
|
269
|
+
this.el("fb_page_select")?.addEventListener("change", (event) => {
|
|
270
|
+
this.canvas.setPage(event.target.value);
|
|
271
|
+
this.contextBar?.update([]);
|
|
272
|
+
});
|
|
273
|
+
this.el("fb_page_menu")?.addEventListener("click", (event) => {
|
|
274
|
+
this.openMenu(event.currentTarget, this.pageMenuEntries(),
|
|
275
|
+
(act) => this.pageAction(act));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
this.el("fb_zoom_in")?.addEventListener("click",
|
|
279
|
+
() => this.setScale(this.canvas.scale * 1.25));
|
|
280
|
+
this.el("fb_zoom_out")?.addEventListener("click",
|
|
281
|
+
() => this.setScale(this.canvas.scale * 0.8));
|
|
282
|
+
this.el("fb_zoom_readout")?.addEventListener("click", (event) => {
|
|
283
|
+
this.openMenu(event.currentTarget, this.zoomMenuEntries(),
|
|
284
|
+
(act) => this.zoomAction(act));
|
|
285
|
+
});
|
|
286
|
+
this.el("fb_zoom_fit")?.addEventListener("click", () => this.zoomToFit());
|
|
287
|
+
|
|
288
|
+
// Under the page rather than in a menu: adding one is the commonest
|
|
289
|
+
// thing anybody does to a figure that has run out of room, and it is
|
|
290
|
+
// the only page action worth a permanent button.
|
|
291
|
+
this.el("fb_page_add")?.addEventListener("click", () => this.addPage());
|
|
292
|
+
|
|
293
|
+
this.el("fb_view_menu")?.addEventListener("click", (event) => {
|
|
294
|
+
this.openMenu(event.currentTarget, this.viewOptions.menuEntries(),
|
|
295
|
+
(act) => this.viewOptions.pick(act));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
this.el("fb_conflict_reload")?.addEventListener("click", () => window.location.reload());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Where the back arrow goes.
|
|
303
|
+
*
|
|
304
|
+
* Two ways in to this page, and they want different ways out. Arriving
|
|
305
|
+
* from a project's viewer, back is that viewer with the tool still open --
|
|
306
|
+
* the commonest trip there is is capture a few fields, look at the figure,
|
|
307
|
+
* go back to the slide for one more, and an arrow that always went to the
|
|
308
|
+
* library made the way back a search. Arriving from the library, back is
|
|
309
|
+
* the library, which is what the markup already says.
|
|
310
|
+
*
|
|
311
|
+
* The note is left by the viewer at the moment it sends the user here. It
|
|
312
|
+
* carries the figure id, so one left over from a different figure is
|
|
313
|
+
* ignored rather than followed back to a slide nobody asked for, and it is
|
|
314
|
+
* NOT consumed: reloading this page should not change where back goes.
|
|
315
|
+
*/
|
|
316
|
+
applyBackLink() {
|
|
317
|
+
const link = this.el("fb_back");
|
|
318
|
+
if (!link) return;
|
|
319
|
+
let note = null;
|
|
320
|
+
try {
|
|
321
|
+
note = JSON.parse(window.sessionStorage.getItem(
|
|
322
|
+
FigureWorkspace.ORIGIN_KEY) || "null");
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return; // private-browsing modes throw rather than answering null
|
|
325
|
+
}
|
|
326
|
+
// A path on this server and nothing else: this value ends up in an
|
|
327
|
+
// href, and sessionStorage is writable by anything else on the origin.
|
|
328
|
+
if (!note || note.figure_id !== this.figureId) return;
|
|
329
|
+
if (typeof note.href !== "string" || !note.href.startsWith("/")
|
|
330
|
+
|| note.href.startsWith("//")) return;
|
|
331
|
+
link.href = note.href;
|
|
332
|
+
const label = note.label ? `Back to ${note.label}` : "Back to the viewer";
|
|
333
|
+
link.title = label;
|
|
334
|
+
link.setAttribute("aria-label", label);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
bindToolRail() {
|
|
338
|
+
this.el("fb_tool_rail")?.addEventListener("click", (event) => {
|
|
339
|
+
const tool = event.target.closest("[data-tool]");
|
|
340
|
+
if (tool) this.setTool(tool.dataset.tool);
|
|
341
|
+
});
|
|
342
|
+
// Split where the controls differ rather than by how they are drawn: a
|
|
343
|
+
// rectangle and an ellipse have a fill and are dragged out as a box, a
|
|
344
|
+
// line and an arrow have two ends and are dragged out along one.
|
|
345
|
+
this.el("fb_tool_shapes")?.addEventListener("click", (event) => {
|
|
346
|
+
this.openMenu(event.currentTarget, [
|
|
347
|
+
{ act: "rect", label: "Rectangle" },
|
|
348
|
+
{ act: "ellipse", label: "Ellipse" },
|
|
349
|
+
], (act) => this.setTool(act));
|
|
350
|
+
});
|
|
351
|
+
this.el("fb_tool_lines")?.addEventListener("click", (event) => {
|
|
352
|
+
this.openMenu(event.currentTarget, [
|
|
353
|
+
{ act: "line", label: "Line" },
|
|
354
|
+
{ act: "arrow", label: "Arrow" },
|
|
355
|
+
], (act) => this.setTool(act));
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The tray, entirely delegated.
|
|
361
|
+
*
|
|
362
|
+
* It is re-rendered on every document change, so handlers bound to its
|
|
363
|
+
* items would be rebound continuously and leak the ones that were replaced.
|
|
364
|
+
*/
|
|
365
|
+
bindTray() {
|
|
366
|
+
const strip = this.el("fb_tray_strip");
|
|
367
|
+
if (!strip) return;
|
|
368
|
+
|
|
369
|
+
strip.addEventListener("pointerdown", (event) => {
|
|
370
|
+
const item = event.target.closest(".fb-tray-item");
|
|
371
|
+
if (item) this.trayPointerDown(item.dataset.panelId, event);
|
|
372
|
+
});
|
|
373
|
+
strip.addEventListener("dragstart", (event) => {
|
|
374
|
+
const item = event.target.closest(".fb-tray-item");
|
|
375
|
+
if (!item) return;
|
|
376
|
+
// The whole tray selection travels if the dragged item is part of
|
|
377
|
+
// it -- picking four panels and then dragging one of them plainly
|
|
378
|
+
// means all four.
|
|
379
|
+
const ids = this.traySelection.has(item.dataset.panelId)
|
|
380
|
+
? Array.from(this.traySelection)
|
|
381
|
+
: [item.dataset.panelId];
|
|
382
|
+
event.dataTransfer.setData("text/x-plexora-panel", JSON.stringify(ids));
|
|
383
|
+
event.dataTransfer.effectAllowed = "move";
|
|
384
|
+
});
|
|
385
|
+
strip.addEventListener("dblclick", (event) => {
|
|
386
|
+
const item = event.target.closest(".fb-tray-item");
|
|
387
|
+
if (item) this.placeFromTray([item.dataset.panelId]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Filters as the characters arrive; nothing is submitted. Esc and the ×
|
|
391
|
+
// are the way back out -- a filter still on from five minutes ago looks
|
|
392
|
+
// exactly like a tray that has lost panels, and the names it would be
|
|
393
|
+
// recognised by are behind a hover now.
|
|
394
|
+
const search = this.el("fb_tray_search");
|
|
395
|
+
search?.addEventListener("input", (event) => {
|
|
396
|
+
this.traySearch = event.target.value || "";
|
|
397
|
+
this.renderTray();
|
|
398
|
+
});
|
|
399
|
+
search?.addEventListener("keydown", (event) => {
|
|
400
|
+
if (event.key !== "Escape" || !this.traySearch) return;
|
|
401
|
+
// Kept off the window: Escape out here also clears the canvas
|
|
402
|
+
// selection, and one key should not do two things at once.
|
|
403
|
+
event.stopPropagation();
|
|
404
|
+
this.clearTraySearch();
|
|
405
|
+
});
|
|
406
|
+
this.el("fb_tray_search_clear")?.addEventListener("click", () => {
|
|
407
|
+
this.clearTraySearch();
|
|
408
|
+
search?.focus();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Two ways in and out of the same card: the rail item that opens it,
|
|
412
|
+
// and the card's own X. Both go through one method so the rail button
|
|
413
|
+
// and the aria state cannot disagree.
|
|
414
|
+
this.el("fb_tool_panels")?.addEventListener("click", () => this.showTray());
|
|
415
|
+
this.el("fb_tray_close")?.addEventListener("click", () => this.showTray(false));
|
|
416
|
+
|
|
417
|
+
// Where the next panels come from. Built when it is opened rather than
|
|
418
|
+
// at boot: the project list is a fetch, and a figure page that never
|
|
419
|
+
// adds a panel should never ask the server for one.
|
|
420
|
+
this.el("fb_tray_add")?.addEventListener("click", (event) => {
|
|
421
|
+
this.openAddMenu(event.currentTarget);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// The file input moved here from the rail with the button that opens
|
|
425
|
+
// it: importing a picture is a way of adding a panel, and this is where
|
|
426
|
+
// adding panels lives now.
|
|
427
|
+
this.el("fb_add_image_input")?.addEventListener("change", (event) => {
|
|
428
|
+
const files = Array.from(event.target.files || []);
|
|
429
|
+
// Cleared straight away, or choosing the same file twice in a row
|
|
430
|
+
// fires nothing the second time and reads as a broken button.
|
|
431
|
+
event.target.value = "";
|
|
432
|
+
if (files.length) this.importFiles(files);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// -- the strip beside the rail ------------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* One secondary sidebar at a time.
|
|
440
|
+
*
|
|
441
|
+
* The tray and the text panel occupy the SAME strip beside the rail, and
|
|
442
|
+
* they used to hide and show themselves independently: selecting a caption
|
|
443
|
+
* while the tray was open drew the text panel underneath it, where it was
|
|
444
|
+
* invisible and still took every click that landed on the overlap. Two
|
|
445
|
+
* panels each answering "am I open?" for themselves is two answers to a
|
|
446
|
+
* question that has room for one.
|
|
447
|
+
*
|
|
448
|
+
* So the strip has an owner, and it is here. `name` is the panel that
|
|
449
|
+
* should be in it, or null for none.
|
|
450
|
+
*
|
|
451
|
+
* The tray is opened BY HAND and the text panel appears with the selection,
|
|
452
|
+
* which is why the tray is remembered as `pinned` and the text panel is
|
|
453
|
+
* not: a contextual panel borrows the strip while it has something to say
|
|
454
|
+
* and gives it back afterwards, rather than leaving the user to go and
|
|
455
|
+
* reopen the drawer they never closed.
|
|
456
|
+
*/
|
|
457
|
+
showSidebar(name) {
|
|
458
|
+
this.sidebar = name || null;
|
|
459
|
+
if (name !== "text") this.pinnedSidebar = this.sidebar;
|
|
460
|
+
this.applySidebar();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The contextual panel asking for the strip, or handing it back. */
|
|
464
|
+
contextSidebar(wants) {
|
|
465
|
+
this.showSidebar(wants ? "text" : this.pinnedSidebar);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
applySidebar() {
|
|
469
|
+
for (const [name, id] of Object.entries(FigureWorkspace.SIDEBARS)) {
|
|
470
|
+
const el = this.el(id);
|
|
471
|
+
if (el) el.hidden = this.sidebar !== name;
|
|
472
|
+
}
|
|
473
|
+
// The tray's badge only shows while its card is shut, so it depends on
|
|
474
|
+
// what was just decided.
|
|
475
|
+
this.renderTray();
|
|
476
|
+
this.renderRail();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Which element each secondary sidebar is. The single place the two are
|
|
480
|
+
* named together, so adding a third is one line rather than a search. */
|
|
481
|
+
static get SIDEBARS() {
|
|
482
|
+
return { panels: "fb_tray_panel", text: "fb_text_panel" };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The unit the page is measured in, changed from wherever names it. */
|
|
486
|
+
setUnits(unit) {
|
|
487
|
+
this.viewOptions?.setUnit(unit);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Open or close the panel tray.
|
|
492
|
+
*
|
|
493
|
+
* Not remembered between sessions. The tray is where captured panels
|
|
494
|
+
* arrive, and a figure page that opened with it shut would look, on the day
|
|
495
|
+
* it is reopened, like a figure that had lost them.
|
|
496
|
+
*
|
|
497
|
+
* With no argument this toggles, so the rail button is a switch rather than
|
|
498
|
+
* a one-way door -- clicking the lit item is how anybody expects to put it
|
|
499
|
+
* out.
|
|
500
|
+
*
|
|
501
|
+
* Nothing else moves. The card lies over the desk beside the sheet and the
|
|
502
|
+
* canvas keeps its position: the tray used to push the page sideways, which
|
|
503
|
+
* meant looking at your panels moved the thing you were composing.
|
|
504
|
+
*/
|
|
505
|
+
showTray(open) {
|
|
506
|
+
const want = open === undefined ? this.sidebar !== "panels" : !!open;
|
|
507
|
+
this.showSidebar(want ? "panels" : null);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Where the next panels come from.
|
|
512
|
+
*
|
|
513
|
+
* Two routes and one menu. A project is a page load into that project's
|
|
514
|
+
* viewer, with THIS figure remembered as the capture destination first --
|
|
515
|
+
* the same localStorage key the dock over there reads, because "which
|
|
516
|
+
* figure am I capturing into" is one setting and this is plainly the
|
|
517
|
+
* figure the user is looking at. Nothing is written to the figure by
|
|
518
|
+
* opening the menu or by opening a project: a slide somebody looks at and
|
|
519
|
+
* leaves, leaves no trace.
|
|
520
|
+
*
|
|
521
|
+
* An image file is the other kind of thing a figure is made of -- the
|
|
522
|
+
* schematic, the plot that came out of R -- and it goes into this figure
|
|
523
|
+
* without a project at all.
|
|
524
|
+
*/
|
|
525
|
+
async openAddMenu(anchor) {
|
|
526
|
+
const projects = await this.projectList();
|
|
527
|
+
const shown = projects.slice(0, FigureWorkspace.ADD_MENU_PROJECTS);
|
|
528
|
+
const entries = shown.map((project) => ({
|
|
529
|
+
act: "open:" + project.name,
|
|
530
|
+
label: project.name,
|
|
531
|
+
}));
|
|
532
|
+
if (!shown.length) {
|
|
533
|
+
entries.push({ act: "none", label: "No projects on this server",
|
|
534
|
+
disabled: true });
|
|
535
|
+
}
|
|
536
|
+
entries.push({ separator: true });
|
|
537
|
+
// The rest of them on the page built for choosing between many. That
|
|
538
|
+
// page opens a project WITHOUT the tool, so the trip is two clicks
|
|
539
|
+
// longer at the far end -- the figure is remembered either way, so the
|
|
540
|
+
// captures still land here.
|
|
541
|
+
if (projects.length > shown.length) {
|
|
542
|
+
entries.push({ act: "browse", label: "All projects…" });
|
|
543
|
+
}
|
|
544
|
+
entries.push({ act: "file", label: "Image file…" });
|
|
545
|
+
this.openMenu(anchor, entries, (act) => this.addFrom(act));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The projects on this server, most recently opened first.
|
|
550
|
+
*
|
|
551
|
+
* Fetched once: the list changes when somebody creates a project, which is
|
|
552
|
+
* not something that happens while a figure page sits open, and a menu
|
|
553
|
+
* that re-fetched on every click would be a request per glance.
|
|
554
|
+
*/
|
|
555
|
+
async projectList() {
|
|
556
|
+
if (this._projects) return this._projects;
|
|
557
|
+
this._projects = [];
|
|
558
|
+
try {
|
|
559
|
+
const response = await fetch(this.api.url("projects"));
|
|
560
|
+
const data = response.ok ? await response.json() : [];
|
|
561
|
+
if (Array.isArray(data)) {
|
|
562
|
+
this._projects = data
|
|
563
|
+
.filter((project) => project && project.name)
|
|
564
|
+
.sort((a, b) => String(b.lastOpenedAt || "")
|
|
565
|
+
.localeCompare(String(a.lastOpenedAt || "")));
|
|
566
|
+
}
|
|
567
|
+
} catch (error) {
|
|
568
|
+
// The menu still has the image-file route, which needs no server.
|
|
569
|
+
}
|
|
570
|
+
return this._projects;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
addFrom(act) {
|
|
574
|
+
if (act === "file") {
|
|
575
|
+
this.el("fb_add_image_input")?.click();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (act === "browse") {
|
|
579
|
+
this.rememberDestination();
|
|
580
|
+
window.location.href = this.api.url("open_project");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (!act.startsWith("open:")) return;
|
|
584
|
+
this.rememberDestination();
|
|
585
|
+
// ?tool= so the capture dock is on the image when the page lands. The
|
|
586
|
+
// user asked to add panels; arriving at a viewer with no way to
|
|
587
|
+
// capture from it would be most of the way to nothing.
|
|
588
|
+
window.location.href = this.api.url(encodeURIComponent(act.slice(5)))
|
|
589
|
+
+ "?tool=figure_builder";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Point the viewer's capture dock at THIS figure before leaving for it.
|
|
594
|
+
*
|
|
595
|
+
* The key is read off the sidebar controller rather than spelled again
|
|
596
|
+
* here: two copies of a storage key is how a rename turns into captures
|
|
597
|
+
* quietly landing in the figure somebody was working on last week.
|
|
598
|
+
*/
|
|
599
|
+
rememberDestination() {
|
|
600
|
+
try {
|
|
601
|
+
window.localStorage.setItem(
|
|
602
|
+
FigureBuilderSidebarController.STORAGE_KEY, this.figureId);
|
|
603
|
+
} catch (error) {
|
|
604
|
+
/* Private-browsing modes throw. The trip is still worth making --
|
|
605
|
+
the dock asks which figure when it gets there. */
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
bindCanvasHost() {
|
|
610
|
+
// Dropping an image onto the page imports it into THIS FIGURE and
|
|
611
|
+
// nowhere else. Making the user create a project to put a schematic in
|
|
612
|
+
// a figure is exactly the setup step this plugin exists to remove.
|
|
613
|
+
const scroll = this.el("fb_canvas_scroll");
|
|
614
|
+
if (!scroll) return;
|
|
615
|
+
scroll.addEventListener("dragover", (event) => {
|
|
616
|
+
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
|
|
617
|
+
});
|
|
618
|
+
scroll.addEventListener("drop", (event) => this.dropFiles(event));
|
|
619
|
+
// The floating bar is positioned from where the selection is on SCREEN,
|
|
620
|
+
// so scrolling the page moves it.
|
|
621
|
+
scroll.addEventListener("scroll", () => this.contextBar?.position(), { passive: true });
|
|
622
|
+
|
|
623
|
+
// Clicking off the page puts the pointer back to selecting. ON the
|
|
624
|
+
// page an armed tool is USED by the click and hands itself back the
|
|
625
|
+
// moment it has placed something; the desk around the sheet is where a
|
|
626
|
+
// tool armed by mistake is abandoned, and with no Select button in the
|
|
627
|
+
// rail this is the way out that does not need the keyboard.
|
|
628
|
+
//
|
|
629
|
+
// Not while space is held: that is a pan, and the pointer is out here
|
|
630
|
+
// because that is where the hand happened to be.
|
|
631
|
+
scroll.addEventListener("pointerdown", (event) => {
|
|
632
|
+
if (this.spaceHeld || this.tool === "select") return;
|
|
633
|
+
if (event.target.closest("#fb_page")) return;
|
|
634
|
+
this.setTool("select");
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Ctrl/Cmd + wheel zooms about the pointer. A trackpad pinch arrives as
|
|
638
|
+
// exactly this event with ctrlKey set, which is why one handler serves
|
|
639
|
+
// both and neither needs a gesture library.
|
|
640
|
+
scroll.addEventListener("wheel", (event) => {
|
|
641
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
642
|
+
event.preventDefault();
|
|
643
|
+
// Exponential, so a fast scroll and several slow ones of the same
|
|
644
|
+
// total distance land in the same place.
|
|
645
|
+
this.zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.0035));
|
|
646
|
+
}, { passive: false });
|
|
647
|
+
|
|
648
|
+
this.bindSpacePan(scroll);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Hold space to pan.
|
|
653
|
+
*
|
|
654
|
+
* The listener is on the SCROLL container in the capture phase, so it runs
|
|
655
|
+
* before the canvas surface's own pointerdown and can stop it: without
|
|
656
|
+
* that, a space-drag would start a marquee under the hand that is trying to
|
|
657
|
+
* move the page.
|
|
658
|
+
*/
|
|
659
|
+
bindSpacePan(scroll) {
|
|
660
|
+
this._onSpaceDown = (event) => {
|
|
661
|
+
if (event.code !== "Space" || this.spaceHeld) return;
|
|
662
|
+
const typing = document.activeElement
|
|
663
|
+
&& ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
|
|
664
|
+
if (typing) return;
|
|
665
|
+
event.preventDefault();
|
|
666
|
+
this.spaceHeld = true;
|
|
667
|
+
scroll.classList.add("is-pannable");
|
|
668
|
+
};
|
|
669
|
+
this._releaseSpace = () => {
|
|
670
|
+
this.spaceHeld = false;
|
|
671
|
+
this.panning = null;
|
|
672
|
+
scroll.classList.remove("is-pannable", "is-panning");
|
|
673
|
+
};
|
|
674
|
+
this._onSpaceUp = (event) => {
|
|
675
|
+
if (event.code === "Space") this._releaseSpace();
|
|
676
|
+
};
|
|
677
|
+
window.addEventListener("keydown", this._onSpaceDown);
|
|
678
|
+
window.addEventListener("keyup", this._onSpaceUp);
|
|
679
|
+
// A window that loses focus mid-pan never sees the keyup, and would
|
|
680
|
+
// come back still in pan mode with no key held down to explain it.
|
|
681
|
+
window.addEventListener("blur", this._releaseSpace);
|
|
682
|
+
|
|
683
|
+
scroll.addEventListener("pointerdown", (event) => {
|
|
684
|
+
if (!this.spaceHeld || event.button !== 0) return;
|
|
685
|
+
event.preventDefault();
|
|
686
|
+
event.stopPropagation();
|
|
687
|
+
scroll.classList.add("is-panning");
|
|
688
|
+
this.panning = {
|
|
689
|
+
x: event.clientX, y: event.clientY,
|
|
690
|
+
left: scroll.scrollLeft, top: scroll.scrollTop,
|
|
691
|
+
};
|
|
692
|
+
scroll.setPointerCapture?.(event.pointerId);
|
|
693
|
+
}, true);
|
|
694
|
+
|
|
695
|
+
scroll.addEventListener("pointermove", (event) => {
|
|
696
|
+
if (!this.panning) return;
|
|
697
|
+
scroll.scrollLeft = this.panning.left - (event.clientX - this.panning.x);
|
|
698
|
+
scroll.scrollTop = this.panning.top - (event.clientY - this.panning.y);
|
|
699
|
+
});
|
|
700
|
+
const stop = () => {
|
|
701
|
+
if (!this.panning) return;
|
|
702
|
+
this.panning = null;
|
|
703
|
+
scroll.classList.remove("is-panning");
|
|
704
|
+
};
|
|
705
|
+
scroll.addEventListener("pointerup", stop);
|
|
706
|
+
scroll.addEventListener("pointercancel", stop);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// -- tools -------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Arm a rail tool, or put the pointer back to selecting.
|
|
713
|
+
*
|
|
714
|
+
* Tools are one-shot everywhere they place something: the rail disarms
|
|
715
|
+
* itself afterwards, so a lit button always means "the next click on the
|
|
716
|
+
* page does this" rather than a mode somebody has to remember to leave.
|
|
717
|
+
* That is why there is no Select button to go back to -- "select" is the
|
|
718
|
+
* state this page is in unless it has just been told otherwise, and the
|
|
719
|
+
* ways out of a tool are placing something with it, clicking the desk, and
|
|
720
|
+
* Escape.
|
|
721
|
+
*/
|
|
722
|
+
setTool(name) {
|
|
723
|
+
this.tool = name || "select";
|
|
724
|
+
this.canvas.setTool(this.tool);
|
|
725
|
+
this.renderRail();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
renderRail() {
|
|
729
|
+
const rail = this.el("fb_tool_rail");
|
|
730
|
+
rail?.querySelectorAll("[data-tool]").forEach((button) => {
|
|
731
|
+
const on = button.dataset.tool === this.tool;
|
|
732
|
+
button.classList.toggle("is-active", on);
|
|
733
|
+
button.setAttribute("aria-pressed", String(on));
|
|
734
|
+
});
|
|
735
|
+
// The two menu buttons have no data-tool of their own -- each stands
|
|
736
|
+
// for a pair -- so each is lit whenever either of its pair is armed.
|
|
737
|
+
for (const [id, tools] of [["fb_tool_shapes", ["rect", "ellipse"]],
|
|
738
|
+
["fb_tool_lines", ["line", "arrow"]]]) {
|
|
739
|
+
const button = this.el(id);
|
|
740
|
+
if (!button) continue;
|
|
741
|
+
const on = tools.includes(this.tool);
|
|
742
|
+
button.classList.toggle("is-active", on);
|
|
743
|
+
button.setAttribute("aria-pressed", String(on));
|
|
744
|
+
}
|
|
745
|
+
// The tray's rail item is a switch, not a tool: it is lit while its
|
|
746
|
+
// card is open, and the card is the authority on that -- keeping a
|
|
747
|
+
// second copy of "is the tray open" in a field is how the button and
|
|
748
|
+
// the panel end up disagreeing after an undo or a reload.
|
|
749
|
+
const tray = this.el("fb_tray_panel");
|
|
750
|
+
const trayButton = this.el("fb_tool_panels");
|
|
751
|
+
if (tray && trayButton) {
|
|
752
|
+
trayButton.classList.toggle("is-active", !tray.hidden);
|
|
753
|
+
trayButton.setAttribute("aria-expanded", String(!tray.hidden));
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// -- editing text in place -----------------------------------------------
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Type into a text annotation where it sits.
|
|
761
|
+
*
|
|
762
|
+
* The editor itself is `FigureTextEditor` -- a contenteditable in the
|
|
763
|
+
* overlay layer, which is where it has to be: `FigureCanvas.render()`
|
|
764
|
+
* replaces the page surface wholesale on every change, and an editor
|
|
765
|
+
* mounted inside it would be destroyed by the autosave triggered by the
|
|
766
|
+
* last thing the user typed.
|
|
767
|
+
*
|
|
768
|
+
* This used to be a <textarea>, and the reason it no longer is, is the
|
|
769
|
+
* whole feature: a textarea holds one style for its entire contents, so an
|
|
770
|
+
* italic gene name inside a roman sentence was unreachable.
|
|
771
|
+
*/
|
|
772
|
+
editText(annotationId) {
|
|
773
|
+
this.textEditor?.open(annotationId);
|
|
774
|
+
// Typing into a caption is exactly when its formatting is wanted, so
|
|
775
|
+
// this overrides a panel the user shut earlier for the same object.
|
|
776
|
+
this.textPanel?.reveal();
|
|
777
|
+
this.textPanel?.update([annotationId]);
|
|
778
|
+
this.contextSidebar(Boolean(this.textPanel?.wants));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Put a character in at the caret.
|
|
783
|
+
*
|
|
784
|
+
* Opens the editor if it is not open, with the caret at the END rather than
|
|
785
|
+
* over everything: the editor's own "select all on open" is right for
|
|
786
|
+
* double-click-and-retype, and wrong for a palette whose whole purpose is
|
|
787
|
+
* to ADD one character to what is already there.
|
|
788
|
+
*/
|
|
789
|
+
insertSymbol(annotationId, glyph) {
|
|
790
|
+
if (!this.textEditor) return;
|
|
791
|
+
if (!this.textEditor.active || this.textEditor.annotationId !== annotationId) {
|
|
792
|
+
this.textEditor.open(annotationId);
|
|
793
|
+
const end = this.textEditor.plainLength();
|
|
794
|
+
this.textEditor.setOffsets({ start: end, end: end });
|
|
795
|
+
}
|
|
796
|
+
this.textEditor.replaceSelection(glyph);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
closeTextEditor() {
|
|
800
|
+
this.textEditor?.close(true);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** One annotation's style or text, from the context bar. */
|
|
804
|
+
updateAnnotation(annotationId, changes) {
|
|
805
|
+
const current = this.state.document.annotations[annotationId];
|
|
806
|
+
if (!current) return;
|
|
807
|
+
changes = this.withAutofit(current, changes);
|
|
808
|
+
this.state.commit(
|
|
809
|
+
[{ op: "update_annotation", annotation_id: annotationId, changes: changes }],
|
|
810
|
+
(draft) => {
|
|
811
|
+
const annotation = draft.annotations[annotationId];
|
|
812
|
+
if (changes.style) Object.assign(annotation.style, changes.style);
|
|
813
|
+
if (changes.geometry) Object.assign(annotation.geometry, changes.geometry);
|
|
814
|
+
if ("text" in changes) annotation.text = changes.text;
|
|
815
|
+
// `rich` is replaced whole, never merged -- half a line list
|
|
816
|
+
// means nothing -- and `text` is re-derived from it here so the
|
|
817
|
+
// optimistic draft matches what the server will send back.
|
|
818
|
+
if ("rich" in changes) {
|
|
819
|
+
annotation.rich = changes.rich;
|
|
820
|
+
annotation.text = FigureRichText.plainText(changes.rich);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Grow or shrink a text box to the height of what is in it.
|
|
827
|
+
*
|
|
828
|
+
* Computed HERE and sent as part of the same operation, rather than left to
|
|
829
|
+
* the optimistic draft: a height that only ever existed in the browser is a
|
|
830
|
+
* box that is the right size until the page is reloaded. Dragging the top or
|
|
831
|
+
* bottom handle is what turns this off -- the gesture is the opt-out, so
|
|
832
|
+
* there is no checkbox to find first.
|
|
833
|
+
*/
|
|
834
|
+
withAutofit(annotation, changes) {
|
|
835
|
+
const style = { ...annotation.style, ...(changes.style || {}) };
|
|
836
|
+
if (annotation.type !== "text" || !style.autofit) return changes;
|
|
837
|
+
const merged = {
|
|
838
|
+
...annotation, style: style,
|
|
839
|
+
geometry: { ...annotation.geometry, ...(changes.geometry || {}) },
|
|
840
|
+
rich: "rich" in changes ? changes.rich : annotation.rich,
|
|
841
|
+
};
|
|
842
|
+
const height = FigureCanvas.textLayout(merged).block_h_mm;
|
|
843
|
+
if (Math.abs(height - merged.geometry.h_mm) < 1e-6) return changes;
|
|
844
|
+
return { ...changes,
|
|
845
|
+
geometry: { ...(changes.geometry || {}), h_mm: height } };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// -- menus ---------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A small menu under a topbar button.
|
|
852
|
+
*
|
|
853
|
+
* Appended to <body> with fixed positioning rather than inside the topbar:
|
|
854
|
+
* the topbar is a flex row whose children are clipped, and a menu inside it
|
|
855
|
+
* would be cut off by the button it hangs from.
|
|
856
|
+
*/
|
|
857
|
+
openMenu(anchor, entries, onPick) {
|
|
858
|
+
const reopening = this.menu && this._menuAnchor === anchor;
|
|
859
|
+
this.closeMenu();
|
|
860
|
+
if (reopening) return;
|
|
861
|
+
|
|
862
|
+
const menu = document.createElement("div");
|
|
863
|
+
menu.className = "fb-menu";
|
|
864
|
+
// A checkable entry keeps the tick's WIDTH whether it is ticked or not,
|
|
865
|
+
// so the labels line up and the menu does not shuffle sideways as
|
|
866
|
+
// things are turned on.
|
|
867
|
+
menu.innerHTML = entries.map((entry) => entry.separator
|
|
868
|
+
? '<span class="fb-menu-separator"></span>'
|
|
869
|
+
: `<button type="button" class="fb-menu-item" data-act="${entry.act}"
|
|
870
|
+
${entry.disabled ? "disabled" : ""}>
|
|
871
|
+
${"checked" in entry
|
|
872
|
+
? `<span class="fb-menu-tick">${entry.checked ? "✓" : ""}</span>`
|
|
873
|
+
: ""}${FigureSchema.escapeHtml(entry.label)}</button>`).join("");
|
|
874
|
+
document.body.appendChild(menu);
|
|
875
|
+
|
|
876
|
+
const box = anchor.getBoundingClientRect();
|
|
877
|
+
// See FigureContextMenu.open -- .fb-menu animates in with a scale, so
|
|
878
|
+
// the layout box is the one to measure, not the transformed one.
|
|
879
|
+
const size = { width: menu.offsetWidth, height: menu.offsetHeight };
|
|
880
|
+
menu.style.top = Math.round(box.bottom + 6) + "px";
|
|
881
|
+
menu.style.left = Math.round(Math.max(8,
|
|
882
|
+
Math.min(box.left, window.innerWidth - size.width - 8))) + "px";
|
|
883
|
+
|
|
884
|
+
menu.addEventListener("click", (event) => {
|
|
885
|
+
const item = event.target.closest("[data-act]");
|
|
886
|
+
if (!item || item.disabled) return;
|
|
887
|
+
this.closeMenu();
|
|
888
|
+
onPick(item.dataset.act);
|
|
889
|
+
});
|
|
890
|
+
this._onMenuDismiss = (event) => {
|
|
891
|
+
if (menu.contains(event.target) || anchor.contains(event.target)) return;
|
|
892
|
+
this.closeMenu();
|
|
893
|
+
};
|
|
894
|
+
document.addEventListener("pointerdown", this._onMenuDismiss, true);
|
|
895
|
+
anchor.setAttribute("aria-expanded", "true");
|
|
896
|
+
this.menu = menu;
|
|
897
|
+
this._menuAnchor = anchor;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
closeMenu() {
|
|
901
|
+
if (this._onMenuDismiss) {
|
|
902
|
+
document.removeEventListener("pointerdown", this._onMenuDismiss, true);
|
|
903
|
+
this._onMenuDismiss = null;
|
|
904
|
+
}
|
|
905
|
+
this._menuAnchor?.setAttribute("aria-expanded", "false");
|
|
906
|
+
this._menuAnchor = null;
|
|
907
|
+
this.menu?.remove();
|
|
908
|
+
this.menu = null;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
pageMenuEntries() {
|
|
912
|
+
return [
|
|
913
|
+
{ act: "add", label: "Add a page" },
|
|
914
|
+
{ act: "duplicate", label: "Duplicate this page" },
|
|
915
|
+
{ act: "remove", label: "Delete this page",
|
|
916
|
+
disabled: this.state.pages.length <= 1 },
|
|
917
|
+
{ separator: true },
|
|
918
|
+
{ act: "background", label: "Page background…" },
|
|
919
|
+
];
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
pageAction(act) {
|
|
923
|
+
if (act === "add") this.addPage();
|
|
924
|
+
else if (act === "duplicate") this.duplicatePage();
|
|
925
|
+
else if (act === "remove") this.removePage();
|
|
926
|
+
else if (act === "background") this.openPageBackground();
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The page background, as a second level of the page menu.
|
|
931
|
+
*
|
|
932
|
+
* Anchored to the same button whichever route got here, including the
|
|
933
|
+
* canvas right-click: a popover that appeared where the pointer happened to
|
|
934
|
+
* be would be a third place this figure's page settings live.
|
|
935
|
+
*/
|
|
936
|
+
openPageBackground() {
|
|
937
|
+
const anchor = this.el("fb_page_menu");
|
|
938
|
+
if (!anchor) return;
|
|
939
|
+
this.openMenu(anchor, [
|
|
940
|
+
{ act: "#ffffff", label: "White" },
|
|
941
|
+
{ act: "#000000", label: "Black" },
|
|
942
|
+
{ act: "custom", label: "Custom color…" },
|
|
943
|
+
{ separator: true },
|
|
944
|
+
// Only PNG carries it through: the export dialog says so, and the
|
|
945
|
+
// canvas draws the conventional checkerboard so nobody discovers it
|
|
946
|
+
// at the file.
|
|
947
|
+
{ act: FigureCanvas.TRANSPARENT, label: "Transparent" },
|
|
948
|
+
], (act) => this.setPageBackground(act));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
setPageBackground(act) {
|
|
952
|
+
if (act !== "custom") {
|
|
953
|
+
this.commitPageBackground(act);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
// The OS colour picker, through an input nothing ever sees. Building a
|
|
957
|
+
// colour wheel here would be a worse one that also has to be
|
|
958
|
+
// maintained.
|
|
959
|
+
const picker = document.createElement("input");
|
|
960
|
+
picker.type = "color";
|
|
961
|
+
picker.value = this.canvas.page?.background || "#ffffff";
|
|
962
|
+
picker.style.position = "fixed";
|
|
963
|
+
picker.style.opacity = "0";
|
|
964
|
+
picker.style.pointerEvents = "none";
|
|
965
|
+
document.body.appendChild(picker);
|
|
966
|
+
picker.addEventListener("change", () => {
|
|
967
|
+
this.commitPageBackground(picker.value);
|
|
968
|
+
picker.remove();
|
|
969
|
+
});
|
|
970
|
+
picker.addEventListener("blur", () => picker.remove());
|
|
971
|
+
picker.click();
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
commitPageBackground(background) {
|
|
975
|
+
const pageId = this.canvas.pageId;
|
|
976
|
+
if (!pageId) return;
|
|
977
|
+
this.state.commit(
|
|
978
|
+
[{ op: "update_page", page_id: pageId, changes: { background: background } }],
|
|
979
|
+
(draft) => {
|
|
980
|
+
const page = draft.pages.find((entry) => entry.page_id === pageId);
|
|
981
|
+
if (page) page.background = background;
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* A copy of this page, with everything on it, as ONE undo step.
|
|
987
|
+
*
|
|
988
|
+
* The commonest reason to want it is a second version of a figure that is
|
|
989
|
+
* nearly right -- which means the copy has to carry the panels, not just
|
|
990
|
+
* the page size, and has to arrive complete enough to compare the two.
|
|
991
|
+
*/
|
|
992
|
+
duplicatePage() {
|
|
993
|
+
const current = this.canvas.page;
|
|
994
|
+
if (!current) return;
|
|
995
|
+
const page = {
|
|
996
|
+
...JSON.parse(JSON.stringify(current)),
|
|
997
|
+
page_id: FigureSchema.newPageId(),
|
|
998
|
+
name: current.name + " copy",
|
|
999
|
+
};
|
|
1000
|
+
const panels = FigureSchema.panelsOnPage(this.state.document, current.page_id);
|
|
1001
|
+
const annotations = Object.values(this.state.document.annotations)
|
|
1002
|
+
.filter((annotation) => annotation.page_id === current.page_id);
|
|
1003
|
+
const made = this.canvas.copiesOf(panels, annotations, 0, 0, page.page_id);
|
|
1004
|
+
|
|
1005
|
+
// The page has to exist before anything can be placed on it, so it
|
|
1006
|
+
// leads the batch -- and the whole batch is one commit, or duplicating
|
|
1007
|
+
// a six-panel page would be seven undo steps.
|
|
1008
|
+
const done = this.canvas.commitCopies(made, {
|
|
1009
|
+
select: false,
|
|
1010
|
+
operations: [{ op: "add_page", page: page }],
|
|
1011
|
+
mutate: (draft) => { draft.pages.push(page); },
|
|
1012
|
+
});
|
|
1013
|
+
(done || Promise.resolve(false)).then((stored) => {
|
|
1014
|
+
if (stored !== false) this.canvas.setPage(page.page_id);
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
zoomMenuEntries() {
|
|
1019
|
+
return [
|
|
1020
|
+
{ act: "fit", label: "Fit page ⌘0" },
|
|
1021
|
+
{ act: "selection", label: "Fit selection",
|
|
1022
|
+
disabled: !this.canvas.selection.size },
|
|
1023
|
+
{ act: "100", label: "100% ⌘1" },
|
|
1024
|
+
];
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
zoomAction(act) {
|
|
1028
|
+
if (act === "fit") this.zoomToFit();
|
|
1029
|
+
else if (act === "selection") this.zoomToSelection();
|
|
1030
|
+
else if (act === "100") this.setScale(FigureWorkspace.PX_PER_MM);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// -- keyboard ------------------------------------------------------------
|
|
1034
|
+
|
|
1035
|
+
keyDown(event) {
|
|
1036
|
+
// A <dialog> traps focus, not keystrokes. With the delete confirmation
|
|
1037
|
+
// up, Escape reached this handler as well as the dialog and disarmed
|
|
1038
|
+
// the drawing tool behind it.
|
|
1039
|
+
if (FigureConfirm.modalOpen) return;
|
|
1040
|
+
const typing = document.activeElement
|
|
1041
|
+
&& ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName);
|
|
1042
|
+
if (typing) return;
|
|
1043
|
+
|
|
1044
|
+
if (event.key === "Escape") {
|
|
1045
|
+
this.closeMenu();
|
|
1046
|
+
this.contextBar?.closePopover();
|
|
1047
|
+
// Escape also disarms a drawing tool, which is the other thing on
|
|
1048
|
+
// this page that is waiting for a click nobody wants to make.
|
|
1049
|
+
if (this.tool !== "select") this.setTool("select");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
if (!(event.metaKey || event.ctrlKey)) return;
|
|
1053
|
+
|
|
1054
|
+
// The zoom chords. Bound here rather than on the canvas because they
|
|
1055
|
+
// are about the window, not about the selection.
|
|
1056
|
+
const zoom = {
|
|
1057
|
+
"0": () => this.zoomToFit(),
|
|
1058
|
+
"1": () => this.setScale(FigureWorkspace.PX_PER_MM),
|
|
1059
|
+
"=": () => this.setScale(this.canvas.scale * 1.25),
|
|
1060
|
+
"+": () => this.setScale(this.canvas.scale * 1.25),
|
|
1061
|
+
"-": () => this.setScale(this.canvas.scale * 0.8),
|
|
1062
|
+
}[event.key];
|
|
1063
|
+
if (zoom) {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
zoom();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (event.key === "z" && !event.shiftKey) {
|
|
1069
|
+
event.preventDefault();
|
|
1070
|
+
this.state.undo();
|
|
1071
|
+
} else if ((event.key === "z" && event.shiftKey) || event.key === "y") {
|
|
1072
|
+
event.preventDefault();
|
|
1073
|
+
this.state.redo();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// -- editing ---------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
commitTitle() {
|
|
1080
|
+
const input = this.el("fb_title");
|
|
1081
|
+
const title = (input.value || "").trim();
|
|
1082
|
+
if (!title || title === this.state.title) {
|
|
1083
|
+
input.value = this.state.title;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
this.state.commit([{ op: "set_meta", changes: { title: title } }],
|
|
1087
|
+
(draft) => { draft.title = title; });
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* The same change to several panels, as ONE undo step.
|
|
1091
|
+
*
|
|
1092
|
+
* Turning scale bars on across a row of six is one thing the user did. Six
|
|
1093
|
+
* commits would be six saves and six presses of Cmd+Z to take back, and the
|
|
1094
|
+
* fourth press would leave the row half-changed.
|
|
1095
|
+
*/
|
|
1096
|
+
updatePanels(updates) {
|
|
1097
|
+
const real = updates.filter((entry) => this.state.panel(entry.panel_id));
|
|
1098
|
+
if (!real.length) return Promise.resolve(false);
|
|
1099
|
+
return this.state.commit(
|
|
1100
|
+
real.map((entry) => ({
|
|
1101
|
+
op: "update_panel", panel_id: entry.panel_id, changes: entry.changes })),
|
|
1102
|
+
(draft) => {
|
|
1103
|
+
for (const entry of real) {
|
|
1104
|
+
Object.assign(draft.panels[entry.panel_id], entry.changes);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** A figure-wide setting -- the label style, the gutter, the default DPI. */
|
|
1110
|
+
updateSettings(settings) {
|
|
1111
|
+
this.state.commit(
|
|
1112
|
+
[{ op: "set_meta", changes: { settings: settings } }],
|
|
1113
|
+
(draft) => { Object.assign(draft.settings, settings); });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Record a pixel size the user typed for images that never had one.
|
|
1118
|
+
*
|
|
1119
|
+
* On the SOURCE, because it is a fact about the image and every panel of it
|
|
1120
|
+
* is entitled to the same answer -- and marked `manual`, which the
|
|
1121
|
+
* provenance page prints. A number somebody typed is not the same evidence
|
|
1122
|
+
* as one the file stated, and a figure that could not tell the difference
|
|
1123
|
+
* would have scale bars nobody could check.
|
|
1124
|
+
*/
|
|
1125
|
+
setPixelSize(sourceIds, value) {
|
|
1126
|
+
const real = sourceIds.filter((id) => this.state.source(id));
|
|
1127
|
+
if (!real.length || !(value > 0)) return;
|
|
1128
|
+
const pixelSize = { value: value, unit: "µm", source: "manual" };
|
|
1129
|
+
this.state.commit(
|
|
1130
|
+
real.map((sourceId) => ({
|
|
1131
|
+
op: "update_source", source_id: sourceId,
|
|
1132
|
+
changes: { pixel_size: pixelSize } })),
|
|
1133
|
+
(draft) => {
|
|
1134
|
+
for (const sourceId of real) draft.sources[sourceId].pixel_size = pixelSize;
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Make a set of panels draw each marker the same colour.
|
|
1140
|
+
*
|
|
1141
|
+
* Asked for explicitly, never inferred: two panels showing CD8 in different
|
|
1142
|
+
* colours may be deliberate, and quietly repainting one of them would
|
|
1143
|
+
* change what a figure asserts. The legend popover puts this behind a
|
|
1144
|
+
* button that says what it does, with "keep them separate" first.
|
|
1145
|
+
*
|
|
1146
|
+
* Every recoloured panel gets a new render revision, so its cached preview
|
|
1147
|
+
* is refetched and the export re-renders it -- a recolour that left the old
|
|
1148
|
+
* raster in place would be a panel whose picture and whose legend disagree.
|
|
1149
|
+
*/
|
|
1150
|
+
shareLegendColours(panelIds, canonical) {
|
|
1151
|
+
const updates = [];
|
|
1152
|
+
for (const panelId of panelIds) {
|
|
1153
|
+
const panel = this.state.panel(panelId);
|
|
1154
|
+
if (!panel) continue;
|
|
1155
|
+
let touched = false;
|
|
1156
|
+
const channels = (panel.scene.channels || []).map((channel) => {
|
|
1157
|
+
const name = channel.fullname_at_capture || channel.key;
|
|
1158
|
+
const colour = canonical.get(name);
|
|
1159
|
+
if (!colour) return channel;
|
|
1160
|
+
if (channel.color.r === colour.r && channel.color.g === colour.g
|
|
1161
|
+
&& channel.color.b === colour.b) {
|
|
1162
|
+
return channel;
|
|
1163
|
+
}
|
|
1164
|
+
touched = true;
|
|
1165
|
+
return { ...channel, color: { ...colour } };
|
|
1166
|
+
});
|
|
1167
|
+
if (!touched) continue;
|
|
1168
|
+
updates.push({
|
|
1169
|
+
panel_id: panelId,
|
|
1170
|
+
changes: {
|
|
1171
|
+
scene: { ...panel.scene, channels: channels },
|
|
1172
|
+
render_revision: panel.render_revision + 1,
|
|
1173
|
+
},
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
if (!updates.length) return;
|
|
1177
|
+
this.updatePanels(updates);
|
|
1178
|
+
// The previews are now pictures of the old colours. Saying so beats
|
|
1179
|
+
// showing a row that looks unchanged until it is exported.
|
|
1180
|
+
this.toast("Colors matched. Reopen a panel to redraw its preview — "
|
|
1181
|
+
+ "the export renders the new colors either way.");
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
/** One panel's properties, from the context bar. */
|
|
1187
|
+
updatePanel(panelId, changes) {
|
|
1188
|
+
if (!this.state.panel(panelId)) return;
|
|
1189
|
+
this.state.commit(
|
|
1190
|
+
[{ op: "update_panel", panel_id: panelId, changes: changes }],
|
|
1191
|
+
(draft) => { Object.assign(draft.panels[panelId], changes); });
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Rearrange the selection, then check the labels still make sense.
|
|
1195
|
+
*
|
|
1196
|
+
* An arrange moves panels into a new reading order, and automatic labels
|
|
1197
|
+
* follow it -- but a label the user typed does not. So a row that was
|
|
1198
|
+
* A B C and is now C A B keeps its hand-written letters in the wrong
|
|
1199
|
+
* places, silently, in a figure whose whole job is to be referred to by
|
|
1200
|
+
* those letters. The offer is a toast rather than a prompt: it is a
|
|
1201
|
+
* suggestion about presentation, and interrupting a layout gesture with a
|
|
1202
|
+
* dialog would be worse than the problem.
|
|
1203
|
+
*/
|
|
1204
|
+
arrange(command) {
|
|
1205
|
+
this.canvas.arrange(command);
|
|
1206
|
+
const manual = this.canvas.selectedPanels()
|
|
1207
|
+
.filter((panel) => panel.placement && panel.label.visible && !panel.label.auto);
|
|
1208
|
+
if (manual.length < 2) return;
|
|
1209
|
+
this.toast("Panel order changed. Some labels were typed in, so they have not"
|
|
1210
|
+
+ " moved.", { label: "Reset them to automatic",
|
|
1211
|
+
act: () => this.resetLabels(manual.map((p) => p.panel_id)) });
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
resetLabels(panelIds) {
|
|
1215
|
+
this.updatePanels(panelIds
|
|
1216
|
+
.map((panelId) => ({ panel: this.state.panel(panelId), panel_id: panelId }))
|
|
1217
|
+
.filter((entry) => entry.panel)
|
|
1218
|
+
.map((entry) => ({
|
|
1219
|
+
panel_id: entry.panel_id,
|
|
1220
|
+
changes: { label: { ...entry.panel.label, auto: true, text: "" } },
|
|
1221
|
+
})));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Something worth saying that is not worth stopping for.
|
|
1226
|
+
*
|
|
1227
|
+
* In the overlay layer, bottom centre, gone after a while. Everything it
|
|
1228
|
+
* carries is a suggestion -- reset these labels, reopen a panel to redraw
|
|
1229
|
+
* it -- and a modal for any of them would interrupt a gesture to report on
|
|
1230
|
+
* that same gesture.
|
|
1231
|
+
*/
|
|
1232
|
+
toast(message, action) {
|
|
1233
|
+
const host = this.el("fb_overlay_layer");
|
|
1234
|
+
if (!host) return;
|
|
1235
|
+
host.querySelectorAll(".fb-toast").forEach((old) => old.remove());
|
|
1236
|
+
|
|
1237
|
+
const toast = document.createElement("div");
|
|
1238
|
+
toast.className = "fb-toast";
|
|
1239
|
+
toast.innerHTML = `<span>${FigureSchema.escapeHtml(message)}</span>`;
|
|
1240
|
+
if (action) {
|
|
1241
|
+
const button = document.createElement("button");
|
|
1242
|
+
button.type = "button";
|
|
1243
|
+
button.className = "fb-toast-action";
|
|
1244
|
+
button.textContent = action.label;
|
|
1245
|
+
button.addEventListener("click", () => {
|
|
1246
|
+
toast.remove();
|
|
1247
|
+
action.act();
|
|
1248
|
+
});
|
|
1249
|
+
toast.appendChild(button);
|
|
1250
|
+
}
|
|
1251
|
+
const dismiss = document.createElement("button");
|
|
1252
|
+
dismiss.type = "button";
|
|
1253
|
+
dismiss.className = "fb-toast-close";
|
|
1254
|
+
dismiss.innerHTML = '<span class="fas fa-xmark"></span>';
|
|
1255
|
+
dismiss.addEventListener("click", () => toast.remove());
|
|
1256
|
+
toast.appendChild(dismiss);
|
|
1257
|
+
|
|
1258
|
+
host.appendChild(toast);
|
|
1259
|
+
window.setTimeout(() => toast.remove(), FigureWorkspace.TOAST_MS);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
split(mode) {
|
|
1265
|
+
const panelId = Array.from(this.canvas.selection)[0];
|
|
1266
|
+
if (panelId) this.canvas.splitComposite(panelId, mode);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
addPage() {
|
|
1270
|
+
const template = this.state.pages[this.state.pages.length - 1];
|
|
1271
|
+
// A new page copies the last one's size rather than defaulting to A4: a
|
|
1272
|
+
// figure whose pages are not all the same size exports as a document
|
|
1273
|
+
// nobody meant to make.
|
|
1274
|
+
const page = {
|
|
1275
|
+
page_id: FigureSchema.newPageId(),
|
|
1276
|
+
name: "Page " + (this.state.pages.length + 1),
|
|
1277
|
+
preset: template ? template.preset : "a4",
|
|
1278
|
+
orientation: template ? template.orientation : "portrait",
|
|
1279
|
+
size_mm: template ? { ...template.size_mm } : { w: 210, h: 297 },
|
|
1280
|
+
margins_mm: template ? { ...template.margins_mm } : undefined,
|
|
1281
|
+
background: template ? template.background : "#ffffff",
|
|
1282
|
+
};
|
|
1283
|
+
this.state.commit([{ op: "add_page", page: page }],
|
|
1284
|
+
(draft) => { draft.pages.push(page); });
|
|
1285
|
+
this.canvas.pageId = page.page_id;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async removePage() {
|
|
1289
|
+
if (this.state.pages.length <= 1) {
|
|
1290
|
+
FigureConfirm.tell({
|
|
1291
|
+
title: "A figure needs at least one page.",
|
|
1292
|
+
body: "Add another page first, or delete what is on this one.",
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const pageId = this.canvas.pageId;
|
|
1297
|
+
const panels = FigureSchema.panelsOnPage(this.state.document, pageId);
|
|
1298
|
+
|
|
1299
|
+
// Three answers, and it used to be asked with two.
|
|
1300
|
+
//
|
|
1301
|
+
// A page holding panels can go three ways -- take the panels with it,
|
|
1302
|
+
// leave them in the tray, or not go at all -- and `window.confirm` has
|
|
1303
|
+
// room for the first two. So Cancel was spent on "keep them in the
|
|
1304
|
+
// tray", the page went either way, and the one thing a user pressing
|
|
1305
|
+
// Escape on a delete confirmation means was the one thing they could
|
|
1306
|
+
// not say. Now Escape means what it says and the two real answers are
|
|
1307
|
+
// two buttons that name themselves.
|
|
1308
|
+
//
|
|
1309
|
+
// Never silently orphaned, and keeping them is the safe answer, because
|
|
1310
|
+
// a captured scene may be the only record of a view somebody spent an
|
|
1311
|
+
// hour finding.
|
|
1312
|
+
let destroy = false;
|
|
1313
|
+
if (panels.length) {
|
|
1314
|
+
const answer = await FigureConfirm.choose({
|
|
1315
|
+
title: "Delete this page?",
|
|
1316
|
+
body: [`It holds ${FigureSchema.countPhrase(panels.length, "panel")}.`,
|
|
1317
|
+
"Panels kept in the tray can be placed on another page. "
|
|
1318
|
+
+ "Deleted, they go with their captured scenes."],
|
|
1319
|
+
choices: [
|
|
1320
|
+
{ value: null, label: "Cancel", focus: true },
|
|
1321
|
+
{ value: false, label: "Keep the panels", kind: "primary" },
|
|
1322
|
+
{ value: true, label: "Delete them too", kind: "danger" },
|
|
1323
|
+
],
|
|
1324
|
+
});
|
|
1325
|
+
if (answer === null) return;
|
|
1326
|
+
destroy = answer;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
this.state.commit(
|
|
1330
|
+
[{ op: "remove_page", page_id: pageId, panels: destroy ? "delete" : "tray" }],
|
|
1331
|
+
(draft) => {
|
|
1332
|
+
draft.pages = draft.pages.filter((page) => page.page_id !== pageId);
|
|
1333
|
+
panels.forEach((panel) => {
|
|
1334
|
+
if (destroy) delete draft.panels[panel.panel_id];
|
|
1335
|
+
else draft.panels[panel.panel_id].placement = null;
|
|
1336
|
+
});
|
|
1337
|
+
Object.keys(draft.annotations).forEach((id) => {
|
|
1338
|
+
if (draft.annotations[id].page_id === pageId) delete draft.annotations[id];
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
this.canvas.pageId = null;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// -- the tray ------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* A click in the tray.
|
|
1348
|
+
*
|
|
1349
|
+
* On pointerdown rather than click so that a drag which begins on an
|
|
1350
|
+
* unselected item selects it first -- otherwise dragging a panel out of a
|
|
1351
|
+
* four-panel selection would carry four panels the user could no longer see
|
|
1352
|
+
* highlighted.
|
|
1353
|
+
*/
|
|
1354
|
+
trayPointerDown(panelId, event) {
|
|
1355
|
+
const ids = this.trayVisibleIds();
|
|
1356
|
+
if (event.shiftKey && this.trayAnchor && ids.includes(this.trayAnchor)) {
|
|
1357
|
+
const from = ids.indexOf(this.trayAnchor);
|
|
1358
|
+
const to = ids.indexOf(panelId);
|
|
1359
|
+
const [low, high] = from < to ? [from, to] : [to, from];
|
|
1360
|
+
this.traySelection = new Set(ids.slice(low, high + 1));
|
|
1361
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
1362
|
+
if (this.traySelection.has(panelId)) this.traySelection.delete(panelId);
|
|
1363
|
+
else this.traySelection.add(panelId);
|
|
1364
|
+
this.trayAnchor = panelId;
|
|
1365
|
+
} else {
|
|
1366
|
+
this.traySelection = new Set([panelId]);
|
|
1367
|
+
this.trayAnchor = panelId;
|
|
1368
|
+
}
|
|
1369
|
+
this.renderTray();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** The tray panels currently on screen, in the order they are shown. */
|
|
1373
|
+
trayVisibleIds() {
|
|
1374
|
+
return this.trayPanels().map((panel) => panel.panel_id);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Drop the filter and show the whole tray again. */
|
|
1378
|
+
clearTraySearch() {
|
|
1379
|
+
this.traySearch = "";
|
|
1380
|
+
const search = this.el("fb_tray_search");
|
|
1381
|
+
if (search) search.value = "";
|
|
1382
|
+
this.renderTray();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
trayPanels() {
|
|
1386
|
+
const panels = FigureSchema.panelsInTray(this.state.document);
|
|
1387
|
+
const needle = this.traySearch.trim().toLowerCase();
|
|
1388
|
+
if (!needle) return panels;
|
|
1389
|
+
return panels.filter((panel) => this.trayHaystack(panel).includes(needle));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
trayHaystack(panel) {
|
|
1393
|
+
const source = this.state.source(panel.source_id);
|
|
1394
|
+
return [panel.title, panel.label && panel.label.text,
|
|
1395
|
+
source && (source.display_name || source.datasource)]
|
|
1396
|
+
.filter(Boolean).join(" ").toLowerCase();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* Put tray panels onto the page without dragging them.
|
|
1401
|
+
*
|
|
1402
|
+
* The same path a drag takes, so double-clicking four selected panels and
|
|
1403
|
+
* dragging four selected panels produce the same layout -- and neither
|
|
1404
|
+
* stacks them.
|
|
1405
|
+
*/
|
|
1406
|
+
placeFromTray(panelIds) {
|
|
1407
|
+
const ids = panelIds.filter((id) => this.state.panel(id));
|
|
1408
|
+
const page = this.canvas.page;
|
|
1409
|
+
if (!ids.length || !page) return;
|
|
1410
|
+
this.canvas.placePanels(ids, this.canvas.traySizes(ids, page), null);
|
|
1411
|
+
ids.forEach((id) => this.traySelection.delete(id));
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Right-clicking a tray item that is not in the tray selection selects it
|
|
1415
|
+
* first, so the menu never acts on something else. */
|
|
1416
|
+
trayContext(panelId) {
|
|
1417
|
+
if (this.traySelection.has(panelId)) return;
|
|
1418
|
+
this.traySelection = new Set([panelId]);
|
|
1419
|
+
this.trayAnchor = panelId;
|
|
1420
|
+
this.renderTray();
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
duplicateTray() {
|
|
1424
|
+
const panels = Array.from(this.traySelection)
|
|
1425
|
+
.map((id) => this.state.panel(id)).filter(Boolean);
|
|
1426
|
+
if (!panels.length) return;
|
|
1427
|
+
// No offset and no selection: these have no placement, so there is
|
|
1428
|
+
// nothing to offset and nothing on the page to select.
|
|
1429
|
+
this.canvas.commitCopies(this.canvas.copiesOf(panels, [], 0, 0), { select: false });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Destroy panels, and the sources nothing references any more.
|
|
1434
|
+
*
|
|
1435
|
+
* The OTHER delete, and the reason the two are worded differently. Removing
|
|
1436
|
+
* a panel from a page is a statement about the page and is one keystroke
|
|
1437
|
+
* away; this destroys the captured scene, which may be the only record of a
|
|
1438
|
+
* view somebody spent an hour finding. So it asks, it says how many, and it
|
|
1439
|
+
* says what else goes with them.
|
|
1440
|
+
*
|
|
1441
|
+
* A source left behind with no panels is not harmless: it is what the
|
|
1442
|
+
* provenance page lists and what "this source has changed" is checked
|
|
1443
|
+
* against, so a figure that accumulated one per deleted panel would warn
|
|
1444
|
+
* about images it no longer draws. They go in the SAME commit, so the whole
|
|
1445
|
+
* thing is one undo step -- and undoing it brings the source back too.
|
|
1446
|
+
*/
|
|
1447
|
+
async deleteFromFigure(ids) {
|
|
1448
|
+
const panels = ids.map((id) => this.state.panel(id)).filter(Boolean);
|
|
1449
|
+
const annotationIds = ids.filter((id) => this.state.document.annotations[id]);
|
|
1450
|
+
if (!panels.length && !annotationIds.length) return;
|
|
1451
|
+
|
|
1452
|
+
const doomed = new Set(panels.map((panel) => panel.panel_id));
|
|
1453
|
+
const orphaned = this.sourcesLeftUnreferenced(doomed);
|
|
1454
|
+
|
|
1455
|
+
const parts = [];
|
|
1456
|
+
if (panels.length) parts.push(FigureSchema.countPhrase(panels.length, "panel"));
|
|
1457
|
+
if (annotationIds.length) {
|
|
1458
|
+
parts.push(FigureSchema.countPhrase(annotationIds.length, "annotation"));
|
|
1459
|
+
}
|
|
1460
|
+
const body = ["This cannot be undone by closing the page — the captured "
|
|
1461
|
+
+ "scenes go with them."];
|
|
1462
|
+
if (orphaned.length) {
|
|
1463
|
+
body.push("The figure will also stop referencing "
|
|
1464
|
+
+ `${FigureSchema.countPhrase(orphaned.length, "image")}, `
|
|
1465
|
+
+ "which nothing else uses.");
|
|
1466
|
+
}
|
|
1467
|
+
const go = await FigureConfirm.ask({
|
|
1468
|
+
title: `Delete ${parts.join(" and ")} from this figure?`,
|
|
1469
|
+
body: body,
|
|
1470
|
+
confirm: "Delete",
|
|
1471
|
+
});
|
|
1472
|
+
if (!go) return;
|
|
1473
|
+
|
|
1474
|
+
const operations = [];
|
|
1475
|
+
if (panels.length) {
|
|
1476
|
+
operations.push({ op: "remove_panels", panel_ids: Array.from(doomed) });
|
|
1477
|
+
}
|
|
1478
|
+
if (annotationIds.length) {
|
|
1479
|
+
operations.push({ op: "remove_annotations", annotation_ids: annotationIds });
|
|
1480
|
+
}
|
|
1481
|
+
// "keep" rather than "delete": the panels are already going in the
|
|
1482
|
+
// operation above, and asking the server to delete them twice is how a
|
|
1483
|
+
// batch fails halfway.
|
|
1484
|
+
for (const sourceId of orphaned) {
|
|
1485
|
+
operations.push({ op: "remove_source", source_id: sourceId, panels: "keep" });
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
for (const id of doomed) this.canvas.selection.delete(id);
|
|
1489
|
+
for (const id of annotationIds) this.canvas.selection.delete(id);
|
|
1490
|
+
for (const id of ids) this.traySelection.delete(id);
|
|
1491
|
+
|
|
1492
|
+
this.state.commit(operations, (draft) => {
|
|
1493
|
+
for (const id of doomed) delete draft.panels[id];
|
|
1494
|
+
for (const id of annotationIds) delete draft.annotations[id];
|
|
1495
|
+
for (const id of orphaned) delete draft.sources[id];
|
|
1496
|
+
for (const [groupId, group] of Object.entries(draft.groups || {})) {
|
|
1497
|
+
group.member_ids = group.member_ids.filter(
|
|
1498
|
+
(member) => !doomed.has(member) && !annotationIds.includes(member));
|
|
1499
|
+
if (group.member_ids.length < 2) delete draft.groups[groupId];
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
this.canvas.onSelectionChange(Array.from(this.canvas.selection));
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** The sources that would have no panels left once `doomed` has gone. */
|
|
1506
|
+
sourcesLeftUnreferenced(doomed) {
|
|
1507
|
+
const survivors = new Set();
|
|
1508
|
+
for (const panel of Object.values(this.state.document.panels)) {
|
|
1509
|
+
if (!doomed.has(panel.panel_id)) survivors.add(panel.source_id);
|
|
1510
|
+
}
|
|
1511
|
+
return Object.keys(this.state.document.sources)
|
|
1512
|
+
.filter((sourceId) => !survivors.has(sourceId));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
// -- importing files -----------------------------------------------------
|
|
1518
|
+
|
|
1519
|
+
dropFiles(event) {
|
|
1520
|
+
const files = Array.from(event.dataTransfer?.files || []);
|
|
1521
|
+
if (!files.length) return;
|
|
1522
|
+
event.preventDefault();
|
|
1523
|
+
this.importFiles(files, this.canvas.surfacePoint(event));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Files brought in from outside the project.
|
|
1528
|
+
*
|
|
1529
|
+
* Figure-only by design: a schematic or a supporting RGB image is not a
|
|
1530
|
+
* project, and the bytes land in this figure's own directory. Each panel
|
|
1531
|
+
* arrives at the image's own aspect ratio, and a batch is laid out rather
|
|
1532
|
+
* than stacked -- landing everything square, or all at one coordinate, is
|
|
1533
|
+
* work the user then has to undo.
|
|
1534
|
+
*/
|
|
1535
|
+
async importFiles(files, atPoint) {
|
|
1536
|
+
const page = this.canvas.page;
|
|
1537
|
+
if (!page) return;
|
|
1538
|
+
const task = window.PlexoraStatus?.begin("Importing");
|
|
1539
|
+
|
|
1540
|
+
const added = [];
|
|
1541
|
+
for (const file of files) {
|
|
1542
|
+
const uploaded = await this.api.addAsset(this.figureId, file.name, file);
|
|
1543
|
+
if (!uploaded.ok) {
|
|
1544
|
+
task?.fail(uploaded.data.error || "That file could not be imported");
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
added.push({ asset: uploaded.data, dimensions: await this.imageSize(file) });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const width = Math.min(60, page.size_mm.w / 3);
|
|
1551
|
+
const sizes = added.map((entry) => ({
|
|
1552
|
+
w_mm: width,
|
|
1553
|
+
h_mm: width * ((entry.dimensions.height / entry.dimensions.width) || 1),
|
|
1554
|
+
}));
|
|
1555
|
+
const origin = atPoint ? {
|
|
1556
|
+
x_mm: Math.max(page.margins_mm.left, atPoint.x - width / 2),
|
|
1557
|
+
y_mm: Math.max(page.margins_mm.top, atPoint.y - sizes[0].h_mm / 2),
|
|
1558
|
+
} : null;
|
|
1559
|
+
const boxes = FigureCanvas.freePlacements(
|
|
1560
|
+
sizes, page, this.canvas.occupiedBoxes(),
|
|
1561
|
+
this.state.document.settings.style.gutter_mm, origin);
|
|
1562
|
+
|
|
1563
|
+
let z = this.canvas.nextZ();
|
|
1564
|
+
const operations = [];
|
|
1565
|
+
const records = [];
|
|
1566
|
+
added.forEach((entry, index) => {
|
|
1567
|
+
const sourceId = FigureSchema.newSourceId();
|
|
1568
|
+
const panelId = FigureSchema.newPanelId();
|
|
1569
|
+
const source = {
|
|
1570
|
+
source_id: sourceId, kind: "imported_asset", asset_id: entry.asset.asset_id,
|
|
1571
|
+
display_name: entry.asset.filename,
|
|
1572
|
+
image: { width: entry.dimensions.width, height: entry.dimensions.height },
|
|
1573
|
+
// No calibration, and none invented: an imported PNG has no
|
|
1574
|
+
// physical scale, so its panels have no scale bar until
|
|
1575
|
+
// somebody types one in.
|
|
1576
|
+
pixel_size: null, channels: [], status: "ok",
|
|
1577
|
+
};
|
|
1578
|
+
const panel = {
|
|
1579
|
+
panel_id: panelId, source_id: sourceId,
|
|
1580
|
+
scene: { ...FigureSchema.emptyScene(sourceId),
|
|
1581
|
+
viewport: { x: 0, y: 0,
|
|
1582
|
+
w: entry.dimensions.width, h: entry.dimensions.height } },
|
|
1583
|
+
placement: { page_id: page.page_id, ...boxes[index], z: z++ },
|
|
1584
|
+
title: entry.asset.filename, label: { text: "", auto: true, visible: true },
|
|
1585
|
+
scalebar: { visible: false, target_um: null },
|
|
1586
|
+
legend: { channels: false, plugins: false }, render_revision: 1,
|
|
1587
|
+
};
|
|
1588
|
+
operations.push({ op: "add_source", source: source },
|
|
1589
|
+
{ op: "add_panel", panel: panel });
|
|
1590
|
+
records.push({ source: source, panel: panel });
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
// One commit: dropping four files is one thing the user did, and four
|
|
1594
|
+
// would be four undo steps for it.
|
|
1595
|
+
await this.state.commit(operations, (draft) => {
|
|
1596
|
+
for (const record of records) {
|
|
1597
|
+
draft.sources[record.source.source_id] = record.source;
|
|
1598
|
+
draft.panels[record.panel.panel_id] = record.panel;
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
task?.done();
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
imageSize(file) {
|
|
1605
|
+
return new Promise((resolve) => {
|
|
1606
|
+
const url = URL.createObjectURL(file);
|
|
1607
|
+
const image = new Image();
|
|
1608
|
+
image.onload = () => {
|
|
1609
|
+
resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 });
|
|
1610
|
+
URL.revokeObjectURL(url);
|
|
1611
|
+
};
|
|
1612
|
+
// A TIFF the browser cannot decode still imports; it simply lands
|
|
1613
|
+
// square until the user resizes it, which is better than refusing
|
|
1614
|
+
// a file the export renderer can read perfectly well.
|
|
1615
|
+
image.onerror = () => {
|
|
1616
|
+
resolve({ width: 1, height: 1 });
|
|
1617
|
+
URL.revokeObjectURL(url);
|
|
1618
|
+
};
|
|
1619
|
+
image.src = url;
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// -- reopening a panel ---------------------------------------------------
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* The cheap edit, where it is possible.
|
|
1627
|
+
*
|
|
1628
|
+
* Quick Edit opens beside the canvas and costs nothing but a few small
|
|
1629
|
+
* reads; the main viewer is a page load away from the figure. So this is
|
|
1630
|
+
* what a double-click and the bar's first button reach for, and the
|
|
1631
|
+
* expensive route is what "Open in Main Viewer" says on it.
|
|
1632
|
+
*
|
|
1633
|
+
* A panel with no project image behind it -- an imported PNG, a source
|
|
1634
|
+
* whose project has gone -- has nothing to quick-edit, and falls through to
|
|
1635
|
+
* the route that can at least explain itself.
|
|
1636
|
+
*/
|
|
1637
|
+
quickEditPanel(panelId) {
|
|
1638
|
+
const panel = this.state.panel(panelId);
|
|
1639
|
+
if (this.quickEdit?.canEdit(panel)) {
|
|
1640
|
+
this.quickEdit.open(panelId);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
this.onEditPanel(panelId);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Open a panel's view in the main viewer.
|
|
1648
|
+
*
|
|
1649
|
+
* There is no viewer on this page, so this navigates -- with the request
|
|
1650
|
+
* left in sessionStorage for the page that lands to pick up.
|
|
1651
|
+
*
|
|
1652
|
+
* The note carries three things the viewer cannot work out for itself: WHICH
|
|
1653
|
+
* panel, what SHAPE it is now (a square capture the user has since dragged
|
|
1654
|
+
* into a wide strip must be reframed as a wide strip), and that the user
|
|
1655
|
+
* expects to come back here when they are done. Without the last one the
|
|
1656
|
+
* round trip is a one-way trip and the user has to find the figure again.
|
|
1657
|
+
*/
|
|
1658
|
+
editPanel(panelId) {
|
|
1659
|
+
const panel = this.state.panel(panelId);
|
|
1660
|
+
const source = panel && this.state.source(panel.source_id);
|
|
1661
|
+
if (!source || source.kind !== "plexora_project" || !source.datasource) {
|
|
1662
|
+
FigureConfirm.tell({
|
|
1663
|
+
title: "This panel has no project image to reopen.",
|
|
1664
|
+
body: "It was captured from an image the figure no longer references.",
|
|
1665
|
+
});
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const place = panel.placement;
|
|
1669
|
+
const aspect = place && place.h_mm > 0
|
|
1670
|
+
? place.w_mm / place.h_mm
|
|
1671
|
+
: (panel.scene.viewport.h > 0
|
|
1672
|
+
? panel.scene.viewport.w / panel.scene.viewport.h : 1);
|
|
1673
|
+
try {
|
|
1674
|
+
window.sessionStorage.setItem("plexora:figure-builder-pending",
|
|
1675
|
+
JSON.stringify({
|
|
1676
|
+
figure_id: this.figureId,
|
|
1677
|
+
panel_id: panelId,
|
|
1678
|
+
mode: "update",
|
|
1679
|
+
aspect: aspect,
|
|
1680
|
+
return_to: "canvas",
|
|
1681
|
+
}));
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
/* Private-browsing modes throw; the navigation is still worth doing. */
|
|
1684
|
+
}
|
|
1685
|
+
window.location.href = this.api.url(encodeURIComponent(source.datasource))
|
|
1686
|
+
+ "?tool=figure_builder";
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Record that the user has looked at a changed source and accepted it.
|
|
1691
|
+
*
|
|
1692
|
+
* Only the fingerprint moves. No panel is re-rendered and no captured scene
|
|
1693
|
+
* is touched -- accepting the new image is a statement about what the
|
|
1694
|
+
* warning should say from now on, not a decision to redraw a figure from
|
|
1695
|
+
* data that has changed underneath it. Re-rendering a panel is done by
|
|
1696
|
+
* reopening it, deliberately, one panel at a time.
|
|
1697
|
+
*/
|
|
1698
|
+
async acceptChangedSource(sourceId) {
|
|
1699
|
+
const source = this.state.source(sourceId);
|
|
1700
|
+
if (!source || !source.datasource) return;
|
|
1701
|
+
const described = await this.api.describeSource(source.datasource);
|
|
1702
|
+
if (!described.ok) return;
|
|
1703
|
+
|
|
1704
|
+
const changes = {
|
|
1705
|
+
image: described.data.source.image,
|
|
1706
|
+
channels: described.data.source.channels,
|
|
1707
|
+
fingerprint: described.data.source.fingerprint,
|
|
1708
|
+
status: "ok",
|
|
1709
|
+
};
|
|
1710
|
+
await this.state.commit(
|
|
1711
|
+
[{ op: "update_source", source_id: sourceId, changes: changes }],
|
|
1712
|
+
(draft) => { Object.assign(draft.sources[sourceId], changes); });
|
|
1713
|
+
// The status the context bar shows is computed by the server on read,
|
|
1714
|
+
// so the local copy has to be told too or the badge stays until a
|
|
1715
|
+
// reload.
|
|
1716
|
+
this.state.sourceStatus[sourceId] = { status: "ok", reasons: [] };
|
|
1717
|
+
this.render();
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// -- zoom and pan ----------------------------------------------------------
|
|
1721
|
+
|
|
1722
|
+
setScale(scale) {
|
|
1723
|
+
this.canvas.setScale(scale);
|
|
1724
|
+
this.afterZoom();
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
zoomToFit() {
|
|
1728
|
+
this.canvas.zoomToFit(this.el("fb_canvas_scroll"));
|
|
1729
|
+
this.afterZoom();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
afterZoom() {
|
|
1733
|
+
this.renderZoom();
|
|
1734
|
+
this.viewOptions?.apply();
|
|
1735
|
+
this.contextBar?.position();
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* Zoom about a point on screen, keeping what is under it still.
|
|
1740
|
+
*
|
|
1741
|
+
* Measured before and after rather than computed from the scroll offset,
|
|
1742
|
+
* because the page is centred with `margin: auto` whenever it is narrower
|
|
1743
|
+
* than the viewport -- so its left edge is not a function of scrollLeft and
|
|
1744
|
+
* the arithmetic that assumes it is drifts by half the slack, which is most
|
|
1745
|
+
* of the window when a page is zoomed out.
|
|
1746
|
+
*/
|
|
1747
|
+
zoomAt(clientX, clientY, factor) {
|
|
1748
|
+
const scroll = this.el("fb_canvas_scroll");
|
|
1749
|
+
const pageEl = this.el("fb_page");
|
|
1750
|
+
if (!scroll || !pageEl) return;
|
|
1751
|
+
|
|
1752
|
+
const before = pageEl.getBoundingClientRect();
|
|
1753
|
+
const mmX = (clientX - before.left) / this.canvas.scale;
|
|
1754
|
+
const mmY = (clientY - before.top) / this.canvas.scale;
|
|
1755
|
+
|
|
1756
|
+
const wanted = this.canvas.scale * factor;
|
|
1757
|
+
this.canvas.setScale(wanted);
|
|
1758
|
+
// setScale clamps, so the point is recomputed from where it actually
|
|
1759
|
+
// landed rather than from where it was asked to.
|
|
1760
|
+
const after = pageEl.getBoundingClientRect();
|
|
1761
|
+
scroll.scrollLeft += (after.left + mmX * this.canvas.scale) - clientX;
|
|
1762
|
+
scroll.scrollTop += (after.top + mmY * this.canvas.scale) - clientY;
|
|
1763
|
+
this.afterZoom();
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Fill the window with what is selected.
|
|
1768
|
+
*
|
|
1769
|
+
* Padded rather than edge to edge: a selection that fills the viewport
|
|
1770
|
+
* exactly hides what is next to it, and "is this panel the same size as the
|
|
1771
|
+
* one beside it" is most of what anybody zooms in to check.
|
|
1772
|
+
*/
|
|
1773
|
+
zoomToSelection() {
|
|
1774
|
+
const boxes = this.canvas.selectedPanels()
|
|
1775
|
+
.filter((panel) => panel.placement).map((panel) => panel.placement)
|
|
1776
|
+
.concat(this.canvas.selectedAnnotations().map((annotation) => ({
|
|
1777
|
+
x_mm: annotation.geometry.x_mm + Math.min(0, annotation.geometry.w_mm),
|
|
1778
|
+
y_mm: annotation.geometry.y_mm + Math.min(0, annotation.geometry.h_mm),
|
|
1779
|
+
w_mm: Math.abs(annotation.geometry.w_mm),
|
|
1780
|
+
h_mm: Math.abs(annotation.geometry.h_mm),
|
|
1781
|
+
})));
|
|
1782
|
+
const scroll = this.el("fb_canvas_scroll");
|
|
1783
|
+
if (!boxes.length || !scroll) {
|
|
1784
|
+
this.zoomToFit();
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
const left = Math.min(...boxes.map((box) => box.x_mm));
|
|
1788
|
+
const top = Math.min(...boxes.map((box) => box.y_mm));
|
|
1789
|
+
const right = Math.max(...boxes.map((box) => box.x_mm + box.w_mm));
|
|
1790
|
+
const bottom = Math.max(...boxes.map((box) => box.y_mm + box.h_mm));
|
|
1791
|
+
|
|
1792
|
+
const padding = 64;
|
|
1793
|
+
this.canvas.setScale(Math.min(
|
|
1794
|
+
(scroll.clientWidth - padding) / Math.max(1, right - left),
|
|
1795
|
+
(scroll.clientHeight - padding) / Math.max(1, bottom - top)));
|
|
1796
|
+
|
|
1797
|
+
const pageEl = this.el("fb_page");
|
|
1798
|
+
const paper = pageEl.getBoundingClientRect();
|
|
1799
|
+
const view = scroll.getBoundingClientRect();
|
|
1800
|
+
scroll.scrollLeft += (paper.left + this.canvas.toPx((left + right) / 2))
|
|
1801
|
+
- (view.left + view.width / 2);
|
|
1802
|
+
scroll.scrollTop += (paper.top + this.canvas.toPx((top + bottom) / 2))
|
|
1803
|
+
- (view.top + view.height / 2);
|
|
1804
|
+
this.afterZoom();
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// -- rendering -------------------------------------------------------
|
|
1808
|
+
|
|
1809
|
+
render() {
|
|
1810
|
+
if (!this.state.document || !this.root) return;
|
|
1811
|
+
const title = this.el("fb_title");
|
|
1812
|
+
if (title && title !== document.activeElement) title.value = this.state.title;
|
|
1813
|
+
|
|
1814
|
+
this.renderPageList();
|
|
1815
|
+
this.canvas.render();
|
|
1816
|
+
if (!this._fitted) {
|
|
1817
|
+
this._fitted = true;
|
|
1818
|
+
this.canvas.zoomToFit(this.el("fb_canvas_scroll"));
|
|
1819
|
+
}
|
|
1820
|
+
// Margins move with the page, the grid and the rulers move with the
|
|
1821
|
+
// zoom, and both can have changed by the time this runs.
|
|
1822
|
+
this.viewOptions?.apply();
|
|
1823
|
+
// The annotation being typed into is hidden behind its editor, and the
|
|
1824
|
+
// render above has just put it back. Re-hidden here rather than in the
|
|
1825
|
+
// canvas, which does not know an editor exists.
|
|
1826
|
+
if (this.textEditor?.active) {
|
|
1827
|
+
const element = this.canvas.surfaceEl.querySelector(
|
|
1828
|
+
`[data-annotation-id="${this.textEditor.annotationId}"]`);
|
|
1829
|
+
// Measured before it is hidden. `visibility: hidden` keeps the
|
|
1830
|
+
// layout box, so the order does not strictly matter -- but the
|
|
1831
|
+
// editor follows the annotation's new geometry, leading and
|
|
1832
|
+
// alignment here, and reading them off it first is the honest way
|
|
1833
|
+
// round.
|
|
1834
|
+
this.textEditor.reposition(element);
|
|
1835
|
+
if (element) element.style.visibility = "hidden";
|
|
1836
|
+
}
|
|
1837
|
+
this.renderZoom();
|
|
1838
|
+
this.renderTray();
|
|
1839
|
+
this.renderHistory();
|
|
1840
|
+
this.renderRail();
|
|
1841
|
+
this.renderPageMeta();
|
|
1842
|
+
this.contextBar?.update(Array.from(this.canvas.selection));
|
|
1843
|
+
this.textPanel?.update(Array.from(this.canvas.selection));
|
|
1844
|
+
this.contextSidebar(Boolean(this.textPanel?.wants));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* The two captions that describe the page rather than change it.
|
|
1849
|
+
*
|
|
1850
|
+
* The size sits on the sheet, above its top-left corner, because it is a
|
|
1851
|
+
* property of the paper; the object count sits in the status bar, because
|
|
1852
|
+
* it is a property of the window's contents. Both are read-only -- the size
|
|
1853
|
+
* is changed from the page menu, and there is nothing to click on a count.
|
|
1854
|
+
*/
|
|
1855
|
+
renderPageMeta() {
|
|
1856
|
+
const page = this.canvas.page;
|
|
1857
|
+
const caption = this.el("fb_sheet_caption");
|
|
1858
|
+
if (caption) {
|
|
1859
|
+
// Rounded to a tenth and stripped of a trailing zero: A4 is 210 mm,
|
|
1860
|
+
// not 210.0 mm, and a custom page of 148.5 keeps its half.
|
|
1861
|
+
const mm = (value) => String(Math.round(value * 10) / 10);
|
|
1862
|
+
caption.textContent = page
|
|
1863
|
+
? `${mm(page.size_mm.w)} × ${mm(page.size_mm.h)} mm`
|
|
1864
|
+
: "";
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const count = this.el("fb_object_count");
|
|
1868
|
+
if (!count) return;
|
|
1869
|
+
if (!page) {
|
|
1870
|
+
count.textContent = "";
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const placed = FigureSchema.panelsOnPage(this.state.document, page.page_id).length
|
|
1874
|
+
+ Object.values(this.state.document.annotations)
|
|
1875
|
+
.filter((annotation) => annotation.page_id === page.page_id).length;
|
|
1876
|
+
count.textContent = placed === 1 ? "1 object" : `${placed} objects`;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
selectionChanged(ids) {
|
|
1880
|
+
// Selecting on the page is a statement about the page, so the tray's
|
|
1881
|
+
// own selection lets go: two highlighted sets meaning two different
|
|
1882
|
+
// things is what makes "which of these does Delete act on?"
|
|
1883
|
+
// unanswerable.
|
|
1884
|
+
if (ids.length && this.traySelection.size) {
|
|
1885
|
+
this.traySelection.clear();
|
|
1886
|
+
this.renderTray();
|
|
1887
|
+
}
|
|
1888
|
+
this.renderRail();
|
|
1889
|
+
this.contextBar?.update(ids);
|
|
1890
|
+
this.textPanel?.update(ids);
|
|
1891
|
+
this.contextSidebar(Boolean(this.textPanel?.wants));
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
renderPageList() {
|
|
1895
|
+
const select = this.el("fb_page_select");
|
|
1896
|
+
const pages = this.state.pages;
|
|
1897
|
+
if (!pages.some((page) => page.page_id === this.canvas.pageId)) {
|
|
1898
|
+
this.canvas.pageId = pages.length ? pages[0].page_id : null;
|
|
1899
|
+
}
|
|
1900
|
+
if (!select) return;
|
|
1901
|
+
select.innerHTML = pages.map((page) =>
|
|
1902
|
+
`<option value="${FigureSchema.escapeHtml(page.page_id)}">${FigureSchema.escapeHtml(page.name)}</option>`
|
|
1903
|
+
).join("");
|
|
1904
|
+
select.value = this.canvas.pageId || "";
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
renderZoom() {
|
|
1908
|
+
const readout = this.el("fb_zoom_readout");
|
|
1909
|
+
if (readout) {
|
|
1910
|
+
readout.textContent =
|
|
1911
|
+
Math.round((this.canvas.scale / FigureWorkspace.PX_PER_MM) * 100) + "%";
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
renderHistory() {
|
|
1916
|
+
const undo = this.el("fb_undo");
|
|
1917
|
+
const redo = this.el("fb_redo");
|
|
1918
|
+
if (undo) undo.disabled = !this.state.canUndo;
|
|
1919
|
+
if (redo) redo.disabled = !this.state.canRedo;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
renderTray() {
|
|
1923
|
+
const all = FigureSchema.panelsInTray(this.state.document);
|
|
1924
|
+
const shown = this.trayPanels();
|
|
1925
|
+
|
|
1926
|
+
const wrap = this.el("fb_tray_search_wrap");
|
|
1927
|
+
if (wrap) wrap.hidden = all.length < FigureWorkspace.SEARCH_THRESHOLD;
|
|
1928
|
+
const clear = this.el("fb_tray_search_clear");
|
|
1929
|
+
if (clear) clear.hidden = !this.traySearch;
|
|
1930
|
+
const count = this.el("fb_tray_count");
|
|
1931
|
+
if (count) {
|
|
1932
|
+
count.textContent = all.length
|
|
1933
|
+
? (shown.length === all.length
|
|
1934
|
+
? String(all.length)
|
|
1935
|
+
: `${shown.length} / ${all.length}`)
|
|
1936
|
+
: "";
|
|
1937
|
+
}
|
|
1938
|
+
const empty = this.el("fb_tray_empty");
|
|
1939
|
+
if (empty) empty.hidden = all.length > 0;
|
|
1940
|
+
|
|
1941
|
+
// Only while the card is shut. Open, the count is already in its
|
|
1942
|
+
// heading, and two copies of the same number a hundred pixels apart
|
|
1943
|
+
// read as two different numbers.
|
|
1944
|
+
const badge = this.el("fb_tray_badge");
|
|
1945
|
+
const panel = this.el("fb_tray_panel");
|
|
1946
|
+
if (badge) {
|
|
1947
|
+
badge.textContent = (all.length && panel?.hidden) ? String(all.length) : "";
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const strip = this.el("fb_tray_strip");
|
|
1951
|
+
if (!strip) return;
|
|
1952
|
+
// Anything filtered out of view is out of the selection too: a batch
|
|
1953
|
+
// placed from a search box has to be the batch that was visible.
|
|
1954
|
+
const visible = new Set(shown.map((panel) => panel.panel_id));
|
|
1955
|
+
for (const id of Array.from(this.traySelection)) {
|
|
1956
|
+
if (!visible.has(id)) this.traySelection.delete(id);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (all.length && !shown.length) {
|
|
1960
|
+
strip.innerHTML = '<p class="fb-muted fb-tray-nomatch">No panel matches that.</p>';
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
strip.innerHTML = shown.map((panel) => {
|
|
1964
|
+
const source = this.state.source(panel.source_id);
|
|
1965
|
+
const span = FigureSchema.physicalWidthUm(source, panel.scene.viewport);
|
|
1966
|
+
const caption = panel.title
|
|
1967
|
+
|| (source && (source.display_name || source.datasource))
|
|
1968
|
+
|| "Untitled panel";
|
|
1969
|
+
const detail = span ? FigureSchema.formatMicrons(span) + " wide" : "";
|
|
1970
|
+
const selected = this.traySelection.has(panel.panel_id);
|
|
1971
|
+
// The tiles are two across and mostly picture now, so the caption
|
|
1972
|
+
// is behind a hover -- which makes this the accessible name as well
|
|
1973
|
+
// as the tooltip, and it has to carry both lines.
|
|
1974
|
+
const tip = detail ? `${caption} — ${detail}` : caption;
|
|
1975
|
+
// And the tile is the SHAPE of the region it shows, before the
|
|
1976
|
+
// picture arrives: the preview is this viewport, so the ratio is
|
|
1977
|
+
// known without waiting for the file. A grid that resized itself as
|
|
1978
|
+
// twelve lazy images loaded would move the tile out from under the
|
|
1979
|
+
// pointer that was reaching for it.
|
|
1980
|
+
const view = panel.scene && panel.scene.viewport;
|
|
1981
|
+
const ratio = (view && view.w > 0 && view.h > 0)
|
|
1982
|
+
? ` style="aspect-ratio:${Number(view.w)} / ${Number(view.h)}"`
|
|
1983
|
+
: "";
|
|
1984
|
+
return `<div class="fb-tray-item${selected ? " is-selected" : ""}" draggable="true"
|
|
1985
|
+
role="option" aria-selected="${selected}"
|
|
1986
|
+
data-panel-id="${FigureSchema.escapeHtml(panel.panel_id)}"
|
|
1987
|
+
aria-label="${FigureSchema.escapeHtml(tip)}"
|
|
1988
|
+
title="${FigureSchema.escapeHtml(tip)}">
|
|
1989
|
+
<img src="${this.api.previewUrl(this.figureId, panel.panel_id, panel.render_revision)}"
|
|
1990
|
+
alt="" draggable="false" loading="lazy"${ratio}>
|
|
1991
|
+
<span class="fb-tray-item-text">
|
|
1992
|
+
<span class="fb-tray-item-name">${FigureSchema.escapeHtml(caption)}</span>
|
|
1993
|
+
<span class="fb-tray-item-meta">${FigureSchema.escapeHtml(detail)}</span>
|
|
1994
|
+
</span>
|
|
1995
|
+
</div>`;
|
|
1996
|
+
}).join("");
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
renderStatus(payload) {
|
|
2000
|
+
const status = this.el("fb_save_status");
|
|
2001
|
+
if (status) {
|
|
2002
|
+
status.textContent = {
|
|
2003
|
+
loading: "Opening…",
|
|
2004
|
+
saving: "Saving…",
|
|
2005
|
+
saved: "Saved",
|
|
2006
|
+
unsaved: "Unsaved changes",
|
|
2007
|
+
failed: payload.detail || "Save failed",
|
|
2008
|
+
conflict: "Changed elsewhere",
|
|
2009
|
+
unreadable: "Cannot be opened",
|
|
2010
|
+
}[payload.status] || "";
|
|
2011
|
+
status.className = "fb-save-status fb-status-" + payload.status;
|
|
2012
|
+
}
|
|
2013
|
+
const conflict = this.el("fb_conflict_banner");
|
|
2014
|
+
if (conflict) conflict.hidden = payload.status !== "conflict";
|
|
2015
|
+
const unreadable = this.el("fb_unreadable_banner");
|
|
2016
|
+
if (unreadable) {
|
|
2017
|
+
unreadable.hidden = payload.status !== "unreadable";
|
|
2018
|
+
const detail = this.el("fb_unreadable_detail");
|
|
2019
|
+
if (detail) detail.textContent = payload.detail || "";
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
if (typeof document !== "undefined" && document.addEventListener) {
|
|
2025
|
+
if (document.readyState === "loading") {
|
|
2026
|
+
document.addEventListener("DOMContentLoaded", () => FigureWorkspace.boot());
|
|
2027
|
+
} else {
|
|
2028
|
+
FigureWorkspace.boot();
|
|
2029
|
+
}
|
|
2030
|
+
}
|