├── .DS_Store
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── demo.gif
├── demo
├── .DS_Store
├── public
│ └── 404.html
└── src
│ ├── App.js
│ ├── components
│ ├── Button
│ │ └── index.js
│ ├── Docs
│ │ └── index.js
│ ├── Footer
│ │ └── index.js
│ ├── GithubStarLink
│ │ └── index.js
│ ├── Highlight
│ │ └── index.js
│ ├── Home
│ │ ├── index.js
│ │ └── simple.txt
│ ├── NavBar
│ │ └── index.js
│ ├── Root
│ │ └── index.js
│ └── Samples
│ │ ├── Custom
│ │ └── index.js
│ │ ├── Linked
│ │ ├── index.js
│ │ └── index.txt
│ │ ├── Multiple
│ │ ├── index.js
│ │ └── index.txt
│ │ ├── Simple
│ │ └── index.js
│ │ ├── Threaded
│ │ └── index.js
│ │ └── Touch
│ │ ├── index.js
│ │ └── index.txt
│ ├── img.jpeg
│ ├── index.css
│ ├── index.html
│ ├── index.js
│ ├── mocks.js
│ └── registerServiceWorker.js
├── nwb.config.js
├── package.json
├── public
└── 404.html
├── src
├── components
│ ├── Annotation.js
│ ├── Content
│ │ └── index.js
│ ├── Editor
│ │ └── index.js
│ ├── FancyRectangle
│ │ └── index.js
│ ├── Oval
│ │ └── index.js
│ ├── Overlay
│ │ └── index.js
│ ├── Point
│ │ └── index.js
│ ├── Rectangle
│ │ └── index.js
│ ├── TextEditor
│ │ └── index.js
│ └── defaultProps.js
├── hocs
│ ├── OvalSelector.js
│ ├── PointSelector.js
│ └── RectangleSelector.js
├── index.js
├── selectors.js
├── types
│ └── index.d.ts
└── utils
│ ├── compose.js
│ ├── isMouseHovering.js
│ ├── offsetCoordinates.js
│ └── withRelativeMousePos.js
├── tests
├── .eslintrc
├── Annotation.spec.js
├── index.test.js
└── selectors
│ ├── OvalSelector.spec.js
│ ├── PointSelector.spec.js
│ └── RectangleSelector.spec.js
└── yarn.lock
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Secretmapper/react-image-annotation/3ddde7c7f52073c7f977485456e825b5592afb1d/.DS_Store
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /coverage
2 | /demo/dist
3 | /es
4 | /lib
5 | /node_modules
6 | /umd
7 | npm-debug.log*
8 | .vscode/
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 |
3 | language: node_js
4 | node_js:
5 | - 8
6 |
7 | before_install:
8 | - npm install codecov.io coveralls
9 |
10 | after_success:
11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js
12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
13 |
14 | branches:
15 | only:
16 | - master
17 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.9.8
2 |
3 | ### Improvements
4 |
5 | - Add Type Definitions for Typescript (#12) (thanks @danilofuchs)
6 | - Add support for `children` property (#13) (thanks @federico-bohn)
7 |
8 | ## 0.9.7
9 |
10 | ### Fixes
11 |
12 | - [Interaction] Fix bug where point annotation would fail abort (#8) (thanks @joshuadeguzman)
13 |
14 | ## 0.9.6
15 |
16 | ### Breaking change
17 |
18 | - [Interaction] Change annotation click action to click and drag (#6)
19 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | [Node.js](http://nodejs.org/) >= v4 must be installed.
4 |
5 | ## Installation
6 |
7 | - Running `npm install` in the component's root directory will install everything you need for development.
8 |
9 | ## Demo Development Server
10 |
11 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading.
12 |
13 | ## Running Tests
14 |
15 | - `npm test` will run the tests once.
16 |
17 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`.
18 |
19 | - `npm run test:watch` will run the tests on every change.
20 |
21 | ## Building
22 |
23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app.
24 |
25 | - `npm run clean` will delete built resources.
26 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018-present, Arian Allenson Valdez.
4 |
5 | Permission is hereby granted, free of charge, to any person
6 | obtaining a copy of this software and associated documentation
7 | files (the "Software"), to deal in the Software without
8 | restriction, including without limitation the rights to use,
9 | copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the
11 | Software is furnished to do so, subject to the following
12 | conditions:
13 |
14 | The above copyright notice and this permission notice shall be
15 | included in all copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24 | OTHER DEALINGS IN THE SOFTWARE.
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | React Image Annotation
2 | =========================
3 |
4 | An infinitely customizable image annotation library built on React
5 |
6 | 
7 |
8 | ## Installation
9 |
10 | ```
11 | npm install --save react-image-annotation
12 | # or
13 | yarn add react-image-annotation
14 | ```
15 |
16 | ## Usage
17 |
18 | ```js
19 | export default class Simple extends Component {
20 | state = {
21 | annotations: [],
22 | annotation: {}
23 | }
24 |
25 | onChange = (annotation) => {
26 | this.setState({ annotation })
27 | }
28 |
29 | onSubmit = (annotation) => {
30 | const { geometry, data } = annotation
31 |
32 | this.setState({
33 | annotation: {},
34 | annotations: this.state.annotations.concat({
35 | geometry,
36 | data: {
37 | ...data,
38 | id: Math.random()
39 | }
40 | })
41 | })
42 | }
43 |
44 | render () {
45 | return (
46 |
47 |
58 |
59 | )
60 | }
61 | }
62 | ```
63 |
64 |
65 | ### Props
66 |
67 | Prop | Description | Default
68 | ---- | ----------- | -------
69 | `src` | Image src attribute |
70 | `alt` | Image alt attribute |
71 | `annotations` | Array of annotations |
72 | `value` | Annotation object currently being created. See [annotation object](#annotation-object) |
73 | `onChange` | `onChange` handler for annotation object |
74 | `onSubmit` | `onSubmit` handler for annotation object |
75 | `type` | Selector type. See [custom shapes](#using-custom-shapes) | `RECTANGLE`
76 | `allowTouch` | Set to `true` to allow the target to handle touch events. This disables one-finger scrolling | `false`
77 | `selectors` | An array of selectors. See [adding custom selector logic](#adding-custom-selector-logic) | `[RectangleSelector, PointSelector, OvalSelector]`
78 | `activeAnnotations` | Array of annotations that will be passed as 'active' (active highlight and shows content) |
79 | `activeAnnotationComparator` | Method to compare annotation and `activeAnnotation` item (from `props.activeAnnotations`). Return `true` if it's the annotations are equal | `(a, b) => a === b`
80 | `disableAnnotation` | Set to `true` to disable creating of annotations (note that no callback methods will be called if this is `true`) | `false`
81 | `disableSelector` | Set to `true` to not render `Selector` | `false`
82 | `disableEditor` | Set to `true` to not render `Editor` | `false`
83 | `disableOverlay` | Set to `true` to not render `Overlay` | `false`
84 | `renderSelector` | Function that renders `Selector` Component | See [custom components](#using-custom-components)
85 | `renderEditor` | Function that renders `Editor` Component | See [custom components](#using-custom-components)
86 | `renderHighlight` | Function that renders `Highlight` Component | See [custom components](#using-custom-components)
87 | `renderContent` | Function that renders `Content` | See [custom components](#using-custom-components)
88 | `renderOverlay` | Function that renders `Overlay` | See [custom components](#using-custom-components)
89 | `onMouseUp` | `onMouseUp` handler on annotation target |
90 | `onMouseDown` | `onMouseDown` handler on annotation target |
91 | `onMouseMove` | `onMouseMove` handler on annotation target |
92 | `onClick` | `onClick` handler on annotation target |
93 |
94 | #### Annotation object
95 |
96 | An Annotation object is an object that conforms to the object shape
97 |
98 | ```js
99 | ({
100 | selection: T.object, // temporary object for selector logic
101 | geometry: T.shape({ // geometry data for annotation
102 | type: T.string.isRequired // type is used to resolve Highlighter/Selector renderer
103 | }),
104 | // auxiliary data object for application.
105 | // Content data can be stored here (text, image, primary key, etc.)
106 | data: T.object
107 | })
108 | ```
109 |
110 | ## Using custom components
111 |
112 | `Annotation` supports `renderProp`s for almost every internal component.
113 |
114 | This allows you to customize everything about the the look of the annotation interface, and you can even use canvas elements for performance or more complex interaction models.
115 |
116 | - `renderSelector` - used for selecting annotation area (during annotation creation)
117 | - `renderEditor` - appears after annotation area has been selected (during annotation creation)
118 | - `renderHighlight` - used to render current annotations in the annotation interface. It is passed an object that contains the property `active`, which is true if the mouse is hovering over the higlight
119 | - `renderComponent` - auxiliary component that appears when mouse is hovering over the highlight. It is passed an object that contains the annotation being hovered over. `{ annotation }`
120 | - `renderOverlay` - Component overlay for Annotation (i.e. 'Click and Drag to Annotate')
121 |
122 | You can view the default renderProps [here](src/components/defaultProps.js)
123 |
124 | **Note**: You cannot use `:hover` selectors in css for components returned by `renderSelector` and `renderHighlight`. This is due to the fact that `Annotation` places DOM layers on top of these components, preventing triggering of `:hover`
125 |
126 | ## Using custom shapes
127 |
128 | `Annotation` supports three shapes by default, `RECTANGLE`, `POINT` and `OVAL`.
129 |
130 | You can switch the shape selector by passing the appropriate `type` as a property. Default shape `TYPE`s are accessible on their appropriate selectors:
131 |
132 | ```js
133 | import {
134 | PointSelector,
135 | RectangleSelector,
136 | OvalSelector
137 | } from 'react-image-annotation/lib/selectors'
138 |
139 |
142 | ```
143 |
144 | ### Adding custom selector logic
145 |
146 | #### This is an Advanced Topic
147 |
148 | The Annotation API allows support for custom shapes that use custom logic such as polygon or freehand selection. This is done by defining your own selection logic and passing it as a selector in the `selectors` property.
149 |
150 | Selectors are objects that must have the following properties:
151 |
152 | - `TYPE` - string that uniquely identifies this selector (i.e. `RECTANGLE`)
153 | - `intersects` - method that returns true if the mouse point intersects with the annotation geometry
154 | - `area` - method that calculates and returns the area of the annotation geometry
155 | - `methods` - object that can contain various listener handlers (`onMouseUp`, `onMouseDown`, `onMouseMove`, `onClick`). These listener handlers are called when triggered in the annotation area. These handlers must be reducer-like methods - returning a new annotation object depending on the change of the method
156 |
157 | You can view a defined `RectangleSelector` [here](src/hocs/RectangleSelector.js)
158 |
159 | ### Connecting selector logic to Redux/MobX
160 |
161 | First see [Selectors](#adding-custom-selector-logic)
162 |
163 | You can use `Selector` methods to connect these method logic to your stores. This is due to the fact that selector methods function as reducers, returning new state depending on the event.
164 |
165 | ***Note that it is not necessary to connect the selector logic with redux/mobx. Connecting the annotation and annotations state is more than enough for most use cases.***
166 |
167 | ## License
168 |
169 | MIT
170 |
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Secretmapper/react-image-annotation/3ddde7c7f52073c7f977485456e825b5592afb1d/demo.gif
--------------------------------------------------------------------------------
/demo/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Secretmapper/react-image-annotation/3ddde7c7f52073c7f977485456e825b5592afb1d/demo/.DS_Store
--------------------------------------------------------------------------------
/demo/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/demo/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter as Router, Route } from 'react-router-dom'
3 | import styled from 'styled-components'
4 |
5 | import NavBar from './components/NavBar'
6 | import Root from './components/Root'
7 | import Home from './components/Home'
8 | import Docs from './components/Docs'
9 | import Footer from './components/Footer'
10 |
11 | const Main = styled.main`
12 | margin: 0 16px;
13 | margin-top: 51px;
14 | `
15 |
16 | export default () => (
17 |
18 |
19 |
22 |
23 |
28 |
32 |
33 |
34 |
35 |
36 | )
37 |
--------------------------------------------------------------------------------
/demo/src/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components'
2 | import { Link } from 'react-router-dom'
3 |
4 | const styles = css`
5 | background: #24B3C8;
6 | border: 0;
7 | color: white;
8 | cursor: pointer;
9 | font-family: Montserrat;
10 | font-size: 13px;
11 | font-weight: 700;
12 | outline: 0;
13 | margin: 4px;
14 | padding: 8px 16px;
15 | text-shadow: 0 1px 0 rgba(0,0,0,0.1);
16 | text-transform: uppercase;
17 |
18 | transition: background 0.21s ease-in-out;
19 |
20 | &:focus, &:hover {
21 | background: #176572;
22 | }
23 |
24 | ${props => props.active && `
25 | background: #176572;
26 | `}
27 | `
28 |
29 | export default styled.button`
30 | ${props => styles}
31 | `
32 |
33 | export const ButtonLink = styled(Link)`
34 | text-decoration: none;
35 | ${props => styles}
36 | `
37 |
--------------------------------------------------------------------------------
/demo/src/components/Docs/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import styled from 'styled-components'
3 | import Highlight from '../Highlight'
4 | import Multi from '../Samples/Multiple'
5 | import multiCode from '../Samples/Multiple/index.txt'
6 | import Linked from '../Samples/Linked'
7 | import linkedCode from '../Samples/Linked/index.txt'
8 | import Custom from '../Samples/Custom'
9 | import Threaded from '../Samples/Threaded'
10 | import Touch from '../Samples/Touch'
11 | import touchCode from '../Samples/Touch/index.txt'
12 |
13 | const Container = styled.main`
14 | margin: 0 auto;
15 | padding-top: 16px;
16 | padding-bottom: 64px;
17 | max-width: 700px;
18 | `
19 |
20 | const SourceLink = styled.a`
21 | display: block;
22 | margin-top: 8px;
23 | font-size: 18px;
24 | text-align: center;
25 | text-decoration: none;
26 | `
27 |
28 | export default class Docs extends Component {
29 | render () {
30 | return (
31 |
32 | Multiple Type/Shape Support
33 |
34 |
35 | {multiCode}
36 |
37 | Controlled Active Annotations
38 |
39 | Hover over the text items above and notice how it triggers the active status of their respective annotations
40 |
41 | {linkedCode}
42 |
43 | Custom Renderers/Components/Styles
44 |
45 |
46 | View source
47 |
48 | Threaded Comments (Custom Content Overlay)
49 |
50 |
51 | View source
52 |
53 | Touch support
54 |
55 |
56 | {touchCode}
57 |
58 |
59 | )
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/demo/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Footer = styled.div`
5 | color: #666;
6 | padding: 16px;
7 | padding-bottom: 32px;
8 | text-align: center;
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | &:hover {
13 | color: #222;
14 | }
15 | }
16 | `
17 |
18 | export default () => (
19 |
28 | )
29 |
--------------------------------------------------------------------------------
/demo/src/components/GithubStarLink/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default () => (
4 |
11 | Star
12 |
13 | )
14 |
--------------------------------------------------------------------------------
/demo/src/components/Highlight/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import SyntaxHighlighter from 'react-syntax-highlighter/prism-light'
3 | import prism from 'react-syntax-highlighter/styles/prism/prism'
4 |
5 | export default (props) => (
6 |
7 | {props.children}
8 |
9 | )
10 |
--------------------------------------------------------------------------------
/demo/src/components/Home/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import styled from 'styled-components'
3 | import Simple from '../Samples/Simple'
4 | import Highlight from '../Highlight'
5 | import GithubStarLink from '../GithubStarLink'
6 | import { ButtonLink } from '../Button'
7 |
8 | import simple from './simple.txt'
9 |
10 | const Hero = styled.div`
11 | text-align: center;
12 | `
13 |
14 | const Title = styled.h1`
15 | font-size: 36px;
16 | text-align: center;
17 | `
18 |
19 | const Subtitle = styled.p`
20 | font-size: 20px;
21 | text-align: center;
22 | `
23 |
24 | const Container = styled.main`
25 | margin: 0 auto;
26 | padding: 64px 0;
27 | max-width: 700px;
28 | `
29 |
30 | const GithubButton = styled.div`
31 | margin-bottom: 16px;
32 | `
33 |
34 | export default class App extends Component {
35 | render () {
36 | return (
37 |
38 |
39 | React Image Annotation
40 |
41 | An infinitely customizable image annotation library built on React
42 |
43 |
44 |
45 |
46 |
47 | More Examples
48 |
49 |
50 | Install
51 |
52 | npm install --save react-image-annotation
53 |
54 | Demo
55 |
56 |
57 | {simple}
58 |
59 |
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/demo/src/components/Home/simple.txt:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from 'react-image-annotation'
3 |
4 | export default class Simple extends Component {
5 | state = {
6 | annotations: [],
7 | annotation: {}
8 | }
9 |
10 | onChange = (annotation) => {
11 | this.setState({ annotation })
12 | }
13 |
14 | onSubmit = (annotation) => {
15 | const { geometry, data } = annotation
16 |
17 | this.setState({
18 | annotation: {},
19 | annotations: this.state.annotations.concat({
20 | geometry,
21 | data: {
22 | ...data,
23 | id: Math.random()
24 | }
25 | })
26 | })
27 | }
28 |
29 | render () {
30 | return (
31 |
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/demo/src/components/NavBar/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { Link } from 'react-router-dom'
4 |
5 | const Header = styled.header`
6 | background-color: #fcfcfc;
7 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.25);
8 | box-sizing: border-box;
9 | width: 100%;
10 |
11 | position: fixed;
12 | top: 0;
13 | left: 0;
14 | right: 0;
15 | z-index: 100;
16 | `
17 |
18 | const Items = styled.div`
19 | margin: 0 auto;
20 | max-width: 720px;
21 | display: table;
22 | `
23 |
24 | const Item = styled.div`
25 | display: table-cell;
26 | padding: 16px 0;
27 | ${props => props.grow && `
28 | width: 100%;
29 | `}
30 |
31 | a {
32 | color: black;
33 | text-decoration: none;
34 | padding: 16px;
35 |
36 | transition:
37 | background 0.1s ease,
38 | color 0.21s ease;
39 | &:hover {
40 | background: #dadada;
41 | color: white;
42 | }
43 | }
44 | `
45 |
46 | const Title = styled(Link)`
47 | margin-right: 16px;
48 | `
49 |
50 | export default (props) => (
51 |
52 |
53 | -
54 |
55 | {props.title}
56 |
57 | Docs
58 |
59 | -
60 |
61 | Github
62 |
63 |
64 |
65 |
66 | )
67 |
--------------------------------------------------------------------------------
/demo/src/components/Root/index.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export default styled.div`
4 | font-family: 'Open Sans', sans-serif;
5 | margin: 0 auto;
6 | font-size: 14px;
7 |
8 | h1, h2, h3, h4, h5, h6 {
9 | font-family: 'Montserrat', sans-serif;
10 | }
11 |
12 | input {
13 | font-family: 'Open Sans', sans-serif;
14 | }
15 | `
16 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Custom/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from '../../../../../src'
3 | import {
4 | PointSelector,
5 | RectangleSelector,
6 | OvalSelector
7 | } from '../../../../../src/selectors'
8 |
9 | import Button from '../../Button'
10 |
11 | import mocks from '../../../mocks'
12 | import img from '../../../img.jpeg'
13 |
14 | const Box = ({ children, geometry, style }) => (
15 |
25 | {children}
26 |
27 | )
28 |
29 | function renderSelector ({ annotation, active }) {
30 | const { geometry } = annotation
31 | if (!geometry) return null
32 |
33 | return (
34 |
41 | Custom Selector
42 |
43 | )
44 | }
45 |
46 | function renderHighlight ({ annotation, active }) {
47 | const { geometry } = annotation
48 | if (!geometry) return null
49 |
50 | return (
51 |
60 | Custom Highlight
61 |
62 | )
63 | }
64 |
65 | function renderContent ({ annotation }) {
66 | const { geometry } = annotation
67 | return (
68 |
80 |
Custom Content
81 | {annotation.data && annotation.data.text}
82 |
83 | )
84 | }
85 |
86 | function renderEditor (props) {
87 | const { geometry } = props.annotation
88 | if (!geometry) return null
89 |
90 | return (
91 |
100 |
Custom Editor
101 |
props.onChange({
103 | ...props.annotation,
104 | data: {
105 | ...props.annotation.data,
106 | text: e.target.value
107 | }
108 | })}
109 | />
110 |
Comment
111 |
112 | )
113 | }
114 |
115 | function renderOverlay () {
116 | return (
117 |
128 | Custom Overlay
129 |
130 | )
131 | }
132 |
133 | export default class Custom extends Component {
134 | state = {
135 | annotations: [mocks.annotations[0]],
136 | annotation: {}
137 | }
138 |
139 | onChange = (annotation) => {
140 | this.setState({ annotation })
141 | }
142 |
143 | onSubmit = (annotation) => {
144 | const { geometry, data } = annotation
145 |
146 | this.setState({
147 | annotation: {},
148 | annotations: this.state.annotations.concat({
149 | geometry,
150 | data: {
151 | ...data,
152 | id: Math.random()
153 | }
154 | })
155 | })
156 | }
157 |
158 | onChangeType = (e) => {
159 | this.setState({
160 | annotation: {},
161 | type: e.currentTarget.innerHTML
162 | })
163 | }
164 |
165 | render () {
166 | return (
167 |
185 | )
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Linked/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import styled from 'styled-components'
3 | import Annotation from '../../../../../src'
4 |
5 | import Root from '../../Root'
6 | import img from '../../../img.jpeg'
7 |
8 | const Comments = styled.div`
9 | border: 1px solid black;
10 | max-height: 80px;
11 | overflow: auto;
12 | `
13 |
14 | const Comment = styled.div`
15 | padding: 8px;
16 |
17 | &:nth-child(even) {
18 | background: rgba(0, 0, 0, .05);
19 | }
20 | &:hover {
21 | background: #ececec;
22 | }
23 | `
24 |
25 | export default class Linked extends Component {
26 | state = {
27 | activeAnnotations: [],
28 | annotations: [
29 | {
30 | data: {text: 'Hello!', id: 0.5986265691759928},
31 | geometry: {type: 'RECTANGLE', x: 25.571428571428573, y: 33, width: 21.142857142857142, height: 34}
32 | },
33 | {
34 | data: {text: 'Hi!', id: 0.5986265691759929},
35 | geometry: {type: 'RECTANGLE', x: 50.571428571428573, y: 33, width: 21.142857142857142, height: 34}
36 | }
37 | ],
38 | annotation: {}
39 | }
40 |
41 | onChange = (annotation) => {
42 | this.setState({ annotation })
43 | }
44 |
45 | onSubmit = (annotation) => {
46 | const { geometry, data } = annotation
47 |
48 | this.setState({
49 | annotation: {},
50 | annotations: this.state.annotations.concat({
51 | geometry,
52 | data: {
53 | ...data,
54 | id: Math.random()
55 | }
56 | })
57 | })
58 | }
59 |
60 | onMouseOver = (id) => e => {
61 | this.setState({
62 | activeAnnotations: [
63 | ...this.state.activeAnnotations,
64 | id
65 | ]
66 | })
67 | }
68 |
69 | onMouseOut = (id) => e => {
70 | const index = this.state.activeAnnotations.indexOf(id)
71 |
72 | this.setState({
73 | activeAnnotations: [
74 | ...this.state.activeAnnotations.slice(0, index),
75 | ...this.state.activeAnnotations.slice(index + 1)
76 | ]
77 | })
78 | }
79 |
80 | activeAnnotationComparator = (a, b) => {
81 | return a.data.id === b
82 | }
83 |
84 | render () {
85 | return (
86 |
87 |
100 | Annotations
101 |
102 | {this.state.annotations.map(annotation => (
103 |
108 | {annotation.data.text}
109 |
110 | ))}
111 |
112 |
113 | )
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Linked/index.txt:
--------------------------------------------------------------------------------
1 | classs React extends Component {
2 | state = {
3 | activeAnnotations: []
4 | }
5 |
6 | // ...other React code
7 |
8 | onMouseOver = (id) => e => {
9 | this.setState({
10 | activeAnnotations: [
11 | ...this.state.activeAnnotations,
12 | id
13 | ]
14 | })
15 | }
16 |
17 | onMouseOut = (id) => e => {
18 | const index = this.state.activeAnnotations.indexOf(id)
19 |
20 | this.setState({
21 | activeAnnotations: [
22 | ...this.state.activeAnnotations.slice(0, index),
23 | ...this.state.activeAnnotations.slice(index + 1)
24 | ]
25 | })
26 | }
27 |
28 | activeAnnotationComparator = (a, b) => {
29 | return a.data.id === b
30 | }
31 |
32 | render () {
33 | return (
34 |
35 |
40 | Annotations
41 |
42 | {this.state.annotations.map(annotation => (
43 |
48 | {annotation.data.text}
49 |
50 | ))}
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Multiple/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from '../../../../../src'
3 | import {
4 | PointSelector,
5 | RectangleSelector,
6 | OvalSelector
7 | } from '../../../../../src/selectors'
8 |
9 | import Button from '../../Button'
10 |
11 | import mocks from '../../../mocks'
12 | import img from '../../../img.jpeg'
13 |
14 | export default class Multiple extends Component {
15 | state = {
16 | type: RectangleSelector.TYPE,
17 | annotations: mocks.annotations,
18 | annotation: {}
19 | }
20 |
21 | onChange = (annotation) => {
22 | this.setState({ annotation })
23 | }
24 |
25 | onSubmit = (annotation) => {
26 | const { geometry, data } = annotation
27 |
28 | this.setState({
29 | annotation: {},
30 | annotations: this.state.annotations.concat({
31 | geometry,
32 | data: {
33 | ...data,
34 | id: Math.random()
35 | }
36 | })
37 | })
38 | }
39 |
40 | onChangeType = (e) => {
41 | this.setState({
42 | annotation: {},
43 | type: e.currentTarget.innerHTML
44 | })
45 | }
46 |
47 | render () {
48 | return (
49 |
50 |
54 | {RectangleSelector.TYPE}
55 |
56 |
60 | {PointSelector.TYPE}
61 |
62 |
66 | {OvalSelector.TYPE}
67 |
68 |
69 |
80 |
81 | )
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Multiple/index.txt:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from 'react-image-annotation'
3 | import {
4 | PointSelector,
5 | RectangleSelector,
6 | OvalSelector
7 | } from 'react-image-annotation/lib/selectors'
8 |
9 | export default class Multiple extends Component {
10 | state = {
11 | type: RectangleSelector.TYPE,
12 | annotations: mocks.annotations,
13 | annotation: {}
14 | }
15 |
16 | onChange = (annotation) => {
17 | this.setState({ annotation })
18 | }
19 |
20 | onSubmit = (annotation) => {
21 | const { geometry, data } = annotation
22 |
23 | this.setState({
24 | annotation: {},
25 | annotations: this.state.annotations.concat({
26 | geometry,
27 | data: {
28 | ...data,
29 | id: Math.random()
30 | }
31 | })
32 | })
33 | }
34 |
35 | onChangeType = (e) => {
36 | this.setState({
37 | annotation: {},
38 | type: e.currentTarget.innerHTML
39 | })
40 | }
41 |
42 | render () {
43 | return (
44 |
45 |
46 | {RectangleSelector.TYPE}
47 |
48 |
49 | {PointSelector.TYPE}
50 |
51 |
52 | {OvalSelector.TYPE}
53 |
54 |
55 |
66 |
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Simple/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from '../../../../../src'
3 |
4 | import Root from '../../Root'
5 | import img from '../../../img.jpeg'
6 |
7 | export default class Simple extends Component {
8 | state = {
9 | annotations: [],
10 | annotation: {}
11 | }
12 |
13 | onChange = (annotation) => {
14 | this.setState({ annotation })
15 | }
16 |
17 | onSubmit = (annotation) => {
18 | const { geometry, data } = annotation
19 |
20 | this.setState({
21 | annotation: {},
22 | annotations: this.state.annotations.concat({
23 | geometry,
24 | data: {
25 | ...data,
26 | id: Math.random()
27 | }
28 | })
29 | })
30 | }
31 |
32 | render () {
33 | return (
34 |
35 |
47 |
48 | )
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Threaded/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from '../../../../../src'
3 |
4 | import styled, { keyframes } from 'styled-components'
5 | import {
6 | RectangleSelector
7 | } from '../../../../../src/selectors'
8 | import TextEditor from '../../../../../src/components/TextEditor'
9 | import Root from '../../Root'
10 | import img from '../../../img.jpeg'
11 |
12 | /*
13 | * You would normally have the different components here
14 | * split into different files but I am
15 | * putting it in one file so it's easier to skim
16 | */
17 |
18 | const Content = styled.div`
19 | background: white;
20 | border-radius: 2px;
21 | box-shadow:
22 | 0px 1px 5px 0px rgba(0, 0, 0, 0.2),
23 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14),
24 | 0px 3px 1px -2px rgba(0, 0, 0, 0.12);
25 | margin: 8px 0;
26 | `
27 |
28 | const ContentClearanceTop = styled.div`
29 | position: absolute;
30 | height: 8px;
31 | top: -8px;
32 | left: -17px;
33 | right: -17px;
34 | `
35 |
36 | const ContentClearanceLeft = styled.div`
37 | position: absolute;
38 | height: 100%;
39 | left: -17px;
40 | width: 20px;
41 | `
42 |
43 | const ContentClearanceRight = styled.div`
44 | position: absolute;
45 | height: 100%;
46 | right: 0px;
47 | width: 20px;
48 | `
49 |
50 | const fadeInScale = keyframes`
51 | from {
52 | opacity: 0;
53 | transform: scale(0);
54 | }
55 |
56 | to {
57 | opacity: 1;
58 | transform: scale(1);
59 | }
60 | `
61 |
62 | const EditorContainer = styled.div`
63 | background: white;
64 | border-radius: 2px;
65 | box-shadow:
66 | 0px 1px 5px 0px rgba(0, 0, 0, 0.2),
67 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14),
68 | 0px 3px 1px -2px rgba(0, 0, 0, 0.12);
69 | margin-top: 16px;
70 | transform-origin: top left;
71 |
72 | animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275);
73 | overflow: hidden;
74 | `
75 |
76 | const Comment = styled.div`
77 | border-bottom: 1px solid whitesmoke;
78 | padding: 8px 16px;
79 | `
80 |
81 | const CommentDescription = styled.div`
82 | margin: 10px 0;
83 | `
84 |
85 | const UserPill = styled.span`
86 | background-color: #2FB3C6;
87 | border-radius: 4px;
88 | color: white;
89 | padding: 2px 4px;
90 | font-size: 13.5px;
91 | `
92 |
93 | class ThreadedEditor extends Component {
94 | state = { text: '' }
95 |
96 | onUpdateText = (e) => {
97 | const { props } = this
98 |
99 | // This is the purest (native es6) way to do this
100 | // You can use a library such as redux and/or
101 | // lodash/ramda to make this cleaner
102 | props.onChange({
103 | ...props.annotation,
104 | data: {
105 | ...props.annotation.data,
106 | comments: [
107 | this.props.annotation.data
108 | ? {
109 | ...this.props.annotation.data.comments[0],
110 | text: e.target.value
111 | }
112 | : {
113 | id: Math.random(),
114 | text: e.target.value
115 | }
116 | ]
117 | }
118 | })
119 | }
120 |
121 | render () {
122 | const { props } = this
123 | const { geometry } = props.annotation
124 | if (!geometry) return null
125 |
126 | return (
127 |
136 |
145 |
146 | )
147 | }
148 | }
149 |
150 | class ThreadedContent extends Component {
151 | state = {
152 | editorText: ''
153 | }
154 |
155 | onUpdateEditorText = (e) => {
156 | this.setState({ editorText: e.target.value })
157 | }
158 |
159 | renderComment (comment) {
160 | return (
161 |
162 | {comment.text}
163 |
164 | User
165 |
166 |
167 | )
168 | }
169 |
170 | render () {
171 | const { props } = this
172 | const { annotation } = props
173 | const { geometry } = annotation
174 | const comments = annotation.data && annotation.data.comments
175 |
176 | return (
177 |
178 |
186 |
187 |
188 |
189 | {(comments) && comments.map(this.renderComment)}
190 | {
196 | const annotationIndex = props.annotations.indexOf(annotation)
197 | const annotations = props.annotations.map((annotation, i) => (
198 | i === annotationIndex
199 | ? {
200 | ...annotation,
201 | data: {
202 | ...annotation.data,
203 | comments: [
204 | ...comments,
205 | { id: Math.random(), text: this.state.editorText }
206 | ]
207 | }
208 | }
209 | : annotation
210 | ))
211 |
212 | this.setState({ editorText: '' })
213 | props.setAnnotations(annotations)
214 | }}
215 | />
216 |
217 |
218 | )
219 | }
220 | }
221 |
222 | export default class Threaded extends Component {
223 | state = {
224 | activeAnnotations: [],
225 | annotations: [],
226 | annotation: {}
227 | }
228 |
229 | onChange = (annotation) => {
230 | this.setState({ annotation })
231 | }
232 |
233 | onSubmit = (annotation) => {
234 | const { geometry, data } = annotation
235 |
236 | this.setState({
237 | annotation: {},
238 | annotations: this.state.annotations.concat({
239 | geometry,
240 | data: {
241 | ...data,
242 | id: Math.random()
243 | }
244 | })
245 | })
246 | }
247 |
248 | renderEditor = (props) => {
249 | const { geometry } = props.annotation
250 | if (!geometry) return null
251 |
252 | return (
253 |
254 | )
255 | }
256 |
257 | renderContent = ({ key, annotation }) => {
258 | return (
259 | this.setState({ annotations })}
264 | onFocus={this.onFocus(key)}
265 | onBlur={this.onBlur(key)}
266 | />
267 | )
268 | }
269 |
270 | onFocus = (id) => e => {
271 | this.setState({
272 | activeAnnotations: [
273 | ...this.state.activeAnnotations,
274 | id
275 | ]
276 | })
277 | }
278 |
279 | onBlur = (id) => e => {
280 | const index = this.state.activeAnnotations.indexOf(id)
281 |
282 | this.setState({
283 | activeAnnotations: [
284 | ...this.state.activeAnnotations.slice(0, index),
285 | ...this.state.activeAnnotations.slice(index + 1)
286 | ]
287 | })
288 | }
289 |
290 | activeAnnotationComparator = (a, b) => {
291 | return a.data.id === b
292 | }
293 |
294 | render () {
295 | return (
296 |
297 |
312 |
313 | )
314 | }
315 | }
316 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Touch/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import Annotation from '../../../../../src'
3 | import {
4 | PointSelector,
5 | RectangleSelector,
6 | OvalSelector
7 | } from '../../../../../src/selectors'
8 |
9 | import Button from '../../Button'
10 |
11 | import mocks from '../../../mocks'
12 | import img from '../../../img.jpeg'
13 |
14 | export default class Multiple extends Component {
15 | state = {
16 | type: RectangleSelector.TYPE,
17 | annotations: mocks.annotations,
18 | annotation: {},
19 | allowTouch: true
20 | }
21 |
22 | onChange = annotation => {
23 | this.setState({ annotation })
24 | }
25 |
26 | onSubmit = annotation => {
27 | const { geometry, data } = annotation
28 |
29 | this.setState({
30 | annotation: {},
31 | annotations: this.state.annotations.concat({
32 | geometry,
33 | data: {
34 | ...data,
35 | id: Math.random()
36 | }
37 | })
38 | })
39 | }
40 |
41 | onChangeType = e => {
42 | this.setState({
43 | annotation: {},
44 | type: e.currentTarget.innerHTML
45 | })
46 | }
47 |
48 | toggleAllowTouch = () => {
49 | this.setState(prevState => ({ allowTouch: !prevState.allowTouch }))
50 | }
51 |
52 | render() {
53 | return (
54 |
55 |
56 |
57 | {this.state.allowTouch
58 | ? 'Stop allowing touch'
59 | : 'Start allowing touch'}
60 |
61 |
62 |
63 |
67 | {RectangleSelector.TYPE}
68 |
69 |
73 | {PointSelector.TYPE}
74 |
75 |
76 |
80 | {OvalSelector.TYPE}
81 |
82 |
83 |
93 |
94 | )
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/demo/src/components/Samples/Touch/index.txt:
--------------------------------------------------------------------------------
1 | class Touch extends Component {
2 | state = { allowTouch: true }
3 |
4 | toggleAllowTouch = () => {
5 | this.setState((prevState) => (
6 | {allowTouch: !prevState.allowTouch}
7 | ))
8 | }
9 |
10 | render () {
11 | return (
12 |
13 |
14 |
15 | {this.state.allowTouch
16 | ? "Stop allowing touch"
17 | : "Start allowing touch"
18 | }
19 |
20 |
21 |
33 |
34 | )
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/demo/src/img.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Secretmapper/react-image-annotation/3ddde7c7f52073c7f977485456e825b5592afb1d/demo/src/img.jpeg
--------------------------------------------------------------------------------
/demo/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Montserrat:700|Open+Sans');
2 |
3 | html, body {
4 | margin: 0;
5 | }
6 |
--------------------------------------------------------------------------------
/demo/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= htmlWebpackPlugin.options.title %>
8 |
9 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/demo/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import './index.css'
4 | import App from './App'
5 | import registerServiceWorker from './registerServiceWorker'
6 |
7 | import { registerLanguage } from 'react-syntax-highlighter/prism-light'
8 | import jsx from 'react-syntax-highlighter/languages/prism/jsx'
9 |
10 | registerLanguage('jsx', jsx)
11 |
12 | ReactDOM.render( , document.getElementById('demo'))
13 | registerServiceWorker()
14 |
--------------------------------------------------------------------------------
/demo/src/mocks.js:
--------------------------------------------------------------------------------
1 | import {
2 | RectangleSelector,
3 | OvalSelector
4 | } from '../../src/selectors'
5 |
6 | export default {
7 | annotations: [
8 | {
9 | geometry:
10 | {
11 | type: RectangleSelector.TYPE,
12 | x: 25,
13 | y: 31,
14 | width: 21,
15 | height: 35
16 | },
17 | data: {
18 | text: 'Annotate!',
19 | id: 1
20 | }
21 | },
22 | {
23 | geometry:
24 | {
25 | type: OvalSelector.TYPE,
26 | x: 53,
27 | y: 33,
28 | width : 17.5,
29 | height: 28
30 | },
31 | data: {
32 | text: 'Supports custom shapes too!',
33 | id: 2
34 | }
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/demo/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | )
20 |
21 | export default function register () {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl)
38 | } else {
39 | // Is not local host. Just register service worker
40 | registerValidSW(swUrl)
41 | }
42 | })
43 | }
44 | }
45 |
46 | function registerValidSW (swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.')
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.')
65 | }
66 | }
67 | }
68 | }
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error)
72 | })
73 | }
74 |
75 | function checkValidServiceWorker (swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload()
88 | })
89 | })
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl)
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | )
99 | })
100 | }
101 |
102 | export function unregister () {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister()
106 | })
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/nwb.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | module.exports = {
4 | type: 'react-component',
5 | npm: {
6 | esModules: true,
7 | umd: {
8 | global: 'ReactImageAnnotation',
9 | externals: {
10 | react: 'React'
11 | }
12 | }
13 | },
14 | webpack: {
15 | html: {
16 | template: 'demo/src/index.html'
17 | },
18 | extra: {
19 | module: {
20 | rules: [
21 | {test: /\.txt/, loader: 'raw-loader'}
22 | ]
23 | }
24 | }
25 | },
26 | karma: {
27 | testContext: 'tests/index.test.js'
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-image-annotation",
3 | "version": "0.9.10",
4 | "description": "react-image-annotation React component",
5 | "author": "Arian Allenson Valdez (http://arianv.com/)",
6 | "main": "lib/index.js",
7 | "module": "es/index.js",
8 | "files": [
9 | "css",
10 | "es",
11 | "lib",
12 | "umd"
13 | ],
14 | "scripts": {
15 | "build": "nwb build-react-component",
16 | "deploy": "gh-pages -d demo/dist",
17 | "clean": "nwb clean-module && nwb clean-demo",
18 | "start": "nwb serve-react-demo",
19 | "test": "nwb test-react",
20 | "test:coverage": "nwb test-react --coverage",
21 | "test:watch": "nwb test-react --server"
22 | },
23 | "dependencies": {
24 | "styled-components": "^3.1.6"
25 | },
26 | "peerDependencies": {
27 | "prop-types": "^15.6.0",
28 | "react": "^16.3",
29 | "react-dom": ">=0.14"
30 | },
31 | "devDependencies": {
32 | "chai": "^4.1.2",
33 | "enzyme": "^3.3.0",
34 | "enzyme-adapter-react-16": "^1.1.1",
35 | "gh-pages": "^1.1.0",
36 | "nwb": "0.21.x",
37 | "raw-loader": "^0.5.1",
38 | "react": "^16.8.6",
39 | "react-dom": "^16.8.6",
40 | "react-router": "^4.2.0",
41 | "react-router-dom": "^4.2.2",
42 | "react-syntax-highlighter": "^7.0.0",
43 | "standard": "^10.0.3"
44 | },
45 | "standard": {
46 | "env": [
47 | "jest",
48 | "jasmine"
49 | ],
50 | "globals": [
51 | "fetch",
52 | "URL"
53 | ],
54 | "parser": "babel-eslint"
55 | },
56 | "homepage": "",
57 | "license": "MIT",
58 | "repository": {
59 | "type": "git",
60 | "url": "https://github.com/Secretmapper/react-image-annotation"
61 | },
62 | "keywords": [
63 | "react-component"
64 | ]
65 | }
66 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/Annotation.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import T from 'prop-types'
3 | import styled from 'styled-components'
4 | import compose from '../utils/compose'
5 | import isMouseHovering from '../utils/isMouseHovering'
6 | import withRelativeMousePos from '../utils/withRelativeMousePos'
7 |
8 | import defaultProps from './defaultProps'
9 | import Overlay from './Overlay'
10 |
11 | const Container = styled.div`
12 | clear: both;
13 | position: relative;
14 | width: 100%;
15 | &:hover ${Overlay} {
16 | opacity: 1;
17 | }
18 | touch-action: ${(props) => (props.allowTouch ? "pinch-zoom" : "auto")};
19 | `
20 |
21 | const Img = styled.img`
22 | display: block;
23 | width: 100%;
24 | `
25 |
26 | const Items = styled.div`
27 | position: absolute;
28 | top: 0;
29 | left: 0;
30 | bottom: 0;
31 | right: 0;
32 | `
33 |
34 | const Target = Items
35 |
36 | export default compose(
37 | isMouseHovering(),
38 | withRelativeMousePos()
39 | )(class Annotation extends Component {
40 | static propTypes = {
41 | innerRef: T.func,
42 | onMouseUp: T.func,
43 | onMouseDown: T.func,
44 | onMouseMove: T.func,
45 | onClick: T.func,
46 | children: T.object,
47 |
48 | annotations: T.arrayOf(
49 | T.shape({
50 | type: T.string
51 | })
52 | ).isRequired,
53 | type: T.string,
54 | selectors: T.arrayOf(
55 | T.shape({
56 | TYPE: T.string,
57 | intersects: T.func.isRequired,
58 | area: T.func.isRequired,
59 | methods: T.object.isRequired
60 | })
61 | ).isRequired,
62 |
63 | value: T.shape({
64 | selection: T.object,
65 | geometry: T.shape({
66 | type: T.string.isRequired
67 | }),
68 | data: T.object
69 | }),
70 | onChange: T.func,
71 | onSubmit: T.func,
72 |
73 | activeAnnotationComparator: T.func,
74 | activeAnnotations: T.arrayOf(T.any),
75 |
76 | disableAnnotation: T.bool,
77 | disableSelector: T.bool,
78 | renderSelector: T.func,
79 | disableEditor: T.bool,
80 | renderEditor: T.func,
81 |
82 | renderHighlight: T.func.isRequired,
83 | renderContent: T.func.isRequired,
84 |
85 | disableOverlay: T.bool,
86 | renderOverlay: T.func.isRequired,
87 | allowTouch: T.bool
88 | }
89 |
90 | static defaultProps = defaultProps
91 |
92 | targetRef = React.createRef();
93 | componentDidMount() {
94 | if (this.props.allowTouch) {
95 | this.addTargetTouchEventListeners();
96 | }
97 | }
98 |
99 | addTargetTouchEventListeners = () => {
100 | // Safari does not recognize touch-action CSS property,
101 | // so we need to call preventDefault ourselves to stop touch from scrolling
102 | // Event handlers must be set via ref to enable e.preventDefault()
103 | // https://github.com/facebook/react/issues/9809
104 |
105 | this.targetRef.current.ontouchstart = this.onTouchStart;
106 | this.targetRef.current.ontouchend = this.onTouchEnd;
107 | this.targetRef.current.ontouchmove = this.onTargetTouchMove;
108 | this.targetRef.current.ontouchcancel = this.onTargetTouchLeave;
109 |
110 | }
111 | removeTargetTouchEventListeners = () => {
112 | this.targetRef.current.ontouchstart = undefined;
113 | this.targetRef.current.ontouchend = undefined;
114 | this.targetRef.current.ontouchmove = undefined;
115 | this.targetRef.current.ontouchcancel = undefined;
116 | }
117 |
118 | componentDidUpdate(prevProps) {
119 | if (this.props.allowTouch !== prevProps.allowTouch) {
120 | if (this.props.allowTouch) {
121 | this.addTargetTouchEventListeners()
122 | } else {
123 | this.removeTargetTouchEventListeners()
124 | }
125 | }
126 | }
127 |
128 | setInnerRef = (el) => {
129 | this.container = el
130 | this.props.relativeMousePos.innerRef(el)
131 | this.props.innerRef(el)
132 | }
133 |
134 | getSelectorByType = (type) => {
135 | return this.props.selectors.find(s => s.TYPE === type)
136 | }
137 |
138 | getTopAnnotationAt = (x, y) => {
139 | const { annotations } = this.props
140 | const { container, getSelectorByType } = this
141 |
142 | if (!container) return
143 |
144 | const intersections = annotations
145 | .map(annotation => {
146 | const { geometry } = annotation
147 | const selector = getSelectorByType(geometry.type)
148 |
149 | return selector.intersects({ x, y }, geometry, container)
150 | ? annotation
151 | : false
152 | })
153 | .filter(a => !!a)
154 | .sort((a, b) => {
155 | const aSelector = getSelectorByType(a.geometry.type)
156 | const bSelector = getSelectorByType(b.geometry.type)
157 |
158 | return aSelector.area(a.geometry, container) - bSelector.area(b.geometry, container)
159 | })
160 |
161 | return intersections[0]
162 | }
163 |
164 | onTargetMouseMove = (e) => {
165 | this.props.relativeMousePos.onMouseMove(e)
166 | this.onMouseMove(e)
167 | }
168 | onTargetTouchMove = (e) => {
169 | this.props.relativeMousePos.onTouchMove(e)
170 | this.onTouchMove(e)
171 | }
172 |
173 | onTargetMouseLeave = (e) => {
174 | this.props.relativeMousePos.onMouseLeave(e)
175 | }
176 | onTargetTouchLeave = (e) => {
177 | this.props.relativeMousePos.onTouchLeave(e)
178 | }
179 |
180 | onMouseUp = (e) => this.callSelectorMethod('onMouseUp', e)
181 | onMouseDown = (e) => this.callSelectorMethod('onMouseDown', e)
182 | onMouseMove = (e) => this.callSelectorMethod('onMouseMove', e)
183 | onTouchStart = (e) => this.callSelectorMethod("onTouchStart", e)
184 | onTouchEnd = (e) => this.callSelectorMethod("onTouchEnd", e)
185 | onTouchMove = (e) => this.callSelectorMethod("onTouchMove", e)
186 | onClick = (e) => this.callSelectorMethod('onClick', e)
187 |
188 | onSubmit = () => {
189 | this.props.onSubmit(this.props.value)
190 | }
191 |
192 | callSelectorMethod = (methodName, e) => {
193 | if (this.props.disableAnnotation) {
194 | return
195 | }
196 |
197 | if (!!this.props[methodName]) {
198 | this.props[methodName](e)
199 | } else {
200 | const selector = this.getSelectorByType(this.props.type)
201 | if (selector && selector.methods[methodName]) {
202 | const value = selector.methods[methodName](this.props.value, e)
203 |
204 | if (typeof value === 'undefined') {
205 | if (process.env.NODE_ENV !== 'production') {
206 | console.error(`
207 | ${methodName} of selector type ${this.props.type} returned undefined.
208 | Make sure to explicitly return the previous state
209 | `)
210 | }
211 | } else {
212 | this.props.onChange(value)
213 | }
214 | }
215 | }
216 | }
217 |
218 | shouldAnnotationBeActive = (annotation, top) => {
219 | if (this.props.activeAnnotations) {
220 | const isActive = !!this.props.activeAnnotations.find(active => (
221 | this.props.activeAnnotationComparator(annotation, active)
222 | ))
223 |
224 | return isActive || top === annotation
225 | } else {
226 | return top === annotation
227 | }
228 | }
229 |
230 |
231 |
232 | render () {
233 | const { props } = this
234 | const {
235 | isMouseHovering,
236 |
237 | renderHighlight,
238 | renderContent,
239 | renderSelector,
240 | renderEditor,
241 | renderOverlay,
242 | allowTouch
243 | } = props
244 |
245 | const topAnnotationAtMouse = this.getTopAnnotationAt(
246 | this.props.relativeMousePos.x,
247 | this.props.relativeMousePos.y
248 | )
249 |
250 | return (
251 |
258 |
266 |
267 | {props.annotations.map(annotation => (
268 | renderHighlight({
269 | key: annotation.data.id,
270 | annotation,
271 | active: this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse)
272 | })
273 | ))}
274 | {!props.disableSelector
275 | && props.value
276 | && props.value.geometry
277 | && (
278 | renderSelector({
279 | annotation: props.value
280 | })
281 | )
282 | }
283 |
284 |
291 | {!props.disableOverlay && (
292 | renderOverlay({
293 | type: props.type,
294 | annotation: props.value
295 | })
296 | )}
297 | {props.annotations.map(annotation => (
298 | this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse)
299 | && (
300 | renderContent({
301 | key: annotation.data.id,
302 | annotation: annotation
303 | })
304 | )
305 | ))}
306 | {!props.disableEditor
307 | && props.value
308 | && props.value.selection
309 | && props.value.selection.showEditor
310 | && (
311 | renderEditor({
312 | annotation: props.value,
313 | onChange: props.onChange,
314 | onSubmit: this.onSubmit
315 | })
316 | )
317 | }
318 | {props.children}
319 |
320 | )
321 | }
322 | })
323 |
--------------------------------------------------------------------------------
/src/components/Content/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.div`
5 | background: white;
6 | border-radius: 2px;
7 | box-shadow:
8 | 0px 1px 5px 0px rgba(0, 0, 0, 0.2),
9 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14),
10 | 0px 3px 1px -2px rgba(0, 0, 0, 0.12);
11 | padding: 8px 16px;
12 | margin-top: 8px;
13 | margin-left: 8px;
14 | `
15 |
16 | function Content (props) {
17 | const { geometry } = props.annotation
18 | if (!geometry) return null
19 |
20 | return (
21 |
31 | {props.annotation.data && props.annotation.data.text}
32 |
33 | )
34 | }
35 |
36 | Content.defaultProps = {
37 | style: {},
38 | className: ''
39 | }
40 |
41 | export default Content
42 |
--------------------------------------------------------------------------------
/src/components/Editor/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled, { keyframes } from 'styled-components'
3 | import TextEditor from '../TextEditor'
4 |
5 | const fadeInScale = keyframes`
6 | from {
7 | opacity: 0;
8 | transform: scale(0);
9 | }
10 |
11 | to {
12 | opacity: 1;
13 | transform: scale(1);
14 | }
15 | `
16 |
17 | const Container = styled.div`
18 | background: white;
19 | border-radius: 2px;
20 | box-shadow:
21 | 0px 1px 5px 0px rgba(0, 0, 0, 0.2),
22 | 0px 2px 2px 0px rgba(0, 0, 0, 0.14),
23 | 0px 3px 1px -2px rgba(0, 0, 0, 0.12);
24 | margin-top: 16px;
25 | transform-origin: top left;
26 |
27 | animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275);
28 | overflow: hidden;
29 | `
30 |
31 | function Editor (props) {
32 | const { geometry } = props.annotation
33 | if (!geometry) return null
34 |
35 | return (
36 |
45 | props.onChange({
47 | ...props.annotation,
48 | data: {
49 | ...props.annotation.data,
50 | text: e.target.value
51 | }
52 | })}
53 | onSubmit={props.onSubmit}
54 | value={props.annotation.data && props.annotation.data.text}
55 | />
56 |
57 | )
58 | }
59 |
60 | Editor.defaultProps = {
61 | className: '',
62 | style: {}
63 | }
64 |
65 | export default Editor
66 |
--------------------------------------------------------------------------------
/src/components/FancyRectangle/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Box = styled.div`
5 | background: rgba(0, 0, 0, 0.2);
6 | position: absolute;
7 | `
8 |
9 | const Container = styled.div`
10 | position: absolute;
11 | top: 0;
12 | left: 0;
13 | bottom: 0;
14 | right: 0;
15 | `
16 |
17 | function FancyRectangle (props) {
18 | const { geometry } = props.annotation
19 |
20 | if (!geometry) return null
21 |
22 | return (
23 |
27 |
33 |
40 |
48 |
55 |
56 | )
57 | }
58 |
59 | FancyRectangle.defaultProps = {
60 | className: '',
61 | style: {}
62 | }
63 |
64 | export default FancyRectangle
65 |
--------------------------------------------------------------------------------
/src/components/Oval/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.div`
5 | border: dashed 2px black;
6 | border-radius: 100%;
7 | box-shadow: 0px 0px 1px 1px white inset;
8 | box-sizing: border-box;
9 | transition: box-shadow 0.21s ease-in-out;
10 | `
11 |
12 | function Oval (props) {
13 | const { geometry } = props.annotation
14 | if (!geometry) return null
15 |
16 | return (
17 |
29 | )
30 | }
31 |
32 | Oval.defaultProps = {
33 | className: '',
34 | style: {}
35 | }
36 |
37 | export default Oval
38 |
--------------------------------------------------------------------------------
/src/components/Overlay/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | export default styled.div`
5 | background: rgba(0, 0, 0, .4);
6 | border-radius: 5px;
7 | bottom: 4px;
8 | color: white;
9 | font-size: 12px;
10 | font-weight: bold;
11 | opacity: 0;
12 | padding: 10px;
13 | pointer-events: none;
14 | position: absolute;
15 | right: 4px;
16 | transition: opacity 0.21s ease-in-out;
17 | user-select: none;
18 | `
19 |
--------------------------------------------------------------------------------
/src/components/Point/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.div`
5 | border: solid 3px white;
6 | border-radius: 50%;
7 | box-sizing: border-box;
8 | box-shadow:
9 | 0 0 0 1px rgba(0,0,0,0.3),
10 | 0 0 0 2px rgba(0,0,0,0.2),
11 | 0 5px 4px rgba(0,0,0,0.4);
12 | height: 16px;
13 | position: absolute;
14 | transform: translate3d(-50%, -50%, 0);
15 | width: 16px;
16 | `
17 |
18 | function Point (props) {
19 | const { geometry } = props.annotation
20 | if (!geometry) return null
21 |
22 | return (
23 |
29 | )
30 | }
31 |
32 |
33 | export default Point
34 |
--------------------------------------------------------------------------------
/src/components/Rectangle/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const Container = styled.div`
5 | border: dashed 2px black;
6 | box-shadow: 0px 0px 1px 1px white inset;
7 | box-sizing: border-box;
8 | transition: box-shadow 0.21s ease-in-out;
9 | `
10 |
11 | function Rectangle (props) {
12 | const { geometry } = props.annotation
13 | if (!geometry) return null
14 |
15 | return (
16 |
28 | )
29 | }
30 |
31 | Rectangle.defaultProps = {
32 | className: '',
33 | style: {}
34 | }
35 |
36 | export default Rectangle
37 |
--------------------------------------------------------------------------------
/src/components/TextEditor/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled, { keyframes } from 'styled-components'
3 |
4 | const Inner = styled.div`
5 | padding: 8px 16px;
6 |
7 | textarea {
8 | border: 0;
9 | font-size: 14px;
10 | margin: 6px 0;
11 | min-height: 60px;
12 | outline: 0;
13 | }
14 | `
15 |
16 | const Button = styled.div`
17 | background: whitesmoke;
18 | border: 0;
19 | box-sizing: border-box;
20 | color: #363636;
21 | cursor: pointer;
22 | font-size: 1rem;
23 | margin: 0;
24 | outline: 0;
25 | padding: 8px 16px;
26 | text-align: center;
27 | text-shadow: 0 1px 0 rgba(0,0,0,0.1);
28 | width: 100%;
29 |
30 | transition: background 0.21s ease-in-out;
31 |
32 | &:focus, &:hover {
33 | background: #eeeeee;
34 | }
35 | `
36 |
37 | function TextEditor (props) {
38 | return (
39 |
40 |
41 |
49 |
50 | {props.value && (
51 |
54 | Submit
55 |
56 | )}
57 |
58 | )
59 | }
60 |
61 | export default TextEditor
62 |
--------------------------------------------------------------------------------
/src/components/defaultProps.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Point from './Point'
4 | import Editor from './Editor'
5 | import FancyRectangle from './FancyRectangle'
6 | import Rectangle from './Rectangle'
7 | import Oval from './Oval'
8 | import Content from './Content'
9 | import Overlay from './Overlay'
10 |
11 | import {
12 | RectangleSelector,
13 | PointSelector,
14 | OvalSelector
15 | } from '../selectors'
16 |
17 | export default {
18 | innerRef: () => {},
19 | onChange: () => {},
20 | onSubmit: () => {},
21 | type: RectangleSelector.TYPE,
22 | selectors: [
23 | RectangleSelector,
24 | PointSelector,
25 | OvalSelector
26 | ],
27 | disableAnnotation: false,
28 | disableSelector: false,
29 | disableEditor: false,
30 | disableOverlay: false,
31 | activeAnnotationComparator: (a, b) => a === b,
32 | renderSelector: ({ annotation }) => {
33 | switch (annotation.geometry.type) {
34 | case RectangleSelector.TYPE:
35 | return (
36 |
39 | )
40 | case PointSelector.TYPE:
41 | return (
42 |
45 | )
46 | case OvalSelector.TYPE:
47 | return (
48 |
51 | )
52 | default:
53 | return null
54 | }
55 | },
56 | renderEditor: ({ annotation, onChange, onSubmit }) => (
57 |
62 | ),
63 | renderHighlight: ({ key, annotation, active }) => {
64 | switch (annotation.geometry.type) {
65 | case RectangleSelector.TYPE:
66 | return (
67 |
72 | )
73 | case PointSelector.TYPE:
74 | return (
75 |
80 | )
81 | case OvalSelector.TYPE:
82 | return (
83 |
88 | )
89 | default:
90 | return null
91 | }
92 | },
93 | renderContent: ({ key, annotation }) => (
94 |
98 | ),
99 | renderOverlay: ({ type, annotation }) => {
100 | switch (type) {
101 | case PointSelector.TYPE:
102 | return (
103 |
104 | Click to Annotate
105 |
106 | )
107 | default:
108 | return (
109 |
110 | Click and Drag to Annotate
111 |
112 | )
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/hocs/OvalSelector.js:
--------------------------------------------------------------------------------
1 | import { getCoordPercentage } from '../utils/offsetCoordinates';
2 |
3 | const square = n => Math.pow(n, 2)
4 |
5 | export const TYPE = 'OVAL'
6 |
7 | export function intersects({ x, y }, geometry) {
8 | const rx = geometry.width / 2
9 | const ry = geometry.height / 2
10 | const h = geometry.x + rx
11 | const k = geometry.y + ry
12 |
13 | const value = square(x - h) / square(rx) + square(y - k) / square(ry)
14 |
15 | return value <= 1
16 | }
17 |
18 | export function area(geometry) {
19 | const rx = geometry.width / 2
20 | const ry = geometry.height / 2
21 |
22 | return Math.PI * rx * ry
23 | }
24 |
25 | export const methods = {
26 | onTouchStart(annotation, e) {
27 | return pointerDown(annotation, e)
28 | },
29 | onTouchEnd(annotation, e) {
30 | return pointerUp(annotation, e)
31 | },
32 | onTouchMove(annotation, e) {
33 | return pointerMove(annotation, e)
34 | },
35 | onMouseDown(annotation, e) {
36 | return pointerDown(annotation, e)
37 | },
38 | onMouseUp(annotation, e) {
39 | return pointerUp(annotation, e)
40 | },
41 | onMouseMove(annotation, e) {
42 | return pointerMove(annotation, e)
43 | }
44 | }
45 |
46 | function pointerDown(annotation, e) {
47 | if (!annotation.selection) {
48 | const { x: anchorX, y: anchorY } = getCoordPercentage(e)
49 |
50 | return {
51 | ...annotation,
52 | selection: {
53 | ...annotation.selection,
54 | mode: 'SELECTING',
55 | anchorX,
56 | anchorY
57 | }
58 | }
59 | } else {
60 | return {}
61 | }
62 | return annotation
63 | }
64 |
65 | function pointerUp(annotation, e) {
66 | if (annotation.selection) {
67 | const { selection, geometry } = annotation
68 |
69 | if (!geometry) {
70 | return {}
71 | }
72 |
73 | switch (annotation.selection.mode) {
74 | case 'SELECTING':
75 | return {
76 | ...annotation,
77 | selection: {
78 | ...annotation.selection,
79 | showEditor: true,
80 | mode: 'EDITING'
81 | }
82 | }
83 | default:
84 | break
85 | }
86 | }
87 | return annotation
88 | }
89 |
90 | function pointerMove(annotation, e) {
91 | if (annotation.selection && annotation.selection.mode === 'SELECTING') {
92 | const { anchorX, anchorY } = annotation.selection
93 | const { x: newX, y: newY } = getCoordPercentage(e)
94 | const width = newX - anchorX
95 | const height = newY - anchorY
96 |
97 | return {
98 | ...annotation,
99 | geometry: {
100 | ...annotation.geometry,
101 | type: TYPE,
102 | x: width > 0 ? anchorX : newX,
103 | y: height > 0 ? anchorY : newY,
104 | width: Math.abs(width),
105 | height: Math.abs(height)
106 | }
107 | }
108 | }
109 | return annotation
110 | }
111 |
112 | export default {
113 | TYPE,
114 | intersects,
115 | area,
116 | methods
117 | }
118 |
--------------------------------------------------------------------------------
/src/hocs/PointSelector.js:
--------------------------------------------------------------------------------
1 | import { getCoordPercentage } from '../utils/offsetCoordinates';
2 | const MARGIN = 6
3 |
4 | const marginToPercentage = (container) => ({
5 | marginX: MARGIN / container.width * 100,
6 | marginY: MARGIN / container.height * 100
7 | })
8 |
9 | export const TYPE = 'POINT'
10 |
11 | export function intersects ({ x, y }, geometry, container) {
12 | const { marginX, marginY } = marginToPercentage(container)
13 |
14 | if (x < geometry.x - marginX) return false
15 | if (y < geometry.y - marginY) return false
16 | if (x > geometry.x + marginX) return false
17 | if (y > geometry.y + marginY) return false
18 |
19 | return true
20 | }
21 |
22 | export function area (geometry, container) {
23 | const { marginX, marginY } = marginToPercentage(container)
24 |
25 | return marginX * marginY
26 | }
27 |
28 | export const methods = {
29 | onClick (annotation, e) {
30 | if (!annotation.geometry) {
31 | return {
32 | ...annotation,
33 | selection: {
34 | ...annotation.selection,
35 | showEditor: true,
36 | mode: 'EDITING'
37 | },
38 | geometry: {
39 | ...annotation.geometry,
40 | ...getCoordPercentage(e),
41 | width: 0,
42 | height: 0,
43 | type: TYPE,
44 | }
45 | }
46 | } else{
47 | return {}
48 | }
49 | }
50 | }
51 |
52 | export default {
53 | TYPE,
54 | intersects,
55 | area,
56 | methods
57 | }
58 |
--------------------------------------------------------------------------------
/src/hocs/RectangleSelector.js:
--------------------------------------------------------------------------------
1 | import { getCoordPercentage } from '../utils/offsetCoordinates';
2 |
3 | export const TYPE = 'RECTANGLE'
4 |
5 | export function intersects({ x, y }, geometry) {
6 | if (x < geometry.x) return false
7 | if (y < geometry.y) return false
8 | if (x > geometry.x + geometry.width) return false
9 | if (y > geometry.y + geometry.height) return false
10 |
11 | return true
12 | }
13 |
14 | export function area(geometry) {
15 | return geometry.height * geometry.width
16 | }
17 |
18 | export const methods = {
19 | onTouchStart(annotation, e) {
20 | return pointerDown(annotation, e)
21 | },
22 | onTouchEnd(annotation, e) {
23 | return pointerUp(annotation, e)
24 | },
25 | onTouchMove(annotation, e) {
26 | return pointerMove(annotation, e)
27 | },
28 | onMouseDown(annotation, e) {
29 | return pointerDown(annotation, e)
30 | },
31 | onMouseUp(annotation, e) {
32 | return pointerUp(annotation, e)
33 | },
34 | onMouseMove(annotation, e) {
35 | return pointerMove(annotation, e)
36 | }
37 | }
38 |
39 | function pointerDown(annotation, e) {
40 | if (!annotation.selection) {
41 | const { x: anchorX, y: anchorY } = getCoordPercentage(e)
42 | return {
43 | ...annotation,
44 | selection: {
45 | ...annotation.selection,
46 | mode: 'SELECTING',
47 | anchorX,
48 | anchorY
49 | }
50 | }
51 | } else {
52 | return {}
53 | }
54 | }
55 |
56 | function pointerUp(annotation, e) {
57 | if (annotation.selection) {
58 | const { selection, geometry } = annotation
59 | if (!geometry) {
60 | return {}
61 | }
62 | switch (annotation.selection.mode) {
63 | case 'SELECTING':
64 | return {
65 | ...annotation,
66 | selection: {
67 | ...annotation.selection,
68 | showEditor: true,
69 | mode: 'EDITING'
70 | }
71 | }
72 | default:
73 | break
74 | }
75 | }
76 | return annotation
77 | }
78 |
79 | function pointerMove(annotation, e) {
80 | if (annotation.selection && annotation.selection.mode === 'SELECTING') {
81 | const { anchorX, anchorY } = annotation.selection
82 | const { x: newX, y: newY } = getCoordPercentage(e)
83 | const width = newX - anchorX
84 | const height = newY - anchorY
85 |
86 | return {
87 | ...annotation,
88 | geometry: {
89 | ...annotation.geometry,
90 | type: TYPE,
91 | x: width > 0 ? anchorX : newX,
92 | y: height > 0 ? anchorY : newY,
93 | width: Math.abs(width),
94 | height: Math.abs(height)
95 | }
96 | }
97 | }
98 | return annotation
99 | }
100 |
101 | export default {
102 | TYPE,
103 | intersects,
104 | area,
105 | methods
106 | }
107 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Annotation from './components/Annotation'
2 | export { default as defaultProps } from './components/defaultProps'
3 |
4 | export default Annotation
5 |
--------------------------------------------------------------------------------
/src/selectors.js:
--------------------------------------------------------------------------------
1 | export { default as RectangleSelector } from './hocs/RectangleSelector'
2 | export { default as PointSelector } from './hocs/PointSelector'
3 | export { default as OvalSelector } from './hocs/OvalSelector'
4 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module "react-image-annotation" {
2 | export interface IGeometry {
3 | type: string;
4 | x?: number;
5 | y?: number;
6 | height?: number;
7 | width?: number;
8 | }
9 | export interface ISelector {
10 | TYPE: string;
11 | intersects: (
12 | { x, y }: { x: number; y: number },
13 | geometry: IGeometry,
14 | container: { width: number; height: number }
15 | ) => boolean;
16 | area: (
17 | geometry: IGeometry,
18 | container: { width: number; height: number }
19 | ) => number;
20 | methods: {
21 | onMouseUp?: (annotation: IAnnotation, e: any) => IAnnotation | {};
22 | onMouseDown?: (annotation: IAnnotation, e: any) => IAnnotation | {};
23 | onMouseMove?: (annotation: IAnnotation, e: any) => IAnnotation | {};
24 | onClick?: (annotation: IAnnotation, e: any) => IAnnotation | {};
25 | };
26 | }
27 | export interface IAnnotation {
28 | selection?: {
29 | mode: string;
30 | showEditor: boolean;
31 | };
32 | geometry: IGeometry;
33 | data: {
34 | text: string;
35 | id?: number;
36 | };
37 | }
38 | interface IAnnotationProps {
39 | src: string;
40 | alt?: string;
41 | innerRef?: (e: any) => any;
42 | onMouseUp?: (e: React.MouseEvent) => any;
43 | onMouseDown?: (e: React.MouseEvent) => any;
44 | onMouseMove?: (e: React.MouseEvent) => any;
45 | onClick?: (e: React.MouseEvent) => any;
46 |
47 | annotations: IAnnotation[];
48 | type?: string;
49 | selectors?: ISelector[];
50 |
51 | value: IAnnotation | {};
52 | onChange?: (e: any) => any;
53 | onSubmit?: (e: any) => any;
54 |
55 | activeAnnotationComparator?: (annotation: IAnnotation) => boolean;
56 | activeAnnotations?: IAnnotation[];
57 |
58 | disableAnnotation?: boolean;
59 | disableSelector?: boolean;
60 | renderSelector?: (
61 | { annotation, active }: { annotation: IAnnotation; active: boolean }
62 | ) => any;
63 | disableEditor?: boolean;
64 | renderEditor?: (
65 | {
66 | annotation,
67 | onChange,
68 | onSubmit
69 | }: {
70 | annotation: IAnnotation;
71 | onChange: (annotation: IAnnotation | {}) => any;
72 | onSubmit: (e?: any) => any;
73 | }
74 | ) => any;
75 |
76 | renderHighlight?: (
77 | { annotation, active }: { annotation: IAnnotation; active: boolean }
78 | ) => any;
79 | renderContent?: ({ annotation }: { annotation: IAnnotation }) => any;
80 |
81 | disableOverlay?: boolean;
82 | renderOverlay?: () => any;
83 | allowTouch: boolean;
84 | }
85 |
86 | class Annotation extends React.Component {}
87 | export default Annotation;
88 | }
89 |
--------------------------------------------------------------------------------
/src/utils/compose.js:
--------------------------------------------------------------------------------
1 | export default function compose (...funcs) {
2 | if (funcs.length === 0) {
3 | return arg => arg
4 | }
5 |
6 | if (funcs.length === 1) {
7 | return funcs[0]
8 | }
9 |
10 | return funcs.reduce((a, b) => (...args) => a(b(...args)))
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/isMouseHovering.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent as Component } from 'react'
2 |
3 | const isMouseOverElement = ({ elem, e }) => {
4 | const { pageY, pageX } = e
5 | const { left, right, bottom, top } = elem.getBoundingClientRect()
6 |
7 | return pageX > left && pageX < right && pageY > top && pageY < bottom
8 | }
9 |
10 | const isMouseHovering = (key = 'isMouseHovering') => DecoratedComponent => {
11 | class IsMouseHovering extends Component {
12 | constructor() {
13 | super()
14 |
15 | this.state = {
16 | isHoveringOver: false
17 | }
18 | }
19 |
20 | componentDidMount() {
21 | document.addEventListener('mousemove', this.onMouseMove)
22 | }
23 |
24 | componentWillUnmount() {
25 | document.removeEventListener('mousemove', this.onMouseMove)
26 | }
27 |
28 | onMouseMove = e => {
29 | const elem = this.el
30 |
31 | this.setState({
32 | isHoveringOver: isMouseOverElement({ elem, e })
33 | })
34 | }
35 |
36 | render() {
37 | const hocProps = {
38 | [key]: {
39 | innerRef: el => this.el = el,
40 | isHoveringOver: this.state.isHoveringOver
41 | }
42 | }
43 |
44 | return (
45 |
49 | )
50 | }
51 | }
52 |
53 | IsMouseHovering.displayName = `IsMouseHovering(${DecoratedComponent.displayName})`
54 |
55 | return IsMouseHovering
56 | }
57 |
58 | export default isMouseHovering
59 |
--------------------------------------------------------------------------------
/src/utils/offsetCoordinates.js:
--------------------------------------------------------------------------------
1 | const getMouseRelativeCoordinates = e => {
2 | // nativeEvent.offsetX gives inconsistent results when dragging
3 | // up and to the left rather than the more natural down and to the
4 | // right. The reason could be browser implementation (it is still experimental)
5 | // or it could be that nativeEvent offsets are based on target rather than
6 | // currentTarget.
7 | // To keep consistent behavior of the selector use the bounding client rect.
8 | const rect = e.currentTarget.getBoundingClientRect();
9 | const offsetX = e.clientX - rect.x;
10 | const offsetY = e.clientY - rect.y;
11 |
12 | return {
13 | x: offsetX / rect.width * 100,
14 | y: offsetY / rect.height * 100
15 | };
16 | }
17 |
18 | const clamp = (a, b, i) => Math.max(a, Math.min(b, i))
19 | const getTouchRelativeCoordinates = e => {
20 | const touch = e.targetTouches[0]
21 |
22 | const boundingRect = e.currentTarget.getBoundingClientRect()
23 | // https://idiallo.com/javascript/element-postion
24 | // https://stackoverflow.com/questions/25630035/javascript-getboundingclientrect-changes-while-scrolling
25 | const offsetX = touch.pageX - boundingRect.left
26 | const offsetY = touch.pageY - (boundingRect.top + window.scrollY)
27 |
28 | return {
29 | x: clamp(0, 100, (offsetX / boundingRect.width) * 100),
30 | y: clamp(0, 100, (offsetY / boundingRect.height) * 100)
31 | }
32 | }
33 |
34 | const getCoordPercentage = (e) => {
35 | if (isTouchEvent(e)) {
36 | if (isValidTouchEvent(e)) {
37 | isTouchMoveEvent(e) && e.preventDefault()
38 | return getTouchRelativeCoordinates(e)
39 | } else {
40 | return {
41 | x: null
42 | }
43 | }
44 | } else {
45 | return getMouseRelativeCoordinates(e)
46 | }
47 | }
48 |
49 | const isTouchEvent = e => e.targetTouches !== undefined
50 | const isValidTouchEvent = e => e.targetTouches.length === 1
51 | const isTouchMoveEvent = e => e.type === 'touchmove'
52 |
53 | export { getMouseRelativeCoordinates as getOffsetCoordPercentage, getCoordPercentage };
54 |
--------------------------------------------------------------------------------
/src/utils/withRelativeMousePos.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent as Component } from 'react'
2 | import { getOffsetCoordPercentage } from './offsetCoordinates';
3 |
4 | const withRelativeMousePos = (key = 'relativeMousePos') => DecoratedComponent => {
5 | class WithRelativeMousePos extends Component {
6 | state = { x: null, y: null }
7 |
8 | innerRef = el => {
9 | this.container = el
10 | }
11 |
12 | onMouseMove = (e) => {
13 | const xystate = getOffsetCoordPercentage(e, this.container);
14 | this.setState(xystate);
15 | }
16 | onTouchMove = (e) => {
17 | if (e.targetTouches.length === 1) {
18 | const touch = e.targetTouches[0]
19 |
20 | const offsetX = touch.pageX - this.container.offsetParent.offsetLeft
21 | const offsetY = touch.pageY - this.container.offsetParent.offsetTop
22 |
23 | this.setState({
24 | x: (offsetX / this.container.width) * 100,
25 | y: (offsetY / this.container.height) * 100
26 | })
27 | }
28 | }
29 |
30 | onMouseLeave = (e) => {
31 | this.setState({ x: null, y: null })
32 | }
33 | onTouchLeave = (e) => {
34 | this.setState({ x: null, y: null })
35 | }
36 |
37 | render () {
38 | const hocProps = {
39 | [key]: {
40 | innerRef: this.innerRef,
41 | onMouseMove: this.onMouseMove,
42 | onMouseLeave: this.onMouseLeave,
43 | onTouchMove: this.onTouchMove,
44 | onTouchLeave: this.onTouchLeave,
45 | x: this.state.x,
46 | y: this.state.y
47 | }
48 | }
49 |
50 | return (
51 |
55 | )
56 | }
57 | }
58 |
59 | WithRelativeMousePos.displayName = `withRelativeMousePos(${DecoratedComponent.displayName})`
60 |
61 | return WithRelativeMousePos
62 | }
63 |
64 | export default withRelativeMousePos
65 |
--------------------------------------------------------------------------------
/tests/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "mocha": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/tests/Annotation.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme'
2 | import { expect } from 'chai'
3 | import React from 'react'
4 |
5 | import Annotation from '../src/components/Annotation'
6 |
7 | const requiredProps = {
8 | annotations: []
9 | }
10 |
11 | describe('Annotation', () => {
12 | describe('render', () => {
13 | it('renders ', () => {
14 | const wrapper = mount( )
15 | expect(wrapper.find('Annotation')).to.have.length(1)
16 | })
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import Enzyme from 'enzyme'
2 | import Adapter from 'enzyme-adapter-react-16'
3 |
4 | Enzyme.configure({ adapter: new Adapter() })
5 |
6 | let context = require.context('./', true, /\.spec\.js$/)
7 | context.keys().forEach(context)
8 |
--------------------------------------------------------------------------------
/tests/selectors/OvalSelector.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme'
2 | import { expect } from 'chai'
3 | import React from 'react'
4 |
5 | import { OvalSelector as selector } from '../../src/selectors'
6 |
7 | function createOval ({ x, y, width, height } = { x: 10, y: 10, width: 20, height: 10 }) {
8 | return {
9 | x, y, width, height
10 | }
11 | }
12 |
13 | describe('OvalSelector', () => {
14 | describe('TYPE', () => {
15 | it('should be a defined string', () => {
16 | expect(selector.TYPE).to.be.a('string')
17 | })
18 | })
19 |
20 | describe('intersects', () => {
21 | it('should return true when point is inside geometry', () => {
22 | expect(
23 | selector.intersects({ x: 15, y: 15 }, createOval())
24 | ).to.be.true
25 |
26 | const x = 15
27 | const y = 17
28 | expect(
29 | selector.intersects({ x, y }, createOval())
30 | ).to.be.true
31 | })
32 | it('should return false when point is outside of geometry', () => {
33 | expect(selector.intersects({ x: 0, y: 0 }, createOval())).to.be.false
34 | expect(selector.intersects({ x: 10, y: 0 }, createOval())).to.be.false
35 | expect(selector.intersects({ x: 0, y: 10 }, createOval())).to.be.false
36 | expect(selector.intersects({ x: 30, y: 30 }, createOval())).to.be.false
37 | })
38 | })
39 |
40 | describe('area', () => {
41 | it('should return geometry area', () => {
42 | expect(selector.area(createOval())).to.equal(157.07963267948966)
43 | })
44 | })
45 |
46 | describe('methods', () => {
47 | xit('should be defined')
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/tests/selectors/PointSelector.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme'
2 | import { expect } from 'chai'
3 | import React from 'react'
4 |
5 | import { PointSelector as selector } from '../../src/selectors'
6 |
7 | function createPoint ({ x, y } = { x: 10, y: 10 }) {
8 | return { x, y }
9 | }
10 |
11 | function createContainer({ width, height } = { width: 100, height: 100 }) {
12 | return { width, height }
13 | }
14 |
15 | describe('PoinntSelector', () => {
16 | describe('TYPE', () => {
17 | it('should be a defined string', () => {
18 | expect(selector.TYPE).to.be.a('string')
19 | })
20 | })
21 |
22 | describe('intersects', () => {
23 | it('should return true when point is inside geometry', () => {
24 | expect(
25 | selector.intersects({ x: 10, y: 10 }, createPoint(), createContainer())
26 | ).to.be.true
27 | })
28 | it('should return false when point is outside of geometry', () => {
29 | expect(selector.intersects({ x: 0, y: 0 }, createPoint(), createContainer())).to.be.false
30 | expect(selector.intersects({ x: 10, y: 0 }, createPoint(), createContainer())).to.be.false
31 | expect(selector.intersects({ x: 0, y: 10 }, createPoint(), createContainer())).to.be.false
32 | expect(selector.intersects({ x: 30, y: 30 }, createPoint(), createContainer())).to.be.false
33 | })
34 | })
35 |
36 | describe('area', () => {
37 | it('should return geometry area', () => {
38 | expect(
39 | selector.area(createPoint(), createContainer())
40 | ).to.equal(36)
41 | })
42 | it('should return geometry area based on container', () => {
43 | expect(
44 | selector.area(createPoint(), createContainer({ width: 200, height: 200 }))
45 | ).to.equal(9)
46 | })
47 | })
48 |
49 | describe('methods', () => {
50 | xit('should be defined')
51 | })
52 | })
53 |
--------------------------------------------------------------------------------
/tests/selectors/RectangleSelector.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme'
2 | import { expect } from 'chai'
3 | import React from 'react'
4 |
5 | import { RectangleSelector as selector } from '../../src/selectors'
6 |
7 | function createRect ({ x, y, width, height } = { x: 10, y: 10, width: 10, height: 10 }) {
8 | return {
9 | x, y, width, height
10 | }
11 | }
12 |
13 | describe('RectangleSelector', () => {
14 | describe('TYPE', () => {
15 | it('should be a defined string', () => {
16 | expect(selector.TYPE).to.be.a('string')
17 | })
18 | })
19 |
20 | describe('intersects', () => {
21 | it('should return true when point is on top left of geometry', () => {
22 | expect(
23 | selector.intersects({ x: 10, y: 10 }, createRect())
24 | ).to.be.true
25 | })
26 | it('should return true when point is on top right of geometry', () => {
27 | expect(
28 | selector.intersects({ x: 20, y: 10 }, createRect())
29 | ).to.be.true
30 | })
31 | it('should return true when point is on bottom left of geometry', () => {
32 | expect(
33 | selector.intersects({ x: 10, y: 20 }, createRect())
34 | ).to.be.true
35 | })
36 | it('should return true when point is on bottom right of geometry', () => {
37 | expect(
38 | selector.intersects({ x: 20, y: 20 }, createRect())
39 | ).to.be.true
40 | })
41 | it('should return true when point is inside geometry', () => {
42 | expect(
43 | selector.intersects({ x: 15, y: 15 }, createRect())
44 | ).to.be.true
45 | })
46 | it('should return false when point is outside of geometry', () => {
47 | expect(selector.intersects({ x: 0, y: 0 }, createRect())).to.be.false
48 | expect(selector.intersects({ x: 10, y: 0 }, createRect())).to.be.false
49 | expect(selector.intersects({ x: 0, y: 10 }, createRect())).to.be.false
50 | expect(selector.intersects({ x: 30, y: 30 }, createRect())).to.be.false
51 | })
52 | })
53 |
54 | describe('area', () => {
55 | it('should return geometry area', () => {
56 | expect(selector.area(createRect({ width: 10, height: 10 }))).to.equal(100)
57 | })
58 | })
59 |
60 | describe('methods', () => {
61 | xit('should be defined')
62 | })
63 | })
64 |
--------------------------------------------------------------------------------