Repository: Secretmapper/react-image-annotation
Branch: master
Commit: 3ddde7c7f520
Files: 60
Total size: 87.7 KB
Directory structure:
gitextract_r5h__py7/
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── demo/
│ ├── 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
│ ├── 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
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
/coverage
/demo/dist
/es
/lib
/node_modules
/umd
npm-debug.log*
.vscode/
================================================
FILE: .travis.yml
================================================
sudo: false
language: node_js
node_js:
- 8
before_install:
- npm install codecov.io coveralls
after_success:
- cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js
- cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
branches:
only:
- master
================================================
FILE: CHANGELOG.md
================================================
## 0.9.8
### Improvements
- Add Type Definitions for Typescript (#12) (thanks @danilofuchs)
- Add support for `children` property (#13) (thanks @federico-bohn)
## 0.9.7
### Fixes
- [Interaction] Fix bug where point annotation would fail abort (#8) (thanks @joshuadeguzman)
## 0.9.6
### Breaking change
- [Interaction] Change annotation click action to click and drag (#6)
================================================
FILE: CONTRIBUTING.md
================================================
## Prerequisites
[Node.js](http://nodejs.org/) >= v4 must be installed.
## Installation
- Running `npm install` in the component's root directory will install everything you need for development.
## Demo Development Server
- `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading.
## Running Tests
- `npm test` will run the tests once.
- `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`.
- `npm run test:watch` will run the tests on every change.
## Building
- `npm run build` will build the component for publishing to npm and also bundle the demo app.
- `npm run clean` will delete built resources.
================================================
FILE: LICENSE.md
================================================
The MIT License (MIT)
Copyright (c) 2018-present, Arian Allenson Valdez.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
React Image Annotation
=========================
An infinitely customizable image annotation library built on React

## Installation
```
npm install --save react-image-annotation
# or
yarn add react-image-annotation
```
## Usage
```js
export default class Simple extends Component {
state = {
annotations: [],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
render () {
return (
)
}
}
```
### Props
Prop | Description | Default
---- | ----------- | -------
`src` | Image src attribute |
`alt` | Image alt attribute |
`annotations` | Array of annotations |
`value` | Annotation object currently being created. See [annotation object](#annotation-object) |
`onChange` | `onChange` handler for annotation object |
`onSubmit` | `onSubmit` handler for annotation object |
`type` | Selector type. See [custom shapes](#using-custom-shapes) | `RECTANGLE`
`allowTouch` | Set to `true` to allow the target to handle touch events. This disables one-finger scrolling | `false`
`selectors` | An array of selectors. See [adding custom selector logic](#adding-custom-selector-logic) | `[RectangleSelector, PointSelector, OvalSelector]`
`activeAnnotations` | Array of annotations that will be passed as 'active' (active highlight and shows content) |
`activeAnnotationComparator` | Method to compare annotation and `activeAnnotation` item (from `props.activeAnnotations`). Return `true` if it's the annotations are equal | `(a, b) => a === b`
`disableAnnotation` | Set to `true` to disable creating of annotations (note that no callback methods will be called if this is `true`) | `false`
`disableSelector` | Set to `true` to not render `Selector` | `false`
`disableEditor` | Set to `true` to not render `Editor` | `false`
`disableOverlay` | Set to `true` to not render `Overlay` | `false`
`renderSelector` | Function that renders `Selector` Component | See [custom components](#using-custom-components)
`renderEditor` | Function that renders `Editor` Component | See [custom components](#using-custom-components)
`renderHighlight` | Function that renders `Highlight` Component | See [custom components](#using-custom-components)
`renderContent` | Function that renders `Content` | See [custom components](#using-custom-components)
`renderOverlay` | Function that renders `Overlay` | See [custom components](#using-custom-components)
`onMouseUp` | `onMouseUp` handler on annotation target |
`onMouseDown` | `onMouseDown` handler on annotation target |
`onMouseMove` | `onMouseMove` handler on annotation target |
`onClick` | `onClick` handler on annotation target |
#### Annotation object
An Annotation object is an object that conforms to the object shape
```js
({
selection: T.object, // temporary object for selector logic
geometry: T.shape({ // geometry data for annotation
type: T.string.isRequired // type is used to resolve Highlighter/Selector renderer
}),
// auxiliary data object for application.
// Content data can be stored here (text, image, primary key, etc.)
data: T.object
})
```
## Using custom components
`Annotation` supports `renderProp`s for almost every internal component.
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.
- `renderSelector` - used for selecting annotation area (during annotation creation)
- `renderEditor` - appears after annotation area has been selected (during annotation creation)
- `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
- `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 }`
- `renderOverlay` - Component overlay for Annotation (i.e. 'Click and Drag to Annotate')
You can view the default renderProps [here](src/components/defaultProps.js)
**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`
## Using custom shapes
`Annotation` supports three shapes by default, `RECTANGLE`, `POINT` and `OVAL`.
You can switch the shape selector by passing the appropriate `type` as a property. Default shape `TYPE`s are accessible on their appropriate selectors:
```js
import {
PointSelector,
RectangleSelector,
OvalSelector
} from 'react-image-annotation/lib/selectors'
```
### Adding custom selector logic
#### This is an Advanced Topic
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.
Selectors are objects that must have the following properties:
- `TYPE` - string that uniquely identifies this selector (i.e. `RECTANGLE`)
- `intersects` - method that returns true if the mouse point intersects with the annotation geometry
- `area` - method that calculates and returns the area of the annotation geometry
- `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
You can view a defined `RectangleSelector` [here](src/hocs/RectangleSelector.js)
### Connecting selector logic to Redux/MobX
First see [Selectors](#adding-custom-selector-logic)
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.
***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.***
## License
MIT
================================================
FILE: demo/public/404.html
================================================
Single Page Apps for GitHub Pages
================================================
FILE: demo/src/App.js
================================================
import React from 'react'
import { BrowserRouter as Router, Route } from 'react-router-dom'
import styled from 'styled-components'
import NavBar from './components/NavBar'
import Root from './components/Root'
import Home from './components/Home'
import Docs from './components/Docs'
import Footer from './components/Footer'
const Main = styled.main`
margin: 0 16px;
margin-top: 51px;
`
export default () => (
)
================================================
FILE: demo/src/components/Button/index.js
================================================
import styled, { css } from 'styled-components'
import { Link } from 'react-router-dom'
const styles = css`
background: #24B3C8;
border: 0;
color: white;
cursor: pointer;
font-family: Montserrat;
font-size: 13px;
font-weight: 700;
outline: 0;
margin: 4px;
padding: 8px 16px;
text-shadow: 0 1px 0 rgba(0,0,0,0.1);
text-transform: uppercase;
transition: background 0.21s ease-in-out;
&:focus, &:hover {
background: #176572;
}
${props => props.active && `
background: #176572;
`}
`
export default styled.button`
${props => styles}
`
export const ButtonLink = styled(Link)`
text-decoration: none;
${props => styles}
`
================================================
FILE: demo/src/components/Docs/index.js
================================================
import React, { Component } from 'react'
import styled from 'styled-components'
import Highlight from '../Highlight'
import Multi from '../Samples/Multiple'
import multiCode from '../Samples/Multiple/index.txt'
import Linked from '../Samples/Linked'
import linkedCode from '../Samples/Linked/index.txt'
import Custom from '../Samples/Custom'
import Threaded from '../Samples/Threaded'
import Touch from '../Samples/Touch'
import touchCode from '../Samples/Touch/index.txt'
const Container = styled.main`
margin: 0 auto;
padding-top: 16px;
padding-bottom: 64px;
max-width: 700px;
`
const SourceLink = styled.a`
display: block;
margin-top: 8px;
font-size: 18px;
text-align: center;
text-decoration: none;
`
export default class Docs extends Component {
render () {
return (
Multiple Type/Shape Support
{multiCode}
Controlled Active Annotations
Hover over the text items above and notice how it triggers the active status of their respective annotations
{linkedCode}
Custom Renderers/Components/Styles
View source
Threaded Comments (Custom Content Overlay)
View source
Touch support
{touchCode}
)
}
}
================================================
FILE: demo/src/components/Footer/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Footer = styled.div`
color: #666;
padding: 16px;
padding-bottom: 32px;
text-align: center;
a {
color: inherit;
text-decoration: none;
&:hover {
color: #222;
}
}
`
export default () => (
)
================================================
FILE: demo/src/components/GithubStarLink/index.js
================================================
import React from 'react'
export default () => (
Star
)
================================================
FILE: demo/src/components/Highlight/index.js
================================================
import React from 'react'
import SyntaxHighlighter from 'react-syntax-highlighter/prism-light'
import prism from 'react-syntax-highlighter/styles/prism/prism'
export default (props) => (
{props.children}
)
================================================
FILE: demo/src/components/Home/index.js
================================================
import React, { Component } from 'react'
import styled from 'styled-components'
import Simple from '../Samples/Simple'
import Highlight from '../Highlight'
import GithubStarLink from '../GithubStarLink'
import { ButtonLink } from '../Button'
import simple from './simple.txt'
const Hero = styled.div`
text-align: center;
`
const Title = styled.h1`
font-size: 36px;
text-align: center;
`
const Subtitle = styled.p`
font-size: 20px;
text-align: center;
`
const Container = styled.main`
margin: 0 auto;
padding: 64px 0;
max-width: 700px;
`
const GithubButton = styled.div`
margin-bottom: 16px;
`
export default class App extends Component {
render () {
return (
React Image Annotation
An infinitely customizable image annotation library built on React
More Examples
Install
npm install --save react-image-annotation
Demo
{simple}
)
}
}
================================================
FILE: demo/src/components/Home/simple.txt
================================================
import React, { Component } from 'react'
import Annotation from 'react-image-annotation'
export default class Simple extends Component {
state = {
annotations: [],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
render () {
return (
)
}
}
================================================
FILE: demo/src/components/NavBar/index.js
================================================
import React from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
const Header = styled.header`
background-color: #fcfcfc;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.25);
box-sizing: border-box;
width: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
`
const Items = styled.div`
margin: 0 auto;
max-width: 720px;
display: table;
`
const Item = styled.div`
display: table-cell;
padding: 16px 0;
${props => props.grow && `
width: 100%;
`}
a {
color: black;
text-decoration: none;
padding: 16px;
transition:
background 0.1s ease,
color 0.21s ease;
&:hover {
background: #dadada;
color: white;
}
}
`
const Title = styled(Link)`
margin-right: 16px;
`
export default (props) => (
)
================================================
FILE: demo/src/components/Root/index.js
================================================
import styled from 'styled-components'
export default styled.div`
font-family: 'Open Sans', sans-serif;
margin: 0 auto;
font-size: 14px;
h1, h2, h3, h4, h5, h6 {
font-family: 'Montserrat', sans-serif;
}
input {
font-family: 'Open Sans', sans-serif;
}
`
================================================
FILE: demo/src/components/Samples/Custom/index.js
================================================
import React, { Component } from 'react'
import Annotation from '../../../../../src'
import {
PointSelector,
RectangleSelector,
OvalSelector
} from '../../../../../src/selectors'
import Button from '../../Button'
import mocks from '../../../mocks'
import img from '../../../img.jpeg'
const Box = ({ children, geometry, style }) => (
{children}
)
function renderSelector ({ annotation, active }) {
const { geometry } = annotation
if (!geometry) return null
return (
Custom Selector
)
}
function renderHighlight ({ annotation, active }) {
const { geometry } = annotation
if (!geometry) return null
return (
Custom Highlight
)
}
function renderContent ({ annotation }) {
const { geometry } = annotation
return (
Custom Content
{annotation.data && annotation.data.text}
)
}
function renderEditor (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
function renderOverlay () {
return (
Custom Overlay
)
}
export default class Custom extends Component {
state = {
annotations: [mocks.annotations[0]],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
onChangeType = (e) => {
this.setState({
annotation: {},
type: e.currentTarget.innerHTML
})
}
render () {
return (
)
}
}
================================================
FILE: demo/src/components/Samples/Linked/index.js
================================================
import React, { Component } from 'react'
import styled from 'styled-components'
import Annotation from '../../../../../src'
import Root from '../../Root'
import img from '../../../img.jpeg'
const Comments = styled.div`
border: 1px solid black;
max-height: 80px;
overflow: auto;
`
const Comment = styled.div`
padding: 8px;
&:nth-child(even) {
background: rgba(0, 0, 0, .05);
}
&:hover {
background: #ececec;
}
`
export default class Linked extends Component {
state = {
activeAnnotations: [],
annotations: [
{
data: {text: 'Hello!', id: 0.5986265691759928},
geometry: {type: 'RECTANGLE', x: 25.571428571428573, y: 33, width: 21.142857142857142, height: 34}
},
{
data: {text: 'Hi!', id: 0.5986265691759929},
geometry: {type: 'RECTANGLE', x: 50.571428571428573, y: 33, width: 21.142857142857142, height: 34}
}
],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
onMouseOver = (id) => e => {
this.setState({
activeAnnotations: [
...this.state.activeAnnotations,
id
]
})
}
onMouseOut = (id) => e => {
const index = this.state.activeAnnotations.indexOf(id)
this.setState({
activeAnnotations: [
...this.state.activeAnnotations.slice(0, index),
...this.state.activeAnnotations.slice(index + 1)
]
})
}
activeAnnotationComparator = (a, b) => {
return a.data.id === b
}
render () {
return (
Annotations
{this.state.annotations.map(annotation => (
{annotation.data.text}
))}
)
}
}
================================================
FILE: demo/src/components/Samples/Linked/index.txt
================================================
classs React extends Component {
state = {
activeAnnotations: []
}
// ...other React code
onMouseOver = (id) => e => {
this.setState({
activeAnnotations: [
...this.state.activeAnnotations,
id
]
})
}
onMouseOut = (id) => e => {
const index = this.state.activeAnnotations.indexOf(id)
this.setState({
activeAnnotations: [
...this.state.activeAnnotations.slice(0, index),
...this.state.activeAnnotations.slice(index + 1)
]
})
}
activeAnnotationComparator = (a, b) => {
return a.data.id === b
}
render () {
return (
Annotations
{this.state.annotations.map(annotation => (
{annotation.data.text}
))}
)
}
================================================
FILE: demo/src/components/Samples/Multiple/index.js
================================================
import React, { Component } from 'react'
import Annotation from '../../../../../src'
import {
PointSelector,
RectangleSelector,
OvalSelector
} from '../../../../../src/selectors'
import Button from '../../Button'
import mocks from '../../../mocks'
import img from '../../../img.jpeg'
export default class Multiple extends Component {
state = {
type: RectangleSelector.TYPE,
annotations: mocks.annotations,
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
onChangeType = (e) => {
this.setState({
annotation: {},
type: e.currentTarget.innerHTML
})
}
render () {
return (
{RectangleSelector.TYPE}
{PointSelector.TYPE}
{OvalSelector.TYPE}
)
}
}
================================================
FILE: demo/src/components/Samples/Multiple/index.txt
================================================
import React, { Component } from 'react'
import Annotation from 'react-image-annotation'
import {
PointSelector,
RectangleSelector,
OvalSelector
} from 'react-image-annotation/lib/selectors'
export default class Multiple extends Component {
state = {
type: RectangleSelector.TYPE,
annotations: mocks.annotations,
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
onChangeType = (e) => {
this.setState({
annotation: {},
type: e.currentTarget.innerHTML
})
}
render () {
return (
{RectangleSelector.TYPE}
{PointSelector.TYPE}
{OvalSelector.TYPE}
)
}
}
================================================
FILE: demo/src/components/Samples/Simple/index.js
================================================
import React, { Component } from 'react'
import Annotation from '../../../../../src'
import Root from '../../Root'
import img from '../../../img.jpeg'
export default class Simple extends Component {
state = {
annotations: [],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
render () {
return (
)
}
}
================================================
FILE: demo/src/components/Samples/Threaded/index.js
================================================
import React, { Component } from 'react'
import Annotation from '../../../../../src'
import styled, { keyframes } from 'styled-components'
import {
RectangleSelector
} from '../../../../../src/selectors'
import TextEditor from '../../../../../src/components/TextEditor'
import Root from '../../Root'
import img from '../../../img.jpeg'
/*
* You would normally have the different components here
* split into different files but I am
* putting it in one file so it's easier to skim
*/
const Content = styled.div`
background: white;
border-radius: 2px;
box-shadow:
0px 1px 5px 0px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 3px 1px -2px rgba(0, 0, 0, 0.12);
margin: 8px 0;
`
const ContentClearanceTop = styled.div`
position: absolute;
height: 8px;
top: -8px;
left: -17px;
right: -17px;
`
const ContentClearanceLeft = styled.div`
position: absolute;
height: 100%;
left: -17px;
width: 20px;
`
const ContentClearanceRight = styled.div`
position: absolute;
height: 100%;
right: 0px;
width: 20px;
`
const fadeInScale = keyframes`
from {
opacity: 0;
transform: scale(0);
}
to {
opacity: 1;
transform: scale(1);
}
`
const EditorContainer = styled.div`
background: white;
border-radius: 2px;
box-shadow:
0px 1px 5px 0px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 3px 1px -2px rgba(0, 0, 0, 0.12);
margin-top: 16px;
transform-origin: top left;
animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275);
overflow: hidden;
`
const Comment = styled.div`
border-bottom: 1px solid whitesmoke;
padding: 8px 16px;
`
const CommentDescription = styled.div`
margin: 10px 0;
`
const UserPill = styled.span`
background-color: #2FB3C6;
border-radius: 4px;
color: white;
padding: 2px 4px;
font-size: 13.5px;
`
class ThreadedEditor extends Component {
state = { text: '' }
onUpdateText = (e) => {
const { props } = this
// This is the purest (native es6) way to do this
// You can use a library such as redux and/or
// lodash/ramda to make this cleaner
props.onChange({
...props.annotation,
data: {
...props.annotation.data,
comments: [
this.props.annotation.data
? {
...this.props.annotation.data.comments[0],
text: e.target.value
}
: {
id: Math.random(),
text: e.target.value
}
]
}
})
}
render () {
const { props } = this
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
}
class ThreadedContent extends Component {
state = {
editorText: ''
}
onUpdateEditorText = (e) => {
this.setState({ editorText: e.target.value })
}
renderComment (comment) {
return (
{comment.text}
User
)
}
render () {
const { props } = this
const { annotation } = props
const { geometry } = annotation
const comments = annotation.data && annotation.data.comments
return (
{(comments) && comments.map(this.renderComment)}
{
const annotationIndex = props.annotations.indexOf(annotation)
const annotations = props.annotations.map((annotation, i) => (
i === annotationIndex
? {
...annotation,
data: {
...annotation.data,
comments: [
...comments,
{ id: Math.random(), text: this.state.editorText }
]
}
}
: annotation
))
this.setState({ editorText: '' })
props.setAnnotations(annotations)
}}
/>
)
}
}
export default class Threaded extends Component {
state = {
activeAnnotations: [],
annotations: [],
annotation: {}
}
onChange = (annotation) => {
this.setState({ annotation })
}
onSubmit = (annotation) => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
renderEditor = (props) => {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
renderContent = ({ key, annotation }) => {
return (
this.setState({ annotations })}
onFocus={this.onFocus(key)}
onBlur={this.onBlur(key)}
/>
)
}
onFocus = (id) => e => {
this.setState({
activeAnnotations: [
...this.state.activeAnnotations,
id
]
})
}
onBlur = (id) => e => {
const index = this.state.activeAnnotations.indexOf(id)
this.setState({
activeAnnotations: [
...this.state.activeAnnotations.slice(0, index),
...this.state.activeAnnotations.slice(index + 1)
]
})
}
activeAnnotationComparator = (a, b) => {
return a.data.id === b
}
render () {
return (
)
}
}
================================================
FILE: demo/src/components/Samples/Touch/index.js
================================================
import React, { Component } from 'react'
import Annotation from '../../../../../src'
import {
PointSelector,
RectangleSelector,
OvalSelector
} from '../../../../../src/selectors'
import Button from '../../Button'
import mocks from '../../../mocks'
import img from '../../../img.jpeg'
export default class Multiple extends Component {
state = {
type: RectangleSelector.TYPE,
annotations: mocks.annotations,
annotation: {},
allowTouch: true
}
onChange = annotation => {
this.setState({ annotation })
}
onSubmit = annotation => {
const { geometry, data } = annotation
this.setState({
annotation: {},
annotations: this.state.annotations.concat({
geometry,
data: {
...data,
id: Math.random()
}
})
})
}
onChangeType = e => {
this.setState({
annotation: {},
type: e.currentTarget.innerHTML
})
}
toggleAllowTouch = () => {
this.setState(prevState => ({ allowTouch: !prevState.allowTouch }))
}
render() {
return (
{this.state.allowTouch
? 'Stop allowing touch'
: 'Start allowing touch'}
{RectangleSelector.TYPE}
{PointSelector.TYPE}
{OvalSelector.TYPE}
)
}
}
================================================
FILE: demo/src/components/Samples/Touch/index.txt
================================================
class Touch extends Component {
state = { allowTouch: true }
toggleAllowTouch = () => {
this.setState((prevState) => (
{allowTouch: !prevState.allowTouch}
))
}
render () {
return (
{this.state.allowTouch
? "Stop allowing touch"
: "Start allowing touch"
}
)
}
}
================================================
FILE: demo/src/index.css
================================================
@import url('https://fonts.googleapis.com/css?family=Montserrat:700|Open+Sans');
html, body {
margin: 0;
}
================================================
FILE: demo/src/index.html
================================================
<%= htmlWebpackPlugin.options.title %>
================================================
FILE: demo/src/index.js
================================================
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
import registerServiceWorker from './registerServiceWorker'
import { registerLanguage } from 'react-syntax-highlighter/prism-light'
import jsx from 'react-syntax-highlighter/languages/prism/jsx'
registerLanguage('jsx', jsx)
ReactDOM.render( , document.getElementById('demo'))
registerServiceWorker()
================================================
FILE: demo/src/mocks.js
================================================
import {
RectangleSelector,
OvalSelector
} from '../../src/selectors'
export default {
annotations: [
{
geometry:
{
type: RectangleSelector.TYPE,
x: 25,
y: 31,
width: 21,
height: 35
},
data: {
text: 'Annotate!',
id: 1
}
},
{
geometry:
{
type: OvalSelector.TYPE,
x: 53,
y: 33,
width : 17.5,
height: 28
},
data: {
text: 'Supports custom shapes too!',
id: 2
}
}
]
}
================================================
FILE: demo/src/registerServiceWorker.js
================================================
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export default function register () {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl)
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl)
}
})
}
}
function registerValidSW (swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.')
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
}
}
}
}
})
.catch(error => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker (swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl)
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister () {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister()
})
}
}
================================================
FILE: nwb.config.js
================================================
const path = require('path')
module.exports = {
type: 'react-component',
npm: {
esModules: true,
umd: {
global: 'ReactImageAnnotation',
externals: {
react: 'React'
}
}
},
webpack: {
html: {
template: 'demo/src/index.html'
},
extra: {
module: {
rules: [
{test: /\.txt/, loader: 'raw-loader'}
]
}
}
},
karma: {
testContext: 'tests/index.test.js'
}
}
================================================
FILE: package.json
================================================
{
"name": "react-image-annotation",
"version": "0.9.10",
"description": "react-image-annotation React component",
"author": "Arian Allenson Valdez (http://arianv.com/)",
"main": "lib/index.js",
"module": "es/index.js",
"files": [
"css",
"es",
"lib",
"umd"
],
"scripts": {
"build": "nwb build-react-component",
"deploy": "gh-pages -d demo/dist",
"clean": "nwb clean-module && nwb clean-demo",
"start": "nwb serve-react-demo",
"test": "nwb test-react",
"test:coverage": "nwb test-react --coverage",
"test:watch": "nwb test-react --server"
},
"dependencies": {
"styled-components": "^3.1.6"
},
"peerDependencies": {
"prop-types": "^15.6.0",
"react": "^16.3",
"react-dom": ">=0.14"
},
"devDependencies": {
"chai": "^4.1.2",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"gh-pages": "^1.1.0",
"nwb": "0.21.x",
"raw-loader": "^0.5.1",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-syntax-highlighter": "^7.0.0",
"standard": "^10.0.3"
},
"standard": {
"env": [
"jest",
"jasmine"
],
"globals": [
"fetch",
"URL"
],
"parser": "babel-eslint"
},
"homepage": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Secretmapper/react-image-annotation"
},
"keywords": [
"react-component"
]
}
================================================
FILE: public/404.html
================================================
Single Page Apps for GitHub Pages
================================================
FILE: src/components/Annotation.js
================================================
import React, { Component } from 'react'
import T from 'prop-types'
import styled from 'styled-components'
import compose from '../utils/compose'
import isMouseHovering from '../utils/isMouseHovering'
import withRelativeMousePos from '../utils/withRelativeMousePos'
import defaultProps from './defaultProps'
import Overlay from './Overlay'
const Container = styled.div`
clear: both;
position: relative;
width: 100%;
&:hover ${Overlay} {
opacity: 1;
}
touch-action: ${(props) => (props.allowTouch ? "pinch-zoom" : "auto")};
`
const Img = styled.img`
display: block;
width: 100%;
`
const Items = styled.div`
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
`
const Target = Items
export default compose(
isMouseHovering(),
withRelativeMousePos()
)(class Annotation extends Component {
static propTypes = {
innerRef: T.func,
onMouseUp: T.func,
onMouseDown: T.func,
onMouseMove: T.func,
onClick: T.func,
children: T.object,
annotations: T.arrayOf(
T.shape({
type: T.string
})
).isRequired,
type: T.string,
selectors: T.arrayOf(
T.shape({
TYPE: T.string,
intersects: T.func.isRequired,
area: T.func.isRequired,
methods: T.object.isRequired
})
).isRequired,
value: T.shape({
selection: T.object,
geometry: T.shape({
type: T.string.isRequired
}),
data: T.object
}),
onChange: T.func,
onSubmit: T.func,
activeAnnotationComparator: T.func,
activeAnnotations: T.arrayOf(T.any),
disableAnnotation: T.bool,
disableSelector: T.bool,
renderSelector: T.func,
disableEditor: T.bool,
renderEditor: T.func,
renderHighlight: T.func.isRequired,
renderContent: T.func.isRequired,
disableOverlay: T.bool,
renderOverlay: T.func.isRequired,
allowTouch: T.bool
}
static defaultProps = defaultProps
targetRef = React.createRef();
componentDidMount() {
if (this.props.allowTouch) {
this.addTargetTouchEventListeners();
}
}
addTargetTouchEventListeners = () => {
// Safari does not recognize touch-action CSS property,
// so we need to call preventDefault ourselves to stop touch from scrolling
// Event handlers must be set via ref to enable e.preventDefault()
// https://github.com/facebook/react/issues/9809
this.targetRef.current.ontouchstart = this.onTouchStart;
this.targetRef.current.ontouchend = this.onTouchEnd;
this.targetRef.current.ontouchmove = this.onTargetTouchMove;
this.targetRef.current.ontouchcancel = this.onTargetTouchLeave;
}
removeTargetTouchEventListeners = () => {
this.targetRef.current.ontouchstart = undefined;
this.targetRef.current.ontouchend = undefined;
this.targetRef.current.ontouchmove = undefined;
this.targetRef.current.ontouchcancel = undefined;
}
componentDidUpdate(prevProps) {
if (this.props.allowTouch !== prevProps.allowTouch) {
if (this.props.allowTouch) {
this.addTargetTouchEventListeners()
} else {
this.removeTargetTouchEventListeners()
}
}
}
setInnerRef = (el) => {
this.container = el
this.props.relativeMousePos.innerRef(el)
this.props.innerRef(el)
}
getSelectorByType = (type) => {
return this.props.selectors.find(s => s.TYPE === type)
}
getTopAnnotationAt = (x, y) => {
const { annotations } = this.props
const { container, getSelectorByType } = this
if (!container) return
const intersections = annotations
.map(annotation => {
const { geometry } = annotation
const selector = getSelectorByType(geometry.type)
return selector.intersects({ x, y }, geometry, container)
? annotation
: false
})
.filter(a => !!a)
.sort((a, b) => {
const aSelector = getSelectorByType(a.geometry.type)
const bSelector = getSelectorByType(b.geometry.type)
return aSelector.area(a.geometry, container) - bSelector.area(b.geometry, container)
})
return intersections[0]
}
onTargetMouseMove = (e) => {
this.props.relativeMousePos.onMouseMove(e)
this.onMouseMove(e)
}
onTargetTouchMove = (e) => {
this.props.relativeMousePos.onTouchMove(e)
this.onTouchMove(e)
}
onTargetMouseLeave = (e) => {
this.props.relativeMousePos.onMouseLeave(e)
}
onTargetTouchLeave = (e) => {
this.props.relativeMousePos.onTouchLeave(e)
}
onMouseUp = (e) => this.callSelectorMethod('onMouseUp', e)
onMouseDown = (e) => this.callSelectorMethod('onMouseDown', e)
onMouseMove = (e) => this.callSelectorMethod('onMouseMove', e)
onTouchStart = (e) => this.callSelectorMethod("onTouchStart", e)
onTouchEnd = (e) => this.callSelectorMethod("onTouchEnd", e)
onTouchMove = (e) => this.callSelectorMethod("onTouchMove", e)
onClick = (e) => this.callSelectorMethod('onClick', e)
onSubmit = () => {
this.props.onSubmit(this.props.value)
}
callSelectorMethod = (methodName, e) => {
if (this.props.disableAnnotation) {
return
}
if (!!this.props[methodName]) {
this.props[methodName](e)
} else {
const selector = this.getSelectorByType(this.props.type)
if (selector && selector.methods[methodName]) {
const value = selector.methods[methodName](this.props.value, e)
if (typeof value === 'undefined') {
if (process.env.NODE_ENV !== 'production') {
console.error(`
${methodName} of selector type ${this.props.type} returned undefined.
Make sure to explicitly return the previous state
`)
}
} else {
this.props.onChange(value)
}
}
}
}
shouldAnnotationBeActive = (annotation, top) => {
if (this.props.activeAnnotations) {
const isActive = !!this.props.activeAnnotations.find(active => (
this.props.activeAnnotationComparator(annotation, active)
))
return isActive || top === annotation
} else {
return top === annotation
}
}
render () {
const { props } = this
const {
isMouseHovering,
renderHighlight,
renderContent,
renderSelector,
renderEditor,
renderOverlay,
allowTouch
} = props
const topAnnotationAtMouse = this.getTopAnnotationAt(
this.props.relativeMousePos.x,
this.props.relativeMousePos.y
)
return (
{props.annotations.map(annotation => (
renderHighlight({
key: annotation.data.id,
annotation,
active: this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse)
})
))}
{!props.disableSelector
&& props.value
&& props.value.geometry
&& (
renderSelector({
annotation: props.value
})
)
}
{!props.disableOverlay && (
renderOverlay({
type: props.type,
annotation: props.value
})
)}
{props.annotations.map(annotation => (
this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse)
&& (
renderContent({
key: annotation.data.id,
annotation: annotation
})
)
))}
{!props.disableEditor
&& props.value
&& props.value.selection
&& props.value.selection.showEditor
&& (
renderEditor({
annotation: props.value,
onChange: props.onChange,
onSubmit: this.onSubmit
})
)
}
{props.children}
)
}
})
================================================
FILE: src/components/Content/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Container = styled.div`
background: white;
border-radius: 2px;
box-shadow:
0px 1px 5px 0px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 3px 1px -2px rgba(0, 0, 0, 0.12);
padding: 8px 16px;
margin-top: 8px;
margin-left: 8px;
`
function Content (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
{props.annotation.data && props.annotation.data.text}
)
}
Content.defaultProps = {
style: {},
className: ''
}
export default Content
================================================
FILE: src/components/Editor/index.js
================================================
import React from 'react'
import styled, { keyframes } from 'styled-components'
import TextEditor from '../TextEditor'
const fadeInScale = keyframes`
from {
opacity: 0;
transform: scale(0);
}
to {
opacity: 1;
transform: scale(1);
}
`
const Container = styled.div`
background: white;
border-radius: 2px;
box-shadow:
0px 1px 5px 0px rgba(0, 0, 0, 0.2),
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
0px 3px 1px -2px rgba(0, 0, 0, 0.12);
margin-top: 16px;
transform-origin: top left;
animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275);
overflow: hidden;
`
function Editor (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
props.onChange({
...props.annotation,
data: {
...props.annotation.data,
text: e.target.value
}
})}
onSubmit={props.onSubmit}
value={props.annotation.data && props.annotation.data.text}
/>
)
}
Editor.defaultProps = {
className: '',
style: {}
}
export default Editor
================================================
FILE: src/components/FancyRectangle/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Box = styled.div`
background: rgba(0, 0, 0, 0.2);
position: absolute;
`
const Container = styled.div`
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
`
function FancyRectangle (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
FancyRectangle.defaultProps = {
className: '',
style: {}
}
export default FancyRectangle
================================================
FILE: src/components/Oval/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Container = styled.div`
border: dashed 2px black;
border-radius: 100%;
box-shadow: 0px 0px 1px 1px white inset;
box-sizing: border-box;
transition: box-shadow 0.21s ease-in-out;
`
function Oval (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
Oval.defaultProps = {
className: '',
style: {}
}
export default Oval
================================================
FILE: src/components/Overlay/index.js
================================================
import React from 'react'
import styled from 'styled-components'
export default styled.div`
background: rgba(0, 0, 0, .4);
border-radius: 5px;
bottom: 4px;
color: white;
font-size: 12px;
font-weight: bold;
opacity: 0;
padding: 10px;
pointer-events: none;
position: absolute;
right: 4px;
transition: opacity 0.21s ease-in-out;
user-select: none;
`
================================================
FILE: src/components/Point/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Container = styled.div`
border: solid 3px white;
border-radius: 50%;
box-sizing: border-box;
box-shadow:
0 0 0 1px rgba(0,0,0,0.3),
0 0 0 2px rgba(0,0,0,0.2),
0 5px 4px rgba(0,0,0,0.4);
height: 16px;
position: absolute;
transform: translate3d(-50%, -50%, 0);
width: 16px;
`
function Point (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
export default Point
================================================
FILE: src/components/Rectangle/index.js
================================================
import React from 'react'
import styled from 'styled-components'
const Container = styled.div`
border: dashed 2px black;
box-shadow: 0px 0px 1px 1px white inset;
box-sizing: border-box;
transition: box-shadow 0.21s ease-in-out;
`
function Rectangle (props) {
const { geometry } = props.annotation
if (!geometry) return null
return (
)
}
Rectangle.defaultProps = {
className: '',
style: {}
}
export default Rectangle
================================================
FILE: src/components/TextEditor/index.js
================================================
import React from 'react'
import styled, { keyframes } from 'styled-components'
const Inner = styled.div`
padding: 8px 16px;
textarea {
border: 0;
font-size: 14px;
margin: 6px 0;
min-height: 60px;
outline: 0;
}
`
const Button = styled.div`
background: whitesmoke;
border: 0;
box-sizing: border-box;
color: #363636;
cursor: pointer;
font-size: 1rem;
margin: 0;
outline: 0;
padding: 8px 16px;
text-align: center;
text-shadow: 0 1px 0 rgba(0,0,0,0.1);
width: 100%;
transition: background 0.21s ease-in-out;
&:focus, &:hover {
background: #eeeeee;
}
`
function TextEditor (props) {
return (
{props.value && (
Submit
)}
)
}
export default TextEditor
================================================
FILE: src/components/defaultProps.js
================================================
import React from 'react'
import Point from './Point'
import Editor from './Editor'
import FancyRectangle from './FancyRectangle'
import Rectangle from './Rectangle'
import Oval from './Oval'
import Content from './Content'
import Overlay from './Overlay'
import {
RectangleSelector,
PointSelector,
OvalSelector
} from '../selectors'
export default {
innerRef: () => {},
onChange: () => {},
onSubmit: () => {},
type: RectangleSelector.TYPE,
selectors: [
RectangleSelector,
PointSelector,
OvalSelector
],
disableAnnotation: false,
disableSelector: false,
disableEditor: false,
disableOverlay: false,
activeAnnotationComparator: (a, b) => a === b,
renderSelector: ({ annotation }) => {
switch (annotation.geometry.type) {
case RectangleSelector.TYPE:
return (
)
case PointSelector.TYPE:
return (
)
case OvalSelector.TYPE:
return (
)
default:
return null
}
},
renderEditor: ({ annotation, onChange, onSubmit }) => (
),
renderHighlight: ({ key, annotation, active }) => {
switch (annotation.geometry.type) {
case RectangleSelector.TYPE:
return (
)
case PointSelector.TYPE:
return (
)
case OvalSelector.TYPE:
return (
)
default:
return null
}
},
renderContent: ({ key, annotation }) => (
),
renderOverlay: ({ type, annotation }) => {
switch (type) {
case PointSelector.TYPE:
return (
Click to Annotate
)
default:
return (
Click and Drag to Annotate
)
}
}
}
================================================
FILE: src/hocs/OvalSelector.js
================================================
import { getCoordPercentage } from '../utils/offsetCoordinates';
const square = n => Math.pow(n, 2)
export const TYPE = 'OVAL'
export function intersects({ x, y }, geometry) {
const rx = geometry.width / 2
const ry = geometry.height / 2
const h = geometry.x + rx
const k = geometry.y + ry
const value = square(x - h) / square(rx) + square(y - k) / square(ry)
return value <= 1
}
export function area(geometry) {
const rx = geometry.width / 2
const ry = geometry.height / 2
return Math.PI * rx * ry
}
export const methods = {
onTouchStart(annotation, e) {
return pointerDown(annotation, e)
},
onTouchEnd(annotation, e) {
return pointerUp(annotation, e)
},
onTouchMove(annotation, e) {
return pointerMove(annotation, e)
},
onMouseDown(annotation, e) {
return pointerDown(annotation, e)
},
onMouseUp(annotation, e) {
return pointerUp(annotation, e)
},
onMouseMove(annotation, e) {
return pointerMove(annotation, e)
}
}
function pointerDown(annotation, e) {
if (!annotation.selection) {
const { x: anchorX, y: anchorY } = getCoordPercentage(e)
return {
...annotation,
selection: {
...annotation.selection,
mode: 'SELECTING',
anchorX,
anchorY
}
}
} else {
return {}
}
return annotation
}
function pointerUp(annotation, e) {
if (annotation.selection) {
const { selection, geometry } = annotation
if (!geometry) {
return {}
}
switch (annotation.selection.mode) {
case 'SELECTING':
return {
...annotation,
selection: {
...annotation.selection,
showEditor: true,
mode: 'EDITING'
}
}
default:
break
}
}
return annotation
}
function pointerMove(annotation, e) {
if (annotation.selection && annotation.selection.mode === 'SELECTING') {
const { anchorX, anchorY } = annotation.selection
const { x: newX, y: newY } = getCoordPercentage(e)
const width = newX - anchorX
const height = newY - anchorY
return {
...annotation,
geometry: {
...annotation.geometry,
type: TYPE,
x: width > 0 ? anchorX : newX,
y: height > 0 ? anchorY : newY,
width: Math.abs(width),
height: Math.abs(height)
}
}
}
return annotation
}
export default {
TYPE,
intersects,
area,
methods
}
================================================
FILE: src/hocs/PointSelector.js
================================================
import { getCoordPercentage } from '../utils/offsetCoordinates';
const MARGIN = 6
const marginToPercentage = (container) => ({
marginX: MARGIN / container.width * 100,
marginY: MARGIN / container.height * 100
})
export const TYPE = 'POINT'
export function intersects ({ x, y }, geometry, container) {
const { marginX, marginY } = marginToPercentage(container)
if (x < geometry.x - marginX) return false
if (y < geometry.y - marginY) return false
if (x > geometry.x + marginX) return false
if (y > geometry.y + marginY) return false
return true
}
export function area (geometry, container) {
const { marginX, marginY } = marginToPercentage(container)
return marginX * marginY
}
export const methods = {
onClick (annotation, e) {
if (!annotation.geometry) {
return {
...annotation,
selection: {
...annotation.selection,
showEditor: true,
mode: 'EDITING'
},
geometry: {
...annotation.geometry,
...getCoordPercentage(e),
width: 0,
height: 0,
type: TYPE,
}
}
} else{
return {}
}
}
}
export default {
TYPE,
intersects,
area,
methods
}
================================================
FILE: src/hocs/RectangleSelector.js
================================================
import { getCoordPercentage } from '../utils/offsetCoordinates';
export const TYPE = 'RECTANGLE'
export function intersects({ x, y }, geometry) {
if (x < geometry.x) return false
if (y < geometry.y) return false
if (x > geometry.x + geometry.width) return false
if (y > geometry.y + geometry.height) return false
return true
}
export function area(geometry) {
return geometry.height * geometry.width
}
export const methods = {
onTouchStart(annotation, e) {
return pointerDown(annotation, e)
},
onTouchEnd(annotation, e) {
return pointerUp(annotation, e)
},
onTouchMove(annotation, e) {
return pointerMove(annotation, e)
},
onMouseDown(annotation, e) {
return pointerDown(annotation, e)
},
onMouseUp(annotation, e) {
return pointerUp(annotation, e)
},
onMouseMove(annotation, e) {
return pointerMove(annotation, e)
}
}
function pointerDown(annotation, e) {
if (!annotation.selection) {
const { x: anchorX, y: anchorY } = getCoordPercentage(e)
return {
...annotation,
selection: {
...annotation.selection,
mode: 'SELECTING',
anchorX,
anchorY
}
}
} else {
return {}
}
}
function pointerUp(annotation, e) {
if (annotation.selection) {
const { selection, geometry } = annotation
if (!geometry) {
return {}
}
switch (annotation.selection.mode) {
case 'SELECTING':
return {
...annotation,
selection: {
...annotation.selection,
showEditor: true,
mode: 'EDITING'
}
}
default:
break
}
}
return annotation
}
function pointerMove(annotation, e) {
if (annotation.selection && annotation.selection.mode === 'SELECTING') {
const { anchorX, anchorY } = annotation.selection
const { x: newX, y: newY } = getCoordPercentage(e)
const width = newX - anchorX
const height = newY - anchorY
return {
...annotation,
geometry: {
...annotation.geometry,
type: TYPE,
x: width > 0 ? anchorX : newX,
y: height > 0 ? anchorY : newY,
width: Math.abs(width),
height: Math.abs(height)
}
}
}
return annotation
}
export default {
TYPE,
intersects,
area,
methods
}
================================================
FILE: src/index.js
================================================
import Annotation from './components/Annotation'
export { default as defaultProps } from './components/defaultProps'
export default Annotation
================================================
FILE: src/selectors.js
================================================
export { default as RectangleSelector } from './hocs/RectangleSelector'
export { default as PointSelector } from './hocs/PointSelector'
export { default as OvalSelector } from './hocs/OvalSelector'
================================================
FILE: src/types/index.d.ts
================================================
declare module "react-image-annotation" {
export interface IGeometry {
type: string;
x?: number;
y?: number;
height?: number;
width?: number;
}
export interface ISelector {
TYPE: string;
intersects: (
{ x, y }: { x: number; y: number },
geometry: IGeometry,
container: { width: number; height: number }
) => boolean;
area: (
geometry: IGeometry,
container: { width: number; height: number }
) => number;
methods: {
onMouseUp?: (annotation: IAnnotation, e: any) => IAnnotation | {};
onMouseDown?: (annotation: IAnnotation, e: any) => IAnnotation | {};
onMouseMove?: (annotation: IAnnotation, e: any) => IAnnotation | {};
onClick?: (annotation: IAnnotation, e: any) => IAnnotation | {};
};
}
export interface IAnnotation {
selection?: {
mode: string;
showEditor: boolean;
};
geometry: IGeometry;
data: {
text: string;
id?: number;
};
}
interface IAnnotationProps {
src: string;
alt?: string;
innerRef?: (e: any) => any;
onMouseUp?: (e: React.MouseEvent) => any;
onMouseDown?: (e: React.MouseEvent) => any;
onMouseMove?: (e: React.MouseEvent) => any;
onClick?: (e: React.MouseEvent) => any;
annotations: IAnnotation[];
type?: string;
selectors?: ISelector[];
value: IAnnotation | {};
onChange?: (e: any) => any;
onSubmit?: (e: any) => any;
activeAnnotationComparator?: (annotation: IAnnotation) => boolean;
activeAnnotations?: IAnnotation[];
disableAnnotation?: boolean;
disableSelector?: boolean;
renderSelector?: (
{ annotation, active }: { annotation: IAnnotation; active: boolean }
) => any;
disableEditor?: boolean;
renderEditor?: (
{
annotation,
onChange,
onSubmit
}: {
annotation: IAnnotation;
onChange: (annotation: IAnnotation | {}) => any;
onSubmit: (e?: any) => any;
}
) => any;
renderHighlight?: (
{ annotation, active }: { annotation: IAnnotation; active: boolean }
) => any;
renderContent?: ({ annotation }: { annotation: IAnnotation }) => any;
disableOverlay?: boolean;
renderOverlay?: () => any;
allowTouch: boolean;
}
class Annotation extends React.Component {}
export default Annotation;
}
================================================
FILE: src/utils/compose.js
================================================
export default function compose (...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
================================================
FILE: src/utils/isMouseHovering.js
================================================
import React, { PureComponent as Component } from 'react'
const isMouseOverElement = ({ elem, e }) => {
const { pageY, pageX } = e
const { left, right, bottom, top } = elem.getBoundingClientRect()
return pageX > left && pageX < right && pageY > top && pageY < bottom
}
const isMouseHovering = (key = 'isMouseHovering') => DecoratedComponent => {
class IsMouseHovering extends Component {
constructor() {
super()
this.state = {
isHoveringOver: false
}
}
componentDidMount() {
document.addEventListener('mousemove', this.onMouseMove)
}
componentWillUnmount() {
document.removeEventListener('mousemove', this.onMouseMove)
}
onMouseMove = e => {
const elem = this.el
this.setState({
isHoveringOver: isMouseOverElement({ elem, e })
})
}
render() {
const hocProps = {
[key]: {
innerRef: el => this.el = el,
isHoveringOver: this.state.isHoveringOver
}
}
return (
)
}
}
IsMouseHovering.displayName = `IsMouseHovering(${DecoratedComponent.displayName})`
return IsMouseHovering
}
export default isMouseHovering
================================================
FILE: src/utils/offsetCoordinates.js
================================================
const getMouseRelativeCoordinates = e => {
// nativeEvent.offsetX gives inconsistent results when dragging
// up and to the left rather than the more natural down and to the
// right. The reason could be browser implementation (it is still experimental)
// or it could be that nativeEvent offsets are based on target rather than
// currentTarget.
// To keep consistent behavior of the selector use the bounding client rect.
const rect = e.currentTarget.getBoundingClientRect();
const offsetX = e.clientX - rect.x;
const offsetY = e.clientY - rect.y;
return {
x: offsetX / rect.width * 100,
y: offsetY / rect.height * 100
};
}
const clamp = (a, b, i) => Math.max(a, Math.min(b, i))
const getTouchRelativeCoordinates = e => {
const touch = e.targetTouches[0]
const boundingRect = e.currentTarget.getBoundingClientRect()
// https://idiallo.com/javascript/element-postion
// https://stackoverflow.com/questions/25630035/javascript-getboundingclientrect-changes-while-scrolling
const offsetX = touch.pageX - boundingRect.left
const offsetY = touch.pageY - (boundingRect.top + window.scrollY)
return {
x: clamp(0, 100, (offsetX / boundingRect.width) * 100),
y: clamp(0, 100, (offsetY / boundingRect.height) * 100)
}
}
const getCoordPercentage = (e) => {
if (isTouchEvent(e)) {
if (isValidTouchEvent(e)) {
isTouchMoveEvent(e) && e.preventDefault()
return getTouchRelativeCoordinates(e)
} else {
return {
x: null
}
}
} else {
return getMouseRelativeCoordinates(e)
}
}
const isTouchEvent = e => e.targetTouches !== undefined
const isValidTouchEvent = e => e.targetTouches.length === 1
const isTouchMoveEvent = e => e.type === 'touchmove'
export { getMouseRelativeCoordinates as getOffsetCoordPercentage, getCoordPercentage };
================================================
FILE: src/utils/withRelativeMousePos.js
================================================
import React, { PureComponent as Component } from 'react'
import { getOffsetCoordPercentage } from './offsetCoordinates';
const withRelativeMousePos = (key = 'relativeMousePos') => DecoratedComponent => {
class WithRelativeMousePos extends Component {
state = { x: null, y: null }
innerRef = el => {
this.container = el
}
onMouseMove = (e) => {
const xystate = getOffsetCoordPercentage(e, this.container);
this.setState(xystate);
}
onTouchMove = (e) => {
if (e.targetTouches.length === 1) {
const touch = e.targetTouches[0]
const offsetX = touch.pageX - this.container.offsetParent.offsetLeft
const offsetY = touch.pageY - this.container.offsetParent.offsetTop
this.setState({
x: (offsetX / this.container.width) * 100,
y: (offsetY / this.container.height) * 100
})
}
}
onMouseLeave = (e) => {
this.setState({ x: null, y: null })
}
onTouchLeave = (e) => {
this.setState({ x: null, y: null })
}
render () {
const hocProps = {
[key]: {
innerRef: this.innerRef,
onMouseMove: this.onMouseMove,
onMouseLeave: this.onMouseLeave,
onTouchMove: this.onTouchMove,
onTouchLeave: this.onTouchLeave,
x: this.state.x,
y: this.state.y
}
}
return (
)
}
}
WithRelativeMousePos.displayName = `withRelativeMousePos(${DecoratedComponent.displayName})`
return WithRelativeMousePos
}
export default withRelativeMousePos
================================================
FILE: tests/.eslintrc
================================================
{
"env": {
"mocha": true
}
}
================================================
FILE: tests/Annotation.spec.js
================================================
import { mount } from 'enzyme'
import { expect } from 'chai'
import React from 'react'
import Annotation from '../src/components/Annotation'
const requiredProps = {
annotations: []
}
describe('Annotation', () => {
describe('render', () => {
it('renders ', () => {
const wrapper = mount( )
expect(wrapper.find('Annotation')).to.have.length(1)
})
})
})
================================================
FILE: tests/index.test.js
================================================
import Enzyme from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
Enzyme.configure({ adapter: new Adapter() })
let context = require.context('./', true, /\.spec\.js$/)
context.keys().forEach(context)
================================================
FILE: tests/selectors/OvalSelector.spec.js
================================================
import { mount } from 'enzyme'
import { expect } from 'chai'
import React from 'react'
import { OvalSelector as selector } from '../../src/selectors'
function createOval ({ x, y, width, height } = { x: 10, y: 10, width: 20, height: 10 }) {
return {
x, y, width, height
}
}
describe('OvalSelector', () => {
describe('TYPE', () => {
it('should be a defined string', () => {
expect(selector.TYPE).to.be.a('string')
})
})
describe('intersects', () => {
it('should return true when point is inside geometry', () => {
expect(
selector.intersects({ x: 15, y: 15 }, createOval())
).to.be.true
const x = 15
const y = 17
expect(
selector.intersects({ x, y }, createOval())
).to.be.true
})
it('should return false when point is outside of geometry', () => {
expect(selector.intersects({ x: 0, y: 0 }, createOval())).to.be.false
expect(selector.intersects({ x: 10, y: 0 }, createOval())).to.be.false
expect(selector.intersects({ x: 0, y: 10 }, createOval())).to.be.false
expect(selector.intersects({ x: 30, y: 30 }, createOval())).to.be.false
})
})
describe('area', () => {
it('should return geometry area', () => {
expect(selector.area(createOval())).to.equal(157.07963267948966)
})
})
describe('methods', () => {
xit('should be defined')
})
})
================================================
FILE: tests/selectors/PointSelector.spec.js
================================================
import { mount } from 'enzyme'
import { expect } from 'chai'
import React from 'react'
import { PointSelector as selector } from '../../src/selectors'
function createPoint ({ x, y } = { x: 10, y: 10 }) {
return { x, y }
}
function createContainer({ width, height } = { width: 100, height: 100 }) {
return { width, height }
}
describe('PoinntSelector', () => {
describe('TYPE', () => {
it('should be a defined string', () => {
expect(selector.TYPE).to.be.a('string')
})
})
describe('intersects', () => {
it('should return true when point is inside geometry', () => {
expect(
selector.intersects({ x: 10, y: 10 }, createPoint(), createContainer())
).to.be.true
})
it('should return false when point is outside of geometry', () => {
expect(selector.intersects({ x: 0, y: 0 }, createPoint(), createContainer())).to.be.false
expect(selector.intersects({ x: 10, y: 0 }, createPoint(), createContainer())).to.be.false
expect(selector.intersects({ x: 0, y: 10 }, createPoint(), createContainer())).to.be.false
expect(selector.intersects({ x: 30, y: 30 }, createPoint(), createContainer())).to.be.false
})
})
describe('area', () => {
it('should return geometry area', () => {
expect(
selector.area(createPoint(), createContainer())
).to.equal(36)
})
it('should return geometry area based on container', () => {
expect(
selector.area(createPoint(), createContainer({ width: 200, height: 200 }))
).to.equal(9)
})
})
describe('methods', () => {
xit('should be defined')
})
})
================================================
FILE: tests/selectors/RectangleSelector.spec.js
================================================
import { mount } from 'enzyme'
import { expect } from 'chai'
import React from 'react'
import { RectangleSelector as selector } from '../../src/selectors'
function createRect ({ x, y, width, height } = { x: 10, y: 10, width: 10, height: 10 }) {
return {
x, y, width, height
}
}
describe('RectangleSelector', () => {
describe('TYPE', () => {
it('should be a defined string', () => {
expect(selector.TYPE).to.be.a('string')
})
})
describe('intersects', () => {
it('should return true when point is on top left of geometry', () => {
expect(
selector.intersects({ x: 10, y: 10 }, createRect())
).to.be.true
})
it('should return true when point is on top right of geometry', () => {
expect(
selector.intersects({ x: 20, y: 10 }, createRect())
).to.be.true
})
it('should return true when point is on bottom left of geometry', () => {
expect(
selector.intersects({ x: 10, y: 20 }, createRect())
).to.be.true
})
it('should return true when point is on bottom right of geometry', () => {
expect(
selector.intersects({ x: 20, y: 20 }, createRect())
).to.be.true
})
it('should return true when point is inside geometry', () => {
expect(
selector.intersects({ x: 15, y: 15 }, createRect())
).to.be.true
})
it('should return false when point is outside of geometry', () => {
expect(selector.intersects({ x: 0, y: 0 }, createRect())).to.be.false
expect(selector.intersects({ x: 10, y: 0 }, createRect())).to.be.false
expect(selector.intersects({ x: 0, y: 10 }, createRect())).to.be.false
expect(selector.intersects({ x: 30, y: 30 }, createRect())).to.be.false
})
})
describe('area', () => {
it('should return geometry area', () => {
expect(selector.area(createRect({ width: 10, height: 10 }))).to.equal(100)
})
})
describe('methods', () => {
xit('should be defined')
})
})