canvasElement).getContext("2d", contextAttributes)!;
context.canvas.width = qrRegion.width;
context.canvas.height = qrRegion.height;
// Insert the canvas
this.element!.append(canvasElement);
if (shouldShadingBeApplied) {
this.possiblyInsertShadingElement(
this.element!, viewfinderWidth, viewfinderHeight, qrDimensions);
}
this.createScannerPausedUiElement(this.element!);
// Update local states
this.qrRegion = qrRegion;
this.context = context;
this.canvasElement = canvasElement;
}
// TODO(mebjas): Convert this to a standard message viewer.
private createScannerPausedUiElement(rootElement: HTMLElement) {
const scannerPausedUiElement = document.createElement("div");
scannerPausedUiElement.innerText = Html5QrcodeStrings.scannerPaused();
scannerPausedUiElement.style.display = "none";
scannerPausedUiElement.style.position = "absolute";
scannerPausedUiElement.style.top = "0px";
scannerPausedUiElement.style.zIndex = "1";
scannerPausedUiElement.style.background = "rgba(9, 9, 9, 0.46)";
scannerPausedUiElement.style.color = "#FFECEC";
scannerPausedUiElement.style.textAlign = "center";
scannerPausedUiElement.style.width = "100%";
rootElement.appendChild(scannerPausedUiElement);
this.scannerPausedUiElement = scannerPausedUiElement;
}
/**
* Scans current context using the qrcode library.
*
* This method call would result in callback being triggered by the
* qrcode library. This method also handles the border coloring.
*
* @returns true if scan match is found, false otherwise.
*/
private scanContext(
qrCodeSuccessCallback: QrcodeSuccessCallback,
qrCodeErrorCallback: QrcodeErrorCallback
): Promise {
if (this.stateManagerProxy.isPaused()) {
return Promise.resolve(false);
}
return this.qrcode.decodeAsync(this.canvasElement!)
.then((result) => {
qrCodeSuccessCallback(
result.text,
Html5QrcodeResultFactory.createFromQrcodeResult(
result));
this.possiblyUpdateShaders(/* qrMatch= */ true);
return true;
}).catch((error) => {
this.possiblyUpdateShaders(/* qrMatch= */ false);
let errorMessage = Html5QrcodeStrings.codeParseError(error);
qrCodeErrorCallback(
errorMessage, Html5QrcodeErrorFactory.createFrom(errorMessage));
return false;
});
}
/**
* Forever scanning method.
*/
private foreverScan(
internalConfig: InternalHtml5QrcodeConfig,
qrCodeSuccessCallback: QrcodeSuccessCallback,
qrCodeErrorCallback: QrcodeErrorCallback) {
if (!this.shouldScan) {
// Stop scanning.
return;
}
if (!this.renderedCamera) {
return;
}
// There is difference in size of rendered video and one that is
// considered by the canvas. Need to account for scaling factor.
const videoElement = this.renderedCamera!.getSurface();
const widthRatio
= videoElement.videoWidth / videoElement.clientWidth;
const heightRatio
= videoElement.videoHeight / videoElement.clientHeight;
if (!this.qrRegion) {
throw "qrRegion undefined when localMediaStream is ready.";
}
const sWidthOffset = this.qrRegion.width * widthRatio;
const sHeightOffset = this.qrRegion.height * heightRatio;
const sxOffset = this.qrRegion.x * widthRatio;
const syOffset = this.qrRegion.y * heightRatio;
// Only decode the relevant area, ignore the shaded area,
// More reference:
// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
this.context!.drawImage(
videoElement,
/* sx= */ sxOffset,
/* sy= */ syOffset,
/* sWidth= */ sWidthOffset,
/* sHeight= */ sHeightOffset,
/* dx= */ 0,
/* dy= */ 0,
/* dWidth= */ this.qrRegion.width,
/* dHeight= */ this.qrRegion.height);
const triggerNextScan = () => {
this.foreverScanTimeout = setTimeout(() => {
this.foreverScan(
internalConfig, qrCodeSuccessCallback, qrCodeErrorCallback);
}, this.getTimeoutFps(internalConfig.fps));
};
// Try scanning normal frame and in case of failure, scan
// the inverted context if not explictly disabled.
// TODO(mebjas): Move this logic to decoding library.
this.scanContext(qrCodeSuccessCallback, qrCodeErrorCallback)
.then((isSuccessfull) => {
// Previous scan failed and disableFlip is off.
if (!isSuccessfull && internalConfig.disableFlip !== true) {
this.context!.translate(this.context!.canvas.width, 0);
this.context!.scale(-1, 1);
this.scanContext(qrCodeSuccessCallback, qrCodeErrorCallback)
.finally(() => {
triggerNextScan();
});
} else {
triggerNextScan();
}
}).catch((error) => {
this.logger.logError(
"Error happend while scanning context", error);
triggerNextScan();
});
}
private createVideoConstraints(
cameraIdOrConfig: string | MediaTrackConstraints)
: MediaTrackConstraints | undefined {
if (typeof cameraIdOrConfig == "string") {
// If it's a string it should be camera device Id.
return { deviceId: { exact: cameraIdOrConfig } };
} else if (typeof cameraIdOrConfig == "object") {
const facingModeKey = "facingMode";
const deviceIdKey = "deviceId";
const allowedFacingModeValues
= { "user" : true, "environment" : true};
const exactKey = "exact";
const isValidFacingModeValue = (value: string) => {
if (value in allowedFacingModeValues) {
// Valid config
return true;
} else {
// Invalid config
throw "config has invalid 'facingMode' value = "
+ `'${value}'`;
}
};
const keys = Object.keys(cameraIdOrConfig);
if (keys.length !== 1) {
throw "'cameraIdOrConfig' object should have exactly 1 key,"
+ ` if passed as an object, found ${keys.length} keys`;
}
const key:string = Object.keys(cameraIdOrConfig)[0];
if (key !== facingModeKey && key !== deviceIdKey) {
throw `Only '${facingModeKey}' and '${deviceIdKey}' `
+ " are supported for 'cameraIdOrConfig'";
}
if (key === facingModeKey) {
/**
* Supported scenarios:
* - { facingMode: "user" }
* - { facingMode: "environment" }
* - { facingMode: { exact: "environment" } }
* - { facingMode: { exact: "user" } }
*/
const facingMode: any = cameraIdOrConfig.facingMode;
if (typeof facingMode == "string") {
if (isValidFacingModeValue(facingMode)) {
return { facingMode: facingMode };
}
} else if (typeof facingMode == "object") {
if (exactKey in facingMode) {
if (isValidFacingModeValue(facingMode[`${exactKey}`])) {
return {
facingMode: {
exact: facingMode[`${exactKey}`]
}
};
}
} else {
throw "'facingMode' should be string or object with"
+ ` ${exactKey} as key.`;
}
} else {
const type = (typeof facingMode);
throw `Invalid type of 'facingMode' = ${type}`;
}
} else {
/**
* key == deviceIdKey; Supported scenarios:
* - { deviceId: { exact: "a76afe74e95e3.....38627b3bde" }
* - { deviceId: "a76afe74e95e3....065c9cd89438627b3bde" }
*/
const deviceId: any = cameraIdOrConfig.deviceId;
if (typeof deviceId == "string") {
return { deviceId: deviceId };
} else if (typeof deviceId == "object") {
if (exactKey in deviceId) {
return {
deviceId : { exact: deviceId[`${exactKey}`] }
};
} else {
throw "'deviceId' should be string or object with"
+ ` ${exactKey} as key.`;
}
} else {
const type = (typeof deviceId);
throw `Invalid type of 'deviceId' = ${type}`;
}
}
}
// invalid type
const type = (typeof cameraIdOrConfig);
throw `Invalid type of 'cameraIdOrConfig' = ${type}`;
}
//#endregion
//#region Documented private methods for file based scanner.
private computeCanvasDrawConfig(
imageWidth: number,
imageHeight: number,
containerWidth: number,
containerHeight: number): QrcodeRegionBounds {
if (imageWidth <= containerWidth
&& imageHeight <= containerHeight) {
// no downsampling needed.
const xoffset = (containerWidth - imageWidth) / 2;
const yoffset = (containerHeight - imageHeight) / 2;
return {
x: xoffset,
y: yoffset,
width: imageWidth,
height: imageHeight
};
} else {
const formerImageWidth = imageWidth;
const formerImageHeight = imageHeight;
if (imageWidth > containerWidth) {
imageHeight = (containerWidth / imageWidth) * imageHeight;
imageWidth = containerWidth;
}
if (imageHeight > containerHeight) {
imageWidth = (containerHeight / imageHeight) * imageWidth;
imageHeight = containerHeight;
}
this.logger.log(
"Image downsampled from "
+ `${formerImageWidth}X${formerImageHeight}`
+ ` to ${imageWidth}X${imageHeight}.`);
return this.computeCanvasDrawConfig(
imageWidth, imageHeight, containerWidth, containerHeight);
}
}
//#endregion
private clearElement(): void {
if (this.stateManagerProxy.isScanning()) {
throw "Cannot clear while scan is ongoing, close it first.";
}
const element = document.getElementById(this.elementId);
if (element) {
element.innerHTML = "";
}
}
private possiblyUpdateShaders(qrMatch: boolean) {
if (this.qrMatch === qrMatch) {
return;
}
if (this.hasBorderShaders
&& this.borderShaders
&& this.borderShaders.length) {
this.borderShaders.forEach((shader) => {
shader.style.backgroundColor = qrMatch
? Constants.BORDER_SHADER_MATCH_COLOR
: Constants.BORDER_SHADER_DEFAULT_COLOR;
});
}
this.qrMatch = qrMatch;
}
private possiblyCloseLastScanImageFile() {
if (this.lastScanImageFile) {
URL.revokeObjectURL(this.lastScanImageFile);
this.lastScanImageFile = null;
}
}
private createCanvasElement(
width: number, height: number, customId?: string): HTMLCanvasElement {
const canvasWidth = width;
const canvasHeight = height;
const canvasElement = document.createElement("canvas");
canvasElement.style.width = `${canvasWidth}px`;
canvasElement.style.height = `${canvasHeight}px`;
canvasElement.style.display = "none";
canvasElement.id = isNullOrUndefined(customId)
? "qr-canvas" : customId!;
return canvasElement;
}
private getShadedRegionBounds(
width: number, height: number, qrboxSize: QrDimensions)
: QrcodeRegionBounds {
if (qrboxSize.width > width || qrboxSize.height > height) {
throw "'config.qrbox' dimensions should not be greater than the "
+ "dimensions of the root HTML element.";
}
return {
x: (width - qrboxSize.width) / 2,
y: (height - qrboxSize.height) / 2,
width: qrboxSize.width,
height: qrboxSize.height
};
}
private possiblyInsertShadingElement(
element: HTMLElement,
width: number,
height: number,
qrboxSize: QrDimensions) {
if ((width - qrboxSize.width) < 1 || (height - qrboxSize.height) < 1) {
return;
}
const shadingElement = document.createElement("div");
shadingElement.style.position = "absolute";
const rightLeftBorderSize = (width - qrboxSize.width) / 2;
const topBottomBorderSize = (height - qrboxSize.height) / 2;
shadingElement.style.borderLeft
= `${rightLeftBorderSize}px solid rgba(0, 0, 0, 0.48)`;
shadingElement.style.borderRight
= `${rightLeftBorderSize}px solid rgba(0, 0, 0, 0.48)`;
shadingElement.style.borderTop
= `${topBottomBorderSize}px solid rgba(0, 0, 0, 0.48)`;
shadingElement.style.borderBottom
= `${topBottomBorderSize}px solid rgba(0, 0, 0, 0.48)`;
shadingElement.style.boxSizing = "border-box";
shadingElement.style.top = "0px";
shadingElement.style.bottom = "0px";
shadingElement.style.left = "0px";
shadingElement.style.right = "0px";
shadingElement.id = `${Constants.SHADED_REGION_ELEMENT_ID}`;
// Check if div is too small for shadows. As there are two 5px width
// borders the needs to have a size above 10px.
if ((width - qrboxSize.width) < 11
|| (height - qrboxSize.height) < 11) {
this.hasBorderShaders = false;
} else {
const smallSize = 5;
const largeSize = 40;
this.insertShaderBorders(
shadingElement,
/* width= */ largeSize,
/* height= */ smallSize,
/* top= */ -smallSize,
/* bottom= */ null,
/* side= */ 0,
/* isLeft= */ true);
this.insertShaderBorders(
shadingElement,
/* width= */ largeSize,
/* height= */ smallSize,
/* top= */ -smallSize,
/* bottom= */ null,
/* side= */ 0,
/* isLeft= */ false);
this.insertShaderBorders(
shadingElement,
/* width= */ largeSize,
/* height= */ smallSize,
/* top= */ null,
/* bottom= */ -smallSize,
/* side= */ 0,
/* isLeft= */ true);
this.insertShaderBorders(
shadingElement,
/* width= */ largeSize,
/* height= */ smallSize,
/* top= */ null,
/* bottom= */ -smallSize,
/* side= */ 0,
/* isLeft= */ false);
this.insertShaderBorders(
shadingElement,
/* width= */ smallSize,
/* height= */ largeSize + smallSize,
/* top= */ -smallSize,
/* bottom= */ null,
/* side= */ -smallSize,
/* isLeft= */ true);
this.insertShaderBorders(
shadingElement,
/* width= */ smallSize,
/* height= */ largeSize + smallSize,
/* top= */ null,
/* bottom= */ -smallSize,
/* side= */ -smallSize,
/* isLeft= */ true);
this.insertShaderBorders(
shadingElement,
/* width= */ smallSize,
/* height= */ largeSize + smallSize,
/* top= */ -smallSize,
/* bottom= */ null,
/* side= */ -smallSize,
/* isLeft= */ false);
this.insertShaderBorders(
shadingElement,
/* width= */ smallSize,
/* height= */ largeSize + smallSize,
/* top= */ null,
/* bottom= */ -smallSize,
/* side= */ -smallSize,
/* isLeft= */ false);
this.hasBorderShaders = true;
}
element.append(shadingElement);
}
private insertShaderBorders(
shaderElem: HTMLDivElement,
width: number,
height: number,
top: number | null,
bottom: number | null,
side: number,
isLeft: boolean) {
const elem = document.createElement("div");
elem.style.position = "absolute";
elem.style.backgroundColor = Constants.BORDER_SHADER_DEFAULT_COLOR;
elem.style.width = `${width}px`;
elem.style.height = `${height}px`;
if (top !== null) {
elem.style.top = `${top}px`;
}
if (bottom !== null) {
elem.style.bottom = `${bottom}px`;
}
if (isLeft) {
elem.style.left = `${side}px`;
} else {
elem.style.right = `${side}px`;
}
if (!this.borderShaders) {
this.borderShaders = [];
}
this.borderShaders.push(elem);
shaderElem.appendChild(elem);
}
private showPausedState() {
if (!this.scannerPausedUiElement) {
throw "[internal error] scanner paused UI element not found";
}
this.scannerPausedUiElement.style.display = "block";
}
private hidePausedState() {
if (!this.scannerPausedUiElement) {
throw "[internal error] scanner paused UI element not found";
}
this.scannerPausedUiElement.style.display = "none";
}
private getTimeoutFps(fps: number) {
return 1000 / fps;
}
//#endregion
}