Skip to content

Commit

Permalink
add image upload via drag'n'drop
Browse files Browse the repository at this point in the history
remove image upload by link
move 'image insertion from files' from MenuBar to EditorWrapper
allow uploading multiple files

Signed-off-by: Julien Veyssier <[email protected]>
  • Loading branch information
Julien Veyssier committed Mar 29, 2022
1 parent 0d8aff0 commit c3a1b17
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 118 deletions.
19 changes: 1 addition & 18 deletions cypress/integration/images.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,23 +139,6 @@ describe('Test all image insertion methods', () => {
})
})

it('Insert an image from a link', () => {
cy.openFile('test.md')
clickOnImageAction(ACTION_INSERT_FROM_LINK, (popoverId) => {
const requestAlias = 'insertLinkRequest'
cy.intercept({ method: 'POST', url: '**/link' }).as(requestAlias)

cy.log('Type and validate')
cy.get('div#' + popoverId + ' li:nth-child(3) input[type=text]')
.type('https://nextcloud.com/wp-content/themes/next/assets/img/headers/engineering-small.jpg', { waitForAnimations: true })
.type('{enter}', { waitForAnimations: true })
// Clicking on the validation button is an alternative to typing {enter}
// cy.get('div#' + popoverId + ' li:nth-child(3) form > label').click()

waitForRequestAndCheckImage(requestAlias)
})
})

