added upload functionality (#758)

Co-authored-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
This commit is contained in:
Philip Molares 2020-12-16 23:07:09 +01:00 committed by GitHub
parent 8ce344512c
commit 0c0841639a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 401 additions and 73 deletions

BIN
cypress/fixtures/acme.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0

View file

@ -38,7 +38,7 @@ describe('Intro', () => {
})
})
it('Versioncan be opened and closed', () => {
it('Version can be opened and closed', () => {
cy.get('#versionModal')
.should('not.exist')
cy.get('#version')

View file

@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const imageUrl = 'http://example.com/non-existing.png'
describe('Upload', () => {
beforeEach(() => {
cy.visit('/n/test')
cy.get('.btn.active.btn-outline-secondary > i.fa-columns')
.should('exist')
cy.get('.CodeMirror textarea')
.type('{ctrl}a', { force: true })
.type('{backspace}')
})
it('check that text drag\'n\'drop still works', () => {
const dataTransfer = new DataTransfer()
cy.get('.CodeMirror textarea')
.type('line 1\nline 2\nline3')
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.dblclick()
cy.get('.CodeMirror-line > span > .cm-matchhighlight')
.trigger('dragstart', { dataTransfer })
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span span')
.trigger('drop', { dataTransfer })
cy.get('.CodeMirror-code > div:nth-of-type(1) > .CodeMirror-line > span span')
.should('have.text', 'linline3e 1')
})
describe('upload works', () => {
beforeEach(() => {
cy.intercept({
method: 'POST',
url: '/api/v2/media/upload'
}, {
statusCode: 201,
body: {
link: imageUrl
}
})
cy.fixture('acme.png').then(image => {
this.image = image
})
})
it('via button', () => {
cy.get('.fa-upload')
.click()
cy.get('div.btn-group > input[type=file]')
.attachFile({ filePath: 'acme.png', mimeType: 'image/png' })
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', `![](${imageUrl})`)
})
it('via paste', () => {
const pasteEvent = {
clipboardData: {
files: [Cypress.Blob.base64StringToBlob(this.image, 'image/png')]
}
}
cy.get('.CodeMirror-scroll').trigger('paste', pasteEvent)
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', `![](${imageUrl})`)
})
it('via drag and drop', () => {
const dropEvent = {
dataTransfer: {
files: [Cypress.Blob.base64StringToBlob(this.image, 'image/png')],
effectAllowed: 'uninitialized'
}
}
cy.get('.CodeMirror-scroll').trigger('dragenter', dropEvent)
cy.get('.CodeMirror-scroll').trigger('drop', dropEvent)
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', `![](${imageUrl})`)
})
})
it('upload fails', () => {
cy.get('.CodeMirror textarea')
.type('not empty')
cy.intercept({
method: 'POST',
url: '/api/v2/media/upload'
}, {
statusCode: 400
})
cy.get('.fa-upload')
.click()
cy.get('input[type=file]')
.attachFile({ filePath: 'acme.png', mimeType: 'image/png' })
cy.get('.CodeMirror-activeline > .CodeMirror-line > span')
.should('have.text', 'not empty')
})
})

View file

@ -60,6 +60,9 @@
}
},
"editor": {
"upload": {
"uploadFile": "Datei wird hochgeladen...{{fileName}}"
},
"help": {
"contacts": {
"title": "Kontakte",

View file

@ -178,6 +178,10 @@
}
},
"editor": {
"upload": {
"uploadFile": "Uploading file...{{fileName}}",
"dropImage": "Drop Image to insert"
},
"untitledNote": "Untitled",
"placeholder": "← Start by entering a title here\n===\nVisit the features page if you don't know what to do.\nHappy hacking :)",
"invalidYaml": "The yaml-header is invalid. See <0></0> for more information.",

View file

@ -18,3 +18,21 @@ export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyRespons
expectResponseCode(response)
return await response.json() as Promise<ImageProxyResponse>
}
export interface UploadedMedia {
link: string
}
export const uploadFile = async (noteId: string, contentType: string, media: Blob): Promise<UploadedMedia> => {
const response = await fetch(getApiUrl() + '/media/upload', {
...defaultFetchConfig,
headers: {
'Content-Type': contentType,
'HedgeDoc-Note': noteId
},
method: 'POST',
body: media
})
expectResponseCode(response, 201)
return await response.json() as Promise<UploadedMedia>
}

View file

@ -0,0 +1,59 @@
/*
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useRef } from 'react'
import { Button, Dropdown } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../fork-awesome/fork-awesome-icon'
import { IconName } from '../fork-awesome/types'
export interface HiddenInputMenuEntryProps {
type: 'dropdown' | 'button'
acceptedFiles: string
i18nKey: string
icon: IconName
onLoad: (file: File) => Promise<void>
}
export const HiddenInputMenuEntry: React.FC<HiddenInputMenuEntryProps> = ({ type, acceptedFiles, i18nKey, icon, onLoad }) => {
const { t } = useTranslation()
const fileInputReference = useRef<HTMLInputElement>(null)
const onClick = useCallback(() => {
const fileInput = fileInputReference.current
if (!fileInput) {
return
}
fileInput.addEventListener('change', () => {
if (!fileInput.files || fileInput.files.length < 1) {
return
}
const file = fileInput.files[0]
onLoad(file).then(() => {
fileInput.value = ''
}).catch((error) => {
console.error(error)
})
})
fileInput.click()
}, [onLoad])
return (
<Fragment>
<input type='file' ref={fileInputReference} className='d-none' accept={acceptedFiles}/>
{
type === 'dropdown'
? <Dropdown.Item className={'small import-md-file'} onClick={onClick}>
<ForkAwesomeIcon icon={icon} className={'mx-2'}/>
<Trans i18nKey={i18nKey}/>
</Dropdown.Item>
: <Button variant='light' onClick={onClick} title={t(i18nKey)}>
<ForkAwesomeIcon icon={icon}/>
</Button>
}
</Fragment>
)
}

View file

@ -1,55 +0,0 @@
/*
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useRef } from 'react'
import { Dropdown } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { ApplicationState } from '../../../../redux'
import { setDocumentContent } from '../../../../redux/document-content/methods'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
export const ImportFile: React.FC = () => {
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
const fileInputReference = useRef<HTMLInputElement>(null)
const doImport = useCallback(() => {
const fileInput = fileInputReference.current
if (!fileInput) {
return
}
fileInput.addEventListener('change', () => {
if (!fileInput.files || fileInput.files.length < 1) {
return
}
const file = fileInput.files[0]
const fileReader = new FileReader()
fileReader.addEventListener('load', () => {
const newContent = fileReader.result as string
if (markdownContent.length === 0) {
setDocumentContent(newContent)
} else {
setDocumentContent(markdownContent + '\n' + newContent)
}
})
fileReader.addEventListener('loadend', () => {
fileInput.value = ''
})
fileReader.readAsText(file)
})
fileInput.click()
}, [markdownContent])
return (
<Fragment>
<input type='file' ref={fileInputReference} className='d-none' accept='.md, text/markdown, text/plain'/>
<Dropdown.Item className='small import-md-file' onClick={doImport}>
<ForkAwesomeIcon icon='file-text-o' className={'mx-2'}/>
<Trans i18nKey='editor.import.file'/>
</Dropdown.Item>
</Fragment>
)
}

View file

@ -4,13 +4,39 @@ SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import React, { useCallback } from 'react'
import { Dropdown } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useSelector } from 'react-redux'
import { ApplicationState } from '../../../../redux'
import { setDocumentContent } from '../../../../redux/document-content/methods'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { ImportFile } from '../import/import-file'
import { HiddenInputMenuEntry } from '../../../common/hidden-input-menu-entry/hidden-input-menu-entry'
export const ImportMenu: React.FC = () => {
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
const onImportMarkdown = useCallback((file: File) => {
return new Promise<void>((resolve, reject) => {
const fileReader = new FileReader()
fileReader.addEventListener('load', () => {
const newContent = fileReader.result as string
if (markdownContent.length === 0) {
setDocumentContent(newContent)
} else {
setDocumentContent(markdownContent + '\n' + newContent)
}
})
fileReader.addEventListener('loadend', () => {
resolve()
})
fileReader.addEventListener('error', (error) => {
reject(error)
})
fileReader.readAsText(file)
})
}, [markdownContent])
return (
<Dropdown className='small mx-1' alignRight={true}>
<Dropdown.Toggle variant='light' size='sm' id='editor-menu-import' className=''>
@ -34,7 +60,13 @@ export const ImportMenu: React.FC = () => {
<ForkAwesomeIcon icon='clipboard' className={'mx-2'}/>
<Trans i18nKey='editor.import.clipboard'/>
</Dropdown.Item>
<ImportFile/>
<HiddenInputMenuEntry
type={'dropdown'}
acceptedFiles={'.md, text/markdown, text/plain'}
i18nKey={'editor.import.file'}
icon={'file-text-o'}
onLoad={onImportMarkdown}
/>
</Dropdown.Menu>
</Dropdown>
)

View file

@ -21,6 +21,10 @@
height: 100%;
}
.react-codemirror2.file-drag .CodeMirror-cursors {
visibility: visible;
}
.no-ligatures .CodeMirror {
//These two properties must be set separately because otherwise node-scss breaks.
.CodeMirror-line, .CodeMirror-line-like {

View file

@ -40,6 +40,7 @@ import './editor-pane.scss'
import { defaultKeyMap } from './key-map'
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
import { ToolBar } from './tool-bar/tool-bar'
import { handleUpload } from './upload-handler'
export interface EditorPaneProps {
onContentChange: (content: string) => void
@ -61,6 +62,33 @@ const onChange = (editor: Editor) => {
}
}
interface PasteEvent {
clipboardData: {
files: FileList
},
preventDefault: () => void
}
const onPaste = (pasteEditor: Editor, event: PasteEvent) => {
if (event && event.clipboardData && event.clipboardData.files) {
event.preventDefault()
const files: FileList = event.clipboardData.files
if (files && files.length >= 1) {
handleUpload(files[0], pasteEditor)
}
}
}
interface DropEvent {
pageX: number,
pageY: number,
dataTransfer: {
files: FileList
effectAllowed: string
} | null
preventDefault: () => void
}
export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => {
const { t } = useTranslation()
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
@ -127,6 +155,21 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
setStatusBarInfo(createStatusInfo(editorWithActivity, maxLength))
}, [maxLength])
const onDrop = useCallback((dropEditor: Editor, event: DropEvent) => {
if (event && dropEditor && event.pageX && event.pageY && event.dataTransfer &&
event.dataTransfer.files && event.dataTransfer.files.length >= 1) {
event.preventDefault()
const top: number = event.pageY
const left: number = event.pageX
const newCursor = dropEditor.coordsChar({ top, left }, 'page')
dropEditor.setCursor(newCursor)
const files: FileList = event.dataTransfer.files
handleUpload(files[0], dropEditor)
}
}, [])
const onMaxLengthHide = useCallback(() => setShowMaxLengthWarning(false), [])
const codeMirrorOptions: EditorConfiguration = useMemo<EditorConfiguration>(() => ({
...editorPreferences,
mode: 'gfm',
@ -158,8 +201,8 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
}), [t, editorPreferences])
return (
<div className={'d-flex flex-column h-100'} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={() => setShowMaxLengthWarning(false)} maxLength={maxLength}/>
<div className={'d-flex flex-column h-100 position-relative'} onMouseEnter={onMakeScrollSource}>
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={onMaxLengthHide} maxLength={maxLength}/>
<ToolBar
editor={editor}
/>
@ -168,6 +211,8 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
value={content}
options={codeMirrorOptions}
onChange={onChange}
onPaste={onPaste}
onDrop={onDrop}
onCursorActivity={onCursorActivity}
editorDidMount={onEditorDidMount}
onBeforeChange={onBeforeChange}

View file

@ -5,10 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
*/
import { Editor } from 'codemirror'
import React from 'react'
import React, { useCallback } from 'react'
import { Button, ButtonGroup, ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { HiddenInputMenuEntry } from '../../../common/hidden-input-menu-entry/hidden-input-menu-entry'
import { handleUpload } from '../upload-handler'
import { EditorPreferences } from './editor-preferences/editor-preferences'
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
import { TablePickerButton } from './table-picker/table-picker-button'
@ -32,6 +34,7 @@ import {
superscriptSelection,
underlineSelection
} from './utils/toolbarButtonUtils'
import { supportedMimeTypesJoined } from './utils/upload-image-mimetypes'
export interface ToolBarProps {
editor: Editor | undefined
@ -40,9 +43,12 @@ export interface ToolBarProps {
export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
const { t } = useTranslation()
const notImplemented = () => {
alert('This feature is not yet implemented')
}
const onUploadImage = useCallback((file: File) => {
if (editor) {
handleUpload(file, editor)
}
return Promise.resolve()
}, [editor])
if (!editor) {
return null
@ -97,9 +103,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
<Button variant='light' onClick={() => addImage(editor)} title={t('editor.editorToolbar.image')}>
<ForkAwesomeIcon icon="picture-o"/>
</Button>
<Button variant='light' onClick={notImplemented} title={t('editor.editorToolbar.uploadImage')}>
<ForkAwesomeIcon icon="upload"/>
</Button>
<HiddenInputMenuEntry
type={'button'}
acceptedFiles={supportedMimeTypesJoined}
i18nKey={'editor.editorToolbar.uploadImage'}
icon={'upload'}
onLoad={onUploadImage}
/>
</ButtonGroup>
<ButtonGroup className={'mx-1 flex-wrap'}>
<TablePickerButton editor={editor}/>

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const supportedMimeTypes: string[] = [
'application/pdf',
'image/apng',
'image/bmp',
'image/gif',
'image/heif',
'image/heic',
'image/heif-sequence',
'image/heic-sequence',
'image/jpeg',
'image/png',
'image/svg+xml',
'image/tiff',
'image/webp'
]
export const supportedMimeTypesJoined = supportedMimeTypes.join(', ')

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Editor } from 'codemirror'
import i18n from 'i18next'
import { uploadFile } from '../../../api/media'
import { store } from '../../../redux'
import { supportedMimeTypes } from './tool-bar/utils/upload-image-mimetypes'
export const handleUpload = (file: File, editor: Editor): void => {
if (!file) {
return
}
const mimeType = file.type
if (!supportedMimeTypes.includes(mimeType)) {
// this mimetype is not supported
return
}
const cursor = editor.getCursor()
const uploadPlaceholder = `![${i18n.t('editor.upload.uploadFile', { fileName: file.name })}]()`
const noteId = store.getState().documentContent.noteId
editor.replaceRange(uploadPlaceholder, cursor, cursor, '+input')
uploadFile(noteId, mimeType, file)
.then(({ link }) => {
editor.replaceRange(getCorrectSyntaxForLink(mimeType, link), cursor, {
line: cursor.line,
ch: cursor.ch + uploadPlaceholder.length
}, '+input')
})
.catch(() => {
editor.replaceRange('', cursor, {
line: cursor.line,
ch: cursor.ch + uploadPlaceholder.length
}, '+input')
})
}
const getCorrectSyntaxForLink = (mimeType: string, link: string): string => {
switch (mimeType) {
case 'application/pdf':
return `{%pdf ${link} %}`
default:
return `![](${link})`
}
}

View file

@ -7,11 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { useParams } from 'react-router'
import useMedia from 'use-media'
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
import { useDocumentTitle } from '../../hooks/common/use-document-title'
import { ApplicationState } from '../../redux'
import { setDocumentContent } from '../../redux/document-content/methods'
import { setDocumentContent, setNoteId } from '../../redux/document-content/methods'
import { setEditorMode } from '../../redux/editor/methods'
import { extractNoteTitle } from '../common/document-title/note-title-extractor'
import { MotdBanner } from '../common/motd-banner/motd-banner'
@ -40,6 +41,7 @@ const TASK_REGEX = /(\s*[-*] )(\[[ xX]])( .*)/
export const Editor: React.FC = () => {
const { t } = useTranslation()
const { id } = useParams<EditorPathParams>()
const untitledNote = t('editor.untitledNote')
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
const isWide = useMedia({ minWidth: 576 })
@ -90,6 +92,10 @@ export const Editor: React.FC = () => {
const isFirstDraw = useFirstDraw()
useEffect(() => {
setNoteId(id)
}, [id])
useEffect(() => {
if (!isFirstDraw && !isWide && editorMode === EditorMode.BOTH) {
setEditorMode(EditorMode.PREVIEW)

View file

@ -5,7 +5,7 @@
*/
import { store } from '..'
import { DocumentContentActionType, SetDocumentContentAction } from './types'
import { DocumentContentActionType, SetDocumentContentAction, SetNoteIdAction } from './types'
export const setDocumentContent = (content: string): void => {
const action: SetDocumentContentAction = {
@ -14,3 +14,11 @@ export const setDocumentContent = (content: string): void => {
}
store.dispatch(action)
}
export const setNoteId = (noteId: string): void => {
const action: SetNoteIdAction = {
type: DocumentContentActionType.SET_NOTE_ID,
noteId: noteId
}
store.dispatch(action)
}

View file

@ -5,16 +5,31 @@
*/
import { Reducer } from 'redux'
import { DocumentContent, DocumentContentAction, DocumentContentActionType, SetDocumentContentAction } from './types'
import {
DocumentContent,
DocumentContentAction,
DocumentContentActionType,
SetDocumentContentAction,
SetNoteIdAction
} from './types'
export const initialState: DocumentContent = {
content: ''
content: '',
noteId: ''
}
export const DocumentContentReducer: Reducer<DocumentContent, DocumentContentAction> = (state: DocumentContent = initialState, action: DocumentContentAction) => {
switch (action.type) {
case DocumentContentActionType.SET_DOCUMENT_CONTENT:
return { content: (action as SetDocumentContentAction).content }
return {
...state,
content: (action as SetDocumentContentAction).content
}
case DocumentContentActionType.SET_NOTE_ID:
return {
...state,
noteId: (action as SetNoteIdAction).noteId
}
default:
return state
}

View file

@ -8,10 +8,12 @@ import { Action } from 'redux'
export enum DocumentContentActionType {
SET_DOCUMENT_CONTENT = 'document-content/set',
SET_NOTE_ID = 'document-content/noteid/set'
}
export interface DocumentContent {
content: string
noteId: string
}
export interface DocumentContentAction extends Action<DocumentContentActionType> {
@ -21,3 +23,7 @@ export interface DocumentContentAction extends Action<DocumentContentActionType>
export interface SetDocumentContentAction extends DocumentContentAction {
content: string
}
export interface SetNoteIdAction extends DocumentContentAction {
noteId: string
}

View file

@ -9649,6 +9649,7 @@ micromatch@^4.0.0, micromatch@^4.0.2:
"midi@https://github.com/paulrosen/MIDI.js.git#abcjs":
version "0.4.2"
uid e593ffef81a0350f99448e3ab8111957145ff6b2
resolved "https://github.com/paulrosen/MIDI.js.git#e593ffef81a0350f99448e3ab8111957145ff6b2"
miller-rabin@^4.0.0: