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:
parent
a32c183758
commit
659dc5086e
|
|
@ -3,14 +3,23 @@
|
|||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div class="flex w-full items-center justify-center transition-colors duration-40" :class="[{
|
||||
'!cursor-not-allowed': disabled, 'cursor-pointer': !disabled,
|
||||
[theme.fileInput.inputHover.light + ' dark:' + theme.fileInput.inputHover.dark]: uploadDragoverEvent,
|
||||
['hover:' + theme.fileInput.inputHover.light + ' dark:hover:' + theme.fileInput.inputHover.dark]: !loading
|
||||
}, theme.fileInput.input]" @dragover.prevent="uploadDragoverEvent = true"
|
||||
@dragleave.prevent="uploadDragoverEvent = false" @drop.prevent="onUploadDropEvent" @click="openFileUpload">
|
||||
<div v-if="loading" class="text-gray-600 dark:text-gray-400">
|
||||
<div v-if="cameraUpload && isInWebcam" class="hidden sm:block w-full min-h-40">
|
||||
<camera-upload v-if="cameraUpload" @uploadImage="cameraFileUpload" @stopWebcam="isInWebcam=false" :theme="theme"/>
|
||||
</div>
|
||||
<div v-else class="flex flex-col w-full items-center justify-center transition-colors duration-40"
|
||||
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
|
||||
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
|
||||
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
|
||||
@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" />
|
||||
<p class="mt-2 text-center text-sm text-gray-500">
|
||||
Uploading your file...
|
||||
|
|
@ -43,6 +52,15 @@
|
|||
</div>
|
||||
</template>
|
||||
</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>
|
||||
<slot name="help" />
|
||||
|
|
@ -57,15 +75,18 @@
|
|||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.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"
|
||||
|
||||
export default {
|
||||
name: 'FileInput',
|
||||
components: { InputWrapper, UploadedFile },
|
||||
components: { InputWrapper, UploadedFile, OpenFormButton },
|
||||
mixins: [],
|
||||
props: {
|
||||
...inputProps,
|
||||
multiple: { type: Boolean, default: true },
|
||||
cameraUpload: { type: Boolean, default: false },
|
||||
mbLimit: { type: Number, default: 5 },
|
||||
accept: { type: String, default: '' },
|
||||
moveToFormAssets: { type: Boolean, default: false }
|
||||
|
|
@ -80,7 +101,8 @@ export default {
|
|||
data: () => ({
|
||||
files: [],
|
||||
uploadDragoverEvent: false,
|
||||
loading: false
|
||||
loading: false,
|
||||
isInWebcam:false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
|
|
@ -162,6 +184,17 @@ export default {
|
|||
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) {
|
||||
if (this.disabled) return
|
||||
this.loading = true
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -272,6 +272,7 @@ export default {
|
|||
}
|
||||
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
|
||||
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
|
||||
if (field?.max_file_size > 0) {
|
||||
maxFileSize = Math.min(field.max_file_size, maxFileSize)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@
|
|||
<v-checkbox v-model="field.multiple" class="mt-3" :name="field.id + '_multiple'">
|
||||
Allow multiple files
|
||||
</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"
|
||||
placeholder="jpg,jpeg,png,gif" help="Comma separated values, leave blank to allow all file types" />
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const themes = {
|
|||
},
|
||||
fileInput: {
|
||||
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded-lg',
|
||||
cameraInput: 'min-h-40 rounded-lg',
|
||||
inputHover: {
|
||||
light: 'bg-neutral-50',
|
||||
dark: 'bg-notion-dark-light'
|
||||
|
|
@ -83,6 +84,7 @@ export const themes = {
|
|||
},
|
||||
fileInput: {
|
||||
input: 'min-h-40 border border-dashed border-gray-300 p-4',
|
||||
cameraInput: 'min-h-40',
|
||||
inputHover: {
|
||||
light: 'bg-neutral-50',
|
||||
dark: 'bg-notion-dark-light'
|
||||
|
|
@ -127,6 +129,7 @@ export const themes = {
|
|||
},
|
||||
fileInput: {
|
||||
input: 'min-h-40 border border-dashed border-gray-300 p-4 rounded bg-notion-input-background',
|
||||
cameraInput: 'min-h-40 rounded',
|
||||
inputHover: {
|
||||
light: 'bg-neutral-50',
|
||||
dark: 'bg-notion-dark-light'
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
"vue-notion": "^3.0.0-beta.1",
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-editor": "^0.1.1",
|
||||
"vuedraggable": "next"
|
||||
"vuedraggable": "next",
|
||||
"webcam-easy": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/devtools": "latest",
|
||||
|
|
@ -12343,6 +12344,17 @@
|
|||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz",
|
||||
"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": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.5.3.tgz",
|
||||
|
|
@ -13510,6 +13522,14 @@
|
|||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"vue-notion": "^3.0.0-beta.1",
|
||||
"vue-signature-pad": "^3.0.2",
|
||||
"vue3-editor": "^0.1.1",
|
||||
"vuedraggable": "next"
|
||||
"vuedraggable": "next",
|
||||
"webcam-easy": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue