enh: Add crop component for contact picture

- add cropperjs package

Signed-off-by: Johannes Merkel <mail@johannesgge.de>
This commit is contained in:
Johannes Merkel 2023-04-23 15:19:17 +02:00
parent 3cd4e113a9
commit d4942b367b
No known key found for this signature in database
4 changed files with 239 additions and 57 deletions

27
package-lock.json generated
View File

@ -39,6 +39,7 @@
"uuid": "^9.0.0",
"vue": "~2.6.14",
"vue-click-outside": "^1.1.0",
"vue-cropperjs": "^4.2.0",
"vue-material-design-icons": "^5.2.0",
"vue-router": "^3.6.5",
"vue-virtual-scroll-list": "^2.3.4",
@ -6021,6 +6022,11 @@
"sha.js": "^2.4.8"
}
},
"node_modules/cropperjs": {
"version": "1.5.13",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.13.tgz",
"integrity": "sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
@ -16766,6 +16772,14 @@
"tinycolor2": "^1.1.2"
}
},
"node_modules/vue-cropperjs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/vue-cropperjs/-/vue-cropperjs-4.2.0.tgz",
"integrity": "sha512-dvwCBtjGMiznkNIK2GFd1SQm1x+wmtWg4g4t+NrJSPj/fpHnubXxAUOIvY7lMFeR2lawRLsigCaGZrcXCzuTKA==",
"dependencies": {
"cropperjs": "^1.5.6"
}
},
"node_modules/vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
@ -22280,6 +22294,11 @@
"sha.js": "^2.4.8"
}
},
"cropperjs": {
"version": "1.5.13",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.13.tgz",
"integrity": "sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA=="
},
"cross-spawn": {
"version": "7.0.3",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
@ -30092,6 +30111,14 @@
"tinycolor2": "^1.1.2"
}
},
"vue-cropperjs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/vue-cropperjs/-/vue-cropperjs-4.2.0.tgz",
"integrity": "sha512-dvwCBtjGMiznkNIK2GFd1SQm1x+wmtWg4g4t+NrJSPj/fpHnubXxAUOIvY7lMFeR2lawRLsigCaGZrcXCzuTKA==",
"requires": {
"cropperjs": "^1.5.6"
}
},
"vue-demi": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",

View File

@ -66,6 +66,7 @@
"uuid": "^9.0.0",
"vue": "~2.6.14",
"vue-click-outside": "^1.1.0",
"vue-cropperjs": "^4.2.0",
"vue-material-design-icons": "^5.2.0",
"vue-router": "^3.6.5",
"vue-virtual-scroll-list": "^2.3.4",

View File

