Autocompletion and toolbar button for collapsable blocks (#615)

* Add autocompletion for <details construct

* Add toolbar button for <details>-construct

* Added CHANGELOG notice
This commit is contained in:
Erik Michelson 2020-09-30 23:35:10 +02:00 committed by GitHub
parent 2b6ba82b4b
commit 0f31c3b0b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 171 additions and 2 deletions

View file

@ -35,7 +35,8 @@
- HedgeDoc instances can now be branded either with a '@ <custom string>' or '@ <custom logo>' after the HedgeDoc logo and text
- Images will be loaded via proxy if an image proxy is configured in the backend
- Asciinema videos may now be embedded by pasting the URL of one video into a single line
- The Toolbar includes an EmojiPicker
- The toolbar includes an EmojiPicker
- Collapsable blocks can be added via a toolbar button or via autocompletion of "<details"
- Added shortcodes for [fork-awesome icons](https://forkaweso.me/Fork-Awesome/icons/) (e.g. `:fa-picture-o:`)
- The code button now adds code fences even if the user selected nothing beforehand
- Code blocks with 'csv' as language render as tables.

View file

@ -265,4 +265,34 @@ describe('Autocompletion', () => {
.should('exist')
})
})
describe('collapsable blocks', () => {
it('via Enter', () => {
cy.get('.CodeMirror textarea')
.type('<d')
cy.get('.CodeMirror-hints')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{enter}')
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '</details>') // after selecting the hint, the last line of the inserted suggestion is active
cy.get('.markdown-body > details')
.should('exist')
})
it('via doubleclick', () => {
cy.get('.CodeMirror textarea')
.type('<d')
cy.get('.CodeMirror-hints > li')
.first()
.dblclick()
cy.get('.CodeMirror-hints')
.should('not.exist')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', '</details>')
cy.get('.markdown-body > details')
.should('exist')
})
})
})

View file

