mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge pull request #4199 from Ocelot-Social-Community/1934-image-cropping-optional
feat: Image Cropping Is Optional
This commit is contained in:
commit
55983db2ca
@ -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)
|
||||
|
||||
@ -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() },
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import Resolver from './helpers/Resolver'
|
||||
export default {
|
||||
Image: {
|
||||
...Resolver('Image', {
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio'],
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -151,6 +151,7 @@ describe('ContributionForm.vue', () => {
|
||||
aspectRatio: null,
|
||||
sensitive: false,
|
||||
upload: imageUpload,
|
||||
type: null,
|
||||
}
|
||||
const spy = jest
|
||||
.spyOn(FileReader.prototype, 'readAsDataURL')
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -51,6 +51,7 @@ export const postFragment = gql`
|
||||
url
|
||||
sensitive
|
||||
aspectRatio
|
||||
type
|
||||
}
|
||||
author {
|
||||
...user
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user