Skip to content

Commit 70eb905

Browse files
committed
Basic MarkdownToolbar component (no multiline style support)
1 parent af3f5fe commit 70eb905

File tree

4 files changed

+487
-0
lines changed

4 files changed

+487
-0
lines changed

components/MarkdownToolbar.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import '@testing-library/jest-dom'
5+
import MarkdownToolbar from './MarkdownToolbar'
6+
7+
describe('MarkdownToolbar Component', () => {
8+
const mockExecCommand = jest.fn()
9+
global.window.document.execCommand = mockExecCommand
10+
const mockRef = { current: null }
11+
beforeEach(() => {
12+
mockRef.current = document.createElement('textarea')
13+
jest.spyOn(mockRef.current, 'setSelectionRange')
14+
jest.clearAllMocks()
15+
})
16+
test('Should match screenshot', () => {
17+
const { container } = render(<MarkdownToolbar inputRef={mockRef} />)
18+
expect(container).toMatchSnapshot()
19+
})
20+
describe('Bold Button', () => {
21+
test('should bold selected word and update selection', () => {
22+
mockRef.current.value = 'tom'
23+
mockRef.current.setSelectionRange(0, 3)
24+
25+
render(<MarkdownToolbar inputRef={mockRef} />)
26+
userEvent.click(screen.getByRole('button', { name: /bold/ }))
27+
expect(mockExecCommand).toBeCalledWith('insertText', false, '**tom**')
28+
expect(mockRef.current.setSelectionRange).toHaveBeenLastCalledWith(2, 5)
29+
})
30+
test('should remove bold around already bolded word and update selection', () => {
31+
mockRef.current.value = '**tom**'
32+
mockRef.current.setSelectionRange(2, 5)
33+
render(<MarkdownToolbar inputRef={mockRef} />)
34+
35+
userEvent.click(screen.getByRole('button', { name: /bold/ }))
36+
expect(mockExecCommand).toBeCalledWith('insertText', false, 'tom')
37+
expect(mockRef.current.setSelectionRange).toHaveBeenLastCalledWith(0, 3)
38+
})
39+
xtest('should bold full word if cursor inside a word', () => {})
40+
xtest('should remove bold from word if cursor is inside a bolded word', () => {})
41+
xtest('should bold around cursor if cursor is touching whitespace', () => {})
42+
})
43+
describe('Header Button', () => {
44+
test('should add header ', () => {
45+
mockRef.current.value = 'tom'
46+
mockRef.current.setSelectionRange(0, 3)
47+
48+
render(<MarkdownToolbar inputRef={mockRef} />)
49+
userEvent.click(screen.getByRole('button', { name: /header/ }))
50+
expect(mockExecCommand).toBeCalledWith('insertText', false, '### tom')
51+
expect(mockRef.current.setSelectionRange).toHaveBeenLastCalledWith(4, 7)
52+
})
53+
})
54+
})

