├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── package-lock.json ├── package.json ├── src ├── Highlightable.tsx ├── Range.ts ├── helpers.ts ├── index.ts └── nodes │ ├── EmojiNode.tsx │ ├── Node.tsx │ └── UrlNode.tsx ├── test ├── .setup.js ├── Highlightable.test.js └── __snapshots__ │ └── Highlightable.test.js.snap ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], 3 | "plugins": ["babel-plugin-add-module-exports", "@babel/plugin-transform-arrow-functions"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | 8 | "globals": { 9 | "document": false, 10 | "escape": false, 11 | "navigator": false, 12 | "unescape": false, 13 | "window": false, 14 | "describe": true, 15 | "before": true, 16 | "it": true, 17 | "expect": true, 18 | "sinon": true 19 | }, 20 | 21 | "parser": "@babel/eslint-parser", 22 | "extends": [ 23 | "eslint:recommended", 24 | "plugin:react/recommended" 25 | ], 26 | 27 | "parserOptions": { 28 | "ecmaVersion": 6, 29 | "sourceType": "module", 30 | "ecmaFeatures": { 31 | "globalReturn": true, 32 | "jsx": true, 33 | "modules": true 34 | } 35 | }, 36 | 37 | "plugins": [ 38 | "react" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | .DS_Store 30 | 31 | lib 32 | types -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | webpack.* 4 | .babelrc 5 | .eslintrc 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yann Deshayes 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 | [![Build Status](https://travis-ci.org/ydeshayes/react-highlight.svg?branch=master)](https://travis-ci.org/ydeshayes/react-highlight) 2 | # Highlight component for ReactJS 3 | 4 | ReactJS component that help you highlight ranges of text and give you callbacks to detect user text selection. 5 | 6 | ## Installation 7 | 8 | ``` 9 | npm install highlightable 10 | ``` 11 | 12 | ## Features 13 | 14 | * Pass ranges and the component will highlight the text for you 15 | * Callback function that give you the start and end of the user highlited text 16 | * Customisable renderRange function that allow you to add tooltip on the top of user selection for exemple 17 | * Convert url string into link 18 | 19 | ## Getting started 20 | 21 | 22 | ```jsx 23 | 33 | ``` 34 | ### Props: 35 | 36 | * **ranges** -> array: of Range objects (see Range object below). 37 | 38 | * **text** -> string: the all text that the user can highlight. 39 | 40 | * **enabled** -> bool: The user can't highlight text if false. 41 | 42 | * **onMouseOverHighlightedWord** -> func: Callback function when the user mouse is over an highlighted text. 43 | `(range) => {}` 44 | 45 | * **onTextHighlighted** -> func: Callback function when the user highlight new text. 46 | `(range) => {}` 47 | 48 | * **highlightStyle** -> obj: Style of the text when the text is highlighted. or func: 49 | `(range, charIndex) => {return style}` 50 | * **style** -> obj: The style of the main div container 51 | 52 | * **rangeRenderer** -> func: Use this function to customise the render of the highlighted text. 53 | `(currentRenderedNodes, currentRenderedRange, currentRenderedIndex, onMouseOverHighlightedWord) => {return node}` 54 | 55 | * **nodeRenderer** -> func: Use this function to customise the render of the nodes. 56 | `(charIndex, range, text, url, isEmoji) => {return node}` 57 | 58 | ### Range object: 59 | 60 | The range object attributes: 61 | * **start** -> int: the index of the character where the range start. 62 | * **end** -> int: the index of the character where the range stop. 63 | * **text** -> string: the highlighted text. 64 | * **data** -> object: extra data (the props of the highlight component) 65 | 66 | ## Development 67 | 68 | * `npm run build` - produces production version 69 | * `npm run dev` - produces development version 70 | * `npm test` - run the tests 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "highlightable", 3 | "version": "1.3.0-beta.0", 4 | "description": "Component that help highlighting text", 5 | "main": "lib/Highlightable.min.js", 6 | "types": "types/index.d.ts", 7 | "scripts": { 8 | "build": "webpack && tsc", 9 | "dev": "webpack --progress --colors --watch --mode=dev", 10 | "test": "jest" 11 | }, 12 | "devDependencies": { 13 | "@babel/cli": "^7.13.0", 14 | "@babel/core": "^7.13.8", 15 | "@babel/eslint-parser": "^7.13.8", 16 | "@babel/plugin-transform-arrow-functions": "^7.13.0", 17 | "@babel/preset-env": "^7.20.2", 18 | "@babel/preset-react": "^7.18.6", 19 | "@babel/preset-typescript": "^7.18.6", 20 | "@babel/register": "^7.13.8", 21 | "@types/emoji-regex": "^9.2.0", 22 | "babel-jest": "^29.5.0", 23 | "babel-loader": "8.2.2", 24 | "babel-plugin-add-module-exports": "^1.0.4", 25 | "chai": "4.1.0", 26 | "enzyme": "^3.11.0", 27 | "eslint": "^8.33.0", 28 | "eslint-plugin-react": "^7.32.2", 29 | "eslint-webpack-plugin": "^4.0.0", 30 | "jest": "^29.5.0", 31 | "jsdom": "^11.1.0", 32 | "mocha": "3.4.2", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "react-test-renderer": "^18.2.0", 36 | "sinon": "^2.4.1", 37 | "terser-webpack-plugin": "^5.3.6", 38 | "typescript": "^4.9.5", 39 | "webpack": "5.75.0", 40 | "webpack-cli": "^5.0.1", 41 | "yargs": "8.0.2" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/ydeshayes/react-highlight.git" 46 | }, 47 | "keywords": [ 48 | "highlight", 49 | "tooltip", 50 | "React", 51 | "ReactJS", 52 | "range", 53 | "text" 54 | ], 55 | "author": "Yann Deshayes", 56 | "bugs": { 57 | "url": "https://github.com/ydeshayes/react-highlight/issues" 58 | }, 59 | "homepage": "https://ydeshayes.github.io/react-highlight/", 60 | "dependencies": { 61 | "react": "^18.2.0", 62 | "react-dom": "^18.2.0", 63 | "@types/react": "^18.0.28", 64 | "emoji-regex": "^10.2.1" 65 | }, 66 | "license": "MIT" 67 | } 68 | -------------------------------------------------------------------------------- /src/Highlightable.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, ReactNode } from 'react'; 2 | import emojiRegex from 'emoji-regex'; 3 | 4 | import EmojiNode from './nodes/EmojiNode'; 5 | import Node from './nodes/Node'; 6 | import Range from './Range'; 7 | import UrlNode from './nodes/UrlNode'; 8 | import {getUrl, debounce} from './helpers'; 9 | 10 | export type CustomHTMLNode = { 11 | dataset: { 12 | position: string 13 | } 14 | } & ParentNode; 15 | 16 | export type HighlightStyle = Record; 17 | export type HighlightStyleFunc = ((range: Range, charIndex: number) => HighlightStyle); 18 | 19 | export type OnMouseOverHighlightedWord = (range: Range) => void; 20 | export type OnMouseOverHighlightedWordHandler = (range: Range, isVisible: boolean) => void; 21 | 22 | export interface HighlightableProps { 23 | ranges: Range[], 24 | onMouseOverHighlightedWord: OnMouseOverHighlightedWord, 25 | id: string, 26 | highlightStyle?: HighlightStyle | HighlightStyleFunc, 27 | text: string, 28 | enabled: boolean, 29 | rangeRenderer?: (currentRenderedNodes: JSX.Element[], currentRenderedRange: Range, currentRenderedIndex: number, onMouseOverHighlightedWord: OnMouseOverHighlightedWordHandler) => JSX.Element, 30 | nodeRenderer?: (charIndex: number, range: Range, text: string, url: string, isEmoji: boolean) => JSX.Element, 31 | style: Record, 32 | onTextHighlighted: (range: Range) => void 33 | }; 34 | 35 | const Highlightable: FunctionComponent = ( 36 | { 37 | ranges, 38 | onMouseOverHighlightedWord, 39 | id, 40 | highlightStyle, 41 | text, 42 | enabled, 43 | rangeRenderer, 44 | nodeRenderer, 45 | style, 46 | onTextHighlighted 47 | }: HighlightableProps) => { 48 | let dismissMouseUp = 0; 49 | 50 | let doucleckicked = false; 51 | 52 | const getRange = (charIndex: number) => { 53 | return ranges 54 | && ranges.find(r => charIndex >= r.start && charIndex <= r.end); 55 | }; 56 | 57 | const onMouseOverHighlightedWordHandler = (range: Range, visible: boolean): void => { 58 | if(visible && onMouseOverHighlightedWord) { 59 | onMouseOverHighlightedWord(range); 60 | } 61 | }; 62 | 63 | const getLetterNode = (charIndex: number, range: Range) => { 64 | return ( 69 | {text[charIndex]} 70 | ); 71 | }; 72 | 73 | const getEmojiNode = (charIndex: number, range: Range) => { 74 | return (); 80 | }; 81 | 82 | const getUrlNode = (charIndex: number, range: Range, url: string) => { 83 | return (); 89 | }; 90 | 91 | const mouseEvent = () => { 92 | if(!enabled) { 93 | return false; 94 | } 95 | 96 | let t = ''; 97 | 98 | if (window.getSelection) { 99 | t = window.getSelection().toString(); 100 | } else if (document.getSelection() && document.getSelection().type !== 'Control') { 101 | t = document.createRange().toString(); 102 | } 103 | 104 | if(!t || !t.length) { 105 | return false; 106 | } 107 | 108 | const r = window.getSelection().getRangeAt(0); 109 | 110 | const startContainerPosition = parseInt((r.startContainer.parentNode as CustomHTMLNode).dataset.position); 111 | const endContainerPosition = parseInt((r.endContainer.parentNode as CustomHTMLNode).dataset.position); 112 | 113 | const startHL = startContainerPosition < endContainerPosition ? startContainerPosition : endContainerPosition; 114 | const endHL = startContainerPosition < endContainerPosition ? endContainerPosition : startContainerPosition; 115 | 116 | const rangeObj = new Range(startHL, endHL, text, { 117 | ranges: undefined, 118 | onMouseOverHighlightedWord, 119 | id, 120 | text, 121 | enabled, 122 | rangeRenderer, 123 | onTextHighlighted, 124 | style 125 | }); 126 | 127 | onTextHighlighted(rangeObj); 128 | }; 129 | 130 | const onMouseUp = () => { 131 | debounce(() => { 132 | if (doucleckicked) { 133 | doucleckicked = false; 134 | dismissMouseUp++; 135 | } else if(dismissMouseUp > 0) { 136 | dismissMouseUp--; 137 | } else { 138 | mouseEvent(); 139 | } 140 | }, 200)(); 141 | }; 142 | 143 | const onDoubleClick = (e: React.MouseEvent) => { 144 | e.stopPropagation(); 145 | 146 | doucleckicked = true; 147 | mouseEvent(); 148 | }; 149 | 150 | const rangeRendererDefault = (letterGroup: JSX.Element[], range: Range, textCharIndex: number, onMouseOverHighlightedWord: OnMouseOverHighlightedWordHandler) => { 151 | return rangeRenderer 152 | ? rangeRenderer(letterGroup, range, textCharIndex, onMouseOverHighlightedWord) 153 | : letterGroup; 154 | }; 155 | 156 | // charIndex: number, range: Range, text: string, url: string, isEmoji: boolean 157 | const getNode = (i: number, r: Range, t: string, url: string, isEmoji: boolean): JSX.Element => { 158 | if(nodeRenderer) { 159 | return nodeRenderer(i, r, t, url, isEmoji); 160 | } 161 | 162 | if(url.length) { 163 | return getUrlNode(i, r, url); 164 | } else if(isEmoji) { 165 | return getEmojiNode(i, r); 166 | } 167 | 168 | return getLetterNode(i, r); 169 | }; 170 | 171 | const getRanges = (): ReactNode => { 172 | const newText = []; 173 | 174 | let lastRange; 175 | 176 | // For all the characters on the text 177 | for(let textCharIndex = 0;textCharIndex < text.length;textCharIndex++) { 178 | const range = getRange(textCharIndex); 179 | const url = getUrl(textCharIndex, text); 180 | const isEmoji = emojiRegex().test(text[textCharIndex] + text[textCharIndex + 1]); 181 | // Get the current character node 182 | const node = getNode(textCharIndex, range, text, url, isEmoji); 183 | 184 | // If the next node is an url one, we fast forward to the end of it 185 | if(url.length) { 186 | textCharIndex += url.length - 1; 187 | } else if(isEmoji) { 188 | // Because an emoji is composed of 2 chars 189 | textCharIndex++; 190 | } 191 | 192 | if(!range) { 193 | newText.push(node); 194 | continue; 195 | } 196 | 197 | // If the char is in range 198 | lastRange = range; 199 | // We put the first range node on the array 200 | const letterGroup = [node]; 201 | 202 | // For all the characters in the highlighted range 203 | let rangeCharIndex = textCharIndex + 1; 204 | 205 | for(;rangeCharIndex < range.end + 1;rangeCharIndex++) { 206 | const isEmoji = emojiRegex().test(`${text[rangeCharIndex]}${text[rangeCharIndex + 1]}`); 207 | 208 | 209 | if(isEmoji) { 210 | letterGroup.push(getEmojiNode(rangeCharIndex, range)); 211 | // Because an emoji is composed of 2 chars 212 | rangeCharIndex++; 213 | } else { 214 | letterGroup.push(getNode(rangeCharIndex, range, text, url, isEmoji)); 215 | } 216 | 217 | textCharIndex = rangeCharIndex; 218 | } 219 | 220 | newText.push(rangeRendererDefault(letterGroup, 221 | range, 222 | textCharIndex, 223 | onMouseOverHighlightedWordHandler)); 224 | } 225 | 226 | if(lastRange) { 227 | // Callback function 228 | onMouseOverHighlightedWordHandler(lastRange, true); 229 | } 230 | 231 | return newText; 232 | }; 233 | 234 | return ( 235 |
238 | {getRanges()} 239 |
240 | ); 241 | }; 242 | 243 | export default Highlightable; 244 | -------------------------------------------------------------------------------- /src/Range.ts: -------------------------------------------------------------------------------- 1 | import { HighlightableProps } from "./Highlightable"; 2 | 3 | export default class Range { 4 | start: number; 5 | end: number; 6 | text: string; 7 | data: HighlightableProps; 8 | 9 | constructor(start: number, end: number, text: string, data: HighlightableProps) { 10 | this.start = start; 11 | this.end = end; 12 | this.text = text; 13 | this.data = data; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getUrl(i: number, text: string) { 2 | const stringToTest = text.slice(i); 3 | const myRegexp = /^(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g; 4 | const match = myRegexp.exec(stringToTest); 5 | 6 | return match && match.length ? match[1] : ''; 7 | } 8 | 9 | export function debounce(func: any, wait: number, immediate?: boolean) { 10 | let timeout: NodeJS.Timeout; 11 | 12 | return function () { 13 | const context = this, args = arguments; 14 | const later = () => { 15 | timeout = null; 16 | if (!immediate) func.apply(context, args); 17 | }; 18 | const callNow = immediate && !timeout; 19 | 20 | clearTimeout(timeout); 21 | timeout = setTimeout(later, wait); 22 | if (callNow) func.apply(context, args); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Highlightable from './Highlightable'; 2 | import Range from './Range'; 3 | import EmojiNode from './nodes/EmojiNode'; 4 | import Node from './nodes/Node'; 5 | import UrlNode from './nodes/UrlNode'; 6 | 7 | export default Highlightable; 8 | export { 9 | Range, 10 | EmojiNode, 11 | UrlNode, 12 | Node 13 | }; -------------------------------------------------------------------------------- /src/nodes/EmojiNode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Node, { NodeProps } from './Node'; 4 | 5 | export type EmojiNodeType = { 6 | text: string; 7 | } & NodeProps; 8 | 9 | const EmojiNode = (props: EmojiNodeType) => { 10 | 11 | return 15 | {`${props.text[props.charIndex]}${props.text[props.charIndex + 1]}`} 16 | ; 17 | }; 18 | 19 | export default EmojiNode; 20 | -------------------------------------------------------------------------------- /src/nodes/Node.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HighlightStyle, HighlightStyleFunc } from '../Highlightable'; 3 | import Range from '../Range'; 4 | 5 | export interface NodeProps { 6 | range: Range; 7 | charIndex: number; 8 | highlightStyle?: HighlightStyle | HighlightStyleFunc; 9 | style?: Record; 10 | id?: string; 11 | children?: JSX.Element | string; 12 | }; 13 | 14 | const Node = (props: NodeProps) => { 15 | const getStyle = (range: Range) => range ? (typeof props.highlightStyle === 'function' ? props.highlightStyle(range, props.charIndex) : props.highlightStyle) : props.style; 16 | const getRangeKey = () => `${props.id}-${props.range.start}-${props.charIndex}`; 17 | const getNormalKey = () => `${props.id}-${props.charIndex}`; 18 | const getKey = (range: Range) => range ? getRangeKey() : getNormalKey(); 19 | 20 | return ( 24 | {props.children} 25 | ); 26 | }; 27 | 28 | export default Node; 29 | -------------------------------------------------------------------------------- /src/nodes/UrlNode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Node, { NodeProps } from './Node'; 4 | 5 | export type UrlNodeType = { 6 | url: string; 7 | } & NodeProps; 8 | 9 | const UrlNode = (props: UrlNodeType) => { 10 | const style = {wordWrap: 'break-word'}; 11 | 12 | return 17 | 20 | {props.url} 21 | 22 | ; 23 | }; 24 | 25 | export default UrlNode; 26 | -------------------------------------------------------------------------------- /test/.setup.js: -------------------------------------------------------------------------------- 1 | require('@babel/register')(); 2 | 3 | const Enzyme = require('enzyme'); 4 | const Adapter = require('@wojtekmaj/enzyme-adapter-react-17'); 5 | 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | 8 | const { JSDOM } = require('jsdom'); 9 | 10 | const jsdom = new JSDOM(''); 11 | const { window } = jsdom; 12 | 13 | function copyProps(src, target) { 14 | const props = Object.getOwnPropertyNames(src) 15 | .filter(prop => typeof target[prop] === 'undefined') 16 | .map(prop => Object.getOwnPropertyDescriptor(src, prop)); 17 | Object.defineProperties(target, props); 18 | } 19 | 20 | global.window = window; 21 | global.document = window.document; 22 | global.navigator = { 23 | userAgent: 'node.js', 24 | }; 25 | copyProps(window, global); 26 | -------------------------------------------------------------------------------- /test/Highlightable.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import sinon from 'sinon'; 3 | import { expect as expectChai } from 'chai'; 4 | import renderer from 'react-test-renderer'; 5 | 6 | import Highlightable, { Range, Node } from '../src'; 7 | 8 | describe('Highlightable component', function () { 9 | describe('with basic props', function () { 10 | it('should render the text without highlight', () => { 11 | const onMouseOverHighlightedWord = sinon.spy(); 12 | const onTextHighlighted = sinon.spy(); 13 | const range = []; 14 | const text = 'test the text'; 15 | 16 | let component; 17 | renderer.act(() => { 18 | component = renderer.create( 19 | b} 26 | highlightStyle={{ 27 | backgroundColor: '#ffcc80', 28 | enabled: true 29 | }} 30 | text={text} 31 | />, 32 | ) 33 | }); 34 | let tree = component.toJSON(); 35 | expect(tree).toMatchSnapshot(); 36 | 37 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 0); 38 | expectChai(onTextHighlighted).to.have.property('callCount', 0); 39 | }); 40 | }); 41 | 42 | describe('with range props', function () { 43 | it('should render with highlighted text', () => { 44 | const onMouseOverHighlightedWord = sinon.spy(); 45 | const onTextHighlighted = sinon.spy(); 46 | const range = [new Range(0, 5)]; 47 | const text = 'test the text'; 48 | 49 | let component; 50 | renderer.act(() => { 51 | component = renderer.create( 52 | a} 59 | highlightStyle={{ 60 | backgroundColor: '#ffcc80', 61 | enabled: true 62 | }} 63 | text={text} 64 | />) 65 | }); 66 | 67 | let tree = component.toJSON(); 68 | expect(tree).toMatchSnapshot(); 69 | 70 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 1); 71 | expectChai(onTextHighlighted).to.have.property('callCount', 0); 72 | }); 73 | }); 74 | 75 | describe('testing update', function () { 76 | it('should highlight text', () => { 77 | const onMouseOverHighlightedWord = sinon.spy(); 78 | const onTextHighlighted = sinon.spy(); 79 | const range = []; 80 | const text = 'test the text'; 81 | 82 | const component = renderer.create( a} 89 | highlightStyle={{ 90 | backgroundColor: '#ffcc80', 91 | enabled: true 92 | }} 93 | text={text} 94 | />); 95 | 96 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 0); 97 | expectChai(onTextHighlighted).to.have.property('callCount', 0); 98 | 99 | let tree = component.toJSON(); 100 | expect(tree).toMatchSnapshot(); 101 | 102 | const newRange = [new Range(0, 5)]; 103 | renderer.act(() => { 104 | component.update( a} 111 | highlightStyle={{ 112 | backgroundColor: '#ffcc80', 113 | enabled: true 114 | }} 115 | text={text} 116 | />); 117 | }); 118 | 119 | tree = component.toJSON(); 120 | expect(tree).toMatchSnapshot(); 121 | 122 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 1); 123 | }); 124 | }); 125 | 126 | describe('with smiley', function () { 127 | it('should highlight text and keep the smiley at the end of the text', () => { 128 | const onMouseOverHighlightedWord = sinon.spy(); 129 | const onTextHighlighted = sinon.spy(); 130 | const range = []; 131 | const text = 'test the text 😘'; 132 | 133 | const component = renderer.create( a} 140 | highlightStyle={{ 141 | backgroundColor: '#ffcc80', 142 | enabled: true 143 | }} 144 | text={text} 145 | />); 146 | 147 | let tree = component.toJSON(); 148 | expect(tree).toMatchSnapshot(); 149 | }); 150 | 151 | it('should highlight text and keep the smiley at the end of the highlighted text', () => { 152 | const onMouseOverHighlightedWord = sinon.spy(); 153 | const onTextHighlighted = sinon.spy(); 154 | const range = [new Range(13, 14)]; 155 | const text = 'test the text 😘'; 156 | 157 | const component = renderer.create( a} 164 | highlightStyle={{ 165 | backgroundColor: '#ffcc80', 166 | enabled: true 167 | }} 168 | text={text} 169 | />); 170 | 171 | let tree = component.toJSON(); 172 | expect(tree).toMatchSnapshot(); 173 | 174 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 1); 175 | }); 176 | 177 | it('should highlight text and keep the smiley in the middle of the highlighted text', () => { 178 | const onMouseOverHighlightedWord = sinon.spy(); 179 | const onTextHighlighted = sinon.spy(); 180 | const range = [new Range(13, 18)]; 181 | const text = 'test the text 😘 test again'; 182 | 183 | const component = renderer.create( a} 190 | highlightStyle={{ 191 | backgroundColor: '#ffcc80', 192 | enabled: true 193 | }} 194 | text={text} 195 | />); 196 | 197 | 198 | let tree = component.toJSON(); 199 | expect(tree).toMatchSnapshot(); 200 | 201 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 1); 202 | }); 203 | }); 204 | 205 | describe('with url', function () { 206 | it('should render with url', () => { 207 | const onMouseOverHighlightedWord = sinon.spy(); 208 | const onTextHighlighted = sinon.spy(); 209 | const range = []; 210 | const text = 'test http://www.google.fr'; 211 | 212 | const component = renderer.create( a} 219 | highlightStyle={{ 220 | backgroundColor: '#ffcc80', 221 | enabled: true 222 | }} 223 | text={text} 224 | />); 225 | 226 | let tree = component.toJSON(); 227 | expect(tree).toMatchSnapshot(); 228 | }); 229 | 230 | it('should render with highlighted url', () => { 231 | const onMouseOverHighlightedWord = sinon.spy(); 232 | const onTextHighlighted = sinon.spy(); 233 | const range = [new Range(5, 7)]; 234 | const text = 'test http://www.google.fr'; 235 | 236 | const component = renderer.create( a} 243 | highlightStyle={{ 244 | backgroundColor: '#ffcc80', 245 | enabled: true 246 | }} 247 | text={text} 248 | />); 249 | 250 | 251 | let tree = component.toJSON(); 252 | expect(tree).toMatchSnapshot(); 253 | 254 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 1); 255 | expectChai(onTextHighlighted).to.have.property('callCount', 0); 256 | }); 257 | }); 258 | 259 | describe('with custom node', function() { 260 | it('should render the text wit custom node', () => { 261 | const onMouseOverHighlightedWord = sinon.spy(); 262 | const onTextHighlighted = sinon.spy(); 263 | const nodeRenderer = sinon.spy((i, r, text) => { 264 | return {text[i]} 268 | }); 269 | const range = []; 270 | const text = 'test the text'; 271 | 272 | const component = renderer.create( b} 279 | highlightStyle={{ 280 | backgroundColor: '#ffcc80', 281 | enabled: true 282 | }} 283 | nodeRenderer={nodeRenderer} 284 | text={text} 285 | />); 286 | 287 | expectChai(onMouseOverHighlightedWord).to.have.property('callCount', 0); 288 | expectChai(onTextHighlighted).to.have.property('callCount', 0); 289 | expectChai(nodeRenderer).to.have.property('callCount', 13); 290 | 291 | let tree = component.toJSON(); 292 | expect(tree).toMatchSnapshot(); 293 | }); 294 | }); 295 | }); -------------------------------------------------------------------------------- /test/__snapshots__/Highlightable.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Highlightable component testing update should highlight text 1`] = ` 4 |
8 | 11 | t 12 | 13 | 16 | e 17 | 18 | 21 | s 22 | 23 | 26 | t 27 | 28 | 31 | 32 | 33 | 36 | t 37 | 38 | 41 | h 42 | 43 | 46 | e 47 | 48 | 51 | 52 | 53 | 56 | t 57 | 58 | 61 | e 62 | 63 | 66 | x 67 | 68 | 71 | t 72 | 73 |
74 | `; 75 | 76 | exports[`Highlightable component testing update should highlight text 2`] = ` 77 |
81 | 90 | t 91 | 92 | 101 | e 102 | 103 | 112 | s 113 | 114 | 123 | t 124 | 125 | 134 | 135 | 136 | 145 | t 146 | 147 | 150 | h 151 | 152 | 155 | e 156 | 157 | 160 | 161 | 162 | 165 | t 166 | 167 | 170 | e 171 | 172 | 175 | x 176 | 177 | 180 | t 181 | 182 |
183 | `; 184 | 185 | exports[`Highlightable component with basic props should render the text without highlight 1`] = ` 186 |
190 | 193 | t 194 | 195 | 198 | e 199 | 200 | 203 | s 204 | 205 | 208 | t 209 | 210 | 213 | 214 | 215 | 218 | t 219 | 220 | 223 | h 224 | 225 | 228 | e 229 | 230 | 233 | 234 | 235 | 238 | t 239 | 240 | 243 | e 244 | 245 | 248 | x 249 | 250 | 253 | t 254 | 255 |
256 | `; 257 | 258 | exports[`Highlightable component with custom node should render the text wit custom node 1`] = ` 259 |
263 | 266 | t 267 | 268 | 271 | e 272 | 273 | 276 | s 277 | 278 | 281 | t 282 | 283 | 286 | 287 | 288 | 291 | t 292 | 293 | 296 | h 297 | 298 | 301 | e 302 | 303 | 306 | 307 | 308 | 311 | t 312 | 313 | 316 | e 317 | 318 | 321 | x 322 | 323 | 326 | t 327 | 328 |
329 | `; 330 | 331 | exports[`Highlightable component with range props should render with highlighted text 1`] = ` 332 |
336 | 345 | t 346 | 347 | 356 | e 357 | 358 | 367 | s 368 | 369 | 378 | t 379 | 380 | 389 | 390 | 391 | 400 | t 401 | 402 | 405 | h 406 | 407 | 410 | e 411 | 412 | 415 | 416 | 417 | 420 | t 421 | 422 | 425 | e 426 | 427 | 430 | x 431 | 432 | 435 | t 436 | 437 |
438 | `; 439 | 440 | exports[`Highlightable component with smiley should highlight text and keep the smiley at the end of the highlighted text 1`] = ` 441 |
445 | 448 | t 449 | 450 | 453 | e 454 | 455 | 458 | s 459 | 460 | 463 | t 464 | 465 | 468 | 469 | 470 | 473 | t 474 | 475 | 478 | h 479 | 480 | 483 | e 484 | 485 | 488 | 489 | 490 | 493 | t 494 | 495 | 498 | e 499 | 500 | 503 | x 504 | 505 | 508 | t 509 | 510 | 519 | 520 | 521 | 530 | 😘 531 | 532 |
533 | `; 534 | 535 | exports[`Highlightable component with smiley should highlight text and keep the smiley at the end of the text 1`] = ` 536 |
540 | 543 | t 544 | 545 | 548 | e 549 | 550 | 553 | s 554 | 555 | 558 | t 559 | 560 | 563 | 564 | 565 | 568 | t 569 | 570 | 573 | h 574 | 575 | 578 | e 579 | 580 | 583 | 584 | 585 | 588 | t 589 | 590 | 593 | e 594 | 595 | 598 | x 599 | 600 | 603 | t 604 | 605 | 608 | 609 | 610 | 613 | 😘 614 | 615 |
616 | `; 617 | 618 | exports[`Highlightable component with smiley should highlight text and keep the smiley in the middle of the highlighted text 1`] = ` 619 |
623 | 626 | t 627 | 628 | 631 | e 632 | 633 | 636 | s 637 | 638 | 641 | t 642 | 643 | 646 | 647 | 648 | 651 | t 652 | 653 | 656 | h 657 | 658 | 661 | e 662 | 663 | 666 | 667 | 668 | 671 | t 672 | 673 | 676 | e 677 | 678 | 681 | x 682 | 683 | 686 | t 687 | 688 | 697 | 698 | 699 | 708 | 😘 709 | 710 | 719 | 720 | 721 | 730 | t 731 | 732 | 741 | e 742 | 743 | 746 | s 747 | 748 | 751 | t 752 | 753 | 756 | 757 | 758 | 761 | a 762 | 763 | 766 | g 767 | 768 | 771 | a 772 | 773 | 776 | i 777 | 778 | 781 | n 782 | 783 |
784 | `; 785 | 786 | exports[`Highlightable component with url should render with highlighted url 1`] = ` 787 |
791 | 794 | t 795 | 796 | 799 | e 800 | 801 | 804 | s 805 | 806 | 809 | t 810 | 811 | 814 | 815 | 816 | 826 | 831 | http://www.google.fr 832 | 833 | 834 |
835 | `; 836 | 837 | exports[`Highlightable component with url should render with url 1`] = ` 838 |
842 | 845 | t 846 | 847 | 850 | e 851 | 852 | 855 | s 856 | 857 | 860 | t 861 | 862 | 865 | 866 | 867 | 875 | 880 | http://www.google.fr 881 | 882 | 883 |
884 | `; 885 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "./types", 5 | "emitDeclarationOnly": true, 6 | "noImplicitAny": true, 7 | "module": "es6", 8 | "target": "es6", 9 | "jsx": "react", 10 | "allowJs": true, 11 | "moduleResolution": "node", 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | "include": ["src"] 16 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | var path = require('path'); 4 | 5 | var libraryName = 'Highlightable'; 6 | 7 | var plugins = [], outputFile; 8 | 9 | outputFile = libraryName + '.min.js'; 10 | 11 | var config = { 12 | entry: __dirname + '/src/index.ts', 13 | devtool: 'source-map', 14 | output: { 15 | path: __dirname + '/lib', 16 | filename: outputFile, 17 | library: libraryName, 18 | libraryTarget: 'umd', 19 | umdNamedDefine: true 20 | }, 21 | optimization: { 22 | minimize: true, 23 | minimizer: [new TerserPlugin()], 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /(\.tsx|\.ts)$/, 29 | loader: 'babel-loader', 30 | exclude: /(node_modules|bower_components)/ 31 | } 32 | ] 33 | }, 34 | resolve: { 35 | modules: [ 36 | path.resolve('./src'), 37 | path.resolve('./node_modules') 38 | ], 39 | extensions: ['.js', '.tsx', '.ts'] 40 | }, 41 | plugins: plugins 42 | }; 43 | 44 | module.exports = config; 45 | --------------------------------------------------------------------------------