Skip to content

Commit

Permalink
Refactor types w/ generics and add use functional components (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
mcamac authored Apr 8, 2020
1 parent 1119e34 commit b3553fd
Show file tree
Hide file tree
Showing 13 changed files with 1,744 additions and 150 deletions.
2 changes: 1 addition & 1 deletion example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from 'react'
import React from 'react'
import {hot} from 'react-hot-loader'

import {TextAnnotator, TokenAnnotator} from '../../src'
Expand Down
4 changes: 2 additions & 2 deletions example/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import React from 'react'
import ReactDOM from 'react-dom'

import App from './App'

Expand Down
5 changes: 5 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/.docz/', '/lib/'],
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@
"react-dom": "^16.8.0"
},
"devDependencies": {
"@testing-library/react": "^10.0.2",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react": "^16.8.0",
"@types/react-dom": "^16.8.0",
"docz": "^2.2.0",
"gh-pages": "^2.1.1",
"jest": "^25.2.7",
"prettier": "^1.19.1",
"react": "^16.8.0",
"react-dom": "^16.8.0",
"react-hot-loader": "^4.0.1",
"react-powerplug": "^1.0.0",
"typescript": "^3.7.2"
"ts-jest": "^25.3.1",
"typescript": "^3.8.3"
},
"scripts": {
"dev": "cd example && webpack-dev-server --hot --history-api-fallback --mode development",
Expand Down
2 changes: 1 addition & 1 deletion src/Mark.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from 'react'
import React from 'react'

export interface MarkProps {
key: string
Expand Down
79 changes: 28 additions & 51 deletions src/TextAnnotator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as React from 'react'
import React from 'react'

import Mark from './Mark'
import {selectionIsEmpty, selectionIsBackwards, splitWithOffsets} from './utils'
import {Span} from './span'

const Split = props => {
if (props.mark) return <Mark {...props} />
Expand All @@ -17,40 +18,29 @@ const Split = props => {
)
}

interface TextSpan {
start: number
end: number
interface TextSpan extends Span {
text: string
}

export interface TextAnnotatorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
style: object
type TextBaseProps<T> = {
content: string
value: TextSpan[]
onChange: (value: TextSpan[]) => any
getSpan?: (span: TextSpan) => TextSpan
value: T[]
onChange: (value: T[]) => any
getSpan?: (span: TextSpan) => T
// TODO: determine whether to overwrite or leave intersecting ranges.
}

class TextAnnotator extends React.Component<TextAnnotatorProps, {}> {
rootRef: React.RefObject<HTMLDivElement>
type TextAnnotatorProps<T> = React.HTMLAttributes<HTMLDivElement> & TextBaseProps<T>

constructor(props) {
super(props)

this.rootRef = React.createRef()
}

componentDidMount() {
this.rootRef.current.addEventListener('mouseup', this.handleMouseUp)
}

componentWillUnmount() {
this.rootRef.current.removeEventListener('mouseup', this.handleMouseUp)
const TextAnnotator = <T extends Span>(props: TextAnnotatorProps<T>) => {
const getSpan = (span: TextSpan): T => {
// TODO: Better typings here.
if (props.getSpan) return props.getSpan(span) as T
return {start: span.start, end: span.end} as T
}

handleMouseUp = () => {
if (!this.props.onChange) return
const handleMouseUp = () => {
if (!props.onChange) return

const selection = window.getSelection()

Expand All @@ -67,41 +57,28 @@ class TextAnnotator extends React.Component<TextAnnotatorProps, {}> {
;[start, end] = [end, start]
}

this.props.onChange([
...this.props.value,
this.getSpan({start, end, text: this.props.content.slice(start, end)}),
])
props.onChange([...props.value, getSpan({start, end, text: content.slice(start, end)})])

window.getSelection().empty()
}

handleSplitClick = ({start, end}) => {
const handleSplitClick = ({start, end}) => {
// Find and remove the matching split.
const splitIndex = this.props.value.findIndex(s => s.start === start && s.end === end)
const splitIndex = props.value.findIndex(s => s.start === start && s.end === end)
if (splitIndex >= 0) {
this.props.onChange([
...this.props.value.slice(0, splitIndex),
...this.props.value.slice(splitIndex + 1),
])
props.onChange([...props.value.slice(0, splitIndex), ...props.value.slice(splitIndex + 1)])
}
}

getSpan = (span: TextSpan) => {
if (this.props.getSpan) return this.props.getSpan(span)
return span
}

render() {
const {content, value, style} = this.props
const splits = splitWithOffsets(content, value)
return (
<div style={style} ref={this.rootRef}>
{splits.map(split => (
<Split key={`${split.start}-${split.end}`} {...split} onClick={this.handleSplitClick} />
))}
</div>
)
}
const {content, value, style} = props
const splits = splitWithOffsets(content, value)
return (
<div style={style} onMouseUp={handleMouseUp}>
{splits.map(split => (
<Split key={`${split.start}-${split.end}`} {...split} onClick={handleSplitClick} />
))}
</div>
)
}

export default TextAnnotator
94 changes: 34 additions & 60 deletions src/TokenAnnotator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as React from 'react'
import React from 'react'

import Mark, {MarkProps} from './Mark'
import {selectionIsEmpty, selectionIsBackwards, splitTokensWithOffsets} from './utils'
import {Span} from './span'

interface TokenProps {
i: number
Expand All @@ -18,40 +19,26 @@ const Token: React.SFC<TokenProps> = props => {
return <span data-i={props.i}>{props.content} </span>
}

export interface TokenAnnotatorProps
export interface TokenAnnotatorProps<T>
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
tokens: string[]
value: TokenSpan[]
onChange: (value: TokenSpan[]) => any
getSpan?: (span: TokenSpan) => TokenSpan
value: T[]
onChange: (value: T[]) => any
getSpan?: (span: TokenSpan) => T
renderMark?: (props: MarkProps) => JSX.Element
// TODO: determine whether to overwrite or leave intersecting ranges.
}

// TODO: When React 16.3 types are ready, remove casts.
class TokenAnnotator extends React.Component<TokenAnnotatorProps, {}> {
static defaultProps = {
renderMark: props => <Mark {...props} />,
}

rootRef: React.RefObject<HTMLDivElement>

constructor(props) {
super(props)

this.rootRef = React.createRef()
}
const TokenAnnotator = <T extends Span>(props: TokenAnnotatorProps<T>) => {
const renderMark = props.renderMark || (props => <Mark {...props} />)

componentDidMount() {
this.rootRef.current.addEventListener('mouseup', this.handleMouseUp)
const getSpan = (span: TokenSpan): T => {
if (props.getSpan) return props.getSpan(span)
return {start: span.start, end: span.end} as T
}

componentWillUnmount() {
this.rootRef.current.removeEventListener('mouseup', this.handleMouseUp)
}

handleMouseUp = () => {
if (!this.props.onChange) return
const handleMouseUp = () => {
if (!props.onChange) return

const selection = window.getSelection()

Expand All @@ -74,48 +61,35 @@ class TokenAnnotator extends React.Component<TokenAnnotatorProps, {}> {

end += 1

this.props.onChange([
...this.props.value,
this.getSpan({start, end, tokens: this.props.tokens.slice(start, end)}),
])
props.onChange([...props.value, getSpan({start, end, tokens: props.tokens.slice(start, end)})])
window.getSelection().empty()
}

handleSplitClick = ({start, end}) => {
const handleSplitClick = ({start, end}) => {
// Find and remove the matching split.
const splitIndex = this.props.value.findIndex(s => s.start === start && s.end === end)
const splitIndex = props.value.findIndex(s => s.start === start && s.end === end)
if (splitIndex >= 0) {
this.props.onChange([
...this.props.value.slice(0, splitIndex),
...this.props.value.slice(splitIndex + 1),
])
props.onChange([...props.value.slice(0, splitIndex), ...props.value.slice(splitIndex + 1)])
}
}

getSpan = (span: TokenSpan) => {
if (this.props.getSpan) return this.props.getSpan(span)
return span
}

render() {
const {tokens, value, renderMark, onChange, getSpan, ...divProps} = this.props
const splits = splitTokensWithOffsets(tokens, value)
return (
<div ref={this.rootRef} {...divProps}>
{splits.map((split, i) =>
split.mark ? (
renderMark({
key: `${split.start}-${split.end}`,
...split,
onClick: this.handleSplitClick,
})
) : (
<Token key={split.i} {...split} />
)
)}
</div>
)
}
const {tokens, value, onChange, getSpan: _, ...divProps} = props
const splits = splitTokensWithOffsets(tokens, value)
return (
<div {...divProps} onMouseUp={handleMouseUp}>
{splits.map((split, i) =>
split.mark ? (
renderMark({
key: `${split.start}-${split.end}`,
...split,
onClick: handleSplitClick,
})
) : (
<Token key={split.i} {...split} />
)
)}
</div>
)
}

export default TokenAnnotator
24 changes: 24 additions & 0 deletions src/__tests__/TextAnnotator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import TextAnnotator from '../TextAnnotator'
import {render, fireEvent} from '@testing-library/react'

test('renders without getSpan', () => {
render(
<TextAnnotator
content="Foo bar baz"
value={[{start: 0, end: 5, tag: 'PERSON', text: 'foo', extra: 1}]}
onChange={() => {}}
/>
)
})

test('renders when value and getSpan return match', () => {
render(
<TextAnnotator
content="Foo bar baz"
value={[{start: 0, end: 5, tag: 'PERSON', text: 'foo', extra: 1}]}
onChange={() => {}}
getSpan={span => ({...span, tag: 'FOO', text: 'foo', extra: 1})}
/>
)
})
24 changes: 24 additions & 0 deletions src/__tests__/TokenAnnotator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import {render} from '@testing-library/react'
import TokenAnnotator from '../TokenAnnotator'

test('renders without getSpan', () => {
render(
<TokenAnnotator
tokens={['Foo', 'Bar', 'Baz']}
value={[{start: 0, end: 5, tag: 'PERSON', tokens: [], extra: 1}]}
onChange={() => {}}
/>
)
})

test('renders when value and getSpan return match', () => {
render(
<TokenAnnotator
tokens={['Foo', 'Bar', 'Baz']}
value={[{start: 0, end: 1, tag: 'PERSON', tokens: ['Foo'], extra: 1}]}
onChange={() => {}}
getSpan={span => ({...span, tag: 'FOO', tokens: ['Foo'], extra: 1})}
/>
)
})
4 changes: 4 additions & 0 deletions src/span.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Span = {
start: number
end: number
}
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as sortBy from 'lodash.sortby'
import sortBy from 'lodash.sortby'

export const splitWithOffsets = (text, offsets: {start: number; end: number}[]) => {
let lastEnd = 0
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"module": "commonjs",
"sourceMap": true,
"jsx": "react",
"lib": ["es2016", "dom"]
"lib": ["es2016", "dom"],
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Loading

0 comments on commit b3553fd

Please sign in to comment.