UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

566 lines (493 loc) 17.6 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomElement } from "../../dom/customelement.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { CameraCaptureStyleSheet } from "./stylesheet/camera-capture.mjs"; import "../form/button.mjs"; import "../state/state.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { addErrorAttribute } from "../../dom/error.mjs"; import { Queue } from "../../types/queue.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import "../layout/full-screen.mjs"; export { CameraCapture }; /** * @private * @type {symbol} */ const controlElementSymbol = Symbol("copyElement"); /** * @private * @type {symbol} */ const videoElementSymbol = Symbol("videoElement"); /** * @private * @type {symbol} */ const fullScreenElementSymbol = Symbol("fullScreenElement"); /** * @private * @type {symbol} */ const takePictureButtonElementSymbol = Symbol("takePictureButtonElement"); /** * @private * @type {symbol} */ const canvasElementSymbol = Symbol("canvasElement"); /** * @private * @type {symbol} */ const queueSymbol = Symbol("queue"); /** * @private * @type {symbol} */ const emptyHistoryStateElementSymbol = Symbol("emptyHistoryStateElement"); /** * This is a camera capture component. * * @fragments /fragments/components/content/camera-capture/ * * @example /examples/components/content/camera-capture-simple/ * * @since 3.111.0 * @copyright Volker Schukai * @summary A simple but powerful camera capture component. It can be used to capture images from the camera. * @fires monster-camera-capture-captured */ class CameraCapture extends CustomElement { /** * Constructor for the CameraCapture class. * Calls the parent class constructor. */ constructor() { super(); this[queueSymbol] = new Queue(); } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/content/camera-capture@instance", ); } /** * * @return {Components.Content.Copy */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); setupLazyCameraInit.call(this); return this; } /** * To set the options via the HTML Tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} actions Callbacks * @property {string} actions.click="throw Error" Callback when clicked * @property {Object} features Features * @property {boolean} features.stripTags=true Strip tags from the copied text * @property {boolean} features.preventOpenEventSent=false Prevent open event from being sent * @property {Object} popper Popper configuration * @property {string} popper.placement="top" Popper placement * @property {string[]} popper.middleware=["autoPlacement", "shift", "offset:15", "arrow"] Popper middleware * @property {boolean} disabled=false Disabled state */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, disabled: false, features: {}, labels: getTranslations(), }); } /** * @return {string} */ static getTag() { return "monster-camera-capture"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [CameraCaptureStyleSheet]; } /** * Retrieve the next image from the queue. * If the queue is empty, it returns `null`. * @returns {string|null} */ getNextImage() { if (!this[queueSymbol].isEmpty()) { const next = this[queueSymbol].poll(); if (!next) { return null; } return next; } return null; } /** * Capture an image from the camera and add it to the queue. * The image is returned as a data URL. * @returns {string} */ capture() { this[canvasElementSymbol].width = this[videoElementSymbol].videoWidth; this[canvasElementSymbol].height = this[videoElementSymbol].videoHeight; const ctx = this[canvasElementSymbol].getContext("2d"); ctx.drawImage( this[videoElementSymbol], 0, 0, this[canvasElementSymbol].width, this[canvasElementSymbol].height, ); const dataURL = this[canvasElementSymbol].toDataURL("image/png"); this[queueSymbol].add(dataURL); return dataURL; } } /** * @private */ function setupLazyCameraInit() { const self = this; let initialized = false; const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting && !initialized) { initialized = true; observer.disconnect(); initCameraControl.call(self); } } }, { root: null, threshold: 0.1, }, ); observer.observe(this); } /** * @private */ function initCameraControl() { const self = this; if ( !navigator || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia ) { self[fullScreenElementSymbol].style.display = "none"; addErrorAttribute(self, "Browser not supported"); return; } navigator.mediaDevices .getUserMedia({ video: true }) .then((stream) => { stream.getTracks().forEach((track) => track.stop()); return navigator.mediaDevices.enumerateDevices(); }) .then((devices) => { const cameras = devices.filter((device) => device.kind === "videoinput"); if (cameras.length === 0) { addErrorAttribute( self, getTranslations().cameraNotSupportedOrNotAllowed, ); return; } if (cameras.length > 1) { const select = document.createElement("select"); select.setAttribute("data-monster-role", "cameraSelector"); select.style.marginBottom = "0.5rem"; select.style.width = "100%"; select.style.maxWidth = "100%"; select.style.height = "1.4rem"; select.style.marginTop = "0"; select.style.marginBottom = "0"; cameras.forEach((camera, index) => { const option = document.createElement("option"); option.value = camera.deviceId; option.text = camera.label || `Camera ${index + 1}`; select.appendChild(option); }); select.addEventListener("change", () => { startCameraWithDeviceId.call(self, select.value); }); self[fullScreenElementSymbol].style.top = "2rem"; self[controlElementSymbol].insertBefore( select, self[videoElementSymbol], ); } startCameraWithDeviceId.call(self, cameras[0].deviceId); }) .catch((err) => { self[fullScreenElementSymbol].style.display = "none"; addErrorAttribute(self, err); }); } function startCameraWithDeviceId(deviceId) { const self = this; navigator.mediaDevices .getUserMedia({ video: { deviceId: { exact: deviceId } } }) .then(function (stream) { self[takePictureButtonElementSymbol].style.display = "block"; self[videoElementSymbol].style.display = "block"; self[emptyHistoryStateElementSymbol].style.display = "none"; self[videoElementSymbol].srcObject = stream; self[fullScreenElementSymbol].style.display = "block"; }) .catch(function (e) { self[fullScreenElementSymbol].style.display = "none"; addErrorAttribute(self, e); }); } /** * @private * @returns {{takePicture: string}} */ function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { takePicture: "Bild aufnehmen", cameraNotSupportedOrNotAllowed: "Die Kamera wird nicht unterstützt oder die Berechtigung wurde nicht erteilt.", }; case "es": return { takePicture: "Tomar una foto", cameraNotSupportedOrNotAllowed: "La cámara no es compatible o no se ha otorgado permiso.", }; case "zh": return { takePicture: "拍照", cameraNotSupportedOrNotAllowed: "相机不受支持或未授予权限。", }; case "hi": return { takePicture: "तस्वीर खींचें", cameraNotSupportedOrNotAllowed: "कैमरा समर्थित नहीं है या अनुमति नहीं दी गई है।", }; case "bn": return { takePicture: "ছবি তুলুন", cameraNotSupportedOrNotAllowed: "ক্যামেরা সমর্থিত নয় বা অনুমতি দেয়া হয়নি।", }; case "pt": return { takePicture: "Tirar uma foto", cameraNotSupportedOrNotAllowed: "A câmera não é suportada ou a permissão não foi concedida.", }; case "ru": return { takePicture: "Сделать фото", cameraNotSupportedOrNotAllowed: "Камера не поддерживается или разрешение не предоставлено.", }; case "ja": return { takePicture: "写真を撮る", cameraNotSupportedOrNotAllowed: "カメラがサポートされていないか、許可が付与されていません。", }; case "pa": return { takePicture: "ਤਸਵੀਰ ਖਿੱਚੋ", cameraNotSupportedOrNotAllowed: "ਕੈਮਰਾ ਦਾ ਸਮਰਥਨ ਨਹੀਂ ਹੈ ਜਾਂ ਅਨੁਮਤੀ ਨਹੀਂ ਦਿੱਤੀ ਗਈ ਹੈ।", }; case "mr": return { takePicture: "फोटो घ्या", cameraNotSupportedOrNotAllowed: "कॅमेरा समर्थित नाही किंवा परवानगी दिलेली नाही.", }; case "fr": return { takePicture: "Prendre une photo", cameraNotSupportedOrNotAllowed: "La caméra n'est pas prise en charge ou l'autorisation n'a pas été accordée.", }; case "it": return { takePicture: "Scattare una foto", cameraNotSupportedOrNotAllowed: "La fotocamera non è supportata o l'autorizzazione non è stata concessa.", }; case "nl": return { takePicture: "Maak een foto", cameraNotSupportedOrNotAllowed: "De camera wordt niet ondersteund of er is geen toestemming verleend.", }; case "sv": return { takePicture: "Ta ett foto", cameraNotSupportedOrNotAllowed: "Kameran stöds inte eller tillståndet har inte beviljats.", }; case "pl": return { takePicture: "Zrób zdjęcie", cameraNotSupportedOrNotAllowed: "Kamera nie jest obsługiwana lub nie udzielono zgody.", }; case "da": return { takePicture: "Tag et billede", cameraNotSupportedOrNotAllowed: "Kameraen understøttes ikke eller tilladelsen er ikke givet.", }; case "fi": return { takePicture: "Ota kuva", cameraNotSupportedOrNotAllowed: "Kameraa ei tueta tai lupaa ei ole myönnetty.", }; case "no": return { takePicture: "Ta et bilde", cameraNotSupportedOrNotAllowed: "Kameraen støttes ikke eller tillatelsen er ikke gitt.", }; case "cs": return { takePicture: "Vyfotit", cameraNotSupportedOrNotAllowed: "Fotoaparát není podporován nebo povolení nebylo uděleno.", }; case "en": default: return { takePicture: "Take a picture", cameraNotSupportedOrNotAllowed: "The camera is not supported or permission has not been granted.", }; } } /** * @private * @return {initEventHandler} * @fires monster-camera-capture-captured */ function initEventHandler() { const self = this; this[takePictureButtonElementSymbol].setOption("actions.click", function () { self.capture(); fireCustomEvent(self, "monster-camera-capture-captured", { element: self, }); }); return this; } /** * @private * @return {void} */ function initControlReferences() { this[controlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[takePictureButtonElementSymbol] = this.shadowRoot.querySelector( `[data-monster-role="takePicture"]`, ); this[videoElementSymbol] = this.shadowRoot.querySelector(`video`); this[canvasElementSymbol] = this.shadowRoot.querySelector(`canvas`); this[emptyHistoryStateElementSymbol] = this.shadowRoot.querySelector( `[data-monster-role="emptyHistoryState"]`, ); this[fullScreenElementSymbol] = this.shadowRoot.querySelector( `[data-monster-role="full-screen"]`, ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <monster-full-screen part="full-screen" style="display: none" data-monster-role="full-screen" data-monster-option-selector="[data-monster-role=control]"></monster-full-screen> <monster-state data-monster-role="emptyHistoryState"> <svg slot="visual" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="350" height="350" viewBox="0 0 92.604 92.604"> <defs> <linearGradient id="a" x1="336.587" x2="372.879" y1="218.625" y2="218.625" gradientUnits="userSpaceOnUse" spreadMethod="pad"> <stop offset="0" style="stop-color:#fefe00"/> <stop offset="1" style="stop-color:#ff43c6"/> </linearGradient> <linearGradient xlink:href="#a" id="p" x1="-2894.157" x2="-805.215" y1="-4285.143" y2="-2196.201" gradientTransform="translate(135.207 194.415)scale(.04433)" gradientUnits="userSpaceOnUse" spreadMethod="pad"/> <linearGradient xlink:href="#a" id="q" x1="-2894.157" x2="-805.215" y1="-4285.143" y2="-2196.201" gradientTransform="translate(135.207 194.415)scale(.04433)" gradientUnits="userSpaceOnUse" spreadMethod="pad"/> <linearGradient xlink:href="#a" id="o" x1="-2894.157" x2="-805.215" y1="-4285.143" y2="-2196.201" gradientTransform="translate(135.207 194.415)scale(.04433)" gradientUnits="userSpaceOnUse" spreadMethod="pad"/> </defs> <g style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:url(#o);fill-opacity:1;stroke-width:.0440952;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000;stop-opacity:1"> <path d="M8.983 34.64a9.33 9.33 0 0 1 9.33-9.33H74.29a9.33 9.33 0 0 1 9.33 9.33v13.995a9.33 9.33 0 0 1-9.33 9.33h-22.07c.673.755 1.54 1.51 2.478 2.215a32.7 32.7 0 0 0 4.23 2.66l.066.027.014.01a2.332 2.332 0 0 1-1.045 4.417H34.64a2.332 2.332 0 0 1-1.045-4.417l.014-.01.065-.033a23 23 0 0 0 1.25-.69 33 33 0 0 0 2.981-1.965c.933-.7 1.806-1.46 2.477-2.215h-22.07a9.33 9.33 0 0 1-9.329-9.33Zm9.33-4.665a4.665 4.665 0 0 0-4.665 4.665v13.995a4.665 4.665 0 0 0 4.665 4.664H74.29a4.665 4.665 0 0 0 4.665-4.664V34.64a4.665 4.665 0 0 0-4.665-4.665z" style="font-variation-settings:normal;vector-effect:none;fill:url(#p);fill-opacity:1;stroke-width:.0440952;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000;stop-opacity:1"/> <path d="M46.302 36.972a4.665 4.665 0 1 0 0 9.33 4.665 4.665 0 0 0 0-9.33m-9.33 4.665a9.33 9.33 0 1 1 18.66 0 9.33 9.33 0 0 1-18.66 0m32.655 0a2.332 2.332 0 1 1-4.665 0 2.332 2.332 0 0 1 4.665 0" style="font-variation-settings:normal;vector-effect:none;fill:url(#q);fill-opacity:1;stroke-width:.0440952;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000;stop-opacity:1"/> </g> </svg> <p class="camera-not-supported-text" data-monster-replace="path:labels.cameraNotSupportedOrNotAllowed | default:there was an error:string"></p> </monster-state> <video autoplay style="display:none"></video> <canvas style="display:none;"></canvas> <div> <monster-button part="takePictureButton" style="display:none" data-monster-role="takePicture" data-monster-attributes="classes.takePictureButton" data-monster-replace="path:labels.takePicture"></monster-button> </div> </div>`; } registerCustomElement(CameraCapture);