mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-14 23:24:46 -04:00
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:
parent
8ce344512c
commit
0c0841639a
20 changed files with 401 additions and 73 deletions
BIN
cypress/fixtures/acme.png
Normal file
BIN
cypress/fixtures/acme.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.2 KiB |
3
cypress/fixtures/acme.png.license
Normal file
3
cypress/fixtures/acme.png.license
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
|
@ -38,7 +38,7 @@ describe('Intro', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Versioncan be opened and closed', () => {
|
it('Version can be opened and closed', () => {
|
||||||
cy.get('#versionModal')
|
cy.get('#versionModal')
|
||||||
.should('not.exist')
|
.should('not.exist')
|
||||||
cy.get('#version')
|
cy.get('#version')
|
||||||
|
|
98
cypress/integration/upload.spec.ts
Normal file
98
cypress/integration/upload.spec.ts
Normal 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', ``)
|
||||||
|
})
|
||||||
|
|
||||||
|
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', ``)
|
||||||
|
})
|
||||||
|
|
||||||
|
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', ``)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -60,6 +60,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
"upload": {
|
||||||
|
"uploadFile": "Datei wird hochgeladen...{{fileName}}"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"contacts": {
|
"contacts": {
|
||||||
"title": "Kontakte",
|
"title": "Kontakte",
|
||||||
|
|
|
@ -178,6 +178,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
"upload": {
|
||||||
|
"uploadFile": "Uploading file...{{fileName}}",
|
||||||
|
"dropImage": "Drop Image to insert"
|
||||||
|
},
|
||||||
"untitledNote": "Untitled",
|
"untitledNote": "Untitled",
|
||||||
"placeholder": "← Start by entering a title here\n===\nVisit the features page if you don't know what to do.\nHappy hacking :)",
|
"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.",
|
"invalidYaml": "The yaml-header is invalid. See <0></0> for more information.",
|
||||||
|
|
|
@ -18,3 +18,21 @@ export const getProxiedUrl = async (imageUrl: string): Promise<ImageProxyRespons
|
||||||
expectResponseCode(response)
|
expectResponseCode(response)
|
||||||
return await response.json() as Promise<ImageProxyResponse>
|
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>
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -4,13 +4,39 @@ SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file)
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
import { Trans } from 'react-i18next'
|
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 { 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 = () => {
|
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 (
|
return (
|
||||||
<Dropdown className='small mx-1' alignRight={true}>
|
<Dropdown className='small mx-1' alignRight={true}>
|
||||||
<Dropdown.Toggle variant='light' size='sm' id='editor-menu-import' className=''>
|
<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'}/>
|
<ForkAwesomeIcon icon='clipboard' className={'mx-2'}/>
|
||||||
<Trans i18nKey='editor.import.clipboard'/>
|
<Trans i18nKey='editor.import.clipboard'/>
|
||||||
</Dropdown.Item>
|
</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.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,10 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-codemirror2.file-drag .CodeMirror-cursors {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.no-ligatures .CodeMirror {
|
.no-ligatures .CodeMirror {
|
||||||
//These two properties must be set separately because otherwise node-scss breaks.
|
//These two properties must be set separately because otherwise node-scss breaks.
|
||||||
.CodeMirror-line, .CodeMirror-line-like {
|
.CodeMirror-line, .CodeMirror-line-like {
|
||||||
|
|
|
@ -40,6 +40,7 @@ import './editor-pane.scss'
|
||||||
import { defaultKeyMap } from './key-map'
|
import { defaultKeyMap } from './key-map'
|
||||||
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
|
import { createStatusInfo, defaultState, StatusBar, StatusBarInfo } from './status-bar/status-bar'
|
||||||
import { ToolBar } from './tool-bar/tool-bar'
|
import { ToolBar } from './tool-bar/tool-bar'
|
||||||
|
import { handleUpload } from './upload-handler'
|
||||||
|
|
||||||
export interface EditorPaneProps {
|
export interface EditorPaneProps {
|
||||||
onContentChange: (content: string) => void
|
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 }) => {
|
export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentChange, content, scrollState, onScroll, onMakeScrollSource }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
|
const maxLength = useSelector((state: ApplicationState) => state.config.maxDocumentLength)
|
||||||
|
@ -127,6 +155,21 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
|
||||||
setStatusBarInfo(createStatusInfo(editorWithActivity, maxLength))
|
setStatusBarInfo(createStatusInfo(editorWithActivity, maxLength))
|
||||||
}, [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>(() => ({
|
const codeMirrorOptions: EditorConfiguration = useMemo<EditorConfiguration>(() => ({
|
||||||
...editorPreferences,
|
...editorPreferences,
|
||||||
mode: 'gfm',
|
mode: 'gfm',
|
||||||
|
@ -158,8 +201,8 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
|
||||||
}), [t, editorPreferences])
|
}), [t, editorPreferences])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'d-flex flex-column h-100'} onMouseEnter={onMakeScrollSource}>
|
<div className={'d-flex flex-column h-100 position-relative'} onMouseEnter={onMakeScrollSource}>
|
||||||
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={() => setShowMaxLengthWarning(false)} maxLength={maxLength}/>
|
<MaxLengthWarningModal show={showMaxLengthWarning} onHide={onMaxLengthHide} maxLength={maxLength}/>
|
||||||
<ToolBar
|
<ToolBar
|
||||||
editor={editor}
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
|
@ -168,6 +211,8 @@ export const EditorPane: React.FC<EditorPaneProps & ScrollProps> = ({ onContentC
|
||||||
value={content}
|
value={content}
|
||||||
options={codeMirrorOptions}
|
options={codeMirrorOptions}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onPaste={onPaste}
|
||||||
|
onDrop={onDrop}
|
||||||
onCursorActivity={onCursorActivity}
|
onCursorActivity={onCursorActivity}
|
||||||
editorDidMount={onEditorDidMount}
|
editorDidMount={onEditorDidMount}
|
||||||
onBeforeChange={onBeforeChange}
|
onBeforeChange={onBeforeChange}
|
||||||
|
|
|
@ -5,10 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Editor } from 'codemirror'
|
import { Editor } from 'codemirror'
|
||||||
import React from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { Button, ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
import { Button, ButtonGroup, ButtonToolbar } from 'react-bootstrap'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
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 { EditorPreferences } from './editor-preferences/editor-preferences'
|
||||||
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
|
import { EmojiPickerButton } from './emoji-picker/emoji-picker-button'
|
||||||
import { TablePickerButton } from './table-picker/table-picker-button'
|
import { TablePickerButton } from './table-picker/table-picker-button'
|
||||||
|
@ -32,6 +34,7 @@ import {
|
||||||
superscriptSelection,
|
superscriptSelection,
|
||||||
underlineSelection
|
underlineSelection
|
||||||
} from './utils/toolbarButtonUtils'
|
} from './utils/toolbarButtonUtils'
|
||||||
|
import { supportedMimeTypesJoined } from './utils/upload-image-mimetypes'
|
||||||
|
|
||||||
export interface ToolBarProps {
|
export interface ToolBarProps {
|
||||||
editor: Editor | undefined
|
editor: Editor | undefined
|
||||||
|
@ -40,9 +43,12 @@ export interface ToolBarProps {
|
||||||
export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
|
export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const notImplemented = () => {
|
const onUploadImage = useCallback((file: File) => {
|
||||||
alert('This feature is not yet implemented')
|
if (editor) {
|
||||||
}
|
handleUpload(file, editor)
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
}, [editor])
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null
|
return null
|
||||||
|
@ -97,9 +103,13 @@ export const ToolBar: React.FC<ToolBarProps> = ({ editor }) => {
|
||||||
<Button variant='light' onClick={() => addImage(editor)} title={t('editor.editorToolbar.image')}>
|
<Button variant='light' onClick={() => addImage(editor)} title={t('editor.editorToolbar.image')}>
|
||||||
<ForkAwesomeIcon icon="picture-o"/>
|
<ForkAwesomeIcon icon="picture-o"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='light' onClick={notImplemented} title={t('editor.editorToolbar.uploadImage')}>
|
<HiddenInputMenuEntry
|
||||||
<ForkAwesomeIcon icon="upload"/>
|
type={'button'}
|
||||||
</Button>
|
acceptedFiles={supportedMimeTypesJoined}
|
||||||
|
i18nKey={'editor.editorToolbar.uploadImage'}
|
||||||
|
icon={'upload'}
|
||||||
|
onLoad={onUploadImage}
|
||||||
|
/>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<ButtonGroup className={'mx-1 flex-wrap'}>
|
<ButtonGroup className={'mx-1 flex-wrap'}>
|
||||||
<TablePickerButton editor={editor}/>
|
<TablePickerButton editor={editor}/>
|
||||||
|
|
|
@ -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(', ')
|
48
src/components/editor/editor-pane/upload-handler.ts
Normal file
48
src/components/editor/editor-pane/upload-handler.ts
Normal 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 ``
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,11 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
|
import { useParams } from 'react-router'
|
||||||
import useMedia from 'use-media'
|
import useMedia from 'use-media'
|
||||||
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
|
import { useApplyDarkMode } from '../../hooks/common/use-apply-dark-mode'
|
||||||
import { useDocumentTitle } from '../../hooks/common/use-document-title'
|
import { useDocumentTitle } from '../../hooks/common/use-document-title'
|
||||||
import { ApplicationState } from '../../redux'
|
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 { setEditorMode } from '../../redux/editor/methods'
|
||||||
import { extractNoteTitle } from '../common/document-title/note-title-extractor'
|
import { extractNoteTitle } from '../common/document-title/note-title-extractor'
|
||||||
import { MotdBanner } from '../common/motd-banner/motd-banner'
|
import { MotdBanner } from '../common/motd-banner/motd-banner'
|
||||||
|
@ -40,6 +41,7 @@ const TASK_REGEX = /(\s*[-*] )(\[[ xX]])( .*)/
|
||||||
|
|
||||||
export const Editor: React.FC = () => {
|
export const Editor: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { id } = useParams<EditorPathParams>()
|
||||||
const untitledNote = t('editor.untitledNote')
|
const untitledNote = t('editor.untitledNote')
|
||||||
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
|
const markdownContent = useSelector((state: ApplicationState) => state.documentContent.content)
|
||||||
const isWide = useMedia({ minWidth: 576 })
|
const isWide = useMedia({ minWidth: 576 })
|
||||||
|
@ -90,6 +92,10 @@ export const Editor: React.FC = () => {
|
||||||
|
|
||||||
const isFirstDraw = useFirstDraw()
|
const isFirstDraw = useFirstDraw()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNoteId(id)
|
||||||
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFirstDraw && !isWide && editorMode === EditorMode.BOTH) {
|
if (!isFirstDraw && !isWide && editorMode === EditorMode.BOTH) {
|
||||||
setEditorMode(EditorMode.PREVIEW)
|
setEditorMode(EditorMode.PREVIEW)
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { store } from '..'
|
import { store } from '..'
|
||||||
import { DocumentContentActionType, SetDocumentContentAction } from './types'
|
import { DocumentContentActionType, SetDocumentContentAction, SetNoteIdAction } from './types'
|
||||||
|
|
||||||
export const setDocumentContent = (content: string): void => {
|
export const setDocumentContent = (content: string): void => {
|
||||||
const action: SetDocumentContentAction = {
|
const action: SetDocumentContentAction = {
|
||||||
|
@ -14,3 +14,11 @@ export const setDocumentContent = (content: string): void => {
|
||||||
}
|
}
|
||||||
store.dispatch(action)
|
store.dispatch(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setNoteId = (noteId: string): void => {
|
||||||
|
const action: SetNoteIdAction = {
|
||||||
|
type: DocumentContentActionType.SET_NOTE_ID,
|
||||||
|
noteId: noteId
|
||||||
|
}
|
||||||
|
store.dispatch(action)
|
||||||
|
}
|
||||||
|
|
|
@ -5,16 +5,31 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Reducer } from 'redux'
|
import { Reducer } from 'redux'
|
||||||
import { DocumentContent, DocumentContentAction, DocumentContentActionType, SetDocumentContentAction } from './types'
|
import {
|
||||||
|
DocumentContent,
|
||||||
|
DocumentContentAction,
|
||||||
|
DocumentContentActionType,
|
||||||
|
SetDocumentContentAction,
|
||||||
|
SetNoteIdAction
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export const initialState: DocumentContent = {
|
export const initialState: DocumentContent = {
|
||||||
content: ''
|
content: '',
|
||||||
|
noteId: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentContentReducer: Reducer<DocumentContent, DocumentContentAction> = (state: DocumentContent = initialState, action: DocumentContentAction) => {
|
export const DocumentContentReducer: Reducer<DocumentContent, DocumentContentAction> = (state: DocumentContent = initialState, action: DocumentContentAction) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case DocumentContentActionType.SET_DOCUMENT_CONTENT:
|
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:
|
default:
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,12 @@ import { Action } from 'redux'
|
||||||
|
|
||||||
export enum DocumentContentActionType {
|
export enum DocumentContentActionType {
|
||||||
SET_DOCUMENT_CONTENT = 'document-content/set',
|
SET_DOCUMENT_CONTENT = 'document-content/set',
|
||||||
|
SET_NOTE_ID = 'document-content/noteid/set'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentContent {
|
export interface DocumentContent {
|
||||||
content: string
|
content: string
|
||||||
|
noteId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentContentAction extends Action<DocumentContentActionType> {
|
export interface DocumentContentAction extends Action<DocumentContentActionType> {
|
||||||
|
@ -21,3 +23,7 @@ export interface DocumentContentAction extends Action<DocumentContentActionType>
|
||||||
export interface SetDocumentContentAction extends DocumentContentAction {
|
export interface SetDocumentContentAction extends DocumentContentAction {
|
||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetNoteIdAction extends DocumentContentAction {
|
||||||
|
noteId: string
|
||||||
|
}
|
||||||
|
|
|
@ -9649,6 +9649,7 @@ micromatch@^4.0.0, micromatch@^4.0.2:
|
||||||
|
|
||||||
"midi@https://github.com/paulrosen/MIDI.js.git#abcjs":
|
"midi@https://github.com/paulrosen/MIDI.js.git#abcjs":
|
||||||
version "0.4.2"
|
version "0.4.2"
|
||||||
|
uid e593ffef81a0350f99448e3ab8111957145ff6b2
|
||||||
resolved "https://github.com/paulrosen/MIDI.js.git#e593ffef81a0350f99448e3ab8111957145ff6b2"
|
resolved "https://github.com/paulrosen/MIDI.js.git#e593ffef81a0350f99448e3ab8111957145ff6b2"
|
||||||
|
|
||||||
miller-rabin@^4.0.0:
|
miller-rabin@^4.0.0:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue