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", "uuid": "^9.0.0",
"vue": "~2.6.14", "vue": "~2.6.14",
"vue-click-outside": "^1.1.0", "vue-click-outside": "^1.1.0",
"vue-cropperjs": "^4.2.0",
"vue-material-design-icons": "^5.2.0", "vue-material-design-icons": "^5.2.0",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vue-virtual-scroll-list": "^2.3.4", "vue-virtual-scroll-list": "^2.3.4",
@ -6021,6 +6022,11 @@
"sha.js": "^2.4.8" "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": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
@ -16766,6 +16772,14 @@
"tinycolor2": "^1.1.2" "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": { "node_modules/vue-demi": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
@ -22280,6 +22294,11 @@
"sha.js": "^2.4.8" "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": { "cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
@ -30092,6 +30111,14 @@
"tinycolor2": "^1.1.2" "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": { "vue-demi": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",

View File

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

View File

@ -29,7 +29,7 @@
type="file" type="file"
class="hidden" class="hidden"
accept="image/*" accept="image/*"
@change="processFile"> @change="handleUploadedFile">
<!-- Avatar display --> <!-- Avatar display -->
<Avatar :disable-tooltip="true" <Avatar :disable-tooltip="true"
@ -39,6 +39,24 @@
:url="photoUrl" :url="photoUrl"
class="contact-header-avatar__photo" /> 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" <Actions v-if="!isReadOnly || contact.photo"
:force-menu="true" :force-menu="true"
:open.sync="opened" :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 IconUpload from 'vue-material-design-icons/Upload.vue'
import IconFolder from 'vue-material-design-icons/Folder.vue' import IconFolder from 'vue-material-design-icons/Folder.vue'
import IconImage from 'vue-material-design-icons/Image.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 { showError, showInfo, getFilePickerBuilder, showSuccess } from '@nextcloud/dialogs'
import { generateUrl, generateRemoteUrl } from '@nextcloud/router' import { generateUrl, generateRemoteUrl } from '@nextcloud/router'
@ -126,6 +149,9 @@ export default {
IconUpload, IconUpload,
IconFolder, IconFolder,
IconImage, IconImage,
NcButton,
VueCropper,
NcModal,
}, },
props: { props: {
@ -141,6 +167,18 @@ export default {
loading: false, loading: false,
photoUrl: undefined, photoUrl: undefined,
root: generateRemoteUrl(`dav/files/${getCurrentUser().uid}`), 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() { onLoad() {
console.debug(...arguments) 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 * @param {object} event the event object containing the image
*/ */
processFile(event) { handleUploadedFile(event) {
if (event.target.files && !this.loading) { if (event.target.files && !this.loading) {
this.closeMenu() this.closeMenu()
const file = event.target.files[0] 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) { const reader = new FileReader()
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()
if (self.getMimetype(hex).startsWith('image/')) { reader.onload = (e) => {
type = self.getMimetype(hex) try {
// we got a valid image, read it again as base64 if (typeof e.target.result === 'object') {
reader.readAsDataURL(file)
return const data = Buffer.from(e.target.result, 'binary')
}
throw new Error('Wrong image mimetype') if (this.processPicture(data)) {
return
} }
// else we got the base64 and we're good to go! throw new Error('Wrong image mimetype')
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()
} }
}
// start by reading the magic bytes to detect proper photo mimetype } catch (error) {
const blob = file.slice(0, 4) console.error(error)
reader.readAsArrayBuffer(blob) showError(t('contacts', 'Invalid image'))
} else { } finally {
showError(t('contacts', 'Image is too big (max 1MB).')) this.resetPicker()
this.resetPicker() }
} }
reader.readAsArrayBuffer(file)
} }
}, },
@ -268,13 +319,19 @@ export default {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() 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 * @return {string} the mimetype
*/ */
getMimetype(signature) { getMimetype(uint) {
switch (signature) { const bytes = []
uint.slice(0, 4).forEach((byte) => {
bytes.push(byte.toString(16))
})
const hex = bytes.join('').toUpperCase()
switch (hex) {
case '89504E47': case '89504E47':
return 'image/png' return 'image/png'
case '47494638': 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 * 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() { selectFileInput() {
if (!this.loading) { if (!this.loading) {
this.$refs.uploadInput.click() this.$refs.uploadInput.click()
} }
}, },
/**
* Picker handlers from Files
*/
async selectFilePicker() { async selectFilePicker() {
if (!this.loading) { if (!this.loading) {
this.closeMenu()
const picker = getFilePickerBuilder(t('contacts', 'Pick an avatar')) const picker = getFilePickerBuilder(t('contacts', 'Pick an avatar'))
.setMimeTypeFilter([ .setMimeTypeFilter([
'image/png', 'image/png',
@ -368,19 +467,25 @@ export default {
.build() .build()
const file = await picker.pick() const file = await picker.pick()
if (file) { if (file) {
this.loading = true this.loading = true
try { try {
const response = await axios.get(`${this.root}${file}`, { const response = await axios.get(`${this.root}${file}`, {
responseType: 'arraybuffer', responseType: 'arraybuffer',
}) })
const type = response.headers['content-type']
const data = Buffer.from(response.data, 'binary').toString('base64') const data = Buffer.from(response.data, 'binary')
this.setPhoto(data, type)
this.processPicture(data)
} catch (error) { } catch (error) {
showError(t('contacts', 'Error while processing the picture.')) showError(t('contacts', 'Error while processing the picture.'))
console.error(error) console.error(error)
this.loading = false this.loading = false
} finally {
this.resetPicker()
} }
} }
} }
@ -436,6 +541,53 @@ export default {
} }
</script> </script>
<style lang="scss" scoped> <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 { .contact-header-avatar {
// Wrap and cut // Wrap and cut
&__wrapper { &__wrapper {

View File

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