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,2362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigureCanvas - laying panels out on a page.
|
|
3
|
+
*
|
|
4
|
+
* Everything positional is in MILLIMETRES, converted to CSS pixels by exactly
|
|
5
|
+
* one number (`scale`) at the moment of drawing. Nothing is ever stored in
|
|
6
|
+
* pixels. That is what makes zooming free, what makes the same figure lay out
|
|
7
|
+
* identically on a different monitor, and what makes "4 mm gutter" mean 4 mm in
|
|
8
|
+
* the exported PDF rather than 4 mm on whichever screen it was nudged into
|
|
9
|
+
* place on.
|
|
10
|
+
*
|
|
11
|
+
* Panels are cached preview rasters -- <img> elements, one HTTP request each.
|
|
12
|
+
* Never live viewers: a figure with a hundred panels would otherwise be a
|
|
13
|
+
* hundred WebGL contexts and a hundred tile queues, and the browser stops
|
|
14
|
+
* responding long before the figure is finished. Editing a panel's SCENE hands
|
|
15
|
+
* off to the one live viewer (see onEditPanel).
|
|
16
|
+
*
|
|
17
|
+
* ## Drags are provisional until they are released
|
|
18
|
+
*
|
|
19
|
+
* A pointer move writes inline styles and nothing else. One `move_panels`
|
|
20
|
+
* operation is committed on release, which is what makes a drag of five
|
|
21
|
+
* selected panels one undo step, and what keeps a save out of the pointer path
|
|
22
|
+
* -- a request per mousemove would be hundreds of writes for one gesture whose
|
|
23
|
+
* intermediate positions nobody wants.
|
|
24
|
+
*
|
|
25
|
+
* ## Snapping
|
|
26
|
+
*
|
|
27
|
+
* Candidate lines come from the page (edges, margins, centre) and from every
|
|
28
|
+
* other panel (its three horizontal and three vertical lines). The threshold is
|
|
29
|
+
* in SCREEN pixels, converted to mm per gesture: a fixed mm threshold is
|
|
30
|
+
* unusably sticky zoomed in and useless zoomed out.
|
|
31
|
+
*/
|
|
32
|
+
class FigureCanvas {
|
|
33
|
+
|
|
34
|
+
/** How close, in SCREEN pixels, snaps. */
|
|
35
|
+
static get SNAP_PIXELS() { return 6; }
|
|
36
|
+
|
|
37
|
+
/** Nudge distance in mm, and the coarse one with Shift. */
|
|
38
|
+
static get NUDGE_MM() { return 0.5; }
|
|
39
|
+
static get NUDGE_COARSE_MM() { return 5; }
|
|
40
|
+
|
|
41
|
+
/** Smallest panel, in mm. Below this the handles overlap and it cannot be
|
|
42
|
+
* grabbed to make it bigger again. */
|
|
43
|
+
static get MIN_SIZE_MM() { return 5; }
|
|
44
|
+
|
|
45
|
+
/** The floor for a TEXT box, which is not the same problem.
|
|
46
|
+
*
|
|
47
|
+
* A single 8 pt line is 8 x 1.2 x 25.4/72 = 3.39mm tall, so the 5mm floor
|
|
48
|
+
* would inflate every one-line caption by nearly half and make `autofit`
|
|
49
|
+
* fight the clamp on every commit. */
|
|
50
|
+
static get MIN_TEXT_MM() { return 1; }
|
|
51
|
+
|
|
52
|
+
/** How far a duplicate or a paste lands from the original. Enough to see
|
|
53
|
+
* that there are two of something, small enough to still be next to it. */
|
|
54
|
+
static get PASTE_OFFSET_MM() { return 4; }
|
|
55
|
+
|
|
56
|
+
/** What `page.background` says instead of a colour. Matches
|
|
57
|
+
* `server/schema.TRANSPARENT`. */
|
|
58
|
+
static get TRANSPARENT() { return "transparent"; }
|
|
59
|
+
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.state = options.state;
|
|
62
|
+
this.api = options.api;
|
|
63
|
+
this.figureId = options.figureId;
|
|
64
|
+
this.onEditPanel = options.onEditPanel || (() => {});
|
|
65
|
+
this.onSelectionChange = options.onSelectionChange || (() => {});
|
|
66
|
+
//: Told when a drag starts and stops, so chrome that floats over the
|
|
67
|
+
//: page can get out of the way of it. Not an event bus: there is one
|
|
68
|
+
//: listener and it is the workspace that built this.
|
|
69
|
+
this.onGesture = options.onGesture || (() => {});
|
|
70
|
+
//: A drawing tool has placed something and the rail should stand down.
|
|
71
|
+
this.onToolFinished = options.onToolFinished || (() => {});
|
|
72
|
+
//: Open the in-place editor on a text annotation. The editor lives in
|
|
73
|
+
//: the overlay layer, which this does not own -- render() would destroy
|
|
74
|
+
//: it mid-keystroke.
|
|
75
|
+
this.onEditText = options.onEditText || (() => {});
|
|
76
|
+
|
|
77
|
+
this.pageEl = options.pageEl;
|
|
78
|
+
this.surfaceEl = options.surfaceEl;
|
|
79
|
+
this.guideEl = options.guideEl || null;
|
|
80
|
+
|
|
81
|
+
this.scale = 96 / 25.4;
|
|
82
|
+
this.pageId = null;
|
|
83
|
+
this.selection = new Set();
|
|
84
|
+
//: {kind, items:[{kind, id, start}], origin, handle} while a gesture is
|
|
85
|
+
//: in flight. Null the rest of the time, which is what every handler
|
|
86
|
+
//: below tests instead of a set of booleans.
|
|
87
|
+
this.gesture = null;
|
|
88
|
+
//: The armed drawing tool -- "text", "rect", "ellipse", "line",
|
|
89
|
+
//: "arrow" -- or null for select. One-shot: see setTool.
|
|
90
|
+
this.tool = null;
|
|
91
|
+
//: What a drag may snap onto, from the View menu. Held here rather than
|
|
92
|
+
//: read from a preference store on every pointer move.
|
|
93
|
+
this.snapping = { guides: true, grid: false, gridMm: 5 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -- units -----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
toPx(mm) { return mm * this.scale; }
|
|
99
|
+
|
|
100
|
+
toMm(px) { return px / this.scale; }
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The page being edited, or null when there is not one yet.
|
|
104
|
+
*
|
|
105
|
+
* Null is a real answer here rather than a defensive shrug: the canvas is
|
|
106
|
+
* constructed and wired before `state.load()` has returned, and the View
|
|
107
|
+
* menu draws the margins from this during that window. Every caller already
|
|
108
|
+
* tests it -- `render`, `zoomToFit`, `drawMargins` -- so the one place that
|
|
109
|
+
* has to know a document can be absent is this getter.
|
|
110
|
+
*/
|
|
111
|
+
get page() {
|
|
112
|
+
if (!this.state.document) return null;
|
|
113
|
+
return FigureSchema.pageById(this.state.document, this.pageId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// -- lifecycle -------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
setup() {
|
|
119
|
+
this.surfaceEl.addEventListener("pointerdown", (event) => this.pointerDown(event));
|
|
120
|
+
// On the window rather than on the surface: a fast drag leaves the
|
|
121
|
+
// element behind, and a move handler bound to the panel stops firing
|
|
122
|
+
// the moment the pointer outruns it.
|
|
123
|
+
this._onMove = (event) => this.pointerMove(event);
|
|
124
|
+
this._onUp = (event) => this.pointerUp(event);
|
|
125
|
+
window.addEventListener("pointermove", this._onMove);
|
|
126
|
+
window.addEventListener("pointerup", this._onUp);
|
|
127
|
+
this._onKey = (event) => this.keyDown(event);
|
|
128
|
+
window.addEventListener("keydown", this._onKey);
|
|
129
|
+
|
|
130
|
+
this.surfaceEl.addEventListener("dragover", (event) => {
|
|
131
|
+
if (event.dataTransfer?.types.includes("text/x-plexora-panel")) event.preventDefault();
|
|
132
|
+
});
|
|
133
|
+
this.surfaceEl.addEventListener("drop", (event) => this.dropFromTray(event));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
destroy() {
|
|
137
|
+
window.removeEventListener("pointermove", this._onMove);
|
|
138
|
+
window.removeEventListener("pointerup", this._onUp);
|
|
139
|
+
window.removeEventListener("keydown", this._onKey);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -- rendering -------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
setPage(pageId) {
|
|
145
|
+
this.pageId = pageId;
|
|
146
|
+
this.render();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setScale(scale) {
|
|
150
|
+
this.scale = Math.max(0.4, Math.min(20, scale));
|
|
151
|
+
this.render();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
zoomToFit(viewportEl) {
|
|
155
|
+
const page = this.page;
|
|
156
|
+
if (!page || !viewportEl) return;
|
|
157
|
+
const margin = 48;
|
|
158
|
+
// clientWidth INCLUDES the padding, and the scroll surface's left
|
|
159
|
+
// padding is what holds the sheet clear of the floating rail and tray
|
|
160
|
+
// -- two hundred and eighty pixels of it with the tray open. Fitting to
|
|
161
|
+
// the padding box put the page that far off the right edge of the
|
|
162
|
+
// window. Guarded because the layout probes drive this class with stub
|
|
163
|
+
// elements that have no owner document.
|
|
164
|
+
const view = viewportEl.ownerDocument?.defaultView;
|
|
165
|
+
const box = view?.getComputedStyle ? view.getComputedStyle(viewportEl) : null;
|
|
166
|
+
const pad = (name) => (box ? parseFloat(box[name]) || 0 : 0);
|
|
167
|
+
const width = viewportEl.clientWidth - pad("paddingLeft") - pad("paddingRight");
|
|
168
|
+
const height = viewportEl.clientHeight - pad("paddingTop") - pad("paddingBottom");
|
|
169
|
+
this.setScale(Math.min(
|
|
170
|
+
(width - margin) / page.size_mm.w,
|
|
171
|
+
(height - margin) / page.size_mm.h));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render() {
|
|
175
|
+
const page = this.page;
|
|
176
|
+
if (!page) return;
|
|
177
|
+
|
|
178
|
+
this.pageEl.style.width = this.toPx(page.size_mm.w) + "px";
|
|
179
|
+
this.pageEl.style.height = this.toPx(page.size_mm.h) + "px";
|
|
180
|
+
// A transparent page is drawn as the conventional checkerboard, from a
|
|
181
|
+
// class rather than an inline colour: "transparent" as a CSS background
|
|
182
|
+
// would show the dark app surface behind it, which reads as a black
|
|
183
|
+
// page rather than as no page.
|
|
184
|
+
const clear = page.background === FigureCanvas.TRANSPARENT;
|
|
185
|
+
this.pageEl.classList.toggle("is-transparent", clear);
|
|
186
|
+
this.pageEl.style.background = clear ? "" : page.background;
|
|
187
|
+
|
|
188
|
+
const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId);
|
|
189
|
+
const labelStyle = this.state.document.settings.label_style;
|
|
190
|
+
const annotations = Object.values(this.state.document.annotations)
|
|
191
|
+
.filter((annotation) => annotation.page_id === this.pageId);
|
|
192
|
+
|
|
193
|
+
this.surfaceEl.innerHTML =
|
|
194
|
+
panels.map((panel, index) => this.panelMarkup(panel, index, labelStyle)).join("")
|
|
195
|
+
+ annotations.map((annotation) => this.annotationMarkup(annotation)).join("");
|
|
196
|
+
this.clearGuides();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
panelMarkup(panel, index, labelStyle) {
|
|
200
|
+
const place = panel.placement;
|
|
201
|
+
const selected = this.selection.has(panel.panel_id);
|
|
202
|
+
const label = panel.label.auto ? FigureSchema.labelFor(index, labelStyle) : panel.label.text;
|
|
203
|
+
const source = this.state.source(panel.source_id);
|
|
204
|
+
const status = this.state.sourceStatus[panel.source_id]?.status || "ok";
|
|
205
|
+
|
|
206
|
+
return `<div class="fb-panel${selected ? " is-selected" : ""}"
|
|
207
|
+
data-panel-id="${FigureSchema.escapeHtml(panel.panel_id)}"
|
|
208
|
+
style="left:${this.toPx(place.x_mm)}px;top:${this.toPx(place.y_mm)}px;
|
|
209
|
+
width:${this.toPx(place.w_mm)}px;height:${this.toPx(place.h_mm)}px;
|
|
210
|
+
z-index:${place.z}">
|
|
211
|
+
<img class="fb-panel-image" draggable="false"
|
|
212
|
+
src="${this.panelImageUrl(panel, source)}"
|
|
213
|
+
alt="" onerror="this.classList.add('fb-panel-image-missing')">
|
|
214
|
+
${this.legendMarkup(panel)}
|
|
215
|
+
${this.scaleBarMarkup(panel, source, place)}
|
|
216
|
+
${panel.label.visible && label
|
|
217
|
+
? `<span class="fb-panel-label">${FigureSchema.escapeHtml(label)}</span>` : ""}
|
|
218
|
+
${panel.title ? `<span class="fb-panel-title">${FigureSchema.escapeHtml(panel.title)}</span>` : ""}
|
|
219
|
+
${status !== "ok"
|
|
220
|
+
? `<span class="fb-panel-badge fb-panel-badge-${status}"
|
|
221
|
+
title="This panel's source has ${status === "missing" ? "gone" : "changed"}">
|
|
222
|
+
<span class="fas fa-triangle-exclamation"></span></span>` : ""}
|
|
223
|
+
${selected ? this.handlesMarkup() : ""}
|
|
224
|
+
</div>`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A scale bar, or nothing at all.
|
|
229
|
+
*
|
|
230
|
+
* Nothing, specifically, when the source has no physical calibration --
|
|
231
|
+
* never a bar drawn from an assumed pixel size, which is wrong and looks
|
|
232
|
+
* exactly like one that is right.
|
|
233
|
+
*/
|
|
234
|
+
scaleBarMarkup(panel, source, place) {
|
|
235
|
+
if (!panel.scalebar.visible) return "";
|
|
236
|
+
const span = FigureSchema.physicalWidthUm(source, panel.scene.viewport);
|
|
237
|
+
if (!span) return "";
|
|
238
|
+
const length = panel.scalebar.target_um || FigureSchema.scaleBarLength(span);
|
|
239
|
+
const fraction = length / span;
|
|
240
|
+
if (!(fraction > 0) || fraction > 1) return "";
|
|
241
|
+
return `<span class="fb-panel-scalebar" style="width:${(fraction * 100).toFixed(2)}%">
|
|
242
|
+
<span class="fb-panel-scalebar-label">${FigureSchema.escapeHtml(
|
|
243
|
+
FigureSchema.formatMicrons(length))}</span>
|
|
244
|
+
</span>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Where a panel's picture comes from on screen.
|
|
249
|
+
*
|
|
250
|
+
* An imported asset is served straight from the figure's own directory:
|
|
251
|
+
* there is nothing to preview because the file IS the panel, and rendering
|
|
252
|
+
* a preview of it would be storing a worse copy of something already here.
|
|
253
|
+
* Everything else is the cached capture raster.
|
|
254
|
+
*/
|
|
255
|
+
panelImageUrl(panel, source) {
|
|
256
|
+
if (source && source.kind === "imported_asset" && source.asset_id) {
|
|
257
|
+
return this.api.assetUrl(this.figureId, source.asset_id);
|
|
258
|
+
}
|
|
259
|
+
return this.api.previewUrl(this.figureId, panel.panel_id, panel.render_revision);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The panel's legend, drawn from what was recorded at capture time.
|
|
264
|
+
*
|
|
265
|
+
* Never from the live plugins. A legend regenerated from a palette that has
|
|
266
|
+
* since changed is a legend that disagrees with the panel above it -- and
|
|
267
|
+
* on a figure whose plugin is not even installed there would be nothing to
|
|
268
|
+
* regenerate it from. Each plugin computes its rows once, at capture, and
|
|
269
|
+
* they travel with the panel; see the capture bridge.
|
|
270
|
+
*/
|
|
271
|
+
legendMarkup(panel) {
|
|
272
|
+
const rows = [];
|
|
273
|
+
if (panel.legend.channels) {
|
|
274
|
+
for (const channel of panel.scene.channels || []) {
|
|
275
|
+
const color = `rgb(${channel.color.r},${channel.color.g},${channel.color.b})`;
|
|
276
|
+
rows.push(this.legendRow(color, channel.fullname_at_capture || channel.key));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (panel.legend.plugins) {
|
|
280
|
+
for (const contribution of Object.values(panel.scene.plugins || {})) {
|
|
281
|
+
for (const entry of contribution.legend || []) {
|
|
282
|
+
if (entry.kind === "continuous") {
|
|
283
|
+
rows.push(this.legendRamp(entry));
|
|
284
|
+
} else {
|
|
285
|
+
rows.push(this.legendRow(entry.color, entry.label));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!rows.length) return "";
|
|
291
|
+
return `<div class="fb-panel-legend">${rows.join("")}</div>`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
legendRow(color, label) {
|
|
295
|
+
return `<span class="fb-legend-row">
|
|
296
|
+
<span class="fb-legend-swatch" style="background:${FigureSchema.escapeHtml(color)}"></span>
|
|
297
|
+
<span>${FigureSchema.escapeHtml(label)}</span>
|
|
298
|
+
</span>`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
legendRamp(entry) {
|
|
302
|
+
const stops = (entry.ramp || []).map((color) => FigureSchema.escapeHtml(color)).join(",");
|
|
303
|
+
const [low, high] = entry.domain || [0, 1];
|
|
304
|
+
return `<span class="fb-legend-row">
|
|
305
|
+
<span class="fb-legend-ramp" style="background:linear-gradient(to right,${stops})"></span>
|
|
306
|
+
<span>${FigureSchema.escapeHtml(this.formatNumber(low))}–${FigureSchema.escapeHtml(this.formatNumber(high))}</span>
|
|
307
|
+
</span>`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
formatNumber(value) {
|
|
311
|
+
if (!Number.isFinite(value)) return "";
|
|
312
|
+
const magnitude = Math.abs(value);
|
|
313
|
+
if (magnitude >= 1000 || (magnitude > 0 && magnitude < 0.01)) return value.toExponential(1);
|
|
314
|
+
return String(Math.round(value * 100) / 100);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
handlesMarkup(rotatable) {
|
|
318
|
+
const handles = ["nw", "ne", "se", "sw", "n", "e", "s", "w"].map((handle) =>
|
|
319
|
+
`<span class="fb-handle fb-handle-${handle}" data-handle="${handle}"></span>`).join("");
|
|
320
|
+
// The rotate handle stands OFF the top edge rather than sitting on it:
|
|
321
|
+
// on the outline it would land under the `n` handle, and at a caption's
|
|
322
|
+
// size those are the same few pixels.
|
|
323
|
+
return rotatable
|
|
324
|
+
? handles + '<span class="fb-handle fb-handle-rotate" data-handle="rotate"></span>'
|
|
325
|
+
: handles;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Points per millimetre. Annotation stroke and font sizes are stored in
|
|
329
|
+
* points because that is what the PDF exporter draws in. */
|
|
330
|
+
static get PT_PER_MM() { return 2.8346; }
|
|
331
|
+
|
|
332
|
+
annotationMarkup(annotation) {
|
|
333
|
+
if (annotation.type === "line" || annotation.type === "arrow") {
|
|
334
|
+
return this.strokeMarkup(annotation);
|
|
335
|
+
}
|
|
336
|
+
const geometry = annotation.geometry;
|
|
337
|
+
const selected = this.selection.has(annotation.annotation_id) ? " is-selected" : "";
|
|
338
|
+
// A rotation turns the whole box about its own centre, AFTER the layout
|
|
339
|
+
// inside it is settled -- the same rule the PDF and raster writers use.
|
|
340
|
+
// CSS carries hit-testing through a transform, so `closest()` keeps
|
|
341
|
+
// working and only `resizedBox` has to know the angle.
|
|
342
|
+
const rotation = geometry.rotation
|
|
343
|
+
? `transform:rotate(${geometry.rotation}deg);transform-origin:center;` : "";
|
|
344
|
+
const style = [
|
|
345
|
+
`left:${this.toPx(geometry.x_mm)}px`,
|
|
346
|
+
`top:${this.toPx(geometry.y_mm)}px`,
|
|
347
|
+
`width:${this.toPx(geometry.w_mm)}px`,
|
|
348
|
+
`height:${this.toPx(geometry.h_mm)}px`,
|
|
349
|
+
`z-index:${1000 + annotation.z}`,
|
|
350
|
+
`color:${annotation.style.color}`,
|
|
351
|
+
].join(";");
|
|
352
|
+
|
|
353
|
+
if (annotation.type === "text") {
|
|
354
|
+
return `<div class="fb-annotation fb-annotation-text${selected}"
|
|
355
|
+
style="${style};${rotation}"
|
|
356
|
+
data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}"
|
|
357
|
+
>${this.textMarkup(annotation)}${
|
|
358
|
+
selected ? this.handlesMarkup(true) : ""}</div>`;
|
|
359
|
+
}
|
|
360
|
+
const fill = annotation.style.fill
|
|
361
|
+
? `background:${annotation.style.fill};` : "";
|
|
362
|
+
return `<div class="fb-annotation fb-annotation-${annotation.type}${selected}"
|
|
363
|
+
style="${style};${rotation}${fill}border-color:${annotation.style.color};
|
|
364
|
+
border-width:${Math.max(1,
|
|
365
|
+
annotation.style.line_width_pt * this.scale / FigureCanvas.PT_PER_MM)}px"
|
|
366
|
+
data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}"
|
|
367
|
+
>${selected ? this.handlesMarkup(true) : ""}</div>`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// -- text ------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Every line of a text annotation, positioned. Pure arithmetic, no DOM.
|
|
374
|
+
*
|
|
375
|
+
* The JavaScript half of `compose._text_layout`, and
|
|
376
|
+
* `test_the_canvas_and_the_exporter_put_the_baseline_in_the_same_place`
|
|
377
|
+
* asserts the two agree to a nanometre. If they drift, the caption sits in
|
|
378
|
+
* one place on screen and another in the PDF -- and nothing says so until
|
|
379
|
+
* somebody opens the export.
|
|
380
|
+
*
|
|
381
|
+
* Where the lines BREAK is not decided here. Only a browser can measure a
|
|
382
|
+
* string, so the browser breaks them once (`FigureRichText.rewrap`) and the
|
|
383
|
+
* break is stored; this only stacks lines that already exist.
|
|
384
|
+
*
|
|
385
|
+
* Baselines come back in millimetres from the PAGE top, matching the
|
|
386
|
+
* exporter. `textMarkup` subtracts the box's own origin.
|
|
387
|
+
*/
|
|
388
|
+
static textLayout(annotation) {
|
|
389
|
+
const style = annotation.style;
|
|
390
|
+
const geometry = annotation.geometry;
|
|
391
|
+
// Falls back to normalising the flat string, because the draft this
|
|
392
|
+
// draws from is OPTIMISTIC: `state.commit` applies the change locally
|
|
393
|
+
// and the server's normaliser -- which is what puts `rich` on an
|
|
394
|
+
// annotation -- has not run yet. Without this a text box drawn a moment
|
|
395
|
+
// ago renders as nothing at all until the page is reloaded.
|
|
396
|
+
const rich = annotation.rich && annotation.rich.lines
|
|
397
|
+
? annotation.rich
|
|
398
|
+
: FigureRichText.normalize(annotation.text || "", null);
|
|
399
|
+
const lines = rich.lines;
|
|
400
|
+
|
|
401
|
+
const measured = lines.map((line) => {
|
|
402
|
+
const metrics = FigureRichText.lineMetrics(line.runs, style);
|
|
403
|
+
return { runs: line.runs, hard: line.hard !== false, ...metrics };
|
|
404
|
+
});
|
|
405
|
+
const block = measured.reduce((total, line) => total + line.lead, 0);
|
|
406
|
+
|
|
407
|
+
let top = geometry.y_mm;
|
|
408
|
+
if (style.valign === "middle") top += (geometry.h_mm - block) / 2;
|
|
409
|
+
else if (style.valign === "bottom") top += geometry.h_mm - block;
|
|
410
|
+
|
|
411
|
+
let cursor = top;
|
|
412
|
+
const out = measured.map((line, index) => {
|
|
413
|
+
// The line sits centred in its own box, half the leading above and
|
|
414
|
+
// half below, so a line mixing an 8 pt caption with a 6 pt
|
|
415
|
+
// superscript lands where a reader expects rather than riding the
|
|
416
|
+
// bottom of the box.
|
|
417
|
+
const halfLead = (line.lead - (line.ascent + line.descent)) / 2;
|
|
418
|
+
const next = measured[index + 1];
|
|
419
|
+
const entry = {
|
|
420
|
+
baseline_mm: cursor + halfLead + line.ascent,
|
|
421
|
+
lead_mm: line.lead,
|
|
422
|
+
runs: line.runs,
|
|
423
|
+
last_of_paragraph: !next || next.hard,
|
|
424
|
+
};
|
|
425
|
+
cursor += line.lead;
|
|
426
|
+
return entry;
|
|
427
|
+
});
|
|
428
|
+
return { block_h_mm: block, lines: out };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A text annotation's words, as SVG inside its box.
|
|
433
|
+
*
|
|
434
|
+
* SVG rather than a styled <div> because `<text y>` places the BASELINE
|
|
435
|
+
* exactly at y. A div's baseline comes from whatever font file the browser
|
|
436
|
+
* actually resolved -- Arial where the PDF holds Helvetica -- and those
|
|
437
|
+
* differ by about 0.09 em, which at 8 pt is a quarter of a millimetre of
|
|
438
|
+
* disagreement that no test could reach. Putting the baseline in the markup
|
|
439
|
+
* takes the browser's guess out of the loop entirely.
|
|
440
|
+
*
|
|
441
|
+
* Each run is its own <text> at a computed pen position, which is the same
|
|
442
|
+
* walk both exporters do, so a line of mixed styling cannot be spaced one
|
|
443
|
+
* way here and another way there. The precedent is `strokeMarkup`, which
|
|
444
|
+
* already draws lines and arrows as inline SVG.
|
|
445
|
+
*/
|
|
446
|
+
textMarkup(annotation) {
|
|
447
|
+
const layout = FigureCanvas.textLayout(annotation);
|
|
448
|
+
const style = annotation.style;
|
|
449
|
+
const boxWidth = this.toPx(annotation.geometry.w_mm);
|
|
450
|
+
const parts = [];
|
|
451
|
+
|
|
452
|
+
for (const line of layout.lines) {
|
|
453
|
+
if (!line.runs.length) continue;
|
|
454
|
+
const runs = line.runs.map((run) => FigureRichText.resolveRun(run, style));
|
|
455
|
+
const widths = runs.map((run) => this.measureRun(run));
|
|
456
|
+
let lineWidth = widths.reduce((total, width) => total + width, 0);
|
|
457
|
+
// Trailing whitespace is excluded from the width so a wrapped line
|
|
458
|
+
// centres on its words. The space the break landed on is kept in
|
|
459
|
+
// the run -- that is what makes the break reversible -- and would
|
|
460
|
+
// otherwise pull every centred line slightly left.
|
|
461
|
+
const last = runs[runs.length - 1];
|
|
462
|
+
if (last.text !== last.text.replace(/\s+$/, "")) {
|
|
463
|
+
lineWidth -= widths[widths.length - 1]
|
|
464
|
+
- this.measureRun({ ...last, text: last.text.replace(/\s+$/, "") });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let pen = 0;
|
|
468
|
+
if (style.align === "center") pen = (boxWidth - lineWidth) / 2;
|
|
469
|
+
else if (style.align === "right") pen = boxWidth - lineWidth;
|
|
470
|
+
let extra = 0;
|
|
471
|
+
if (style.align === "justify" && !line.last_of_paragraph) {
|
|
472
|
+
const gaps = runs.reduce(
|
|
473
|
+
(total, run) => total + (run.text.split(" ").length - 1), 0);
|
|
474
|
+
if (gaps && boxWidth > lineWidth) extra = (boxWidth - lineWidth) / gaps;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const baseline = this.toPx(line.baseline_mm - annotation.geometry.y_mm);
|
|
478
|
+
for (let index = 0; index < runs.length; index += 1) {
|
|
479
|
+
const run = runs[index];
|
|
480
|
+
const spread = extra * (run.text.split(" ").length - 1);
|
|
481
|
+
const width = widths[index] + spread;
|
|
482
|
+
parts.push(this.runMarkup(run, pen, baseline, extra));
|
|
483
|
+
parts.push(this.decorationMarkup(run, pen, baseline, width));
|
|
484
|
+
pen += width;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// overflow visible: a box narrower than a word shows the word running
|
|
488
|
+
// past its edge rather than clipping it, which is the honest picture --
|
|
489
|
+
// the export will do the same.
|
|
490
|
+
return `<svg class="fb-text-svg" width="100%" height="100%" overflow="visible"
|
|
491
|
+
aria-hidden="true">${parts.join("")}</svg>`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
runMarkup(run, x, baseline, wordSpacing) {
|
|
495
|
+
const escape = FigureSchema.escapeHtml.bind(FigureSchema);
|
|
496
|
+
return `<text x="${x}" y="${baseline}" xml:space="preserve"
|
|
497
|
+
font-family='${escape(FigureRichText.cssStack(run.family))}'
|
|
498
|
+
font-size="${this.fontPx(run.size_pt)}"
|
|
499
|
+
${run.bold ? 'font-weight="bold"' : ""}
|
|
500
|
+
${run.italic ? 'font-style="italic"' : ""}
|
|
501
|
+
${wordSpacing ? `word-spacing="${wordSpacing}"` : ""}
|
|
502
|
+
fill="${escape(run.color)}">${escape(run.text)}</text>`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Underline and strike, as drawn rules.
|
|
507
|
+
*
|
|
508
|
+
* Not `text-decoration`: SVG takes the underline's position from the font
|
|
509
|
+
* file the browser resolved, and the PDF has no underline of its own at all
|
|
510
|
+
* -- it is always a drawn rule. Both sides use the same fraction of the em
|
|
511
|
+
* instead, so the mark lands in the same place in both.
|
|
512
|
+
*/
|
|
513
|
+
decorationMarkup(run, x, baseline, width) {
|
|
514
|
+
const em = this.fontPx(run.size_pt);
|
|
515
|
+
const thickness = Math.max(1, em * FigureRichText.UNDERLINE_THICKNESS_EM);
|
|
516
|
+
const rules = [];
|
|
517
|
+
if (run.underline) rules.push(baseline + em * FigureRichText.UNDERLINE_OFFSET_EM);
|
|
518
|
+
if (run.strike) rules.push(baseline - em * FigureRichText.STRIKE_OFFSET_EM);
|
|
519
|
+
return rules.map((y) =>
|
|
520
|
+
`<rect x="${x}" y="${y}" width="${Math.max(0, width)}" height="${thickness}"
|
|
521
|
+
fill="${FigureSchema.escapeHtml(run.color)}"></rect>`).join("");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** A run's type size in CSS pixels at the current zoom. */
|
|
525
|
+
fontPx(sizePt) {
|
|
526
|
+
return sizePt * FigureRichText.MM_PER_PT * this.scale;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* How wide a run is, in CSS pixels.
|
|
531
|
+
*
|
|
532
|
+
* The one genuinely browser-bound piece of text layout, which is why
|
|
533
|
+
* `FigureRichText.rewrap` takes a measuring function rather than calling
|
|
534
|
+
* this: node has no text engine, and a probe that could not measure could
|
|
535
|
+
* not test the breaking algorithm at all.
|
|
536
|
+
*
|
|
537
|
+
* Returns 0 without a real canvas, which is the probe's case -- a fake
|
|
538
|
+
* measurement would be worse than none, because it would look like layout.
|
|
539
|
+
*/
|
|
540
|
+
measureRun(run) {
|
|
541
|
+
if (!this._measureCtx) {
|
|
542
|
+
const canvas = document.createElement("canvas");
|
|
543
|
+
this._measureCtx = canvas.getContext ? canvas.getContext("2d") : null;
|
|
544
|
+
}
|
|
545
|
+
if (!this._measureCtx) return 0;
|
|
546
|
+
this._measureCtx.font = `${run.italic ? "italic " : ""}${run.bold ? "bold " : ""}`
|
|
547
|
+
+ `${this.fontPx(run.size_pt)}px ${FigureRichText.cssStack(run.family)}`;
|
|
548
|
+
return this._measureCtx.measureText(run.text).width;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Re-break a text annotation to its own box width, in millimetres. */
|
|
552
|
+
rewrapAnnotation(annotation) {
|
|
553
|
+
return FigureRichText.rewrap(
|
|
554
|
+
annotation.rich, annotation.geometry.w_mm, annotation.style,
|
|
555
|
+
(text, run) => this.toMm(this.measureRun({ ...run, text: text })));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A line or an arrow, as real SVG.
|
|
560
|
+
*
|
|
561
|
+
* These used to render as empty bordered boxes -- the schema, the
|
|
562
|
+
* operations and the PDF exporter all understood them and the canvas drew a
|
|
563
|
+
* rectangle, so an arrow looked like a rectangle right up until it was
|
|
564
|
+
* exported.
|
|
565
|
+
*
|
|
566
|
+
* Two things make this fiddlier than the other four types:
|
|
567
|
+
*
|
|
568
|
+
* **The geometry is a vector, not a box.** `w_mm` and `h_mm` are legally
|
|
569
|
+
* negative -- they are the offset from the start point to the end point --
|
|
570
|
+
* and a div cannot have a negative width. The element is therefore the
|
|
571
|
+
* NORMALISED bounds, padded, with the line drawn inside it in local
|
|
572
|
+
* coordinates.
|
|
573
|
+
*
|
|
574
|
+
* **A diagonal's bounding box is mostly empty.** A long arrow across a page
|
|
575
|
+
* has a bounding box covering a quarter of it, and a box that took clicks
|
|
576
|
+
* would be a place where selecting panels underneath quietly stopped
|
|
577
|
+
* working. So the container takes no pointer events and a fat transparent
|
|
578
|
+
* line under the visible one does, which is the standard trick and the only
|
|
579
|
+
* one that puts the hit area on the ink.
|
|
580
|
+
*/
|
|
581
|
+
strokeMarkup(annotation) {
|
|
582
|
+
const geometry = annotation.geometry;
|
|
583
|
+
const selected = this.selection.has(annotation.annotation_id);
|
|
584
|
+
const width = this.toPx(geometry.w_mm);
|
|
585
|
+
const height = this.toPx(geometry.h_mm);
|
|
586
|
+
const stroke = Math.max(1,
|
|
587
|
+
annotation.style.line_width_pt * this.scale / FigureCanvas.PT_PER_MM);
|
|
588
|
+
const head = this.arrowHeadPx(annotation.style.line_width_pt);
|
|
589
|
+
const pad = head + stroke + 2;
|
|
590
|
+
|
|
591
|
+
const x1 = pad + (width < 0 ? -width : 0);
|
|
592
|
+
const y1 = pad + (height < 0 ? -height : 0);
|
|
593
|
+
const x2 = pad + (width < 0 ? 0 : width);
|
|
594
|
+
const y2 = pad + (height < 0 ? 0 : height);
|
|
595
|
+
|
|
596
|
+
const parts = [
|
|
597
|
+
`<line class="fb-stroke-hit" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`,
|
|
598
|
+
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
|
|
599
|
+
+ ` stroke="${FigureSchema.escapeHtml(annotation.style.color)}"`
|
|
600
|
+
+ ` stroke-width="${stroke}" stroke-linecap="round"/>`,
|
|
601
|
+
];
|
|
602
|
+
if (annotation.type === "arrow") {
|
|
603
|
+
for (const [hx, hy] of FigureCanvas.arrowHeadPoints(x1, y1, x2, y2, head)) {
|
|
604
|
+
parts.push(`<line x1="${x2}" y1="${y2}" x2="${hx}" y2="${hy}"`
|
|
605
|
+
+ ` stroke="${FigureSchema.escapeHtml(annotation.style.color)}"`
|
|
606
|
+
+ ` stroke-width="${stroke}" stroke-linecap="round"/>`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const boxWidth = Math.abs(width) + pad * 2;
|
|
611
|
+
const boxHeight = Math.abs(height) + pad * 2;
|
|
612
|
+
const style = [
|
|
613
|
+
`left:${this.toPx(geometry.x_mm + Math.min(0, geometry.w_mm)) - pad}px`,
|
|
614
|
+
`top:${this.toPx(geometry.y_mm + Math.min(0, geometry.h_mm)) - pad}px`,
|
|
615
|
+
`width:${boxWidth}px`,
|
|
616
|
+
`height:${boxHeight}px`,
|
|
617
|
+
`z-index:${1000 + annotation.z}`,
|
|
618
|
+
].join(";");
|
|
619
|
+
|
|
620
|
+
return `<div class="fb-annotation fb-annotation-stroke${selected ? " is-selected" : ""}"
|
|
621
|
+
style="${style}"
|
|
622
|
+
data-annotation-id="${FigureSchema.escapeHtml(annotation.annotation_id)}">
|
|
623
|
+
<svg width="${boxWidth}" height="${boxHeight}">${parts.join("")}</svg>
|
|
624
|
+
${selected
|
|
625
|
+
? `<span class="fb-handle fb-handle-point" data-handle="p1"
|
|
626
|
+
style="left:${x1 - 5}px;top:${y1 - 5}px"></span>
|
|
627
|
+
<span class="fb-handle fb-handle-point" data-handle="p2"
|
|
628
|
+
style="left:${x2 - 5}px;top:${y2 - 5}px"></span>` : ""}
|
|
629
|
+
</div>`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The arrowhead's length, in screen pixels.
|
|
634
|
+
*
|
|
635
|
+
* Deliberately the same rule as `export._arrow_head`, which uses
|
|
636
|
+
* `max(3, line_width * 4)` POINTS. An arrowhead sized by some other rule
|
|
637
|
+
* here would make every arrow look different in the PDF from the way it
|
|
638
|
+
* looked while it was being placed -- and the PDF is the deliverable.
|
|
639
|
+
*/
|
|
640
|
+
arrowHeadPx(lineWidthPt) {
|
|
641
|
+
return Math.max(3, lineWidthPt * 4) * this.scale / FigureCanvas.PT_PER_MM;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** The two barb endpoints, spread 160 degrees from the shaft -- the angle
|
|
645
|
+
* `export._arrow_head` uses. Pure, so the parity can be checked. */
|
|
646
|
+
static arrowHeadPoints(x1, y1, x2, y2, size) {
|
|
647
|
+
const angle = Math.atan2(y2 - y1, x2 - x1);
|
|
648
|
+
return [-1, 1].map((direction) => {
|
|
649
|
+
const spread = angle + direction * (160 * Math.PI / 180);
|
|
650
|
+
return [x2 + size * Math.cos(spread), y2 + size * Math.sin(spread)];
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// -- selection -------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Set or extend the selection.
|
|
658
|
+
*
|
|
659
|
+
* Every id is expanded to its whole visual group on the way in, so a group
|
|
660
|
+
* cannot be half-selected from anywhere -- not from a click, not from a
|
|
661
|
+
* marquee, not from a context menu. Half a group would drag apart under
|
|
662
|
+
* the pointer, which is the one thing grouping exists to prevent.
|
|
663
|
+
*/
|
|
664
|
+
select(ids, additive) {
|
|
665
|
+
if (!additive) this.selection.clear();
|
|
666
|
+
for (const id of this.expandToGroups(ids)) {
|
|
667
|
+
if (additive && this.selection.has(id)) this.selection.delete(id);
|
|
668
|
+
else this.selection.add(id);
|
|
669
|
+
}
|
|
670
|
+
this.render();
|
|
671
|
+
this.onSelectionChange(Array.from(this.selection));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The visual group holding this panel or annotation, or null. */
|
|
675
|
+
groupFor(id) {
|
|
676
|
+
const groups = this.state.document.groups || {};
|
|
677
|
+
for (const group of Object.values(groups)) {
|
|
678
|
+
if (group.member_ids.includes(id)) return group;
|
|
679
|
+
}
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
expandToGroups(ids) {
|
|
684
|
+
const out = [];
|
|
685
|
+
const seen = new Set();
|
|
686
|
+
for (const id of ids) {
|
|
687
|
+
const members = this.groupFor(id)?.member_ids || [id];
|
|
688
|
+
for (const member of members) {
|
|
689
|
+
if (seen.has(member)) continue;
|
|
690
|
+
seen.add(member);
|
|
691
|
+
out.push(member);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return out;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
selectedPanels() {
|
|
698
|
+
return Array.from(this.selection)
|
|
699
|
+
.map((id) => this.state.panel(id))
|
|
700
|
+
.filter(Boolean);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
selectedAnnotations() {
|
|
704
|
+
return Array.from(this.selection)
|
|
705
|
+
.map((id) => this.state.document.annotations[id])
|
|
706
|
+
.filter(Boolean);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
selectAllOnPage() {
|
|
710
|
+
const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId)
|
|
711
|
+
.map((panel) => panel.panel_id);
|
|
712
|
+
const annotations = Object.values(this.state.document.annotations)
|
|
713
|
+
.filter((annotation) => annotation.page_id === this.pageId)
|
|
714
|
+
.map((annotation) => annotation.annotation_id);
|
|
715
|
+
this.select(panels.concat(annotations), false);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// -- gestures --------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
surfacePoint(event) {
|
|
721
|
+
const rect = this.surfaceEl.getBoundingClientRect();
|
|
722
|
+
return { x: this.toMm(event.clientX - rect.left), y: this.toMm(event.clientY - rect.top) };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
pointerDown(event) {
|
|
726
|
+
if (event.button !== 0) return;
|
|
727
|
+
|
|
728
|
+
// A drawing tool takes the whole surface: while one is armed, a press
|
|
729
|
+
// starts a shape rather than selecting whatever is under it. Tools are
|
|
730
|
+
// one-shot, so this state lasts exactly one gesture.
|
|
731
|
+
if (this.tool && this.tool !== "select") {
|
|
732
|
+
event.preventDefault();
|
|
733
|
+
this.select([], false);
|
|
734
|
+
this.beginGesture("draw", event, {});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const handle = event.target.closest?.(".fb-handle");
|
|
739
|
+
const panelEl = event.target.closest?.(".fb-panel");
|
|
740
|
+
const annotationEl = event.target.closest?.(".fb-annotation");
|
|
741
|
+
|
|
742
|
+
// The second press comes first, ahead of the handle, and ahead of the
|
|
743
|
+
// move: a one-line caption at 14 pt is about twenty pixels tall, so its
|
|
744
|
+
// handles cover most of it, and "wherever in the object you pressed
|
|
745
|
+
// twice" is the only rule that opens it every time.
|
|
746
|
+
const id = panelEl ? panelEl.dataset.panelId
|
|
747
|
+
: (annotationEl ? annotationEl.dataset.annotationId : null);
|
|
748
|
+
const opens = annotationEl?.classList.contains("fb-annotation-text")
|
|
749
|
+
? () => this.onEditText(id)
|
|
750
|
+
: (panelEl ? () => this.onEditPanel(id) : null);
|
|
751
|
+
if (this.secondPress(event, id) && opens) {
|
|
752
|
+
event.preventDefault();
|
|
753
|
+
this._press = null;
|
|
754
|
+
opens();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (handle && (panelEl || annotationEl)) {
|
|
759
|
+
event.preventDefault();
|
|
760
|
+
const kind = handle.dataset.handle === "rotate" ? "rotate" : "resize";
|
|
761
|
+
this.beginGesture(kind, event, { handle: handle.dataset.handle });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (panelEl || annotationEl) {
|
|
765
|
+
if (!this.selection.has(id)) this.select([id], event.shiftKey);
|
|
766
|
+
else if (event.shiftKey) { this.select([id], true); return; }
|
|
767
|
+
event.preventDefault();
|
|
768
|
+
this.beginGesture("move", event, {});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// Empty page: a marquee, or a click that clears the selection.
|
|
772
|
+
this.select([], false);
|
|
773
|
+
this.beginGesture("marquee", event, {});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Is this press the second half of a double-click on the same object?
|
|
778
|
+
*
|
|
779
|
+
* Asked here rather than answered by a `dblclick` listener, and that is the
|
|
780
|
+
* whole of the fix. Three separate things were breaking that listener, all
|
|
781
|
+
* of them still true of this file:
|
|
782
|
+
*
|
|
783
|
+
* * the first press SELECTS, selecting re-renders, and `render()` rewrites
|
|
784
|
+
* the surface -- so the two clicks have different targets and the event
|
|
785
|
+
* is retargeted to the surface, where `closest` finds nothing;
|
|
786
|
+
* * the recovery for that was `elementFromPoint`, which answers with the
|
|
787
|
+
* topmost element at the point -- and the floating bar the first click
|
|
788
|
+
* just opened is in the overlay ABOVE the page, so whenever it lands
|
|
789
|
+
* over the object the handler bailed;
|
|
790
|
+
* * `pointerdown` is default-prevented on every press that lands on an
|
|
791
|
+
* object (see below), which suppresses the compatibility mouse events a
|
|
792
|
+
* double-click is derived from.
|
|
793
|
+
*
|
|
794
|
+
* A press knows what it hit, before any of that: `event.target` at
|
|
795
|
+
* `pointerdown` is the element that was pressed, the surface has not been
|
|
796
|
+
* rewritten yet, and nothing has been drawn over it. What it does not know
|
|
797
|
+
* is that a press happened here a moment ago, which is the one thing kept
|
|
798
|
+
* here.
|
|
799
|
+
*
|
|
800
|
+
* The identity is the OBJECT, not the element -- the element the second
|
|
801
|
+
* press lands on is a different one from the first, freshly rendered, and
|
|
802
|
+
* comparing elements is exactly what does not work.
|
|
803
|
+
*/
|
|
804
|
+
secondPress(event, id) {
|
|
805
|
+
const previous = this._press;
|
|
806
|
+
this._press = id
|
|
807
|
+
? { id: id, at: event.timeStamp, x: event.clientX, y: event.clientY }
|
|
808
|
+
: null;
|
|
809
|
+
return Boolean(id && previous && previous.id === id
|
|
810
|
+
&& event.timeStamp - previous.at < FigureCanvas.DOUBLE_PRESS_MS
|
|
811
|
+
&& Math.abs(event.clientX - previous.x) <= FigureCanvas.DOUBLE_PRESS_PX
|
|
812
|
+
&& Math.abs(event.clientY - previous.y) <= FigureCanvas.DOUBLE_PRESS_PX);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The platform's own double-click interval is not readable from a page, so
|
|
816
|
+
* this is the usual default. The distance is what keeps a drag that
|
|
817
|
+
* happened to end where it started from reading as a double-click. */
|
|
818
|
+
static get DOUBLE_PRESS_MS() { return 400; }
|
|
819
|
+
static get DOUBLE_PRESS_PX() { return 4; }
|
|
820
|
+
|
|
821
|
+
beginGesture(kind, event, extra) {
|
|
822
|
+
this.onGesture(true);
|
|
823
|
+
const origin = this.surfacePoint(event);
|
|
824
|
+
this.gesture = {
|
|
825
|
+
kind: kind,
|
|
826
|
+
origin: origin,
|
|
827
|
+
current: origin,
|
|
828
|
+
moved: false,
|
|
829
|
+
handle: extra.handle || null,
|
|
830
|
+
// The starting geometry of everything being moved, captured once:
|
|
831
|
+
// reading it back off the DOM each frame would compound rounding
|
|
832
|
+
// and make a long drag drift.
|
|
833
|
+
items: this.gestureItems(),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
gestureItems() {
|
|
838
|
+
const items = [];
|
|
839
|
+
for (const id of this.selection) {
|
|
840
|
+
const panel = this.state.panel(id);
|
|
841
|
+
if (panel && panel.placement) {
|
|
842
|
+
items.push({ kind: "panel", id: id, start: { ...panel.placement } });
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const annotation = this.state.document.annotations[id];
|
|
846
|
+
if (annotation) {
|
|
847
|
+
items.push({ kind: "annotation", id: id, start: { ...annotation.geometry } });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return items;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
pointerMove(event) {
|
|
854
|
+
if (!this.gesture) return;
|
|
855
|
+
this.gesture.current = this.surfacePoint(event);
|
|
856
|
+
const dx = this.gesture.current.x - this.gesture.origin.x;
|
|
857
|
+
const dy = this.gesture.current.y - this.gesture.origin.y;
|
|
858
|
+
if (Math.abs(dx) > 0.2 || Math.abs(dy) > 0.2) this.gesture.moved = true;
|
|
859
|
+
|
|
860
|
+
if (this.gesture.kind === "move") this.previewMove(dx, dy, event.shiftKey);
|
|
861
|
+
else if (this.gesture.kind === "resize") this.previewResize(dx, dy, event.shiftKey);
|
|
862
|
+
else if (this.gesture.kind === "marquee") this.previewMarquee();
|
|
863
|
+
else if (this.gesture.kind === "draw") this.previewDraw(event.shiftKey);
|
|
864
|
+
else if (this.gesture.kind === "rotate") this.previewRotate(event.shiftKey);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
pointerUp() {
|
|
868
|
+
const gesture = this.gesture;
|
|
869
|
+
this.gesture = null;
|
|
870
|
+
if (!gesture) return;
|
|
871
|
+
this.onGesture(false);
|
|
872
|
+
this.clearGuides();
|
|
873
|
+
// A press that turned into a drag cannot be the first half of a double
|
|
874
|
+
// click, however quickly the next one follows it.
|
|
875
|
+
if (gesture.moved) this._press = null;
|
|
876
|
+
|
|
877
|
+
if (gesture.kind === "draw") {
|
|
878
|
+
this.finishDraw(gesture);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (gesture.kind === "marquee") {
|
|
882
|
+
this.finishMarquee(gesture);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (!gesture.moved) {
|
|
886
|
+
this.render();
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.commitGesture(gesture);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Write the provisional positions straight onto the elements.
|
|
894
|
+
*
|
|
895
|
+
* Inline styles rather than a re-render: a re-render per pointer move
|
|
896
|
+
* rebuilds every <img> in the page, which makes the browser re-decode the
|
|
897
|
+
* previews and turns a smooth drag into a slideshow.
|
|
898
|
+
*/
|
|
899
|
+
previewMove(dx, dy, disableSnap) {
|
|
900
|
+
const snapped = disableSnap ? { dx, dy } : this.snapMove(dx, dy);
|
|
901
|
+
for (const item of this.gesture.items) {
|
|
902
|
+
this.previewBox(item, {
|
|
903
|
+
...item.start,
|
|
904
|
+
x_mm: item.start.x_mm + snapped.dx,
|
|
905
|
+
y_mm: item.start.y_mm + snapped.dy,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
this.gesture.delta = snapped;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Angles a rotation snaps to with Shift held, in degrees. */
|
|
912
|
+
static get ROTATE_STEP() { return 15; }
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Turn the selection to follow the pointer.
|
|
916
|
+
*
|
|
917
|
+
* The angle is measured from the box's CENTRE to the pointer, not from the
|
|
918
|
+
* drag's start -- so grabbing the handle and swinging round puts the top of
|
|
919
|
+
* the box under the pointer, which is the thing every drawing tool does and
|
|
920
|
+
* the only reading that survives dragging past 180 degrees.
|
|
921
|
+
*/
|
|
922
|
+
previewRotate(snap) {
|
|
923
|
+
for (const item of this.gesture.items) {
|
|
924
|
+
if (item.kind === "panel") continue;
|
|
925
|
+
const centre = { x: item.start.x_mm + item.start.w_mm / 2,
|
|
926
|
+
y: item.start.y_mm + item.start.h_mm / 2 };
|
|
927
|
+
const point = this.gesture.current;
|
|
928
|
+
// +90 because the handle stands ABOVE the box: with the pointer
|
|
929
|
+
// straight up from the centre the box is at rest, not at -90.
|
|
930
|
+
let degrees = Math.atan2(point.y - centre.y, point.x - centre.x)
|
|
931
|
+
* 180 / Math.PI + 90;
|
|
932
|
+
if (snap) {
|
|
933
|
+
degrees = Math.round(degrees / FigureCanvas.ROTATE_STEP)
|
|
934
|
+
* FigureCanvas.ROTATE_STEP;
|
|
935
|
+
}
|
|
936
|
+
degrees = Math.round(((degrees % 360) + 360) % 360 * 10) / 10;
|
|
937
|
+
item.rotation = degrees;
|
|
938
|
+
const element = this.elementFor(item);
|
|
939
|
+
if (element) {
|
|
940
|
+
element.style.transform = `rotate(${degrees}deg)`;
|
|
941
|
+
element.style.transformOrigin = "center";
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
previewResize(dx, dy, keepAspect) {
|
|
947
|
+
const handle = this.gesture.handle;
|
|
948
|
+
for (const item of this.gesture.items) {
|
|
949
|
+
this.previewBox(item, this.resizedBox(
|
|
950
|
+
item.start, handle, dx, dy, keepAspect, this.annotationFor(item)));
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Whether this resize is the user taking a text box's height into their
|
|
955
|
+
* own hands -- any handle that changes the height, on a box that was
|
|
956
|
+
* following its contents. */
|
|
957
|
+
clearsAutofit(item, gesture) {
|
|
958
|
+
const annotation = this.annotationFor(item);
|
|
959
|
+
return Boolean(annotation) && annotation.type === "text"
|
|
960
|
+
&& annotation.style.autofit
|
|
961
|
+
&& /[ns]/.test(gesture.handle || "");
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** The annotation an in-flight gesture item refers to, or null. */
|
|
965
|
+
annotationFor(item) {
|
|
966
|
+
return item.kind === "panel"
|
|
967
|
+
? null : (this.state.document.annotations[item.id] || null);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Show one item at a provisional box, and remember it.
|
|
972
|
+
*
|
|
973
|
+
* The box is stored on the gesture item as well as written to the DOM, so
|
|
974
|
+
* the commit reads a number rather than parsing a style back -- which
|
|
975
|
+
* matters for lines, whose element is not their geometry (see
|
|
976
|
+
* strokeMarkup).
|
|
977
|
+
*
|
|
978
|
+
* Ordinary items get inline styles rather than a re-render: re-rendering
|
|
979
|
+
* per pointer move rebuilds every <img> on the page and turns a smooth drag
|
|
980
|
+
* into a slideshow. A line has no <img> and cannot be expressed as four
|
|
981
|
+
* style properties, so it is the one thing redrawn each frame.
|
|
982
|
+
*/
|
|
983
|
+
previewBox(item, box) {
|
|
984
|
+
item.box = box;
|
|
985
|
+
const element = this.elementFor(item);
|
|
986
|
+
if (!element) return;
|
|
987
|
+
if (element.classList.contains("fb-annotation-stroke")) {
|
|
988
|
+
const annotation = this.state.document.annotations[item.id];
|
|
989
|
+
if (!annotation) return;
|
|
990
|
+
element.outerHTML = this.strokeMarkup({ ...annotation, geometry: box });
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
element.style.left = this.toPx(box.x_mm) + "px";
|
|
994
|
+
element.style.top = this.toPx(box.y_mm) + "px";
|
|
995
|
+
if (this.gesture.kind === "resize") {
|
|
996
|
+
element.style.width = this.toPx(box.w_mm) + "px";
|
|
997
|
+
element.style.height = this.toPx(box.h_mm) + "px";
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* The box a resize produces.
|
|
1003
|
+
*
|
|
1004
|
+
* Corner handles keep the aspect ratio by DEFAULT and free it with Shift,
|
|
1005
|
+
* which is the opposite of most drawing tools and the right way round here:
|
|
1006
|
+
* a panel's aspect ratio is the shape of the region it shows, and changing
|
|
1007
|
+
* it silently squashes the tissue. Edge handles are single-axis by
|
|
1008
|
+
* definition and ignore the modifier.
|
|
1009
|
+
*
|
|
1010
|
+
* `p1` and `p2` are a line's two ENDS, and they are a different kind of
|
|
1011
|
+
* handle: the geometry of a line is a start point and an offset, so w and h
|
|
1012
|
+
* are legally negative and the minimum-size clamp below must not apply --
|
|
1013
|
+
* a line is allowed to be a hair thick, and clamping it to 5mm would stop
|
|
1014
|
+
* anyone drawing a horizontal one.
|
|
1015
|
+
*/
|
|
1016
|
+
resizedBox(start, handle, dx, dy, freeAspect, annotation) {
|
|
1017
|
+
let { x_mm: x, y_mm: y, w_mm: w, h_mm: h } = start;
|
|
1018
|
+
const isText = Boolean(annotation) && annotation.type === "text";
|
|
1019
|
+
const rotation = (annotation && annotation.geometry.rotation) || 0;
|
|
1020
|
+
|
|
1021
|
+
// A rotated box resizes along ITS OWN axes, so the pointer's movement is
|
|
1022
|
+
// turned back through the angle before any of the arithmetic below sees
|
|
1023
|
+
// it. Without this, dragging the corner of a box rotated 45 degrees
|
|
1024
|
+
// moves it diagonally instead of widening it.
|
|
1025
|
+
if (rotation) {
|
|
1026
|
+
const turned = FigureCanvas.turn(dx, dy, -rotation);
|
|
1027
|
+
dx = turned.x;
|
|
1028
|
+
dy = turned.y;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (handle === "p1") {
|
|
1032
|
+
return { ...start, x_mm: x + dx, y_mm: y + dy, w_mm: w - dx, h_mm: h - dy };
|
|
1033
|
+
}
|
|
1034
|
+
if (handle === "p2") {
|
|
1035
|
+
return { ...start, w_mm: w + dx, h_mm: h + dy };
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Text inverts the modifier. The aspect lock exists because a panel's
|
|
1039
|
+
// shape is the shape of the region it shows and squashing it is a
|
|
1040
|
+
// scientific error -- a text box has no such invariant, and locking it
|
|
1041
|
+
// would mean widening a caption also made it taller, which `autofit`
|
|
1042
|
+
// then immediately undoes.
|
|
1043
|
+
const corner = handle.length === 2;
|
|
1044
|
+
if (corner && (isText ? freeAspect : !freeAspect)) {
|
|
1045
|
+
// Drive both axes from whichever the pointer moved further along,
|
|
1046
|
+
// so the shape follows the gesture rather than snapping between
|
|
1047
|
+
// two interpretations of it.
|
|
1048
|
+
const aspect = start.w_mm / start.h_mm;
|
|
1049
|
+
const signX = handle.includes("w") ? -1 : 1;
|
|
1050
|
+
const signY = handle.includes("n") ? -1 : 1;
|
|
1051
|
+
const byWidth = signX * dx;
|
|
1052
|
+
const byHeight = signY * dy * aspect;
|
|
1053
|
+
const grow = Math.abs(byWidth) >= Math.abs(byHeight) ? byWidth : byHeight;
|
|
1054
|
+
dx = signX * grow;
|
|
1055
|
+
dy = signY * (grow / aspect);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (handle.includes("w")) { x = start.x_mm + dx; w = start.w_mm - dx; }
|
|
1059
|
+
if (handle.includes("e")) { w = start.w_mm + dx; }
|
|
1060
|
+
if (handle.includes("n")) { y = start.y_mm + dy; h = start.h_mm - dy; }
|
|
1061
|
+
if (handle.includes("s")) { h = start.h_mm + dy; }
|
|
1062
|
+
|
|
1063
|
+
const smallest = isText ? FigureCanvas.MIN_TEXT_MM : FigureCanvas.MIN_SIZE_MM;
|
|
1064
|
+
if (w < smallest) { if (handle.includes("w")) x -= smallest - w; w = smallest; }
|
|
1065
|
+
if (h < smallest) { if (handle.includes("n")) y -= smallest - h; h = smallest; }
|
|
1066
|
+
if (!rotation) return { ...start, x_mm: x, y_mm: y, w_mm: w, h_mm: h };
|
|
1067
|
+
|
|
1068
|
+
// The box is drawn from its top-left and then turned about its own
|
|
1069
|
+
// CENTRE, so a resize that moves the centre also swings the corner the
|
|
1070
|
+
// user is not touching. Put the centre where the rotated geometry says
|
|
1071
|
+
// it belongs, and the anchored corner stays under the pointer's
|
|
1072
|
+
// opposite number instead of sliding away as the box grows.
|
|
1073
|
+
const shift = FigureCanvas.turn(
|
|
1074
|
+
(x - start.x_mm) + (w - start.w_mm) / 2,
|
|
1075
|
+
(y - start.y_mm) + (h - start.h_mm) / 2, rotation);
|
|
1076
|
+
return { ...start,
|
|
1077
|
+
x_mm: start.x_mm + (start.w_mm - w) / 2 + shift.x,
|
|
1078
|
+
y_mm: start.y_mm + (start.h_mm - h) / 2 + shift.y,
|
|
1079
|
+
w_mm: w, h_mm: h };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** A vector turned clockwise by `degrees`, in page coordinates (y down). */
|
|
1083
|
+
static turn(x, y, degrees) {
|
|
1084
|
+
const radians = degrees * Math.PI / 180;
|
|
1085
|
+
const cos = Math.cos(radians);
|
|
1086
|
+
const sin = Math.sin(radians);
|
|
1087
|
+
return { x: x * cos - y * sin, y: x * sin + y * cos };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
previewMarquee() {
|
|
1091
|
+
const { origin, current } = this.gesture;
|
|
1092
|
+
this.showMarquee({
|
|
1093
|
+
x: Math.min(origin.x, current.x), y: Math.min(origin.y, current.y),
|
|
1094
|
+
w: Math.abs(current.x - origin.x), h: Math.abs(current.y - origin.y),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
finishMarquee(gesture) {
|
|
1099
|
+
this.showMarquee(null);
|
|
1100
|
+
if (!gesture.moved) return;
|
|
1101
|
+
const box = {
|
|
1102
|
+
x: Math.min(gesture.origin.x, gesture.current.x),
|
|
1103
|
+
y: Math.min(gesture.origin.y, gesture.current.y),
|
|
1104
|
+
w: Math.abs(gesture.current.x - gesture.origin.x),
|
|
1105
|
+
h: Math.abs(gesture.current.y - gesture.origin.y),
|
|
1106
|
+
};
|
|
1107
|
+
// Intersecting rather than fully-enclosed: on a page where panels butt
|
|
1108
|
+
// up against each other, "fully enclosed" means a marquee has to be
|
|
1109
|
+
// drawn outside the page to catch the edge ones.
|
|
1110
|
+
// Intersecting rather than fully-enclosed: on a page where panels butt
|
|
1111
|
+
// up against each other, "fully enclosed" means a marquee has to be
|
|
1112
|
+
// drawn outside the page to catch the edge ones.
|
|
1113
|
+
const hits = FigureSchema.panelsOnPage(this.state.document, this.pageId)
|
|
1114
|
+
.filter((panel) => {
|
|
1115
|
+
const place = panel.placement;
|
|
1116
|
+
return place.x_mm < box.x + box.w && place.x_mm + place.w_mm > box.x
|
|
1117
|
+
&& place.y_mm < box.y + box.h && place.y_mm + place.h_mm > box.y;
|
|
1118
|
+
})
|
|
1119
|
+
.map((panel) => panel.panel_id);
|
|
1120
|
+
// Annotations are caught too. They were not, and a marquee that swept
|
|
1121
|
+
// over an arrow and left it behind made grouping an image with its
|
|
1122
|
+
// label impossible to do by dragging.
|
|
1123
|
+
const annotations = Object.values(this.state.document.annotations)
|
|
1124
|
+
.filter((annotation) => {
|
|
1125
|
+
if (annotation.page_id !== this.pageId) return false;
|
|
1126
|
+
const g = annotation.geometry;
|
|
1127
|
+
const left = g.x_mm + Math.min(0, g.w_mm);
|
|
1128
|
+
const top = g.y_mm + Math.min(0, g.h_mm);
|
|
1129
|
+
return left < box.x + box.w && left + Math.abs(g.w_mm) > box.x
|
|
1130
|
+
&& top < box.y + box.h && top + Math.abs(g.h_mm) > box.y;
|
|
1131
|
+
})
|
|
1132
|
+
.map((annotation) => annotation.annotation_id);
|
|
1133
|
+
this.select(hits.concat(annotations), false);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// -- drawing -----------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Arm a drawing tool, or go back to selecting.
|
|
1140
|
+
*
|
|
1141
|
+
* One-shot: `finishDraw` puts it back to "select" the moment something has
|
|
1142
|
+
* been placed. A mode that persisted would be a canvas where the next click
|
|
1143
|
+
* on a panel drew a rectangle on top of it, and the only clue would be a
|
|
1144
|
+
* pressed button in the rail 200 pixels away.
|
|
1145
|
+
*/
|
|
1146
|
+
setTool(name) {
|
|
1147
|
+
this.tool = name && name !== "select" ? name : null;
|
|
1148
|
+
this.surfaceEl.classList.toggle("is-drawing", Boolean(this.tool));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Default size for a shape placed with a click rather than a drag, in mm. */
|
|
1152
|
+
static get DRAW_DEFAULT_MM() { return { w: 30, h: 18 }; }
|
|
1153
|
+
|
|
1154
|
+
previewDraw(constrain) {
|
|
1155
|
+
const type = this.tool;
|
|
1156
|
+
const box = this.drawBox(constrain, this.gesture, type);
|
|
1157
|
+
if (!this.guideEl) return;
|
|
1158
|
+
// Into the guides layer rather than the surface: the surface is what
|
|
1159
|
+
// render() replaces, and the provisional shape is not part of the
|
|
1160
|
+
// document until the pointer comes up.
|
|
1161
|
+
if (type === "line" || type === "arrow") {
|
|
1162
|
+
this.guideEl.innerHTML = this.strokeMarkup({
|
|
1163
|
+
annotation_id: "__draft", type: type, z: 999,
|
|
1164
|
+
geometry: { ...box, rotation: 0 },
|
|
1165
|
+
style: this.drawStyle(),
|
|
1166
|
+
});
|
|
1167
|
+
} else {
|
|
1168
|
+
this.guideEl.innerHTML = `<span class="fb-draft fb-draft-${type}"
|
|
1169
|
+
style="left:${this.toPx(box.x_mm)}px;top:${this.toPx(box.y_mm)}px;
|
|
1170
|
+
width:${this.toPx(box.w_mm)}px;height:${this.toPx(box.h_mm)}px"></span>`;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* The geometry a draw gesture describes.
|
|
1176
|
+
*
|
|
1177
|
+
* Rectangles and ellipses are normalised -- dragging up and left gives a
|
|
1178
|
+
* box, not a negative one. Lines and arrows are NOT: their w/h is the
|
|
1179
|
+
* offset to the far end, and normalising it would point every arrow down
|
|
1180
|
+
* and to the right whichever way it was drawn.
|
|
1181
|
+
*
|
|
1182
|
+
* Both the gesture and the tool are ARGUMENTS rather than `this.gesture`
|
|
1183
|
+
* and `this.tool`, because the one caller that matters has already cleared
|
|
1184
|
+
* both: `pointerUp` nulls the gesture before it hands it on, and
|
|
1185
|
+
* `finishDraw` releases the tool before it asks for the geometry. Reading
|
|
1186
|
+
* the fields here threw on every shape drawn, and would have pointed every
|
|
1187
|
+
* arrow down-and-right if it had not.
|
|
1188
|
+
*/
|
|
1189
|
+
drawBox(constrain, gesture, type) {
|
|
1190
|
+
const { origin, current } = gesture;
|
|
1191
|
+
let dx = current.x - origin.x;
|
|
1192
|
+
let dy = current.y - origin.y;
|
|
1193
|
+
if (constrain) {
|
|
1194
|
+
// Shift gives a square, or an axis-aligned line -- the two things
|
|
1195
|
+
// the modifier means in every drawing tool anyone has used.
|
|
1196
|
+
if (type === "line" || type === "arrow") {
|
|
1197
|
+
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
|
1198
|
+
else dx = 0;
|
|
1199
|
+
} else {
|
|
1200
|
+
const size = Math.max(Math.abs(dx), Math.abs(dy));
|
|
1201
|
+
dx = Math.sign(dx || 1) * size;
|
|
1202
|
+
dy = Math.sign(dy || 1) * size;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (type === "line" || type === "arrow") {
|
|
1206
|
+
return { x_mm: origin.x, y_mm: origin.y, w_mm: dx, h_mm: dy };
|
|
1207
|
+
}
|
|
1208
|
+
return {
|
|
1209
|
+
x_mm: Math.min(origin.x, origin.x + dx),
|
|
1210
|
+
y_mm: Math.min(origin.y, origin.y + dy),
|
|
1211
|
+
w_mm: Math.abs(dx),
|
|
1212
|
+
h_mm: Math.abs(dy),
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* The style a newly drawn annotation starts with.
|
|
1218
|
+
*
|
|
1219
|
+
* The FAMILY and the colour come from the document's own defaults, so a
|
|
1220
|
+
* figure set in Times does not place a Helvetica caption. The SIZE does
|
|
1221
|
+
* not, and that is the correction: this asked for `label_size_pt`, which is
|
|
1222
|
+
* the size of the letter "A" in the corner of a panel. A caption inherited
|
|
1223
|
+
* it and came out at 10 pt -- typeset for the inside of an image, while
|
|
1224
|
+
* sitting beside one. The document's `font_size_pt` would have been no
|
|
1225
|
+
* better: it is 8 pt, and it is the legend and scale-bar type, small for
|
|
1226
|
+
* the same reason.
|
|
1227
|
+
*
|
|
1228
|
+
* So a text box starts at a reading size of its own, and whoever wants
|
|
1229
|
+
* something else changes it in the sidebar -- which is where the number now
|
|
1230
|
+
* is, in a stepper, rather than nowhere.
|
|
1231
|
+
*/
|
|
1232
|
+
drawStyle() {
|
|
1233
|
+
const style = this.state.document.settings.style;
|
|
1234
|
+
return {
|
|
1235
|
+
color: style.text_color || "#000000",
|
|
1236
|
+
fill: "",
|
|
1237
|
+
line_width_pt: 0.75,
|
|
1238
|
+
font_size_pt: FigureRichText.DEFAULT_SIZE_PT,
|
|
1239
|
+
font_family: FigureRichText.family(style.font_family),
|
|
1240
|
+
align: "left",
|
|
1241
|
+
valign: "top",
|
|
1242
|
+
autofit: true,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Commit whatever was drawn.
|
|
1248
|
+
*
|
|
1249
|
+
* A click with no drag still places something, at a default size: a tool
|
|
1250
|
+
* that silently did nothing unless the pointer travelled far enough is a
|
|
1251
|
+
* tool people press twice and then give up on.
|
|
1252
|
+
*/
|
|
1253
|
+
finishDraw(gesture) {
|
|
1254
|
+
const type = this.tool;
|
|
1255
|
+
this.setTool(null);
|
|
1256
|
+
this.onToolFinished();
|
|
1257
|
+
this.clearGuides();
|
|
1258
|
+
if (!type || !this.pageId) return;
|
|
1259
|
+
|
|
1260
|
+
let box = this.drawBox(false, gesture, type);
|
|
1261
|
+
if (!gesture.moved) {
|
|
1262
|
+
const size = FigureCanvas.DRAW_DEFAULT_MM;
|
|
1263
|
+
box = (type === "line" || type === "arrow")
|
|
1264
|
+
? { x_mm: gesture.origin.x, y_mm: gesture.origin.y, w_mm: size.w, h_mm: 0 }
|
|
1265
|
+
: { x_mm: gesture.origin.x, y_mm: gesture.origin.y,
|
|
1266
|
+
w_mm: size.w,
|
|
1267
|
+
// One line at the default size, computed rather than the
|
|
1268
|
+
// 8 mm literal this used to be: that was two lines of 8 pt
|
|
1269
|
+
// type, and at 14 pt it is not quite one -- so the box a
|
|
1270
|
+
// click placed was shorter than the text it was about to
|
|
1271
|
+
// hold. `autofit` grows it as soon as there are words in
|
|
1272
|
+
// it; this only has to be right for the empty one.
|
|
1273
|
+
h_mm: type === "text"
|
|
1274
|
+
? FigureRichText.DEFAULT_SIZE_PT * FigureRichText.MM_PER_PT
|
|
1275
|
+
* FigureRichText.LINE_HEIGHT
|
|
1276
|
+
: size.h };
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const annotation = {
|
|
1280
|
+
annotation_id: FigureSchema.newAnnotationId(),
|
|
1281
|
+
type: type,
|
|
1282
|
+
page_id: this.pageId,
|
|
1283
|
+
geometry: { ...box, rotation: 0 },
|
|
1284
|
+
text: "",
|
|
1285
|
+
// Written here rather than left for the server so that the local
|
|
1286
|
+
// draft is already in the shape the canvas draws from.
|
|
1287
|
+
...(type === "text" ? { rich: FigureRichText.normalize("", null) } : {}),
|
|
1288
|
+
style: this.drawStyle(),
|
|
1289
|
+
z: this.nextAnnotationZ(),
|
|
1290
|
+
};
|
|
1291
|
+
this.state.commit(
|
|
1292
|
+
[{ op: "add_annotation", annotation: annotation }],
|
|
1293
|
+
(draft) => { draft.annotations[annotation.annotation_id] = annotation; });
|
|
1294
|
+
this.select([annotation.annotation_id], false);
|
|
1295
|
+
// A text box placed with nothing in it is invisible, so the editor opens
|
|
1296
|
+
// on it straight away -- placing text and typing it are one action.
|
|
1297
|
+
if (type === "text") this.onEditText(annotation.annotation_id);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
nextAnnotationZ() {
|
|
1301
|
+
return Object.values(this.state.document.annotations)
|
|
1302
|
+
.filter((annotation) => annotation.page_id === this.pageId)
|
|
1303
|
+
.reduce((top, annotation) => Math.max(top, annotation.z), 0) + 1;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
commitGesture(gesture) {
|
|
1307
|
+
const moves = [];
|
|
1308
|
+
const annotationOps = [];
|
|
1309
|
+
for (const item of gesture.items) {
|
|
1310
|
+
// The provisional box the preview computed, if there was one. Read
|
|
1311
|
+
// in preference to the DOM because a line's element is its
|
|
1312
|
+
// NORMALISED bounds plus padding -- measuring that back would turn
|
|
1313
|
+
// every arrow into a rectangle a few pixels bigger than itself.
|
|
1314
|
+
let box = item.box;
|
|
1315
|
+
if (!box) {
|
|
1316
|
+
const element = this.elementFor(item);
|
|
1317
|
+
if (!element) continue;
|
|
1318
|
+
box = {
|
|
1319
|
+
x_mm: this.toMm(parseFloat(element.style.left)),
|
|
1320
|
+
y_mm: this.toMm(parseFloat(element.style.top)),
|
|
1321
|
+
w_mm: this.toMm(parseFloat(element.style.width) || this.toPx(item.start.w_mm)),
|
|
1322
|
+
h_mm: this.toMm(parseFloat(element.style.height) || this.toPx(item.start.h_mm)),
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
if (item.kind === "panel") {
|
|
1326
|
+
moves.push({ panel_id: item.id, placement: { ...item.start, ...box } });
|
|
1327
|
+
if (gesture.kind === "resize") {
|
|
1328
|
+
// A linked row shares a box. Only on a resize: sharing a
|
|
1329
|
+
// POSITION would mean dragging one panel dragged them all
|
|
1330
|
+
// onto each other, and the row could never be a row.
|
|
1331
|
+
moves.push(...this._linkedSizeMoves(this.state.panel(item.id), box));
|
|
1332
|
+
}
|
|
1333
|
+
} else if (gesture.kind === "resize" && this.clearsAutofit(item, gesture)) {
|
|
1334
|
+
// Dragging the top or bottom edge of a text box is the user
|
|
1335
|
+
// saying how tall it should be, so it stops following its
|
|
1336
|
+
// contents. The gesture IS the opt-out -- a checkbox they had
|
|
1337
|
+
// to find first would make the drag do nothing.
|
|
1338
|
+
annotationOps.push({
|
|
1339
|
+
op: "update_annotation", annotation_id: item.id,
|
|
1340
|
+
changes: { geometry: box, style: { autofit: false } },
|
|
1341
|
+
});
|
|
1342
|
+
} else if (gesture.kind === "rotate") {
|
|
1343
|
+
// Only the angle: a rotation must not also write back the box,
|
|
1344
|
+
// or a preview transform read off the DOM would be committed as
|
|
1345
|
+
// a resize.
|
|
1346
|
+
annotationOps.push({
|
|
1347
|
+
op: "update_annotation", annotation_id: item.id,
|
|
1348
|
+
changes: { geometry: { rotation: item.rotation || 0 } },
|
|
1349
|
+
});
|
|
1350
|
+
} else {
|
|
1351
|
+
annotationOps.push({
|
|
1352
|
+
op: "update_annotation", annotation_id: item.id, changes: { geometry: box },
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
const operations = moves.length ? [{ op: "move_panels", moves: moves }] : [];
|
|
1357
|
+
operations.push(...annotationOps);
|
|
1358
|
+
if (!operations.length) return;
|
|
1359
|
+
|
|
1360
|
+
// One commit for the whole gesture: dragging five selected panels is
|
|
1361
|
+
// one thing the user did and must be one thing they can undo.
|
|
1362
|
+
this.state.commit(operations, (draft) => {
|
|
1363
|
+
for (const move of moves) {
|
|
1364
|
+
draft.panels[move.panel_id].placement = move.placement;
|
|
1365
|
+
}
|
|
1366
|
+
for (const op of annotationOps) {
|
|
1367
|
+
Object.assign(draft.annotations[op.annotation_id].geometry, op.changes.geometry);
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
elementFor(item) {
|
|
1373
|
+
const selector = item.kind === "panel"
|
|
1374
|
+
? `.fb-panel[data-panel-id="${item.id}"]`
|
|
1375
|
+
: `.fb-annotation[data-annotation-id="${item.id}"]`;
|
|
1376
|
+
return this.surfaceEl.querySelector(selector);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// -- snapping --------------------------------------------------------
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Nudge a move onto a nearby edge, centre or margin.
|
|
1383
|
+
*
|
|
1384
|
+
* The threshold is in screen pixels: a fixed millimetre threshold is
|
|
1385
|
+
* unusably sticky zoomed in and does nothing at all zoomed out.
|
|
1386
|
+
*/
|
|
1387
|
+
snapMove(dx, dy) {
|
|
1388
|
+
const tolerance = this.toMm(FigureCanvas.SNAP_PIXELS);
|
|
1389
|
+
const targets = this.snapTargets();
|
|
1390
|
+
// Annotations both snap and are snapped to. They were neither, so a
|
|
1391
|
+
// caption could not be lined up with the panel above it by dragging --
|
|
1392
|
+
// which is the one alignment on a figure page that always matters.
|
|
1393
|
+
const moving = this.gesture.items;
|
|
1394
|
+
if (!moving.length) return { dx, dy };
|
|
1395
|
+
|
|
1396
|
+
let bestX = { distance: tolerance, delta: dx, line: null };
|
|
1397
|
+
let bestY = { distance: tolerance, delta: dy, line: null };
|
|
1398
|
+
|
|
1399
|
+
for (const item of moving) {
|
|
1400
|
+
const edgesX = [item.start.x_mm + dx,
|
|
1401
|
+
item.start.x_mm + dx + item.start.w_mm / 2,
|
|
1402
|
+
item.start.x_mm + dx + item.start.w_mm];
|
|
1403
|
+
const edgesY = [item.start.y_mm + dy,
|
|
1404
|
+
item.start.y_mm + dy + item.start.h_mm / 2,
|
|
1405
|
+
item.start.y_mm + dy + item.start.h_mm];
|
|
1406
|
+
for (const edge of edgesX) {
|
|
1407
|
+
for (const line of targets.x) {
|
|
1408
|
+
const distance = Math.abs(edge - line);
|
|
1409
|
+
if (distance < bestX.distance) {
|
|
1410
|
+
bestX = { distance: distance, delta: dx + (line - edge), line: line };
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
for (const edge of edgesY) {
|
|
1415
|
+
for (const line of targets.y) {
|
|
1416
|
+
const distance = Math.abs(edge - line);
|
|
1417
|
+
if (distance < bestY.distance) {
|
|
1418
|
+
bestY = { distance: distance, delta: dy + (line - edge), line: line };
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
this.showGuides(bestX.line, bestY.line);
|
|
1424
|
+
return { dx: bestX.delta, dy: bestY.delta };
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Every line a move may snap onto.
|
|
1429
|
+
*
|
|
1430
|
+
* The page's own lines and every other panel's three are always here --
|
|
1431
|
+
* that is what "smart guides" means, and turning it off in the View menu
|
|
1432
|
+
* empties this rather than changing the signature, because `snapMove` is
|
|
1433
|
+
* pinned by a probe and the point of it is the arithmetic, not the source
|
|
1434
|
+
* of the candidates.
|
|
1435
|
+
*/
|
|
1436
|
+
snapTargets() {
|
|
1437
|
+
const page = this.page;
|
|
1438
|
+
const moving = new Set(this.gesture.items.map((item) => item.id));
|
|
1439
|
+
const x = [];
|
|
1440
|
+
const y = [];
|
|
1441
|
+
|
|
1442
|
+
if (this.snapping.guides) {
|
|
1443
|
+
x.push(0, page.size_mm.w / 2, page.size_mm.w,
|
|
1444
|
+
page.margins_mm.left, page.size_mm.w - page.margins_mm.right);
|
|
1445
|
+
y.push(0, page.size_mm.h / 2, page.size_mm.h,
|
|
1446
|
+
page.margins_mm.top, page.size_mm.h - page.margins_mm.bottom);
|
|
1447
|
+
for (const panel of FigureSchema.panelsOnPage(this.state.document, this.pageId)) {
|
|
1448
|
+
if (moving.has(panel.panel_id)) continue;
|
|
1449
|
+
const place = panel.placement;
|
|
1450
|
+
x.push(place.x_mm, place.x_mm + place.w_mm / 2, place.x_mm + place.w_mm);
|
|
1451
|
+
y.push(place.y_mm, place.y_mm + place.h_mm / 2, place.y_mm + place.h_mm);
|
|
1452
|
+
}
|
|
1453
|
+
for (const annotation of Object.values(this.state.document.annotations)) {
|
|
1454
|
+
if (annotation.page_id !== this.pageId) continue;
|
|
1455
|
+
if (moving.has(annotation.annotation_id)) continue;
|
|
1456
|
+
// Normalised, because a line's w/h are legally negative: they
|
|
1457
|
+
// are an offset from its start point, not a box.
|
|
1458
|
+
const g = annotation.geometry;
|
|
1459
|
+
const left = g.x_mm + Math.min(0, g.w_mm);
|
|
1460
|
+
const top = g.y_mm + Math.min(0, g.h_mm);
|
|
1461
|
+
const width = Math.abs(g.w_mm);
|
|
1462
|
+
const height = Math.abs(g.h_mm);
|
|
1463
|
+
x.push(left, left + width / 2, left + width);
|
|
1464
|
+
y.push(top, top + height / 2, top + height);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (this.snapping.grid && this.snapping.gridMm > 0) {
|
|
1468
|
+
const step = this.snapping.gridMm;
|
|
1469
|
+
for (let value = 0; value <= page.size_mm.w + 1e-6; value += step) x.push(value);
|
|
1470
|
+
for (let value = 0; value <= page.size_mm.h + 1e-6; value += step) y.push(value);
|
|
1471
|
+
}
|
|
1472
|
+
return { x: x, y: y };
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
showGuides(lineX, lineY) {
|
|
1476
|
+
if (!this.guideEl) return;
|
|
1477
|
+
const parts = [];
|
|
1478
|
+
if (lineX !== null && lineX !== undefined) {
|
|
1479
|
+
parts.push(`<span class="fb-guide fb-guide-v" style="left:${this.toPx(lineX)}px"></span>`);
|
|
1480
|
+
}
|
|
1481
|
+
if (lineY !== null && lineY !== undefined) {
|
|
1482
|
+
parts.push(`<span class="fb-guide fb-guide-h" style="top:${this.toPx(lineY)}px"></span>`);
|
|
1483
|
+
}
|
|
1484
|
+
this.guideEl.innerHTML = parts.join("");
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
showMarquee(box) {
|
|
1488
|
+
if (!this.guideEl) return;
|
|
1489
|
+
this.guideEl.innerHTML = box
|
|
1490
|
+
? `<span class="fb-marquee" style="left:${this.toPx(box.x)}px;top:${this.toPx(box.y)}px;
|
|
1491
|
+
width:${this.toPx(box.w)}px;height:${this.toPx(box.h)}px"></span>`
|
|
1492
|
+
: "";
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
clearGuides() {
|
|
1496
|
+
if (this.guideEl) this.guideEl.innerHTML = "";
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// -- keyboard --------------------------------------------------------
|
|
1500
|
+
|
|
1501
|
+
keyDown(event) {
|
|
1502
|
+
// A <dialog> traps focus, not keystrokes: with the delete confirmation
|
|
1503
|
+
// up and its Cancel button focused, pressing Delete again arrived here
|
|
1504
|
+
// and asked the same question a second time. The tag guard below does
|
|
1505
|
+
// not catch it, because a BUTTON is not one of the tags it names.
|
|
1506
|
+
if (FigureConfirm.modalOpen) return;
|
|
1507
|
+
// `isContentEditable` is checked too: the text editor is a DIV, not a
|
|
1508
|
+
// TEXTAREA, so the tag list alone would let every canvas shortcut fire
|
|
1509
|
+
// while somebody is typing a caption.
|
|
1510
|
+
const active = document.activeElement;
|
|
1511
|
+
const typing = active
|
|
1512
|
+
&& (["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName)
|
|
1513
|
+
|| active.isContentEditable);
|
|
1514
|
+
if (typing) return;
|
|
1515
|
+
|
|
1516
|
+
// The standard chords, bound here because this is what owns the
|
|
1517
|
+
// selection they act on. Undo and redo are the workspace's -- they are
|
|
1518
|
+
// the document's, not the page's.
|
|
1519
|
+
if (event.metaKey || event.ctrlKey) {
|
|
1520
|
+
// Z-order, keyed off `event.code` rather than `event.key`: with
|
|
1521
|
+
// Shift held, the bracket keys report "{" and "}" on a US layout
|
|
1522
|
+
// and something else again on most others, so the physical key is
|
|
1523
|
+
// the only stable name for them.
|
|
1524
|
+
const bracket = { BracketRight: "up", BracketLeft: "down" }[event.code];
|
|
1525
|
+
if (bracket && this.selection.size) {
|
|
1526
|
+
event.preventDefault();
|
|
1527
|
+
this.reorderZ(bracket === "up"
|
|
1528
|
+
? (event.shiftKey ? "front" : "forward")
|
|
1529
|
+
: (event.shiftKey ? "back" : "backward"));
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const chord = {
|
|
1533
|
+
c: () => this.copySelection(),
|
|
1534
|
+
v: () => this.paste(null),
|
|
1535
|
+
d: () => this.duplicateSelection(),
|
|
1536
|
+
a: () => this.selectAllOnPage(),
|
|
1537
|
+
g: () => (event.shiftKey ? this.ungroupSelection() : this.groupSelection()),
|
|
1538
|
+
}[event.key.toLowerCase()];
|
|
1539
|
+
if (!chord) return;
|
|
1540
|
+
// Cmd+A on an empty page is still "select everything", so the
|
|
1541
|
+
// empty-selection guard comes after the chords that do not need one.
|
|
1542
|
+
if (event.key.toLowerCase() !== "a" && event.key.toLowerCase() !== "v"
|
|
1543
|
+
&& !this.selection.size) {
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
event.preventDefault();
|
|
1547
|
+
chord();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (!this.selection.size) return;
|
|
1551
|
+
|
|
1552
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
1553
|
+
event.preventDefault();
|
|
1554
|
+
this.removeSelection();
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const step = event.shiftKey ? FigureCanvas.NUDGE_COARSE_MM : FigureCanvas.NUDGE_MM;
|
|
1558
|
+
const deltas = {
|
|
1559
|
+
ArrowLeft: [-step, 0], ArrowRight: [step, 0],
|
|
1560
|
+
ArrowUp: [0, -step], ArrowDown: [0, step],
|
|
1561
|
+
}[event.key];
|
|
1562
|
+
if (!deltas) return;
|
|
1563
|
+
event.preventDefault();
|
|
1564
|
+
this.nudge(deltas[0], deltas[1]);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Move the selection by a step.
|
|
1569
|
+
*
|
|
1570
|
+
* Annotations move too. They did not, and the filter to panels made the
|
|
1571
|
+
* arrow keys silently inert on a caption -- the commonest thing on a page
|
|
1572
|
+
* to want a half-millimetre nudge on, and the one object that had no way to
|
|
1573
|
+
* get one.
|
|
1574
|
+
*/
|
|
1575
|
+
nudge(dx, dy) {
|
|
1576
|
+
const moves = this.selectedPanels()
|
|
1577
|
+
.filter((panel) => panel.placement)
|
|
1578
|
+
.map((panel) => ({
|
|
1579
|
+
panel_id: panel.panel_id,
|
|
1580
|
+
placement: { ...panel.placement,
|
|
1581
|
+
x_mm: panel.placement.x_mm + dx,
|
|
1582
|
+
y_mm: panel.placement.y_mm + dy },
|
|
1583
|
+
}));
|
|
1584
|
+
const shifts = this.selectedAnnotations().map((annotation) => ({
|
|
1585
|
+
op: "update_annotation", annotation_id: annotation.annotation_id,
|
|
1586
|
+
changes: { geometry: { x_mm: annotation.geometry.x_mm + dx,
|
|
1587
|
+
y_mm: annotation.geometry.y_mm + dy } },
|
|
1588
|
+
}));
|
|
1589
|
+
if (!moves.length && !shifts.length) return;
|
|
1590
|
+
// One batch, so a nudge of an image and its caption is one undo step
|
|
1591
|
+
// rather than two.
|
|
1592
|
+
const ops = moves.length ? [{ op: "move_panels", moves: moves }] : [];
|
|
1593
|
+
this.state.commit(ops.concat(shifts), (draft) => {
|
|
1594
|
+
for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
|
|
1595
|
+
for (const shift of shifts) {
|
|
1596
|
+
Object.assign(draft.annotations[shift.annotation_id].geometry,
|
|
1597
|
+
shift.changes.geometry);
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Delete what is selected.
|
|
1604
|
+
*
|
|
1605
|
+
* A panel goes back to the TRAY rather than being destroyed: the captured
|
|
1606
|
+
* scene may be the only record of a view somebody spent an hour finding,
|
|
1607
|
+
* and Delete on a layout is a statement about the layout. Removing it for
|
|
1608
|
+
* good is done from the tray, where the thing being destroyed is what is
|
|
1609
|
+
* under the pointer.
|
|
1610
|
+
*/
|
|
1611
|
+
removeSelection() {
|
|
1612
|
+
const panels = this.selectedPanels().filter((panel) => panel.placement);
|
|
1613
|
+
const annotationIds = Array.from(this.selection)
|
|
1614
|
+
.filter((id) => this.state.document.annotations[id]);
|
|
1615
|
+
|
|
1616
|
+
const operations = [];
|
|
1617
|
+
if (panels.length) {
|
|
1618
|
+
operations.push({
|
|
1619
|
+
op: "move_panels",
|
|
1620
|
+
moves: panels.map((panel) => ({ panel_id: panel.panel_id, placement: null })),
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
if (annotationIds.length) {
|
|
1624
|
+
operations.push({ op: "remove_annotations", annotation_ids: annotationIds });
|
|
1625
|
+
}
|
|
1626
|
+
if (!operations.length) return;
|
|
1627
|
+
|
|
1628
|
+
this.selection.clear();
|
|
1629
|
+
this.state.commit(operations, (draft) => {
|
|
1630
|
+
for (const panel of panels) draft.panels[panel.panel_id].placement = null;
|
|
1631
|
+
for (const id of annotationIds) delete draft.annotations[id];
|
|
1632
|
+
});
|
|
1633
|
+
this.onSelectionChange([]);
|
|
1634
|
+
}
|
|
1635
|
+
// -- duplicating, copying, grouping, stacking --------------------------
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* Copy the selection, offset a little, in ONE commit.
|
|
1639
|
+
*
|
|
1640
|
+
* The scene is deep-copied and the render revision is carried across
|
|
1641
|
+
* unchanged, which is what makes the copy show a picture immediately: its
|
|
1642
|
+
* preview is the original's bytes, uploaded under the new panel's id. A
|
|
1643
|
+
* copy whose preview had to be re-rendered would arrive as an empty frame
|
|
1644
|
+
* and stay that way until the user reopened it in the viewer.
|
|
1645
|
+
*/
|
|
1646
|
+
duplicateSelection() {
|
|
1647
|
+
const made = this.copiesOf(this.selectedPanels(), this.selectedAnnotations(),
|
|
1648
|
+
FigureCanvas.PASTE_OFFSET_MM, FigureCanvas.PASTE_OFFSET_MM);
|
|
1649
|
+
this.commitCopies(made);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
copySelection() {
|
|
1653
|
+
FigureClipboard.put(this.selectedPanels(), this.selectedAnnotations());
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** Paste at a point, or offset from where the originals were. */
|
|
1657
|
+
paste(point) {
|
|
1658
|
+
const held = FigureClipboard.take();
|
|
1659
|
+
if (!held.panels.length && !held.annotations.length) return;
|
|
1660
|
+
|
|
1661
|
+
let dx = FigureCanvas.PASTE_OFFSET_MM;
|
|
1662
|
+
let dy = FigureCanvas.PASTE_OFFSET_MM;
|
|
1663
|
+
if (point) {
|
|
1664
|
+
const boxes = held.panels.map((panel) => panel.placement)
|
|
1665
|
+
.concat(held.annotations.map((annotation) => annotation.geometry))
|
|
1666
|
+
.filter(Boolean);
|
|
1667
|
+
if (boxes.length) {
|
|
1668
|
+
dx = point.x - Math.min(...boxes.map((box) => box.x_mm));
|
|
1669
|
+
dy = point.y - Math.min(...boxes.map((box) => box.y_mm));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
this.commitCopies(this.copiesOf(held.panels, held.annotations, dx, dy));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* New panels and annotations, offset, ready to commit.
|
|
1677
|
+
*
|
|
1678
|
+
* `pageId` is where the copies land, defaulting to the page being looked
|
|
1679
|
+
* at; page duplication passes the new page. A panel with no placement (one
|
|
1680
|
+
* sitting in the tray) keeps none -- duplicating a tray panel gives another
|
|
1681
|
+
* tray panel, not one that jumps onto whatever page happened to be open.
|
|
1682
|
+
*/
|
|
1683
|
+
copiesOf(panels, annotations, dx, dy, pageId) {
|
|
1684
|
+
const page = pageId || this.pageId;
|
|
1685
|
+
const madePanels = panels.map((panel) => {
|
|
1686
|
+
const copy = JSON.parse(JSON.stringify(panel));
|
|
1687
|
+
copy.panel_id = FigureSchema.newPanelId();
|
|
1688
|
+
// Never a member of the original's link group: a duplicate is a new
|
|
1689
|
+
// panel that happens to look the same, and joining the row would
|
|
1690
|
+
// make resizing the original resize it too.
|
|
1691
|
+
copy.link_group = null;
|
|
1692
|
+
copy.derived_from = { panel_id: panel.panel_id, operation: "duplicate", layer: "" };
|
|
1693
|
+
if (copy.placement) {
|
|
1694
|
+
copy.placement = { ...copy.placement, page_id: page,
|
|
1695
|
+
x_mm: copy.placement.x_mm + dx,
|
|
1696
|
+
y_mm: copy.placement.y_mm + dy };
|
|
1697
|
+
}
|
|
1698
|
+
return { copy: copy, from: panel };
|
|
1699
|
+
});
|
|
1700
|
+
const madeAnnotations = annotations.map((annotation) => {
|
|
1701
|
+
const copy = JSON.parse(JSON.stringify(annotation));
|
|
1702
|
+
copy.annotation_id = FigureSchema.newAnnotationId();
|
|
1703
|
+
copy.page_id = page;
|
|
1704
|
+
copy.geometry = { ...copy.geometry,
|
|
1705
|
+
x_mm: copy.geometry.x_mm + dx, y_mm: copy.geometry.y_mm + dy };
|
|
1706
|
+
return copy;
|
|
1707
|
+
});
|
|
1708
|
+
return { panels: madePanels, annotations: madeAnnotations };
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Store a set of copies and give each one the original's preview.
|
|
1713
|
+
*
|
|
1714
|
+
* `options.select` is false when the copies are not on the page being
|
|
1715
|
+
* looked at -- duplicating a tray panel, or a whole page -- because
|
|
1716
|
+
* selecting something the user cannot see puts a context bar on screen
|
|
1717
|
+
* pointing at nothing. `options.operations` lets a caller put the copies
|
|
1718
|
+
* inside a larger batch, which is how page duplication stays one undo step.
|
|
1719
|
+
*/
|
|
1720
|
+
commitCopies(made, options) {
|
|
1721
|
+
const flags = options || {};
|
|
1722
|
+
if (!made.panels.length && !made.annotations.length) return Promise.resolve(false);
|
|
1723
|
+
let z = this.nextZ();
|
|
1724
|
+
for (const entry of made.panels) {
|
|
1725
|
+
if (entry.copy.placement && flags.select !== false) entry.copy.placement.z = z++;
|
|
1726
|
+
}
|
|
1727
|
+
const operations = (flags.operations || []).slice();
|
|
1728
|
+
operations.push(...made.panels.map((entry) => ({ op: "add_panel", panel: entry.copy })));
|
|
1729
|
+
operations.push(...made.annotations.map((a) => ({ op: "add_annotation", annotation: a })));
|
|
1730
|
+
|
|
1731
|
+
const stored = this.state.commit(operations, (draft) => {
|
|
1732
|
+
if (typeof flags.mutate === "function") flags.mutate(draft);
|
|
1733
|
+
for (const entry of made.panels) draft.panels[entry.copy.panel_id] = entry.copy;
|
|
1734
|
+
for (const a of made.annotations) draft.annotations[a.annotation_id] = a;
|
|
1735
|
+
}).then((ok) => {
|
|
1736
|
+
if (ok) this.copyPreviews(made.panels);
|
|
1737
|
+
return ok;
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
if (flags.select !== false) {
|
|
1741
|
+
this.select(made.panels.map((entry) => entry.copy.panel_id)
|
|
1742
|
+
.concat(made.annotations.map((a) => a.annotation_id)), false);
|
|
1743
|
+
}
|
|
1744
|
+
return stored;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Give each copy the original's preview bytes.
|
|
1749
|
+
*
|
|
1750
|
+
* After the document commit, not before: a preview uploaded for a panel the
|
|
1751
|
+
* server has not accepted yet is a file with no owner. Failures are silent
|
|
1752
|
+
* on purpose -- the copy is already real, and an empty frame is a smaller
|
|
1753
|
+
* problem than an error about a raster.
|
|
1754
|
+
*/
|
|
1755
|
+
async copyPreviews(entries) {
|
|
1756
|
+
for (const entry of entries) {
|
|
1757
|
+
const source = this.state.source(entry.from.source_id);
|
|
1758
|
+
// An imported asset is drawn straight from the figure's own
|
|
1759
|
+
// directory, so the copy already has its picture.
|
|
1760
|
+
if (source && source.kind === "imported_asset") continue;
|
|
1761
|
+
try {
|
|
1762
|
+
const response = await fetch(this.api.previewUrl(
|
|
1763
|
+
this.figureId, entry.from.panel_id, entry.from.render_revision));
|
|
1764
|
+
if (!response.ok) continue;
|
|
1765
|
+
const blob = await response.blob();
|
|
1766
|
+
await this.api.putPreview(this.figureId, entry.copy.panel_id,
|
|
1767
|
+
entry.copy.render_revision, blob, {});
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
/* see the docstring */
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
this.render();
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
groupSelection() {
|
|
1776
|
+
const ids = Array.from(this.selection);
|
|
1777
|
+
if (ids.length < 2) return;
|
|
1778
|
+
// Already grouped members are dissolved into the new group rather than
|
|
1779
|
+
// refused: selecting a group plus one more thing and pressing Cmd+G
|
|
1780
|
+
// plainly means "all of these".
|
|
1781
|
+
const dissolve = new Set();
|
|
1782
|
+
for (const id of ids) {
|
|
1783
|
+
const group = this.groupFor(id);
|
|
1784
|
+
if (group) dissolve.add(group.group_id);
|
|
1785
|
+
}
|
|
1786
|
+
const groupId = FigureSchema.newGroupId();
|
|
1787
|
+
const group = { group_id: groupId, member_ids: ids };
|
|
1788
|
+
const operations = Array.from(dissolve)
|
|
1789
|
+
.map((id) => ({ op: "ungroup_items", group_id: id }));
|
|
1790
|
+
operations.push({ op: "group_items", group: group });
|
|
1791
|
+
|
|
1792
|
+
this.state.commit(operations, (draft) => {
|
|
1793
|
+
for (const id of dissolve) delete draft.groups[id];
|
|
1794
|
+
draft.groups[groupId] = group;
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
ungroupSelection() {
|
|
1799
|
+
const ids = new Set();
|
|
1800
|
+
for (const id of this.selection) {
|
|
1801
|
+
const group = this.groupFor(id);
|
|
1802
|
+
if (group) ids.add(group.group_id);
|
|
1803
|
+
}
|
|
1804
|
+
if (!ids.size) return;
|
|
1805
|
+
this.state.commit(
|
|
1806
|
+
Array.from(ids).map((id) => ({ op: "ungroup_items", group_id: id })),
|
|
1807
|
+
(draft) => { for (const id of ids) delete draft.groups[id]; });
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Move the selection through the z-order.
|
|
1812
|
+
*
|
|
1813
|
+
* Four commands, not two. "To the front" and "one place forward" are
|
|
1814
|
+
* different intents, and a stack of overlapping panels is exactly where the
|
|
1815
|
+
* second is the only one that gets you where you meant to go -- with only
|
|
1816
|
+
* the two absolute commands, putting a panel between two others meant
|
|
1817
|
+
* fronting it and then fronting each of the ones that had to end up over it.
|
|
1818
|
+
*
|
|
1819
|
+
* Written as absolute z values rather than as +1/-1, because "bring to
|
|
1820
|
+
* front" past a panel already at the front has to be a no-op rather than a
|
|
1821
|
+
* number that climbs for ever.
|
|
1822
|
+
*
|
|
1823
|
+
* Panels and annotations are reordered in separate stacks. Annotations draw
|
|
1824
|
+
* at `1000 + z` and so are always above every panel; "bring to front" on a
|
|
1825
|
+
* caption means the front of the captions, and always did.
|
|
1826
|
+
*/
|
|
1827
|
+
reorderZ(command) {
|
|
1828
|
+
const annotations = this.selectedAnnotations();
|
|
1829
|
+
if (annotations.length) this.reorderAnnotationZ(command, annotations);
|
|
1830
|
+
const selected = this.selectedPanels().filter((panel) => panel.placement);
|
|
1831
|
+
if (!selected.length) return;
|
|
1832
|
+
|
|
1833
|
+
const chosen = new Set(selected.map((panel) => panel.panel_id));
|
|
1834
|
+
// Sorted by z here, not taken in `panelsOnPage`'s order: that one sorts
|
|
1835
|
+
// into READING order for numbering (A B C / D E F), and renumbering z
|
|
1836
|
+
// from it would rewrite the stacking of every panel the user did not
|
|
1837
|
+
// touch. One press of "Bring to front" would reshuffle the whole page.
|
|
1838
|
+
const ordered = FigureCanvas.reordered(
|
|
1839
|
+
FigureSchema.panelsOnPage(this.state.document, this.pageId)
|
|
1840
|
+
.slice().sort((a, b) => a.placement.z - b.placement.z),
|
|
1841
|
+
chosen, command, (panel) => panel.panel_id);
|
|
1842
|
+
if (!ordered) return;
|
|
1843
|
+
|
|
1844
|
+
const moves = ordered.map((panel, index) => ({
|
|
1845
|
+
panel_id: panel.panel_id,
|
|
1846
|
+
placement: { ...panel.placement, z: index },
|
|
1847
|
+
}));
|
|
1848
|
+
this.state.commit([{ op: "move_panels", moves: moves }], (draft) => {
|
|
1849
|
+
for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Reorder annotations among themselves.
|
|
1855
|
+
*
|
|
1856
|
+
* They can be ordered against EACH OTHER at all only since the offset above
|
|
1857
|
+
* was separated from the stack below it -- before that, "Bring to front" on
|
|
1858
|
+
* a text box was a menu item that did nothing, in two different menus.
|
|
1859
|
+
*/
|
|
1860
|
+
reorderAnnotationZ(command, selected) {
|
|
1861
|
+
const chosen = new Set(selected.map((annotation) => annotation.annotation_id));
|
|
1862
|
+
const ordered = FigureCanvas.reordered(
|
|
1863
|
+
Object.values(this.state.document.annotations)
|
|
1864
|
+
.filter((annotation) => annotation.page_id === this.pageId)
|
|
1865
|
+
.sort((a, b) => a.z - b.z),
|
|
1866
|
+
chosen, command, (annotation) => annotation.annotation_id);
|
|
1867
|
+
if (!ordered) return;
|
|
1868
|
+
|
|
1869
|
+
const ops = ordered.map((annotation, index) => ({
|
|
1870
|
+
op: "update_annotation", annotation_id: annotation.annotation_id,
|
|
1871
|
+
changes: { z: index },
|
|
1872
|
+
}));
|
|
1873
|
+
this.state.commit(ops, (draft) => {
|
|
1874
|
+
ops.forEach((op) => { draft.annotations[op.annotation_id].z = op.changes.z; });
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* `ordered` with the chosen members moved as `command` asks, or null when
|
|
1880
|
+
* there is nothing for the command to do.
|
|
1881
|
+
*
|
|
1882
|
+
* `ordered` runs back to front. Pure and static, which is the point: this
|
|
1883
|
+
* is the only part of z-ordering that is arithmetic rather than DOM, and it
|
|
1884
|
+
* is the part that is easy to get subtly wrong.
|
|
1885
|
+
*
|
|
1886
|
+
* A multiple selection moves as a BLOCK. Stepping each member on its own
|
|
1887
|
+
* reverses a pair of adjacent objects on the second press -- the classic
|
|
1888
|
+
* bug in every "send backward" written as a loop over the selection -- and
|
|
1889
|
+
* it also lets a selection come apart across a gap it should have jumped.
|
|
1890
|
+
*/
|
|
1891
|
+
static reordered(ordered, chosen, command, idOf) {
|
|
1892
|
+
const picked = ordered.filter((item) => chosen.has(idOf(item)));
|
|
1893
|
+
const rest = ordered.filter((item) => !chosen.has(idOf(item)));
|
|
1894
|
+
if (!picked.length || !rest.length) return null;
|
|
1895
|
+
if (command === "front") return rest.concat(picked);
|
|
1896
|
+
if (command === "back") return picked.concat(rest);
|
|
1897
|
+
|
|
1898
|
+
const forward = command === "forward";
|
|
1899
|
+
// The edge of the block on the side it is moving towards, gaps in the
|
|
1900
|
+
// selection included -- so a split selection jumps whatever sits
|
|
1901
|
+
// between its two halves rather than closing up around it.
|
|
1902
|
+
const edge = forward
|
|
1903
|
+
? ordered.length - 1 - [...ordered].reverse()
|
|
1904
|
+
.findIndex((item) => chosen.has(idOf(item)))
|
|
1905
|
+
: ordered.findIndex((item) => chosen.has(idOf(item)));
|
|
1906
|
+
const neighbour = ordered[edge + (forward ? 1 : -1)];
|
|
1907
|
+
if (!neighbour) return null;
|
|
1908
|
+
|
|
1909
|
+
const out = rest.slice();
|
|
1910
|
+
out.splice(out.indexOf(neighbour) + (forward ? 1 : 0), 0, ...picked);
|
|
1911
|
+
return out;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// -- layout commands -------------------------------------------------
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Align, distribute or equalise the selection.
|
|
1918
|
+
*
|
|
1919
|
+
* Everything compiles to ONE commit, so each command is one undo step --
|
|
1920
|
+
* the same rule the drag follows, for the same reason.
|
|
1921
|
+
*
|
|
1922
|
+
* ANNOTATIONS TOO. This read `selectedPanels()` and nothing else, so
|
|
1923
|
+
* selecting two captions and pressing Align left ran the whole arithmetic
|
|
1924
|
+
* over an empty list and returned without touching either of them -- a menu
|
|
1925
|
+
* with six live rows in it, none of which did anything. It is the same bug
|
|
1926
|
+
* `reorderZ` and `nudge` had, in the last place it was left.
|
|
1927
|
+
*
|
|
1928
|
+
* A caption's rectangle is its `geometry` and a panel's is its `placement`;
|
|
1929
|
+
* they hold the same four numbers under the same names, which is what makes
|
|
1930
|
+
* one list of boxes possible at all. What differs is the fifth key -- a
|
|
1931
|
+
* panel carries `z` and an annotation carries `rotation` -- so each box is
|
|
1932
|
+
* spread from its own object and handed back to it whole.
|
|
1933
|
+
*
|
|
1934
|
+
* Lines and arrows are left out, by `arrangeItems` rather than here. See
|
|
1935
|
+
* FigureSelection.describe: their `w_mm`/`h_mm` are a vector, and "same
|
|
1936
|
+
* width" on one would reverse its direction rather than resize it.
|
|
1937
|
+
*/
|
|
1938
|
+
arrange(command) {
|
|
1939
|
+
const items = this.arrangeItems();
|
|
1940
|
+
if (items.length < 2) return;
|
|
1941
|
+
const boxes = items.map((item) => ({ ...item.box }));
|
|
1942
|
+
|
|
1943
|
+
const left = Math.min(...boxes.map((b) => b.x_mm));
|
|
1944
|
+
const right = Math.max(...boxes.map((b) => b.x_mm + b.w_mm));
|
|
1945
|
+
const top = Math.min(...boxes.map((b) => b.y_mm));
|
|
1946
|
+
const bottom = Math.max(...boxes.map((b) => b.y_mm + b.h_mm));
|
|
1947
|
+
|
|
1948
|
+
if (command === "left") boxes.forEach((b) => { b.x_mm = left; });
|
|
1949
|
+
else if (command === "right") boxes.forEach((b) => { b.x_mm = right - b.w_mm; });
|
|
1950
|
+
else if (command === "center") boxes.forEach((b) => { b.x_mm = (left + right - b.w_mm) / 2; });
|
|
1951
|
+
else if (command === "top") boxes.forEach((b) => { b.y_mm = top; });
|
|
1952
|
+
else if (command === "bottom") boxes.forEach((b) => { b.y_mm = bottom - b.h_mm; });
|
|
1953
|
+
else if (command === "middle") boxes.forEach((b) => { b.y_mm = (top + bottom - b.h_mm) / 2; });
|
|
1954
|
+
else if (command === "same_width") boxes.forEach((b) => { b.w_mm = boxes[0].w_mm; });
|
|
1955
|
+
else if (command === "same_height") boxes.forEach((b) => { b.h_mm = boxes[0].h_mm; });
|
|
1956
|
+
else if (command === "same_size") {
|
|
1957
|
+
boxes.forEach((b) => { b.w_mm = boxes[0].w_mm; b.h_mm = boxes[0].h_mm; });
|
|
1958
|
+
} else if (command === "distribute_h") this.distribute(boxes, "x_mm", "w_mm", left, right);
|
|
1959
|
+
else if (command === "distribute_v") this.distribute(boxes, "y_mm", "h_mm", top, bottom);
|
|
1960
|
+
else if (command === "row") this.pack(boxes, "row");
|
|
1961
|
+
else if (command === "column") this.pack(boxes, "column");
|
|
1962
|
+
else if (command === "grid") this.pack(boxes, "grid");
|
|
1963
|
+
else return;
|
|
1964
|
+
|
|
1965
|
+
this.commitBoxes(items, boxes);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* The selection as one list of rectangles, whatever kind each object is.
|
|
1970
|
+
*
|
|
1971
|
+
* In selection order rather than in page order, because `same_width` and
|
|
1972
|
+
* friends take their answer from `boxes[0]` -- so "the first one I picked"
|
|
1973
|
+
* is the one the others match, which is the only reading of it a user can
|
|
1974
|
+
* predict.
|
|
1975
|
+
*/
|
|
1976
|
+
arrangeItems() {
|
|
1977
|
+
const items = [];
|
|
1978
|
+
for (const id of this.selection) {
|
|
1979
|
+
const panel = this.state.panel(id);
|
|
1980
|
+
if (panel && panel.placement) {
|
|
1981
|
+
items.push({ kind: "panel", id: id, box: { ...panel.placement } });
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
const annotation = this.state.document.annotations[id];
|
|
1985
|
+
if (annotation && !["line", "arrow"].includes(annotation.type)) {
|
|
1986
|
+
items.push({ kind: "annotation", id: id,
|
|
1987
|
+
box: { ...annotation.geometry } });
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return items;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** Equal GAPS, not equal centres. Equal centres leaves visibly uneven space
|
|
1994
|
+
* the moment the panels are not all the same size, which for a figure of
|
|
1995
|
+
* mixed crops is most of the time. */
|
|
1996
|
+
distribute(boxes, axis, size, low, high) {
|
|
1997
|
+
const ordered = boxes.slice().sort((a, b) => a[axis] - b[axis]);
|
|
1998
|
+
const total = ordered.reduce((sum, box) => sum + box[size], 0);
|
|
1999
|
+
const gap = (high - low - total) / (ordered.length - 1);
|
|
2000
|
+
let cursor = low;
|
|
2001
|
+
for (const box of ordered) {
|
|
2002
|
+
box[axis] = cursor;
|
|
2003
|
+
cursor += box[size] + gap;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/** Row, column or smart grid, inside the bounding box of the selection and
|
|
2008
|
+
* using the document's gutter. The result is ordinary geometry the user can
|
|
2009
|
+
* then drag -- an arrangement, not a layout mode that has to be maintained. */
|
|
2010
|
+
pack(boxes, shape) {
|
|
2011
|
+
const gutter = this.state.document.settings.style.gutter_mm;
|
|
2012
|
+
const left = Math.min(...boxes.map((b) => b.x_mm));
|
|
2013
|
+
const top = Math.min(...boxes.map((b) => b.y_mm));
|
|
2014
|
+
const columns = shape === "row" ? boxes.length
|
|
2015
|
+
: shape === "column" ? 1
|
|
2016
|
+
: Math.max(1, Math.round(Math.sqrt(boxes.length)));
|
|
2017
|
+
|
|
2018
|
+
const width = Math.max(...boxes.map((b) => b.w_mm));
|
|
2019
|
+
const height = Math.max(...boxes.map((b) => b.h_mm));
|
|
2020
|
+
boxes.forEach((box, index) => {
|
|
2021
|
+
box.x_mm = left + (index % columns) * (width + gutter);
|
|
2022
|
+
box.y_mm = top + Math.floor(index / columns) * (height + gutter);
|
|
2023
|
+
box.w_mm = width;
|
|
2024
|
+
box.h_mm = height;
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Put the arranged boxes back, panels and annotations together, as ONE
|
|
2030
|
+
* commit.
|
|
2031
|
+
*
|
|
2032
|
+
* One commit and not two: aligning a caption to a panel is a single thing
|
|
2033
|
+
* the user did, and two commits would be two presses of Ctrl+Z to undo it,
|
|
2034
|
+
* with the figure sitting in a half-aligned state in between. The operation
|
|
2035
|
+
* vocabulary already batches panels; the annotations ride along in the same
|
|
2036
|
+
* list, which is what `splitComposite` does with panels and links.
|
|
2037
|
+
*/
|
|
2038
|
+
commitBoxes(items, boxes) {
|
|
2039
|
+
const moves = [];
|
|
2040
|
+
const operations = [];
|
|
2041
|
+
items.forEach((item, index) => {
|
|
2042
|
+
if (item.kind === "panel") {
|
|
2043
|
+
moves.push({ panel_id: item.id, placement: boxes[index] });
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
operations.push({ op: "update_annotation", annotation_id: item.id,
|
|
2047
|
+
changes: { geometry: boxes[index] } });
|
|
2048
|
+
});
|
|
2049
|
+
if (moves.length) operations.unshift({ op: "move_panels", moves: moves });
|
|
2050
|
+
if (!operations.length) return;
|
|
2051
|
+
|
|
2052
|
+
this.state.commit(operations, (draft) => {
|
|
2053
|
+
for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
|
|
2054
|
+
for (const operation of operations) {
|
|
2055
|
+
if (operation.op !== "update_annotation") continue;
|
|
2056
|
+
draft.annotations[operation.annotation_id].geometry =
|
|
2057
|
+
operation.changes.geometry;
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
// -- split composite -------------------------------------------------
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Turn one composite panel into a row of single-channel panels.
|
|
2065
|
+
*
|
|
2066
|
+
* The move this whole plugin is worth building for. Making the same figure
|
|
2067
|
+
* by hand is: find the field again, turn off every channel but one,
|
|
2068
|
+
* screenshot, repeat, then line five images up and hope they are the same
|
|
2069
|
+
* crop. Here the crop is not hoped for -- every derived panel carries the
|
|
2070
|
+
* SAME viewport, because it is copied rather than re-found.
|
|
2071
|
+
*
|
|
2072
|
+
* `mode` is "with_composite" (the original stays, first) or "channels_only".
|
|
2073
|
+
*
|
|
2074
|
+
* Everything arrives in ONE commit: N panels, their layout, and the link
|
|
2075
|
+
* between them. That is what makes a five-channel split one Ctrl+Z rather
|
|
2076
|
+
* than five, and it is why the operation vocabulary has batch forms at all.
|
|
2077
|
+
*/
|
|
2078
|
+
splitComposite(panelId, mode) {
|
|
2079
|
+
const panel = this.state.panel(panelId);
|
|
2080
|
+
if (!panel || !panel.placement) return null;
|
|
2081
|
+
const channels = (panel.scene.channels || []).filter((c) => c.visible !== false);
|
|
2082
|
+
if (channels.length < 2) return null;
|
|
2083
|
+
|
|
2084
|
+
const gutter = this.state.document.settings.style.gutter_mm;
|
|
2085
|
+
const place = panel.placement;
|
|
2086
|
+
const page = this.page;
|
|
2087
|
+
const keepComposite = mode !== "channels_only";
|
|
2088
|
+
|
|
2089
|
+
const derived = channels.map((channel) => ({
|
|
2090
|
+
panel_id: FigureSchema.newPanelId(),
|
|
2091
|
+
source_id: panel.source_id,
|
|
2092
|
+
scene: {
|
|
2093
|
+
...JSON.parse(JSON.stringify(panel.scene)),
|
|
2094
|
+
// One channel each. The windows, the colours and the region stay
|
|
2095
|
+
// the composite's -- a split that re-auto-levelled each channel
|
|
2096
|
+
// would produce a row nobody could compare.
|
|
2097
|
+
channels: [JSON.parse(JSON.stringify(channel))],
|
|
2098
|
+
captured_at: new Date().toISOString(),
|
|
2099
|
+
},
|
|
2100
|
+
placement: null,
|
|
2101
|
+
// Named from the channel, because that is what the panel now shows
|
|
2102
|
+
// and typing five titles is the tax this feature exists to remove.
|
|
2103
|
+
title: channel.fullname_at_capture || channel.key,
|
|
2104
|
+
label: { text: "", auto: true, visible: panel.label.visible },
|
|
2105
|
+
scalebar: { visible: false, target_um: panel.scalebar.target_um },
|
|
2106
|
+
legend: { channels: false, plugins: false },
|
|
2107
|
+
render_revision: 1,
|
|
2108
|
+
derived_from: { panel_id: panelId, operation: "split_channel",
|
|
2109
|
+
layer: channel.key },
|
|
2110
|
+
}));
|
|
2111
|
+
|
|
2112
|
+
const row = keepComposite
|
|
2113
|
+
? [panelId, ...derived.map((entry) => entry.panel_id)]
|
|
2114
|
+
: derived.map((entry) => entry.panel_id);
|
|
2115
|
+
const placements = this._rowPlacements(row, place, gutter, page);
|
|
2116
|
+
const groupId = FigureSchema.newGroupId();
|
|
2117
|
+
|
|
2118
|
+
const operations = derived.map((entry) => ({ op: "add_panel", panel: entry }));
|
|
2119
|
+
operations.push({
|
|
2120
|
+
op: "move_panels",
|
|
2121
|
+
moves: row.map((id) => ({ panel_id: id, placement: placements[id] })),
|
|
2122
|
+
});
|
|
2123
|
+
if (!keepComposite) {
|
|
2124
|
+
// Removing the original is part of the same action, so it rides in
|
|
2125
|
+
// the same batch -- and therefore in the same undo step.
|
|
2126
|
+
operations.push({ op: "remove_panels", panel_ids: [panelId] });
|
|
2127
|
+
}
|
|
2128
|
+
operations.push({
|
|
2129
|
+
op: "link_panels",
|
|
2130
|
+
group: {
|
|
2131
|
+
group_id: groupId, panel_ids: row,
|
|
2132
|
+
// The crop and the box, not the channels: a split row shares a
|
|
2133
|
+
// field of view and emphatically does not share what is drawn
|
|
2134
|
+
// in it, which is the entire point of it.
|
|
2135
|
+
sync: ["viewport", "size"],
|
|
2136
|
+
},
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
this.state.commit(operations, (draft) => {
|
|
2140
|
+
for (const entry of derived) draft.panels[entry.panel_id] = entry;
|
|
2141
|
+
for (const id of row) {
|
|
2142
|
+
if (draft.panels[id]) draft.panels[id].placement = placements[id];
|
|
2143
|
+
}
|
|
2144
|
+
if (!keepComposite) delete draft.panels[panelId];
|
|
2145
|
+
draft.link_groups[groupId] = {
|
|
2146
|
+
group_id: groupId, panel_ids: row.slice(), sync: ["viewport", "size"],
|
|
2147
|
+
};
|
|
2148
|
+
for (const id of row) {
|
|
2149
|
+
if (draft.panels[id]) draft.panels[id].link_group = groupId;
|
|
2150
|
+
}
|
|
2151
|
+
});
|
|
2152
|
+
this.select(derived.map((entry) => entry.panel_id), false);
|
|
2153
|
+
return groupId;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* Lay a set of panels out in a row from where the original sat, wrapping
|
|
2158
|
+
* onto further rows when the page runs out.
|
|
2159
|
+
*
|
|
2160
|
+
* Wrapping rather than shrinking: a row of nine channels squeezed to an A4
|
|
2161
|
+
* width is nine panels too small to read, and the user can always drag them
|
|
2162
|
+
* afterwards. The result is ordinary geometry, not a layout mode that has
|
|
2163
|
+
* to be maintained.
|
|
2164
|
+
*/
|
|
2165
|
+
_rowPlacements(ids, origin, gutter, page) {
|
|
2166
|
+
const available = page ? page.size_mm.w - origin.x_mm : Infinity;
|
|
2167
|
+
const perRow = Math.max(1, Math.floor((available + gutter) / (origin.w_mm + gutter)));
|
|
2168
|
+
const out = {};
|
|
2169
|
+
ids.forEach((id, index) => {
|
|
2170
|
+
out[id] = {
|
|
2171
|
+
...origin,
|
|
2172
|
+
x_mm: origin.x_mm + (index % perRow) * (origin.w_mm + gutter),
|
|
2173
|
+
y_mm: origin.y_mm + Math.floor(index / perRow) * (origin.h_mm + gutter),
|
|
2174
|
+
z: origin.z + index,
|
|
2175
|
+
};
|
|
2176
|
+
});
|
|
2177
|
+
return out;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
/**
|
|
2181
|
+
* The extra moves a resize owes to everyone linked to this panel.
|
|
2182
|
+
*
|
|
2183
|
+
* Only on a RESIZE, never on a move. Sharing a size is what keeps a split
|
|
2184
|
+
* row comparable; sharing a POSITION would mean the row could never be a
|
|
2185
|
+
* row, because dragging one panel would drag them all onto each other.
|
|
2186
|
+
*/
|
|
2187
|
+
_linkedSizeMoves(panel, box) {
|
|
2188
|
+
const groupId = panel.link_group;
|
|
2189
|
+
const group = groupId && this.state.document.link_groups[groupId];
|
|
2190
|
+
if (!group || !group.sync.includes("size")) return [];
|
|
2191
|
+
return group.panel_ids
|
|
2192
|
+
.filter((id) => id !== panel.panel_id)
|
|
2193
|
+
.map((id) => this.state.panel(id))
|
|
2194
|
+
.filter((other) => other && other.placement)
|
|
2195
|
+
.map((other) => ({
|
|
2196
|
+
panel_id: other.panel_id,
|
|
2197
|
+
placement: { ...other.placement, w_mm: box.w_mm, h_mm: box.h_mm },
|
|
2198
|
+
}));
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
// -- the tray --------------------------------------------------------
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Panels dragged in from the tray -- one, or a whole multiple selection.
|
|
2207
|
+
*
|
|
2208
|
+
* Sized from the region each one shows rather than to a fixed box, so a
|
|
2209
|
+
* wide field arrives wide: landing every panel as a square and making the
|
|
2210
|
+
* user fix the aspect ratio afterwards is squashed tissue waiting to be
|
|
2211
|
+
* exported.
|
|
2212
|
+
*
|
|
2213
|
+
* Several at once are laid out from the drop point rather than dropped on
|
|
2214
|
+
* top of each other. A pile at one coordinate looks like ONE panel, and the
|
|
2215
|
+
* others are found only by dragging the top one off -- which is a way to
|
|
2216
|
+
* lose work that looks exactly like a bug.
|
|
2217
|
+
*/
|
|
2218
|
+
dropFromTray(event) {
|
|
2219
|
+
const panelIds = this.readTrayPayload(event.dataTransfer);
|
|
2220
|
+
if (!panelIds.length) return;
|
|
2221
|
+
event.preventDefault();
|
|
2222
|
+
const page = this.page;
|
|
2223
|
+
if (!page) return;
|
|
2224
|
+
|
|
2225
|
+
const point = this.surfacePoint(event);
|
|
2226
|
+
const sizes = this.traySizes(panelIds, page);
|
|
2227
|
+
// The first panel lands centred under the pointer, which is where the
|
|
2228
|
+
// user aimed; the rest flow from there.
|
|
2229
|
+
const origin = {
|
|
2230
|
+
x_mm: Math.max(page.margins_mm.left, point.x - (sizes[0]?.w_mm || 0) / 2),
|
|
2231
|
+
y_mm: Math.max(page.margins_mm.top, point.y - (sizes[0]?.h_mm || 0) / 2),
|
|
2232
|
+
};
|
|
2233
|
+
this.placePanels(panelIds, sizes, origin);
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/** The dragged panel ids. A JSON array is what the tray writes now; the
|
|
2237
|
+
* bare id is still read, because a drag started before a reload would
|
|
2238
|
+
* otherwise arrive as an unexplained no-op. */
|
|
2239
|
+
readTrayPayload(transfer) {
|
|
2240
|
+
const raw = transfer?.getData("text/x-plexora-panel");
|
|
2241
|
+
if (!raw) return [];
|
|
2242
|
+
try {
|
|
2243
|
+
const parsed = JSON.parse(raw);
|
|
2244
|
+
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
|
2245
|
+
} catch (error) {
|
|
2246
|
+
return [raw];
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** The size each tray panel wants, from the shape of the region it shows. */
|
|
2251
|
+
traySizes(panelIds, page) {
|
|
2252
|
+
const width = Math.min(60, page.size_mm.w / 3);
|
|
2253
|
+
return panelIds.map((panelId) => {
|
|
2254
|
+
const panel = this.state.panel(panelId);
|
|
2255
|
+
const viewport = panel?.scene?.viewport;
|
|
2256
|
+
const aspect = (viewport && viewport.w) ? viewport.h / viewport.w : 1;
|
|
2257
|
+
return { w_mm: width, h_mm: width * (aspect || 1) };
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* Put a batch of tray panels onto the page, in ONE commit.
|
|
2263
|
+
*
|
|
2264
|
+
* One commit because placing four panels is one thing the user did: four
|
|
2265
|
+
* would be four undo steps and four saves for a single drag.
|
|
2266
|
+
*/
|
|
2267
|
+
placePanels(panelIds, sizes, origin) {
|
|
2268
|
+
const page = this.page;
|
|
2269
|
+
if (!page || !panelIds.length) return;
|
|
2270
|
+
const boxes = FigureCanvas.freePlacements(
|
|
2271
|
+
sizes, page, this.occupiedBoxes(),
|
|
2272
|
+
this.state.document.settings.style.gutter_mm, origin);
|
|
2273
|
+
|
|
2274
|
+
let z = this.nextZ();
|
|
2275
|
+
const moves = panelIds.map((panelId, index) => ({
|
|
2276
|
+
panel_id: panelId,
|
|
2277
|
+
placement: { page_id: this.pageId, ...boxes[index], z: z++ },
|
|
2278
|
+
})).filter((move) => this.state.panel(move.panel_id));
|
|
2279
|
+
if (!moves.length) return;
|
|
2280
|
+
|
|
2281
|
+
this.state.commit([{ op: "move_panels", moves: moves }], (draft) => {
|
|
2282
|
+
for (const move of moves) draft.panels[move.panel_id].placement = move.placement;
|
|
2283
|
+
});
|
|
2284
|
+
this.select(moves.map((move) => move.panel_id), false);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
/** Everything already standing on this page, as plain boxes. */
|
|
2288
|
+
occupiedBoxes() {
|
|
2289
|
+
return FigureSchema.panelsOnPage(this.state.document, this.pageId)
|
|
2290
|
+
.map((panel) => ({ ...panel.placement }));
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
static overlaps(a, b) {
|
|
2294
|
+
return a.x_mm < b.x_mm + b.w_mm && a.x_mm + a.w_mm > b.x_mm
|
|
2295
|
+
&& a.y_mm < b.y_mm + b.h_mm && a.y_mm + a.h_mm > b.y_mm;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* Where a batch of new boxes should go on a page that already has things
|
|
2300
|
+
* on it.
|
|
2301
|
+
*
|
|
2302
|
+
* A suggestion, not a template: the result is ordinary geometry the user
|
|
2303
|
+
* drags immediately afterwards. What it guarantees is only the thing that
|
|
2304
|
+
* cannot be recovered from by dragging -- that nothing lands exactly on top
|
|
2305
|
+
* of something else, which hides it.
|
|
2306
|
+
*
|
|
2307
|
+
* Left to right from the origin, wrapping down a row when the right margin
|
|
2308
|
+
* is reached and stepping past anything already in the way. When the page
|
|
2309
|
+
* genuinely has no room left the remainder cascades from the origin, offset
|
|
2310
|
+
* by a gutter each: still visible, still obviously new, and honest about
|
|
2311
|
+
* the page being full.
|
|
2312
|
+
*
|
|
2313
|
+
* Pure and static so the arithmetic can be checked without a browser.
|
|
2314
|
+
*/
|
|
2315
|
+
static freePlacements(sizes, page, occupied, gutter, origin) {
|
|
2316
|
+
const left = origin ? origin.x_mm : page.margins_mm.left;
|
|
2317
|
+
const top = origin ? origin.y_mm : page.margins_mm.top;
|
|
2318
|
+
const right = page.size_mm.w - page.margins_mm.right;
|
|
2319
|
+
const floor = page.size_mm.h - page.margins_mm.bottom;
|
|
2320
|
+
const taken = (occupied || []).map((box) => ({ ...box }));
|
|
2321
|
+
|
|
2322
|
+
const out = [];
|
|
2323
|
+
let x = left;
|
|
2324
|
+
let y = top;
|
|
2325
|
+
let rowHeight = 0;
|
|
2326
|
+
|
|
2327
|
+
for (const size of sizes) {
|
|
2328
|
+
let box = null;
|
|
2329
|
+
// Bounded rather than "until it fits": a page crowded with narrow
|
|
2330
|
+
// panels can otherwise step forward a fraction of a millimetre at a
|
|
2331
|
+
// time, and a layout helper must not be able to hang the tab.
|
|
2332
|
+
for (let attempt = 0; attempt < 400 && !box; attempt += 1) {
|
|
2333
|
+
if (x + size.w_mm > right + 0.001 && x > left) {
|
|
2334
|
+
x = left;
|
|
2335
|
+
y += (rowHeight || size.h_mm) + gutter;
|
|
2336
|
+
rowHeight = 0;
|
|
2337
|
+
}
|
|
2338
|
+
if (y + size.h_mm > floor + 0.001 && y > top) break;
|
|
2339
|
+
const candidate = { x_mm: x, y_mm: y, w_mm: size.w_mm, h_mm: size.h_mm };
|
|
2340
|
+
const clash = taken.find((other) => FigureCanvas.overlaps(candidate, other));
|
|
2341
|
+
if (!clash) box = candidate;
|
|
2342
|
+
else x = clash.x_mm + clash.w_mm + gutter;
|
|
2343
|
+
}
|
|
2344
|
+
if (!box) {
|
|
2345
|
+
const step = gutter * (out.length + 1);
|
|
2346
|
+
box = { x_mm: left + step, y_mm: top + step, w_mm: size.w_mm, h_mm: size.h_mm };
|
|
2347
|
+
}
|
|
2348
|
+
out.push(box);
|
|
2349
|
+
taken.push(box);
|
|
2350
|
+
rowHeight = Math.max(rowHeight, box.h_mm);
|
|
2351
|
+
x = box.x_mm + box.w_mm + gutter;
|
|
2352
|
+
y = box.y_mm;
|
|
2353
|
+
}
|
|
2354
|
+
return out;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
nextZ() {
|
|
2358
|
+
const panels = FigureSchema.panelsOnPage(this.state.document, this.pageId);
|
|
2359
|
+
return panels.reduce((top, panel) => Math.max(top, panel.placement.z), 0) + 1;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
}
|