├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── Annotators.mdx └── Examples.tsx ├── doczrc.js ├── example ├── index.html ├── src │ ├── App.tsx │ └── index.tsx ├── tsconfig.json └── webpack.config.js ├── jest.config.js ├── package.json ├── src ├── Mark.tsx ├── TextAnnotator.tsx ├── TokenAnnotator.tsx ├── __tests__ │ ├── TextAnnotator.test.tsx │ └── TokenAnnotator.test.tsx ├── index.ts ├── span.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /dist 11 | /lib 12 | /example/dist 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | .docz 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !/lib 2 | .docz 3 | .vscode 4 | docs -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "bracketSpacing": false 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Martin Camacho 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 | # react-text-annotate 2 | [![NPM](https://img.shields.io/npm/v/react-text-annotate)](https://www.npmjs.com/package/react-text-annotate) 3 | 4 | A React component for interactively highlighting parts of text. 5 | 6 | ## Usage 7 | 8 | React `16.8.0` or higher is required as a peer dependency of this package. 9 | 10 | ``` 11 | npm install --save react-text-annotate 12 | ``` 13 | 14 | [Docs](https://mcamac.github.io/react-text-annotate/) 15 | 16 | ## Examples 17 | 18 | A simple controlled annotation. 19 | 20 | ```tsx 21 | import {TokenAnnotator, TextAnnotator} from 'react-text-annotate' 22 | 23 | 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/Annotators.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Annotators 3 | route: /annotators 4 | --- 5 | 6 | # Annotators 7 | 8 | import {Playground, Props} from 'docz' 9 | import {State} from 'react-powerplug' 10 | import {TextAnnotator, TokenAnnotator} from '../src' 11 | import {Card, TEXT, TAG_COLORS} from './Examples' 12 | 13 | 14 | ```tsx 15 | import {TokenAnnotator, TextAnnotator} from 'react-text-annotate' 16 | 17 | 21 | ``` 22 | 23 | 24 | Note: the examples below use the `State` component from [react-powerplug](https://github.com/renatorib/react-powerplug). 25 | 26 | ## `TokenAnnotator` 27 | 28 | Token annotators take a list of tokens, and allow selecting subranges of them. 29 | 30 | In the example below, drag or double click to select text. Click on a selected "mark" to deselect. 31 | 32 | 33 | 34 | {({state, setState}) => ( 35 | 36 | 40 | setState({value})} 48 | getSpan={span => ({ 49 | ...span, 50 | tag: state.tag, 51 | color: TAG_COLORS[state.tag], 52 | })} 53 | /> 54 |
 55 |         {JSON.stringify(state, null, 2)}
 56 |       
57 |
58 | )} 59 |
60 |
61 | 62 | 63 | 64 | ## `TextAnnotator` 65 | 66 | Text annotators take a string `content` prop, and allow the selecting of substrings. 67 | This can be useful for fine-grained text annotation tasks. 68 | 69 | In the example below, drag or double click to select text. Click on a selected "mark" to deselect. 70 | 71 | 72 | 73 | {({state, setState}) => ( 74 | 75 | 79 | setState({value})} 87 | getSpan={span => ({ 88 | ...span, 89 | tag: state.tag, 90 | color: TAG_COLORS[state.tag], 91 | })} 92 | /> 93 |
 94 |         {JSON.stringify(state, null, 2)}
 95 |       
96 |
97 | )} 98 |
99 |
100 | -------------------------------------------------------------------------------- /docs/Examples.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | 3 | import {TextAnnotator, TokenAnnotator} from '../src' 4 | 5 | export const TEXT = `On Monday night , Mr. Fallon will have a co-host for the first time : The rapper Cardi B , who just released her first album, " Invasion of Privacy . "` 6 | 7 | export const TAG_COLORS = { 8 | ORG: '#00ffa2', 9 | PERSON: '#84d2ff', 10 | } 11 | 12 | export const Card = ({children}) => ( 13 |
21 | {children} 22 |
23 | ) 24 | -------------------------------------------------------------------------------- /doczrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | typescript: true, 3 | ignore: ['README.md'], 4 | base: '/react-text-annotate', 5 | } 6 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-text-annotate 6 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {hot} from 'react-hot-loader' 3 | 4 | import {TextAnnotator, TokenAnnotator} from '../../src' 5 | 6 | const TEXT = `On Monday night , Mr. Fallon will have a co-host for the first time : The rapper Cardi B , who just released her first album, " Invasion of Privacy . "` 7 | 8 | const TAG_COLORS = { 9 | ORG: '#00ffa2', 10 | PERSON: '#84d2ff', 11 | } 12 | 13 | const Card = ({children}) => ( 14 |
22 | {children} 23 |
24 | ) 25 | 26 | class App extends React.Component { 27 | state = { 28 | value: [{start: 17, end: 19, tag: 'PERSON'}], 29 | tag: 'PERSON', 30 | } 31 | 32 | handleChange = value => { 33 | this.setState({value}) 34 | } 35 | 36 | handleTagChange = e => { 37 | this.setState({tag: e.target.value}) 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 |

react-text-annotate

44 | Github 45 |

A React component for interactively highlighting parts of text.

46 |
47 | 48 |

Default

49 | 53 | ({ 63 | ...span, 64 | tag: this.state.tag, 65 | color: TAG_COLORS[this.state.tag], 66 | })} 67 | /> 68 |
69 | 70 |

Custom rendered mark

71 | 75 | ({ 85 | ...span, 86 | tag: this.state.tag, 87 | color: TAG_COLORS[this.state.tag], 88 | })} 89 | renderMark={props => ( 90 | props.onClick({start: props.start, end: props.end})} 93 | > 94 | {props.content} [{props.tag}] 95 | 96 | )} 97 | /> 98 |
99 |
100 | 101 |

Current Value

