Merge pull request #4199 from Ocelot-Social-Community/1934-image-cropping-optional

feat: Image Cropping Is Optional
This commit is contained in:
Moriz Wahl 2021-02-09 16:20:03 +01:00 committed by GitHub
commit 55983db2ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 101 additions and 50 deletions

View File

@ -49,8 +49,9 @@ Factory.define('badge')
Factory.define('image') Factory.define('image')
.attr('url', faker.image.unsplash.imageUrl) .attr('url', faker.image.unsplash.imageUrl)
.attr('aspectRatio', 1) .attr('aspectRatio', 1.3333333333333333)
.attr('alt', faker.lorem.sentence) .attr('alt', faker.lorem.sentence)
.attr('type', 'image/jpeg')
.after((buildObject, options) => { .after((buildObject, options) => {
const { url: imageUrl } = buildObject const { url: imageUrl } = buildObject
if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl) if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl)

View File

@ -3,5 +3,6 @@ export default {
alt: { type: 'string' }, alt: { type: 'string' },
sensitive: { type: 'boolean', default: false }, sensitive: { type: 'boolean', default: false },
aspectRatio: { type: 'float', default: 1.0 }, aspectRatio: { type: 'float', default: 1.0 },
type: { type: 'string' },
createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() }, createdAt: { type: 'string', isoDate: true, default: () => new Date().toISOString() },
} }

View File

@ -2,7 +2,7 @@ import Resolver from './helpers/Resolver'
export default { export default {
Image: { Image: {
...Resolver('Image', { ...Resolver('Image', {
undefinedToNull: ['sensitive', 'alt', 'aspectRatio'], undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
}), }),
}, },
} }

View File

