From 1b52bac8383df790cc314a7462a6215636c1f29d Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Thu, 16 Jul 2020 11:34:56 +0200 Subject: [PATCH] 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 Co-authored-by: mrdrogdrog Co-authored-by: Erik Michelson --- .github/workflows/build.yml | 2 + CHANGELOG.md | 1 + README.md | 6 + public/locales/en.json | 4 +- .../editor/editor-window/editor-window.tsx | 121 +++--- .../editor/editor-window/interfaces.ts | 15 + .../editor-window/tool-bar/tool-bar.scss | 3 + .../editor-window/tool-bar/tool-bar.tsx | 82 ++++ .../editor-window/tool-bar/utils.test.ts | 352 ++++++++++++++++++ .../editor/editor-window/tool-bar/utils.ts | 104 ++++++ 10 files changed, 644 insertions(+), 46 deletions(-) create mode 100644 src/components/editor/editor-window/interfaces.ts create mode 100644 src/components/editor/editor-window/tool-bar/tool-bar.scss create mode 100644 src/components/editor/editor-window/tool-bar/tool-bar.tsx create mode 100644 src/components/editor/editor-window/tool-bar/utils.test.ts create mode 100644 src/components/editor/editor-window/tool-bar/utils.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 905088455..0744e5230 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3f3c157..cbff6540a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (add image) and (add link) toolbar buttons, put selected links directly in the `()` instead of the `[]` part of the generated markdown --- diff --git a/README.md b/README.md index ffaa0e66d..9a1c3f2bc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/locales/en.json b/public/locales/en.json index c66d23811..4b9f4ce6b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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": { diff --git a/src/components/editor/editor-window/editor-window.tsx b/src/components/editor/editor-window/editor-window.tsx index 04eaad732..2776eeb56 100644 --- a/src/components/editor/editor-window/editor-window.tsx +++ b/src/components/editor/editor-window/editor-window.tsx @@ -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 = ({ onContentChange, content }) => { +export const EditorWindow: React.FC = ({ onContentChange, content }) => { const { t } = useTranslation() + const [positions, setPositions] = useState({ + 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 ( - { - onContentChange(value) - }} - /> +
+ + { + onContentChange(value) + }} + onSelection={onSelection} + /> +
) } - -export { EditorWindow } diff --git a/src/components/editor/editor-window/interfaces.ts b/src/components/editor/editor-window/interfaces.ts new file mode 100644 index 000000000..d8ea9ff53 --- /dev/null +++ b/src/components/editor/editor-window/interfaces.ts @@ -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 +} diff --git a/src/components/editor/editor-window/tool-bar/tool-bar.scss b/src/components/editor/editor-window/tool-bar/tool-bar.scss new file mode 100644 index 000000000..954de9777 --- /dev/null +++ b/src/components/editor/editor-window/tool-bar/tool-bar.scss @@ -0,0 +1,3 @@ +.btn-toolbar { + border: 1px solid #ededed; +} diff --git a/src/components/editor/editor-window/tool-bar/tool-bar.tsx b/src/components/editor/editor-window/tool-bar/tool-bar.tsx new file mode 100644 index 000000000..abd69de6e --- /dev/null +++ b/src/components/editor/editor-window/tool-bar/tool-bar.tsx @@ -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 = ({ 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 ( + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/editor/editor-window/tool-bar/utils.test.ts b/src/components/editor/editor-window/tool-bar/utils.test.ts new file mode 100644 index 000000000..1a12226d6 --- /dev/null +++ b/src/components/editor/editor-window/tool-bar/utils.test.ts @@ -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() + }, '----') + }) +}) diff --git a/src/components/editor/editor-window/tool-bar/utils.ts b/src/components/editor/editor-window/tool-bar/utils.ts new file mode 100644 index 000000000..ba6737b91 --- /dev/null +++ b/src/components/editor/editor-window/tool-bar/utils.ts @@ -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\`\`\``) +}