Adding support for capturing images from a camera (#1595)

Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
Cocoa 2022-12-22 14:06:17 +00:00 committed by GitHub
parent 82d748a1ff
commit 262381fcc0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 229 additions and 28 deletions

View file

@ -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 }) => `
<button class="menu-item text-gray-500" role="menuitem" data-camera-id="${deviceId}">
<span class="font-medium">${label}</span>
</button>
`
)
.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) {

View file

@ -39,8 +39,9 @@ defmodule LivebookWeb.Output.ImageInputComponent do
~H"""
<div
id={"#{@id}-root"}
class="inline-flex p-4 border-2 border-dashed border-gray-200 rounded-lg cursor-pointer"
class="inline-flex flex-col p-4 border-2 border-dashed border-gray-200 rounded-lg"
phx-hook="ImageInput"
phx-update="ignore"
data-id={@id}
data-phx-target={@target}
data-height={@height}
@ -48,12 +49,50 @@ defmodule LivebookWeb.Output.ImageInputComponent do
data-format={@format}
data-fit={@fit}
>
<input type="file" data-input class="hidden" name="value" />
<div id={"#{@id}-preview"} phx-update="ignore" data-preview>
<div class="text-gray-500">
Drag an image file here or click to open file browser
<input type="file" data-input class="hidden" name="value" accept="image/*" capture="user" />
<div class="flex justify-center" data-preview>
<div class="flex justify-center text-gray-500">
Drag an image file
</div>
</div>
<div class="hidden flex justify-center" data-camera-preview></div>
<div class="mt-4 flex items-center justify-center gap-4">
<button
class="button-base button-gray border-transparent py-2 px-4 inline-flex text-gray-500"
data-btn-upload
>
<.remix_icon icon="upload-2-line" class="text-lg leading-none mr-2" />
<span>Upload</span>
</button>
<.menu id={"#{@id}-camera-select-menu"} position="bottom-left">
<:toggle>
<button
class="button-base button-gray border-transparent py-2 px-4 inline-flex text-gray-500"
data-btn-open-camera
>
<.remix_icon icon="camera-line" class="text-lg leading-none mr-2" />
<span>Open camera</span>
</button>
</:toggle>
<:content>
<div data-camera-list></div>
</:content>
</.menu>
<button
class="hidden button-base button-gray border-transparent py-2 px-4 inline-flex text-gray-500"
data-btn-capture-camera
>
<.remix_icon icon="camera-line" class="text-lg leading-none mr-2" />
<span>Take photo</span>
</button>
<button
class="hidden button-base button-gray border-transparent py-2 px-4 inline-flex text-gray-500"
data-btn-cancel
>
<.remix_icon icon="close-circle-line" class="text-lg leading-none mr-2" />
<span>Cancel</span>
</button>
</div>
</div>
"""
end