Initial commit: any%

main
Charlotte Som 2023-02-13 15:48:46 +00:00
commit c938303a07
13 changed files with 4313 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

11
README.md Normal file
View File

@ -0,0 +1,11 @@
# qr.umm.gay
qr decoder web tool any% (20 minutes)
## Setup
```shell
$ npm install # Install dependencies
$ npm run dev # Start a local dev server
$ npm run build # Generate static bundle in 'out/' directory
```

155
lib/gluejar.ts Normal file
View File

@ -0,0 +1,155 @@
import { useEffect, useCallback, useReducer } from "react";
/* <PasteContainer
onPaste={files => files.length > 0 && this.props.onFileAccepted(files[0], files[0])}
/> */
// NOTE: `onPaste` should return an array of files that were acceptedFiles
//
// const Image = ({ src }) => <img src={image} alt={`Pasted: ${image}`} />
//
// <PasteContainer
// onPaste={files => this.method(files)}
// onError={err => console.error(err)}
// >
// {({images}) => images.map((image, i) => <Image src={image} key={i} />)}
// </PasteContainer>
const DEFAULT_ACCEPTED_FILES = ["image/gif", "image/png", "image/jpeg", "image/bmp"];
const _transformImageDataToURL = (
data: DataTransfer,
acceptedFiles: string[] = DEFAULT_ACCEPTED_FILES
): string | undefined => {
const isValidFormat = (fileType: string): boolean => acceptedFiles.includes(fileType);
// NOTE: This needs to be a for loop, it's a list like object
for (let i = 0; i < data.items.length; i++) {
if (!isValidFormat(data.items[i].type)) {
continue;
// throw new Error(`Sorry, that's not a format we support ${data.items[i].type}`);
}
let blob: BlobLikeFile = data.items[i].getAsFile();
if (blob) {
// We shouldn't fire the callback if we can't create `new Blob()`
let file = window.URL.createObjectURL(blob);
return file;
}
}
};
type BlobLikeFile = File | null;
export interface GlueJarOptions<T extends HTMLElement> {
acceptedFiles: string[];
ref?: React.RefObject<T>;
}
export interface IGlueJarState {
pasted: string[];
error: string | null;
}
export enum GlueActions {
SET_FILE = "SET_FILES",
SET_ERROR = "SET_ERROR",
}
type GlueJarActions =
| {
type: GlueActions.SET_FILE;
file: string;
}
| { type: GlueActions.SET_ERROR; error: string };
type GlueJarReducer = React.Reducer<IGlueJarState, GlueJarActions>;
const reducer: GlueJarReducer = (state, action) => {
switch (action.type) {
case GlueActions.SET_FILE:
return { ...state, pasted: [action.file, ...state.pasted], error: null };
case GlueActions.SET_ERROR:
return { ...state, error: action.error };
default:
throw new Error("Must specify action type");
}
};
const useGlueJarReducer = () =>
useReducer<GlueJarReducer>(reducer, {
pasted: [],
error: null,
});
export function usePasteHandler(acceptedFiles: string[] = DEFAULT_ACCEPTED_FILES) {
const [state, dispatch] = useGlueJarReducer();
const transformImageDataToURL = useCallback(
(data: DataTransfer): void => {
try {
const file = _transformImageDataToURL(data, acceptedFiles);
dispatch(
file
? { type: GlueActions.SET_FILE, file }
: {
type: GlueActions.SET_ERROR,
error: "Something went wrong",
}
);
} catch (error: any) {
dispatch({ type: GlueActions.SET_ERROR, error: error.message });
}
},
[dispatch, acceptedFiles]
);
const pasteHandler = useCallback(
({ clipboardData }: ClipboardEvent): void => {
if (clipboardData && clipboardData.items.length > 0) {
transformImageDataToURL(clipboardData);
} else {
dispatch({
type: GlueActions.SET_ERROR,
error: `Sorry, to bother you but there was no image pasted.`,
});
}
},
[dispatch, transformImageDataToURL]
);
return [state, pasteHandler] as const;
}
/**
* useGlueJar
* if you don't pass a `ref` to the options it will default to use the document
* to add an event listener
* @param options
* @returns `IGlueJarState`
*/
export function useGlueJar<T extends HTMLElement>(
{ ref, ...options }: Partial<GlueJarOptions<T>> = {
acceptedFiles: DEFAULT_ACCEPTED_FILES,
}
) {
const [state, pasteHandler] = usePasteHandler(options.acceptedFiles);
useEffect(() => {
if (ref && ref.current) {
ref.current.addEventListener("paste", pasteHandler);
} else {
document.addEventListener("paste", pasteHandler);
}
return () => {
if (ref && ref.current) {
ref.current.removeEventListener("paste", pasteHandler);
} else {
document.removeEventListener("paste", pasteHandler);
}
};
}, [ref, options]);
return state;
}

344
lib/qr.ts Normal file
View File

@ -0,0 +1,344 @@
import { Options } from 'jsqr';
import jsQR from 'jsqr';
const videoSize = {
width: { min: 360, ideal: 720, max: 1080 },
height: { min: 360, ideal: 720, max: 1080 },
};
class QrcodeDecoder {
timerCapture: null | NodeJS.Timeout;
canvasElem: null | HTMLCanvasElement;
gCtx: null | CanvasRenderingContext2D;
stream: null | MediaStream;
videoElem: null | HTMLVideoElement;
getUserMediaHandler: null;
videoConstraints: MediaStreamConstraints;
defaultOption: Options;
constructor() {
this.timerCapture = null;
this.canvasElem = null;
this.gCtx = null;
this.stream = null;
this.videoElem = null;
this.getUserMediaHandler = null;
this.videoConstraints = {
// default use rear camera
video: { ...videoSize, facingMode: { exact: 'environment' } },
audio: false,
};
this.defaultOption = { inversionAttempts: 'attemptBoth' };
}
/**
* Verifies if canvas element is supported.
*/
isCanvasSupported() {
const elem = document.createElement('canvas');
return !!(elem.getContext && elem.getContext('2d'));
}
_createImageData(target: CanvasImageSource, width: number, height: number) {
if (!this.canvasElem) {
this._prepareCanvas(width, height);
}
this.gCtx!.clearRect(0, 0, width, height);
this.gCtx!.drawImage(target, 0, 0, width, height);
const imageData = this.gCtx!.getImageData(
0,
0,
this.canvasElem!.width,
this.canvasElem!.height,
);
return imageData;
}
/**
* Prepares the canvas element (which will
* receive the image from the camera and provide
* what the algorithm needs for checking for a
* QRCode and then decoding it.)
*
*
* @param {DOMElement} canvasElem the canvas
* element
* @param {number} width The width that
* the canvas element
* should have
* @param {number} height The height that
* the canvas element
* should have
* @return {DOMElement} the canvas
* after the resize if width and height
* provided.
*/
_prepareCanvas(width: number, height: number) {
if (!this.canvasElem) {
this.canvasElem = document.createElement('canvas');
this.canvasElem.style.width = `${width}px`;
this.canvasElem.style.height = `${height}px`;
this.canvasElem.width = width;
this.canvasElem.height = height;
}
this.gCtx = this.canvasElem.getContext('2d');
}
/**
* Based on the video dimensions and the canvas
* that was previously generated captures the
* video/image source and then paints into the
* canvas so that the decoder is able to work as
* it expects.
* @param {DOMElement} videoElem <video> dom element
* @param {Object} options options (optional) - Additional options.
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
* refer to jsqr options: https://github.com/cozmo/jsQR
*/
async _captureToCanvas(videoElem: HTMLVideoElement, options: Options) {
if (this.timerCapture) {
clearTimeout(this.timerCapture);
}
const proms = () => {
const p = new Promise(async (resolve) => {
let code;
if (videoElem.videoWidth && videoElem.videoHeight) {
const imageData = this._createImageData(
videoElem,
videoElem.videoWidth,
videoElem.videoHeight,
);
code = jsQR(
imageData.data,
imageData.width,
imageData.height,
options,
);
if (code) {
resolve(code);
} else {
this.timerCapture = setTimeout(async () => {
code = await this._captureToCanvas(videoElem, options);
resolve(code);
}, 500);
}
} else {
this.timerCapture = setTimeout(async () => {
code = await this._captureToCanvas(videoElem, options);
resolve(code);
}, 500);
}
});
return p;
};
const result = await proms();
return result;
}
/**
* Prepares the video element for receiving
* camera's input. Releases a stream if there
* was any (resets).
*
* @param {DOMElement} videoElem <video> dom element
* @param {Object} options options (optional) - Additional options.
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
* refer to jsqr options: https://github.com/cozmo/jsQR
*/
async decodeFromCamera(videoElem: HTMLVideoElement, options: any = {}) {
const opts = {
...this.defaultOption,
...options,
};
this.stop();
if (!navigator.mediaDevices.getUserMedia) {
throw new Error("Couldn't get video from camera");
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia(this.videoConstraints);
} catch (e) {
if ((e as OverconstrainedError).name === 'OverconstrainedError') {
console.log('[OverconstrainedError] Can not use rear camera.');
stream = await navigator.mediaDevices.getUserMedia({
video: {
...videoSize,
...{
width: opts.width,
height: opts.height,
},
},
audio: false,
});
} else {
throw e;
}
}
if (stream) {
videoElem.srcObject = stream;
// videoElem.src = window.URL.createObjectURL(stream);
this.videoElem = videoElem;
this.stream = stream;
const code = await this.decodeFromVideo(videoElem, opts);
return code;
}
}
/**
* Prepares the video element for video file.
*
* @param {DOMElement} videoElem <video> dom element
* @param {Object} options options (optional) - Additional options.
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
* refer to jsqr options: https://github.com/cozmo/jsQR
*/
async decodeFromVideo(videoElem: HTMLVideoElement, options = {}) {
const opts = {
...this.defaultOption,
...options,
};
try {
this.videoElem = videoElem;
const code = await this._captureToCanvas(videoElem, opts);
return code;
} catch (e) {
throw e;
}
}
/**
* Decodes an image from its src.
* @param {DOMElement} imageElem
* @param {Object} options options (optional) - Additional options.
* inversionAttempts - (attemptBoth (default), dontInvert, onlyInvert, or invertFirst)
* refer to jsqr options: https://github.com/cozmo/jsQR
*/
async decodeFromImage(
img: HTMLImageElement | string,
options: { crossOrigin?: string } = {},
) {
let imgDom: HTMLImageElement | null = null;
const opts = {
...this.defaultOption,
...options,
};
if (typeof img === 'string') {
imgDom = document.createElement('img');
if (options.crossOrigin) {
imgDom.crossOrigin = options.crossOrigin;
}
imgDom.src = img;
const proms = () =>
new Promise((resolve) => {
imgDom!.onload = () => resolve(true);
});
await proms();
} else if (+img.nodeType > 0) {
if (!img.src) {
throw new Error('The ImageElement must contain a src');
}
imgDom = img;
}
let code = null;
if (imgDom) {
code = this._decodeFromImageElm(imgDom, opts);
}
return code;
}
_decodeFromImageElm(imgObj: HTMLImageElement, options = {}) {
const opts: Options = {
...this.defaultOption,
...options,
};
const imageData = this._createImageData(
imgObj,
imgObj.width,
imgObj.height,
);
const code = jsQR(imageData.data, imageData.width, imageData.height, opts);
if (code) {
return code;
}
return false;
}
/**
* Releases a video stream that was being
* captured by prepareToVideo
*/
stop() {
if (this.stream) {
const track = this.stream.getTracks()[0];
track.stop();
this.stream = null;
// fix: clear black bg after camera capture
this.videoElem!.srcObject = null;
}
if (this.timerCapture) {
clearTimeout(this.timerCapture);
this.timerCapture = null;
}
return this;
}
/**
* Sets the sourceId for the camera to use.
*/
setGroupId(groupId: string) {
if (groupId) {
this.videoConstraints.video = {
advanced: [{ groupId }],
};
} else {
this.videoConstraints.video = true;
}
return this;
}
/**
* Gets a list of all available video sources on
* the current device.
*/
async getVideoDevices() {
if (navigator.mediaDevices.enumerateDevices) {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter((item: MediaDeviceInfo) => {
if (item.kind === 'videoinput') {
return true;
}
return false;
});
} else {
throw new Error(
'Current browser doest not support MediaStreamTrack.getSources',
);
}
}
}
export default QrcodeDecoder;

