diff --git a/cypress/fixtures/acme.png b/cypress/fixtures/acme.png new file mode 100644 index 000000000..d423cd8b2 Binary files /dev/null and b/cypress/fixtures/acme.png differ diff --git a/cypress/fixtures/acme.png.license b/cypress/fixtures/acme.png.license new file mode 100644 index 000000000..a2952f013 --- /dev/null +++ b/cypress/fixtures/acme.png.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file) + +SPDX-License-Identifier: CC0-1.0 diff --git a/cypress/integration/intro.spec.ts b/cypress/integration/intro.spec.ts index ce241c7c9..adc180bcf 100644 --- a/cypress/integration/intro.spec.ts +++ b/cypress/integration/intro.spec.ts @@ -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') diff --git a/cypress/integration/upload.spec.ts b/cypress/integration/upload.spec.ts new file mode 100644 index 000000000..40dd70b25 --- /dev/null +++ b/cypress/integration/upload.spec.ts @@ -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') + }) +}) diff --git a/public/locales/de.json b/public/locales/de.json index 98b5c9a4e..ca905ee61 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -60,6 +60,9 @@ } }, "editor": { + "upload": { + "uploadFile": "Datei wird hochgeladen...{{fileName}}" + }, "help": { "contacts": { "title": "Kontakte", diff --git a/public/locales/en.json b/public/locales/en.json index d1d6abf66..bf64bd628 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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> for more information.", diff --git a/src/api/media/index.ts b/src/api/media/index.ts index 1ee2a1dfe..520116477 100644 --- a/src/api/media/index.ts +++ b/src/api/media/index.ts @@ -18,3 +18,21 @@ export const getProxiedUrl = async (imageUrl: string): Promise } + +export interface UploadedMedia { + link: string +} + +export const uploadFile = async (noteId: string, contentType: string, media: Blob): Promise => { + 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 +} diff --git a/src/components/common/hidden-input-menu-entry/hidden-input-menu-entry.tsx b/src/components/common/hidden-input-menu-entry/hidden-input-menu-entry.tsx new file mode 100644 index 000000000..2de14664e --- /dev/null +++ b/src/components/common/hidden-input-menu-entry/hidden-input-menu-entry.tsx @@ -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 +} + +export const HiddenInputMenuEntry: React.FC = ({ type, acceptedFiles, i18nKey, icon, onLoad }) => { + const { t } = useTranslation() + + const fileInputReference = useRef(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 ( + + + { + type === 'dropdown' + ? + + + + : + } + + ) +} diff --git a/src/components/editor/document-bar/import/import-file.tsx b/src/components/editor/document-bar/import/import-file.tsx deleted file mode 100644 index bc0b659e3..000000000 --- a/src/components/editor/document-bar/import/import-file.tsx +++ /dev/null @@ -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(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 ( - - - - - - - - ) -} diff --git a/src/components/editor/document-bar/menus/import-menu.tsx b/src/components/editor/document-bar/menus/import-menu.tsx index 6f420a351..5ea5742d6 100644 --- a/src/components/editor/document-bar/menus/import-menu.tsx +++ b/src/components/editor/document-bar/menus/import-menu.tsx @@ -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((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 ( @@ -34,7 +60,13 @@ export const ImportMenu: React.FC = () => { - + ) diff --git a/src/components/editor/editor-pane/editor-pane.scss b/src/components/editor/editor-pane/editor-pane.scss index 73e23de4e..152e7b7ea 100644 --- a/src/components/editor/editor-pane/editor-pane.scss +++ b/src/components/editor/editor-pane/editor-pane.scss @@ -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 { diff --git a/src/components/editor/editor-pane/editor-pane.tsx b/src/components/editor/editor-pane/editor-pane.tsx index 77cdcd86d..65ccf4787 100644 --- a/src/components/editor/editor-pane/editor-pane.tsx +++ b/src/components/editor/editor-pane/editor-pane.tsx @@ -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 = ({ 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 = ({ 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(() => ({ ...editorPreferences, mode: 'gfm', @@ -158,8 +201,8 @@ export const EditorPane: React.FC = ({ onContentC }), [t, editorPreferences]) return ( -
- setShowMaxLengthWarning(false)} maxLength={maxLength}/> +
+ @@ -168,6 +211,8 @@ export const EditorPane: React.FC = ({ onContentC value={content} options={codeMirrorOptions} onChange={onChange} + onPaste={onPaste} + onDrop={onDrop} onCursorActivity={onCursorActivity} editorDidMount={onEditorDidMount} onBeforeChange={onBeforeChange} diff --git a/src/components/editor/editor-pane/tool-bar/tool-bar.tsx b/src/components/editor/editor-pane/tool-bar/tool-bar.tsx index f5435a30b..3d46c8746 100644 --- a/src/components/editor/editor-pane/tool-bar/tool-bar.tsx +++ b/src/components/editor/editor-pane/tool-bar/tool-bar.tsx @@ -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 = ({ 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 = ({ editor }) => { - + diff --git a/src/components/editor/editor-pane/tool-bar/utils/upload-image-mimetypes.ts b/src/components/editor/editor-pane/tool-bar/utils/upload-image-mimetypes.ts new file mode 100644 index 000000000..849cecb1d --- /dev/null +++ b/src/components/editor/editor-pane/tool-bar/utils/upload-image-mimetypes.ts @@ -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(', ') diff --git a/src/components/editor/editor-pane/upload-handler.ts b/src/components/editor/editor-pane/upload-handler.ts new file mode 100644 index 000000000..a4661a8b2 --- /dev/null +++ b/src/components/editor/editor-pane/upload-handler.ts @@ -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})` + } +} diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index d716366e6..81633db7c 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -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() 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) diff --git a/src/redux/document-content/methods.ts b/src/redux/document-content/methods.ts index 7536a805b..0aa3c1703 100644 --- a/src/redux/document-content/methods.ts +++ b/src/redux/document-content/methods.ts @@ -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) +} diff --git a/src/redux/document-content/reducers.ts b/src/redux/document-content/reducers.ts index dd2e04519..0ae700316 100644 --- a/src/redux/document-content/reducers.ts +++ b/src/redux/document-content/reducers.ts @@ -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 = (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 } diff --git a/src/redux/document-content/types.ts b/src/redux/document-content/types.ts index 60d2a8cc6..62132ea12 100644 --- a/src/redux/document-content/types.ts +++ b/src/redux/document-content/types.ts @@ -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 { @@ -21,3 +23,7 @@ export interface DocumentContentAction extends Action export interface SetDocumentContentAction extends DocumentContentAction { content: string } + +export interface SetNoteIdAction extends DocumentContentAction { + noteId: string +} diff --git a/yarn.lock b/yarn.lock index 1a75cd987..15f65ac22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: