From 262381fcc0364eff72fa2ec15bf38fd85cdc78db Mon Sep 17 00:00:00 2001 From: Cocoa Date: Thu, 22 Dec 2022 14:06:17 +0000 Subject: [PATCH] Adding support for capturing images from a camera (#1595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jonatan Kłosko --- assets/js/hooks/image_input.js | 208 ++++++++++++++++-- .../live/output/image_input_component.ex | 49 ++++- 2 files changed, 229 insertions(+), 28 deletions(-) diff --git a/assets/js/hooks/image_input.js b/assets/js/hooks/image_input.js index 43e79e5ef..ba3623b92 100644 --- a/assets/js/hooks/image_input.js +++ b/assets/js/hooks/image_input.js @@ -24,11 +24,6 @@ const dropClasses = ["bg-yellow-100", "border-yellow-300"]; * * * `data-fit` - the fit strategy * - * The element should have the following children: - * - * * `[data-input]` - a file input used for file selection - * - * * `[data-preview]` - a container to put image preview in */ const ImageInput = { mounted() { @@ -37,6 +32,20 @@ const ImageInput = { this.inputEl = this.el.querySelector(`[data-input]`); this.previewEl = this.el.querySelector(`[data-preview]`); + this.cameraPreviewEl = this.el.querySelector(`[data-camera-preview]`); + this.cameraListEl = this.el.querySelector(`[data-camera-list]`); + + this.uploadButton = this.el.querySelector(`[data-btn-upload]`); + this.openCameraButton = this.el.querySelector(`[data-btn-open-camera]`); + this.captureCameraButton = this.el.querySelector( + `[data-btn-capture-camera]` + ); + this.cancelButton = this.el.querySelector(`[data-btn-cancel]`); + + this.cameraListPopulated = false; + this.cameraVideoEl = null; + this.cameraStream = null; + // Render initial value this.handleEvent(`image_input_init:${this.props.id}`, (imageInfo) => { const canvas = imageInfoToElement(imageInfo); @@ -45,7 +54,7 @@ const ImageInput = { // File selection - this.el.addEventListener("click", (event) => { + this.uploadButton.addEventListener("click", (event) => { this.inputEl.click(); }); @@ -67,6 +76,7 @@ const ImageInput = { event.preventDefault(); const [file] = event.dataTransfer.files; file && this.loadFile(file); + this.closeCameraView(); }); this.el.addEventListener("dragenter", (event) => { @@ -82,6 +92,39 @@ const ImageInput = { this.el.addEventListener("drop", (event) => { this.el.classList.remove(...dropClasses); }); + + // Camera capture + + this.openCameraButton.addEventListener("click", (event) => { + if (!this.cameraListPopulated) { + this.renderCameraList(); + this.cameraListPopulated = true; + } + }); + + this.cameraListEl.addEventListener("click", (event) => { + const button = event.target.closest(`[data-camera-id]`); + + if (button) { + const cameraId = button.dataset.cameraId; + this.openCameraView(cameraId); + } + }); + + this.captureCameraButton.addEventListener("click", (event) => { + const canvas = this.toCanvas( + this.cameraVideoEl, + this.cameraVideoEl.videoWidth, + this.cameraVideoEl.videoHeight + ); + this.setPreview(canvas); + this.pushImage(canvas); + this.closeCameraView(); + }); + + this.cancelButton.addEventListener("click", (event) => { + this.closeCameraView(); + }); }, updated() { @@ -106,17 +149,9 @@ const ImageInput = { const imgEl = document.createElement("img"); imgEl.addEventListener("load", (loadEvent) => { - const canvas = this.toCanvas(imgEl); - + const canvas = this.toCanvas(imgEl, imgEl.width, imgEl.height); this.setPreview(canvas); - - this.pushEventTo(this.props.phxTarget, "change", { - value: { - data: canvasToBase64(canvas, this.props.format), - height: canvas.height, - width: canvas.width, - }, - }); + this.pushImage(canvas); }); imgEl.src = readerEvent.target.result; @@ -125,8 +160,120 @@ const ImageInput = { reader.readAsDataURL(file); }, - toCanvas(imgEl) { - const { width, height } = imgEl; + openCameraView(targetCameraId) { + this.cameraPreviewEl.classList.remove("hidden"); + this.cancelButton.classList.remove("hidden"); + this.captureCameraButton.classList.remove("hidden"); + this.previewEl.classList.add("hidden"); + this.openCameraButton.classList.add("hidden"); + this.uploadButton.classList.add("hidden"); + + navigator.mediaDevices + .getUserMedia(this.cameraConstraints(targetCameraId)) + .then((stream) => { + this.cameraStream = stream; + + this.cameraVideoEl = document.createElement("video"); + this.cameraVideoEl.autoplay = true; + this.cameraVideoEl.playsinline = true; + this.cameraVideoEl.muted = true; + this.cameraVideoEl.srcObject = stream; + this.setCameraPreview(this.cameraVideoEl); + }) + .catch(() => {}); + }, + + cameraConstraints(targetCameraId) { + if (targetCameraId === "system_default") { + return { + audio: false, + video: true, + }; + } else { + return { + audio: false, + video: { deviceId: targetCameraId }, + }; + } + }, + + renderCameraList() { + // In Firefox we need to make sure media permissions are granted, + // then enumerate devices, and only then stop the stream; otherwise + // device labels are empty + navigator.mediaDevices + .getUserMedia({ audio: false, video: true }) + .then((stream) => { + return navigator.mediaDevices.enumerateDevices().then((devices) => { + this.stopMediaStream(stream); + return devices; + }); + }) + .then((devices) => { + const deviceOptions = devices + .filter((device) => device.kind === "videoinput") + .map((device) => ({ + deviceId: device.deviceId, + label: device.label, + })); + + this.cameraListEl.innerHTML = [ + { deviceId: "system_default", label: "System Default" }, + ...deviceOptions, + ] + .map( + ({ deviceId, label }) => ` + + ` + ) + .join(""); + }) + .catch((error) => { + console.error(error); + this.openCameraButton.disabled = true; + }); + }, + + closeCameraView() { + if (this.cameraStream !== null) { + this.stopMediaStream(this.cameraStream); + this.cameraStream = null; + } + + if (this.cameraVideoEl !== null) { + this.cameraVideoEl.remove(); + this.cameraVideoEl = null; + } + + this.cameraPreviewEl.classList.add("hidden"); + this.cancelButton.classList.add("hidden"); + this.captureCameraButton.classList.add("hidden"); + this.previewEl.classList.remove("hidden"); + this.openCameraButton.classList.remove("hidden"); + this.uploadButton.classList.remove("hidden"); + + return true; + }, + + stopMediaStream(mediaStream) { + mediaStream.getTracks().forEach((track) => { + track.stop(); + }); + }, + + pushImage(canvas) { + this.pushEventTo(this.props.phxTarget, "change", { + value: { + data: canvasToBase64(canvas, this.props.format), + height: canvas.height, + width: canvas.width, + }, + }); + }, + + toCanvas(imageEl, width, height) { const { width: boundWidth, height: boundHeight } = this.props; const canvas = document.createElement("canvas"); @@ -141,7 +288,7 @@ const ImageInput = { canvas .getContext("2d") - .drawImage(imgEl, 0, 0, width, height, 0, 0, width, height); + .drawImage(imageEl, 0, 0, width, height, 0, 0, width, height); } else if (this.props.fit === "contain") { const widthScale = boundWidth / width; const heightScale = boundHeight / height; @@ -154,7 +301,7 @@ const ImageInput = { canvas.height = scaledHeight; ctx.drawImage( - imgEl, + imageEl, 0, 0, width, @@ -176,7 +323,7 @@ const ImageInput = { canvas.height = boundHeight; ctx.drawImage( - imgEl, + imageEl, Math.round((scaledWidth - boundWidth) / scale / 2), Math.round((scaledHeight - boundHeight) / scale / 2), width - Math.round((scaledWidth - boundWidth) / scale), @@ -201,7 +348,7 @@ const ImageInput = { ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage( - imgEl, + imageEl, 0, 0, width, @@ -215,7 +362,17 @@ const ImageInput = { canvas.width = boundWidth; canvas.height = boundHeight; - ctx.drawImage(imgEl, 0, 0, width, height, 0, 0, boundWidth, boundHeight); + ctx.drawImage( + imageEl, + 0, + 0, + width, + height, + 0, + 0, + boundWidth, + boundHeight + ); } return canvas; @@ -225,6 +382,11 @@ const ImageInput = { element.style.maxHeight = "300px"; this.previewEl.replaceChildren(element); }, + + setCameraPreview(element) { + element.style.maxHeight = "300px"; + this.cameraPreviewEl.replaceChildren(element); + }, }; function canvasToBase64(canvas, format) { diff --git a/lib/livebook_web/live/output/image_input_component.ex b/lib/livebook_web/live/output/image_input_component.ex index 1461991d3..60c8d425a 100644 --- a/lib/livebook_web/live/output/image_input_component.ex +++ b/lib/livebook_web/live/output/image_input_component.ex @@ -39,8 +39,9 @@ defmodule LivebookWeb.Output.ImageInputComponent do ~H"""
- -
-
- Drag an image file here or click to open file browser + +
+
+ Drag an image file
+ +
+ + <.menu id={"#{@id}-camera-select-menu"} position="bottom-left"> + <:toggle> + + + <:content> +
+ + + + +
""" end