├── .babelrc.json ├── .gitignore ├── .storybook ├── addons.js ├── config.ts └── main.js ├── .stylelintrc.json ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── doc └── rect.gif ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── Annotation.ts ├── DefaultInputSection.tsx ├── DeleteButton.tsx ├── ReactPictureAnnotation.tsx ├── Shape.ts ├── Transformer.ts ├── annotation │ ├── AnnotationState.ts │ ├── CreatingAnnotationState.ts │ ├── DefaultAnnotationState.ts │ ├── DraggingAnnotationState.ts │ └── TransfromationState.ts ├── index.ts ├── styles.css └── utils │ └── randomId.ts ├── stories └── index.stories.tsx ├── tsconfig.json ├── tslint.json └── yarn.lock /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 9 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # developer tool 64 | .idea/ 65 | 66 | 67 | # build 68 | build 69 | dist 70 | .rpt2_cache 71 | 72 | # storybook 73 | storybook-static/ -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | import '@storybook/addon-a11y/register'; 4 | -------------------------------------------------------------------------------- /.storybook/config.ts: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | // automatically import all files ending in *.stories.tsx 3 | const req = require.context("../stories", true, /\.stories\.tsx$/); 4 | 5 | function loadStories() { 6 | req.keys().forEach(req); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.tsx'], 3 | webpackFinal: async config => { 4 | config.module.rules.push({ 5 | test: /\.(ts|tsx)$/, 6 | loader: require.resolve('babel-loader'), 7 | options: { 8 | presets: [['react-app', { flow: false, typescript: true }]], 9 | }, 10 | }); 11 | config.resolve.extensions.push('.ts', '.tsx'); 12 | return config; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-rational-order", 5 | "stylelint-config-prettier" 6 | ], 7 | "plugins": [ 8 | "stylelint-order", 9 | "stylelint-declaration-block-no-ignored-properties" 10 | ], 11 | "rules": { 12 | "no-descending-specificity": null, 13 | "plugin/declaration-block-no-ignored-properties": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | services: 4 | - docker 5 | 6 | node_js: 7 | - 10 8 | 9 | # Trigger a push build on master and greenkeeper branches + PRs build on every branches 10 | # Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) 11 | branches: 12 | only: 13 | - master 14 | 15 | jobs: 16 | include: 17 | # Define the release stage that runs semantic-release 18 | - stage: release 19 | node_js: lts/* 20 | 21 | # Retry install on fail to avoid failing a build on network/disk/external errors 22 | install: 23 | - travis_retry npm install 24 | 25 | script: 26 | - npm run build 27 | 28 | # Advanced: optionally overwrite your default `script` step to skip the tests 29 | # script: skip 30 | deploy: 31 | provider: script 32 | skip_cleanup: true 33 | script: 34 | - npx semantic-release 35 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:6006", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.autoFixOnSave": true, 3 | "editor.tabSize": 2 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Picture Annotation 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kunduin/react-picture-annotation/blob/master/LICENSE) [![Travis (.com)](https://img.shields.io/travis/com/kunduin/react-picture-annotation.svg)](https://travis-ci.com/Kunduin/react-picture-annotation) [![npm](https://img.shields.io/npm/v/react-picture-annotation.svg)](https://www.npmjs.com/package/react-picture-annotation) 4 | 5 | A simple annotation component. 6 | 7 | ![rect](./doc/rect.gif) 8 | 9 | ## Install 10 | 11 | ```Bash 12 | # npm 13 | npm install react-picture-annotation 14 | 15 | # yarn 16 | yarn add react-picture-annotation 17 | ``` 18 | 19 | ## Basic Example 20 | 21 | [![Edit react-picture-annotation-example](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/react-picture-annotation-example-cw49e?fontsize=14) 22 | 23 | ```jsx 24 | const App = () => { 25 | const [pageSize, setPageSize] = useState({ 26 | width: window.innerWidth, 27 | height: window.innerHeight 28 | }); 29 | const onResize = () => { 30 | setPageSize({ width: window.innerWidth, height: window.innerHeight }); 31 | }; 32 | 33 | useEffect(() => { 34 | window.addEventListener('resize', onResize); 35 | return () => window.removeEventListener('resize', onResize); 36 | }, []); 37 | 38 | const onSelect = selectedId => console.log(selectedId); 39 | const onChange = data => console.log(data); 40 | 41 | return ( 42 |
43 | 50 |
51 | ); 52 | }; 53 | 54 | const rootElement = document.getElementById('root'); 55 | ReactDOM.render(, rootElement); 56 | ``` 57 | 58 | ## ReactPictureAnnotation Props 59 | 60 | | Name | Type | Comment | required | 61 | | --------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------ | -------- | 62 | | onChange | `(annotationData: IAnnotation[]) => void` | Called every time the shape changes. | √ | 63 | | onSelected | `(id: string or null) => void` | Called each time the selection is changed. | √ | 64 | | width | `number` | Width of the canvas. | √ | 65 | | height | `number` | Height of the canvas. | √ | 66 | | image | `string` | Image to be annotated. | √ | 67 | | inputElement | `(value: string,onChange: (value: string) => void,onDelete: () => void) => React.ReactElement;` | Customizable input control. | X | 68 | | annotationData | `Array` | Control the marked areas on the page. | X | 69 | | annotationStyle | `IShapeStyle` | Control the mark style | X | 70 | | selectedId | `string or null` | Selected markId | X | 71 | | scrollSpeed | `number` | Speed of wheel zoom, default 0.0005 | X | 72 | | marginWithInput | `number` | Margin between input and mark, default 1 | X | 73 | | defaultAnnotationSize | `number[]` | Size for annotations created by clicking. | X | 74 | 75 | ## IShapeStyle 76 | 77 | ReactPictureAnnotation can be easily modified the style through a prop named `annotationStyle` 78 | 79 | ```typescript 80 | export const defaultShapeStyle: IShapeStyle = { 81 | /** text area **/ 82 | padding: 5, // text padding 83 | fontSize: 12, // text font size 84 | fontColor: "#212529", // text font color 85 | fontBackground: "#f8f9fa", // text background color 86 | fontFamily: 87 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif", 88 | 89 | /** stroke style **/ 90 | lineWidth: 2, // stroke width 91 | shapeBackground: "hsla(210, 16%, 93%, 0.2)", // background color in the middle of the marker 92 | shapeStrokeStyle: "#f8f9fa", // shape stroke color 93 | shadowBlur: 10, // stroke shadow blur 94 | shapeShadowStyle: "hsla(210, 9%, 31%, 0.35)", // shape shadow color 95 | 96 | /** transformer style **/ 97 | transformerBackground: "#5c7cfa", 98 | transformerSize: 10 99 | }; 100 | ``` 101 | 102 | ## IAnnotation 103 | 104 | ```js 105 | { 106 | id:"to identify this shape", // required, 107 | comment:"string type comment", // not required 108 | mark:{ 109 | type:"RECT", // now only support rect 110 | 111 | // The number of pixels in the upper left corner of the image 112 | x:0, 113 | y:0, 114 | 115 | // The size of tag 116 | width:0, 117 | height:0 118 | } 119 | } 120 | ``` 121 | 122 | ## Licence 123 | 124 | [MIT License](https://github.com/kunduin/react-picture-annotation/blob/master/LICENSE) 125 | 126 | ## How To Contribute 127 | 128 | This repo uses semantic release. By running `npm run commit` and merging commits into master branch, travis will automatically trigger release. 129 | 130 | Thanks all your great contributions. -------------------------------------------------------------------------------- /doc/rect.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kunduin/react-picture-annotation/2a72f19f4f6b00500bc572eecfc2483e65470824/doc/rect.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | ".(ts|tsx)": "ts-jest" 4 | }, 5 | testPathIgnorePatterns: ["/node_modules/", "/lib/"], 6 | testRegex: "(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "json"] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-picture-annotation", 3 | "version": "0.0.0-development", 4 | "description": "A simple annotation component.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "typings": "dist/types/src/index.d.ts", 8 | "scripts": { 9 | "build": "tsc --build && rollup -c rollup.config.js", 10 | "storybook": "start-storybook -p 6006 --debug-webpack --no-dll", 11 | "build-storybook": "build-storybook", 12 | "commit": "npx git-cz", 13 | "precommit": "lint-staged", 14 | "lib:publish": "npm run build && npm publish", 15 | "semantic-release": "semantic-release" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/Kunduin/react-picture-annotation.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "typescript-library", 24 | "javascript-library", 25 | "annotation" 26 | ], 27 | "author": "Bay", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/Kunduin/react-picture-annotation/issues" 31 | }, 32 | "homepage": "https://github.com/Kunduin/react-picture-annotation#readme", 33 | "peerDependencies": { 34 | "react": ">=16.0.0", 35 | "react-dom": ">=16.0.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.11.0", 39 | "@babel/plugin-proposal-class-properties": "^7.10.4", 40 | "@babel/plugin-proposal-decorators": "^7.10.5", 41 | "@babel/preset-env": "^7.11.0", 42 | "@babel/preset-react": "^7.10.4", 43 | "@babel/preset-typescript": "^7.10.4", 44 | "@rollup/plugin-babel": "^5.1.0", 45 | "@rollup/plugin-node-resolve": "^8.4.0", 46 | "@rollup/plugin-typescript": "^5.0.2", 47 | "@storybook/addon-a11y": "^5.2.3", 48 | "@storybook/addon-actions": "^5.2.3", 49 | "@storybook/addon-info": "^5.3.19", 50 | "@storybook/addon-links": "^5.2.3", 51 | "@storybook/addons": "^5.2.3", 52 | "@storybook/preset-create-react-app": "^3.1.4", 53 | "@storybook/react": "^5.2.3", 54 | "@svgr/rollup": "^5.4.0", 55 | "@types/jest": "^26.0.8", 56 | "@types/react": "^16.9.1", 57 | "@types/react-dom": "^16.9.1", 58 | "@types/storybook__react": "^5.2.1", 59 | "babel-loader": "^8.0.5", 60 | "babel-preset-react-app": "^9.1.2", 61 | "commitizen": "^4.0.0", 62 | "cz-conventional-changelog": "3.2.0", 63 | "husky": "^4.2.5", 64 | "jest": "^26.2.2", 65 | "lint-staged": "^10.2.11", 66 | "prettier": "^2.0.5", 67 | "react-docgen-typescript-loader": "^3.7.2", 68 | "rollup": "^2.23.0", 69 | "rollup-plugin-peer-deps-external": "^2.2.3", 70 | "rollup-plugin-postcss": "^3.1.4", 71 | "semantic-release": "^17.1.1", 72 | "ts-jest": "^26.1.4", 73 | "tslint": "^6.1.3", 74 | "tslint-config-prettier": "^1.18.0", 75 | "tslint-react": "^5.0.0", 76 | "typescript": "^3.9.7" 77 | }, 78 | "dependencies": {}, 79 | "husky": { 80 | "hooks": { 81 | "pre-commit": "lint-staged" 82 | } 83 | }, 84 | "config": { 85 | "commitizen": { 86 | "path": "./node_modules/cz-conventional-changelog" 87 | } 88 | }, 89 | "lint-staged": { 90 | "{stories,src}/**/*.css": "stylelint", 91 | "{stories,src}/**/*.{ts,tsx}": [ 92 | "tslint --project tsconfig.json -c tslint.json --fix", 93 | "prettier --write", 94 | "git add" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "@rollup/plugin-babel"; 2 | import postcss from "rollup-plugin-postcss"; 3 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 4 | import external from "rollup-plugin-peer-deps-external"; 5 | import path from "path"; 6 | 7 | // tslint:disable-next-line: no-var-requires 8 | import pkg from "./package.json"; 9 | 10 | const extensions = [".js", ".jsx", ".ts", ".tsx"]; 11 | 12 | export default { 13 | input: `src/index.ts`, 14 | output: [ 15 | // { dir: "dist/umd", name: "named", format: "umd", sourcemap: true }, 16 | { file: pkg.main, name: "named", format: "umd", sourcemap: true }, 17 | // { dir: "dist/es", name: "named", format: "es", sourcemap: true } 18 | { file: pkg.module, name: "named", format: "es", sourcemap: true }, 19 | ], 20 | // tslint:disable-next-line: object-literal-sort-keys 21 | external: [], 22 | watch: { 23 | include: "src/**", 24 | }, 25 | plugins: [ 26 | external(), 27 | postcss({ 28 | modules: false, 29 | }), 30 | nodeResolve({ extensions }), 31 | // Allow json resolution 32 | babel({ 33 | exclude: "node_modules/**", 34 | extensions, 35 | configFile: path.resolve(__dirname, ".babelrc.json"), 36 | }), 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /src/Annotation.ts: -------------------------------------------------------------------------------- 1 | import { IShapeData } from "Shape"; 2 | 3 | export interface IAnnotation { 4 | comment?: string; 5 | id: string; 6 | mark: T; 7 | } 8 | -------------------------------------------------------------------------------- /src/DefaultInputSection.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DeleteButton from "./DeleteButton"; 3 | 4 | export interface IDefaultInputSection { 5 | value: string; 6 | placeholder?: string; 7 | onChange: (value: string) => void; 8 | onDelete: () => void; 9 | } 10 | 11 | export default ({ 12 | value, 13 | onChange, 14 | onDelete, 15 | placeholder = "INPUT TAG HERE", 16 | }: IDefaultInputSection) => { 17 | return ( 18 |
19 | onChange(e.target.value)} 24 | /> 25 | onDelete()}> 26 | 27 | 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default () => { 4 | return ( 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/ReactPictureAnnotation.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from "react"; 2 | import { IAnnotation } from "./Annotation"; 3 | import { IAnnotationState } from "./annotation/AnnotationState"; 4 | import { DefaultAnnotationState } from "./annotation/DefaultAnnotationState"; 5 | import DefaultInputSection from "./DefaultInputSection"; 6 | // import DeleteButton from "./DeleteButton"; 7 | import { 8 | defaultShapeStyle, 9 | IShape, 10 | IShapeBase, 11 | IShapeStyle, 12 | RectShape, 13 | } from "./Shape"; 14 | import Transformer, { ITransformer } from "./Transformer"; 15 | 16 | interface IReactPictureAnnotationProps { 17 | annotationData?: IAnnotation[]; 18 | selectedId?: string | null; 19 | scrollSpeed: number; 20 | marginWithInput: number; 21 | onChange: (annotationData: IAnnotation[]) => void; 22 | onSelect: (id: string | null) => void; 23 | width: number; 24 | height: number; 25 | image: string; 26 | annotationStyle: IShapeStyle; 27 | defaultAnnotationSize?: number[]; 28 | inputElement: ( 29 | value: string, 30 | onChange: (value: string) => void, 31 | onDelete: () => void 32 | ) => React.ReactElement; 33 | } 34 | 35 | interface IStageState { 36 | scale: number; 37 | originX: number; 38 | originY: number; 39 | } 40 | 41 | const defaultState: IStageState = { 42 | scale: 1, 43 | originX: 0, 44 | originY: 0, 45 | }; 46 | 47 | export default class ReactPictureAnnotation extends React.Component< 48 | IReactPictureAnnotationProps 49 | > { 50 | public static defaultProps = { 51 | marginWithInput: 10, 52 | scrollSpeed: 0.0005, 53 | annotationStyle: defaultShapeStyle, 54 | inputElement: ( 55 | value: string, 56 | onChange: (value: string) => void, 57 | onDelete: () => void 58 | ) => ( 59 | 64 | ), 65 | }; 66 | 67 | public state = { 68 | inputPosition: { 69 | left: 0, 70 | top: 0, 71 | }, 72 | showInput: false, 73 | inputComment: "", 74 | }; 75 | 76 | set selectedId(value: string | null) { 77 | const { onSelect } = this.props; 78 | this.selectedIdTrueValue = value; 79 | onSelect(value); 80 | } 81 | 82 | get selectedId() { 83 | return this.selectedIdTrueValue; 84 | } 85 | 86 | get annotationStyle() { 87 | return this.props.annotationStyle; 88 | } 89 | 90 | get defaultAnnotationSize() { 91 | return this.props.defaultAnnotationSize; 92 | } 93 | 94 | public shapes: IShape[] = []; 95 | public scaleState = defaultState; 96 | public currentTransformer: ITransformer; 97 | 98 | private currentAnnotationData: IAnnotation[] = []; 99 | private selectedIdTrueValue: string | null; 100 | private canvasRef = React.createRef(); 101 | private canvas2D?: CanvasRenderingContext2D | null; 102 | private imageCanvasRef = React.createRef(); 103 | private imageCanvas2D?: CanvasRenderingContext2D | null; 104 | private currentImageElement?: HTMLImageElement; 105 | private currentAnnotationState: IAnnotationState = new DefaultAnnotationState( 106 | this 107 | ); 108 | 109 | public componentDidMount = () => { 110 | const currentCanvas = this.canvasRef.current; 111 | const currentImageCanvas = this.imageCanvasRef.current; 112 | if (currentCanvas && currentImageCanvas) { 113 | this.setCanvasDPI(); 114 | 115 | this.canvas2D = currentCanvas.getContext("2d"); 116 | this.imageCanvas2D = currentImageCanvas.getContext("2d"); 117 | this.onImageChange(); 118 | } 119 | 120 | this.syncAnnotationData(); 121 | this.syncSelectedId(); 122 | }; 123 | 124 | public componentDidUpdate = (preProps: IReactPictureAnnotationProps) => { 125 | const { width, height, image } = this.props; 126 | if (preProps.width !== width || preProps.height !== height) { 127 | this.setCanvasDPI(); 128 | this.onShapeChange(); 129 | this.onImageChange(); 130 | } 131 | if (preProps.image !== image) { 132 | this.cleanImage(); 133 | if (this.currentImageElement) { 134 | this.currentImageElement.src = image; 135 | } else { 136 | this.onImageChange(); 137 | } 138 | } 139 | 140 | this.syncAnnotationData(); 141 | this.syncSelectedId(); 142 | }; 143 | 144 | public calculateMousePosition = (positionX: number, positionY: number) => { 145 | const { originX, originY, scale } = this.scaleState; 146 | return { 147 | positionX: (positionX - originX) / scale, 148 | positionY: (positionY - originY) / scale, 149 | }; 150 | }; 151 | 152 | public calculateShapePosition = (shapeData: IShapeBase): IShapeBase => { 153 | const { originX, originY, scale } = this.scaleState; 154 | const { x, y, width, height } = shapeData; 155 | return { 156 | x: x * scale + originX, 157 | y: y * scale + originY, 158 | width: width * scale, 159 | height: height * scale, 160 | }; 161 | }; 162 | 163 | public render() { 164 | const { width, height, inputElement } = this.props; 165 | const { showInput, inputPosition, inputComment } = this.state; 166 | return ( 167 |
168 | 175 | 187 | {showInput && ( 188 |
189 | {inputElement( 190 | inputComment, 191 | this.onInputCommentChange, 192 | this.onDelete 193 | )} 194 |
195 | )} 196 |
197 | ); 198 | } 199 | 200 | public setAnnotationState = (annotationState: IAnnotationState) => { 201 | this.currentAnnotationState = annotationState; 202 | }; 203 | 204 | public onShapeChange = () => { 205 | if (this.canvas2D && this.canvasRef.current) { 206 | this.canvas2D.clearRect( 207 | 0, 208 | 0, 209 | this.canvasRef.current.width, 210 | this.canvasRef.current.height 211 | ); 212 | 213 | let hasSelectedItem = false; 214 | 215 | for (const item of this.shapes) { 216 | const isSelected = item.getAnnotationData().id === this.selectedId; 217 | const { x, y, height } = item.paint( 218 | this.canvas2D, 219 | this.calculateShapePosition, 220 | isSelected 221 | ); 222 | 223 | if (isSelected) { 224 | if (!this.currentTransformer) { 225 | this.currentTransformer = new Transformer( 226 | item, 227 | this.scaleState.scale 228 | ); 229 | } 230 | 231 | hasSelectedItem = true; 232 | 233 | this.currentTransformer.paint( 234 | this.canvas2D, 235 | this.calculateShapePosition, 236 | this.scaleState.scale 237 | ); 238 | 239 | this.setState({ 240 | showInput: true, 241 | inputPosition: { 242 | left: x, 243 | top: y + height + this.props.marginWithInput, 244 | }, 245 | inputComment: item.getAnnotationData().comment || "", 246 | }); 247 | } 248 | } 249 | 250 | if (!hasSelectedItem) { 251 | this.setState({ 252 | showInput: false, 253 | inputComment: "", 254 | }); 255 | } 256 | } 257 | 258 | this.currentAnnotationData = this.shapes.map((item) => 259 | item.getAnnotationData() 260 | ); 261 | const { onChange } = this.props; 262 | onChange(this.currentAnnotationData); 263 | }; 264 | 265 | private syncAnnotationData = () => { 266 | const { annotationData } = this.props; 267 | if (annotationData) { 268 | const refreshShapesWithAnnotationData = () => { 269 | this.selectedId = null; 270 | this.shapes = annotationData.map( 271 | (eachAnnotationData) => 272 | new RectShape( 273 | eachAnnotationData, 274 | this.onShapeChange, 275 | this.annotationStyle 276 | ) 277 | ); 278 | this.onShapeChange(); 279 | }; 280 | 281 | if (annotationData.length !== this.shapes.length) { 282 | refreshShapesWithAnnotationData(); 283 | } else { 284 | for (const annotationDataItem of annotationData) { 285 | const targetShape = this.shapes.find( 286 | (item) => item.getAnnotationData().id === annotationDataItem.id 287 | ); 288 | if (targetShape && targetShape.equal(annotationDataItem)) { 289 | continue; 290 | } else { 291 | refreshShapesWithAnnotationData(); 292 | break; 293 | } 294 | } 295 | } 296 | } 297 | }; 298 | 299 | private syncSelectedId = () => { 300 | const { selectedId } = this.props; 301 | 302 | if (selectedId && selectedId !== this.selectedId) { 303 | this.selectedId = selectedId; 304 | this.onShapeChange(); 305 | } 306 | }; 307 | 308 | private onDelete = () => { 309 | const deleteTarget = this.shapes.findIndex( 310 | (shape) => shape.getAnnotationData().id === this.selectedId 311 | ); 312 | if (deleteTarget >= 0) { 313 | this.shapes.splice(deleteTarget, 1); 314 | this.onShapeChange(); 315 | } 316 | }; 317 | 318 | private setCanvasDPI = () => { 319 | const currentCanvas = this.canvasRef.current; 320 | const currentImageCanvas = this.imageCanvasRef.current; 321 | if (currentCanvas && currentImageCanvas) { 322 | const currentCanvas2D = currentCanvas.getContext("2d"); 323 | const currentImageCanvas2D = currentImageCanvas.getContext("2d"); 324 | if (currentCanvas2D && currentImageCanvas2D) { 325 | currentCanvas2D.scale(2, 2); 326 | currentImageCanvas2D.scale(2, 2); 327 | } 328 | } 329 | }; 330 | 331 | private onInputCommentChange = (comment: string) => { 332 | const selectedShapeIndex = this.shapes.findIndex( 333 | (item) => item.getAnnotationData().id === this.selectedId 334 | ); 335 | this.shapes[selectedShapeIndex].setComment(comment); 336 | this.setState({ inputComment: comment }); 337 | }; 338 | 339 | private cleanImage = () => { 340 | if (this.imageCanvas2D && this.imageCanvasRef.current) { 341 | this.imageCanvas2D.clearRect( 342 | 0, 343 | 0, 344 | this.imageCanvasRef.current.width, 345 | this.imageCanvasRef.current.height 346 | ); 347 | } 348 | }; 349 | 350 | private onImageChange = () => { 351 | this.cleanImage(); 352 | if (this.imageCanvas2D && this.imageCanvasRef.current) { 353 | if (this.currentImageElement) { 354 | const { originX, originY, scale } = this.scaleState; 355 | this.imageCanvas2D.drawImage( 356 | this.currentImageElement, 357 | originX, 358 | originY, 359 | this.currentImageElement.width * scale, 360 | this.currentImageElement.height * scale 361 | ); 362 | } else { 363 | const nextImageNode = document.createElement("img"); 364 | nextImageNode.addEventListener("load", () => { 365 | this.currentImageElement = nextImageNode; 366 | const { width, height } = nextImageNode; 367 | const imageNodeRatio = height / width; 368 | const { width: canvasWidth, height: canvasHeight } = this.props; 369 | const canvasNodeRatio = canvasHeight / canvasWidth; 370 | if (!isNaN(imageNodeRatio) && !isNaN(canvasNodeRatio)) { 371 | if (imageNodeRatio < canvasNodeRatio) { 372 | const scale = canvasWidth / width; 373 | this.scaleState = { 374 | originX: 0, 375 | originY: (canvasHeight - scale * height) / 2, 376 | scale, 377 | }; 378 | } else { 379 | const scale = canvasHeight / height; 380 | this.scaleState = { 381 | originX: (canvasWidth - scale * width) / 2, 382 | originY: 0, 383 | scale, 384 | }; 385 | } 386 | } 387 | this.onImageChange(); 388 | this.onShapeChange(); 389 | }); 390 | nextImageNode.alt = ""; 391 | nextImageNode.src = this.props.image; 392 | } 393 | } 394 | }; 395 | 396 | private onMouseDown: MouseEventHandler = (event) => { 397 | const { offsetX, offsetY } = event.nativeEvent; 398 | const { positionX, positionY } = this.calculateMousePosition( 399 | offsetX, 400 | offsetY 401 | ); 402 | this.currentAnnotationState.onMouseDown(positionX, positionY); 403 | }; 404 | 405 | private onMouseMove: MouseEventHandler = (event) => { 406 | const { offsetX, offsetY } = event.nativeEvent; 407 | const { positionX, positionY } = this.calculateMousePosition( 408 | offsetX, 409 | offsetY 410 | ); 411 | this.currentAnnotationState.onMouseMove(positionX, positionY); 412 | }; 413 | 414 | private onMouseUp: MouseEventHandler = () => { 415 | this.currentAnnotationState.onMouseUp(); 416 | }; 417 | 418 | private onMouseLeave: MouseEventHandler = () => { 419 | this.currentAnnotationState.onMouseLeave(); 420 | }; 421 | 422 | private onWheel = (event: React.WheelEvent) => { 423 | // https://stackoverflow.com/a/31133823/9071503 424 | const { clientHeight, scrollTop, scrollHeight } = event.currentTarget; 425 | if (clientHeight + scrollTop + event.deltaY > scrollHeight) { 426 | // event.preventDefault(); 427 | event.currentTarget.scrollTop = scrollHeight; 428 | } else if (scrollTop + event.deltaY < 0) { 429 | // event.preventDefault(); 430 | event.currentTarget.scrollTop = 0; 431 | } 432 | 433 | const { scale: preScale } = this.scaleState; 434 | this.scaleState.scale += event.deltaY * this.props.scrollSpeed; 435 | if (this.scaleState.scale > 10) { 436 | this.scaleState.scale = 10; 437 | } 438 | if (this.scaleState.scale < 0.1) { 439 | this.scaleState.scale = 0.1; 440 | } 441 | 442 | const { originX, originY, scale } = this.scaleState; 443 | const { offsetX, offsetY } = event.nativeEvent; 444 | this.scaleState.originX = 445 | offsetX - ((offsetX - originX) / preScale) * scale; 446 | this.scaleState.originY = 447 | offsetY - ((offsetY - originY) / preScale) * scale; 448 | 449 | this.setState({ imageScale: this.scaleState }); 450 | 451 | requestAnimationFrame(() => { 452 | this.onShapeChange(); 453 | this.onImageChange(); 454 | }); 455 | }; 456 | } 457 | -------------------------------------------------------------------------------- /src/Shape.ts: -------------------------------------------------------------------------------- 1 | import { IAnnotation } from "./Annotation"; 2 | 3 | export const defaultShapeStyle: IShapeStyle = { 4 | padding: 5, 5 | lineWidth: 2, 6 | shadowBlur: 10, 7 | fontSize: 12, 8 | fontColor: "#212529", 9 | fontBackground: "#f8f9fa", 10 | fontFamily: 11 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, sans-serif", 12 | shapeBackground: "hsla(210, 16%, 93%, 0.2)", 13 | shapeStrokeStyle: "#f8f9fa", 14 | shapeShadowStyle: "hsla(210, 9%, 31%, 0.35)", 15 | transformerBackground: "#5c7cfa", 16 | transformerSize: 10, 17 | }; 18 | 19 | export interface IShapeStyle { 20 | padding: number; 21 | lineWidth: number; 22 | shadowBlur: number; 23 | fontSize: number; 24 | fontColor: string; 25 | fontBackground: string; 26 | fontFamily: string; 27 | shapeBackground: string; 28 | shapeStrokeStyle: string; 29 | shapeShadowStyle: string; 30 | transformerBackground: string; 31 | transformerSize: number; 32 | } 33 | 34 | export interface IShapeBase { 35 | x: number; 36 | y: number; 37 | width: number; 38 | height: number; 39 | } 40 | 41 | export interface IShapeAdjustBase { 42 | x?: number; 43 | y?: number; 44 | width?: number; 45 | height?: number; 46 | } 47 | 48 | export interface IShapeData extends IShapeBase { 49 | type: string; 50 | } 51 | 52 | export interface IRectShapeData extends IShapeData { 53 | type: "RECT"; 54 | } 55 | 56 | export interface IShape { 57 | shapeStyle: IShapeStyle; 58 | onDragStart: (positionX: number, positionY: number) => void; 59 | onDrag: (positionX: number, positionY: number) => void; 60 | checkBoundary: (positionX: number, positionY: number) => boolean; 61 | paint: ( 62 | canvas2D: CanvasRenderingContext2D, 63 | calculateTruePosition: (shapeData: IShapeBase) => IShapeBase, 64 | selected: boolean 65 | ) => IShapeBase; 66 | getAnnotationData: () => IAnnotation; 67 | adjustMark: (adjustBase: IShapeAdjustBase) => void; 68 | setComment: (comment: string) => void; 69 | equal: (data: IAnnotation) => boolean; 70 | } 71 | 72 | export class RectShape implements IShape { 73 | private readonly annotationData: IAnnotation; 74 | 75 | private readonly onChangeCallBack: () => void; 76 | 77 | private dragStartOffset: { offsetX: number; offsetY: number }; 78 | 79 | public readonly shapeStyle: IShapeStyle; 80 | 81 | constructor( 82 | data: IAnnotation, 83 | onChange: () => void, 84 | shapeStyle: IShapeStyle = defaultShapeStyle 85 | ) { 86 | this.annotationData = data; 87 | this.onChangeCallBack = onChange; 88 | this.shapeStyle = shapeStyle; 89 | } 90 | 91 | public onDragStart = (positionX: number, positionY: number) => { 92 | const { x, y } = this.annotationData.mark; 93 | this.dragStartOffset = { 94 | offsetX: positionX - x, 95 | offsetY: positionY - y, 96 | }; 97 | }; 98 | 99 | public onDrag = (positionX: number, positionY: number) => { 100 | this.annotationData.mark.x = positionX - this.dragStartOffset.offsetX; 101 | this.annotationData.mark.y = positionY - this.dragStartOffset.offsetY; 102 | this.onChangeCallBack(); 103 | }; 104 | 105 | public checkBoundary = (positionX: number, positionY: number) => { 106 | const { 107 | mark: { x, y, width, height }, 108 | } = this.annotationData; 109 | 110 | return ( 111 | ((positionX > x && positionX < x + width) || 112 | (positionX < x && positionX > x + width)) && 113 | ((positionY > y && positionY < y + height) || 114 | (positionY < y && positionY > y + height)) 115 | ); 116 | }; 117 | 118 | public paint = ( 119 | canvas2D: CanvasRenderingContext2D, 120 | calculateTruePosition: (shapeData: IShapeBase) => IShapeBase, 121 | selected: boolean 122 | ) => { 123 | const { x, y, width, height } = calculateTruePosition( 124 | this.annotationData.mark 125 | ); 126 | canvas2D.save(); 127 | 128 | const { 129 | padding, 130 | lineWidth, 131 | shadowBlur, 132 | fontSize, 133 | fontColor, 134 | fontBackground, 135 | fontFamily, 136 | shapeBackground, 137 | shapeStrokeStyle, 138 | shapeShadowStyle, 139 | } = this.shapeStyle; 140 | 141 | canvas2D.shadowBlur = shadowBlur; 142 | canvas2D.shadowColor = shapeShadowStyle; 143 | canvas2D.strokeStyle = shapeStrokeStyle; 144 | canvas2D.lineWidth = lineWidth; 145 | canvas2D.strokeRect(x, y, width, height); 146 | canvas2D.restore(); 147 | if (selected) { 148 | canvas2D.fillStyle = shapeBackground; 149 | canvas2D.fillRect(x, y, width, height); 150 | } else { 151 | const { comment } = this.annotationData; 152 | if (comment) { 153 | canvas2D.font = `${fontSize}px ${fontFamily}`; 154 | const metrics = canvas2D.measureText(comment); 155 | canvas2D.save(); 156 | canvas2D.fillStyle = fontBackground; 157 | canvas2D.fillRect( 158 | x, 159 | y, 160 | metrics.width + padding * 2, 161 | fontSize + padding * 2 162 | ); 163 | canvas2D.textBaseline = "top"; 164 | canvas2D.fillStyle = fontColor; 165 | canvas2D.fillText(comment, x + padding, y + padding); 166 | } 167 | } 168 | canvas2D.restore(); 169 | 170 | return { x, y, width, height }; 171 | }; 172 | 173 | public adjustMark = ({ 174 | x = this.annotationData.mark.x, 175 | y = this.annotationData.mark.y, 176 | width = this.annotationData.mark.width, 177 | height = this.annotationData.mark.height, 178 | }: { 179 | x?: number; 180 | y?: number; 181 | width?: number; 182 | height?: number; 183 | }) => { 184 | this.annotationData.mark.x = x; 185 | this.annotationData.mark.y = y; 186 | this.annotationData.mark.width = width; 187 | this.annotationData.mark.height = height; 188 | this.onChangeCallBack(); 189 | }; 190 | 191 | public getAnnotationData = () => { 192 | return this.annotationData; 193 | }; 194 | 195 | public setComment = (comment: string) => { 196 | this.annotationData.comment = comment; 197 | }; 198 | 199 | public equal = (data: IAnnotation) => { 200 | return ( 201 | data.id === this.annotationData.id && 202 | data.comment === this.annotationData.comment && 203 | data.mark.x === this.annotationData.mark.x && 204 | data.mark.y === this.annotationData.mark.y && 205 | data.mark.width === this.annotationData.mark.width && 206 | data.mark.height === this.annotationData.mark.height 207 | ); 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /src/Transformer.ts: -------------------------------------------------------------------------------- 1 | import { IShape, IShapeBase } from "Shape"; 2 | 3 | export interface ITransformer { 4 | checkBoundary: (positionX: number, positionY: number) => boolean; 5 | startTransformation: (positionX: number, positionY: number) => void; 6 | onTransformation: (positionX: number, positionY: number) => void; 7 | paint: ( 8 | canvas2D: CanvasRenderingContext2D, 9 | calculateTruePosition: (shapeData: IShapeBase) => IShapeBase, 10 | scale: number 11 | ) => void; 12 | } 13 | 14 | export default class Transformer implements ITransformer { 15 | private readonly shape: IShape; 16 | private currentNodeCenterIndex: number; 17 | private scale: number; 18 | 19 | private get nodeWidth() { 20 | return this.shape.shapeStyle.transformerSize / this.scale; 21 | } 22 | 23 | constructor(shape: IShape, scale: number) { 24 | this.shape = shape; 25 | this.scale = scale; 26 | } 27 | 28 | public checkBoundary = (positionX: number, positionY: number) => { 29 | const currentCenterIndex = this.getCenterIndexByCursor( 30 | positionX, 31 | positionY 32 | ); 33 | return currentCenterIndex >= 0; 34 | }; 35 | 36 | public startTransformation = (positionX: number, positionY: number) => { 37 | this.currentNodeCenterIndex = this.getCenterIndexByCursor( 38 | positionX, 39 | positionY 40 | ); 41 | }; 42 | 43 | public onTransformation = (positionX: number, positionY: number) => { 44 | const currentCentersTable = this.getAllCentersTable(); 45 | currentCentersTable[this.currentNodeCenterIndex].adjust( 46 | positionX, 47 | positionY 48 | ); 49 | }; 50 | 51 | public paint = ( 52 | canvas2D: CanvasRenderingContext2D, 53 | calculateTruePosition: (shapeData: IShapeBase) => IShapeBase, 54 | scale: number 55 | ) => { 56 | this.scale = scale; 57 | 58 | const allCentersTable = this.getAllCentersTable(); 59 | canvas2D.save(); 60 | canvas2D.fillStyle = this.shape.shapeStyle.transformerBackground; 61 | 62 | for (const item of allCentersTable) { 63 | const { x, y, width, height } = calculateTruePosition({ 64 | x: item.x - this.nodeWidth / 2, 65 | y: item.y - this.nodeWidth / 2, 66 | width: this.nodeWidth, 67 | height: this.nodeWidth, 68 | }); 69 | canvas2D.fillRect(x, y, width, height); 70 | } 71 | 72 | canvas2D.restore(); 73 | }; 74 | 75 | private getCenterIndexByCursor = (positionX: number, positionY: number) => { 76 | const allCentersTable = this.getAllCentersTable(); 77 | return allCentersTable.findIndex((item) => 78 | this.checkEachRectBoundary(item.x, item.y, positionX, positionY) 79 | ); 80 | }; 81 | 82 | private checkEachRectBoundary = ( 83 | rectCenterX: number, 84 | rectCenterY: number, 85 | positionX: number, 86 | positionY: number 87 | ) => { 88 | return ( 89 | Math.abs(positionX - rectCenterX) <= this.nodeWidth / 2 && 90 | Math.abs(positionY - rectCenterY) <= this.nodeWidth / 2 91 | ); 92 | }; 93 | 94 | private getAllCentersTable = () => { 95 | const { shape } = this; 96 | const { x, y, width, height } = shape.getAnnotationData().mark; 97 | return [ 98 | { 99 | x, 100 | y, 101 | adjust: (positionX: number, positionY: number) => { 102 | shape.adjustMark({ 103 | x: positionX, 104 | y: positionY, 105 | width: width + x - positionX, 106 | height: height + y - positionY, 107 | }); 108 | }, 109 | }, 110 | { 111 | x: x + width / 2, 112 | y, 113 | adjust: (_: number, positionY: number) => { 114 | shape.adjustMark({ 115 | y: positionY, 116 | height: height + y - positionY, 117 | }); 118 | }, 119 | }, 120 | { 121 | x: x + width, 122 | y, 123 | adjust: (positionX: number, positionY: number) => { 124 | shape.adjustMark({ 125 | x, 126 | y: positionY, 127 | width: positionX - x, 128 | height: y + height - positionY, 129 | }); 130 | }, 131 | }, 132 | { 133 | x, 134 | y: y + height / 2, 135 | adjust: (positionX: number) => { 136 | shape.adjustMark({ 137 | x: positionX, 138 | width: width + x - positionX, 139 | }); 140 | }, 141 | }, 142 | { 143 | x: x + width, 144 | y: y + height / 2, 145 | adjust: (positionX: number) => { 146 | shape.adjustMark({ width: positionX - x }); 147 | }, 148 | }, 149 | { 150 | x, 151 | y: y + height, 152 | adjust: (positionX: number, positionY: number) => { 153 | shape.adjustMark({ 154 | x: positionX, 155 | width: width + x - positionX, 156 | height: positionY - y, 157 | }); 158 | }, 159 | }, 160 | { 161 | x: x + width / 2, 162 | y: y + height, 163 | adjust: (_: number, positionY: number) => { 164 | shape.adjustMark({ 165 | height: positionY - y, 166 | }); 167 | }, 168 | }, 169 | { 170 | x: x + width, 171 | y: y + height, 172 | adjust: (positionX: number, positionY: number) => { 173 | shape.adjustMark({ 174 | width: positionX - x, 175 | height: positionY - y, 176 | }); 177 | }, 178 | }, 179 | ]; 180 | }; 181 | } 182 | -------------------------------------------------------------------------------- /src/annotation/AnnotationState.ts: -------------------------------------------------------------------------------- 1 | export interface IAnnotationState { 2 | onMouseDown: (positionX: number, positionY: number) => void; 3 | onMouseMove: (positionX: number, positionY: number) => void; 4 | onMouseLeave: () => void; 5 | onMouseUp: () => void; 6 | } 7 | -------------------------------------------------------------------------------- /src/annotation/CreatingAnnotationState.ts: -------------------------------------------------------------------------------- 1 | import { ReactPictureAnnotation } from "index"; 2 | import { IShape } from "Shape"; 3 | import { IAnnotationState } from "./AnnotationState"; 4 | import { DefaultAnnotationState } from "./DefaultAnnotationState"; 5 | 6 | export default class CreatingAnnotationState implements IAnnotationState { 7 | private readonly context: ReactPictureAnnotation; 8 | constructor(context: ReactPictureAnnotation) { 9 | this.context = context; 10 | } 11 | public onMouseDown = () => undefined; 12 | public onMouseMove = (positionX: number, positionY: number) => { 13 | const { shapes } = this.context; 14 | if (shapes.length > 0) { 15 | const currentShape = shapes[shapes.length - 1]; 16 | const { 17 | mark: { x, y }, 18 | } = currentShape.getAnnotationData(); 19 | currentShape.adjustMark({ 20 | width: positionX - x, 21 | height: positionY - y, 22 | }); 23 | } 24 | }; 25 | 26 | public onMouseUp = () => { 27 | const { shapes, onShapeChange, setAnnotationState } = this.context; 28 | const data = shapes.pop(); 29 | if ( 30 | data && 31 | data.getAnnotationData().mark.width !== 0 && 32 | data.getAnnotationData().mark.height !== 0 33 | ) { 34 | shapes.push(data); 35 | } else { 36 | if (data && this.applyDefaultAnnotationSize(data)) { 37 | shapes.push(data); 38 | onShapeChange(); 39 | } else { 40 | this.context.selectedId = null; 41 | onShapeChange(); 42 | } 43 | } 44 | setAnnotationState(new DefaultAnnotationState(this.context)); 45 | }; 46 | 47 | private applyDefaultAnnotationSize = (shape: IShape) => { 48 | if (this.context.selectedId) { 49 | // Don't capture clicks meant to de-select another annotation. 50 | return false; 51 | } 52 | if ( 53 | !this.context.defaultAnnotationSize || 54 | this.context.defaultAnnotationSize.length !== 2 55 | ) { 56 | return false; 57 | } 58 | const [width, height] = this.context.defaultAnnotationSize; 59 | shape.adjustMark({ 60 | width, 61 | height, 62 | }); 63 | return true; 64 | }; 65 | 66 | public onMouseLeave = () => this.onMouseUp(); 67 | } 68 | -------------------------------------------------------------------------------- /src/annotation/DefaultAnnotationState.ts: -------------------------------------------------------------------------------- 1 | import ReactPictureAnnotation from "../ReactPictureAnnotation"; 2 | import { RectShape } from "../Shape"; 3 | import Transformer from "../Transformer"; 4 | import randomId from "../utils/randomId"; 5 | import { IAnnotationState } from "./AnnotationState"; 6 | import CreatingAnnotationState from "./CreatingAnnotationState"; 7 | import DraggingAnnotationState from "./DraggingAnnotationState"; 8 | import TransformationState from "./TransfromationState"; 9 | 10 | export class DefaultAnnotationState implements IAnnotationState { 11 | private readonly context: ReactPictureAnnotation; 12 | constructor(context: ReactPictureAnnotation) { 13 | this.context = context; 14 | } 15 | 16 | public onMouseMove = () => undefined; 17 | public onMouseUp = () => undefined; 18 | public onMouseLeave = () => undefined; 19 | 20 | public onMouseDown = (positionX: number, positionY: number) => { 21 | const { 22 | shapes, 23 | currentTransformer, 24 | onShapeChange, 25 | setAnnotationState: setState, 26 | } = this.context; 27 | 28 | if ( 29 | currentTransformer && 30 | currentTransformer.checkBoundary(positionX, positionY) 31 | ) { 32 | currentTransformer.startTransformation(positionX, positionY); 33 | setState(new TransformationState(this.context)); 34 | return; 35 | } 36 | 37 | for (let i = shapes.length - 1; i >= 0; i--) { 38 | if (shapes[i].checkBoundary(positionX, positionY)) { 39 | this.context.selectedId = shapes[i].getAnnotationData().id; 40 | this.context.currentTransformer = new Transformer( 41 | shapes[i], 42 | this.context.scaleState.scale 43 | ); 44 | const [selectedShape] = shapes.splice(i, 1); 45 | shapes.push(selectedShape); 46 | selectedShape.onDragStart(positionX, positionY); 47 | onShapeChange(); 48 | setState(new DraggingAnnotationState(this.context)); 49 | return; 50 | } 51 | } 52 | this.context.shapes.push( 53 | new RectShape( 54 | { 55 | id: randomId(), 56 | mark: { 57 | x: positionX, 58 | y: positionY, 59 | width: 0, 60 | height: 0, 61 | type: "RECT", 62 | }, 63 | }, 64 | onShapeChange, 65 | this.context.annotationStyle 66 | ) 67 | ); 68 | 69 | setState(new CreatingAnnotationState(this.context)); 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/annotation/DraggingAnnotationState.ts: -------------------------------------------------------------------------------- 1 | import { ReactPictureAnnotation } from "index"; 2 | import { IAnnotationState } from "./AnnotationState"; 3 | import { DefaultAnnotationState } from "./DefaultAnnotationState"; 4 | 5 | export default class DraggingAnnotationState implements IAnnotationState { 6 | private readonly context: ReactPictureAnnotation; 7 | constructor(context: ReactPictureAnnotation) { 8 | this.context = context; 9 | } 10 | public onMouseDown = () => undefined; 11 | public onMouseMove = (positionX: number, positionY: number) => { 12 | const { shapes } = this.context; 13 | const currentShape = shapes[shapes.length - 1]; 14 | currentShape.onDrag(positionX, positionY); 15 | }; 16 | 17 | public onMouseUp = () => { 18 | const { setAnnotationState } = this.context; 19 | setAnnotationState(new DefaultAnnotationState(this.context)); 20 | }; 21 | 22 | public onMouseLeave = () => this.onMouseUp(); 23 | } 24 | -------------------------------------------------------------------------------- /src/annotation/TransfromationState.ts: -------------------------------------------------------------------------------- 1 | import { ReactPictureAnnotation } from "index"; 2 | import { IAnnotationState } from "./AnnotationState"; 3 | import { DefaultAnnotationState } from "./DefaultAnnotationState"; 4 | 5 | export default class TransformationState implements IAnnotationState { 6 | private readonly context: ReactPictureAnnotation; 7 | constructor(context: ReactPictureAnnotation) { 8 | this.context = context; 9 | } 10 | public onMouseDown = () => undefined; 11 | public onMouseMove = (positionX: number, positionY: number) => { 12 | const { currentTransformer } = this.context; 13 | if (currentTransformer) { 14 | currentTransformer.onTransformation(positionX, positionY); 15 | } 16 | }; 17 | 18 | public onMouseUp = () => { 19 | const { setAnnotationState } = this.context; 20 | setAnnotationState(new DefaultAnnotationState(this.context)); 21 | }; 22 | 23 | public onMouseLeave = () => this.onMouseUp(); 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | export { default as ReactPictureAnnotation } from "./ReactPictureAnnotation"; 3 | export { default as DefaultInputSection } from "./DefaultInputSection"; 4 | export { defaultShapeStyle } from "./Shape"; 5 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* .rus { 2 | --theme-color: hsl(262, 24%, 84%); 3 | --theme-background: hsl(262, 25%, 98%); 4 | 5 | position: relative; 6 | padding: 10px; 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 8 | Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, 9 | sans-serif; 10 | background-color: var(--theme-background); 11 | border: 1px dashed var(--theme-color); 12 | border-radius: 5px; 13 | cursor: pointer; 14 | } */ 15 | 16 | .rp-stage { 17 | position: relative; 18 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 19 | Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, Arial, 20 | sans-serif; 21 | } 22 | 23 | .rp-image { 24 | position: absolute; 25 | top: 0; 26 | right: 0; 27 | bottom: 0; 28 | left: 0; 29 | overflow: hidden; 30 | } 31 | 32 | .rp-shapes { 33 | position: absolute; 34 | top: 0; 35 | right: 0; 36 | bottom: 0; 37 | left: 0; 38 | } 39 | 40 | .rp-selected-input { 41 | position: absolute; 42 | } 43 | 44 | .rp-delete { 45 | width: 20px; 46 | height: 20px; 47 | fill: white; 48 | } 49 | 50 | .rp-delete-section { 51 | display: flex; 52 | align-items: center; 53 | justify-content: center; 54 | } 55 | 56 | .rp-default-input-section { 57 | display: flex; 58 | align-items: stretch; 59 | background-color: #3384ff; 60 | border: none; 61 | border-radius: 5px; 62 | } 63 | 64 | .rp-default-input-section input { 65 | padding: 10px; 66 | color: white; 67 | background: transparent; 68 | border: 0; 69 | outline: none; 70 | } 71 | 72 | .rp-default-input-section input::placeholder { 73 | color: #94bfff; 74 | } 75 | 76 | .rp-default-input-section a { 77 | display: flex; 78 | align-items: center; 79 | justify-content: center; 80 | width: 35px; 81 | color: white; 82 | font-size: 12px; 83 | background-color: #3b5bdb; 84 | border-radius: 0 5px 5px 0; 85 | cursor: pointer; 86 | transition: background-color 0.5s, color 0.5s; 87 | } 88 | 89 | .rp-default-input-section a:hover, 90 | .rp-default-input-section a:active { 91 | color: #3384ff; 92 | background-color: #5c7cfa; 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/randomId.ts: -------------------------------------------------------------------------------- 1 | export default (len = 6) => { 2 | const chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; 3 | const maxPos = chars.length; 4 | let id = ""; 5 | for (let i = 0; i < len; i++) { 6 | id += chars.charAt(Math.floor(Math.random() * maxPos)); 7 | } 8 | return id; 9 | }; 10 | -------------------------------------------------------------------------------- /stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { withA11y } from "@storybook/addon-a11y"; 2 | import { addDecorator, storiesOf } from "@storybook/react"; 3 | import React, { useEffect, useState } from "react"; 4 | 5 | import { 6 | DefaultInputSection, 7 | defaultShapeStyle, 8 | ReactPictureAnnotation, 9 | } from "../src"; 10 | import { IAnnotation } from "../src/Annotation"; 11 | import { IShapeData } from "../src/Shape"; 12 | 13 | addDecorator((storyFn) =>
{storyFn()}
); 14 | 15 | storiesOf("Hello World", module) 16 | .addDecorator(withA11y) 17 | .add("with text", () => { 18 | const AnnotationComponent = () => { 19 | const [size, setSize] = useState({ 20 | width: window.innerWidth - 16, 21 | height: window.innerHeight - 16, 22 | }); 23 | 24 | const [annotationData, setAnnotationData] = useState< 25 | IAnnotation[] 26 | >([ 27 | { 28 | id: "a", 29 | comment: "HA HA HA", 30 | mark: { 31 | type: "RECT", 32 | width: 161, 33 | height: 165, 34 | x: 229, 35 | y: 92, 36 | }, 37 | }, 38 | ]); 39 | 40 | const [selectedId, setSelectedId] = useState("a"); 41 | 42 | const onResize = () => { 43 | setSize({ 44 | width: window.innerWidth - 16, 45 | height: window.innerHeight - 16, 46 | }); 47 | }; 48 | 49 | useEffect(() => { 50 | window.addEventListener("resize", onResize); 51 | return () => { 52 | window.removeEventListener("resize", onResize); 53 | }; 54 | }, []); 55 | 56 | return ( 57 | setAnnotationData(data)} 62 | selectedId={selectedId} 63 | onSelect={(e) => setSelectedId(e)} 64 | annotationStyle={{ 65 | ...defaultShapeStyle, 66 | shapeStrokeStyle: "#2193ff", 67 | transformerBackground: "black", 68 | }} 69 | defaultAnnotationSize={[120, 90]} 70 | image="https://bequank.oss-cn-beijing.aliyuncs.com/landpage/large/60682895_p0_master1200.jpg" 71 | inputElement={(value, onChange, onDelete) => ( 72 | 76 | )} 77 | /> 78 | ); 79 | }; 80 | 81 | return ; 82 | }); 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declarationDir": "dist/types", 4 | "outDir": "dist/lib", 5 | "baseUrl": "src", 6 | "rootDirs": ["src", "stories"], 7 | "module": "es2015", 8 | "target": "es5", 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "sourceMap": true, 11 | "allowJs": false, 12 | "jsx": "react", 13 | "declaration": true, 14 | "moduleResolution": "node", 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noImplicitAny": true, 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "allowSyntheticDefaultImports": true, 24 | "experimentalDecorators": true, 25 | "emitDecoratorMetadata": true 26 | }, 27 | "include": ["src", "stories"], 28 | "exclude": ["node_modules", "build", "dist", "example", 29 | "rollup.config.js" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "rules": { 4 | "object-literal-sort-keys": false, 5 | "forin": false, 6 | "jsx-no-lambda": false 7 | } 8 | } 9 | --------------------------------------------------------------------------------