diff --git a/package-lock.json b/package-lock.json index ead2af68..dd32914b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0e87d966..1e153fec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index f7836401..dc7de849 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -29,7 +29,7 @@ type="file" class="hidden" accept="image/*" - @change="processFile"> + @change="handleUploadedFile"> + +
+

{{ t('contacts', 'Crop contact photo') }}

+ +
+ + {{ t('contacts', 'Cancel') }} + + + {{ t('contacts', 'Save') }} + +
+
+
+ { - 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 { }