20334 implement desktop camera upload feature (#335)

* wip: camera upload

* Handle camera permissions

* remove console logs

* fix camera theme, hide on small  screen,

* video sizing on camera  upload

* camera feature minor fixes

* Package.json update

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka 2024-03-25 11:00:00 +01:00 committed by GitHub
parent a32c183758
commit 659dc5086e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 218 additions and 12 deletions

View File

@ -3,14 +3,23 @@
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<div v-if="cameraUpload && isInWebcam" class="hidden sm:block w-full min-h-40">
<div class="flex w-full items-center justify-center transition-colors duration-40" :class="[{ <camera-upload v-if="cameraUpload" @uploadImage="cameraFileUpload" @stopWebcam="isInWebcam=false" :theme="theme"/>
'!cursor-not-allowed': disabled, 'cursor-pointer': !disabled, </div>
[theme.fileInput.inputHover.light + ' dark:' + theme.fileInput.inputHover.dark]: uploadDragoverEvent, <div v-else class="flex flex-col w-full items-center justify-center transition-colors duration-40"
['hover:' + theme.fileInput.inputHover.light + ' dark:hover:' + theme.fileInput.inputHover.dark]: !loading :class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
}, theme.fileInput.input]" @dragover.prevent="uploadDragoverEvent = true" [theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
@dragleave.prevent="uploadDragoverEvent = false" @drop.prevent="onUploadDropEvent" @click="openFileUpload"> ['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
<div v-if="loading" class="text-gray-600 dark:text-gray-400"> @dragover.prevent="uploadDragoverEvent=true"
@dragleave.prevent="uploadDragoverEvent=false"
@drop.prevent="onUploadDropEvent"
@click="openFileUpload"
>
<div class="flex w-full items-center justify-center">
<div
v-if="loading"
class="text-gray-600 dark:text-gray-400"
>
<Loader class="mx-auto h-6 w-6" /> <Loader class="mx-auto h-6 w-6" />
<p class="mt-2 text-center text-sm text-gray-500"> <p class="mt-2 text-center text-sm text-gray-500">
Uploading your file... Uploading your file...
@ -43,6 +52,15 @@
</div> </div>
</template> </template>
</div> </div>
<div class="w-full items-center justify-center mt-2 hidden sm:flex">
<open-form-button native-type="button" :loading="loading" :theme="theme" :color="color" class="py-2 p-1 px-2" @click.stop="openWebcam" v-if="cameraUpload">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
</svg>
</open-form-button>
</div>
</div>
<template #help> <template #help>
<slot name="help" /> <slot name="help" />
@ -57,15 +75,18 @@
import { inputProps, useFormInput } from './useFormInput.js' import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from './components/InputWrapper.vue' import InputWrapper from './components/InputWrapper.vue'
import UploadedFile from './components/UploadedFile.vue' import UploadedFile from './components/UploadedFile.vue'
import OpenFormButton from '../open/forms/OpenFormButton.vue'
import CameraUpload from './components/CameraUpload.vue'
import { storeFile } from "~/lib/file-uploads.js" import { storeFile } from "~/lib/file-uploads.js"
export default { export default {
name: 'FileInput', name: 'FileInput',
components: { InputWrapper, UploadedFile }, components: { InputWrapper, UploadedFile, OpenFormButton },
mixins: [], mixins: [],
props: { props: {
...inputProps, ...inputProps,
multiple: { type: Boolean, default: true }, multiple: { type: Boolean, default: true },
cameraUpload: { type: Boolean, default: false },
mbLimit: { type: Number, default: 5 }, mbLimit: { type: Number, default: 5 },
accept: { type: String, default: '' }, accept: { type: String, default: '' },
moveToFormAssets: { type: Boolean, default: false } moveToFormAssets: { type: Boolean, default: false }
@ -80,7 +101,8 @@ export default {
data: () => ({ data: () => ({
files: [], files: [],
uploadDragoverEvent: false, uploadDragoverEvent: false,
loading: false loading: false,
isInWebcam:false
}), }),
computed: { computed: {
@ -162,6 +184,17 @@ export default {
this.uploadFileToServer(files.item(i)) this.uploadFileToServer(files.item(i))
} }
}, },
openWebcam(){
if(!this.cameraUpload){
return;
}
this.isInWebcam = true;
},
cameraFileUpload(file){
this.isInWebcam = false
this.isUploading = false;
this.uploadFileToServer(file)
},
uploadFileToServer(file) { uploadFileToServer(file) {
if (this.disabled) return if (this.disabled) return
this.loading = true this.loading = true

View File

@ -0,0 +1,143 @@
<template>
<div class="relative border">
<video id="webcam" autoplay playsinline :class="[{ 'hidden': !isCapturing },theme.fileInput.cameraInput]" width="1280" height="720"></video>
<canvas id="canvas" :class="{ 'hidden': !capturedImage }"></canvas>
<div v-if="cameraPermissionStatus === 'allowed'" class="absolute inset-x-0 grid place-content-center bottom-2">
<div class=" p-2 px-4 flex items-center justify-center text-xs space-x-2" v-if="isCapturing">
<span class="cursor-pointer rounded-full w-14 h-14 border-2 grid place-content-center"
@click="processCapturedImage">
<span class="cursor-pointer bg-gray-100 rounded-full w-10 h-10 grid place-content-center">
</span>
</span>
<span class="text-white cursor-pointer" @click="cancelCamera">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</span>
</div>
</div>
<div v-else-if="cameraPermissionStatus === 'blocked'"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
@click="openCameraUpload">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
</svg>
<p class="text-center font-bold">
Allow Camera Permission
</p>
<p class="text-xs">You need to allow camera permission before you can take pictures. Go to browser settings to enable camera permission on this page.</p>
<button class="text-xs p-1 px-2 bg-blue-600 rounded" type="button" @click.stop="cancelCamera">Got it!</button>
</div>
<div v-else-if="cameraPermissionStatus === 'loading'"
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
>
<div class="w-6 h-6">
<Loader />
</div>
</div>
<div v-else
class="absolute p-5 top-0 inset-x-0 flex flex-col items-center justify-center space-y-4 text-center rounded border border-gray-400/30 h-full"
@click="openCameraUpload">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M12 18.75H4.5a2.25 2.25 0 0 1-2.25-2.25V9m12.841 9.091L16.5 19.5m-1.409-1.409c.407-.407.659-.97.659-1.591v-9a2.25 2.25 0 0 0-2.25-2.25h-9c-.621 0-1.184.252-1.591.659m12.182 12.182L2.909 5.909M1.5 4.5l1.409 1.409" />
</svg>
<p class="text-center font-bold">
Camera Device Error
</p>
<p class="text-xs">An unknown error occurred when trying to start Webcam device.</p>
<button class="text-xs p-1 px-2 bg-blue-600 rounded" type="button" @click.stop="cancelCamera">Go back</button>
</div>
</div>
</template>
<script>
import Webcam from 'webcam-easy';
import { themes } from '~/lib/forms/form-themes.js'
export default {
name: 'FileInput',
props:{
theme: { type: Object, default: () => themes.default }
},
data: () => ({
webcam: null,
isCapturing: false,
capturedImage: null,
cameraPermissionStatus: 'loading',
}),
computed: {
videoDisplay() {
return this.isCapturing ? '' : 'hidden';
},
canvasDisplay() {
return (!this.isCapturing && this.capturedImage) ? '' : 'hidden'
}
},
mounted() {
const webcamElement = document.getElementById('webcam');
const canvasElement = document.getElementById('canvas');
this.webcam = new Webcam(webcamElement, 'user', canvasElement);
this.openCameraUpload()
},
methods: {
openCameraUpload() {
this.isCapturing = true;
this.capturedImage = null;
this.webcam.start()
.then(result => {
this.cameraPermissionStatus = 'allowed';
})
.catch(err => {
console.log(err)
if(err.toString() === 'NotAllowedError: Permission denied'){
this.cameraPermissionStatus = 'blocked';
return;
}
this.cameraPermissionStatus = 'unknown';
});
},
cancelCamera() {
this.isCapturing = false;
this.capturedImage = null;
this.webcam.stop()
this.$emit('stopWebcam')
},
processCapturedImage() {
this.capturedImage = this.webcam.snap();
this.isCapturing = false;
this.webcam.stop()
const byteCharacters = atob(this.capturedImage.split(',')[1]);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
// Create Blob from binary data
const blob = new Blob(byteArrays, { type: 'image/png' });
const filename = Date.now()
// Create a File object from the Blob
const file = new File([blob], `${filename}.png`, { type: 'image/png' });
this.$emit('uploadImage', file)
}
}
}
</script>

View File

@ -272,6 +272,7 @@ export default {
} }
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) { } else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
inputProperties.multiple = (field.multiple !== undefined && field.multiple) inputProperties.multiple = (field.multiple !== undefined && field.multiple)
inputProperties.cameraUpload = (field.camera_upload !== undefined && field.camera_upload)
let maxFileSize = (this.form?.workspace && this.form?.workspace.max_file_size) ? this.form?.workspace?.max_file_size : 10 let maxFileSize = (this.form?.workspace && this.form?.workspace.max_file_size) ? this.form?.workspace?.max_file_size : 10
if (field?.max_file_size > 0) { if (field?.max_file_size > 0) {
maxFileSize = Math.min(field.max_file_size, maxFileSize) maxFileSize = Math.min(field.max_file_size, maxFileSize)

View File

@ -46,6 +46,11 @@
<v-checkbox v-model="field.multiple" class="mt-3" :name="field.id + '_multiple'"> <v-checkbox v-model="field.multiple" class="mt-3" :name="field.id + '_multiple'">
Allow multiple files Allow multiple files
</v-checkbox> </v-checkbox>
<v-checkbox v-model="field.camera_upload" class="mt-3"
:name="field.id+'_camera_upload'"
>
Allow Camera uploads
</v-checkbox>
<text-input name="allowed_file_types" class="mt-3" :form="field" label="Allowed file types" <text-input name="allowed_file_types" class="mt-3" :form="field" label="Allowed file types"
placeholder="jpg,jpeg,png,gif" help="Comma separated values, leave blank to allow all file types" /> placeholder="jpg,jpeg,png,gif" help="Comma separated values, leave blank to allow all file types" />

View File

@ -39,6 +39,7 @@ export const themes = {
}, },
fileInput: { fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded-lg', input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded-lg',
cameraInput: 'min-h-40 rounded-lg',
inputHover: { inputHover: {
light: 'bg-neutral-50', light: 'bg-neutral-50',
dark: 'bg-notion-dark-light' dark: 'bg-notion-dark-light'
@ -83,6 +84,7 @@ export const themes = {
}, },
fileInput: { fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4', input: 'min-h-40 border border-dashed border-gray-300 p-4',
cameraInput: 'min-h-40',
inputHover: { inputHover: {
light: 'bg-neutral-50', light: 'bg-neutral-50',
dark: 'bg-notion-dark-light' dark: 'bg-notion-dark-light'
@ -127,6 +129,7 @@ export const themes = {
}, },
fileInput: { fileInput: {
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded bg-notion-input-background', input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded bg-notion-input-background',
cameraInput: 'min-h-40 rounded',
inputHover: { inputHover: {
light: 'bg-neutral-50', light: 'bg-neutral-50',
dark: 'bg-notion-dark-light' dark: 'bg-notion-dark-light'

View File

@ -43,7 +43,8 @@
"vue-notion": "^3.0.0-beta.1", "vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2", "vue-signature-pad": "^3.0.2",
"vue3-editor": "^0.1.1", "vue3-editor": "^0.1.1",
"vuedraggable": "next" "vuedraggable": "next",
"webcam-easy": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/devtools": "latest", "@nuxt/devtools": "latest",
@ -12343,6 +12344,17 @@
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz",
"integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==" "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw=="
}, },
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/ultrahtml": { "node_modules/ultrahtml": {
"version": "1.5.3", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.5.3.tgz", "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.5.3.tgz",
@ -13510,6 +13522,14 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/webcam-easy": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webcam-easy/-/webcam-easy-1.1.1.tgz",
"integrity": "sha512-L2/tXBpWCdZAXI7s5SWB0BICiDATih9OHIhIZpkCWJX4lENF0UTsnqztAb+c0XPy63HoiLUpCHRhdMFaospzGw==",
"dependencies": {
"uglify-js": "^3.17.4"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@ -58,6 +58,7 @@
"vue-notion": "^3.0.0-beta.1", "vue-notion": "^3.0.0-beta.1",
"vue-signature-pad": "^3.0.2", "vue-signature-pad": "^3.0.2",
"vue3-editor": "^0.1.1", "vue3-editor": "^0.1.1",
"vuedraggable": "next" "vuedraggable": "next",
"webcam-easy": "^1.1.1"
} }
} }