<template> <div v-if="visible" class="dialog-overlay z-[60]"> <div class="dialog relative"> <div class="text-lg text-center font-light relative z-50"> <div id="ViewHeading" class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none" > <span v-if="uploading"> Uploading... </span> <span v-else-if="blob"> Look Good? </span> <span v-else> Say "Cheese"! </span> </div> <div class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" @click="close()" > <fa icon="xmark" class="w-[1em]"></fa> </div> </div> <div v-if="uploading" class="flex justify-center"> <fa icon="spinner" class="fa-spin fa-3x text-center block px-12 py-12" /> </div> <div v-else-if="blob"> <div v-if="crop"> <VuePictureCropper :boxStyle="{ backgroundColor: '#f8f8f8', margin: 'auto', }" :img="createBlobURL(blob)" :options="{ viewMode: 1, dragMode: 'crop', aspectRatio: 9 / 9, }" class="max-h-[90vh] max-w-[90vw] object-contain" /> <!-- This gives a round cropper. :presetMode="{ mode: 'round', }" --> </div> <div v-else> <div class="flex justify-center"> <img :src="createBlobURL(blob)" class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain" /> </div> </div> <div class="absolute bottom-[1rem] left-[1rem] px-2 py-1"> <button @click="uploadImage" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" > <span>Upload</span> </button> </div> <div v-if="showRetry" class="absolute bottom-[1rem] right-[1rem] px-2 py-1" > <button @click="retryImage" class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md" > <span>Retry</span> </button> </div> </div> <div v-else ref="cameraContainer"> <!-- Camera "resolution" doesn't change how it shows on screen but rather stretches the result, eg the following which just stretches it vertically: :resolution="{ width: 375, height: 812 }" --> <camera facingMode="environment" autoplay ref="camera" @started="cameraStarted()" > <div class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center" > <button @click="takeImage()" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" > <fa icon="camera" class="w-[1em]"></fa> </button> </div> <div class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center" > <button @click="swapMirrorClass()" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" > <fa icon="left-right" class="w-[1em]"></fa> </button> </div> <div v-if="numDevices > 1" class="absolute bottom-2 right-4"> <button @click="switchCamera()" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" > <fa icon="rotate" class="w-[1em]"></fa> </button> </div> </camera> </div> </div> </div> </template> <script lang="ts"> import axios from "axios"; import Camera from "simple-vue-camera"; import { Component, Vue } from "vue-facing-decorator"; import VuePictureCropper, { cropper } from "vue-picture-cropper"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { getIdentity } from "@/libs/util"; import { db } from "@/db/index"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { accessToken } from "@/libs/crypto"; @Component({ components: { Camera, VuePictureCropper } }) export default class PhotoDialog extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; activeDeviceNumber = 0; activeDid = ""; blob?: Blob; claimType = ""; crop = false; fileName?: string; mirror = false; numDevices = 0; setImageCallback: (arg: string) => void = () => {}; showRetry = true; uploading = false; visible = false; URL = window.URL || window.webkitURL; async mounted() { try { await db.open(); const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings; this.activeDid = settings?.activeDid || ""; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { console.error("Error retrieving settings from database:", err); this.$notify( { group: "alert", type: "danger", title: "Error", text: err.message || "There was an error retrieving your settings.", }, -1, ); } } open( setImageFn: (arg: string) => void, claimType: string, crop?: boolean, blob?: Blob, // for image upload, just to use the cropping function inputFileName?: string, ) { this.visible = true; this.claimType = claimType; this.crop = !!crop; const bottomNav = document.querySelector("#QuickNav") as HTMLElement; if (bottomNav) { bottomNav.style.display = "none"; } this.setImageCallback = setImageFn; if (blob) { this.blob = blob; this.fileName = inputFileName; // doesn't make sense to retry the file upload; they can cancel if they picked the wrong one this.showRetry = false; } else { this.blob = undefined; this.fileName = undefined; this.showRetry = true; } } close() { this.visible = false; const bottomNav = document.querySelector("#QuickNav") as HTMLElement; if (bottomNav) { bottomNav.style.display = ""; } this.blob = undefined; } async cameraStarted() { const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; if (cameraComponent) { this.numDevices = (await cameraComponent.devices(["videoinput"])).length; this.mirror = cameraComponent.facingMode === "user"; // figure out which device is active const currentDeviceId = cameraComponent.currentDeviceID(); const devices = await cameraComponent.devices(["videoinput"]); this.activeDeviceNumber = devices.findIndex( (device) => device.deviceId === currentDeviceId, ); } } async switchCamera() { const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices; const devices = await cameraComponent?.devices(["videoinput"]); await cameraComponent?.changeCamera( devices[this.activeDeviceNumber].deviceId, ); } async takeImage(/* payload: MouseEvent */) { const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; /** * This logic to set the image height & width correctly. * Without it, the portrait orientation ends up with an image that is stretched horizontally. * Note that it's the same with raw browser Javascript; see the "drawImage" example below. * Now that I've done it, I can't explain why it works. */ let imageHeight = cameraComponent?.resolution?.height; let imageWidth = cameraComponent?.resolution?.width; const initialImageRatio = imageWidth / imageHeight; const windowRatio = window.innerWidth / window.innerHeight; if (initialImageRatio > 1 && windowRatio < 1) { // the image is wider than it is tall, and the window is taller than it is wide // For some reason, mobile in portrait orientation renders a horizontally-stretched image. // We're gonna force it opposite. imageHeight = cameraComponent?.resolution?.width; imageWidth = cameraComponent?.resolution?.height; } else if (initialImageRatio < 1 && windowRatio > 1) { // the image is taller than it is wide, and the window is wider than it is tall // Haven't seen this happen, but we'll do it just in case. imageHeight = cameraComponent?.resolution?.width; imageWidth = cameraComponent?.resolution?.height; } const newImageRatio = imageWidth / imageHeight; if (newImageRatio < windowRatio) { // the image is a taller ratio than the window, so fit the height first imageHeight = window.innerHeight / 2; imageWidth = imageHeight * newImageRatio; } else { // the image is a wider ratio than the window, so fit the width first imageWidth = window.innerWidth / 2; imageHeight = imageWidth / newImageRatio; } // The resolution is only necessary because of that mobile portrait-orientation case. // The mobile emulation in a browser shows something stretched vertically, but real devices work fine. this.blob = (await cameraComponent?.snapshot({ height: imageHeight, width: imageWidth, })) || undefined; // png is default this.fileName = "snapshot.png"; if (!this.blob) { this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error taking the picture. Please try again.", }, 5000, ); return; } } private createBlobURL(blob: Blob): string { return URL.createObjectURL(blob); } async retryImage() { this.blob = undefined; } /**** Here's an approach to photo capture without a library. It has similar quirks. Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday. <button id="start-camera" @click="cameraClicked">Start Camera</button> <video id="video" width="320" height="240" autoplay></video> <button id="snap-photo" @click="photoSnapped">Snap Photo</button> <canvas id="canvas" width="320" height="240"></canvas> async cameraClicked() { const video = document.querySelector("#video"); const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false, }); if (video instanceof HTMLVideoElement) { video.srcObject = stream; } } photoSnapped() { const video = document.querySelector("#video"); const canvas = document.querySelector("#canvas"); if ( canvas instanceof HTMLCanvasElement && video instanceof HTMLVideoElement ) { canvas ?.getContext("2d") ?.drawImage(video, 0, 0, canvas.width, canvas.height); // ... or set the blob: // canvas?.toBlob( // (blob) => { // this.blob = blob; // }, // "image/jpeg", // 1, // ); // data url of the image const image_data_url = canvas?.toDataURL("image/jpeg"); } } ****/ async uploadImage() { this.uploading = true; if (this.crop) { this.blob = (await cropper?.getBlob()) || undefined; } const token = await accessToken(this.activeDid); const headers = { Authorization: "Bearer " + token, }; const formData = new FormData(); if (!this.blob) { // yeah, this should never happen, but it helps with subsequent type checking this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error finding the picture. Please try again.", }, 5000, ); this.uploading = false; return; } formData.append("image", this.blob, this.fileName || "snapshot.png"); formData.append("claimType", this.claimType); try { const response = await axios.post( DEFAULT_IMAGE_API_SERVER + "/image", formData, { headers }, ); this.uploading = false; this.close(); this.setImageCallback(response.data.url as string); } catch (error) { console.error("Error uploading the image", error); this.$notify( { group: "alert", type: "danger", title: "Error", text: "There was an error saving the picture.", }, 5000, ); this.uploading = false; this.blob = undefined; } } swapMirrorClass() { this.mirror = !this.mirror; if (this.mirror) { (this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video"); } else { (this.$refs.cameraContainer as HTMLElement).classList.remove( "mirror-video", ); } } } </script> <style> .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; padding: 1.5rem; } .dialog { background-color: white; padding: 1rem; border-radius: 0.5rem; width: 100%; max-width: 700px; } .mirror-video { transform: scaleX(-1); -webkit-transform: scaleX(-1); /* For Safari */ -moz-transform: scaleX(-1); /* For Firefox */ -ms-transform: scaleX(-1); /* For IE */ -o-transform: scaleX(-1); /* For Opera */ } </style>