@ -53,8 +53,8 @@ export async function mergeImage(resource, relationshipType, imageInput, opts =
if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource') if (!(existingImage || upload)) throw new UserInputError('Cannot find image for given resource')
if (existingImage && upload) deleteImageFile(existingImage, deleteCallback) if (existingImage && upload) deleteImageFile(existingImage, deleteCallback)
const url = await uploadImageFile(upload, uploadCallback) const url = await uploadImageFile(upload, uploadCallback)
const { alt, sensitive, aspectRatio } = imageInput const { alt, sensitive, aspectRatio, type } = imageInput
const image = { alt, sensitive, aspectRatio, url } const image = { alt, sensitive, aspectRatio, url, type }
txResult = await transaction.run( txResult = await transaction.run(
` `
MATCH (resource {id: $resource.id}) MATCH (resource {id: $resource.id})

View File

@ -8,6 +8,7 @@ type Image {
alt: String, alt: String,
sensitive: Boolean, sensitive: Boolean,
aspectRatio: Float, aspectRatio: Float,
type: String,
} }
input ImageInput { input ImageInput {
@ -15,4 +16,5 @@ input ImageInput {
upload: Upload, upload: Upload,
sensitive: Boolean, sensitive: Boolean,
aspectRatio: Float, aspectRatio: Float,
type: String,
} }

View File

@ -151,6 +151,7 @@ describe('ContributionForm.vue', () => {
aspectRatio: null, aspectRatio: null,
sensitive: false, sensitive: false,
upload: imageUpload, upload: imageUpload,
type: null,
} }
const spy = jest const spy = jest
.spyOn(FileReader.prototype, 'readAsDataURL') .spyOn(FileReader.prototype, 'readAsDataURL')

View File

@ -19,6 +19,7 @@
:class="[formData.imageBlurred && '--blur-image']" :class="[formData.imageBlurred && '--blur-image']"
@addHeroImage="addHeroImage" @addHeroImage="addHeroImage"
@addImageAspectRatio="addImageAspectRatio" @addImageAspectRatio="addImageAspectRatio"
@addImageType="addImageType"
/> />
</template> </template>
<div v-if="formData.image" class="blur-toggle"> <div v-if="formData.image" class="blur-toggle">
@ -84,7 +85,11 @@ export default {
}, },
data() { data() {
const { title, content, image } = this.contribution const { title, content, image } = this.contribution
const { sensitive: imageBlurred = false, aspectRatio: imageAspectRatio = null } = image || {} const {
sensitive: imageBlurred = false,
aspectRatio: imageAspectRatio = null,
type: imageType = null,
} = image || {}
return { return {
links, links,
@ -93,6 +98,7 @@ export default {
content: content || '', content: content || '',
image: image || null, image: image || null,
imageAspectRatio, imageAspectRatio,
imageType,
imageBlurred, imageBlurred,
}, },
formSchema: { formSchema: {
@ -125,6 +131,7 @@ export default {
if (this.imageUpload) { if (this.imageUpload) {
image.upload = this.imageUpload image.upload = this.imageUpload
image.aspectRatio = this.formData.imageAspectRatio image.aspectRatio = this.formData.imageAspectRatio
image.type = this.formData.imageType
} }
} }
this.loading = true this.loading = true
@ -173,6 +180,9 @@ export default {
addImageAspectRatio(aspectRatio) { addImageAspectRatio(aspectRatio) {
this.formData.imageAspectRatio = aspectRatio this.formData.imageAspectRatio = aspectRatio
}, },
addImageType(imageType) {
this.formData.imageType = imageType
},
}, },
apollo: { apollo: {
User: { User: {

View File

@ -25,19 +25,11 @@ describe('ImageUploader.vue', () => {
describe('handles errors', () => { describe('handles errors', () => {
beforeEach(() => jest.useFakeTimers()) beforeEach(() => jest.useFakeTimers())
const message = 'File upload failed' const unSupportedFileMessage = 'message'
const fileError = { status: 'error' }
const unSupportedFileMessage =
'Please upload an image of file format : JPG , JPEG , PNG or GIF'
it('shows an error toaster when verror is called', () => {
wrapper.vm.onDropzoneError(fileError, message)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, message)
})
it('shows an error toaster when unSupported file is uploaded', () => { it('shows an error toaster when unSupported file is uploaded', () => {
wrapper.vm.onUnSupportedFormat(fileError.status, unSupportedFileMessage) wrapper.vm.onUnSupportedFormat(unSupportedFileMessage)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, unSupportedFileMessage) expect(mocks.$toast.error).toHaveBeenCalledWith(unSupportedFileMessage)
}) })
}) })
}) })

View File

@ -1,17 +1,21 @@
<template> <template>
<div class="image-uploader"> <div class="image-uploader">
<vue-dropzone <vue-dropzone
v-show="!showCropper" v-show="!showCropper && !hasImage"
id="postdropzone" id="postdropzone"
:options="dropzoneOptions" :options="dropzoneOptions"
:use-custom-slot="true" :use-custom-slot="true"
@vdropzone-error="onDropzoneError" @vdropzone-file-added="fileAdded"
@vdropzone-file-added="initCropper"
> >
<loading-spinner v-if="isLoadingImage" /> <loading-spinner v-if="isLoadingImage" />
<base-icon v-else name="image" /> <base-icon v-else-if="!hasImage" name="image" />
<div v-if="!hasImage" class="supported-formats">
{{ $t('contribution.teaserImage.supportedFormats') }}
</div>
</vue-dropzone>
<div v-show="!showCropper && hasImage">
<base-button <base-button
v-if="hasImage" class="delete-image-button"
icon="trash" icon="trash"
circle circle
danger danger
@ -20,10 +24,12 @@
:title="$t('actions.delete')" :title="$t('actions.delete')"
@click.stop="deleteImage" @click.stop="deleteImage"
/> />
<div v-if="!hasImage" class="supported-formats"> </div>
{{ $t('contribution.teaserImage.supportedFormats') }} <div v-show="!showCropper && imageCanBeCropped" class="crop-overlay">
</div> <base-button class="crop-confirm" filled @click="initCropper">
</vue-dropzone> {{ $t('contribution.teaserImage.cropImage') }}
</base-button>
</div>
<div v-show="showCropper" class="crop-overlay"> <div v-show="showCropper" class="crop-overlay">
<img id="cropping-image" /> <img id="cropping-image" />
<base-button class="crop-confirm" filled @click="cropImage"> <base-button class="crop-confirm" filled @click="cropImage">
@ -48,6 +54,8 @@ import Cropper from 'cropperjs'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner' import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
import 'cropperjs/dist/cropper.css' import 'cropperjs/dist/cropper.css'
const minAspectRatio = 0.3
export default { export default {
components: { components: {
LoadingSpinner, LoadingSpinner,
@ -70,50 +78,64 @@ export default {
cropper: null, cropper: null,
file: null, file: null,
showCropper: false, showCropper: false,
imageCanBeCropped: false,
isLoadingImage: false, isLoadingImage: false,
} }
}, },
methods: { methods: {
onDropzoneError(file, message) { onUnSupportedFormat(message) {
this.$toast.error(file.status, message) this.$toast.error(message)
}, },
addImageProcess(src) {
onUnSupportedFormat(status, message) { return new Promise((resolve, reject) => {
this.$toast.error(status, message) const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}, },
initCropper(file) { async fileAdded(file) {
const supportedFormats = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif'] const supportedFormats = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif']
if (supportedFormats.indexOf(file.type) < 0) { if (supportedFormats.indexOf(file.type) < 0) {
this.onUnSupportedFormat( this.onUnSupportedFormat(this.$t('contribution.teaserImage.errors.unSupported-file-format'))
'error', this.$nextTick((this.isLoadingImage = false))
this.$t('contribution.teaserImage.errors.unSupported-file-format'), return null
)
return
} }
this.showCropper = true const imageURL = URL.createObjectURL(file)
const image = await this.addImageProcess(imageURL)
const aspectRatio = image.width / image.height
if (aspectRatio < minAspectRatio) {
this.aspectRatioError()
return null
}
this.saveImage(aspectRatio, file, file.type)
this.file = file this.file = file
if (this.file.type === 'image/jpeg') this.imageCanBeCropped = true
this.$nextTick((this.isLoadingImage = false))
},
initCropper() {
this.showCropper = true
const imageElement = document.querySelector('#cropping-image') const imageElement = document.querySelector('#cropping-image')
imageElement.src = URL.createObjectURL(file) imageElement.src = URL.createObjectURL(this.file)
this.cropper = new Cropper(imageElement, { zoomable: false, autoCropArea: 0.9 }) this.cropper = new Cropper(imageElement, { zoomable: false, autoCropArea: 0.9 })
}, },
cropImage() { cropImage() {
this.isLoadingImage = true this.isLoadingImage = true
const onCropComplete = (aspectRatio, imageFile, imageType) => {
const onCropComplete = (aspectRatio, imageFile) => { this.saveImage(aspectRatio, imageFile, imageType)
this.$emit('addImageAspectRatio', aspectRatio)
this.$emit('addHeroImage', imageFile)
this.$nextTick((this.isLoadingImage = false)) this.$nextTick((this.isLoadingImage = false))
this.closeCropper() this.closeCropper()
} }
if (this.file.type === 'image/jpeg') { if (this.file.type === 'image/jpeg') {
const canvas = this.cropper.getCroppedCanvas() const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
const imageAspectRatio = canvas.width / canvas.height const imageAspectRatio = canvas.width / canvas.height
if (imageAspectRatio < minAspectRatio) {
this.aspectRatioError()
return
}
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type }) const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
onCropComplete(imageAspectRatio, croppedImageFile) onCropComplete(imageAspectRatio, croppedImageFile, 'image/jpeg')
}, 'image/jpeg') }, 'image/jpeg')
} else { } else {
// TODO: use cropped file instead of original file // TODO: use cropped file instead of original file
@ -125,9 +147,18 @@ export default {
this.showCropper = false this.showCropper = false
this.cropper.destroy() this.cropper.destroy()
}, },
aspectRatioError() {
this.$toast.error(this.$t('contribution.teaserImage.errors.aspect-ratio-too-small'))
},
saveImage(aspectRatio = 1.0, file, fileType) {
this.$emit('addImageAspectRatio', aspectRatio)
this.$emit('addHeroImage', file)
this.$emit('addImageType', fileType)
},
deleteImage() { deleteImage() {
this.$emit('addHeroImage', null) this.$emit('addHeroImage', null)
this.$emit('addImageAspectRatio', null) this.$emit('addImageAspectRatio', null)
this.$emit('addImageType', null)
}, },
}, },
} }
@ -136,7 +167,6 @@ export default {
.image-uploader { .image-uploader {
position: relative; position: relative;
min-height: $size-image-uploader-min-height; min-height: $size-image-uploader-min-height;
cursor: pointer;
.image + & { .image + & {
position: absolute; position: absolute;
@ -181,6 +211,14 @@ export default {
} }
} }
.delete-image-button {
position: absolute;
top: $space-small;
right: $space-small;
z-index: $z-index-surface;
cursor: pointer;
}
.dz-message { .dz-message {
position: absolute; position: absolute;
display: flex; display: flex;
@ -189,6 +227,7 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: $z-index-surface; z-index: $z-index-surface;
cursor: pointer;
&:hover { &:hover {
> .base-icon { > .base-icon {

View File

@ -51,6 +51,7 @@ export const postFragment = gql`
url url
sensitive sensitive
aspectRatio aspectRatio
type
} }
author { author {
...user ...user

View File

@ -213,8 +213,10 @@
"newPost": "Erstelle einen neuen Beitrag", "newPost": "Erstelle einen neuen Beitrag",
"success": "Gespeichert!", "success": "Gespeichert!",
"teaserImage": { "teaserImage": {
"cropImage": "Bild zuschneiden",
"cropperConfirm": "Bestätigen", "cropperConfirm": "Bestätigen",
"errors": { "errors": {
"aspect-ratio-too-small": "Dieses Bild ist zu hoch.",
"unSupported-file-format": "Bitte lade ein Bild in den folgenden Formaten hoch: JPG, JPEG, PNG or GIF!" "unSupported-file-format": "Bitte lade ein Bild in den folgenden Formaten hoch: JPG, JPEG, PNG or GIF!"
}, },
"supportedFormats": "Füge ein Bild im Dateiformat JPG, PNG oder GIF ein" "supportedFormats": "Füge ein Bild im Dateiformat JPG, PNG oder GIF ein"

View File

@ -213,8 +213,10 @@
"newPost": "Create a new Post", "newPost": "Create a new Post",
"success": "Saved!", "success": "Saved!",
"teaserImage": { "teaserImage": {
"cropImage": "Crop image",
"cropperConfirm": "Confirm", "cropperConfirm": "Confirm",
"errors": { "errors": {
"aspect-ratio-too-small": "This image is too high.",
"unSupported-file-format": "Please upload an image of file format: JPG, JPEG, PNG or GIF!" "unSupported-file-format": "Please upload an image of file format: JPG, JPEG, PNG or GIF!"
}, },
"supportedFormats": "Insert a picture of file format JPG, PNG or GIF" "supportedFormats": "Insert a picture of file format JPG, PNG or GIF"

View File

@ -176,13 +176,13 @@ export default {
/* Return false when image property is not present or is not a number /* Return false when image property is not present or is not a number
so no unnecessary css variables are set. so no unnecessary css variables are set.
*/ */
if (!this.post.image || typeof this.post.image.aspectRatio !== 'number') return false
if (!this.post.image || typeof this.post.image.aspectRatio !== 'number') return false
/* Return the aspect ratio as a css variable. Later to be used when calculating /* Return the aspect ratio as a css variable. Later to be used when calculating
the height with respect to the width. the height with respect to the width.
*/ */
return { return {
'--hero-image-aspect-ratio': 1 / this.post.image.aspectRatio, '--hero-image-aspect-ratio': 1.0 / this.post.image.aspectRatio,
} }
}, },
}, },
@ -258,8 +258,8 @@ export default {
hero image aspect ratio) before the hero image loads so hero image aspect ratio) before the hero image loads so
the autoscroll works correctly when following a comment link. the autoscroll works correctly when following a comment link.
*/ */
padding-top: calc(var(--hero-image-aspect-ratio) * 100%);
padding-top: calc(var(--hero-image-aspect-ratio) * (100% + 48px));
/* Letting the image fill the container, since the container /* Letting the image fill the container, since the container
is the one determining height is the one determining height
*/ */