Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/react managed undo #1135

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions __tests__/__snapshots__/storyshots.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,7 @@ exports[`Storyshots Components/ChallengeMaterial With Comments 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -1894,6 +1895,7 @@ exports[`Storyshots Components/ChallengeMaterial With Diff 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -4706,6 +4708,7 @@ exports[`Storyshots Components/FormCard Basic 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value="oheuhehe"
Expand Down Expand Up @@ -4943,6 +4946,7 @@ exports[`Storyshots Components/FormCard With Border 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value="oheuhehe"
Expand Down Expand Up @@ -5110,6 +5114,7 @@ exports[`Storyshots Components/FormCard With Title 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value="oheuhehe"
Expand Down Expand Up @@ -5277,6 +5282,7 @@ exports[`Storyshots Components/FormCard With Validation 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value="oheuhehe"
Expand Down Expand Up @@ -6898,6 +6904,7 @@ exports[`Storyshots Components/MdInput Basic 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -6960,6 +6967,7 @@ exports[`Storyshots Components/MdInput White 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -7022,6 +7030,7 @@ exports[`Storyshots Components/MdInput With Preset Value 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value="HinGa DIngA dUrgEN"
Expand Down Expand Up @@ -7085,6 +7094,7 @@ exports[`Storyshots Components/MdInput With Submission Buttons 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -9697,6 +9707,7 @@ exports[`Storyshots Components/ReviewCard Active Card 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -10755,6 +10766,7 @@ exports[`Storyshots Components/ReviewCard No Last Name 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -11811,6 +11823,7 @@ exports[`Storyshots Components/ReviewCard With Long Comment 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -12867,6 +12880,7 @@ exports[`Storyshots Components/ReviewCard Without Comment 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down Expand Up @@ -13923,6 +13937,7 @@ exports[`Storyshots Components/ReviewCard Without Username 1`] = `
className="textarea"
data-testid="textbox"
onChange={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
placeholder="Type something..."
value=""
Expand Down
76 changes: 76 additions & 0 deletions components/MdInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,80 @@ describe('MdInput Component', () => {
)
expect(textbox).toHaveStyle('height: 200px')
})
test('Undo should restore previous text', () => {
render(<TestComponent />)
const textbox = screen.getByRole('textbox')

userEvent.click(textbox)
userEvent.type(textbox, 'Hello,{enter}Tom!!')

expect(textbox).toHaveValue('Hello,\nTom!!')
userEvent.type(textbox, '{ctrl}zz')
expect(textbox).toHaveValue('Hello,\nTom')
})
test('Undo should stop if there is no text to undo', () => {
render(<TestComponent />)
const textbox = screen.getByRole('textbox')

userEvent.click(textbox)
userEvent.type(textbox, 'Tom')

expect(textbox).toHaveValue('Tom')
userEvent.type(textbox, '{ctrl}zzzzzzz')
expect(textbox).toHaveValue('')
})
test('Redo should restore previous text from undo', () => {
render(<TestComponent />)
const textbox = screen.getByRole('textbox')

userEvent.click(textbox)
userEvent.type(textbox, 'Hello,{enter}Tom!!')

expect(textbox).toHaveValue('Hello,\nTom!!')
userEvent.type(textbox, '{ctrl}zz')
expect(textbox).toHaveValue('Hello,\nTom')
userEvent.type(textbox, '{ctrl}yy')
expect(textbox).toHaveValue('Hello,\nTom!!')
})
test('Redo should stop if there is no text to redo', () => {
render(<TestComponent />)
const textbox = screen.getByRole('textbox')

userEvent.click(textbox)
userEvent.type(textbox, 'Tom')

expect(textbox).toHaveValue('Tom')
userEvent.type(textbox, '{ctrl}zzzzzzz')
expect(textbox).toHaveValue('')
userEvent.type(textbox, '{ctrl}yyyyyyy')
expect(textbox).toHaveValue('Tom')
})
test('Undo/Redo history should be reset if state is updated outside of component', () => {
const TestRig = () => {
const [value, setValue] = React.useState('')
return (
<div>
<button onClick={() => setValue('Reset')}>Reset</button>
<MdInput value={value} onChange={setValue} />
</div>
)
}
render(<TestRig />)

const textbox = screen.getByRole('textbox')

userEvent.click(textbox)
userEvent.type(textbox, 'Tom')

expect(textbox).toHaveValue('Tom')
userEvent.type(textbox, '{ctrl}z')
expect(textbox).toHaveValue('To')
userEvent.click(screen.getByRole('button', { name: 'Reset' }))

expect(textbox).toHaveValue('Reset')
userEvent.type(textbox, '{ctrl}z')
expect(textbox).toHaveValue('Reset')
userEvent.type(textbox, '{ctrl}y')
expect(textbox).toHaveValue('Reset')
})
})
82 changes: 81 additions & 1 deletion components/MdInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { colors } from './theme/colors'
import { Nav } from 'react-bootstrap'
import noop from '../helpers/noop'
import styles from '../scss/mdInput.module.scss'
import { getHotkeyListener } from '../helpers/hotkeyListener'
import useUndo from 'use-undo'

type TextAreaState = {
value: string
selectionStart: number
selectionEnd: number
}

type MdInputProps = {
onChange?: Function
Expand Down Expand Up @@ -31,6 +39,67 @@ export const MdInput: React.FC<MdInputProps> = ({
const isMountedRef = useRef(false)
const [height, setHeight] = useState<number | null>(null)

// Undo/Redo state must be stored internally to allow custom manipulation of input text.
// This is because any call besides 'onChange' events from the input wipe out the native undo/redo functionality.
const [
{ present: internalState, past, future },
{ set: setInternalState, undo, redo, reset }
] = useUndo<TextAreaState>({
value,
selectionStart: value.length,
selectionEnd: value.length
})

useEffect(() => {
if (internalState.value !== value) {
// Value was updated externally, reset component history
reset({
value,
selectionStart: value.length,
selectionEnd: value.length
})
return
}
// restore cursor position
textareaRef.current?.setSelectionRange(
internalState.selectionStart,
internalState.selectionEnd
)
}, [value, internalState, reset])

const updateState = ({
value,
selectionStart,
selectionEnd
}: TextAreaState) => {
setInternalState({ value, selectionStart, selectionEnd })
onChange(value)
textareaRef.current?.focus()
}

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const { value, selectionStart, selectionEnd } = e.target

updateState({ value, selectionStart, selectionEnd })
}

const handleUndo = () => {
if (!past.length) return

const nextState = past[past.length - 1]
undo()
onChange(nextState.value)
textareaRef.current?.focus()
}
const handleRedo = () => {
if (!future.length) return

const nextState = future[0]
redo()
onChange(nextState.value)
textareaRef.current?.focus()
}

useEffect(() => {
// Focus when returning from preview mode after component has mounted
if (!preview && isMountedRef.current) textareaRef.current?.focus()
Expand Down Expand Up @@ -59,6 +128,16 @@ export const MdInput: React.FC<MdInputProps> = ({
return () => window.removeEventListener('mouseup', updateHeight, false)
}, [])

// Currently registering both linux/windows and mac hotkeys for everyone
const hotkeyMap = {
'ctrl+z': handleUndo,
'cmd+z': handleUndo,
'ctrl+y': handleRedo,
'shift+cmd+z': handleRedo
}

const hotkeyListener = getHotkeyListener(hotkeyMap)

const previewBtnColor = preview ? 'black' : 'lightgrey'
const writeBtnColor = preview ? 'lightgrey' : 'black'

Expand Down Expand Up @@ -106,6 +185,7 @@ export const MdInput: React.FC<MdInputProps> = ({
</>
)}
<textarea
onKeyDown={hotkeyListener}
onMouseDown={() => {
mouseDownHeightRef.current = textareaRef.current!.clientHeight
}}
Expand All @@ -114,7 +194,7 @@ export const MdInput: React.FC<MdInputProps> = ({
className={`${styles['textarea']}${preview ? ' d-none' : ''}`}
onChange={e => {
if (!height && textareaRef.current) autoSize(textareaRef.current)
onChange(e.target.value)
handleChange(e)
}}
placeholder={placeHolder}
style={height ? { height: height + 'px' } : undefined}
Expand Down
Loading