mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-23 03:48:11 +08:00
Adding support for capturing images from a camera (#1595)
Co-authored-by: Jonatan Kłosko <jonatanklosko@gmail.com>
This commit is contained in:
parent
82d748a1ff
commit
262381fcc0
2 changed files with 229 additions and 28 deletions
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue