├── .eslintrc ├── .github └── workflows │ ├── publish.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── example ├── index.html └── index.tsx ├── package.json ├── src └── index.tsx ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "prettier", 5 | "plugin:prettier/recommended", 6 | "eslint:recommended", 7 | "plugin:react/recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "plugins": [ 13 | "@typescript-eslint", 14 | "prettier", 15 | "react", 16 | "react-hooks" 17 | ], 18 | "rules": { 19 | "react-hooks/rules-of-hooks": "error", 20 | "react-hooks/exhaustive-deps": "warn", 21 | "react/display-name": "off", 22 | "@typescript-eslint/no-non-null-assertion": "off", 23 | "@typescript-eslint/ban-ts-comment": "off", 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "@typescript-eslint/no-var-requires": "off" 26 | }, 27 | "settings": { 28 | "react": { 29 | "version": "detect" 30 | } 31 | }, 32 | "env": { 33 | "browser": true, 34 | "node": true 35 | }, 36 | "globals": { 37 | "JSX": true 38 | }, 39 | "ignorePatterns": ["*.js", "**/dist/**", "**/lib/**"] 40 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Setup Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version-file: '.nvmrc' 17 | registry-url: 'https://registry.npmjs.org' 18 | - name: Install dependencies 19 | run: yarn && yarn install 20 | - name: Build 21 | run: yarn prepare 22 | - name: Publish 23 | run: yarn publish 24 | env: 25 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version-file: '.nvmrc' 18 | cache: 'yarn' 19 | - name: Get version from package.json 20 | id: get_version 21 | run: echo "version=v$(node -p -e "require('./package.json').version")" >> $GITHUB_OUTPUT 22 | - name: Check version 23 | uses: mukunku/tag-exists-action@v1.2.0 24 | id: check_version 25 | with: 26 | tag: ${{ steps.get_version.outputs.version }} 27 | - name: Release 28 | if: steps.check_version.outputs.exists != 'true' 29 | id: create_release 30 | uses: softprops/action-gh-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.NPM_GITHUB_TOKEN }} 33 | with: 34 | tag_name: ${{ steps.get_version.outputs.version }} 35 | draft: false 36 | prerelease: false 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node modules 2 | node_modules 3 | dist 4 | yarn-error.log 5 | 6 | # dotenv environment variable files 7 | .env 8 | .env.local 9 | .env.*.local 10 | 11 | # parcel-bundler cache (https://parceljs.org/) 12 | .cache 13 | .parcel-cache -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "semi": false, 6 | "bracketSpacing": true, 7 | "requirePragma": false 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jason Mendoza 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 | [![NPM](https://img.shields.io/npm/v/react-selection-popup.svg)](https://www.npmjs.com/package/react-selection-popup) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 2 | 3 | # React Selection Popup 4 | 5 | 6 | ![image](https://github.com/jasonmz/react-selection-popup/assets/48445639/a4359e07-65b1-4e29-b852-fbc3e449d86e) 7 | 8 | React Selection Popup is an npm package that allows you to show a popup when a user selects text contents in jsx elements like div. The package is built on top of React and provides an easy-to-use API for developers who want to add selection popups to their projects. 9 | 10 | ## Installation 11 | 12 | You can install the package using either `npm` or `yarn`. 13 | 14 | ```sh 15 | npm install react-selection-popup 16 | ``` 17 | 18 | ```sh 19 | yarn add react-selection-popup 20 | ``` 21 | 22 | ## Usage 23 | 24 | To use React Selection Popup, you need to import it into your project and then wrap the content you want to make selectable inside the component. Here's an example: 25 | 26 | ```jsx 27 | import React, { useRef } from 'react'; 28 | import ReactSelectionPopup, { PopupHandle } from 'react-selection-popup'; 29 | 30 | const App = () => { 31 | const ref = useRef(null) 32 | 33 | return ( 34 |
35 | console.debug(text, meta)} 40 | > 41 |
42 |

Popup Content

43 | 44 |
45 |
46 |

47 | Select me to see the popup. 48 |

49 |
50 | ); 51 | }; 52 | ``` 53 | 54 | In this example, we have a simple React component with two elements. The first element is the `ReactSelectionPopup` component, which wraps the content of the popup. The second element is a `p` tag that has the class name `selection` and a data attribute `data-meta` to set a metadata. When the user selects the text inside this `p` tag, the popup defined in the `ReactSelectionPopup` component will appear. 55 | 56 | ## Props 57 | 58 | | name | type | description | 59 | | --- | ---- | --- | 60 | | `ref` | `{ current?: { close: () => void } }` | The Ref of popup handler that returns function `close` to force the popup to be closed. | 61 | | `onSelect` | `(text: string, meta?: any) => void` | This is an optional function property that takes two parameters: a string representing the selected text and an optional parameter metadata, which could be a boolean, string, number or object. The function is called when a user selects text in HTML. | 62 | | `children` | `React.ReactNode` | __required__ This property is required and represents child elements to be displayed within the component. | 63 | | `selectionClassName` | `string` | __required__ This property is required and specifies the class name used to identify selectable element(s). | 64 | | `multipleSelection` | `boolean` | This is an optional boolean property that specifies whether multiple elements can be selected at once. The default value is false. | 65 | | `metaAttrName` | `string` | This is an optional string property that represents the name of the metadata attribute associated with the selected text. The metadata value should be JSON stringified. This is useful in case there are multiple metadata attributes for different types of data on the same page. | 66 | | `offsetToLeft` | `number` | This is an optional numerical property representing the offset (in pixels) to move the popup along the x-axis relative to its initial position on the screen. A positive value moves it to the left and a negative value moves it to the right. The default pivot point is the right side of the popup. | 67 | | `offsetToTop` | `number` | This is an optional numerical property representing the offset (in pixels) to move the popup along the y-axis relative to its initial position on the screen. A positive value moves it upwards and a negative value moves it downwards. The default pivot point is the bottom of the popup. | 68 | 69 | ## Contributing 70 | 71 | Contributions are always welcome! If you find a bug or have a feature request, please [open an issue](https://github.com/jasonmz/react-selection-popup/issues/new). 72 | 73 | ## License 74 | 75 | This package is licensed under the [MIT License](https://opensource.org/licenses/MIT). 76 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Selection Popup Example 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import ReactSelectionPopup, { HandleRef } from '../src' 4 | 5 | const App = () => { 6 | const ref = useRef(null) 7 | 8 | return ( 9 |
10 | console.debug(text, meta)} 17 | onClose={() => false} 18 | > 19 |
20 |
Sample Popup
21 | 28 |
29 |
30 |
31 |
32 |
33 |

34 | Select some text to see the popup. 35 |

36 |
37 |
38 |
39 |
40 | ) 41 | } 42 | 43 | const container = document.getElementById('root') as Element 44 | const root = ReactDOM.createRoot(container) 45 | 46 | root.render() 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-selection-popup", 3 | "version": "0.4.7", 4 | "description": "Simple, yet convenient React popup component that shows when you select texts.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/jasonmz/react-selection-popup.git" 8 | }, 9 | "author": "Jason Mendoza ", 10 | "license": "MIT", 11 | "files": [ 12 | "dist", 13 | "LICENSE", 14 | "README.md" 15 | ], 16 | "keywords": [ 17 | "react", 18 | "typescript", 19 | "selection-popup" 20 | ], 21 | "main": "./dist/cjs/index.js", 22 | "module": "./dist/esm/index.js", 23 | "types": "./dist/esm/index.d.ts", 24 | "scripts": { 25 | "start": "npx parcel ./example/index.html --open --port 3000", 26 | "prepare": "npm run build", 27 | "prepublish": "npm run prettier && npm run lint", 28 | "build": "yarn build:esm && yarn build:cjs", 29 | "build:esm": "tsc", 30 | "build:cjs": "tsc --module commonjs --outDir dist/cjs", 31 | "lint": "eslint \"{**/*,*}.{js,ts,jsx,tsx}\"", 32 | "prettier": "prettier --write \"{src,tests,example/src}/**/*.{js,ts,jsx,tsx}\"" 33 | }, 34 | "devDependencies": { 35 | "@types/react": "^18.0.26", 36 | "@typescript-eslint/eslint-plugin": "^5.59.9", 37 | "@typescript-eslint/parser": "^5.59.9", 38 | "eslint": "^8.31.0", 39 | "eslint-config-react-app": "^7.0.1", 40 | "eslint-config-prettier": "^8.8.0", 41 | "eslint-plugin-prettier": "^4.2.1", 42 | "eslint-plugin-react": "^7.32.2", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "eslint-plugin-import": "^2.27.4", 45 | "parcel": "^2.8.3", 46 | "prettier": "^2.8.8", 47 | "process": "^0.11.10", 48 | "react": ">=18.2.0", 49 | "react-dom": "^18.2.0", 50 | "typescript": "^5.0.4", 51 | "react-selection-popup": "^0.4.4" 52 | }, 53 | "peerDependencies": { 54 | "react": ">=18.2.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' 2 | 3 | interface ReactSelectionPopupProps { 4 | /** 5 | * This function is called when a user selects texts in html. 6 | * @param text - The text of the selection 7 | * @param meta - Additional metadata associated with the selected text (optional) 8 | */ 9 | onSelect?: (text: string, meta?: any) => void 10 | /** 11 | * This function is called when a popup is closed due to focus lost. 12 | */ 13 | onClose?: () => void 14 | /** 15 | * This function returns a function to close a popup. 16 | */ 17 | children: React.ReactNode 18 | /** 19 | * The className to be used to identify selectable element(s). 20 | */ 21 | selectionClassName: string 22 | /** 23 | * Whether multiple elements can be selected at once (default is false). 24 | */ 25 | multipleSelection?: boolean 26 | /** 27 | * The name of the metadata attribute associated with the selected text (optional). 28 | * The metadata value should be JSON stringfied. 29 | * @example
...
30 | * ... 31 | * ... 32 | */ 33 | metaAttrName?: string 34 | /** 35 | * The offset (in pixels) to the left direction of the screen to reposition the popup. The default pivot x is right of the pop. 36 | */ 37 | offsetToLeft?: number 38 | /** 39 | * The offset (in pixels) to the top direction of the screen to reposition the popup. The default pivot y is bottom of the pop. 40 | */ 41 | offsetToTop?: number 42 | 43 | id?: string 44 | className?: string 45 | style?: React.CSSProperties 46 | } 47 | 48 | type Size = { 49 | /** 50 | * The width of the element in pixels. 51 | */ 52 | width: number 53 | /** 54 | * The height of the element in pixels. 55 | */ 56 | height: number 57 | } 58 | 59 | type Position = { 60 | /** 61 | * The x-coordinate of the upper-left corner of the element. 62 | */ 63 | x: number 64 | /** 65 | * The y-coordinate of the upper-left corner of the element. 66 | */ 67 | y: number 68 | } 69 | 70 | export interface HandleRef { 71 | close: () => void 72 | } 73 | 74 | const ReactSelectionPopup: React.ForwardRefRenderFunction = ( 75 | { 76 | onSelect, 77 | onClose, 78 | children, 79 | selectionClassName, 80 | multipleSelection = true, 81 | metaAttrName, 82 | offsetToLeft = 0, 83 | offsetToTop = 0, 84 | ...rest 85 | }, 86 | ref 87 | ) => { 88 | const [size, setSize] = useState({ width: 0, height: 0 }) 89 | const [position, setPosition] = useState(null) 90 | 91 | const popupRef = useRef(null) 92 | const positionRef = useRef(null) 93 | 94 | positionRef.current = position 95 | 96 | const isPopupContent = useCallback((e: any) => { 97 | let node: HTMLElement | null = e.target as HTMLElement 98 | 99 | // Check if the target div is popup which is the exception case 100 | while (node != null) { 101 | if (node === popupRef.current) { 102 | return true 103 | } 104 | 105 | node = node.parentNode as HTMLElement 106 | } 107 | 108 | return false 109 | }, []) 110 | 111 | const close = useCallback(() => { 112 | setPosition(null) 113 | onClose?.() 114 | }, [onClose]) 115 | 116 | useEffect(() => { 117 | const onMouseUp = (e: any) => { 118 | const selection = window.getSelection() 119 | if (selection !== null) { 120 | const { anchorNode, focusNode } = selection 121 | 122 | if (anchorNode !== null && focusNode !== null) { 123 | if (anchorNode.parentElement !== null && anchorNode.parentElement.classList.contains(selectionClassName)) { 124 | const text = selection.toString() 125 | const meta = JSON.parse(e.target.getAttribute(metaAttrName)) 126 | 127 | if (text) { 128 | if (!metaAttrName || meta) { 129 | if (selection.rangeCount !== 0) { 130 | if (anchorNode.isEqualNode(focusNode) || multipleSelection) { 131 | const range = selection.getRangeAt(0) 132 | 133 | const { right: x, top: y } = range.getBoundingClientRect() 134 | 135 | // TODO: position {x, y} should come from the first line of selection 136 | 137 | setPosition({ x, y }) 138 | onSelect?.(text, meta) 139 | return 140 | } else { 141 | selection.removeAllRanges() 142 | } 143 | } 144 | } 145 | 146 | if (!isPopupContent(e)) { 147 | close() 148 | } 149 | } else { 150 | setPosition(null) 151 | } 152 | } 153 | } 154 | } 155 | } 156 | 157 | const onMousedown = (e: any) => { 158 | const selection = window.getSelection() 159 | 160 | if (!isPopupContent(e) && positionRef.current !== null && selection !== null) { 161 | selection.removeAllRanges() 162 | close() 163 | } 164 | } 165 | 166 | const onScroll = () => { 167 | close() 168 | } 169 | 170 | window.addEventListener('mouseup', onMouseUp) 171 | window.addEventListener('mousedown', onMousedown) 172 | window.addEventListener('scroll', onScroll) 173 | 174 | return () => { 175 | window.removeEventListener('mouseup', onMouseUp) 176 | window.removeEventListener('mousedown', onMousedown) 177 | window.removeEventListener('scroll', onScroll) 178 | } 179 | }, [close, onSelect, onClose, isPopupContent, position, multipleSelection, selectionClassName, metaAttrName]) 180 | 181 | useEffect(() => { 182 | if (popupRef.current) { 183 | const width = popupRef.current.offsetWidth 184 | const height = popupRef.current.offsetHeight 185 | 186 | setSize({ width, height }) 187 | } 188 | }, [children, position, popupRef]) 189 | 190 | useImperativeHandle(ref, (): HandleRef => { 191 | return { 192 | close 193 | } 194 | }) 195 | 196 | if (position === null) return <> 197 | 198 | const left = position.x - size.width - offsetToLeft 199 | const top = position.y - size.height - offsetToTop 200 | 201 | return ( 202 |
203 |
204 | {children} 205 |
206 |
207 | ) 208 | } 209 | 210 | export default React.forwardRef(ReactSelectionPopup) 211 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": [ 4 | "dist", 5 | "node_modules" 6 | ], 7 | "compilerOptions": { 8 | "module": "esnext", 9 | "lib": ["dom", "esnext"], 10 | "importHelpers": true, 11 | "declaration": true, 12 | "sourceMap": true, 13 | "rootDir": "./src", 14 | "outDir": "./dist/esm", 15 | "strict": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "moduleResolution": "node", 21 | "jsx": "react", 22 | "esModuleInterop": true, 23 | "skipLibCheck": true, 24 | "forceConsistentCasingInFileNames": true, 25 | } 26 | } --------------------------------------------------------------------------------