commit c938303a07f6426439eb713b7528099c801289f6 Author: videogame hacker Date: Mon Feb 13 15:48:46 2023 +0000 Initial commit: any% diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c87c9b3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b1e966 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/lib/gluejar.ts b/lib/gluejar.ts new file mode 100644 index 0000000..aa9e6b7 --- /dev/null +++ b/lib/gluejar.ts @@ -0,0 +1,155 @@ +import { useEffect, useCallback, useReducer } from "react"; + +/* files.length > 0 && this.props.onFileAccepted(files[0], files[0])} +/> */ + +// NOTE: `onPaste` should return an array of files that were acceptedFiles +// +// const Image = ({ src }) => {`Pasted: +// +// this.method(files)} +// onError={err => console.error(err)} +// > +// {({images}) => images.map((image, i) => )} +// + +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 { + acceptedFiles: string[]; + ref?: React.RefObject; +} + +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; + +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(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( + { ref, ...options }: Partial> = { + 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; +} diff --git a/lib/qr.ts b/lib/qr.ts new file mode 100644 index 0000000..f4c6023 --- /dev/null +++ b/lib/qr.ts @@ -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