components/MarkdownToolbar.tsx

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import React from 'react'
2+
import {
3+
HeadingIcon,
4+
BoldIcon,
5+
ItalicIcon,
6+
QuoteIcon,
7+
CodeIcon,
8+
LinkIcon,
9+
ListUnorderedIcon,
10+
ListOrderedIcon,
11+
TasklistIcon
12+
} from '@primer/octicons-react'
13+
import {
14+
ButtonToolbar,
15+
ButtonToolbarProps,
16+
OverlayTrigger,
17+
Tooltip
18+
} from 'react-bootstrap'
19+
20+
type StyleArgs = {
21+
prefix?: string
22+
suffix?: string
23+
blockPrefix?: string
24+
blockSuffix?: string
25+
multiline?: boolean
26+
surroundWithNewLines?: boolean
27+
}
28+
type ApplyMarkdown = (
29+
inputRef: React.RefObject<HTMLTextAreaElement>,
30+
styleArgs: StyleArgs
31+
) => void
32+
const applyMarkdown: ApplyMarkdown = (
33+
inputRef,
34+
{
35+
prefix = '',
36+
suffix = '',
37+
blockPrefix = '',
38+
blockSuffix = '',
39+
multiline = false,
40+
surroundWithNewLines = false
41+
} = {}
42+
) => {
43+
if (!inputRef.current) return
44+
const { value, selectionStart, selectionEnd } = inputRef.current
45+
46+
const leftOfSelection = value.slice(0, selectionStart)
47+
const selection = value.slice(selectionStart, selectionEnd)
48+
const rightOfSelection = value.slice(selectionEnd)
49+
inputRef.current.focus()
50+
51+
// If Style is already applied remove it
52+
if (leftOfSelection.endsWith(prefix) && rightOfSelection.startsWith(suffix)) {
53+
// expand selection to include prefix and suffix
54+
inputRef.current.setSelectionRange(
55+
selectionStart - prefix.length,
56+
selectionEnd + suffix.length
57+
)
58+
59+
insertText(inputRef.current, selection)
60+
61+
// update selection with removed prefix
62+
inputRef.current.setSelectionRange(
63+
selectionStart - prefix.length,
64+
selectionEnd - prefix.length
65+
)
66+
} else {
67+
// apply style updates
68+
69+
let newText = prefix + selection + suffix
70+
71+
if (surroundWithNewLines)
72+
newText = newLineSurround(leftOfSelection, rightOfSelection, newText)
73+
74+
insertText(inputRef.current, newText)
75+
76+
/// update selection with added prefix and added newlines
77+
78+
const addedNewLines =
79+
!surroundWithNewLines || !leftOfSelection
80+
? 0
81+
: 2 - leftOfSelection.match(/\n*$/)![0].length
82+
83+
inputRef.current.setSelectionRange(
84+
selectionStart + prefix.length + addedNewLines,
85+
selectionEnd + prefix.length + addedNewLines
86+
)
87+
}
88+
}
89+
90+
export function newLineSurround(
91+
leftOfSelection: string,
92+
rightOfSelection: string,
93+
text: string
94+
): string {
95+
// If left or right are empty strings meaning the text is at the beginning or end
96+
// of the textarea so don't add new lines in that case.
97+
// Else make sure there are at least 2 new lines
98+
return (
99+
'\n'.repeat(
100+
!leftOfSelection ? 0 : 2 - leftOfSelection.match(/\n*$/)![0].length
101+
) +
102+
text +
103+
'\n'.repeat(
104+
!rightOfSelection ? 0 : 2 - rightOfSelection.match(/^\n*/)![0].length
105+
)
106+
)
107+
}
108+
109+
export function insertText(textArea: HTMLTextAreaElement, text: string) {
110+
// Detect bug case with chrome automatically removing linefeed character
111+
// when replace all the text on last line with empty string
112+
const leftOfSelection = textArea.value[textArea.selectionStart - 1]
113+
const rightOfSelection = textArea.value[textArea.selectionEnd + 1]
114+
const isChromeBugCase =
115+
text === '' && leftOfSelection === '\n' && rightOfSelection === undefined
116+
117+
// Expand selection to include line feed in bug case
118+
if (isChromeBugCase) textArea.selectionStart = textArea.selectionStart - 1
119+
120+
document.execCommand(
121+
'insertText',
122+
false,
123+
(isChromeBugCase ? '\n' : '') + text // add linefeed back in bug case
124+
)
125+
}
126+
127+
// TODO: Add hotkey support!
128+
const buttons = [
129+
{
130+
tooltipTitle: 'Add header text',
131+
styleArgs: { prefix: '### ', surroundWithNewLines: true },
132+
Icon: HeadingIcon
133+
},
134+
{
135+
tooltipTitle: 'Add bold text',
136+
styleArgs: { prefix: '**', suffix: '**' },
137+
Icon: BoldIcon
138+
},
139+
{
140+
tooltipTitle: 'Add italic text',
141+
styleArgs: { prefix: '_', suffix: '_' },
142+
Icon: ItalicIcon
143+
},
144+
{
145+
tooltipTitle: 'Insert a quote',
146+
styleArgs: { prefix: '> ', surroundWithNewLines: true, multiline: true },
147+
Icon: QuoteIcon
148+
},
149+
{
150+
tooltipTitle: 'Insert code',
151+
styleArgs: {
152+
prefix: '`',
153+
suffix: '`',
154+
blockPrefix: '```',
155+
blockSuffix: '```'
156+
},
157+
Icon: CodeIcon
158+
},
159+
{
160+
tooltipTitle: 'Add a link',
161+
styleArgs: { prefix: '[', suffix: '](URL)' },
162+
Icon: LinkIcon
163+
},
164+
{
165+
tooltipTitle: 'Add a bulleted list',
166+
styleArgs: { prefix: '- ', surroundWithNewLines: true, multiline: true },
167+
Icon: ListUnorderedIcon
168+
},
169+
{
170+
tooltipTitle: 'Add a numbered list',
171+
styleArgs: { prefix: '1. ', surroundWithNewLines: true },
172+
Icon: ListOrderedIcon
173+
},
174+
{
175+
tooltipTitle: 'Add a task list',
176+
styleArgs: { prefix: '- [ ] ', surroundWithNewLines: true },
177+
Icon: TasklistIcon
178+
}
179+
]
180+
181+
type Props = {
182+
inputRef: React.RefObject<HTMLTextAreaElement>
183+
}
184+
185+
const MarkdownToolbar: React.FC<Props & ButtonToolbarProps> = ({
186+
inputRef,
187+
...buttonProps
188+
}) => {
189+
return (
190+
<ButtonToolbar {...buttonProps}>
191+
{buttons.map(({ tooltipTitle, styleArgs, Icon }, i) => (
192+
<OverlayTrigger
193+
key={i}
194+
placement="bottom-end"
195+
delay={{ show: 200, hide: 100 }}
196+
overlay={<Tooltip id="btn-tooltip">{tooltipTitle}</Tooltip>}
197+
>
198+
{({ ref, ...triggerHandler }) => {
199+
return (
200+
<button
201+
className="btn"
202+
{...triggerHandler}
203+
ref={ref}
204+
aria-label={tooltipTitle}
205+
onClick={() => applyMarkdown(inputRef, styleArgs)}
206+
>
207+
<Icon size={'small'} />
208+
</button>
209+
)
210+
}}
211+
</OverlayTrigger>
212+
))}
213+
</ButtonToolbar>
214+
)
215+
}
216+
217+
export default MarkdownToolbar

