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')
.attr('url', faker.image.unsplash.imageUrl)
.attr('aspectRatio', 1)
.attr('aspectRatio', 1.3333333333333333)
.attr('alt', faker.lorem.sentence)
.attr('type', 'image/jpeg')
.after((buildObject, options) => {
const { url: imageUrl } = buildObject
if (imageUrl) buildObject.url = uniqueImageUrl(imageUrl)

View File

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

View File

@ -2,7 +2,7 @@ import Resolver from './helpers/Resolver'
export default {
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) deleteImageFile(existingImage, deleteCallback)
const url = await uploadImageFile(upload, uploadCallback)
const { alt, sensitive, aspectRatio } = imageInput
const image = { alt, sensitive, aspectRatio, url }
const { alt, sensitive, aspectRatio, type } = imageInput
const image = { alt, sensitive, aspectRatio, url, type }
txResult = await transaction.run(
`
MATCH (resource {id: $resource.id})

View File

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

View File

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

View File

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

View File

@ -25,19 +25,11 @@ describe('ImageUploader.vue', () => {
describe('handles errors', () => {
beforeEach(() => jest.useFakeTimers())
const message = 'File upload failed'
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)
})
const unSupportedFileMessage = 'message'
it('shows an error toaster when unSupported file is uploaded', () => {
wrapper.vm.onUnSupportedFormat(fileError.status, unSupportedFileMessage)
expect(mocks.$toast.error).toHaveBeenCalledWith(fileError.status, unSupportedFileMessage)
wrapper.vm.onUnSupportedFormat(unSupportedFileMessage)
expect(mocks.$toast.error).toHaveBeenCalledWith(unSupportedFileMessage)
})
})
})

View File

@ -1,17 +1,21 @@
<template>
<div class="image-uploader">
<vue-dropzone
v-show="!showCropper"
v-show="!showCropper && !hasImage"
id="postdropzone"
:options="dropzoneOptions"
:use-custom-slot="true"
@vdropzone-error="onDropzoneError"
@vdropzone-file-added="initCropper"
@vdropzone-file-added="fileAdded"
>
<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
v-if="hasImage"
class="delete-image-button"
icon="trash"
circle
danger
@ -20,10 +24,12 @@
:title="$t('actions.delete')"
@click.stop="deleteImage"
/>
<div v-if="!hasImage" class="supported-formats">
{{ $t('contribution.teaserImage.supportedFormats') }}
</div>
</vue-dropzone>
</div>
<div v-show="!showCropper && imageCanBeCropped" class="crop-overlay">
<base-button class="crop-confirm" filled @click="initCropper">
{{ $t('contribution.teaserImage.cropImage') }}
</base-button>
</div>
<div v-show="showCropper" class="crop-overlay">
<img id="cropping-image" />
<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 'cropperjs/dist/cropper.css'
const minAspectRatio = 0.3
export default {
components: {
LoadingSpinner,
@ -70,50 +78,64 @@ export default {
cropper: null,
file: null,
showCropper: false,
imageCanBeCropped: false,
isLoadingImage: false,
}
},
methods: {
onDropzoneError(file, message) {
this.$toast.error(file.status, message)
onUnSupportedFormat(message) {
this.$toast.error(message)
},
onUnSupportedFormat(status, message) {
this.$toast.error(status, message)
addImageProcess(src) {
return new Promise((resolve, reject) => {
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']
if (supportedFormats.indexOf(file.type) < 0) {
this.onUnSupportedFormat(
'error',
this.$t('contribution.teaserImage.errors.unSupported-file-format'),
)
return
this.onUnSupportedFormat(this.$t('contribution.teaserImage.errors.unSupported-file-format'))
this.$nextTick((this.isLoadingImage = false))
return null
}
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
if (this.file.type === 'image/jpeg') this.imageCanBeCropped = true
this.$nextTick((this.isLoadingImage = false))
},
initCropper() {
this.showCropper = true
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 })
},
cropImage() {
this.isLoadingImage = true
const onCropComplete = (aspectRatio, imageFile) => {
this.$emit('addImageAspectRatio', aspectRatio)
this.$emit('addHeroImage', imageFile)
const onCropComplete = (aspectRatio, imageFile, imageType) => {
this.saveImage(aspectRatio, imageFile, imageType)
this.$nextTick((this.isLoadingImage = false))
this.closeCropper()
}
if (this.file.type === 'image/jpeg') {
const canvas = this.cropper.getCroppedCanvas()
canvas.toBlob((blob) => {
const imageAspectRatio = canvas.width / canvas.height
if (imageAspectRatio < minAspectRatio) {
this.aspectRatioError()
return
}
const croppedImageFile = new File([blob], this.file.name, { type: this.file.type })
onCropComplete(imageAspectRatio, croppedImageFile)
onCropComplete(imageAspectRatio, croppedImageFile, 'image/jpeg')
}, 'image/jpeg')
} else {
// TODO: use cropped file instead of original file
@ -125,9 +147,18 @@ export default {
this.showCropper = false
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() {
this.$emit('addHeroImage', null)
this.$emit('addImageAspectRatio', null)
this.$emit('addImageType', null)
},
},
}
@ -136,7 +167,6 @@ export default {
.image-uploader {
position: relative;
min-height: $size-image-uploader-min-height;
cursor: pointer;
.image + & {
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 {
position: absolute;
display: flex;
@ -189,6 +227,7 @@ export default {
width: 100%;
height: 100%;
z-index: $z-index-surface;
cursor: pointer;
&:hover {
> .base-icon {

View File

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

View File

@ -213,8 +213,10 @@
"newPost": "Erstelle einen neuen Beitrag",
"success": "Gespeichert!",
"teaserImage": {
"cropImage": "Bild zuschneiden",
"cropperConfirm": "Bestätigen",
"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!"
},
"supportedFormats": "Füge ein Bild im Dateiformat JPG, PNG oder GIF ein"

View File

@ -213,8 +213,10 @@
"newPost": "Create a new Post",
"success": "Saved!",
"teaserImage": {
"cropImage": "Crop image",
"cropperConfirm": "Confirm",
"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!"
},
"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
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
the height with respect to the width.
*/
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
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
is the one determining height
*/