readd toolbar (#302)

* added all functionality to the toolbar buttons
* added unit tests for the toolbar functions
* added unit tests to CI
* Added translated titles to buttons of toolbar

Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Co-authored-by: mrdrogdrog <mr.drogdrog@gmail.com>
Co-authored-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Philip Molares 2020-07-16 11:34:56 +02:00 committed by GitHub
parent f0fe7f5ac2
commit 1b52bac838
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 644 additions and 46 deletions

View file

@ -27,5 +27,7 @@ jobs:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: yarn install
- name: run unit tests
run: yarn test
- name: Build project
run: yarn build

View file

@ -40,6 +40,7 @@
- The gist and pdf embeddings now use a one-click aproach similar to vimeo and youtube
- Use [Twemoji](https://twemoji.twitter.com/) as icon font
- The `[name=...]`, `[time=...]` and `[color=...]` tags may now be used anywhere in the document and not just inside of blockquotes and lists.
- The <i class="fa fa-picture-o"/> (add image) and <i class="fa fa-link"/> (add link) toolbar buttons, put selected links directly in the `()` instead of the `[]` part of the generated markdown
---

View file

@ -22,6 +22,12 @@ You will also see any lint errors in the console.
### Tests
#### Unit
Unit testing is done via jest.
1. `yarn test`
#### End2End
We use [cypress](https://cypress.io) for e2e tests.

View file

@ -203,7 +203,7 @@
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"header": "Header",
"header": "Heading",
"code": "Code",
"blockquote": "Blockquote",
"unorderedList": "Unordered List",
@ -213,7 +213,7 @@
"image": "Image",
"uploadImage": "Upload Image",
"table": "Table",
"line": "Line",
"line": "Horizontal line",
"comment": "Comment"
},
"menu": {

View file

@ -11,61 +11,94 @@ import 'codemirror/addon/search/match-highlighter'
import 'codemirror/addon/selection/active-line'
import 'codemirror/keymap/sublime.js'
import 'codemirror/mode/gfm/gfm.js'
import React from 'react'
import React, { useCallback, useState } from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import './editor-window.scss'
import { Positions, SelectionData } from './interfaces'
import { ToolBar } from './tool-bar/tool-bar'
export interface EditorWindowProps {
onContentChange: (content: string) => void
content: string
}
const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, content }) => {
export const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, content }) => {
const { t } = useTranslation()
const [positions, setPositions] = useState<Positions>({
startPosition: {
ch: 0,
line: 0
},
endPosition: {
ch: 0,
line: 0
}
})
const onSelection = useCallback((editor, data: SelectionData) => {
const { anchor, head } = data.ranges[0]
const headFirst = head.line < anchor.line || (head.line === anchor.line && head.ch < anchor.ch)
setPositions({
startPosition: {
line: headFirst ? head.line : anchor.line,
ch: headFirst ? head.ch : anchor.ch
},
endPosition: {
line: headFirst ? anchor.line : head.line,
ch: headFirst ? anchor.ch : head.ch
}
})
}, [])
return (
<ControlledCodeMirror
className="h-100 w-100 flex-fill"
value={content}
options={{
mode: 'gfm',
theme: 'one-dark',
keyMap: 'sublime',
viewportMargin: 20,
styleActiveLine: true,
lineNumbers: true,
lineWrapping: true,
showCursorWhenSelecting: true,
highlightSelectionMatches: true,
indentUnit: 4,
// continueComments: 'Enter',
inputStyle: 'textarea',
matchBrackets: true,
autoCloseBrackets: true,
matchTags: {
bothTags: true
},
autoCloseTags: true,
foldGutter: true,
gutters: [
'CodeMirror-linenumbers',
'authorship-gutters',
'CodeMirror-foldgutter'
],
// extraKeys: this.defaultExtraKeys,
flattenSpans: true,
addModeClass: true,
// autoRefresh: true,
// otherCursors: true
placeholder: t('editor.placeholder')
}
}
onBeforeChange={(editor, data, value) => {
onContentChange(value)
}}
/>
<div className={'d-flex flex-column h-100'}>
<ToolBar
content={content}
onContentChange={onContentChange}
positions={positions}
/>
<ControlledCodeMirror
className="overflow-hidden w-100 flex-fill"
value={content}
options={{
mode: 'gfm',
theme: 'one-dark',
keyMap: 'sublime',
viewportMargin: 20,
styleActiveLine: true,
lineNumbers: true,
lineWrapping: true,
showCursorWhenSelecting: true,
highlightSelectionMatches: true,
indentUnit: 4,
// continueComments: 'Enter',
inputStyle: 'textarea',
matchBrackets: true,
autoCloseBrackets: true,
matchTags: {
bothTags: true
},
autoCloseTags: true,
foldGutter: true,
gutters: [
'CodeMirror-linenumbers',
'authorship-gutters',
'CodeMirror-foldgutter'
],
// extraKeys: this.defaultExtraKeys,
flattenSpans: true,
addModeClass: true,
// autoRefresh: true,
// otherCursors: true
placeholder: t('editor.placeholder')
}}
onBeforeChange={(editor, data, value) => {
onContentChange(value)
}}
onSelection={onSelection}
/>
</div>
)
}
export { EditorWindow }

