From 0c0841639a0ebf469d6c004897789251068e32a4 Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Wed, 16 Dec 2020 23:07:09 +0100 Subject: [PATCH] added upload functionality (#758) Co-authored-by: Tilman Vatteroth Signed-off-by: Tilman Vatteroth Signed-off-by: Philip Molares --- cypress/fixtures/acme.png | Bin 0 -> 6372 bytes cypress/fixtures/acme.png.license | 3 + cypress/integration/intro.spec.ts | 2 +- cypress/integration/upload.spec.ts | 98 ++++++++++++++++++ public/locales/de.json | 3 + public/locales/en.json | 4 + src/api/media/index.ts | 18 ++++ .../hidden-input-menu-entry.tsx | 59 +++++++++++ .../document-bar/import/import-file.tsx | 55 ---------- .../editor/document-bar/menus/import-menu.tsx | 38 ++++++- .../editor/editor-pane/editor-pane.scss | 4 + .../editor/editor-pane/editor-pane.tsx | 49 ++++++++- .../editor/editor-pane/tool-bar/tool-bar.tsx | 24 +++-- .../tool-bar/utils/upload-image-mimetypes.ts | 23 ++++ .../editor/editor-pane/upload-handler.ts | 48 +++++++++ src/components/editor/editor.tsx | 8 +- src/redux/document-content/methods.ts | 10 +- src/redux/document-content/reducers.ts | 21 +++- src/redux/document-content/types.ts | 6 ++ yarn.lock | 1 + 20 files changed, 401 insertions(+), 73 deletions(-) create mode 100644 cypress/fixtures/acme.png create mode 100644 cypress/fixtures/acme.png.license create mode 100644 cypress/integration/upload.spec.ts create mode 100644 src/components/common/hidden-input-menu-entry/hidden-input-menu-entry.tsx delete mode 100644 src/components/editor/document-bar/import/import-file.tsx create mode 100644 src/components/editor/editor-pane/tool-bar/utils/upload-image-mimetypes.ts create mode 100644 src/components/editor/editor-pane/upload-handler.ts diff --git a/cypress/fixtures/acme.png b/cypress/fixtures/acme.png new file mode 100644 index 0000000000000000000000000000000000000000..d423cd8b255a50d54d92d437f96c9c7cc658e1e3 GIT binary patch literal 6372 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 literal 0 HcmV?d00001 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: