├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── demo │ ├── components │ │ ├── MeasuredImage.js │ │ ├── MeasurementApp.css │ │ └── MeasurementApp.js │ ├── images │ │ └── pollen.jpg │ ├── index.html │ └── index.js └── lib │ ├── components │ ├── MeasurementLayer.css │ ├── MeasurementLayer.js │ ├── buttons │ │ ├── Icons.js │ │ └── MeasurementButtons.js │ └── core │ │ ├── CircleMeasurement.js │ │ ├── LineMeasurement.js │ │ ├── MeasurementLayerBase.css │ │ ├── MeasurementLayerBase.js │ │ ├── TextAnchor.js │ │ └── TextAnnotation.js │ ├── index.js │ └── utils │ └── MeasurementUtils.js ├── webpack.config.demo.js ├── webpack.config.dev.js └── webpack.config.lib.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-flow-strip-types", "transform-object-rest-spread"], 3 | "presets": ["env", "react", "stage-0"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /node_modules 3 | npm-debug.log 4 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Robert Fisher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-measurements 2 | 3 | A React component for measuring & annotating images. 4 | 5 | ## Demo 6 | 7 | Check out the demo [here](https://rmfisher.github.io/react-measurements). 8 | 9 | ## Usage 10 | 11 | ```javascript 12 | import React from "react"; 13 | import { 14 | MeasurementLayer, 15 | calculateDistance, 16 | calculateArea 17 | } from "react-measurements"; 18 | 19 | class App extends React.Component { 20 | state = { measurements: [] }; 21 | 22 | render() { 23 | return ( 24 |
33 | 41 |
42 | ); 43 | } 44 | 45 | onChange = measurements => this.setState({ ...this.state, measurements }); 46 | 47 | measureLine = line => Math.round(calculateDistance(line, 100, 100)) + " mm"; 48 | 49 | measureCircle = circle => 50 | Math.round(calculateArea(circle, 100, 100)) + " mm²"; 51 | } 52 | ``` 53 | 54 | ## Scope 55 | 56 | The component is currently read-only on mobile. A mouse is required to create and edit measurements. 57 | 58 | ## License 59 | 60 | MIT 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-measurements", 3 | "version": "0.6.8", 4 | "description": "A React component for measuring & annotating images.", 5 | "main": "build/lib/index.js", 6 | "author": "Robert Fisher", 7 | "repository": "https://github.com/rmfisher/react-measurements", 8 | "license": "MIT", 9 | "peerDependencies": { 10 | "draft-js": "^0.10.3", 11 | "react": "^16.2.0" 12 | }, 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "babel-core": "^6.26.0", 16 | "babel-loader": "^7.1.2", 17 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 18 | "babel-preset-env": "^1.6.1", 19 | "babel-preset-react": "6.24.1", 20 | "babel-preset-stage-0": "^6.24.1", 21 | "cross-env": "^5.1.1", 22 | "css-loader": "^0.28.7", 23 | "draft-js": "^0.10.4", 24 | "file-loader": "^1.1.5", 25 | "gh-pages": "^1.0.0", 26 | "html-webpack-plugin": "^2.30.1", 27 | "raf": "^3.4.0", 28 | "react": "^16.2.0", 29 | "react-dom": "^16.2.0", 30 | "style-loader": "^0.19.0", 31 | "webpack": "^2.6.1", 32 | "webpack-dev-server": "^2.9.4" 33 | }, 34 | "scripts": { 35 | "build": "npm run build:lib && npm run build:demo", 36 | "build:lib": "cross-env NODE_ENV=production webpack --config webpack.config.lib.js", 37 | "build:demo": "cross-env NODE_ENV=production webpack --config webpack.config.demo.js", 38 | "deploy": "gh-pages -d build/demo", 39 | "start": "webpack-dev-server --hot --inline --config webpack.config.dev.js" 40 | }, 41 | "files": [ 42 | "build/lib/index.js" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/demo/components/MeasuredImage.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import MeasurementLayer from "../../lib/components/MeasurementLayer"; 3 | import { 4 | calculateDistance, 5 | calculateArea 6 | } from "../../lib/utils/MeasurementUtils"; 7 | import { EditorState, ContentState } from "draft-js"; 8 | import pollenImage from "../images/pollen.jpg"; 9 | 10 | export default class MeasuredImage extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | measurements: this.createInitialState(), 15 | widthInPx: 0, 16 | heightInPx: 0 17 | }; 18 | } 19 | 20 | componentDidMount() { 21 | window.addEventListener("resize", this.onImageBoundsChanged); 22 | this.onImageBoundsChanged(); 23 | } 24 | 25 | componentWillUnmount() { 26 | window.addEventListener("resize", this.onImageBoundsChanged); 27 | } 28 | 29 | render() { 30 | return ( 31 |
32 |
33 | Pollen grains (this.image = e)} 37 | onLoad={this.onLoad} 38 | /> 39 | 47 |
48 |
49 | ); 50 | } 51 | 52 | onChange = measurements => this.setState({ ...this.state, measurements }); 53 | 54 | measureLine = line => Math.round(calculateDistance(line, 300, 300)) + " μm"; 55 | 56 | measureCircle = circle => 57 | Math.round(calculateArea(circle, 300, 300) / 10) * 10 + " μm²"; 58 | 59 | onImageBoundsChanged = event => { 60 | const imageBounds = this.image.getBoundingClientRect(); 61 | this.setState({ 62 | ...this.state, 63 | widthInPx: imageBounds.width, 64 | heightInPx: imageBounds.height 65 | }); 66 | }; 67 | 68 | onLoad = () => { 69 | this.onImageBoundsChanged(); 70 | this.props.onImageLoaded(); 71 | }; 72 | 73 | createInitialState = () => [ 74 | { 75 | id: 0, 76 | type: "line", 77 | startX: 0.183, 78 | startY: 0.33, 79 | endX: 0.316, 80 | endY: 0.224 81 | }, 82 | { 83 | id: 1, 84 | type: "circle", 85 | centerX: 0.863, 86 | centerY: 0.414, 87 | radius: 0.0255 88 | }, 89 | { 90 | id: 2, 91 | type: "text", 92 | arrowX: 0.482, 93 | arrowY: 0.739, 94 | textX: 0.54, 95 | textY: 0.82, 96 | editorState: EditorState.createWithContent( 97 | ContentState.createFromText("Pollen Grain") 98 | ) 99 | } 100 | ]; 101 | } 102 | -------------------------------------------------------------------------------- /src/demo/components/MeasurementApp.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #root { 4 | height: 100%; 5 | min-height: 100%; 6 | } 7 | 8 | body { 9 | font-family: -apple-system, BlinkMacSystemFont, "Noto Sans", sans-serif; 10 | font-size: 10pt; 11 | color: #333; 12 | margin: 0; 13 | } 14 | 15 | .container { 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | 20 | .title-bar { 21 | position: relative; 22 | width: 100%; 23 | padding: 15px 20px; 24 | box-sizing: border-box; 25 | display: flex; 26 | flex-direction: column; 27 | } 28 | 29 | .title-bar-inner { 30 | width: 100%; 31 | display: flex; 32 | } 33 | 34 | .mobile-readonly-message { 35 | display: none; 36 | margin: -15px 0 15px 0; 37 | } 38 | 39 | .title-text { 40 | display: none; 41 | margin: 0; 42 | padding: 0; 43 | } 44 | 45 | .splitter { 46 | flex-grow: 1; 47 | } 48 | 49 | a { 50 | margin-left: 15px; 51 | color: #333; 52 | text-decoration: none; 53 | } 54 | 55 | a:hover { 56 | text-decoration: underline; 57 | } 58 | 59 | .content { 60 | position: relative; 61 | padding: 10px 20px 20px 20px; 62 | display: flex; 63 | justify-content: center; 64 | align-items: center; 65 | flex-grow: 1; 66 | } 67 | 68 | .measurements-body { 69 | position: relative; 70 | margin: 0 auto; 71 | display: flex; 72 | flex-direction: column; 73 | width: 100%; 74 | max-width: 500px; 75 | visibility: hidden; 76 | } 77 | 78 | .measurements-body.loaded { 79 | visibility: visible; 80 | } 81 | 82 | .measurements-body p { 83 | padding: 10px; 84 | font-size: 9pt; 85 | text-align: center; 86 | margin: 0; 87 | width: 100%; 88 | box-sizing: border-box; 89 | background-color: rgb(235, 238, 240); 90 | border-width: 0 1px 1px 1px; 91 | border-style: solid; 92 | border-color: rgb(207, 211, 214); 93 | } 94 | 95 | /* The goal is that the child div is always square, i.e. the height is equal to the width. */ 96 | .square-parent { 97 | position: relative; 98 | padding-bottom: 100%; 99 | height: 0; 100 | } 101 | 102 | .square-child { 103 | position: absolute; 104 | top: 0; 105 | left: 0; 106 | width: 100%; 107 | height: 100%; 108 | } 109 | 110 | img { 111 | max-width: 100%; 112 | max-height: 100%; 113 | } 114 | 115 | @media all and (min-width: 640px) { 116 | .container { 117 | min-height: 90%; 118 | } 119 | } 120 | 121 | @media all and (min-height: 750px) { 122 | .measurements-body { 123 | max-width: 600px; 124 | } 125 | } 126 | 127 | @media all and (min-height: 1200px) { 128 | .measurements-body { 129 | max-width: 700px; 130 | } 131 | } 132 | 133 | @media all and (max-width: 520px) { 134 | .content { 135 | padding: 0 10px 10px 10px; 136 | } 137 | 138 | .title-bar { 139 | padding: 10px; 140 | } 141 | 142 | .title-text { 143 | display: block; 144 | } 145 | 146 | a { 147 | margin-left: 10px; 148 | } 149 | 150 | .mobile-readonly-message { 151 | font-size: 9pt; 152 | margin: 3px 0 5px 0; 153 | color: rgb(120, 120, 120); 154 | } 155 | } 156 | 157 | /*---------- Read-only if no mouse, DraftJS doesn't support touch yet. ----------*/ 158 | 159 | @media not all and (pointer: fine) { 160 | .mobile-readonly-message { 161 | display: block; 162 | } 163 | 164 | .measurement-layer .button-bar { 165 | display: none; 166 | } 167 | 168 | .measurement-layer, 169 | .measurement-layer-base .text, 170 | .measurement-layer-base .text-box, 171 | .measurement-layer-base .grabber-group, 172 | .measurement-layer-base .arrow-head-grabber, 173 | .measurement-layer-base .arrow-line-grabber, 174 | .measurement-layer-base .text-anchor.button-showing .delete-button, 175 | .measurement-layer-base .editable .delete-button { 176 | pointer-events: none; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/demo/components/MeasurementApp.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import MeasuredImage from "./MeasuredImage"; 3 | import "./MeasurementApp.css"; 4 | 5 | export default class MeasurementApp extends PureComponent { 6 | state = { loaded: false }; 7 | 8 | render() { 9 | return ( 10 |
11 |
12 |
13 | React Measurements 14 |
15 | 16 | v0.6.8 17 | 18 | GitHub 19 |
20 |
21 | A mouse is required for editing, sorry! 22 |
23 |
24 |
25 |
30 |
31 | 32 |
33 |

Fig. 1: Pollen grains under an electron microscope.

34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | onImageLoaded = () => this.setState({ ...this.state, loaded: true }); 41 | } 42 | -------------------------------------------------------------------------------- /src/demo/images/pollen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmfisher/react-measurements/ab64a451233f11472c3c6c1c5799ac3f00fd7f80/src/demo/images/pollen.jpg -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | React Measurements 9 | 10 | 11 | 12 | 13 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/demo/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import MeasurementApp from "./components/MeasurementApp"; 4 | 5 | ReactDOM.render(, document.getElementById("root")); 6 | -------------------------------------------------------------------------------- /src/lib/components/MeasurementLayer.css: -------------------------------------------------------------------------------- 1 | /*---------- General Layout ----------*/ 2 | 3 | .measurement-layer { 4 | position: absolute; 5 | width: 100%; 6 | height: 100%; 7 | top: 0px; 8 | left: 0px; 9 | } 10 | 11 | /*---------- Buttons ----------*/ 12 | 13 | .measurement-layer .button-bar { 14 | position: absolute; 15 | display: flex; 16 | height: 21px; 17 | top: 5px; 18 | right: 5px; 19 | opacity: 0; 20 | transition: opacity 0.2s; 21 | } 22 | 23 | .measurement-layer:hover .button-bar, 24 | .measurement-layer .button-bar.pressed, 25 | .measurement-layer .button-bar:focus-within { 26 | opacity: 1; 27 | } 28 | 29 | .measurement-layer .button-bar button { 30 | background-color: rgba(0, 0, 0, 0.8); 31 | border-radius: 0; 32 | margin: 0; 33 | padding: 3px; 34 | border: none; 35 | cursor: pointer; 36 | outline: none; 37 | } 38 | 39 | .measurement-layer .button-bar button:hover, 40 | .measurement-layer .button-bar button:focus { 41 | background-color: rgba(70, 60, 50, 0.9); 42 | } 43 | 44 | .measurement-layer .button-bar button.pressed { 45 | background-color: rgba(130, 120, 110, 0.9); 46 | } 47 | 48 | .measurement-layer .circle-icon { 49 | stroke: white; 50 | fill: none; 51 | stroke-width: 2; 52 | } 53 | 54 | .measurement-layer .ruler-icon, 55 | .measurement-layer .text-icon { 56 | stroke: none; 57 | fill: white; 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/components/MeasurementLayer.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import MeasurementLayerBase from "./core/MeasurementLayerBase"; 3 | import MeasurementButtons from "./buttons/MeasurementButtons"; 4 | import "./MeasurementLayer.css"; 5 | 6 | export default class MeasurementLayer extends PureComponent { 7 | state = { mode: null }; 8 | 9 | render() { 10 | const hasSize = this.props.widthInPx > 0 && this.props.heightInPx > 0; 11 | return ( 12 | hasSize && ( 13 |
(this.root = e)}> 14 | 24 | 28 |
29 | ) 30 | ); 31 | } 32 | 33 | toggleMode = mode => 34 | this.setState({ mode: mode === this.state.mode ? null : mode }); 35 | 36 | onCommit = measurement => { 37 | this.setState({ mode: null }); 38 | if (this.props.onCommit) { 39 | this.props.onCommit(measurement); 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/components/buttons/Icons.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const RulerIcon = () => { 4 | const path = 5 | "M 1 2 L 1 12 L 1 13 L 14 13 L 14 12 L 14 2 L 1 2 z M 2 3 L 3 3 L 3 5 L 2 5 L 2 3 z M 4 3 L 5 3 L 5 8 L 4 8 L 4 3 z M 6 3 " + 6 | "L 7 3 L 7 5 L 6 5 L 6 3 z M 8 3 L 9 3 L 9 5 L 8 5 L 8 3 z M 10 3 L 11 3 L 11 8 L 10 8 L 10 3 z M 12 3 L 13 3 L 13 5 L 12 5 L 12 3 z "; 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export const CircleIcon = () => ( 15 | 16 | 17 | 18 | ); 19 | 20 | export const TextIcon = () => { 21 | const path = 22 | "M 5.6367188 1 L 1 14 L 4.2617188 14 L 5.1308594 11.371094 L 9.7851562 11.371094 L 10.652344 14 L 14 14 " + 23 | "L 9.3632812 1 L 5.6367188 1 z M 7.4570312 4.3261719 L 9 8.9882812 L 5.9160156 8.9882812 L 7.4570312 4.3261719 z "; 24 | return ( 25 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/components/buttons/MeasurementButtons.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { RulerIcon, CircleIcon, TextIcon } from "./Icons"; 3 | 4 | export default class MeasurementButtons extends PureComponent { 5 | componentDidMount() { 6 | this.root.addEventListener("mousedown", this.onRootMouseDown); 7 | } 8 | 9 | componentWillUnmount() { 10 | this.root.addEventListener("mousedown", this.onRootMouseDown); 11 | } 12 | 13 | render() { 14 | const rootClass = "button-bar" + (this.props.mode ? " pressed" : ""); 15 | const lineClass = 16 | "line-button" + (this.props.mode === "line" ? " pressed" : ""); 17 | const circleClass = 18 | "circle-button" + (this.props.mode === "circle" ? " pressed" : ""); 19 | const textClass = 20 | "text-button" + (this.props.mode === "text" ? " pressed" : ""); 21 | 22 | return ( 23 |
(this.root = e)}> 24 | 31 | 38 | 45 |
46 | ); 47 | } 48 | 49 | onRootMouseDown = event => { 50 | event.stopPropagation(); 51 | event.preventDefault(); 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/components/core/CircleMeasurement.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import TextAnchor from "./TextAnchor"; 3 | 4 | export const minRadiusInPx = 3; 5 | const textOffset = 16; 6 | 7 | export default class CircleMeasurement extends PureComponent { 8 | componentDidMount() { 9 | this.fill.addEventListener("mousedown", this.onFillMouseDown); 10 | this.stroke.addEventListener("mousedown", this.onStrokeMouseDown); 11 | document.addEventListener("mousemove", this.onMouseMove); 12 | window.addEventListener("mouseup", this.onMouseUp); 13 | window.addEventListener("blur", this.endDrag); 14 | } 15 | 16 | componentWillUnmount() { 17 | this.fill.removeEventListener("mousedown", this.onFillMouseDown); 18 | this.stroke.removeEventListener("mousedown", this.onStrokeMouseDown); 19 | document.removeEventListener("mousemove", this.onMouseMove); 20 | document.removeEventListener("touchmove", this.onTouchMove); 21 | window.removeEventListener("mouseup", this.onMouseUp); 22 | window.removeEventListener("blur", this.endDrag); 23 | } 24 | 25 | render() { 26 | const centerX = this.props.circle.centerX * this.props.parentWidth; 27 | const centerY = this.props.circle.centerY * this.props.parentHeight; 28 | const radius = 29 | this.props.circle.radius * 30 | Math.sqrt(this.props.parentWidth * this.props.parentHeight); 31 | const textY = centerY + radius + textOffset; 32 | const text = this.props.measureCircle(this.props.circle); 33 | 34 | return ( 35 |
(this.root = e)}> 36 | 41 |
(this.text = e)}> 42 | {text} 43 |
44 |
45 | 46 | 47 | (this.fill = e)} 53 | /> 54 | (this.stroke = e)} 60 | /> 61 | (this.circle = e)} 67 | /> 68 | 69 | 70 |
71 | ); 72 | } 73 | 74 | onStrokeMouseDown = event => { 75 | if (event.button === 0) { 76 | this.strokeDragInProgress = true; 77 | event.preventDefault(); 78 | this.onDragBegin(event.clientX, event.clientY); 79 | } 80 | }; 81 | 82 | onFillMouseDown = event => { 83 | if (event.button === 0) { 84 | this.fillDragInProgress = true; 85 | event.preventDefault(); 86 | this.onDragBegin(event.clientX, event.clientY); 87 | } 88 | }; 89 | 90 | onDragBegin = (eventX, eventY) => { 91 | this.mouseXAtPress = eventX; 92 | this.mouseYAtPress = eventY; 93 | this.circleAtPress = this.props.circle; 94 | this.centerXAtPress = this.props.circle.centerX * this.props.parentWidth; 95 | this.centerYAtPress = this.props.circle.centerY * this.props.parentHeight; 96 | 97 | const rect = this.root.getBoundingClientRect(); 98 | const centerClientX = this.centerXAtPress + rect.left; 99 | const centerClientY = this.centerYAtPress + rect.top; 100 | const radiusAtPress = 101 | this.props.circle.radius * 102 | Math.sqrt(this.props.parentWidth * this.props.parentHeight); 103 | const theta = Math.atan2( 104 | this.mouseYAtPress - centerClientY, 105 | this.mouseXAtPress - centerClientX 106 | ); 107 | this.pointXAtPress = radiusAtPress * Math.cos(theta); 108 | this.pointYAtPress = radiusAtPress * Math.sin(theta); 109 | }; 110 | 111 | onMouseMove = event => this.onDrag(event.clientX, event.clientY); 112 | 113 | onDrag = (eventX, eventY) => { 114 | if ( 115 | (this.fillDragInProgress || this.strokeDragInProgress) && 116 | !this.dragOccurred 117 | ) { 118 | this.dragOccurred = true; 119 | this.toggleDragStyles(); 120 | } 121 | 122 | if (this.strokeDragInProgress) { 123 | const newPointX = this.pointXAtPress + eventX - this.mouseXAtPress; 124 | const newPointY = this.pointYAtPress + eventY - this.mouseYAtPress; 125 | const radiusInPixels = Math.max( 126 | Math.hypot(newPointX, newPointY), 127 | minRadiusInPx 128 | ); 129 | let radius = 130 | radiusInPixels / 131 | Math.sqrt(this.props.parentWidth * this.props.parentHeight); 132 | 133 | if (this.props.circle.centerX + radius > 1) { 134 | radius = 1 - this.props.circle.centerX; 135 | } 136 | if (this.props.circle.centerX - radius < 0) { 137 | radius = this.props.circle.centerX; 138 | } 139 | if (this.props.circle.centerY + radius > 1) { 140 | radius = 1 - this.props.circle.centerY; 141 | } 142 | if (this.props.circle.centerY - radius < 0) { 143 | radius = this.props.circle.centerY; 144 | } 145 | this.props.onChange({ ...this.props.circle, radius }); 146 | } else if (this.fillDragInProgress) { 147 | let centerX = 148 | (this.centerXAtPress + eventX - this.mouseXAtPress) / 149 | this.props.parentWidth; 150 | let centerY = 151 | (this.centerYAtPress + eventY - this.mouseYAtPress) / 152 | this.props.parentHeight; 153 | 154 | if (centerX + this.props.circle.radius > 1) { 155 | centerX = 1 - this.props.circle.radius; 156 | } else if (centerX - this.props.circle.radius < 0) { 157 | centerX = this.props.circle.radius; 158 | } 159 | if (centerY + this.props.circle.radius > 1) { 160 | centerY = 1 - this.props.circle.radius; 161 | } else if (centerY - this.props.circle.radius < 0) { 162 | centerY = this.props.circle.radius; 163 | } 164 | this.props.onChange({ ...this.props.circle, centerX, centerY }); 165 | } 166 | }; 167 | 168 | onMouseUp = event => this.endDrag(); 169 | 170 | endDrag = () => { 171 | if (this.dragOccurred) { 172 | this.toggleDragStyles(); 173 | this.dragOccurred = false; 174 | } 175 | const anyDragAttempted = 176 | this.strokeDragInProgress || this.fillDragInProgress; 177 | if (this.strokeDragInProgress) { 178 | this.strokeDragInProgress = false; 179 | } 180 | if (this.fillDragInProgress) { 181 | this.fillDragInProgress = false; 182 | } 183 | if (anyDragAttempted && this.didValuesChange()) { 184 | this.props.onCommit(this.props.circle); 185 | } 186 | }; 187 | 188 | didValuesChange = () => 189 | this.props.circle.centerX !== this.circleAtPress.centerX || 190 | this.props.circle.centerY !== this.circleAtPress.centerY || 191 | this.props.circle.radius !== this.circleAtPress.radius; 192 | 193 | getAnnotationLayerClassList = () => this.root.parentElement.classList; 194 | 195 | toggleDragStyles = () => { 196 | if (this.strokeDragInProgress) { 197 | this.circle.classList.toggle("dragged"); 198 | this.stroke.classList.toggle("dragged"); 199 | this.getAnnotationLayerClassList().toggle("circle-stroke-dragged"); 200 | } 201 | if (this.fillDragInProgress) { 202 | this.circle.classList.toggle("dragged"); 203 | this.fill.classList.toggle("dragged"); 204 | this.getAnnotationLayerClassList().toggle("circle-fill-dragged"); 205 | } 206 | this.getAnnotationLayerClassList().toggle("any-dragged"); 207 | }; 208 | 209 | onDeleteButtonClick = () => this.props.onDeleteButtonClick(this.props.circle); 210 | } 211 | -------------------------------------------------------------------------------- /src/lib/components/core/LineMeasurement.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import TextAnchor from "./TextAnchor"; 3 | 4 | const edgeLength = 15; 5 | const textOffset = 16; 6 | const quarterCircle = Math.PI / 2; 7 | 8 | export default class LineMeasurement extends PureComponent { 9 | state = { midHover: false }; 10 | 11 | componentDidMount() { 12 | this.startGrabber.addEventListener("mousedown", this.onStartMouseDown); 13 | this.midGrabber.addEventListener("mousedown", this.onMidMouseDown); 14 | this.midGrabber.addEventListener("mouseenter", this.onMidMouseEnter); 15 | this.midGrabber.addEventListener("mouseleave", this.onMidMouseLeave); 16 | this.endGrabber.addEventListener("mousedown", this.onEndMouseDown); 17 | document.addEventListener("mousemove", this.onMouseMove); 18 | window.addEventListener("mouseup", this.onMouseUp); 19 | window.addEventListener("blur", this.endDrag); 20 | } 21 | 22 | componentWillUnmount() { 23 | this.startGrabber.removeEventListener("mousedown", this.onStartMouseDown); 24 | this.midGrabber.removeEventListener("mousedown", this.onMidMouseDown); 25 | this.midGrabber.removeEventListener("mouseenter", this.onMidMouseEnter); 26 | this.midGrabber.removeEventListener("mouseleave", this.onMidMouseLeave); 27 | this.endGrabber.removeEventListener("mousedown", this.onEndMouseDown); 28 | document.removeEventListener("mousemove", this.onMouseMove); 29 | window.removeEventListener("mouseup", this.onMouseUp); 30 | window.removeEventListener("blur", this.endDrag); 31 | } 32 | 33 | render() { 34 | // Line layout: 35 | const startX = this.props.line.startX * this.props.parentWidth; 36 | const startY = this.props.line.startY * this.props.parentHeight; 37 | const endX = this.props.line.endX * this.props.parentWidth; 38 | const endY = this.props.line.endY * this.props.parentHeight; 39 | const deltaX = endX - startX; 40 | const deltaY = endY - startY; 41 | const rotate = Math.atan2(deltaY, deltaX); 42 | const edgeX = edgeLength * Math.sin(rotate) / 2.0; 43 | const edgeY = edgeLength * Math.cos(rotate) / 2.0; 44 | 45 | // Text layout (make sure the text is never rotated so much to be upside down): 46 | const centerX = (startX + endX) / 2; 47 | const centerY = (startY + endY) / 2; 48 | const rotateIsSmall = Math.abs(rotate) <= quarterCircle; 49 | const offsetX = (rotateIsSmall ? -1 : 1) * textOffset * Math.sin(rotate); 50 | const offsetY = (rotateIsSmall ? 1 : -1) * textOffset * Math.cos(rotate); 51 | const textX = centerX + offsetX; 52 | const textY = centerY + offsetY; 53 | const textRotate = Math.atan2(offsetY, offsetX) - quarterCircle; 54 | 55 | const text = this.props.measureLine(this.props.line); 56 | const rootClassName = 57 | "line-measurement" + (this.state.midHover ? " mid-hover" : ""); 58 | 59 | return ( 60 |
(this.root = e)}> 61 | 62 | 63 | (this.midGrabber = e)} 70 | /> 71 | (this.midLine = e)} 78 | /> 79 | 80 | 81 | (this.startGrabber = e)} 88 | /> 89 | (this.startLine = e)} 96 | /> 97 | 98 | 99 | (this.endGrabber = e)} 106 | /> 107 | (this.endLine = e)} 114 | /> 115 | 116 | 117 | 123 |
(this.text = e)}> 124 | {text} 125 |
126 |
127 |
128 | ); 129 | } 130 | 131 | onStartMouseDown = event => { 132 | if (event.button === 0) { 133 | this.startDragInProgress = true; 134 | event.preventDefault(); 135 | this.onDragBegin(event.clientX, event.clientY); 136 | } 137 | }; 138 | 139 | onMidMouseDown = event => { 140 | if (event.button === 0) { 141 | this.midDragInProgress = true; 142 | event.preventDefault(); 143 | this.onDragBegin(event.clientX, event.clientY); 144 | } 145 | }; 146 | 147 | onEndMouseDown = event => { 148 | if (event.button === 0) { 149 | this.endDragInProgress = true; 150 | event.preventDefault(); 151 | this.onDragBegin(event.clientX, event.clientY); 152 | } 153 | }; 154 | 155 | onDragBegin = (eventX, eventY) => { 156 | this.mouseXAtPress = eventX; 157 | this.mouseYAtPress = eventY; 158 | this.lineAtPress = this.props.line; 159 | this.startXAtPress = this.props.line.startX * this.props.parentWidth; 160 | this.startYAtPress = this.props.line.startY * this.props.parentHeight; 161 | this.endXAtPress = this.props.line.endX * this.props.parentWidth; 162 | this.endYAtPress = this.props.line.endY * this.props.parentHeight; 163 | }; 164 | 165 | onMouseMove = event => this.onDrag(event.clientX, event.clientY); 166 | 167 | onDrag = (eventX, eventY) => { 168 | if ( 169 | (this.startDragInProgress || 170 | this.endDragInProgress || 171 | this.midDragInProgress) && 172 | !this.dragOccurred 173 | ) { 174 | this.dragOccurred = true; 175 | this.toggleDragStyles(); 176 | } 177 | 178 | if (this.startDragInProgress) { 179 | const startX = this.clamp(this.getXAfterDrag(this.startXAtPress, eventX)); 180 | const startY = this.clamp(this.getYAfterDrag(this.startYAtPress, eventY)); 181 | this.props.onChange({ ...this.props.line, startX, startY }); 182 | } else if (this.endDragInProgress) { 183 | const endX = this.clamp(this.getXAfterDrag(this.endXAtPress, eventX)); 184 | const endY = this.clamp(this.getYAfterDrag(this.endYAtPress, eventY)); 185 | this.props.onChange({ ...this.props.line, endX, endY }); 186 | } else if (this.midDragInProgress) { 187 | let startX = this.getXAfterDrag(this.startXAtPress, eventX); 188 | let startY = this.getYAfterDrag(this.startYAtPress, eventY); 189 | let endX = this.getXAfterDrag(this.endXAtPress, eventX); 190 | let endY = this.getYAfterDrag(this.endYAtPress, eventY); 191 | const deltaX = endX - startX; 192 | const deltaY = endY - startY; 193 | 194 | // Don't let the line be dragged outside the layer bounds: 195 | if (startX < 0) { 196 | startX = 0; 197 | endX = deltaX; 198 | } else if (startX > 1) { 199 | startX = 1; 200 | endX = 1 + deltaX; 201 | } 202 | if (startY < 0) { 203 | startY = 0; 204 | endY = deltaY; 205 | } else if (startY > 1) { 206 | startY = 1; 207 | endY = 1 + deltaY; 208 | } 209 | if (endX < 0) { 210 | startX = -deltaX; 211 | endX = 0; 212 | } else if (endX > 1) { 213 | startX = 1 - deltaX; 214 | endX = 1; 215 | } 216 | if (endY < 0) { 217 | startY = -deltaY; 218 | endY = 0; 219 | } else if (endY > 1) { 220 | startY = 1 - deltaY; 221 | endY = 1; 222 | } 223 | this.props.onChange({ ...this.props.line, startX, startY, endX, endY }); 224 | } 225 | }; 226 | 227 | getXAfterDrag = (xAtPress, clientX) => 228 | (xAtPress + clientX - this.mouseXAtPress) / this.props.parentWidth; 229 | 230 | getYAfterDrag = (yAtPress, clientY) => 231 | (yAtPress + clientY - this.mouseYAtPress) / this.props.parentHeight; 232 | 233 | onMouseUp = event => this.endDrag(); 234 | 235 | endDrag = () => { 236 | if (this.dragOccurred) { 237 | this.toggleDragStyles(); 238 | this.dragOccurred = false; 239 | } 240 | const anyDragAttempted = 241 | this.startDragInProgress || 242 | this.midDragInProgress || 243 | this.endDragInProgress; 244 | if (this.startDragInProgress) { 245 | this.startDragInProgress = false; 246 | } 247 | if (this.midDragInProgress) { 248 | this.midDragInProgress = false; 249 | } 250 | if (this.endDragInProgress) { 251 | this.endDragInProgress = false; 252 | } 253 | if (anyDragAttempted && this.didValuesChange()) { 254 | this.props.onCommit(this.props.line); 255 | } 256 | }; 257 | 258 | didValuesChange = () => 259 | this.props.line.startX !== this.lineAtPress.startX || 260 | this.props.line.startY !== this.lineAtPress.startY || 261 | this.props.line.endX !== this.lineAtPress.endX || 262 | this.props.line.endY !== this.lineAtPress.endY; 263 | 264 | onMidMouseEnter = event => this.setState({ ...this.state, midHover: true }); 265 | 266 | onMidMouseLeave = event => this.setState({ ...this.state, midHover: false }); 267 | 268 | getAnnotationLayerClassList = () => this.root.parentElement.classList; 269 | 270 | clamp = value => Math.min(1, Math.max(0, value)); 271 | 272 | toggleDragStyles = () => { 273 | if (this.startDragInProgress) { 274 | this.startLine.classList.toggle("dragged"); 275 | this.startGrabber.classList.toggle("dragged"); 276 | this.getAnnotationLayerClassList().toggle("line-start-dragged"); 277 | } 278 | if (this.midDragInProgress) { 279 | this.startLine.classList.toggle("dragged"); 280 | this.midLine.classList.toggle("dragged"); 281 | this.endLine.classList.toggle("dragged"); 282 | this.startGrabber.classList.toggle("dragged"); 283 | this.midGrabber.classList.toggle("dragged"); 284 | this.endGrabber.classList.toggle("dragged"); 285 | this.getAnnotationLayerClassList().toggle("line-mid-dragged"); 286 | } 287 | if (this.endDragInProgress) { 288 | this.endLine.classList.toggle("dragged"); 289 | this.endGrabber.classList.toggle("dragged"); 290 | this.getAnnotationLayerClassList().toggle("line-end-dragged"); 291 | } 292 | this.getAnnotationLayerClassList().toggle("any-dragged"); 293 | }; 294 | 295 | onDeleteButtonClick = () => this.props.onDeleteButtonClick(this.props.line); 296 | } 297 | -------------------------------------------------------------------------------- /src/lib/components/core/MeasurementLayerBase.css: -------------------------------------------------------------------------------- 1 | /*---------- General Layout ----------*/ 2 | 3 | .measurement-layer-base { 4 | position: absolute; 5 | width: 100%; 6 | height: 100%; 7 | top: 0px; 8 | left: 0px; 9 | outline: none; 10 | font-size: 10pt; 11 | } 12 | 13 | .line-measurement, 14 | .circle-measurement, 15 | .text-annotation, 16 | .measurement-svg { 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | } 21 | 22 | /*---------- Colors ----------*/ 23 | 24 | .line-measurement .line, 25 | .circle-measurement .circle, 26 | .text-annotation .arrow-line { 27 | stroke: yellow; 28 | } 29 | 30 | .text-annotation .arrow-head { 31 | fill: yellow; 32 | } 33 | 34 | .measurement-text, 35 | .text-annotation .text { 36 | color: yellow; 37 | background-color: rgba(0, 0, 0, 0.7); 38 | } 39 | 40 | .measurement-layer-base .text-anchor.button-showing .delete-button, 41 | .measurement-layer-base .text-annotation.editable .delete-button { 42 | background-color: rgba(50, 45, 40, 0.9); 43 | } 44 | 45 | .measurement-layer-base .text-anchor.button-showing .delete-button:hover, 46 | .measurement-layer-base .text-annotation.editable .delete-button:hover, 47 | .measurement-layer-base .text-anchor .delete-button:focus { 48 | background-color: rgba(70, 60, 50, 0.9); 49 | } 50 | 51 | .measurement-layer-base .text-anchor.button-showing .delete-button-icon, 52 | .measurement-layer-base .text-annotation.editable .delete-button-icon, 53 | .measurement-layer-base .text-anchor .delete-button:focus .delete-button-icon { 54 | stroke: yellow; 55 | } 56 | 57 | /*---------- General Measurement & Text Styling ----------*/ 58 | 59 | .line-measurement .line, 60 | .circle-measurement .circle, 61 | .text-annotation .arrow-line { 62 | stroke-width: 2px; 63 | stroke-linecap: butt; 64 | fill: none; 65 | } 66 | 67 | .text-annotation .arrow-head { 68 | stroke: none; 69 | } 70 | 71 | .measurement-text { 72 | position: relative; 73 | padding: 1px 4px; 74 | white-space: pre; 75 | cursor: default; 76 | /* Use color transitions rather than opacity, which blurs text for some reason. */ 77 | transition: color 0.3s, background-color 0.3s; 78 | } 79 | 80 | .measurement-layer-base.line-end-dragged 81 | .text-anchor.just-created 82 | .measurement-text, 83 | .measurement-layer-base.circle-stroke-dragged 84 | .text-anchor.just-created 85 | .measurement-text { 86 | color: transparent; 87 | background-color: transparent; 88 | } 89 | 90 | .text-annotation .text { 91 | position: relative; 92 | padding: 1px 4px; 93 | } 94 | 95 | .line-measurement .grabber-group:hover .line, 96 | .line-measurement.mid-hover .grabber-group .line, 97 | .line-measurement .line.dragged, 98 | .circle-measurement .grabber-group:hover .circle, 99 | .circle-measurement .circle.dragged, 100 | .text-annotation .arrow-line.hover, 101 | .text-annotation .arrow-line.dragged { 102 | stroke-width: 3px; 103 | } 104 | 105 | .text-annotation .public-DraftEditor-content { 106 | /* Ensures the blinking cursor is always visible when the text is editable but empty. */ 107 | min-width: 1px; 108 | } 109 | 110 | .text-annotation .public-DraftStyleDefault-block { 111 | white-space: pre; 112 | text-align: center; 113 | } 114 | 115 | .text-annotation.no-text .text-anchor { 116 | visibility: hidden; 117 | } 118 | 119 | .text-annotation.no-text.editable .text-anchor { 120 | visibility: visible; 121 | } 122 | 123 | /*---------- Grabbers ----------*/ 124 | 125 | .line-measurement .grabber, 126 | .text-annotation .arrow-line-grabber { 127 | stroke: transparent; 128 | stroke-width: 11px; 129 | stroke-linecap: butt; 130 | } 131 | 132 | .line-measurement .grabber.start-grabber, 133 | .line-measurement .grabber.end-grabber { 134 | stroke-linecap: square; 135 | } 136 | 137 | .circle-measurement .stroke-grabber { 138 | stroke: transparent; 139 | stroke-width: 11px; 140 | fill: none; 141 | } 142 | 143 | .circle-measurement .fill-grabber { 144 | fill: transparent; 145 | stroke: none; 146 | } 147 | 148 | .text-annotation .arrow-head-grabber { 149 | stroke: transparent; 150 | fill: transparent; 151 | } 152 | 153 | /*---------- Text Anchor & Delete Button ----------*/ 154 | 155 | .measurement-layer-base .text-anchor { 156 | /* Zero-size flexbox allows us to center text without using transforms, 157 | which can lead to sub-pixel positioning (blurry lines). */ 158 | position: absolute; 159 | display: flex; 160 | align-items: center; 161 | justify-content: center; 162 | width: 0px; 163 | height: 0px; 164 | } 165 | 166 | .measurement-layer-base .text-box { 167 | position: relative; 168 | display: flex; 169 | } 170 | 171 | .measurement-layer-base .delete-button { 172 | position: absolute; 173 | right: -18px; 174 | background-color: transparent; 175 | border-radius: 0; 176 | border-style: none; 177 | outline: none; 178 | width: 18px; 179 | height: 100%; 180 | top: 0; 181 | margin: 0; 182 | padding: 0; 183 | transition: background-color 0.2s, border-color 0.2s; 184 | } 185 | 186 | .measurement-layer-base .delete-button-svg { 187 | position: absolute; 188 | top: 50%; 189 | left: 50%; 190 | transform: translate(-50%, -50%); 191 | width: 15px; 192 | height: 15px; 193 | } 194 | 195 | .measurement-layer-base.any-dragged .delete-button-svg, 196 | .measurement-layer-base.any-mode-on .delete-button-svg { 197 | background-color: transparent; 198 | border-color: transparent; 199 | } 200 | 201 | .measurement-layer-base .delete-button-icon { 202 | stroke: transparent; 203 | stroke-width: 2px; 204 | stroke-linecap: square; 205 | fill: none; 206 | transition: stroke 0.2s; 207 | } 208 | 209 | .measurement-layer-base.any-dragged .delete-button-icon, 210 | .measurement-layer-base.any-mode-on .delete-button-icon { 211 | stroke: transparent; 212 | } 213 | 214 | /*---------- Cursors ----------*/ 215 | 216 | .line-measurement .mid-grabber, 217 | .circle-measurement .fill-grabber, 218 | .text-annotation .arrow-line-grabber, 219 | .measurement-layer-base.line-mid-dragged, 220 | .measurement-layer-base.circle-fill-dragged, 221 | .measurement-layer-base.arrow-line-dragged { 222 | cursor: move; 223 | } 224 | 225 | .line-measurement .start-grabber, 226 | .line-measurement .end-grabber, 227 | .circle-measurement .stroke-grabber, 228 | .text-annotation .text, 229 | .text-annotation .arrow-head-grabber, 230 | .measurement-layer-base.line-start-dragged, 231 | .measurement-layer-base.line-end-dragged, 232 | .measurement-layer-base.circle-stroke-dragged, 233 | .measurement-layer-base.arrow-head-dragged, 234 | .measurement-layer-base.text-dragged, 235 | .measurement-layer-base .delete-button { 236 | cursor: pointer; 237 | } 238 | 239 | .text-annotation.editable .text { 240 | cursor: text; 241 | } 242 | 243 | /*---------- Pointer Events & Drag ----------*/ 244 | 245 | .line-measurement, 246 | .line-measurement .line, 247 | .circle-measurement, 248 | .circle-measurement .circle, 249 | .text-annotation, 250 | .text-annotation .arrow-line, 251 | .measurement-svg { 252 | pointer-events: none; 253 | } 254 | 255 | .measurement-layer-base .grabber-group { 256 | pointer-events: painted; 257 | } 258 | 259 | .measurement-layer-base .text, 260 | .measurement-layer-base .arrow-head-grabber, 261 | .measurement-layer-base .arrow-line-grabber, 262 | .measurement-layer-base .text-box, 263 | .measurement-layer-base .text-anchor.button-showing .delete-button, 264 | .measurement-layer-base .editable .delete-button, 265 | .measurement-layer-base.any-dragged .grabber-group .start-grabber.dragged, 266 | .measurement-layer-base.any-dragged .grabber-group .mid-grabber.dragged, 267 | .measurement-layer-base.any-dragged .grabber-group .end-grabber.dragged, 268 | .measurement-layer-base.any-dragged .grabber-group .fill-grabber.dragged, 269 | .measurement-layer-base.any-dragged .grabber-group .stroke-grabber.dragged, 270 | .measurement-layer-base.any-dragged .arrow-head-grabber.dragged, 271 | .measurement-layer-base.any-dragged .arrow-line-grabber.dragged { 272 | pointer-events: auto; 273 | } 274 | 275 | .measurement-layer-base.any-dragged .grabber-group, 276 | .measurement-layer-base.any-dragged .text, 277 | .measurement-layer-base.any-dragged .arrow-head-grabber, 278 | .measurement-layer-base.any-dragged .arrow-line-grabber, 279 | .measurement-layer-base.any-dragged .text-box, 280 | .measurement-layer-base.any-dragged .delete-button, 281 | .measurement-layer-base.any-mode-on .grabber-group, 282 | .measurement-layer-base.any-mode-on .text, 283 | .measurement-layer-base.any-mode-on .arrow-head-grabber, 284 | .measurement-layer-base.any-mode-on .arrow-line-grabber, 285 | .measurement-layer-base.any-mode-on .text-box, 286 | .measurement-layer-base .delete-button { 287 | pointer-events: none; 288 | } 289 | -------------------------------------------------------------------------------- /src/lib/components/core/MeasurementLayerBase.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import LineMeasurement from "./LineMeasurement"; 3 | import CircleMeasurement, { minRadiusInPx } from "./CircleMeasurement"; 4 | import TextAnnotation from "./TextAnnotation"; 5 | import { EditorState } from "draft-js"; 6 | import "./MeasurementLayerBase.css"; 7 | 8 | export default class MeasurementLayerBase extends PureComponent { 9 | createdId = null; 10 | 11 | componentDidMount() { 12 | this.root.addEventListener("mousedown", this.onMouseDown); 13 | this.root.addEventListener("click", this.onClick); 14 | document.addEventListener("mousemove", this.onMouseMove); 15 | window.addEventListener("mouseup", this.onMouseUp); 16 | window.addEventListener("blur", this.endDrag); 17 | } 18 | 19 | componentWillUnmount() { 20 | this.root.removeEventListener("mousedown", this.onMouseDown); 21 | this.root.removeEventListener("click", this.onClick); 22 | document.removeEventListener("mousemove", this.onMouseMove); 23 | window.removeEventListener("mouseup", this.onMouseUp); 24 | window.removeEventListener("blur", this.endDrag); 25 | } 26 | 27 | render() { 28 | const className = 29 | "measurement-layer-base" + (this.props.mode ? " any-mode-on" : ""); 30 | return ( 31 |
(this.root = e)}> 32 | {this.props.measurements.map(this.createMeasurementComponent)} 33 |
34 | ); 35 | } 36 | 37 | createMeasurementComponent = measurement => { 38 | if (measurement.type === "line") { 39 | return ( 40 | 50 | ); 51 | } else if (measurement.type === "circle") { 52 | return ( 53 | 63 | ); 64 | } else if (measurement.type === "text") { 65 | return ( 66 | 75 | ); 76 | } else { 77 | return false; 78 | } 79 | }; 80 | 81 | onMouseDown = event => { 82 | this.finishAnyTextEdit(); 83 | if (event.button === 0) { 84 | if (this.props.mode === "line") { 85 | event.preventDefault(); 86 | this.lineCreationInProgress = true; 87 | this.mouseXAtPress = event.clientX; 88 | this.mouseYAtPress = event.clientY; 89 | } else if (this.props.mode === "circle") { 90 | event.preventDefault(); 91 | this.circleCreationInProgress = true; 92 | this.mouseXAtPress = event.clientX; 93 | this.mouseYAtPress = event.clientY; 94 | } 95 | } 96 | }; 97 | 98 | onMouseMove = event => { 99 | if (this.lineCreationInProgress) { 100 | const rect = this.root.getBoundingClientRect(); 101 | const endX = this.clamp( 102 | (event.clientX - rect.left) / this.props.widthInPx 103 | ); 104 | const endY = this.clamp( 105 | (event.clientY - rect.top) / this.props.heightInPx 106 | ); 107 | if (this.createdId === null) { 108 | this.createdId = this.getNextId(); 109 | const startX = this.clamp( 110 | (this.mouseXAtPress - rect.left) / this.props.widthInPx 111 | ); 112 | const startY = this.clamp( 113 | (this.mouseYAtPress - rect.top) / this.props.heightInPx 114 | ); 115 | const line = { 116 | id: this.createdId, 117 | type: "line", 118 | startX, 119 | startY, 120 | endX, 121 | endY 122 | }; 123 | this.root.classList.add("line-end-dragged"); 124 | this.props.onChange([...this.props.measurements, line]); 125 | } else { 126 | const line = this.props.measurements.filter( 127 | a => a.id === this.createdId 128 | )[0]; 129 | this.onChange({ ...line, endX, endY }); 130 | } 131 | } else if (this.circleCreationInProgress) { 132 | const rect = this.root.getBoundingClientRect(); 133 | const cursorX = event.clientX - rect.left; 134 | const cursorY = event.clientY - rect.top; 135 | if (this.createdId === null) { 136 | this.createdId = this.getNextId(); 137 | const centerX = this.clamp( 138 | (this.mouseXAtPress - rect.left) / this.props.widthInPx 139 | ); 140 | const centerY = this.clamp( 141 | (this.mouseYAtPress - rect.top) / this.props.heightInPx 142 | ); 143 | const radius = this.calculateRadius(cursorX, cursorY, centerX, centerY); 144 | const circle = { 145 | id: this.createdId, 146 | type: "circle", 147 | centerX, 148 | centerY, 149 | radius 150 | }; 151 | this.root.classList.add("circle-stroke-dragged"); 152 | this.props.onChange([...this.props.measurements, circle]); 153 | } else { 154 | const circle = this.props.measurements.filter( 155 | a => a.id === this.createdId 156 | )[0]; 157 | const radius = this.calculateRadius( 158 | cursorX, 159 | cursorY, 160 | circle.centerX, 161 | circle.centerY 162 | ); 163 | this.onChange({ ...circle, radius }); 164 | } 165 | } 166 | }; 167 | 168 | calculateRadius = (cursorX, cursorY, centerX, centerY) => { 169 | const deltaX = cursorX - centerX * this.props.widthInPx; 170 | const deltaY = cursorY - centerY * this.props.heightInPx; 171 | const radiusInPx = Math.max(Math.hypot(deltaX, deltaY), minRadiusInPx); 172 | let radius = 173 | radiusInPx / Math.sqrt(this.props.widthInPx * this.props.widthInPx); 174 | 175 | if (centerX + radius > 1) { 176 | radius = 1 - centerX; 177 | } 178 | if (centerX - radius < 0) { 179 | radius = centerX; 180 | } 181 | if (centerY + radius > 1) { 182 | radius = 1 - centerY; 183 | } 184 | if (centerY - radius < 0) { 185 | radius = centerY; 186 | } 187 | return radius; 188 | }; 189 | 190 | onMouseUp = event => this.endDrag(); 191 | 192 | endDrag = () => { 193 | if (this.lineCreationInProgress) { 194 | this.lineCreationInProgress = false; 195 | if (this.createdId !== null) { 196 | this.root.classList.remove("line-end-dragged"); 197 | } 198 | } else if (this.circleCreationInProgress) { 199 | this.circleCreationInProgress = false; 200 | if (this.createdId !== null) { 201 | this.root.classList.remove("circle-stroke-dragged"); 202 | } 203 | } 204 | if (this.createdId !== null) { 205 | this.props.onCommit( 206 | this.props.measurements.filter(a => a.id === this.createdId)[0] 207 | ); 208 | this.createdId = null; 209 | } 210 | }; 211 | 212 | onClick = event => { 213 | if (this.props.mode === "text") { 214 | const id = this.getNextId(); 215 | const rect = this.root.getBoundingClientRect(); 216 | const arrowX = (event.clientX - rect.left) / this.props.widthInPx; 217 | const arrowY = (event.clientY - rect.top) / this.props.heightInPx; 218 | const xOffsetDirection = arrowX < 0.8 ? 1 : -1; 219 | const yOffsetDirection = arrowY < 0.8 ? 1 : -1; 220 | const textX = arrowX + xOffsetDirection * 0.05; 221 | const textY = arrowY + yOffsetDirection * 0.07; 222 | const text = { 223 | id, 224 | type: "text", 225 | arrowX, 226 | arrowY, 227 | textX, 228 | textY, 229 | editorState: null, 230 | editable: true 231 | }; 232 | this.props.onChange([...this.props.measurements, text]); 233 | this.props.onCommit(text); 234 | } 235 | }; 236 | 237 | getNextId = () => 238 | this.props.measurements.length > 0 239 | ? Math.max(...this.props.measurements.map(a => a.id)) + 1 240 | : 0; 241 | 242 | onChange = m => 243 | this.props.onChange( 244 | this.props.measurements.map(n => (m.id === n.id ? m : n)) 245 | ); 246 | 247 | delete = m => { 248 | this.props.onChange(this.props.measurements.filter(n => n.id !== m.id)); 249 | this.props.onCommit(m); 250 | }; 251 | 252 | clamp = value => Math.min(1, Math.max(0, value)); 253 | 254 | finishAnyTextEdit = () => { 255 | const editable = this.props.measurements.filter( 256 | m => m.type === "text" && m.editable 257 | )[0]; 258 | if (editable) { 259 | this.props.onChange( 260 | this.props.measurements.map(m => 261 | m === editable ? this.finishEdit(m) : m 262 | ) 263 | ); 264 | } 265 | }; 266 | 267 | finishEdit = text => ({ 268 | ...text, 269 | editorState: EditorState.moveFocusToEnd( 270 | EditorState.moveSelectionToEnd(text.editorState) 271 | ), 272 | editable: false 273 | }); 274 | } 275 | -------------------------------------------------------------------------------- /src/lib/components/core/TextAnchor.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | 3 | export default class TextAnchor extends PureComponent { 4 | state = { buttonShowing: false, justCreated: true }; 5 | 6 | componentDidMount() { 7 | this.mounted = true; 8 | this.textBox.addEventListener("click", this.onClick); 9 | document.addEventListener("mousedown", this.onDocumentMouseDown); 10 | document.addEventListener("keydown", this.onDocumentKeyDown); 11 | 12 | setTimeout(() => { 13 | if (this.mounted) { 14 | this.setState({ ...this.state, justCreated: false }); 15 | } 16 | }, 200); 17 | } 18 | 19 | componentWillUnmount() { 20 | this.mounted = false; 21 | this.textBox.removeEventListener("click", this.onClick); 22 | document.removeEventListener("mousedown", this.onDocumentMouseDown); 23 | document.removeEventListener("keydown", this.onDocumentKeyDown); 24 | } 25 | 26 | render() { 27 | const textAnchorStyle = { 28 | left: this.props.x + "px", 29 | top: this.props.y + "px" 30 | }; 31 | if (this.props.rotate) { 32 | textAnchorStyle.transform = "rotate(" + this.props.rotate + "rad)"; 33 | } 34 | 35 | const className = 36 | "text-anchor" + 37 | (this.state.buttonShowing ? " button-showing" : "") + 38 | (this.state.justCreated ? " just-created" : ""); 39 | 40 | return ( 41 |
42 |
(this.textBox = e)}> 43 | {this.props.children} 44 | 59 |
60 |
61 | ); 62 | } 63 | 64 | onClick = () => this.setState({ ...this.state, buttonShowing: true }); 65 | 66 | onDocumentMouseDown = e => { 67 | if (!this.textBox.contains(e.target)) { 68 | this.setState({ ...this.state, buttonShowing: false }); 69 | } 70 | }; 71 | 72 | onDocumentKeyDown = e => { 73 | if (e.key === "Escape" || e.keyCode === 27) { 74 | this.setState({ ...this.state, buttonShowing: false }); 75 | } 76 | }; 77 | 78 | onDeleteButtonClick = event => { 79 | if (event.button === 0) { 80 | event.preventDefault(); 81 | event.stopPropagation(); 82 | this.props.onDeleteButtonClick(); 83 | } 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/components/core/TextAnnotation.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { Editor, EditorState } from "draft-js"; 3 | import TextAnchor from "./TextAnchor"; 4 | 5 | const headWidth = 8; 6 | const headHeight = 5; 7 | const headHoverWidth = 10; 8 | const headHoverHeight = 6; 9 | const headHoverOffset = 1.5; 10 | const headGrabberWidth = 15; 11 | const headGrabberHeight = 9; 12 | const headGrabberOffset = 3; 13 | 14 | export default class TextAnnotation extends PureComponent { 15 | propagateTextChanges = false; 16 | state = { 17 | lineHover: false, 18 | headHover: false, 19 | lineDragged: false, 20 | headDragged: false, 21 | textDragged: false 22 | }; 23 | 24 | componentDidMount() { 25 | this.text.addEventListener("mousedown", this.onTextMouseDown); 26 | this.lineGrabber.addEventListener("mousedown", this.onLineMouseDown); 27 | this.lineGrabber.addEventListener("mouseenter", this.onLineMouseEnter); 28 | this.lineGrabber.addEventListener("mouseleave", this.onLineMouseLeave); 29 | this.headGrabber.addEventListener("mousedown", this.onHeadMouseDown); 30 | this.headGrabber.addEventListener("mouseenter", this.onHeadMouseEnter); 31 | this.headGrabber.addEventListener("mouseleave", this.onHeadMouseLeave); 32 | this.root.addEventListener("dblclick", this.onDoubleClick); 33 | document.addEventListener("mousemove", this.onMouseMove); 34 | document.addEventListener("keydown", this.onDocumentKeyDown, true); 35 | window.addEventListener("mouseup", this.onMouseUp); 36 | window.addEventListener("blur", this.endDrag); 37 | this.updateMask(); 38 | 39 | if (this.props.text.editable) { 40 | this.propagateTextChanges = true; 41 | this.editor.focus(); 42 | } 43 | } 44 | 45 | componentWillUnmount() { 46 | this.text.removeEventListener("mousedown", this.onTextMouseDown); 47 | this.lineGrabber.removeEventListener("mousedown", this.onLineMouseDown); 48 | this.lineGrabber.removeEventListener("mouseenter", this.onLineMouseEnter); 49 | this.lineGrabber.removeEventListener("mouseleave", this.onLineMouseLeave); 50 | this.headGrabber.removeEventListener("mousedown", this.onHeadMouseDown); 51 | this.headGrabber.removeEventListener("mouseenter", this.onHeadMouseEnter); 52 | this.headGrabber.removeEventListener("mouseleave", this.onHeadMouseLeave); 53 | this.root.removeEventListener("dblclick", this.onDoubleClick); 54 | this.root.removeEventListener("touchstart", this.onRootTouchStart); 55 | document.removeEventListener("mousemove", this.onMouseMove); 56 | document.removeEventListener("keydown", this.onDocumentKeyDown, true); 57 | window.removeEventListener("mouseup", this.onMouseUp); 58 | window.removeEventListener("blur", this.endDrag); 59 | } 60 | 61 | componentDidUpdate() { 62 | this.updateMask(); 63 | if (!this.props.text.editable) { 64 | this.propagateTextChanges = false; 65 | } 66 | } 67 | 68 | render() { 69 | const pointX = this.props.text.arrowX * this.props.parentWidth; 70 | const pointY = this.props.text.arrowY * this.props.parentHeight; 71 | const textX = this.props.text.textX * this.props.parentWidth; 72 | const textY = this.props.text.textY * this.props.parentHeight; 73 | const rotate = Math.atan2(pointX - textX, textY - pointY) - Math.PI / 2; 74 | const cos = Math.cos(rotate); 75 | const sin = Math.sin(rotate); 76 | 77 | const lineEndX = pointX - (headWidth - 1) * cos; 78 | const lineEndY = pointY - (headWidth - 1) * sin; 79 | const lineClass = 80 | "arrow-line" + 81 | (this.state.lineHover ? " hover" : "") + 82 | (this.state.lineDragged ? " dragged" : ""); 83 | // Extra 'M -1 -1' is a workaround for a chrome bug where the line dissapears if straight, even if outside the mask's clip area: 84 | const linePath = `M -1 -1 M ${textX} ${textY} L ${lineEndX} ${lineEndY}`; 85 | 86 | const showLargerHead = 87 | this.state.lineHover || 88 | this.state.headHover || 89 | this.state.lineDragged || 90 | this.state.headDragged; 91 | const headGrabber = this.drawHead( 92 | pointX, 93 | pointY, 94 | headGrabberWidth, 95 | headGrabberHeight, 96 | rotate, 97 | headGrabberOffset, 98 | cos, 99 | sin 100 | ); 101 | const head = showLargerHead 102 | ? this.drawHead( 103 | pointX, 104 | pointY, 105 | headHoverWidth, 106 | headHoverHeight, 107 | rotate, 108 | headHoverOffset, 109 | cos, 110 | sin 111 | ) 112 | : this.drawHead(pointX, pointY, headWidth, headHeight, rotate, 0, 0, 0); 113 | 114 | const editorState = this.props.text.editorState; 115 | const hasText = 116 | editorState != null && 117 | editorState.getCurrentContent() != null && 118 | editorState.getCurrentContent().hasText(); 119 | const textVisible = hasText || this.props.text.editable; 120 | const rootClass = 121 | "text-annotation" + 122 | (!hasText ? " no-text" : "") + 123 | (this.props.text.editable ? " editable" : ""); 124 | 125 | const lineMaskId = `lineMask${this.props.text.id}`; 126 | const lineMask = textVisible ? "url(#" + lineMaskId + ")" : ""; 127 | 128 | const lineGrabberClass = 129 | "arrow-line-grabber" + (this.state.lineDragged ? " dragged" : ""); 130 | const headGrabberClass = 131 | "arrow-head-grabber" + (this.state.headDragged ? " dragged" : ""); 132 | 133 | return ( 134 |
(this.root = e)}> 135 | 136 | 137 | 138 | 139 | (this.maskRect = e)} /> 140 | 141 | 142 | (this.lineGrabber = e)} 149 | /> 150 | (this.headGrabber = e)} 155 | /> 156 | (this.head = e)} 161 | /> 162 | (this.line = e)} 166 | mask={lineMask} 167 | /> 168 | 169 | 174 |
(this.text = e)}> 175 | (this.editor = e)} 183 | /> 184 |
185 |
186 |
187 | ); 188 | } 189 | 190 | drawHead = (pointX, pointY, w, h, rotate, offset, cos, sin) => { 191 | const x = pointX + offset * cos; 192 | const y = pointY + offset * sin; 193 | const path = `M ${x - w} ${y - h} L ${x} ${y} L ${x - w} ${y + h} Z`; 194 | const rotateInDegrees = (rotate * 180) / Math.PI; 195 | const transform = `rotate(${rotateInDegrees} ${x} ${y})`; 196 | return { path, transform }; 197 | }; 198 | 199 | updateMask = () => { 200 | const rootBox = this.root.getBoundingClientRect(); 201 | const textBox = this.text.getBoundingClientRect(); 202 | 203 | this.maskRect.setAttribute("x", textBox.left - rootBox.left); 204 | this.maskRect.setAttribute("y", textBox.top - rootBox.top); 205 | this.maskRect.setAttribute("width", textBox.width); 206 | this.maskRect.setAttribute("height", textBox.height); 207 | }; 208 | 209 | onTextMouseDown = event => { 210 | if (this.props.text.editable) { 211 | event.stopPropagation(); 212 | } else if (event.button === 0) { 213 | this.textDragInProgress = true; 214 | event.preventDefault(); 215 | this.onDragBegin(event.clientX, event.clientY); 216 | } 217 | }; 218 | 219 | onLineMouseDown = event => { 220 | if (event.button === 0) { 221 | this.lineDragInProgress = true; 222 | event.preventDefault(); 223 | this.onDragBegin(event.clientX, event.clientY); 224 | if (this.props.text.editable) { 225 | event.stopPropagation(); 226 | } 227 | } 228 | }; 229 | 230 | onHeadMouseDown = event => { 231 | if (event.button === 0) { 232 | this.headDragInProgress = true; 233 | event.preventDefault(); 234 | this.onDragBegin(event.clientX, event.clientY); 235 | if (this.props.text.editable) { 236 | event.stopPropagation(); 237 | } 238 | } 239 | }; 240 | 241 | onDragBegin = (eventX, eventY) => { 242 | this.mouseXAtPress = eventX; 243 | this.mouseYAtPress = eventY; 244 | this.textAtPress = this.props.text; 245 | this.arrowXAtPress = this.props.text.arrowX * this.props.parentWidth; 246 | this.arrowYAtPress = this.props.text.arrowY * this.props.parentHeight; 247 | this.textXAtPress = this.props.text.textX * this.props.parentWidth; 248 | this.textYAtPress = this.props.text.textY * this.props.parentHeight; 249 | }; 250 | 251 | onMouseMove = event => this.onDrag(event.clientX, event.clientY); 252 | 253 | onDrag = (eventX, eventY) => { 254 | if ( 255 | (this.textDragInProgress || 256 | this.lineDragInProgress || 257 | this.headDragInProgress) && 258 | this.props.text.editable 259 | ) { 260 | this.finishEdit(); 261 | } 262 | 263 | if ( 264 | (this.textDragInProgress || 265 | this.lineDragInProgress || 266 | this.headDragInProgress) && 267 | !this.dragOccurred 268 | ) { 269 | this.dragOccurred = true; 270 | this.toggleDragStyles(); 271 | } 272 | 273 | if (this.headDragInProgress) { 274 | const arrowX = this.clamp(this.getXAfterDrag(this.arrowXAtPress, eventX)); 275 | const arrowY = this.clamp(this.getYAfterDrag(this.arrowYAtPress, eventY)); 276 | this.props.onChange({ ...this.props.text, arrowX, arrowY }); 277 | } else if (this.textDragInProgress) { 278 | const textX = this.clamp(this.getXAfterDrag(this.textXAtPress, eventX)); 279 | const textY = this.clamp(this.getYAfterDrag(this.textYAtPress, eventY)); 280 | this.props.onChange({ ...this.props.text, textX, textY }); 281 | } else if (this.lineDragInProgress) { 282 | let arrowX = this.getXAfterDrag(this.arrowXAtPress, eventX); 283 | let arrowY = this.getYAfterDrag(this.arrowYAtPress, eventY); 284 | let textX = this.getXAfterDrag(this.textXAtPress, eventX); 285 | let textY = this.getYAfterDrag(this.textYAtPress, eventY); 286 | const deltaX = textX - arrowX; 287 | const deltaY = textY - arrowY; 288 | 289 | if (arrowX < 0) { 290 | arrowX = 0; 291 | textX = deltaX; 292 | } else if (arrowX > 1) { 293 | arrowX = 1; 294 | textX = 1 + deltaX; 295 | } 296 | if (arrowY < 0) { 297 | arrowY = 0; 298 | textY = deltaY; 299 | } else if (arrowY > 1) { 300 | arrowY = 1; 301 | textY = 1 + deltaY; 302 | } 303 | if (textX < 0) { 304 | arrowX = -deltaX; 305 | textX = 0; 306 | } else if (textX > 1) { 307 | arrowX = 1 - deltaX; 308 | textX = 1; 309 | } 310 | if (textY < 0) { 311 | arrowY = -deltaY; 312 | textY = 0; 313 | } else if (textY > 1) { 314 | arrowY = 1 - deltaY; 315 | textY = 1; 316 | } 317 | this.props.onChange({ ...this.props.text, arrowX, arrowY, textX, textY }); 318 | } 319 | }; 320 | 321 | getXAfterDrag = (xAtPress, clientX) => 322 | (xAtPress + clientX - this.mouseXAtPress) / this.props.parentWidth; 323 | 324 | getYAfterDrag = (yAtPress, clientY) => 325 | (yAtPress + clientY - this.mouseYAtPress) / this.props.parentHeight; 326 | 327 | onMouseUp = event => this.endDrag(); 328 | 329 | endDrag = () => { 330 | if (this.dragOccurred) { 331 | this.toggleDragStyles(); 332 | this.dragOccurred = false; 333 | } 334 | 335 | const anyDragAttempted = 336 | this.textDragInProgress || 337 | this.lineDragInProgress || 338 | this.headDragInProgress; 339 | if (this.textDragInProgress) { 340 | this.textDragInProgress = false; 341 | } 342 | if (this.lineDragInProgress) { 343 | this.lineDragInProgress = false; 344 | } 345 | if (this.headDragInProgress) { 346 | this.headDragInProgress = false; 347 | } 348 | if (anyDragAttempted && this.didValuesChange()) { 349 | this.props.onCommit(this.props.text); 350 | } 351 | }; 352 | 353 | didValuesChange = () => 354 | this.props.text.arrowX !== this.textAtPress.arrowX || 355 | this.props.text.arrowY !== this.textAtPress.arrowY || 356 | this.props.text.textX !== this.textAtPress.textX || 357 | this.props.text.textY !== this.textAtPress.textY; 358 | 359 | toggleDragStyles = () => { 360 | this.getAnnotationLayerClassList().toggle("any-dragged"); 361 | if (this.textDragInProgress) { 362 | this.getAnnotationLayerClassList().toggle("text-dragged"); 363 | this.setState({ ...this.state, textDragged: !this.state.textDragged }); 364 | } else if (this.lineDragInProgress) { 365 | this.getAnnotationLayerClassList().toggle("arrow-line-dragged"); 366 | this.setState({ ...this.state, lineDragged: !this.state.lineDragged }); 367 | } else if (this.headDragInProgress) { 368 | this.getAnnotationLayerClassList().toggle("arrow-head-dragged"); 369 | this.setState({ ...this.state, headDragged: !this.state.headDragged }); 370 | } 371 | }; 372 | 373 | onLineMouseEnter = event => this.setState({ ...this.state, lineHover: true }); 374 | 375 | onLineMouseLeave = event => 376 | this.setState({ ...this.state, lineHover: false }); 377 | 378 | onHeadMouseEnter = event => this.setState({ ...this.state, headHover: true }); 379 | 380 | onHeadMouseLeave = event => 381 | this.setState({ ...this.state, headHover: false }); 382 | 383 | getAnnotationLayerClassList = () => this.root.parentElement.classList; 384 | 385 | clamp = value => Math.min(1, Math.max(0, value)); 386 | 387 | onDoubleClick = event => { 388 | if (event.button === 0) { 389 | event.preventDefault(); 390 | this.startEdit(); 391 | } 392 | }; 393 | 394 | onTextChange = editorState => { 395 | if (this.propagateTextChanges) { 396 | this.props.onChange({ ...this.props.text, editorState }); 397 | } 398 | }; 399 | 400 | startEdit = () => { 401 | if (!this.props.text.editable) { 402 | this.contentStateOnEditStart = this.props.text.editorState.getCurrentContent(); 403 | this.setEditState(true); 404 | } 405 | }; 406 | 407 | finishEdit = () => { 408 | if (this.props.text.editable) { 409 | this.setEditState(false); 410 | if ( 411 | this.contentStateOnEditStart !== 412 | this.props.text.editorState.getCurrentContent() 413 | ) { 414 | this.props.onCommit(this.props.text); 415 | } 416 | } 417 | }; 418 | 419 | setEditState = editable => { 420 | this.propagateTextChanges = editable; 421 | // Note: selection change is also important when we finish editing because it clears the selection. 422 | const editorState = EditorState.moveFocusToEnd( 423 | EditorState.moveSelectionToEnd(this.props.text.editorState) 424 | ); 425 | this.props.onChange({ ...this.props.text, editorState, editable }); 426 | }; 427 | 428 | onDocumentKeyDown = event => { 429 | if ( 430 | this.props.text.editable && 431 | (event.keyCode === 27 || (event.keyCode === 13 && !event.shiftKey)) 432 | ) { 433 | event.stopPropagation(); 434 | this.finishEdit(); 435 | } 436 | }; 437 | 438 | onDeleteButtonClick = () => this.props.onDeleteButtonClick(this.props.text); 439 | } 440 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import MeasurementLayer from "./components/MeasurementLayer"; 2 | import MeasurementLayerBase from "./components/core/MeasurementLayerBase"; 3 | import { calculateDistance, calculateArea } from "./utils/MeasurementUtils"; 4 | 5 | module.exports = { 6 | MeasurementLayer, 7 | MeasurementLayerBase, 8 | calculateDistance, 9 | calculateArea 10 | }; 11 | -------------------------------------------------------------------------------- /src/lib/utils/MeasurementUtils.js: -------------------------------------------------------------------------------- 1 | export const calculateDistance = (line, physicalWidth, physicalHeight) => { 2 | const deltaX = (line.endX - line.startX) * physicalWidth; 3 | const deltaY = (line.endY - line.startY) * physicalHeight; 4 | return Math.hypot(deltaX, deltaY); 5 | }; 6 | 7 | export const calculateArea = (circle, physicalWidth, physicalHeight) => { 8 | return ( 9 | Math.PI * circle.radius * circle.radius * physicalWidth * physicalHeight 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /webpack.config.demo.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | 5 | module.exports = { 6 | devtool: "source-map", 7 | entry: "./src/demo/index.js", 8 | output: { 9 | path: path.resolve(__dirname, "build/demo"), 10 | filename: "index.min.js" 11 | }, 12 | plugins: [ 13 | new HtmlWebpackPlugin({ 14 | filename: "index.html", 15 | inject: true, 16 | template: "./src/demo/index.html" 17 | }), 18 | new webpack.optimize.UglifyJsPlugin({ minimize: true }) 19 | ], 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.js$/, 24 | include: path.resolve(__dirname, "src"), 25 | exclude: /(node_modules|build)/, 26 | use: ["babel-loader"] 27 | }, 28 | { 29 | test: /\.css$/, 30 | include: path.resolve(__dirname, "src"), 31 | exclude: /(node_modules|build)/, 32 | use: ["style-loader", "css-loader"] 33 | }, 34 | { 35 | test: /\.(png|jpg|gif)$/, 36 | use: ["file-loader"] 37 | } 38 | ] 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | const path = require("path"); 3 | 4 | module.exports = { 5 | devtool: "eval", 6 | entry: "./src/demo/index.js", 7 | output: { 8 | path: path.resolve(__dirname, "build"), 9 | filename: "index.js" 10 | }, 11 | plugins: [ 12 | new HtmlWebpackPlugin({ 13 | filename: "index.html", 14 | inject: true, 15 | template: "./src/demo/index.html" 16 | }) 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.js$/, 22 | include: path.resolve(__dirname, "src"), 23 | exclude: /(node_modules|build)/, 24 | use: ["babel-loader"] 25 | }, 26 | { 27 | test: /\.css$/, 28 | include: path.resolve(__dirname, "src"), 29 | exclude: /(node_modules|build)/, 30 | use: ["style-loader", "css-loader"] 31 | }, 32 | { 33 | test: /\.(png|jpg|gif)$/, 34 | use: ["file-loader"] 35 | } 36 | ] 37 | }, 38 | devServer: { 39 | contentBase: "build", 40 | port: 3000 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /webpack.config.lib.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | devtool: "source-map", 5 | entry: "./src/lib/index.js", 6 | output: { 7 | path: path.resolve(__dirname, "build"), 8 | filename: "lib/index.js", 9 | libraryTarget: "commonjs2" 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | include: path.resolve(__dirname, "src"), 16 | exclude: /(node_modules|build)/, 17 | use: ["babel-loader"] 18 | }, 19 | { 20 | test: /\.css$/, 21 | include: path.resolve(__dirname, "src"), 22 | exclude: /(node_modules|build)/, 23 | use: ["style-loader", "css-loader"] 24 | } 25 | ] 26 | }, 27 | externals: { 28 | react: "commonjs react", 29 | "draft-js": "draft-js" 30 | } 31 | }; 32 | --------------------------------------------------------------------------------