├── .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 | [](https://github.com/kunduin/react-picture-annotation/blob/master/LICENSE) [](https://travis-ci.com/Kunduin/react-picture-annotation) [](https://www.npmjs.com/package/react-picture-annotation)
4 |
5 | A simple annotation component.
6 |
7 | 
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 | [](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 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/DeleteButton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default () => {
4 | return (
5 |
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 |
--------------------------------------------------------------------------------