├── .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 | 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 | {alt} 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 | --------------------------------------------------------------------------------