diff --git a/app/about.html b/app/about.html
index 884976c0a..9c1f96221 100644
--- a/app/about.html
+++ b/app/about.html
@@ -71,10 +71,6 @@
-
diff --git a/app/index.html b/app/index.html
index 3960bf74e..58573db5f 100644
--- a/app/index.html
+++ b/app/index.html
@@ -71,10 +71,6 @@
-
diff --git a/app/scripts/dialog/help.mjs b/app/scripts/dialog/help.mjs
new file mode 100644
index 000000000..62d6f9647
--- /dev/null
+++ b/app/scripts/dialog/help.mjs
@@ -0,0 +1,20 @@
+export class QRCodeHelpDialog {
+
+ constructor(root) {
+ const elements = this.elements = {};
+ const rootElement = document.getElementById(root);
+ elements['close'] = rootElement.querySelector(".QRCodeAboutDialog-close");
+
+ elements['close'].addEventListener("click", function () {
+ this.closeDialog();
+ }.bind(this));
+ }
+
+ showDialog() {
+ root.style.display = 'block';
+ }
+
+ closeDialog() {
+ root.style.display = 'none';
+ }
+}
\ No newline at end of file
diff --git a/app/scripts/dialog/success.mjs b/app/scripts/dialog/success.mjs
new file mode 100644
index 000000000..806bc9d39
--- /dev/null
+++ b/app/scripts/dialog/success.mjs
@@ -0,0 +1,69 @@
+export class QRCodeSuccessDialog {
+ constructor(element) {
+ const elements = this._elements = {};
+ const root = elements['root'] = document.getElementById(element);
+ elements['qrcodeData'] = root.querySelector(".QRCodeSuccessDialog-data");
+ elements['qrcodeNavigate'] = root.querySelector(".QRCodeSuccessDialog-navigate");
+ elements['qrcodeIgnore'] = root.querySelector(".QRCodeSuccessDialog-ignore");
+ elements['qrcodeShare'] = root.querySelector(".QRCodeSuccessDialog-share");
+ elements['qrcodeCopy'] = root.querySelector(".QRCodeSuccessDialog-copy");
+
+ this.currentUrl = undefined;
+
+ if (navigator.share) {
+ // Sharing is supported so let's make the UI visible
+ elements['qrcodeShare'].classList.remove('hidden');
+ }
+
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ elements['qrcodeCopy'].classList.remove('hidden');
+ }
+
+ elements['qrcodeIgnore'].addEventListener("click", function () {
+ this.closeDialog();
+ }.bind(this));
+
+ elements['qrcodeShare'].addEventListener("click", function () {
+ if (navigator.share) {
+ navigator.share({
+ title: this.currentUrl,
+ text: this.currentUrl,
+ url: this.currentUrl
+ }).then(function () {
+ this.closeDialog();
+ }).catch(function () {
+ this.closeDialog();
+ })
+ }
+
+ }.bind(this));
+
+ elements['qrcodeCopy'].addEventListener("click", function () {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(this.currentUrl)
+ .then(this.closeDialog())
+ .catch(this.closeDialog());
+ }
+ this.closeDialog();
+ }.bind(this));
+
+ elements['qrcodeNavigate'].addEventListener("click", function () {
+ if (this.currentUrl.protocol === "javascript:") {
+ console.log("XSS prevented!");
+ return;
+ }
+ window.location = this.currentUrl;
+ this.closeDialog();
+ }.bind(this));
+ }
+
+ showDialog(normalizedUrl) {
+ this._elements['root'].style.display = 'block';
+ this._elements['qrcodeData'].innerText = normalizedUrl;
+ }
+
+ closeDialog() {
+ this._elements['root'].style.display = 'none';
+ this._elements['qrcodeData'].innerText = "";
+ }
+}
\ No newline at end of file
diff --git a/app/scripts/main.mjs b/app/scripts/main.mjs
index 30d6bde29..4125a8fe1 100644
--- a/app/scripts/main.mjs
+++ b/app/scripts/main.mjs
@@ -17,21 +17,50 @@
*
*/
-import { decode } from './qrclient.js'
+import { decode } from './qrclient.js';
+import { QRCodeHelpDialog as HelpDialog } from './dialog/help.mjs';
+import { QRCodeSuccessDialog as SuccessDialog } from './dialog/success.mjs';
+import { FallbackView } from './view/fallback.mjs';
+import { CameraView } from './view/camera.mjs';
+
+var normalizeUrl = function(url) {
+ // Remove leading/trailing white space from protocol, normalize casing, etc.
+ var normalized;
+ try {
+ normalized = new URL(url);
+ } catch (exception) {
+ return;
+ }
+ return normalized;
+};
+
+
+
(function() {
'use strict';
+
+ // This is the App.
var QRCodeCamera = function(element) {
// Controls the Camera and the QRCode Module
-
+
var cameraManager = new CameraManager('camera');
- var qrCodeManager = new QRCodeManager('qrcode');
- var qrCodeHelpManager = new QRCodeHelpManager('about');
+ var successDialog = new SuccessDialog('qrcode');
+ var helpDialog = new HelpDialog('about');
var helpButton = document.querySelector('.about');
helpButton.onclick = function() {
- qrCodeHelpManager.showDialog();
+ helpDialog.showDialog();
+ };
+
+ var detectQRCode = async function(context) {
+ let result = await decode(context);
+ let normalizedUrl;
+ if(result !== undefined) {
+ normalizedUrl = normalizeUrl(result);
+ }
+ return normalizedUrl;
};
var processingFrame = false;
@@ -40,449 +69,22 @@ import { decode } from './qrclient.js'
// There is a frame in the camera, what should we do with it?
if(processingFrame == false) {
processingFrame = true;
- let url = await qrCodeManager.detectQRCode(context);
+ let url = await detectQRCode(context);
processingFrame = false;
if(url === undefined) return;
if('ga' in window) ga('send', 'event', 'urlfound');
if('vibrate' in navigator) navigator.vibrate([200]);
- qrCodeManager.showDialog(url);
- }
- };
- };
-
- var QRCodeCallbackController = function(element) {
- var callbackName = element.querySelector(".QRCodeSuccessDialogCallback-name");
- var callbackDomain = element.querySelector(".QRCodeSuccessDialogCallback-domain");
- var callbackUrl;
- var qrcodeUrl;
- var isValidCallbackUrl = false;
-
- this.setQrCode = function(normalizedUrl) {
- qrcodeUrl = normalizedUrl;
- };
-
- var init = function() {
- callbackUrl = getCallbackURL();
- isValidCallbackUrl = validateCallbackURL(callbackUrl);
-
- if(callbackUrl) {
- element.addEventListener('click', function() {
- // Maybe we should warn if the callback URL is invalid
- callbackUrl.searchParams.set('qrcode', qrcodeUrl);
- location = callbackUrl;
- });
-
- element.classList.remove('hidden');
- if(isValidCallbackUrl == false) {
- callbackDomain.classList.add('invalid');
- }
- callbackDomain.innerText = callbackUrl.origin;
- }
- };
-
- var validateCallbackURL = function(callbackUrl) {
- if(document.referrer === "") return false;
-
- var referrer = new URL(document.referrer);
-
- return (callbackUrl !== undefined
- && referrer.origin == callbackUrl.origin
- && referrer.scheme !== 'https');
- };
-
- var getCallbackURL = function() {
- var url = new URL(window.location);
- if('searchParams' in url && url.searchParams.has('x-callback-url')) {
- // If the API is not supported, we should shim it. But right now
- // let's just get it working
- return new URL(url.searchParams.get('x-callback-url'));
- }
- };
-
- init();
- };
-
- var normalizeUrl = function(url) {
- // Remove leading/trailing white space from protocol, normalize casing, etc.
- var normalized;
- try {
- normalized = new URL(url);
- } catch (exception) {
- return;
- }
- return normalized;
- };
-
- var QRCodeManager = function(element) {
- var root = document.getElementById(element);
- var qrcodeData = root.querySelector(".QRCodeSuccessDialog-data");
- var qrcodeNavigate = root.querySelector(".QRCodeSuccessDialog-navigate");
- var qrcodeIgnore = root.querySelector(".QRCodeSuccessDialog-ignore");
- var qrcodeShare = root.querySelector(".QRCodeSuccessDialog-share");
- var qrcodeCopy = root.querySelector(".QRCodeSuccessDialog-copy");
- var qrcodeCallback = root.querySelector(".QRCodeSuccessDialog-callback");
- var callbackController = new QRCodeCallbackController(qrcodeCallback);
-
- var self = this;
-
- this.currentUrl = undefined;
-
- if(navigator.share) {
- // Sharing is supported so let's make the UI visible
- qrcodeShare.classList.remove('hidden');
- }
-
- if(navigator.clipboard && navigator.clipboard.writeText) {
- qrcodeCopy.classList.remove('hidden');
- }
-
- this.detectQRCode = async function(context) {
- let result = await decode(context);
- let normalizedUrl;
- if(result !== undefined) {
- normalizedUrl = normalizeUrl(result);
- self.currentUrl = normalizedUrl;
- }
- return normalizedUrl;
- };
-
- this.showDialog = function(normalizedUrl) {
- root.style.display = 'block';
- qrcodeData.innerText = normalizedUrl;
- callbackController.setQrCode(normalizedUrl);
- };
-
- this.closeDialog = function() {
- root.style.display = 'none';
- qrcodeData.innerText = "";
- };
-
- qrcodeIgnore.addEventListener("click", function() {
- this.closeDialog();
- }.bind(this));
-
- qrcodeShare.addEventListener("click", function() {
- if(navigator.share) {
- navigator.share({
- title: this.currentUrl,
- text: this.currentUrl,
- url: this.currentUrl
- }).then(function() {
- self.closeDialog();
- }).catch(function() {
- self.closeDialog();
- })
- }
-
- }.bind(this));
-
- qrcodeCopy.addEventListener("click", function() {
- if(navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(this.currentUrl)
- .then(self.closeDialog())
- .catch(self.closeDialog());
- }
- self.closeDialog();
- }.bind(this));
-
- qrcodeNavigate.addEventListener("click", function() {
- // I really want this to be a link.
-
- // Prevent XSS.
- // Note: there's no need to check for `jAvAsCrIpT:` etc. as
- // `normalizeUrl` already took care of that.
- if (this.currentUrl.protocol === "javascript:") {
- console.log("XSS prevented!");
- return;
- }
- window.location = this.currentUrl;
- this.closeDialog();
- }.bind(this));
-
- };
-
- let QRCodeHelpManager = function(element) {
- let root = document.getElementById(element);
- let qrhelpClose = root.querySelector(".QRCodeAboutDialog-close");
-
- this.showDialog = function() {
- root.style.display = 'block';
- };
-
- this.closeDialog = function() {
- root.style.display = 'none';
- };
-
- qrhelpClose.addEventListener("click", function() {
- this.closeDialog();
- }.bind(this));
- };
-
- var WebCamManager = function(cameraRoot) {
- var width, height;
- var cameraToggleInput = cameraRoot.querySelector('.Camera-toggle-input');
- var cameraToggle = cameraRoot.querySelector('.Camera-toggle');
- var cameraVideo = cameraRoot.querySelector('.Camera-video');
-
- this.resize = function(w, h) {
- if (w && h) {
- height = h;
- width = w;
- }
-
- var videoDimensions = this.getDimensions();
- cameraVideo.style.transform = 'translate(-50%, -50%) scale(' + videoDimensions.scaleFactor + ')';
- }.bind(this);
-
- var source = new CameraSource(cameraVideo);
-
- this.getDimensions = function() {
- var dimensions = source.getDimensions();
- var heightRatio = dimensions.height / height;
- var widthRatio = dimensions.width / width;
- var scaleFactor = 1 / Math.min(heightRatio, widthRatio);
- dimensions.scaleFactor = Number.isFinite(scaleFactor)? scaleFactor : 1;
- return dimensions;
- };
-
- // this method can be overwritten from outside
- this.onDimensionsChanged = function(){};
-
- source.onDimensionsChanged = function() {
- this.onDimensionsChanged();
- this.resize();
- }.bind(this);
-
- source.getCameras(function(cameras) {
- if(cameras.length <= 1) {
- cameraToggle.style.display="none";
+ successDialog.showDialog(url);
}
-
- // Set the source
- source.setCamera(0);
- });
-
- source.onframeready = function(imageData) {
- // The Source has some data, we need to push it the controller.
- this.onframeready(imageData);
- }.bind(this);
-
- cameraToggleInput.addEventListener('change', function(e) {
- // this is the input element, not the control
- var cameraIdx = 0;
-
- if(e.target.checked === true) {
- cameraIdx = 1;
- }
- source.stop();
- source.setCamera(cameraIdx);
- });
-
- this.stop = function() {
- source.stop();
- };
-
- this.start = function() {
- var cameraIdx = 0;
- if(cameraToggleInput.checked === true) {
- cameraIdx = 1;
- }
- source.setCamera(cameraIdx);
- };
-
- // When using the web cam, we need to turn it off when we aren't using it
- document.addEventListener('visibilitychange', function() {
- if(document.visibilityState === 'hidden') {
- // Disconnect the camera.
- this.stop();
- }
- else {
- this.start();
- }
- }.bind(this));
-
- };
-
- var CameraFallbackManager = function(element) {
- var uploadForm = element.querySelector('.CameraFallback-form');
- var inputElement = element.querySelector('.CameraFallback-input');
- var image = new Image();
-
- // these methods can be overwritten from outside
- this.onframeready = function() {};
- this.onDimensionsChanged = function(){};
-
- // these methods are noop for the fallback
- this.resize = function() {};
-
- // We don't need to upload anything.
- uploadForm.addEventListener('submit', function(e) {
- e.preventDefault();
- return false;
- });
-
- inputElement.addEventListener('change', function(e) {
- var objectURL = URL.createObjectURL(e.target.files[0]);
- image.onload = function() {
- this.onDimensionsChanged();
- this.onframeready(image);
- URL.revokeObjectURL(objectURL);
- }.bind(this);
-
- image.src = objectURL;
-
- }.bind(this));
-
- this.getDimensions = function() {
- return {
- width: image.naturalWidth,
- height: image.naturalHeight,
- scaleFactor: 1
- };
};
};
- var CameraSource = function(videoElement) {
- var stream;
- var animationFrameId;
- var cameras = null;
- var self = this;
- var useMediaDevices = ('mediaDevices' in navigator
- && 'enumerateDevices' in navigator.mediaDevices
- && 'getUserMedia' in navigator.mediaDevices);
- var gUM = (navigator.getUserMedia ||
- navigator.webkitGetUserMedia ||
- navigator.mozGetUserMedia ||
- navigator.msGetUserMedia || null);
- var currentCamera = -1;
-
- this.stop = function() {
- currentCamera = -1;
- if(stream) {
- stream.getTracks().forEach(function(t) { t.stop(); } );
- }
- };
-
- this.getDimensions = function() {
- return {
- width: videoElement.videoWidth,
- height: videoElement.videoHeight
- };
- };
-
- // this method can be overwritten from outside
- this.onDimensionsChanged = function(){};
-
- this.getCameras = function(cb) {
- cb = cb || function() {};
-
- if('enumerateDevices' in navigator.mediaDevices) {
- navigator.mediaDevices.enumerateDevices()
- .then(function(sources) {
- return sources.filter(function(source) {
- return source.kind == 'videoinput'
- });
- })
- .then(function(sources) {
- cameras = [];
- sources.forEach(function(source) {
- if(source.label.indexOf('facing back') >= 0) {
- // move front facing to the front.
- cameras.unshift(source);
- }
- else {
- cameras.push(source);
- }
- });
-
- cb(cameras);
- })
- .catch(error => {
- console.error("Enumeration Error", error);
- });
- }
- else if('getSources' in MediaStreamTrack) {
- MediaStreamTrack.getSources(function(sources) {
- cameras = [];
- for(var i = 0; i < sources.length; i++) {
- var source = sources[i];
- if(source.kind === 'video') {
-
- if(source.facing === 'environment') {
- // cameras facing the environment are pushed to the front of the page
- cameras.unshift(source);
- }
- else {
- cameras.push(source);
- }
- }
- }
- cb(cameras);
- });
- }
- else {
- // We can't pick the correct camera because the API doesn't support it.
- cameras = [];
- cb(cameras);
- }
- };
-
- this.setCamera = function(idx) {
- if (currentCamera === idx || cameras === null) {
- return;
- }
- currentCamera = idx;
- var params;
- var videoSource = cameras[idx];
-
- //Cancel any pending frame analysis
- cancelAnimationFrame(animationFrameId);
-
- if(videoSource === undefined && cameras.length == 0) {
- // Because we have no source information, have to assume it user facing.
- params = { video: true, audio: false };
- }
- else {
- params = { video: { deviceId: { exact: videoSource.deviceId || videoSource.id } }, audio: false };
- }
-
- let selectStream = function(cameraStream) {
- stream = cameraStream;
-
- videoElement.addEventListener('loadeddata', function(e) {
- var onframe = function() {
- if(videoElement.videoWidth > 0) self.onframeready(videoElement);
- if (currentCamera !== -1) {
- // if the camera is still running
- animationFrameId = requestAnimationFrame(onframe);
- }
- };
-
- self.onDimensionsChanged();
-
- animationFrameId = requestAnimationFrame(onframe);
- });
-
- videoElement.srcObject = stream;
- videoElement.load();
- videoElement.play()
- .catch(error => {
- console.error("Auto Play Error", error);
- });
- }
-
- if (useMediaDevices) {
- navigator.mediaDevices.getUserMedia(params)
- .then(selectStream)
- .catch(console.error);
- }
- else {
- gUM.call(navigator, params, selectStream, console.error);
- }
- };
- };
+
+
+
+ // This is really the Controller.
var CameraManager = function(element) {
// The camera gets a video stream, and adds it to a canvas.
// The canvas is analysed but also displayed to the user.
@@ -498,24 +100,23 @@ import { decode } from './qrclient.js'
if(location.hash == "#canvasdebug") debug = true;
var root = document.getElementById(element);
- var cameraRoot;
var sourceManager;
+ let onframeready = function(frameData) {
+ // Work out which part of the video to capture and apply to canvas.
+ context.drawImage(frameData, sx, sy, sWidth, sHeight, 0, 0, dWidth, dHeight);
+ if(self.onframe) self.onframe(context);
+ };
+
// Where are we getting the data from
if(gUMPresent === false) {
- cameraRoot = root.querySelector('.CameraFallback');
- sourceManager = new CameraFallbackManager(cameraRoot);
+ sourceManager = new FallbackView('.CameraFallback', onframeready);
}
else {
- cameraRoot = root.querySelector('.CameraRealtime');
- sourceManager = new WebCamManager(cameraRoot);
- }
-
- if(debug) {
- root.classList.add('debug');
+ sourceManager = new CameraView('.CameraRealtime', onframeready);
}
- cameraRoot.classList.remove('hidden');
+ sourceManager.show();
var cameraCanvas = root.querySelector('.Camera-display');
var cameraOverlay = root.querySelector('.Camera-overlay');
@@ -530,14 +131,7 @@ import { decode } from './qrclient.js'
var sHeight;
var sWidth;
- var cameras = [];
- var prevCoordinates = 0;
-
- sourceManager.onframeready = function(frameData) {
- // Work out which part of the video to capture and apply to canvas.
- context.drawImage(frameData, sx, sy, sWidth, sHeight, 0, 0, dWidth, dHeight);
- if(self.onframe) self.onframe(context);
- };
+ // Abstract the Overlay as a UI component
var getOverlayDimensions = function(width, height) {
var minLength = Math.min(width, height);
diff --git a/app/scripts/view/base.mjs b/app/scripts/view/base.mjs
new file mode 100644
index 000000000..37050cdab
--- /dev/null
+++ b/app/scripts/view/base.mjs
@@ -0,0 +1,20 @@
+export class BaseView {
+
+ constructor(root, onframeready) {
+ this.root = document.querySelector(root);
+ this.onframeready = onframeready;
+ this.elements = {};
+ }
+
+ show() {
+ this.root.classList.remove('hidden');
+ }
+
+ resize() { }
+
+ onframeready(frameData) {
+ this.onframeready(frameData)
+ }
+
+ onDimensionsChanged() {}
+}
\ No newline at end of file
diff --git a/app/scripts/view/camera.mjs b/app/scripts/view/camera.mjs
new file mode 100644
index 000000000..b1d262a6d
--- /dev/null
+++ b/app/scripts/view/camera.mjs
@@ -0,0 +1,231 @@
+import { BaseView } from './base.mjs';
+
+
+var CameraSource = function (videoElement) {
+ var stream;
+ var animationFrameId;
+ var cameras = null;
+ var self = this;
+ var useMediaDevices = ('mediaDevices' in navigator
+ && 'enumerateDevices' in navigator.mediaDevices
+ && 'getUserMedia' in navigator.mediaDevices);
+ var gUM = (navigator.getUserMedia ||
+ navigator.webkitGetUserMedia ||
+ navigator.mozGetUserMedia ||
+ navigator.msGetUserMedia || null);
+ var currentCamera = -1;
+
+ this.stop = function () {
+ currentCamera = -1;
+ if (stream) {
+ stream.getTracks().forEach(function (t) { t.stop(); });
+ }
+ };
+
+ this.getDimensions = function () {
+ return {
+ width: videoElement.videoWidth,
+ height: videoElement.videoHeight
+ };
+ };
+
+ // this method can be overwritten from outside
+ this.onDimensionsChanged = function () { };
+
+ this.getCameras = function (cb) {
+ cb = cb || function () { };
+
+ if ('enumerateDevices' in navigator.mediaDevices) {
+ navigator.mediaDevices.enumerateDevices()
+ .then(function (sources) {
+ return sources.filter(function (source) {
+ return source.kind == 'videoinput'
+ });
+ })
+ .then(function (sources) {
+ cameras = [];
+ sources.forEach(function (source) {
+ if (source.label.indexOf('facing back') >= 0) {
+ // move front facing to the front.
+ cameras.unshift(source);
+ }
+ else {
+ cameras.push(source);
+ }
+ });
+
+ cb(cameras);
+ })
+ .catch(error => {
+ console.error("Enumeration Error", error);
+ });
+ }
+ else if ('getSources' in MediaStreamTrack) {
+ MediaStreamTrack.getSources(function (sources) {
+ cameras = [];
+ for (var i = 0; i < sources.length; i++) {
+ var source = sources[i];
+ if (source.kind === 'video') {
+
+ if (source.facing === 'environment') {
+ // cameras facing the environment are pushed to the front of the page
+ cameras.unshift(source);
+ }
+ else {
+ cameras.push(source);
+ }
+ }
+ }
+ cb(cameras);
+ });
+ }
+ else {
+ // We can't pick the correct camera because the API doesn't support it.
+ cameras = [];
+ cb(cameras);
+ }
+ };
+
+ this.setCamera = function (idx) {
+ if (currentCamera === idx || cameras === null) {
+ return;
+ }
+ currentCamera = idx;
+ var params;
+ var videoSource = cameras[idx];
+
+ //Cancel any pending frame analysis
+ cancelAnimationFrame(animationFrameId);
+
+ if (videoSource === undefined && cameras.length == 0) {
+ // Because we have no source information, have to assume it user facing.
+ params = { video: true, audio: false };
+ }
+ else {
+ params = { video: { deviceId: { exact: videoSource.deviceId || videoSource.id } }, audio: false };
+ }
+
+ let selectStream = function (cameraStream) {
+ stream = cameraStream;
+
+ videoElement.addEventListener('loadeddata', function (e) {
+ var onframe = function () {
+ if (videoElement.videoWidth > 0) self.onframeready(videoElement);
+ if (currentCamera !== -1) {
+ // if the camera is still running
+ animationFrameId = requestAnimationFrame(onframe);
+ }
+ };
+
+ self.onDimensionsChanged();
+
+ animationFrameId = requestAnimationFrame(onframe);
+ });
+
+ videoElement.srcObject = stream;
+ videoElement.load();
+ videoElement.play()
+ .catch(error => {
+ console.error("Auto Play Error", error);
+ });
+ }
+
+ if (useMediaDevices) {
+ navigator.mediaDevices.getUserMedia(params)
+ .then(selectStream)
+ .catch(console.error);
+ }
+ else {
+ gUM.call(navigator, params, selectStream, console.error);
+ }
+ };
+};
+
+export class CameraView extends BaseView {
+
+ constructor(element, onframeready) {
+ super(element, onframeready);
+
+ this.width = undefined;
+ this.height = undefined;
+
+ this.elements['cameraToggleInput'] = this.root.querySelector('.Camera-toggle-input');
+ this.elements['cameraToggle'] = this.root.querySelector('.Camera-toggle');
+ this.elements['cameraVideo'] = this.root.querySelector('.Camera-video');
+
+ this.source = new CameraSource(this.elements['cameraVideo']);
+
+ this.source.onDimensionsChanged = function () {
+ this.onDimensionsChanged();
+ this.resize();
+ }.bind(this);
+
+ this.source.getCameras(function (cameras) {
+ if (cameras.length <= 1) {
+ this.elements['cameraToggle'].style.display = "none";
+ }
+
+ // Set the source
+ this.source.setCamera(0);
+ }.bind(this));
+
+ this.source.onframeready = function (imageData) {
+ // The Source has some data, we need to push it the controller.
+ this.onframeready(imageData);
+ }.bind(this);
+
+ this.elements['cameraToggleInput'].addEventListener('change', function (e) {
+ // this is the input element, not the control
+ var cameraIdx = 0;
+
+ if (e.target.checked === true) {
+ cameraIdx = 1;
+ }
+ this.source.stop();
+ this.source.setCamera(cameraIdx);
+ });
+
+ this.stop = function () {
+ this.source.stop();
+ };
+
+ this.start = function () {
+ var cameraIdx = 0;
+ if (this.elements['cameraToggleInput'].checked === true) {
+ cameraIdx = 1;
+ }
+ this.source.setCamera(cameraIdx);
+ };
+
+ // When using the web cam, we need to turn it off when we aren't using it
+ document.addEventListener('visibilitychange', function () {
+ if (document.visibilityState === 'hidden') {
+ // Disconnect the camera.
+ this.stop();
+ }
+ else {
+ this.start();
+ }
+ }.bind(this));
+
+ }
+
+ resize(w, h) {
+ if (w && h) {
+ this.height = h;
+ this.width = w;
+ }
+
+ const videoDimensions = this.getDimensions();
+ this.elements['cameraVideo'].style.transform = 'translate(-50%, -50%) scale(' + videoDimensions.scaleFactor + ')';
+ }
+
+ getDimensions() {
+ var dimensions = this.source.getDimensions();
+ var heightRatio = dimensions.height / this.height;
+ var widthRatio = dimensions.width / this.width;
+ var scaleFactor = 1 / Math.min(heightRatio, widthRatio);
+ dimensions.scaleFactor = Number.isFinite(scaleFactor) ? scaleFactor : 1;
+ return dimensions;
+ }
+}
diff --git a/app/scripts/view/fallback.mjs b/app/scripts/view/fallback.mjs
new file mode 100644
index 000000000..488fe7d07
--- /dev/null
+++ b/app/scripts/view/fallback.mjs
@@ -0,0 +1,37 @@
+import {BaseView} from './base.mjs';
+
+export class FallbackView extends BaseView {
+ constructor(element, onframeready) {
+ super(element, onframeready);
+
+ var uploadForm = this.root.querySelector('.CameraFallback-form');
+ var inputElement = this.root.querySelector('.CameraFallback-input');
+ var image = new Image();
+
+ // We don't need to upload anything.
+ uploadForm.addEventListener('submit', function(e) {
+ e.preventDefault();
+ return false;
+ });
+
+ inputElement.addEventListener('change', function(e) {
+ var objectURL = URL.createObjectURL(e.target.files[0]);
+ image.onload = function() {
+ this.onDimensionsChanged();
+ this.onframeready(image);
+ URL.revokeObjectURL(objectURL);
+ }.bind(this);
+
+ image.src = objectURL;
+
+ }.bind(this));
+
+ this.getDimensions = function() {
+ return {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ scaleFactor: 1
+ };
+ };
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index ece1c1a06..6f8a9dce2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2392,9 +2392,9 @@
},
"dependencies": {
"mime-db": {
- "version": "1.39.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.39.0.tgz",
- "integrity": "sha512-DTsrw/iWVvwHH+9Otxccdyy0Tgiil6TWK/xhfARJZF/QFhwOgZgOIvA2/VIGpM8U7Q8z5nDmdDWC6tuVMJNibw=="
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
+ "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
}
}
},
@@ -2921,24 +2921,47 @@
"dev": true
},
"del": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz",
- "integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/del/-/del-4.1.0.tgz",
+ "integrity": "sha512-C4kvKNlYrwXhKxz97BuohF8YoGgQ23Xm9lvoHmgT7JaPGprSEjk3+XFled74Yt/x0ZABUHg2D67covzAPUKx5Q==",
"dev": true,
"requires": {
"globby": "^6.1.0",
- "is-path-cwd": "^1.0.0",
- "is-path-in-cwd": "^1.0.0",
- "p-map": "^1.1.1",
- "pify": "^3.0.0",
- "rimraf": "^2.2.8"
+ "is-path-cwd": "^2.0.0",
+ "is-path-in-cwd": "^2.0.0",
+ "p-map": "^2.0.0",
+ "pify": "^4.0.1",
+ "rimraf": "^2.6.3"
},
"dependencies": {
+ "glob": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
+ "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
"pify": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
- "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
+ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true
+ },
+ "rimraf": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
+ "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
}
}
},
@@ -3925,14 +3948,14 @@
"dev": true
},
"fsevents": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz",
- "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==",
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.8.tgz",
+ "integrity": "sha512-tPvHgPGB7m40CZ68xqFGkKuzN+RnpGmSV+hgeKxhRpbxdqKXUFJGC3yonBOLzQBcJyGpdZFDfCsdOC2KFsXzeA==",
"dev": true,
"optional": true,
"requires": {
- "nan": "^2.9.2",
- "node-pre-gyp": "^0.10.0"
+ "nan": "^2.12.1",
+ "node-pre-gyp": "^0.12.0"
},
"dependencies": {
"abbrev": {
@@ -3954,7 +3977,7 @@
"optional": true
},
"are-we-there-yet": {
- "version": "1.1.4",
+ "version": "1.1.5",
"bundled": true,
"dev": true,
"optional": true,
@@ -3980,7 +4003,7 @@
}
},
"chownr": {
- "version": "1.0.1",
+ "version": "1.1.1",
"bundled": true,
"dev": true,
"optional": true
@@ -4010,16 +4033,16 @@
"optional": true
},
"debug": {
- "version": "2.6.9",
+ "version": "4.1.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "ms": "2.0.0"
+ "ms": "^2.1.1"
}
},
"deep-extend": {
- "version": "0.5.1",
+ "version": "0.6.0",
"bundled": true,
"dev": true,
"optional": true
@@ -4068,7 +4091,7 @@
}
},
"glob": {
- "version": "7.1.2",
+ "version": "7.1.3",
"bundled": true,
"dev": true,
"optional": true,
@@ -4088,12 +4111,12 @@
"optional": true
},
"iconv-lite": {
- "version": "0.4.21",
+ "version": "0.4.24",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "safer-buffer": "^2.1.0"
+ "safer-buffer": ">= 2.1.2 < 3"
}
},
"ignore-walk": {
@@ -4158,17 +4181,17 @@
"optional": true
},
"minipass": {
- "version": "2.2.4",
+ "version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "safe-buffer": "^5.1.1",
+ "safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
}
},
"minizlib": {
- "version": "1.1.0",
+ "version": "1.2.1",
"bundled": true,
"dev": true,
"optional": true,
@@ -4186,35 +4209,35 @@
}
},
"ms": {
- "version": "2.0.0",
+ "version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
},
"needle": {
- "version": "2.2.0",
+ "version": "2.3.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "debug": "^2.1.2",
+ "debug": "^4.1.0",
"iconv-lite": "^0.4.4",
"sax": "^1.2.4"
}
},
"node-pre-gyp": {
- "version": "0.10.0",
+ "version": "0.12.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"detect-libc": "^1.0.2",
"mkdirp": "^0.5.1",
- "needle": "^2.2.0",
+ "needle": "^2.2.1",
"nopt": "^4.0.1",
"npm-packlist": "^1.1.6",
"npmlog": "^4.0.2",
- "rc": "^1.1.7",
+ "rc": "^1.2.7",
"rimraf": "^2.6.1",
"semver": "^5.3.0",
"tar": "^4"
@@ -4231,13 +4254,13 @@
}
},
"npm-bundled": {
- "version": "1.0.3",
+ "version": "1.0.6",
"bundled": true,
"dev": true,
"optional": true
},
"npm-packlist": {
- "version": "1.1.10",
+ "version": "1.4.1",
"bundled": true,
"dev": true,
"optional": true,
@@ -4314,12 +4337,12 @@
"optional": true
},
"rc": {
- "version": "1.2.7",
+ "version": "1.2.8",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "deep-extend": "^0.5.1",
+ "deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
@@ -4349,16 +4372,16 @@
}
},
"rimraf": {
- "version": "2.6.2",
+ "version": "2.6.3",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "glob": "^7.0.5"
+ "glob": "^7.1.3"
}
},
"safe-buffer": {
- "version": "5.1.1",
+ "version": "5.1.2",
"bundled": true,
"dev": true,
"optional": true
@@ -4376,7 +4399,7 @@
"optional": true
},
"semver": {
- "version": "5.5.0",
+ "version": "5.7.0",
"bundled": true,
"dev": true,
"optional": true
@@ -4429,17 +4452,17 @@
"optional": true
},
"tar": {
- "version": "4.4.1",
+ "version": "4.4.8",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "chownr": "^1.0.1",
+ "chownr": "^1.1.1",
"fs-minipass": "^1.2.5",
- "minipass": "^2.2.4",
- "minizlib": "^1.1.0",
+ "minipass": "^2.3.4",
+ "minizlib": "^1.1.1",
"mkdirp": "^0.5.0",
- "safe-buffer": "^5.1.1",
+ "safe-buffer": "^5.1.2",
"yallist": "^3.0.2"
}
},
@@ -4450,12 +4473,12 @@
"optional": true
},
"wide-align": {
- "version": "1.1.2",
+ "version": "1.1.3",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
- "string-width": "^1.0.2"
+ "string-width": "^1.0.2 || 2"
}
},
"wrappy": {
@@ -4465,7 +4488,7 @@
"optional": true
},
"yallist": {
- "version": "3.0.2",
+ "version": "3.0.3",
"bundled": true,
"dev": true,
"optional": true
@@ -6131,9 +6154,9 @@
}
},
"js-yaml": {
- "version": "3.13.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.0.tgz",
- "integrity": "sha512-pZZoSxcCYco+DIKBTimr67J6Hy+EYGZDY/HCWC+iAEA9h1ByhMXAIVUXMcMFpOCxQ/xjXmPI2MkDL5HRm5eFrQ==",
+ "version": "3.13.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
+ "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
"dev": true,
"optional": true,
"requires": {
@@ -6489,27 +6512,27 @@
"optional": true
},
"is-path-cwd": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz",
- "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.1.0.tgz",
+ "integrity": "sha512-Sc5j3/YnM8tDeyCsVeKlm/0p95075DyLmDEIkSgQ7mXkrOX+uTCtmQFm0CYzVyJwcCCmO3k8qfJt17SxQwB5Zw==",
"dev": true
},
"is-path-in-cwd": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz",
- "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-2.1.0.tgz",
+ "integrity": "sha512-rNocXHgipO+rvnP6dk3zI20RpOtrAM/kzbB258Uw5BWr3TpXi861yzjo16Dn4hUox07iw5AyeMLHWsujkjzvRQ==",
"dev": true,
"requires": {
- "is-path-inside": "^1.0.0"
+ "is-path-inside": "^2.1.0"
}
},
"is-path-inside": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz",
- "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz",
+ "integrity": "sha512-wiyhTzfDWsvwAW53OBWF5zuvaOGlZ6PwYxAbPVDhpm+gM09xKQGjBq/8uYN12aDvMxnAnq3dxTyoSoRNmg5YFg==",
"dev": true,
"requires": {
- "path-is-inside": "^1.0.1"
+ "path-is-inside": "^1.0.2"
}
},
"is-plain-obj": {
@@ -7217,17 +7240,17 @@
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="
},
"mime-types": {
- "version": "2.1.22",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.22.tgz",
- "integrity": "sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog==",
+ "version": "2.1.24",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz",
+ "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==",
"requires": {
- "mime-db": "~1.38.0"
+ "mime-db": "1.40.0"
},
"dependencies": {
"mime-db": {
- "version": "1.38.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.38.0.tgz",
- "integrity": "sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg=="
+ "version": "1.40.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
+ "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
}
}
},
@@ -7292,9 +7315,9 @@
"dev": true
},
"nan": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
- "integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
+ "version": "2.13.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz",
+ "integrity": "sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw==",
"dev": true,
"optional": true
},
@@ -7406,9 +7429,9 @@
}
},
"now": {
- "version": "13.1.3",
- "resolved": "https://registry.npmjs.org/now/-/now-13.1.3.tgz",
- "integrity": "sha512-DYRaOqYblnyXF8Awzy/1+mCWjXESxVe7H9phXRGOUm6Vu2dXuJ07yOU37BYESmRue+Aw4sc51W2duzkhwBuCOA=="
+ "version": "15.0.6",
+ "resolved": "https://registry.npmjs.org/now/-/now-15.0.6.tgz",
+ "integrity": "sha512-kX58WG6ioe8guduIUFW+9yTvQt5XcYQsuSQJL1J1XLpWeJIAy951Bkfvli/tLdMXB/lNwlOQUnMyL4zobBSqlA=="
},
"now-and-later": {
"version": "2.0.0",
@@ -7769,9 +7792,9 @@
}
},
"p-map": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz",
- "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+ "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
"dev": true
},
"p-map-series": {
@@ -9020,9 +9043,9 @@
"integrity": "sha512-A5MOagrPFga4YaKQSWHryl7AXvbQkEqpw4NNYMTNYUNV51bA8ABHgYFpqKx+YFFrw59xMV1qGH1R4AgoNIVgCw=="
},
"serve": {
- "version": "10.1.2",
- "resolved": "https://registry.npmjs.org/serve/-/serve-10.1.2.tgz",
- "integrity": "sha512-TVH35uwndRlCqSeX3grR3Ntrjx2aBTeu6sx+zTD2CzN2N/rHuEDTvxiBwWbrellJNyWiQFz2xZmoW+UxV+Zahg==",
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/serve/-/serve-11.0.0.tgz",
+ "integrity": "sha512-Gnyyp3JAtRUo0dRH1/YWPKbnaXHfzQBiVh9+qSUi6tyVcVA8twUP2c+GnOwsoe9Ss7dfOHJUTSA4fdWP//Y4gQ==",
"requires": {
"@zeit/schemas": "2.6.0",
"ajv": "6.5.3",
@@ -9031,7 +9054,7 @@
"chalk": "2.4.1",
"clipboardy": "1.2.3",
"compression": "1.7.3",
- "serve-handler": "5.0.8",
+ "serve-handler": "6.0.0",
"update-check": "1.5.2"
},
"dependencies": {
@@ -9069,9 +9092,9 @@
}
},
"serve-handler": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-5.0.8.tgz",
- "integrity": "sha512-pqk0SChbBLLHfMIxQ55czjdiW7tj2cFy53svvP8e5VqEN/uB/QpfiTJ8k1uIYeFTDVoi+FGi5aqXScuu88bymg==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.0.0.tgz",
+ "integrity": "sha512-2/e0+N1abV1HAN+YN8uCOPi1B0bIYaR6kRcSfzezRwszak5Yzr6QhT34XJk2Bw89rhXenqwLNJb4NnF2/krnGQ==",
"requires": {
"bytes": "3.0.0",
"content-disposition": "0.5.2",
diff --git a/package.json b/package.json
index 9b7b1b9b1..2e3bcd1fb 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"devDependencies": {
"@babel/core": "^7.4.3",
"@babel/preset-env": "^7.4.3",
- "del": "^3.0.0",
+ "del": "^4.0.0",
"gulp": "^4.0.0",
"gulp-autoprefixer": "^6.0.0",
"gulp-babel": "^8.0.0",
@@ -30,10 +30,10 @@
"@babel/register": "^7.4.0",
"comlink": "^3.2.0",
"gulp-rollup": "^2.16.2",
- "now": "^13.1.3",
- "rollup": "^1.9.0",
+ "now": "^15.0.0",
+ "rollup": "^1.0.0",
"rollup-plugin-terser": "^4.0.4",
- "serve": "^10.1.2"
+ "serve": "^11.0.0"
},
"babel": {
"presets": [