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')
|
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)
|
||||||
|
|||||||
@ -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() },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export const postFragment = gql`
|
|||||||
url
|
url
|
||||||
sensitive
|
sensitive
|
||||||
aspectRatio
|
aspectRatio
|
||||||
|
type
|
||||||
}
|
}
|
||||||
author {
|
author {
|
||||||
...user
|
...user
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user