102 |
{JSON.stringify(this.state.value, null, 2)}
103 |
104 |
105 | ) 106 | } 107 | } 108 | 109 | export default hot(module)(App) 110 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | const render = Component => { 7 | ReactDOM.render(, document.body) 8 | } 9 | 10 | render(App) 11 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | // "noImplicitAny": truowe, 5 | "sourceMap": true, 6 | "jsx": "react", 7 | "lib": ["es2016", "dom"] 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | 4 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 5 | const HtmlWebpackPlugin = require('html-webpack-plugin') 6 | 7 | module.exports = { 8 | devtool: 'source-map', 9 | entry: { 10 | app: ['babel-polyfill', './src/index'], 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, './dist'), 14 | filename: '[name].js', 15 | publicPath: '', 16 | }, 17 | resolve: { 18 | extensions: ['.ts', '.tsx', '.js'], 19 | }, 20 | module: { 21 | rules: [ 22 | {test: /\.tsx?$/, loader: 'ts-loader', options: {transpileOnly: true}}, 23 | {test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader'}, 24 | {test: /\.(png|svg)$/, loader: 'file-loader'}, 25 | { 26 | test: /\.css$/, 27 | use: ['style-loader', 'css-loader'], 28 | }, 29 | ], 30 | }, 31 | plugins: [ 32 | new ForkTsCheckerWebpackPlugin(), 33 | new HtmlWebpackPlugin({ 34 | title: 'react-text-annotate', 35 | }), 36 | ], 37 | } 38 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | testPathIgnorePatterns: ['/node_modules/', '/.docz/', '/lib/'], 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-text-annotate", 3 | "version": "0.3.0", 4 | "main": "./lib/index.js", 5 | "types": "./lib/index.d.ts", 6 | "homepage": "https://mcamac.github.io/react-text-annotate", 7 | "dependencies": { 8 | "lodash.sortby": "^4.7.0" 9 | }, 10 | "peerDependencies": { 11 | "react": "^16.8.0", 12 | "react-dom": "^16.8.0" 13 | }, 14 | "devDependencies": { 15 | "@testing-library/react": "^10.0.2", 16 | "@types/jest": "^25.2.1", 17 | "@types/node": "^12.0.0", 18 | "@types/react": "^16.8.0", 19 | "@types/react-dom": "^16.8.0", 20 | "docz": "^2.2.0", 21 | "gh-pages": "^2.1.1", 22 | "jest": "^25.2.7", 23 | "prettier": "^1.19.1", 24 | "react": "^16.8.0", 25 | "react-dom": "^16.8.0", 26 | "react-hot-loader": "^4.0.1", 27 | "react-powerplug": "^1.0.0", 28 | "ts-jest": "^25.3.1", 29 | "typescript": "^3.8.3" 30 | }, 31 | "scripts": { 32 | "dev": "cd example && webpack-dev-server --hot --history-api-fallback --mode development", 33 | "build": "rm -rf lib && tsc -p ./ --declaration --outDir lib/", 34 | "prepublish": "npm run build", 35 | "predeploy": "docz build", 36 | "deploy": "gh-pages -d .docz/dist", 37 | "docz:dev": "docz dev", 38 | "docz:build": "docz build", 39 | "docz:serve": "docz build && docz serve" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Mark.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export interface MarkProps { 4 | key: string 5 | content: string 6 | start: number 7 | end: number 8 | tag: string 9 | color?: string 10 | onClick: (any) => any 11 | } 12 | 13 | const Mark: React.SFC = props => ( 14 | props.onClick({start: props.start, end: props.end})} 19 | > 20 | {props.content} 21 | {props.tag && ( 22 | {props.tag} 23 | )} 24 | 25 | ) 26 | 27 | export default Mark 28 | -------------------------------------------------------------------------------- /src/TextAnnotator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Mark from './Mark' 4 | import {selectionIsEmpty, selectionIsBackwards, splitWithOffsets} from './utils' 5 | import {Span} from './span' 6 | 7 | const Split = props => { 8 | if (props.mark) return 9 | 10 | return ( 11 | props.onClick({start: props.start, end: props.end})} 15 | > 16 | {props.content} 17 | 18 | ) 19 | } 20 | 21 | interface TextSpan extends Span { 22 | text: string 23 | } 24 | 25 | type TextBaseProps = { 26 | content: string 27 | value: T[] 28 | onChange: (value: T[]) => any 29 | getSpan?: (span: TextSpan) => T 30 | // TODO: determine whether to overwrite or leave intersecting ranges. 31 | } 32 | 33 | type TextAnnotatorProps = React.HTMLAttributes & TextBaseProps 34 | 35 | const TextAnnotator = (props: TextAnnotatorProps) => { 36 | const getSpan = (span: TextSpan): T => { 37 | // TODO: Better typings here. 38 | if (props.getSpan) return props.getSpan(span) as T 39 | return {start: span.start, end: span.end} as T 40 | } 41 | 42 | const handleMouseUp = () => { 43 | if (!props.onChange) return 44 | 45 | const selection = window.getSelection() 46 | 47 | if (selectionIsEmpty(selection)) return 48 | 49 | let start = 50 | parseInt(selection.anchorNode.parentElement.getAttribute('data-start'), 10) + 51 | selection.anchorOffset 52 | let end = 53 | parseInt(selection.focusNode.parentElement.getAttribute('data-start'), 10) + 54 | selection.focusOffset 55 | 56 | if (selectionIsBackwards(selection)) { 57 | ;[start, end] = [end, start] 58 | } 59 | 60 | props.onChange([...props.value, getSpan({start, end, text: content.slice(start, end)})]) 61 | 62 | window.getSelection().empty() 63 | } 64 | 65 | const handleSplitClick = ({start, end}) => { 66 | // Find and remove the matching split. 67 | const splitIndex = props.value.findIndex(s => s.start === start && s.end === end) 68 | if (splitIndex >= 0) { 69 | props.onChange([...props.value.slice(0, splitIndex), ...props.value.slice(splitIndex + 1)]) 70 | } 71 | } 72 | 73 | const {content, value, style} = props 74 | const splits = splitWithOffsets(content, value) 75 | return ( 76 |
77 | {splits.map(split => ( 78 | 79 | ))} 80 |
81 | ) 82 | } 83 | 84 | export default TextAnnotator 85 | -------------------------------------------------------------------------------- /src/TokenAnnotator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Mark, {MarkProps} from './Mark' 4 | import {selectionIsEmpty, selectionIsBackwards, splitTokensWithOffsets} from './utils' 5 | import {Span} from './span' 6 | 7 | interface TokenProps { 8 | i: number 9 | content: string 10 | } 11 | 12 | interface TokenSpan { 13 | start: number 14 | end: number 15 | tokens: string[] 16 | } 17 | 18 | const Token: React.SFC = props => { 19 | return {props.content} 20 | } 21 | 22 | export interface TokenAnnotatorProps 23 | extends Omit, 'onChange'> { 24 | tokens: string[] 25 | value: T[] 26 | onChange: (value: T[]) => any 27 | getSpan?: (span: TokenSpan) => T 28 | renderMark?: (props: MarkProps) => JSX.Element 29 | // TODO: determine whether to overwrite or leave intersecting ranges. 30 | } 31 | 32 | const TokenAnnotator = (props: TokenAnnotatorProps) => { 33 | const renderMark = props.renderMark || (props => ) 34 | 35 | const getSpan = (span: TokenSpan): T => { 36 | if (props.getSpan) return props.getSpan(span) 37 | return {start: span.start, end: span.end} as T 38 | } 39 | 40 | const handleMouseUp = () => { 41 | if (!props.onChange) return 42 | 43 | const selection = window.getSelection() 44 | 45 | if (selectionIsEmpty(selection)) return 46 | 47 | if ( 48 | !selection.anchorNode.parentElement.hasAttribute('data-i') || 49 | !selection.focusNode.parentElement.hasAttribute('data-i') 50 | ) { 51 | window.getSelection().empty() 52 | return false 53 | } 54 | 55 | let start = parseInt(selection.anchorNode.parentElement.getAttribute('data-i'), 10) 56 | let end = parseInt(selection.focusNode.parentElement.getAttribute('data-i'), 10) 57 | 58 | if (selectionIsBackwards(selection)) { 59 | ;[start, end] = [end, start] 60 | } 61 | 62 | end += 1 63 | 64 | props.onChange([...props.value, getSpan({start, end, tokens: props.tokens.slice(start, end)})]) 65 | window.getSelection().empty() 66 | } 67 | 68 | const handleSplitClick = ({start, end}) => { 69 | // Find and remove the matching split. 70 | const splitIndex = props.value.findIndex(s => s.start === start && s.end === end) 71 | if (splitIndex >= 0) { 72 | props.onChange([...props.value.slice(0, splitIndex), ...props.value.slice(splitIndex + 1)]) 73 | } 74 | } 75 | 76 | const {tokens, value, onChange, getSpan: _, ...divProps} = props 77 | const splits = splitTokensWithOffsets(tokens, value) 78 | return ( 79 |
80 | {splits.map((split, i) => 81 | split.mark ? ( 82 | renderMark({ 83 | key: `${split.start}-${split.end}`, 84 | ...split, 85 | onClick: handleSplitClick, 86 | }) 87 | ) : ( 88 | 89 | ) 90 | )} 91 |
92 | ) 93 | } 94 | 95 | export default TokenAnnotator 96 | -------------------------------------------------------------------------------- /src/__tests__/TextAnnotator.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TextAnnotator from '../TextAnnotator' 3 | import {render, fireEvent} from '@testing-library/react' 4 | 5 | test('renders without getSpan', () => { 6 | render( 7 | {}} 11 | /> 12 | ) 13 | }) 14 | 15 | test('renders when value and getSpan return match', () => { 16 | render( 17 | {}} 21 | getSpan={span => ({...span, tag: 'FOO', text: 'foo', extra: 1})} 22 | /> 23 | ) 24 | }) 25 | -------------------------------------------------------------------------------- /src/__tests__/TokenAnnotator.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {render} from '@testing-library/react' 3 | import TokenAnnotator from '../TokenAnnotator' 4 | 5 | test('renders without getSpan', () => { 6 | render( 7 | {}} 11 | /> 12 | ) 13 | }) 14 | 15 | test('renders when value and getSpan return match', () => { 16 | render( 17 | {}} 21 | getSpan={span => ({...span, tag: 'FOO', tokens: ['Foo'], extra: 1})} 22 | /> 23 | ) 24 | }) 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default as TextAnnotator} from './TextAnnotator' 2 | export {default as TokenAnnotator} from './TokenAnnotator' 3 | -------------------------------------------------------------------------------- /src/span.ts: -------------------------------------------------------------------------------- 1 | export type Span = { 2 | start: number 3 | end: number 4 | } 5 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import sortBy from 'lodash.sortby' 2 | 3 | export const splitWithOffsets = (text, offsets: {start: number; end: number}[]) => { 4 | let lastEnd = 0 5 | const splits = [] 6 | 7 | for (let offset of sortBy(offsets, o => o.start)) { 8 | const {start, end} = offset 9 | if (lastEnd < start) { 10 | splits.push({ 11 | start: lastEnd, 12 | end: start, 13 | content: text.slice(lastEnd, start), 14 | }) 15 | } 16 | splits.push({ 17 | ...offset, 18 | mark: true, 19 | content: text.slice(start, end), 20 | }) 21 | lastEnd = end 22 | } 23 | if (lastEnd < text.length) { 24 | splits.push({ 25 | start: lastEnd, 26 | end: text.length, 27 | content: text.slice(lastEnd, text.length), 28 | }) 29 | } 30 | 31 | return splits 32 | } 33 | 34 | export const splitTokensWithOffsets = (text, offsets: {start: number; end: number}[]) => { 35 | let lastEnd = 0 36 | const splits = [] 37 | 38 | for (let offset of sortBy(offsets, o => o.start)) { 39 | const {start, end} = offset 40 | if (lastEnd < start) { 41 | for (let i = lastEnd; i < start; i++) { 42 | splits.push({ 43 | i, 44 | content: text[i], 45 | }) 46 | } 47 | } 48 | splits.push({ 49 | ...offset, 50 | mark: true, 51 | content: text.slice(start, end).join(' '), 52 | }) 53 | lastEnd = end 54 | } 55 | 56 | for (let i = lastEnd; i < text.length; i++) { 57 | splits.push({ 58 | i, 59 | content: text[i], 60 | }) 61 | } 62 | 63 | return splits 64 | } 65 | 66 | export const selectionIsEmpty = (selection: Selection) => { 67 | let position = selection.anchorNode.compareDocumentPosition(selection.focusNode) 68 | 69 | return position === 0 && selection.focusOffset === selection.anchorOffset 70 | } 71 | 72 | export const selectionIsBackwards = (selection: Selection) => { 73 | if (selectionIsEmpty(selection)) return false 74 | 75 | let position = selection.anchorNode.compareDocumentPosition(selection.focusNode) 76 | 77 | let backward = false 78 | if ( 79 | (!position && selection.anchorOffset > selection.focusOffset) || 80 | position === Node.DOCUMENT_POSITION_PRECEDING 81 | ) 82 | backward = true 83 | 84 | return backward 85 | } 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "sourceMap": true, 5 | "jsx": "react", 6 | "lib": ["es2016", "dom"], 7 | "esModuleInterop": true 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | --------------------------------------------------------------------------------