View file

@ -0,0 +1,15 @@
import CodeMirror from 'codemirror'
export interface SelectionData {
ranges: AnchorAndHead[]
}
interface AnchorAndHead {
anchor: CodeMirror.Position
head: CodeMirror.Position
}
export interface Positions {
startPosition: CodeMirror.Position
endPosition: CodeMirror.Position
}

View file

@ -0,0 +1,3 @@
.btn-toolbar {
border: 1px solid #ededed;
}

View file

@ -0,0 +1,82 @@
import React from 'react'
import { Button, ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { Positions } from '../interfaces'
import './tool-bar.scss'
import { addCodeFences, addHeaderLevel, addLink, addMarkup, addQuotes, createList, replaceSelection } from './utils'
export interface ToolBarProps {
content: string
onContentChange: (content: string) => void
positions: Positions
}
export const ToolBar: React.FC<ToolBarProps> = ({ content, positions, onContentChange }) => {
const { t } = useTranslation()
const notImplemented = () => {
alert('This feature is not yet implemented')
}
const makeSelectionBold = () => addMarkup(content, positions.startPosition, positions.endPosition, onContentChange, '**')
const makeSelectionItalic = () => addMarkup(content, positions.startPosition, positions.endPosition, onContentChange, '*')
const strikeThroughSelection = () => addMarkup(content, positions.startPosition, positions.endPosition, onContentChange, '~~')
const addList = () => createList(content, positions.startPosition, positions.endPosition, onContentChange, () => '-')
const addOrderedList = () => createList(content, positions.startPosition, positions.endPosition, onContentChange, j => `${j}.`)
const addTaskList = () => createList(content, positions.startPosition, positions.endPosition, onContentChange, () => '- [ ]')
const addLine = () => replaceSelection(content, positions.startPosition, positions.endPosition, onContentChange, '----')
const addComment = () => replaceSelection(content, positions.startPosition, positions.endPosition, onContentChange, '> []')
const addTable = () => replaceSelection(content, positions.startPosition, positions.endPosition, onContentChange, '| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |')
return (
<ButtonToolbar className='flex-nowrap bg-light'>
<Button variant='light' onClick={makeSelectionBold} title={t('editor.editorToolbar.bold')}>
<ForkAwesomeIcon icon="bold"/>
</Button>
<Button variant='light' onClick={makeSelectionItalic} title={t('editor.editorToolbar.italic')}>
<ForkAwesomeIcon icon="italic"/>
</Button>
<Button variant='light' onClick={strikeThroughSelection} title={t('editor.editorToolbar.strikethrough')}>
<ForkAwesomeIcon icon="strikethrough"/>
</Button>
<Button variant='light' onClick={() => addHeaderLevel(content, positions.startPosition, onContentChange)} title={t('editor.editorToolbar.header')}>
<ForkAwesomeIcon icon="header"/>
</Button>
<Button variant='light' onClick={() => addCodeFences(content, positions.startPosition, positions.endPosition, onContentChange)} title={t('editor.editorToolbar.code')}>
<ForkAwesomeIcon icon="code"/>
</Button>
<Button variant='light' onClick={() => addQuotes(content, positions.startPosition, positions.endPosition, onContentChange)} title={t('editor.editorToolbar.blockquote')}>
<ForkAwesomeIcon icon="quote-right"/>
</Button>
<Button variant='light' onClick={addList} title={t('editor.editorToolbar.unorderedList')}>
<ForkAwesomeIcon icon="list"/>
</Button>
<Button variant='light' onClick={addOrderedList} title={t('editor.editorToolbar.orderedList')}>
<ForkAwesomeIcon icon="list-ol"/>
</Button>
<Button variant='light' onClick={addTaskList} title={t('editor.editorToolbar.checkList')}>
<ForkAwesomeIcon icon="check-square"/>
</Button>
<Button variant='light' onClick={() => addLink(content, positions.startPosition, positions.endPosition, onContentChange)} title={t('editor.editorToolbar.link')}>
<ForkAwesomeIcon icon="link"/>
</Button>
<Button variant='light' onClick={() => addLink(content, positions.startPosition, positions.endPosition, onContentChange, '!')} title={t('editor.editorToolbar.image')}>
<ForkAwesomeIcon icon="picture-o"/>
</Button>
<Button variant='light' onClick={notImplemented} title={t('editor.editorToolbar.uploadImage')}>
<ForkAwesomeIcon icon="upload"/>
</Button>
<Button variant='light' onClick={addTable} title={t('editor.editorToolbar.table')}>
<ForkAwesomeIcon icon="table"/>
</Button>
<Button variant='light' onClick={addLine} title={t('editor.editorToolbar.line')}>
<ForkAwesomeIcon icon="minus"/>
</Button>
<Button variant='light' onClick={addComment} title={t('editor.editorToolbar.comment')}>
<ForkAwesomeIcon icon="comment"/>
</Button>
</ButtonToolbar>
)
}

View file

@ -0,0 +1,352 @@
import {
addCodeFences,
addHeaderLevel,
addLink,
addMarkup,
addQuotes,
createList,
removeLastNewLine,
replaceSelection
} from './utils'
const testContent = '1st line\n2nd line\n3rd line'
const cursor = {
startPosition: { line: 0, ch: 0 },
endPosition: { line: 0, ch: 0 }
}
const firstLine = {
startPosition: { line: 0, ch: 0 },
endPosition: { line: 0, ch: 9 }
}
const multiline = {
startPosition: { line: 1, ch: 0 },
endPosition: { line: 2, ch: 9 }
}
const multilineOffset = {
startPosition: { line: 1, ch: 4 },
endPosition: { line: 2, ch: 4 }
}
describe('test removeLastNewLine', () => {
const testSentence = 'This is a test sentence'
const testMultiLine = 'This is a\ntest sentence over two lines'
it('single line without \\n at the end', () => {
expect(removeLastNewLine(testSentence)).toEqual(testSentence)
})
it('single line with \\n at the end', () => {
expect(removeLastNewLine(testSentence + '\n')).toEqual(testSentence)
})
it('multi line without \\n at the end', () => {
expect(removeLastNewLine(testMultiLine)).toEqual(testMultiLine)
})
it('multi line with \\n at the end', () => {
expect(removeLastNewLine(testMultiLine + '\n')).toEqual(testMultiLine)
})
})
describe('test addMarkUp', () => {
it('just cursor', done => {
let error = false
addMarkup(testContent, cursor.startPosition, cursor.endPosition, () => {
// This should never be called
error = true
}, '**')
expect(error).toBeFalsy()
done()
})
it('1st line', done => {
const newContent = testContent
.split('\n')
.map((line, index) => {
if (index === 0) {
line = `**${line}**`
}
return line
})
.join('\n')
addMarkup(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual(newContent)
done()
}, '**')
})
it('multiple lines', done => {
const newContent = '1st line\n**2nd line\n3rd line**'
addMarkup(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual(newContent)
done()
}, '**')
})
it('multiple lines with offset', done => {
const newContent = '1st line\n2nd **line\n3rd **line'
addMarkup(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual(newContent)
done()
}, '**')
})
})
describe('test addHeaderLevel', () => {
const firstHeading = '# 1st line\n2nd line\n3rd line'
it('no heading before', done => {
addHeaderLevel(testContent, cursor.startPosition, content => {
expect(content).toEqual(firstHeading)
done()
})
})
it('level one heading before', done => {
const secondHeading = '## 1st line\n2nd line\n3rd line'
addHeaderLevel(firstHeading, cursor.startPosition, content => {
expect(content).toEqual(secondHeading)
done()
})
})
it('1st line', done => {
addHeaderLevel(testContent, firstLine.startPosition, content => {
expect(content).toEqual(firstHeading)
done()
})
})
const newMultilineContent = '1st line\n# 2nd line\n3rd line'
it('multiple lines', done => {
addHeaderLevel(testContent, multiline.startPosition, content => {
expect(content).toEqual(newMultilineContent)
done()
})
})
it('multiple lines with offset', done => {
addHeaderLevel(testContent, multilineOffset.startPosition, content => {
expect(content).toEqual(newMultilineContent)
done()
})
})
})
describe('test addCodeFences', () => {
it('just cursor', done => {
addCodeFences(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('```\n\n```' + testContent)
done()
})
})
it('1st line', done => {
addCodeFences(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('```\n1st line\n```\n2nd line\n3rd line')
done()
})
})
it('multiple lines', done => {
addCodeFences(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n```\n2nd line\n3rd line\n```')
done()
})
})
it('multiple lines with offset', done => {
addCodeFences(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n2nd ```\nline\n3rd \n```line')
done()
})
})
})
describe('test addQuotes', () => {
it('just cursor', done => {
addQuotes(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('> ' + testContent)
done()
})
})
it('1st line', done => {
addQuotes(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('> ' + testContent)
done()
})
})
it('multiple lines', done => {
addQuotes(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n> 2nd line\n> 3rd line')
done()
})
})
it('multiple lines with offset', done => {
addQuotes(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n> 2nd line\n> 3rd line')
done()
})
})
})
describe('test createList', () => {
describe('unordered list', () => {
it('just cursor', done => {
createList(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('- ' + testContent)
done()
}, () => '-')
})
it('1st line', done => {
createList(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('- ' + testContent)
done()
}, () => '-')
})
it('multiple lines', done => {
createList(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n- 2nd line\n- 3rd line')
done()
}, () => '-')
})
it('multiple lines with offset', done => {
createList(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n- 2nd line\n- 3rd line')
done()
}, () => '-')
})
})
describe('ordered list', () => {
it('just cursor', done => {
createList(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('1. ' + testContent)
done()
}, (j) => `${j}.`)
})
it('1st line', done => {
createList(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('1. ' + testContent)
done()
}, (j) => `${j}.`)
})
it('multiple lines', done => {
createList(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n1. 2nd line\n2. 3rd line')
done()
}, (j) => `${j}.`)
})
it('multiple lines with offset', done => {
createList(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n1. 2nd line\n2. 3rd line')
done()
}, (j) => `${j}.`)
})
})
})
describe('test addLink', () => {
it('just cursor', done => {
addLink(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('[](https://)' + testContent)
done()
})
})
it('1st line', done => {
addLink(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('[1st line](https://)\n2nd line\n3rd line')
done()
})
})
it('multiple lines', done => {
addLink(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n[2nd line\n3rd line](https://)')
done()
})
})
it('multiple lines with offset', done => {
addLink(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n2nd [line\n3rd ](https://)line')
done()
})
})
it('line with link', done => {
const link = 'http://example.com'
addLink(link, firstLine.startPosition, { line: 0, ch: link.length }, content => {
expect(content).toEqual(`[](${link})`)
done()
})
})
})
describe('test addImage', () => {
it('just cursor', done => {
addLink(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('![](https://)' + testContent)
done()
}, '!')
})
it('1st line', done => {
addLink(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('![1st line](https://)\n2nd line\n3rd line')
done()
}, '!')
})
it('multiple lines', done => {
addLink(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n![2nd line\n3rd line](https://)')
done()
}, '!')
})
it('multiple lines with offset', done => {
addLink(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n2nd ![line\n3rd ](https://)line')
done()
}, '!')
})
})
describe('test changeSelection', () => {
it('just cursor', done => {
replaceSelection(testContent, cursor.startPosition, cursor.endPosition, content => {
expect(content).toEqual('----' + testContent)
done()
}, '----')
})
it('1st line', done => {
replaceSelection(testContent, firstLine.startPosition, firstLine.endPosition, content => {
expect(content).toEqual('----\n2nd line\n3rd line')
done()
}, '----')
})
it('multiple lines', done => {
replaceSelection(testContent, multiline.startPosition, multiline.endPosition, content => {
expect(content).toEqual('1st line\n----\n3rd line')
done()
}, '----')
})
it('multiple lines with offset', done => {
replaceSelection(testContent, multilineOffset.startPosition, multilineOffset.endPosition, content => {
expect(content).toEqual('1st line\n2nd ----line\n3rd line')
done()
}, '----')
})
})

