├── .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 |
(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 |
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 |
14 | You need to enable JavaScript to view this page.
15 |
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 | this.props.onClick("line")}
28 | >
29 |
30 |
31 | this.props.onClick("circle")}
35 | >
36 |
37 |
38 | this.props.onClick("text")}
42 | >
43 |
44 |
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 |
(this.deleteButton = e)}
51 | >
52 |
53 |
57 |
58 |
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 |
--------------------------------------------------------------------------------