components/MdInput.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { colors } from './theme/colors'
44
import { Nav } from 'react-bootstrap'
55
import noop from '../helpers/noop'
66
import styles from '../scss/mdInput.module.scss'
7+
import useBreakpoint from '../helpers/useBreakpoint'
8+
import MarkdownToolbar from './MarkdownToolbar'
79

810
type MdInputProps = {
911
onChange?: Function
@@ -30,6 +32,7 @@ export const MdInput: React.FC<MdInputProps> = ({
3032
const mouseDownHeightRef = useRef<number | null>(null)
3133
const isMountedRef = useRef(false)
3234
const [height, setHeight] = useState<number | null>(null)
35+
const lessThanMd = useBreakpoint('md')
3336

3437
useEffect(() => {
3538
// Focus when returning from preview mode after component has mounted
@@ -83,7 +86,11 @@ export const MdInput: React.FC<MdInputProps> = ({
8386
Preview
8487
</Nav.Link>
8588
</Nav.Item>
89+
{!preview && !lessThanMd && (
90+
<MarkdownToolbar inputRef={textareaRef} className="ml-auto" />
91+
)}
8692
</Nav>
93+
{!preview && lessThanMd && <MarkdownToolbar inputRef={textareaRef} />}
8794
{preview && (
8895
<>
8996
{value ? (

0 commit comments

Comments
 (0)