View file

@ -0,0 +1,104 @@
import CodeMirror from 'codemirror'
export const replaceSelection = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void, replaceText: string): void => {
const contentLines = content.split('\n')
const replaceTextLines = replaceText.split('\n')
const numberOfExtraLines = replaceTextLines.length - 1 - (endPosition.line - startPosition.line)
const replaceTextIncludeNewline = replaceText.includes('\n')
if (!replaceTextIncludeNewline) {
contentLines[startPosition.line] = contentLines[startPosition.line].slice(0, startPosition.ch) + replaceText + contentLines[startPosition.line].slice(endPosition.ch)
} else {
const lastPart = contentLines[endPosition.line].slice(endPosition.ch)
contentLines.push(...contentLines.slice(endPosition.line + 1))
contentLines[startPosition.line] = contentLines[startPosition.line].slice(0, startPosition.ch) + replaceTextLines[0]
contentLines.splice(startPosition.line + 1, replaceTextLines.length - 1, ...replaceTextLines.slice(1))
contentLines[numberOfExtraLines + endPosition.line] += lastPart
}
onContentChange(contentLines.join('\n'))
}
export const extractSelection = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position): string => {
if (startPosition.line === endPosition.line && startPosition.ch === endPosition.ch) {
return ''
}
const lines = content.split('\n')
if (startPosition.line === endPosition.line) {
return removeLastNewLine(lines[startPosition.line].slice(startPosition.ch, endPosition.ch))
}
let multiLineSelection = lines[startPosition.line].slice(startPosition.ch) + '\n'
for (let i = startPosition.line + 1; i <= endPosition.line; i++) {
if (i === endPosition.line) {
multiLineSelection += lines[i].slice(0, endPosition.ch)
} else {
multiLineSelection += lines[i] + '\n'
}
}
return multiLineSelection
}
export const removeLastNewLine = (selection: string): string => {
if (selection.endsWith('\n')) {
selection = selection.slice(0, -1)
}
return selection
}
export const addMarkup = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void, markUp: string): void => {
const selection = extractSelection(content, startPosition, endPosition)
if (selection === '') {
return
}
replaceSelection(content, startPosition, endPosition, onContentChange, `${markUp}${selection}${markUp}`)
}
export const createList = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void, listMark: (j: number) => string): void => {
const lines = content.split('\n')
let j = 1
for (let i = startPosition.line; i <= endPosition.line; i++) {
lines[i] = `${listMark(j)} ${lines[i]}`
j++
}
onContentChange(lines.join('\n'))
}
export const addHeaderLevel = (content: string, startPosition: CodeMirror.Position, onContentChange: (content: string) => void): void => {
const lines = content.split('\n')
const startLine = lines[startPosition.line]
const isHeadingAlready = startLine.startsWith('#')
lines[startPosition.line] = `#${!isHeadingAlready ? ' ' : ''}${startLine}`
onContentChange(lines.join('\n'))
}
export const addLink = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void, prefix?: string): void => {
const selection = extractSelection(content, startPosition, endPosition)
const linkRegex = /^(?:https?|ftp|mailto):/
if (linkRegex.exec(selection)) {
replaceSelection(content, startPosition, endPosition, onContentChange, `${prefix || ''}[](${selection})`)
} else {
replaceSelection(content, startPosition, endPosition, onContentChange, `${prefix || ''}[${selection}](https://)`)
}
}
export const addQuotes = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void): void => {
const selection = extractSelection(content, startPosition, endPosition)
if (selection === '') {
replaceSelection(content, startPosition, endPosition, onContentChange, '> ')
} else if (!selection.includes('\n')) {
const lines = content.split('\n')
replaceSelection(content, startPosition, endPosition, onContentChange, '> ' + lines[startPosition.line])
} else {
const lines = content.split('\n')
for (let i = startPosition.line; i <= endPosition.line; i++) {
lines[i] = `> ${lines[i]}`
}
onContentChange(lines.join('\n'))
}
}
export const addCodeFences = (content: string, startPosition: CodeMirror.Position, endPosition: CodeMirror.Position, onContentChange: (content: string) => void): void => {
const selection = extractSelection(content, startPosition, endPosition)
replaceSelection(content, startPosition, endPosition, onContentChange, `\`\`\`\n${selection}\n\`\`\``)
}