@ -261,6 +261,13 @@ describe('Toolbar', () => {
.should('have.text', '----')
})
it('collapsable block', () => {
cy.get('.fa-caret-square-o-down')
.click()
cy.get('.CodeMirror-code > div:nth-of-type(2) > .CodeMirror-line > span span')
.should('have.text', '<details>')
})
it('comment', () => {
cy.get('.fa-comment')
.click()

View file

@ -256,6 +256,7 @@
"uploadImage": "Upload Image",
"table": "Table",
"line": "Horizontal line",
"collapsableBlock": "Collapsable block",
"comment": "Comment",
"preferences": "Editor settings",
"emoji": "Open emoji picker"

View file

@ -0,0 +1,35 @@
import { Editor, Hint, Hints, Pos } from 'codemirror'
import { findWordAtCursor, Hinter } from './index'
const allowedChars = /[<\w>]/
const wordRegExp = /^(<d(?:e|et|eta|etai|etail|etails)?)$/
const collapsableBlockHint = (editor: Editor): Promise< Hints| null > => {
return new Promise((resolve) => {
const searchTerm = findWordAtCursor(editor, allowedChars)
const searchResult = wordRegExp.exec(searchTerm.text)
if (searchResult === null) {
resolve(null)
return
}
const suggestions = ['<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>']
const cursor = editor.getCursor()
if (!suggestions) {
resolve(null)
} else {
resolve({
list: suggestions.map((suggestion: string): Hint => ({
text: suggestion
})),
from: Pos(cursor.line, searchTerm.start),
to: Pos(cursor.line, searchTerm.end + 1)
})
}
})
}
export const CollapsableBlockHinter: Hinter = {
allowedChars,
wordRegExp,
hint: collapsableBlockHint
}

View file

@ -1,5 +1,6 @@
import { Editor, Hints } from 'codemirror'
import { CodeBlockHinter } from './code-block'
import { CollapsableBlockHinter } from './collapsable-block'
import { ContainerHinter } from './container'
import { EmojiHinter } from './emoji'
import { HeaderHinter } from './header'
@ -55,5 +56,6 @@ export const allHinters: Hinter[] = [
HeaderHinter,
ImageHinter,
LinkAndExtraTagHinter,
PDFHinter
PDFHinter,
CollapsableBlockHinter
]

View file

@ -8,6 +8,7 @@ import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
import './tool-bar.scss'
import {
addCodeFences,
addCollapsableBlock,
addComment,
addHeaderLevel,
addImage,
@ -101,6 +102,9 @@ export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
<Button variant='light' onClick={() => addLine(editor)} title={t('editor.editorToolbar.line')}>
<ForkAwesomeIcon icon="minus"/>
</Button>
<Button variant='light' onClick={() => addCollapsableBlock(editor)} title={t('editor.editorToolbar.collapsableBlock')}>
<ForkAwesomeIcon icon="caret-square-o-down"/>
</Button>
<Button variant='light' onClick={() => addComment(editor)} title={t('editor.editorToolbar.comment')}>
<ForkAwesomeIcon icon="comment"/>
</Button>

View file

@ -3,6 +3,7 @@ import { EmojiData } from 'emoji-mart'
import { Mock } from 'ts-mockery'
import {
addCodeFences,
addCollapsableBlock,
addComment,
addEmoji,
addHeaderLevel,
@ -1499,6 +1500,93 @@ describe('test addLine', () => {
addLine(editor)
})
})
describe('test collapsable block', () => {
const { cursor, firstLine, multiline, multilineOffset } = buildRanges()
const textFirstLine = testContent.split('\n')[0]
it('just cursor', done => {
Mock.extend(editor).with({
listSelections: () => (
Mock.of<Range[]>([{
anchor: cursor.from,
head: cursor.to,
from: () => cursor.from,
to: () => cursor.to,
empty: () => true
}])
),
getLine: (): string => (textFirstLine),
replaceRange: (replacement: string | string[]) => {
expect(replacement).toEqual(`${textFirstLine}\n<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>`)
done()
}
})
addCollapsableBlock(editor)
})
it('1st line', done => {
Mock.extend(editor).with({
listSelections: () => (
Mock.of<Range[]>([{
anchor: firstLine.from,
head: firstLine.to,
from: () => firstLine.from,
to: () => firstLine.to,
empty: () => false
}])
),
getLine: (): string => (textFirstLine),
replaceRange: (replacement: string | string[], from: CodeMirror.Position, to?: CodeMirror.Position) => {
expect(from).toEqual(firstLine.from)
expect(to).toEqual(firstLine.to)
expect(replacement).toEqual(`${textFirstLine}\n<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>`)
done()
}
})
addCollapsableBlock(editor)
})
it('multiple lines', done => {
Mock.extend(editor).with({
listSelections: () => (
Mock.of<Range[]>([{
anchor: multiline.from,
head: multiline.to,
from: () => multiline.from,
to: () => multiline.to,
empty: () => false
}])
),
getLine: (): string => '2nd line',
replaceRange: (replacement: string | string[]) => {
expect(replacement).toEqual('2nd line\n<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>')
done()
}
})
addCollapsableBlock(editor)
})
it('multiple lines with offset', done => {
Mock.extend(editor).with({
listSelections: () => (
Mock.of<Range[]>([{
anchor: multilineOffset.from,
head: multilineOffset.to,
from: () => multilineOffset.from,
to: () => multilineOffset.to,
empty: () => false
}])
),
getLine: (): string => '2nd line',
replaceRange: (replacement: string | string[]) => {
expect(replacement).toEqual('2nd line\n<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>')
done()
}
})
addCollapsableBlock(editor)
})
})
describe('test addComment', () => {
const { cursor, firstLine, multiline, multilineOffset } = buildRanges()
const textFirstLine = testContent.split('\n')[0]

View file

@ -21,6 +21,7 @@ export const addTaskList = (editor: Editor): void => createList(editor, () => '-
export const addImage = (editor: Editor): void => addLink(editor, '!')
export const addLine = (editor: Editor): void => changeLines(editor, line => `${line}\n----`)
export const addCollapsableBlock = (editor: Editor): void => changeLines(editor, line => `${line}\n<details>\n <summary>Toggle label</summary>\n Toggled content\n</details>`)
export const addComment = (editor: Editor): void => changeLines(editor, line => `${line}\n> []`)
export const addTable = (editor: Editor): void => changeLines(editor, line => `${line}\n| # 1 | # 2 | # 3 |\n| ---- | ---- | ---- |\n| Text | Text | Text |`)