6
next.config.js Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

3569
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "qr-decoder",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build && next export",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@next/font": "13.1.6",
"@types/node": "18.13.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.10",
"jsqr": "^1.4.0",
"next": "13.1.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "4.9.5"
},
"devDependencies": {
"eslint": "8.34.0",
"eslint-config-next": "13.1.6"
}
}

6
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,6 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}

13
src/pages/_document.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

67
src/pages/index.tsx Normal file
View File

@ -0,0 +1,67 @@
import Head from 'next/head'
import { useGlueJar } from "../../lib/gluejar";
import QrcodeDecoder from '../../lib/qr';
import { useState } from 'react';
import { QRCode } from 'jsqr';
function QRImage({ image }: { image: string }) {
const [data, setData] = useState<QRCode>();
const [errored, setErrored] = useState(false);
const qr = new QrcodeDecoder();
qr.decodeFromImage(image).then(data => {
if (data != null && typeof data === "object") {
setData(data)
} else {
setErrored(true);
}
}).catch(err => {
setData(undefined);
setErrored(true);
});
return <>
<article>
<img src={image} alt={"Pasted image: " + image} />
{errored
? <strong>There was an error decoding this QR code.</strong>
: data === undefined
? <p>Parsing...</p>
: <code>{data.data}</code>}
</article>
</>
}
export default function Home() {
const { pasted, error } = useGlueJar();
return (
<>
<div className="app">
<Head>
<title>QR Decoder</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main>
<header>
<h1>QR Decoder</h1>
<p>{"Paste an image :)"}</p>
</header>
{error !== null && <span>{error}</span>}
<div>
{pasted.length > 0 &&
pasted.map((image) => (
<QRImage image={image} key={image} />
))}
</div>
</main>
<footer>
A web tool by <a href="https://som.codes">Charlotte Som</a>.
</footer>
</div>
</>
)
}

53
src/styles/globals.css Normal file
View File

@ -0,0 +1,53 @@
:root {
font-family: sans-serif;
}
main {
}
img {
display: block;
max-width: 100%;
margin: 0 auto;
}
article {
display: flex;
flex-direction: column;
border: 1px solid black;
border-radius: 6px;
padding: 2em;
margin-top: 2em;
}
code {
font-size: 1.125rem;
font-family: monospace;
}
body {
margin: 0;
}
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
}
footer {
text-align: center;
font-size: 0.85em;
padding: 2em;
}
p {
margin: 0;
}

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}