├── .eslintrc.cjs ├── .github └── workflows │ └── tests.js.yml ├── .gitignore ├── README.md ├── dts-bundle-generator.config.ts ├── index.html ├── package-lock.json ├── package.json ├── public ├── preview.png └── vite.svg ├── src ├── App.tsx ├── HighlightMenu.tsx ├── MenuButton.tsx ├── classNames.ts ├── index.ts ├── main.tsx ├── tests │ └── useGetSelectionDetails.test.tsx ├── types.ts ├── useGetSelectionDetails.tsx ├── utils.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.pages.ts └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended", 7 | ], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 10 | plugins: ["react-refresh"], 11 | rules: { 12 | "react-refresh/only-export-components": "warn", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | pages 13 | coverage 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | .npmrc 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-highlight-menu 2 | 3 | [![NPM Version](https://shields.io/npm/v/react-highlight-menu)](https://www.npmjs.com/package/react-highlight-menu) 4 | ![Tests](https://github.com/asyndesis/react-highlight-menu/actions/workflows/tests.js.yml/badge.svg) 5 | 6 | A context menu that appears after highlighting or selecting text. 7 | _Similar to how the menu on Medium works._ 8 | 9 | ![preview](https://asyndesis.github.io/react-highlight-menu/preview.png) 10 | 11 | - [Demo](https://asyndesis.github.io/react-highlight-menu/) - Buttons and icons from [ChakraUI](https://chakra-ui.com/) 12 | 13 | ## Installation 14 | 15 | Run one of the following commands: 16 | 17 | - `npm install react-highlight-menu` 18 | - `yarn add react-highlight-menu` 19 | 20 | Then use it in your app: 21 | 22 | ```jsx 23 | import React from "react"; 24 | /* Library comes with some super basic MenuButtons. You can import default styles and use them as a starting point. 25 | The example below shows how to use the `classNames` prop to style the menu, popover, and arrow elements with something like Tailwind.*/ 26 | import { HighlightMenu, MenuButton } from "react-highlight-menu"; 27 | 28 | export default function App() { 29 | return ( 30 |
31 | ( 40 | <> 41 | 46 | setClipboard(selectedText, () => { 47 | alert("Copied to clipboard"); 48 | }) 49 | } 50 | /> 51 | { 55 | window.open( 56 | `https://www.google.com/search?q=${encodeURIComponent( 57 | selectedText 58 | )}` 59 | ); 60 | }} 61 | icon="magnifying-glass" 62 | /> 63 | setMenuOpen(false)} 67 | icon="x-mark" 68 | /> 69 | 70 | )} 71 | allowedPlacements={["top", "bottom"]} 72 | /> 73 |
74 | ); 75 | } 76 | ``` 77 | 78 | ## Props 79 | 80 | - **target** - can either be a querySelector string, or a react ref. 81 | - **styles** - several css attributes can be applied for styling. (See demo) 82 | - **menu** - `({ selectedHtml, selectedText, setMenuOpen, setClipboard }) => <>Buttons` 83 | - **allowedPlacements** - array of allowed placements `-'auto' | 'auto-start' | 'auto-end' | 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'right' | 'right-start' | 'right-end' | 'left' | 'left-start' | 'left-end'` 84 | - **offset** - distance in pixels from highlighted words. `10` 85 | - **zIndex** - zIndex of the popover `999999999` 86 | - **withoutStyles** - if true, the menu will be only styled with props that help it to function. And we the following classnames can be targeted for styling: 87 | - **classNames** - an object where classnames can be passed to the menu, popover, and arrow elements. 88 | - `menu` - for the menu element 89 | - `popover` - for the popover element 90 | - `arrow` - for the arrow element 91 | 92 | ## These classnames are also targetable for styling if you need more control over the styles 93 | 94 | - `.rhm-popover` - for the popover element 95 | - `.rhm-menu` - for the menu element 96 | - `.rhm-button` - for the button element 97 | - `.rhm-arrow` - for the arrow element 98 | - `.rhm-anchor` - for the anchor element 99 | 100 | ## Development 101 | 102 | ```bash 103 | npm install 104 | npm run dev 105 | ``` 106 | -------------------------------------------------------------------------------- /dts-bundle-generator.config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | entries: [ 3 | { 4 | filePath: "./src/index.ts", 5 | outFile: "./dist/index.d.ts", 6 | noCheck: false, 7 | output: { 8 | umdModuleName: "ReactHighlightMenu", 9 | }, 10 | }, 11 | ], 12 | }; 13 | 14 | module.exports = config; 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Highlight Menu 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-highlight-menu", 3 | "version": "2.1.1", 4 | "files": [ 5 | "dist", 6 | "README.md" 7 | ], 8 | "main": "dist/index.cjs.js", 9 | "module": "dist/index.es.js", 10 | "types": "dist/index.d.ts", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/asyndesis/react-highlight-menu" 15 | }, 16 | "homepage": "https://asyndesis.github.io/react-highlight-menu", 17 | "scripts": { 18 | "dev": "vite", 19 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 20 | "preview": "vite preview", 21 | "test": "vitest", 22 | "coverage": "vitest run --coverage", 23 | "build": "rm -rf dist && rm -rf pages && tsc && vite build --config vite.config.ts && vite build --config vite.config.pages.ts && dts-bundle-generator --config ./dts-bundle-generator.config.ts", 24 | "deploy:pages": "gh-pages -d pages" 25 | }, 26 | "dependencies": { 27 | "@floating-ui/react": "^0.27.4", 28 | "clsx": "^2.0.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/cli": "^7.22.5", 32 | "@babel/core": "^7.22.5", 33 | "@babel/preset-react": "^7.22.5", 34 | "@chakra-ui/icons": "^2.0.19", 35 | "@chakra-ui/react": "^2.7.1", 36 | "@emotion/react": "^11.11.1", 37 | "@emotion/styled": "^11.11.0", 38 | "@testing-library/jest-dom": "^5.16.5", 39 | "@testing-library/react": "^14.0.0", 40 | "@types/react": "^18.0.37", 41 | "@types/react-dom": "^18.0.11", 42 | "@types/react-syntax-highlighter": "^15.5.7", 43 | "@typescript-eslint/eslint-plugin": "^5.59.0", 44 | "@typescript-eslint/parser": "^5.59.0", 45 | "@vitejs/plugin-react-swc": "^3.0.0", 46 | "dts-bundle-generator": "^8.0.1", 47 | "eslint": "^8.38.0", 48 | "eslint-plugin-react-hooks": "^4.6.0", 49 | "eslint-plugin-react-refresh": "^0.3.4", 50 | "framer-motion": "^10.12.17", 51 | "fs-extra": "^11.1.1", 52 | "gh-pages": "^5.0.0", 53 | "jsdom": "^22.1.0", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0", 56 | "react-syntax-highlighter": "^15.5.0", 57 | "rollup-plugin-md": "^1.0.1", 58 | "typescript": "^5.0.2", 59 | "vite": "^4.3.9", 60 | "vite-plugin-dts": "^3.0.0-beta.3", 61 | "vitest": "^0.32.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asyndesis/react-highlight-menu/abea1f1c79ccd686a5c9ddd0fc4a81000b989e67/public/preview.png -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, CSSProperties } from "react"; 2 | import { HighlightMenu, MenuButton } from "."; 3 | import { 4 | CopyIcon, 5 | SearchIcon, 6 | CloseIcon, 7 | ChevronDownIcon, 8 | ChevronUpIcon, 9 | } from "@chakra-ui/icons"; 10 | import { 11 | ChakraProvider, 12 | IconButton, 13 | Flex, 14 | Heading, 15 | Text, 16 | Card, 17 | Tooltip, 18 | CardBody, 19 | FormControl, 20 | FormLabel, 21 | Switch, 22 | Input, 23 | Textarea, 24 | Box, 25 | Accordion, 26 | AccordionItem, 27 | AccordionButton, 28 | AccordionPanel, 29 | } from "@chakra-ui/react"; 30 | import { Global } from "@emotion/react"; 31 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 32 | import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; 33 | 34 | import { MenuArgs } from "./types"; 35 | 36 | const MENU_STYLES: Record = { 37 | black: { 38 | borderColor: "black", 39 | backgroundColor: "black", 40 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)", 41 | zIndex: 10, 42 | borderRadius: "5px", 43 | padding: "3px", 44 | }, 45 | white: { 46 | borderColor: "white", 47 | backgroundColor: "white", 48 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)", 49 | zIndex: 10, 50 | borderRadius: "5px", 51 | padding: "3px", 52 | }, 53 | pink: { 54 | borderColor: "#D53F8C", 55 | backgroundColor: "#D53F8C", 56 | boxShadow: "0px 5px 5px 0px rgba(0, 0, 0, 0.15)", 57 | zIndex: 10, 58 | borderRadius: "5px", 59 | padding: "3px", 60 | }, 61 | }; 62 | 63 | const useGetMenu = 64 | (styleColor: any) => 65 | ({ selectedText = "", setMenuOpen, setClipboard }: MenuArgs) => { 66 | const color = styleColor === "white" ? null : styleColor; 67 | return ( 68 | 69 | 70 | { 74 | setClipboard(selectedText, () => { 75 | alert("Copied to clipboard"); 76 | }); 77 | }} 78 | icon={} 79 | /> 80 | 81 | 82 | { 86 | window.open( 87 | `https://www.google.com/search?q=${encodeURIComponent( 88 | selectedText 89 | )}` 90 | ); 91 | }} 92 | icon={} 93 | /> 94 | 95 | 96 | { 100 | setMenuOpen(false); 101 | }} 102 | icon={} 103 | /> 104 | 105 | 106 | ); 107 | }; 108 | 109 | const GithubCorner = () => { 110 | return ( 111 | 116 | 144 | 145 | ); 146 | }; 147 | 148 | // Example components 149 | const TargetExample = () => { 150 | const [useTargetClass, setUseTargetClass] = useState(true); 151 | const menuRef = useRef(null); 152 | const fullMenu = useGetMenu("white"); 153 | 154 | return ( 155 | 156 | 162 | 163 | The target property supports both css-selectors and refs. 164 | 165 | 166 | setUseTargetClass(e.target.checked)} 171 | /> 172 | 173 | target=".target-example" 174 | 175 | setUseTargetClass(!e.target.checked)} 180 | /> 181 | 182 | {`target={ref}`} 183 | 184 | 185 | 186 | 187 | {`const TargetExample = () => { 188 | ${ 189 | !useTargetClass ? "const menuRef = useRef();" : "/* Using a css selector */" 190 | } 191 | return ( 192 | <>Buttons go here} 196 | /> 197 |
198 | Selecting this text will show the menu! 199 |
200 | ); 201 | };`} 202 |
203 |
204 | ); 205 | }; 206 | 207 | const MenuExample = () => { 208 | const fullMenu = useGetMenu("white"); 209 | 210 | return ( 211 | 212 | 218 | 219 | The menu property provides the state of the component through function 220 | arguments. A setClipboard utility function is also 221 | provided. 222 | 223 | 224 | {`const MenuExample = () => { 225 | return ( 226 | 234 | 235 | setClipboard(selectedText, () => { 237 | alert("Copied to clipboard"); 238 | })} 239 | /> 240 | 242 | window.open("https://www.google.com/search?q="+selectedText")} 243 | /> 244 | setMenuOpen(false)} 246 | /> 247 | 248 | } 249 | /> 250 | ); 251 | };`} 252 | 253 | 254 | ); 255 | }; 256 | 257 | const StylesExample = () => { 258 | const [styleColor, setStyleColor] = useState("pink"); 259 | const fullMenu = useGetMenu(styleColor); 260 | 261 | return ( 262 | 263 | 269 | 270 | Change the look of the popover with several style properties. Note that 271 | buttons are not included. We are using ChakraUI behind the scenes here. 272 | 273 | 274 | {Object.entries(MENU_STYLES).map(([key]) => { 275 | return ( 276 | 277 | setStyleColor(key)} 282 | /> 283 | 288 | {key} 289 | 290 | 291 | ); 292 | })} 293 | 294 | 295 | 296 | {`const StylesExample = () => { 297 | return ( 298 | <>Buttons go here} 305 | /> 306 | ); 307 | };`} 308 | 309 | 310 | ); 311 | }; 312 | 313 | const InputExample = () => { 314 | const fullMenu = useGetMenu("white"); 315 | 316 | return ( 317 | 318 | 324 | 325 | The popover should also work inside of Input and TextArea components, 326 | but has limited support for the X,Y due to browser constraints. 327 | 328 | 329 |