@ -29,7 +29,7 @@
type="file"
class="hidden"
accept="image/*"
@change="processFile">
@change="handleUploadedFile">
<!-- Avatar display -->
<Avatar :disable-tooltip="true"
@ -39,6 +39,24 @@
:url="photoUrl"
class="contact-header-avatar__photo" />
<NcModal :show.sync="showCropper" @close="cancel" size="small">
<div class="avatar__container">
<h2>{{ t('contacts', 'Crop contact photo') }}</h2>
<VueCropper ref="cropper"
class="avatar__cropper"
v-bind="cropperOptions" />
<div class="avatar__cropper-buttons">
<NcButton type="tertiary" @click="cancel">
{{ t('contacts', 'Cancel') }}
</NcButton>
<NcButton type="primary"
@click="saveAvatar">
{{ t('contacts', 'Save') }}
</NcButton>
</div>
</div>
</NcModal>
<Actions v-if="!isReadOnly || contact.photo"
:force-menu="true"
:open.sync="opened"
@ -101,6 +119,11 @@ import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconUpload from 'vue-material-design-icons/Upload.vue'
import IconFolder from 'vue-material-design-icons/Folder.vue'
import IconImage from 'vue-material-design-icons/Image.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import VueCropper from 'vue-cropperjs'
import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
// eslint-disable-next-line n/no-extraneous-import
import 'cropperjs/dist/cropper.css'
import { showError, showInfo, getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs'
import { generateUrl, generateRemoteUrl } from '@nextcloud/router'
@ -126,6 +149,9 @@ export default {
IconUpload,
IconFolder,
IconImage,
NcButton,
VueCropper,
NcModal,
},
props: {
@ -141,6 +167,18 @@ export default {
loading: false,
photoUrl: undefined,
root: generateRemoteUrl(`dav/files/${getCurrentUser().uid}`),
showCropper: false,
cropperOptions: {
aspectRatio: 1 / 1,
viewMode: 3,
guides: false,
center: false,
highlight: false,
autoCropArea: 1,
dragMode: 'move',
minContainerWidth: 100,
minContainerHeight: 100,
},
}
},
@ -184,69 +222,82 @@ export default {
onLoad() {
console.debug(...arguments)
},
/**
* Handler to store a new photo on the current contact
* Checks the selected image for mimetype
* and open the cropper if valid, else show error
*
* @param {Buffer} data the image
* @return {boolean}
*/
async processPicture(data) {
const type = this.getMimetype(data)
if (!type.startsWith('image/')) {
showError(t('contacts', 'Please select a valid format'))
return false
}
if (type === 'image/svg') {
const imageSvg = atob(data.toString('base64'))
const cleanSvg = await sanitizeSVG(imageSvg)
if (!cleanSvg) {
throw new Error('Unsafe svg image', imageSvg)
}
}
this.openCropper(data, type)
return true
},
/**
* Open the cropper-modal with the provided data
*
* @param {Buffer} data the image
* @param {string} type of the image
*/
openCropper(data, type) {
const ccc = `data:${type};base64,${data.toString('base64')}`
this.$refs.cropper.replace(ccc)
this.showCropper = true
},
/**
* Handle the uploaded file
*
* @param {object} event the event object containing the image
*/
processFile(event) {
handleUploadedFile(event) {
if (event.target.files && !this.loading) {
this.closeMenu()
const file = event.target.files[0]
if (file && file.size && file.size <= 1 * 1024 * 1024) {
const reader = new FileReader()
const self = this
let type = ''
reader.onloadend = async function(e) {
try {
// We got an ArrayBuffer, checking the true mime type...
if (typeof e.target.result === 'object') {
const uint = new Uint8Array(e.target.result)
const bytes = []
uint.forEach((byte) => {
bytes.push(byte.toString(16))
})
const hex = bytes.join('').toUpperCase()
const reader = new FileReader()
if (self.getMimetype(hex).startsWith('image/')) {
type = self.getMimetype(hex)
// we got a valid image, read it again as base64
reader.readAsDataURL(file)
return
}
throw new Error('Wrong image mimetype')
reader.onload = (e) => {
try {
if (typeof e.target.result === 'object') {
const data = Buffer.from(e.target.result, 'binary')
if (this.processPicture(data)) {
return
}
// else we got the base64 and we're good to go!
const imageBase64 = e.target.result.split(',').pop()
if (e.target.result.indexOf('image/svg') > -1) {
const imageSvg = atob(imageBase64)
const cleanSvg = await sanitizeSVG(imageSvg)
if (!cleanSvg) {
throw new Error('Unsafe svg image', imageSvg)
}
}
// All is well! Set the photo
self.setPhoto(imageBase64, type)
} catch (error) {
console.error(error)
showError(t('contacts', 'Invalid image'))
} finally {
self.resetPicker()
throw new Error('Wrong image mimetype')
}
}
// start by reading the magic bytes to detect proper photo mimetype
const blob = file.slice(0, 4)
reader.readAsArrayBuffer(blob)
} else {
showError(t('contacts', 'Image is too big (max 1MB).'))
this.resetPicker()
} catch (error) {
console.error(error)
showError(t('contacts', 'Invalid image'))
} finally {
this.resetPicker()
}
}
reader.readAsArrayBuffer(file)
}
},
@ -268,13 +319,19 @@ export default {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
},
/**
* Return the mimetype based on the first magix byte
* Return the mimetype based on the first 4 byte
*
* @param {string} signature the first 4 bytes
* @param {Uint8Array} uint content
* @return {string} the mimetype
*/
getMimetype(signature) {
switch (signature) {
getMimetype(uint) {
const bytes = []
uint.slice(0, 4).forEach((byte) => {
bytes.push(byte.toString(16))
})
const hex = bytes.join('').toUpperCase()
switch (hex) {
case '89504E47':
return 'image/png'
case '47494638':
@ -338,6 +395,34 @@ export default {
}
},
/**
* Save the cropped image
*/
saveAvatar() {
this.showCropper = false
this.loading = true
this.$refs.cropper.getCroppedCanvas({
minWidth: 16,
minHeight: 16,
maxWidth: 512,
maxHeight: 512,
}).toBlob(async (blob) => {
if (blob === null) {
showError(t('contacts', 'Error cropping picture'))
this.cancel()
return
}
const reader = new FileReader()
reader.readAsDataURL(blob)
reader.onloadend = () => {
const base64data = reader.result
this.setPhoto(base64data.split(',').pop(), blob.type)
}
})
},
/**
* Remove the contact's picture
*/
@ -347,15 +432,29 @@ export default {
},
/**
* Picker handlers
* Cancel cropping
*/
cancel() {
this.showCropper = false
this.loading = false
},
/**
* Picker handlers Upload
*/
selectFileInput() {
if (!this.loading) {
this.$refs.uploadInput.click()
}
},
/**
* Picker handlers from Files
*/
async selectFilePicker() {
if (!this.loading) {
this.closeMenu()
const picker = getFilePickerBuilder(t('contacts', 'Pick an avatar'))
.setMimeTypeFilter([
'image/png',
@ -368,19 +467,25 @@ export default {
.build()
const file = await picker.pick()
if (file) {
this.loading = true
try {
const response = await axios.get(`${this.root}${file}`, {
responseType: 'arraybuffer',
})
const type = response.headers['content-type']
const data = Buffer.from(response.data, 'binary').toString('base64')
this.setPhoto(data, type)
const data = Buffer.from(response.data, 'binary')
this.processPicture(data)
} catch (error) {
showError(t('contacts', 'Error while processing the picture.'))
console.error(error)
this.loading = false
} finally {
this.resetPicker()
}
}
}
@ -436,6 +541,53 @@ export default {
}
</script>
<style lang="scss" scoped>
.avatar__container {
padding: 24px;
}
.avatar {
&__container {
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px 0;
width: 300px;
span {
color: var(--color-text-lighter);
}
}
&__preview {
display: flex;
justify-content: center;
align-items: center;
width: 180px;
height: 180px;
}
&__buttons {
display: flex;
gap: 0 10px;
}
&__cropper {
overflow: hidden;
&-buttons {
width: 100%;
display: flex;
justify-content: space-between;
}
&::v-deep .cropper-view-box {
border-radius: 50%;
}
}
}
.contact-header-avatar {
// Wrap and cut
&__wrapper {

View File

@ -19,4 +19,6 @@ webpackConfig.module.rules.push({
webpackConfig.resolve.extensions = ['.js', '.vue', '.ts', '.tsx']
webpackConfig.resolve.fallback = {"fs": false}
module.exports = webpackConfig