├── .DS_Store
├── .babelrc
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .npmignore
├── .prettierrc
├── LICENSE
├── README.md
├── __mocks__
└── styleMock.js
├── example
├── .env
├── .gitignore
├── .prettierrc
├── README.md
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.css
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── car-image.jpg
│ ├── index.css
│ ├── index.tsx
│ ├── logo.svg
│ ├── react-app-env.d.ts
│ ├── serviceWorker.ts
│ └── setupTests.ts
├── tsconfig.json
└── yarn.lock
├── jest.config.js
├── package.json
├── rollup.config.js
├── src
├── image-marker.scss
├── index.test.tsx
├── index.tsx
└── utils
│ ├── index.test.ts
│ └── index.ts
├── tsconfig.json
└── yarn.lock
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galexandrade/react-image-marker/3290a7f4dec7145639efa63ec2e5a5ffe3218c37/.DS_Store
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "env", "stage-0"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/workflows/ main.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 | branches:
5 | - master
6 |
7 | jobs:
8 | test:
9 | name: Tests
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout Repo
13 | uses: actions/checkout@master
14 | - name: Install Dependencies
15 | run: yarn install
16 | - name: Run Tests
17 | env:
18 | CI: true
19 | run: yarn test
20 | - name: Upload coverage
21 | run: yarn coverage:upload --token="${{ secrets.CODECOV_TOKEN }}"
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Directory for instrumented libs generated by jscoverage/JSCover
10 | lib-cov
11 |
12 | # Coverage directory used by tools like istanbul
13 | coverage
14 | *.lcov
15 |
16 | # nyc test coverage
17 | .nyc_output
18 |
19 | # node-waf configuration
20 | .lock-wscript
21 |
22 | # Compiled binary addons (https://nodejs.org/api/addons.html)
23 | build/Release
24 |
25 | # Dependency directories
26 | node_modules/
27 | jspm_packages/
28 |
29 | # TypeScript v1 declaration files
30 | typings/
31 |
32 | # TypeScript cache
33 | *.tsbuildinfo
34 |
35 | # Optional npm cache directory
36 | .npm
37 |
38 | # Optional eslint cache
39 | .eslintcache
40 |
41 | # Output of 'npm pack'
42 | *.tgz
43 |
44 | # Yarn Integrity file
45 | .yarn-integrity
46 |
47 | # generate output
48 | build
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | .storybook
3 | .gitignore
4 | .prettierrc
5 | rollup.config.js
6 | tsconfig.json
7 | example
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "singleQuote": true,
4 | "printWidth": 80
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Alex P. Andrade
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | `react-image-marker` makes it easy to add markers and tags to any image.
27 |
28 | ## Why ?
29 |
30 | Adding markers to images seems something really simple, isn't it? You just need to do some hacks with CSS to position your marker where it was clicked and it is done, right? NO!
31 |
32 | There is a bunch of edge cases that need to be addressed:
33 |
34 | - When the screen size is changed, the markers need to keep the same reference.
35 | - When the image is half shown on the screen (let's suppose the user has scrolled down), clicking on the image should position the marker correctly.
36 | - Much more.
37 |
38 | ## Install
39 |
40 | Install with [npm](https://www.npmjs.com/), or [Yarn](https://yarnpkg.com/):
41 |
42 | ```bash
43 | # via npm
44 | npm install react-image-marker --save
45 |
46 | # or Yarn (note that it will automatically save the package to your `dependencies` in `package.json`)
47 | yarn add react-image-marker
48 | ```
49 |
50 | ## Props
51 |
52 | | Prop | Value | Required |
53 | | :-------------- | :-----------------------: | :------: |
54 | | src | image path | Yes |
55 | | markers | Array | Yes |
56 | | onAddMarker | Function (Marker) => void | No |
57 | | markerComponent | Component | No |
58 | | bufferLeft | Number | No |
59 | | bufferTop | Number | No |
60 | | alt | String | No |
61 | | extraClass | String | No |
62 |
63 | ## Usage
64 |
65 | First, you will need to import the `ImageMarker`. If you are using `typescript` on your project you can also import the type `Marker`.
66 |
67 | ```js
68 | // import the dependencies
69 | import ImageMarker, { Marker } from 'react-image-marker';
70 | ```
71 |
72 | Now you will need to provide an array of `Markers`. In this example, we are storing them with `useState`. `top` and `left` are numbers that represent the PERCENTAGE the marker is positioned in relation to the image size.
73 |
74 | ```js
75 | //Define the markers
76 | const [markers, setMarkers] =
77 | useState <
78 | Array <
79 | Marker >>
80 | [
81 | {
82 | top: 10, //10% of the image relative size from top
83 | left: 50, //50% of the image relative size from left
84 | },
85 | ];
86 | ```
87 |
88 | Now you just need to use the `ImageMarker` to render your markers
89 |
90 | ```js
91 | setMarkers([...markers, marker])}
95 | />
96 | ```
97 |
98 | It is going to render something like this
99 |
100 |
101 |
102 |
103 |
104 | ## Usage - Custom Markers
105 |
106 | You can also use a custom marker if you want.
107 | First, you will need to create a custom marker component.
108 |
109 | ```js
110 | // import the dependencies
111 | import ImageMarker, { Marker, MarkerComponentProps } from 'react-image-marker';
112 |
113 | const CustomMarker = (props: MarkerComponentProps) => {
114 | return (
115 | My custom marker - {props.itemNumber}
116 | );
117 | };
118 | ```
119 |
120 | Now you just need to pass it through the prop `markerComponent`
121 |
122 | ```js
123 | setMarkers([...markers, marker])}
127 | markerComponent={CustomMarker}
128 | />
129 | ```
130 |
131 |
132 |
133 |
134 |
--------------------------------------------------------------------------------
/__mocks__/styleMock.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/example/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/example/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "singleQuote": true,
4 | "printWidth": 80
5 | }
6 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | ## TypeScript example
2 |
3 | To run this example locally you just need a new steps:
4 |
5 | ## Install
6 |
7 | Install with [npm](https://www.npmjs.com/), or [Yarn](https://yarnpkg.com/):
8 |
9 | ```bash
10 | # via npm
11 | npm install
12 |
13 | # or Yarn
14 | yarn install
15 | ```
16 |
17 | ## Start
18 |
19 | ```bash
20 | # via npm
21 | npm start
22 |
23 | # or Yarn
24 | yarn start
25 | ```
26 |
27 | It should be running at `http://localhost:3000/`:
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^4.2.4",
7 | "@testing-library/react": "^9.3.2",
8 | "@testing-library/user-event": "^7.1.2",
9 | "@types/jest": "^24.0.0",
10 | "@types/node": "^12.0.0",
11 | "@types/react": "^16.9.0",
12 | "@types/react-dom": "^16.9.0",
13 | "react": "^16.13.1",
14 | "react-dom": "^16.13.1",
15 | "react-image-marker": "^1.1.0",
16 | "react-scripts": "3.4.3",
17 | "typescript": "~3.7.2"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test",
23 | "eject": "react-scripts eject"
24 | },
25 | "eslintConfig": {
26 | "extends": "react-app"
27 | },
28 | "browserslist": {
29 | "production": [
30 | ">0.2%",
31 | "not dead",
32 | "not op_mini all"
33 | ],
34 | "development": [
35 | "last 1 chrome version",
36 | "last 1 firefox version",
37 | "last 1 safari version"
38 | ]
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galexandrade/react-image-marker/3290a7f4dec7145639efa63ec2e5a5ffe3218c37/example/public/favicon.ico
--------------------------------------------------------------------------------
/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/example/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galexandrade/react-image-marker/3290a7f4dec7145639efa63ec2e5a5ffe3218c37/example/public/logo192.png
--------------------------------------------------------------------------------
/example/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galexandrade/react-image-marker/3290a7f4dec7145639efa63ec2e5a5ffe3218c37/example/public/logo512.png
--------------------------------------------------------------------------------
/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/example/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/example/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .custom-marker {
6 | background-color: black;
7 | color: white;
8 | border-radius: 5px;
9 | padding: 5px;
10 | }
11 |
12 | .frame {
13 | max-width: 500px;
14 | margin: 0 auto;
15 | }
16 |
--------------------------------------------------------------------------------
/example/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render( );
7 | const linkElement = getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/example/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import ImageMarker, { Marker, MarkerComponentProps } from 'react-image-marker';
3 | import CarImage from './car-image.jpg';
4 | import './App.css';
5 |
6 | function App() {
7 | const [markers, setMarkers] = useState>([
8 | { top: 10, left: 50 },
9 | ]);
10 | const CustomMarker = (props: MarkerComponentProps) => {
11 | return (
12 |
13 | My custom marker - {props.itemNumber}
14 |
15 | );
16 | };
17 | return (
18 |
19 |
20 |
24 | setMarkers([...markers, marker])
25 | }
26 | markerComponent={CustomMarker}
27 | />
28 |
29 |
30 | );
31 | }
32 |
33 | export default App;
34 |
--------------------------------------------------------------------------------
/example/src/car-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/galexandrade/react-image-marker/3290a7f4dec7145639efa63ec2e5a5ffe3218c37/example/src/car-image.jpg
--------------------------------------------------------------------------------
/example/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/example/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want your app to work offline and load faster, you can change
15 | // unregister() to register() below. Note this comes with some pitfalls.
16 | // Learn more about service workers: https://bit.ly/CRA-PWA
17 | serviceWorker.unregister();
18 |
--------------------------------------------------------------------------------
/example/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/example/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/example/src/serviceWorker.ts:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | type Config = {
24 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
25 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
26 | };
27 |
28 | export function register(config?: Config) {
29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
30 | // The URL constructor is available in all browsers that support SW.
31 | const publicUrl = new URL(
32 | process.env.PUBLIC_URL,
33 | window.location.href
34 | );
35 | if (publicUrl.origin !== window.location.origin) {
36 | // Our service worker won't work if PUBLIC_URL is on a different origin
37 | // from what our page is served on. This might happen if a CDN is used to
38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
39 | return;
40 | }
41 |
42 | window.addEventListener('load', () => {
43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
44 |
45 | if (isLocalhost) {
46 | // This is running on localhost. Let's check if a service worker still exists or not.
47 | checkValidServiceWorker(swUrl, config);
48 |
49 | // Add some additional logging to localhost, pointing developers to the
50 | // service worker/PWA documentation.
51 | navigator.serviceWorker.ready.then(() => {
52 | console.log(
53 | 'This web app is being served cache-first by a service ' +
54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
55 | );
56 | });
57 | } else {
58 | // Is not localhost. Just register service worker
59 | registerValidSW(swUrl, config);
60 | }
61 | });
62 | }
63 | }
64 |
65 | function registerValidSW(swUrl: string, config?: Config) {
66 | navigator.serviceWorker
67 | .register(swUrl)
68 | .then(registration => {
69 | registration.onupdatefound = () => {
70 | const installingWorker = registration.installing;
71 | if (installingWorker == null) {
72 | return;
73 | }
74 | installingWorker.onstatechange = () => {
75 | if (installingWorker.state === 'installed') {
76 | if (navigator.serviceWorker.controller) {
77 | // At this point, the updated precached content has been fetched,
78 | // but the previous service worker will still serve the older
79 | // content until all client tabs are closed.
80 | console.log(
81 | 'New content is available and will be used when all ' +
82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
83 | );
84 |
85 | // Execute callback
86 | if (config && config.onUpdate) {
87 | config.onUpdate(registration);
88 | }
89 | } else {
90 | // At this point, everything has been precached.
91 | // It's the perfect time to display a
92 | // "Content is cached for offline use." message.
93 | console.log('Content is cached for offline use.');
94 |
95 | // Execute callback
96 | if (config && config.onSuccess) {
97 | config.onSuccess(registration);
98 | }
99 | }
100 | }
101 | };
102 | };
103 | })
104 | .catch(error => {
105 | console.error('Error during service worker registration:', error);
106 | });
107 | }
108 |
109 | function checkValidServiceWorker(swUrl: string, config?: Config) {
110 | // Check if the service worker can be found. If it can't reload the page.
111 | fetch(swUrl, {
112 | headers: { 'Service-Worker': 'script' }
113 | })
114 | .then(response => {
115 | // Ensure service worker exists, and that we really are getting a JS file.
116 | const contentType = response.headers.get('content-type');
117 | if (
118 | response.status === 404 ||
119 | (contentType != null && contentType.indexOf('javascript') === -1)
120 | ) {
121 | // No service worker found. Probably a different app. Reload the page.
122 | navigator.serviceWorker.ready.then(registration => {
123 | registration.unregister().then(() => {
124 | window.location.reload();
125 | });
126 | });
127 | } else {
128 | // Service worker found. Proceed as normal.
129 | registerValidSW(swUrl, config);
130 | }
131 | })
132 | .catch(() => {
133 | console.log(
134 | 'No internet connection found. App is running in offline mode.'
135 | );
136 | });
137 | }
138 |
139 | export function unregister() {
140 | if ('serviceWorker' in navigator) {
141 | navigator.serviceWorker.ready
142 | .then(registration => {
143 | registration.unregister();
144 | })
145 | .catch(error => {
146 | console.error(error.message);
147 | });
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/example/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | testMatch: [
4 | '**/__tests__/**/*.+(ts|tsx|js)',
5 | '**/?(*.)+(spec|test).+(ts|tsx|js)',
6 | ],
7 | transform: {
8 | '^.+\\.(ts|tsx)$': 'ts-jest',
9 | },
10 | moduleNameMapper: {
11 | '\\.(scss|less)$': '/__mocks__/styleMock.js',
12 | },
13 | setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
14 | collectCoverage: true,
15 | };
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-image-marker",
3 | "version": "1.2.0",
4 | "description": "Allows to add markers to an image",
5 | "main": "build/index.js",
6 | "types": "build/index.d.ts",
7 | "scripts": {
8 | "test": "jest",
9 | "coverage:upload": "./node_modules/.bin/codecov",
10 | "prebuild": "rm -rf build",
11 | "build": "rollup -c",
12 | "postbuild": "find build -name *.test.d.ts -exec rm -if {} \\; && rm -r build/utils",
13 | "start": "rollup -c -w"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/galexandrade/react-image-marker.git"
18 | },
19 | "keywords": [
20 | "react",
21 | "image-marker"
22 | ],
23 | "author": "Alex P. Andrade",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/galexandrade/react-image-marker/issues"
27 | },
28 | "homepage": "https://github.com/galexandrade/react-image-marker#readme",
29 | "peerDependencies": {
30 | "react": ">= 16.8.0",
31 | "react-dom": ">= 16.8.0"
32 | },
33 | "devDependencies": {
34 | "@testing-library/jest-dom": "^5.11.4",
35 | "@testing-library/react": "^11.0.4",
36 | "@types/jest": "^26.0.14",
37 | "@types/react": "^16.3.13",
38 | "@types/react-dom": "^16.0.5",
39 | "babel-core": "^6.26.3",
40 | "babel-runtime": "^6.26.0",
41 | "codecov": "^3.7.2",
42 | "jest": "^26.4.2",
43 | "react": "^16.12.0",
44 | "react-dom": "^16.12.0",
45 | "rollup": "^1.29.0",
46 | "rollup-plugin-sass": "^1.2.2",
47 | "rollup-plugin-typescript2": "^0.25.3",
48 | "ts-jest": "^26.4.0",
49 | "typescript": "^2.8.3"
50 | },
51 | "files": [
52 | "build"
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import sass from 'rollup-plugin-sass';
2 | import typescript from 'rollup-plugin-typescript2';
3 |
4 | import pkg from './package.json';
5 |
6 | export default {
7 | input: 'src/index.tsx',
8 | output: [
9 | {
10 | file: pkg.main,
11 | format: 'cjs',
12 | exports: 'named',
13 | sourcemap: true,
14 | strict: false,
15 | libraryTarget: 'commonjs2',
16 | },
17 | ],
18 | plugins: [sass({ insert: true }), typescript()],
19 | external: ['react', 'react-dom'],
20 | };
21 |
--------------------------------------------------------------------------------
/src/image-marker.scss:
--------------------------------------------------------------------------------
1 | .image-marker {
2 | position: relative;
3 | margin: 0 auto;
4 |
5 | &__image {
6 | margin: 0 auto;
7 | width: 100%;
8 | }
9 |
10 | &__marker {
11 | position: absolute;
12 | }
13 |
14 | &__marker--default {
15 | width: 25px;
16 | height: 25px;
17 | background-color: brown;
18 | border-radius: 50%;
19 | color: white;
20 | text-align: center;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { render, fireEvent } from '@testing-library/react';
3 | import ImageMarker from '.';
4 |
5 | describe('ImageMarker', () => {
6 | test('the image render', () => {
7 | const altText = 'My cool image';
8 | const { queryByAltText } = render(
9 |
14 | );
15 | expect(queryByAltText(altText)).toBeInTheDocument();
16 | });
17 | test('the image render with markers', () => {
18 | const markers = [
19 | {
20 | top: 10,
21 | left: 30,
22 | },
23 | {
24 | top: 40,
25 | left: 50,
26 | },
27 | ];
28 | const { queryAllByTestId } = render(
29 |
33 | );
34 | expect(queryAllByTestId('marker').length).toBe(markers.length);
35 | });
36 | test('it adds new markers', () => {
37 | const handleOnAddMarker = jest.fn();
38 | const altText = 'My cool image';
39 | const { getByAltText } = render(
40 |
46 | );
47 | fireEvent.click(getByAltText(altText));
48 | expect(handleOnAddMarker).toHaveBeenCalled();
49 | });
50 | test('it renders custom markers', () => {
51 | const handleOnAddMarker = jest.fn();
52 | const altText = 'My cool image';
53 | const markers = [
54 | {
55 | top: 10,
56 | left: 30,
57 | },
58 | ];
59 |
60 | const CustomMarker = ({ top, left, itemNumber }: any) => (
61 | {`CUSTOM [${top}, ${left}, ${itemNumber}]`}
62 | );
63 |
64 | const { queryByText } = render(
65 |
72 | );
73 | expect(queryByText('CUSTOM [10, 30, 0]')).toBeInTheDocument();
74 | });
75 | test('it wont crash if clicks on the image and onAddMarker is not set', () => {
76 | const altText = 'My cool image';
77 | const { getByAltText } = render(
78 |
83 | );
84 | fireEvent.click(getByAltText(altText));
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import './image-marker.scss';
3 | import { calculateMarkerPosition } from './utils';
4 |
5 | const DEFAULT_BUFFER = 12;
6 |
7 | export type Marker = {
8 | top: Number;
9 | left: Number;
10 | };
11 | export type MarkerComponentProps = {
12 | top: Number;
13 | left: Number;
14 | itemNumber: Number;
15 | };
16 |
17 | type Props = {
18 | src: string;
19 | markers: Array;
20 | onAddMarker?: (marker: Marker) => void;
21 | markerComponent?: React.FC;
22 | bufferLeft?: number;
23 | bufferTop?: number;
24 | alt?: string;
25 | extraClass?: string;
26 | };
27 | const ImageMarker: React.FC = ({
28 | src,
29 | markers,
30 | onAddMarker,
31 | markerComponent: MarkerComponent,
32 | bufferLeft = DEFAULT_BUFFER,
33 | bufferTop = DEFAULT_BUFFER,
34 | alt = 'Markers',
35 | extraClass = '',
36 | }: Props) => {
37 | const imageRef = React.useRef(null);
38 | const handleImageClick = (event: React.MouseEvent) => {
39 | if (!imageRef.current || !onAddMarker) {
40 | return;
41 | }
42 | const imageDimentions = imageRef.current.getBoundingClientRect();
43 |
44 | const [top, left] = calculateMarkerPosition(
45 | event,
46 | imageDimentions,
47 | window.scrollY,
48 | bufferLeft,
49 | bufferTop
50 | );
51 |
52 | onAddMarker({
53 | top,
54 | left,
55 | });
56 | };
57 |
58 | const getItemPosition = (marker: Marker) => {
59 | return {
60 | top: `${marker.top}%`,
61 | left: `${marker.left}%`,
62 | };
63 | };
64 |
65 | return (
66 |
67 |
74 | {markers.map((marker, itemNumber) => (
75 |
83 | {MarkerComponent ? (
84 |
85 | ) : (
86 | itemNumber + 1
87 | )}
88 |
89 | ))}
90 |
91 | );
92 | };
93 |
94 | export default ImageMarker;
95 |
--------------------------------------------------------------------------------
/src/utils/index.test.ts:
--------------------------------------------------------------------------------
1 | import { calculateMarkerPosition, ImagePosition } from '.';
2 |
3 | describe('calculateMarkerPosition', () => {
4 | test('the target is calculated right when the image is on the corner', () => {
5 | const imageTop = 0;
6 | const imageLeft = 0;
7 |
8 | const scrollY = 0;
9 |
10 | const mouseEvent = {
11 | clientX: imageLeft + 10,
12 | pageY: imageTop + 20,
13 | };
14 | const imagePosition = mockImagePosition({
15 | top: imageTop - scrollY,
16 | left: imageLeft,
17 | });
18 | const bufferLeft = 0;
19 | const bufferTop = 0;
20 | const [top, left] = calculateMarkerPosition(
21 | mouseEvent,
22 | imagePosition,
23 | scrollY,
24 | bufferLeft,
25 | bufferTop
26 | );
27 |
28 | expect(left).toBe(10);
29 | expect(top).toBe(20);
30 | });
31 | test('the target is calculated right when the image is 100px from top and 200px from left', () => {
32 | const imageTop = 100;
33 | const imageLeft = 200;
34 |
35 | const scrollY = 0;
36 |
37 | const mouseEvent = {
38 | clientX: imageLeft + 10,
39 | pageY: imageTop + 20,
40 | };
41 | const imagePosition = mockImagePosition({
42 | top: imageTop - scrollY,
43 | left: imageLeft,
44 | });
45 | const bufferLeft = 0;
46 | const bufferTop = 0;
47 | const [top, left] = calculateMarkerPosition(
48 | mouseEvent,
49 | imagePosition,
50 | scrollY,
51 | bufferLeft,
52 | bufferTop
53 | );
54 |
55 | expect(left).toBe(10);
56 | expect(top).toBe(20);
57 | });
58 | test('the target is calculated right when there is scrollTop but the image is showing completely', () => {
59 | const imageTop = 100;
60 | const imageLeft = 200;
61 |
62 | const scrollY = 80;
63 |
64 | const mouseEvent = {
65 | clientX: imageLeft + 10,
66 | pageY: imageTop + 20,
67 | };
68 | const imagePosition = mockImagePosition({
69 | top: imageTop - scrollY,
70 | left: imageLeft,
71 | });
72 | const bufferLeft = 0;
73 | const bufferTop = 0;
74 | const [top, left] = calculateMarkerPosition(
75 | mouseEvent,
76 | imagePosition,
77 | scrollY,
78 | bufferLeft,
79 | bufferTop
80 | );
81 |
82 | expect(left).toBe(10);
83 | expect(top).toBe(20);
84 | });
85 | test('the target is calculated right when there is scrollTop and the image is showing partialy', () => {
86 | const imageTop = 100;
87 | const imageLeft = 200;
88 |
89 | //As the image height is 100, the scroll is covering 80% of the image
90 | const scrollY = imageTop + 80;
91 |
92 | const mouseEvent = {
93 | clientX: imageLeft + 10,
94 | pageY: imageTop + 20,
95 | };
96 | const imagePosition = mockImagePosition({
97 | top: imageTop - scrollY,
98 | left: imageLeft,
99 | });
100 | const bufferLeft = 0;
101 | const bufferTop = 0;
102 | const [top, left] = calculateMarkerPosition(
103 | mouseEvent,
104 | imagePosition,
105 | scrollY,
106 | bufferLeft,
107 | bufferTop
108 | );
109 |
110 | expect(left).toBe(10);
111 | expect(top).toBe(20);
112 | });
113 | });
114 |
115 | const mockImagePosition = ({
116 | top = 0,
117 | left = 0,
118 | width = 100,
119 | height = 100,
120 | }: {
121 | top?: number;
122 | left?: number;
123 | width?: number;
124 | height?: number;
125 | }): ImagePosition => ({
126 | top,
127 | left,
128 | width,
129 | height,
130 | });
131 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export type ImagePosition = {
2 | top: number;
3 | left: number;
4 | width: number;
5 | height: number;
6 | };
7 | export type MousePosition = {
8 | clientX: number;
9 | pageY: number;
10 | };
11 | export const calculateMarkerPosition = (
12 | mousePosition: MousePosition,
13 | imagePosition: ImagePosition,
14 | scrollY: number,
15 | bufferLeft: number,
16 | bufferTop: number
17 | ) => {
18 | const pixelsLeft = mousePosition.clientX - imagePosition.left;
19 | let pixelsTop;
20 | if (imagePosition.top < 0) {
21 | pixelsTop = mousePosition.pageY - scrollY + imagePosition.top * -1;
22 | } else {
23 | pixelsTop = mousePosition.pageY - scrollY - imagePosition.top;
24 | }
25 | const top = ((pixelsTop - bufferTop) * 100) / imagePosition.height;
26 | const left = ((pixelsLeft - bufferLeft) * 100) / imagePosition.width;
27 | return [top, left];
28 | };
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "module": "esnext",
5 | "target": "es5",
6 | "lib": ["es6", "dom", "es2016", "es2017"],
7 | "sourceMap": true,
8 | "allowJs": false,
9 | "jsx": "react",
10 | "declaration": true,
11 | "moduleResolution": "node",
12 | "forceConsistentCasingInFileNames": true,
13 | "noImplicitReturns": true,
14 | "noImplicitThis": true,
15 | "noImplicitAny": true,
16 | "strictNullChecks": true,
17 | "suppressImplicitAnyIndexErrors": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true
20 | },
21 | "include": ["src"],
22 | "exclude": ["node_modules", "dist", "example", "rollup.config.js"]
23 | }
24 |
--------------------------------------------------------------------------------