it('Upload a local image', () => {
cy.openFile('test.md')
// in this case we almost could just attach the file to the input
Expand All @@ -174,7 +157,7 @@ describe('Test all image insertion methods', () => {

it('test if image files are in the attachment folder', () => {
// check we stored the image names/ids
cy.expect(Object.keys(attachmentFileNameToId)).to.have.lengthOf(3)
cy.expect(Object.keys(attachmentFileNameToId)).to.have.lengthOf(2)

cy.get(`#fileList tr[data-file="test.md"]`, { timeout: 10000 })
.should('have.attr', 'data-id')
Expand Down
75 changes: 73 additions & 2 deletions src/components/EditorWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
</p>
</div>
<div v-if="displayed" id="editor-wrapper" :class="{'has-conflicts': hasSyncCollission, 'icon-loading': !contentLoaded && !hasConnectionIssue, 'richEditor': isRichEditor, 'show-color-annotations': showAuthorAnnotations}">
<div v-if="tiptap" id="editor">
<div v-if="tiptap"
id="editor"
:class="{ draggedOver }"
@dragover.prevent.stop="draggedOver = true"
@dragleave.prevent.stop="draggedOver = false"
@drop.prevent.stop="onEditorDrop">
<MenuBar v-if="renderMenus"
ref="menubar"
:editor="tiptap"
Expand All @@ -45,7 +50,10 @@
:is-public="isPublic"
:autohide="autohide"
:loaded.sync="menubarLoaded"
@show-help="showHelp">
:uploading-image="nbUploadingImages > 0"
@show-help="showHelp"
@image-insert="insertImagePath"
@image-upload="uploadImageFiles">
<div id="editor-session-list">
<div v-tooltip="lastSavedStatusTooltip" class="save-status" :class="lastSavedStatusClass">
{{ lastSavedStatus }}
Expand Down Expand Up @@ -81,6 +89,7 @@
import Vue from 'vue'
import escapeHtml from 'escape-html'
import moment from '@nextcloud/moment'
import { showError } from '@nextcloud/dialogs'

import { SyncService, ERROR_TYPE, IDLE_TIMEOUT } from './../services/SyncService'
import { endpointUrl, getRandomGuestName } from './../helpers'
Expand All @@ -99,6 +108,18 @@ import { Step } from 'prosemirror-transform'

const EDITOR_PUSH_DEBOUNCE = 200

const imageMimes = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
]

export default {
name: 'EditorWrapper',
components: {
Expand Down Expand Up @@ -179,6 +200,8 @@ export default {
readOnly: true,
forceRecreate: false,
menubarLoaded: false,
nbUploadingImages: 0,
draggedOver: false,

saveStatusPolling: null,
displayHelp: false,
Expand Down Expand Up @@ -543,6 +566,51 @@ export default {
hideHelp() {
this.displayHelp = false
},
onEditorDrop(e) {
this.uploadImageFiles(e.dataTransfer.files)
this.draggedOver = false
},
uploadImageFiles(files) {
if (files) {
files.forEach((file) => {
this.uploadImageFile(file)
})
}
},
uploadImageFile(file) {
if (!imageMimes.includes(file.type)) {
showError(t('text', 'Image file format not supported'))
return
}

this.nbUploadingImages++
this.syncService.uploadImage(file).then((response) => {
this.insertAttachmentImage(response.data?.name, response.data?.id)
}).catch((error) => {
console.error(error)
showError(error?.response?.data?.error)
}).then(() => {
this.nbUploadingImages--
})
},
insertImagePath(imagePath) {
this.nbUploadingImages++
this.syncService.insertImageFile(imagePath).then((response) => {
this.insertAttachmentImage(response.data?.name, response.data?.id)
}).catch((error) => {
console.error(error)
showError(error?.response?.data?.error)
}).then(() => {
this.nbUploadingImages--
})
},
insertAttachmentImage(name, fileId) {
const src = 'text://image?imageFileName=' + encodeURIComponent(name)
// simply get rid of brackets to make sure link text is valid
// as it does not need to be unique and matching the real file name
const alt = name.replaceAll(/[[\]]/g, '')
this.tiptap.chain().setImage({ src, alt }).focus().run()
},
},
}
</script>
Expand Down Expand Up @@ -778,6 +846,9 @@ export default {

// relative position for the alignment of the menububble
#editor {
&.draggedOver {
background-color: var(--color-primary-light);
}
.content-wrapper {
position: relative;
}
Expand Down
107 changes: 9 additions & 98 deletions src/components/MenuBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
accept="image/*"
aria-hidden="true"
class="hidden-visually"
:multiple="true"
@change="onImageUploadFilePicked">
<div v-if="isRichEditor" ref="menubar" class="menubar-icons">
<template v-for="(icon, $index) in allIcons">
Expand All @@ -46,7 +47,7 @@
class="submenu"
:default-icon="'icon-image'"
@open="toggleChildMenu(icon)"
@close="onImageActionClose; toggleChildMenu(icon)">
@close="toggleChildMenu(icon)">
<button slot="icon"
:class="{ 'icon-image': true, 'loading-small': uploadingImage }"
:title="icon.label"
Expand All @@ -65,20 +66,6 @@
@click="showImagePrompt()">
{{ t('text', 'Insert from Files') }}
</ActionButton>
<ActionButton v-if="!showImageLinkPrompt"
icon="icon-link"
:close-after-click="false"
:disabled="uploadingImage"
@click="showImageLinkPrompt = true">
{{ t('text', 'Insert from link') }}
</ActionButton>
<ActionInput v-else
icon="icon-link"
:value="imageLink"
@update:value="onImageLinkUpdateValue"
@submit="onImageLinkSubmit()">
{{ t('text', 'Image link to insert') }}
</ActionInput>
</Actions>
<button v-else-if="icon.class"
v-show="$index < iconCount"
Expand Down Expand Up @@ -137,30 +124,15 @@ import isMobile from './../mixins/isMobile'

import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
import PopoverMenu from '@nextcloud/vue/dist/Components/PopoverMenu'
import EmojiPicker from '@nextcloud/vue/dist/Components/EmojiPicker'
import ClickOutside from 'vue-click-outside'
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'

const imageMimes = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/x-xbitmap',
'image/x-ms-bmp',
'image/bmp',
'image/svg+xml',
'image/webp',
]

export default {
name: 'MenuBar',
components: {
ActionButton,
ActionInput,
PopoverMenu,
Actions,
EmojiPicker,
Expand Down Expand Up @@ -204,6 +176,10 @@ export default {
required: false,
default: 0,
},
uploadingImage: {
type: Boolean,
default: false,
},
},
data: () => {
return {
Expand All @@ -212,9 +188,6 @@ export default {
forceRecompute: 0,
submenuVisibility: {},
lastImagePath: null,
showImageLinkPrompt: false,
uploadingImage: false,
imageLink: '',
icons: [...menuBarIcons],
}
},
Expand Down Expand Up @@ -353,86 +326,24 @@ export default {
this.refocus()
}
},
onImageActionClose() {
this.showImageLinkPrompt = false
},
onUploadImage() {
this.$refs.imageFileInput.click()
},
onImageUploadFilePicked(event) {
this.uploadingImage = true
const files = event.target.files
const image = files[0]
if (!imageMimes.includes(image.type)) {
showError(t('text', 'Image format not supported'))
this.uploadingImage = false
return
}

this.$emit('image-upload', event.target.files)
// Clear input to ensure that the change event will be emitted if
// the same file is picked again.
event.target.value = ''

this.syncService.uploadImage(image).then((response) => {
this.insertAttachmentImage(response.data?.name, response.data?.id)
}).catch((error) => {
console.error(error)
showError(error?.response?.data?.error)
}).then(() => {
this.uploadingImage = false
})
},
onImageLinkUpdateValue(newImageLink) {
// this avoids the input being reset on each file polling
this.imageLink = newImageLink
},
onImageLinkSubmit() {
if (!this.imageLink) {
return
}
this.uploadingImage = true
this.showImageLinkPrompt = false
this.$refs.imageActions[0].closeMenu()

this.syncService.insertImageLink(this.imageLink).then((response) => {
this.insertAttachmentImage(response.data?.name, response.data?.id)
}).catch((error) => {
console.error(error)
showError(error?.response?.data?.error)
}).then(() => {
this.uploadingImage = false
this.imageLink = ''
})
},
onImagePathSubmit(imagePath) {
this.uploadingImage = true
this.$refs.imageActions[0].closeMenu()

this.syncService.insertImageFile(imagePath).then((response) => {
this.insertAttachmentImage(response.data?.name, response.data?.id)
}).catch((error) => {
console.error(error)
showError(error?.response?.data?.error)
}).then(() => {
this.uploadingImage = false
})
},
showImagePrompt() {
const currentUser = getCurrentUser()
if (!currentUser) {
return
}
OC.dialogs.filepicker(t('text', 'Insert an image'), (file) => {
this.onImagePathSubmit(file)
OC.dialogs.filepicker(t('text', 'Insert an image'), (filePath) => {
this.$emit('image-insert', filePath)
}, false, [], true, undefined, this.imagePath)
},
insertAttachmentImage(name, fileId) {
const src = 'text://image?imageFileName=' + encodeURIComponent(name)
// simply get rid of brackets to make sure link text is valid
// as it does not need to be unique and matching the real file name
const alt = name.replaceAll(/[[\]]/g, '')
this.editor.chain().setImage({ src, alt }).focus().run()
},
optimalPathTo(targetFile) {
const absolutePath = targetFile.split('/')
const relativePath = this.relativePathTo(targetFile).split('/')
Expand Down
1 change: 1 addition & 0 deletions src/nodes/ImageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export default {
max-width: 80%;
border: none;
text-align: center;
background-color: transparent;
}
}

Expand Down

0 comments on commit c3a1b17

Please sign in to comment.