diff --git a/examples/halftone.png b/examples/halftone.png index d6a0447..fb77382 100644 Binary files a/examples/halftone.png and b/examples/halftone.png differ diff --git a/presets/Halftone.js b/presets/Halftone.js index 6e887b8..b97513e 100644 --- a/presets/Halftone.js +++ b/presets/Halftone.js @@ -58,7 +58,7 @@ const Module = { SeparatorOFF: 12, }; -export async function renderCanvas(qr, params, ctx) { +export async function renderCanvas(qr, params, canvas) { const unit = 3; const pixel = 1; @@ -68,10 +68,17 @@ export async function renderCanvas(qr, params, ctx) { const bg = params["Background"]; const alignment = params["Alignment pattern"]; const timing = params["Timing pattern"]; - const file = params["Image"]; + let file = params["Image"]; + if (file == null) { + file = await fetch( + "https://upload.wikimedia.org/wikipedia/commons/1/14/The_Widow_%28Boston_Public_Library%29_%28cropped%29.jpg" + ).then((res) => res.blob()); + } + const image = await createImageBitmap(file) const pixelWidth = matrixWidth + 2 * margin; const canvasSize = pixelWidth * unit; + const ctx = canvas.getContext("2d"); ctx.canvas.width = canvasSize; ctx.canvas.height = canvasSize; @@ -91,27 +98,10 @@ export async function renderCanvas(qr, params, ctx) { } } - const image = new Image(); - - if (file != null) { - image.src = URL.createObjectURL(file); - } else { - // if canvas tainted, need to reload - // https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image - image.crossOrigin = "anonymous"; - image.src = - "https://upload.wikimedia.org/wikipedia/commons/1/14/The_Widow_%28Boston_Public_Library%29_%28cropped%29.jpg"; - } - await image.decode(); - ctx.filter = `brightness(${params["Brightness"]}) contrast(${params["Contrast"]})`; ctx.drawImage(image, 0, 0, canvasSize, canvasSize); ctx.filter = "none"; - if (file != null) { - URL.revokeObjectURL(image.src); - } - const imageData = ctx.getImageData(0, 0, canvasSize, canvasSize); const data = imageData.data; diff --git a/presets/Quantum.js b/presets/Quantum.js index 4037ec3..d05600e 100644 --- a/presets/Quantum.js +++ b/presets/Quantum.js @@ -89,7 +89,7 @@ export function renderSVG(qr, params) { svg += ``; switch (params["Finder pattern"]) { - case "Atom": { + case "Atom": let r1 = 0.98; let r2 = 1.5; @@ -101,11 +101,11 @@ export function renderSVG(qr, params) { svg += `M${x + 3.5},${y + 3.5 - 3 * r2}a${r1},${r2} 0,0,1 0,${6 * r2}a${r1},${r2} 0,0,1 0,${-6 * r2}`; break; - } - case "Planet": { + + case "Planet": svg += ``; } diff --git a/public/renderWorker.js b/public/renderWorker.js new file mode 100644 index 0000000..c8cc827 --- /dev/null +++ b/public/renderWorker.js @@ -0,0 +1,40 @@ +let prevToken = { canceled: true }; + +onmessage = async ({ data: { type, url, qr, params, timeoutId } }) => { + prevToken.canceled = true; + const token = { canceled: false }; + prevToken = token; + + try { + switch (type) { + case "svg": { + const { renderSVG } = await import(url); + const svg = await renderSVG(qr, params); + if (token.canceled) { + return postMessage({ type: "canceled", timeoutId }); + } + + postMessage({ type, svg, timeoutId }); + break; + } + case "canvas": { + const { renderCanvas } = await import(url); + const canvas = new OffscreenCanvas(0, 0); + await renderCanvas(qr, params, canvas); + if (token.canceled) { + return postMessage({ type: "canceled", timeoutId }); + } + + const bitmap = canvas.transferToImageBitmap(); + postMessage({ type, bitmap, timeoutId }, [bitmap]); + break; + } + } + } catch (error) { + postMessage({ + type: "error", + error, + timeoutId, + }); + } +}; diff --git a/public/thumbnailWorker.js b/public/thumbnailWorker.js new file mode 100644 index 0000000..bb3199b --- /dev/null +++ b/public/thumbnailWorker.js @@ -0,0 +1,39 @@ +// pre-generated thumbnail qrcode +const PREVIEW_OUTPUTQR = { + text: "https://qrfra.me", + // prettier-ignore + matrix: [3,3,3,3,3,3,3,12,8,0,0,0,0,12,3,3,3,3,3,3,3,3,2,2,2,2,2,3,12,9,0,0,1,1,12,3,2,2,2,2,2,3,3,2,3,3,3,2,3,12,8,1,0,1,1,12,3,2,3,3,3,2,3,3,2,3,3,3,2,3,12,9,1,0,1,0,12,3,2,3,3,3,2,3,3,2,3,3,3,2,3,12,8,0,1,0,0,12,3,2,3,3,3,2,3,3,2,2,2,2,2,3,12,9,1,1,1,1,12,3,2,2,2,2,2,3,3,3,3,3,3,3,3,12,7,6,7,6,7,12,3,3,3,3,3,3,3,12,12,12,12,12,12,12,12,8,0,1,0,0,12,12,12,12,12,12,12,12,9,9,9,9,9,8,7,9,9,1,0,1,0,9,8,9,8,9,8,9,8,0,0,0,1,0,0,6,1,0,0,1,0,0,1,1,1,1,1,1,1,1,1,1,0,0,1,0,7,1,0,0,1,0,1,1,1,1,0,0,1,1,0,0,1,1,0,0,0,6,0,0,1,1,1,1,1,0,0,1,1,1,0,0,1,0,0,1,0,0,7,1,1,0,0,0,1,1,1,0,1,1,0,0,1,12,12,12,12,12,12,12,12,9,1,0,0,0,0,0,1,1,1,1,0,1,3,3,3,3,3,3,3,12,9,1,1,1,1,1,0,1,0,0,1,1,0,3,2,2,2,2,2,3,12,8,1,0,0,0,1,0,1,1,1,1,0,0,3,2,3,3,3,2,3,12,9,0,0,0,1,1,1,1,1,1,0,0,0,3,2,3,3,3,2,3,12,9,0,1,1,0,0,0,0,1,0,1,1,0,3,2,3,3,3,2,3,12,9,1,1,1,1,0,1,1,0,0,1,0,0,3,2,2,2,2,2,3,12,9,0,0,0,0,1,0,0,1,1,1,0,0,3,3,3,3,3,3,3,12,9,1,0,1,1,0,1,1,0,1,0,1,0], + version: 1, + ecl: 0, //ECL.Low + mode: 2, // Mode.Byte + mask: 2, // Mask.M2 +}; + +onmessage = async ({ data: { type, url, params, timeoutId } }) => { + try { + switch (type) { + case "svg": { + const { renderSVG } = await import(url); + const svg = await renderSVG(PREVIEW_OUTPUTQR, params); + + postMessage({ type, svg, timeoutId }); + break; + } + case "canvas": { + const { renderCanvas } = await import(url); + const canvas = new OffscreenCanvas(0, 0); + await renderCanvas(PREVIEW_OUTPUTQR, params, canvas); + + const bitmap = canvas.transferToImageBitmap(); + postMessage({ type, bitmap, timeoutId }, [bitmap]); + break; + } + } + } catch (error) { + postMessage({ + type: "error", + error, + timeoutId, + }); + } +}; diff --git a/src/components/editor/QrEditor.tsx b/src/components/editor/QrEditor.tsx index 877caaf..33d826d 100644 --- a/src/components/editor/QrEditor.tsx +++ b/src/components/editor/QrEditor.tsx @@ -8,16 +8,10 @@ import { defaultParams, paramsEqual, parseParamsSchema, - type Params, type ParamsSchema, } from "~/lib/params"; import { PRESET_CODE } from "~/lib/presets"; -import { - PREVIEW_OUTPUTQR, - useQrContext, - type RenderCanvas, - type RenderSVG, -} from "~/lib/QrContext"; +import { useQrContext, type RenderType } from "~/lib/QrContext"; import { FillButton, FlatButton } from "../Button"; import { Collapsible } from "../Collapsible"; import { IconButtonDialog } from "../Dialog"; @@ -52,14 +46,13 @@ function isPreset(key: string): key is keyof typeof PRESET_CODE { export function Editor(props: Props) { const { setInputQr, - setRenderSVG, - setRenderCanvas, - renderFuncKey, - setRenderFuncKey, paramsSchema, setParamsSchema, params, setParams, + renderKey, + setRenderKey, + setRender, } = useQrContext(); const [code, setCode] = createSignal(PRESET_CODE.Square); @@ -79,6 +72,9 @@ export function Editor(props: Props) { Minimal: "", }); + let thumbWorker: Worker | null = null; + const timeoutIdMap = new Map(); + onMount(async () => { const storedFuncKeys = localStorage.getItem(CUSTOM_FUNCS); let keys; @@ -92,20 +88,22 @@ export function Editor(props: Props) { for (const key of keys) { if (isPreset(key)) { - // const tryThumb = localStorage.getItem(`${key}_thumb`); - // if (tryThumb != null) { - // setThumbs(key, tryThumb); - // continue; - // } else { - // No try-catch b/c presets should not have errors - const { renderSVG, renderCanvas, parsedParamsSchema } = - await importCode(PRESET_CODE[key]); - await updateThumbnail(key, renderSVG, renderCanvas, parsedParamsSchema); - // } + const tryThumb = localStorage.getItem(`${key}_thumb`); + if (tryThumb != null) { + setThumbs(key, tryThumb); + continue; + } else { + // preset CAN error out, e.g. when importing 3rd party dep + try { + const { type, url, parsedParamsSchema } = await importCode( + PRESET_CODE[key] + ); + updateThumbnail(key, type, url, parsedParamsSchema); + } catch (e) { + // skippa + } + } } - - const thumb = localStorage.getItem(`${key}_thumb`) ?? FALLBACK_THUMB; - setThumbs(key, thumb); } }); @@ -119,7 +117,7 @@ export function Editor(props: Props) { }; const setExistingKey = (key: string) => { - setRenderFuncKey(key); + setRenderKey(key); if (isPreset(key)) { trySetCode(PRESET_CODE[key], false); } else { @@ -133,50 +131,43 @@ export function Editor(props: Props) { const importCode = async (code: string) => { const blob = new Blob([code], { type: "text/javascript" }); + // This url is cleaned up in trySetCode() const url = URL.createObjectURL(blob); - // TODO check perf of caching functions const { renderSVG, renderCanvas, paramsSchema: rawParamsSchema, - } = await import(/* @vite-ignore */ url).finally(() => - URL.revokeObjectURL(url) - ); + } = await import(/* @vite-ignore */ url); - if (typeof renderCanvas !== "function" && typeof renderSVG !== "function") { + let type = "" as RenderType; + if (typeof renderSVG === "function") { + type = "svg"; + } + if (typeof renderCanvas === "function") { + if (type) { + throw new Error("renderSVG and renderCanvas cannot both be exported"); + } + type = "canvas"; + } + if (!type) { throw new Error("renderSVG or renderCanvas must be exported"); - } else if ( - typeof renderCanvas === "function" && - typeof renderSVG === "function" - ) { - throw new Error("renderSVG and renderCanvas cannot both be exported"); } // TODO see impl, user set default and props might be wrong const parsedParamsSchema = parseParamsSchema(rawParamsSchema); - return { renderSVG, renderCanvas, parsedParamsSchema }; + return { type, url, parsedParamsSchema }; }; const trySetCode = async (code: string, changed: boolean) => { try { - const { renderSVG, renderCanvas, parsedParamsSchema } = await importCode( - code - ); - if ( - typeof renderCanvas !== "function" && - typeof renderSVG !== "function" - ) { - throw new Error("renderSVG or renderCanvas must be exported"); - } else if ( - typeof renderCanvas === "function" && - typeof renderSVG === "function" - ) { - throw new Error("renderSVG and renderCanvas cannot both be exported"); - } + // If import fails and code is unchanged, it should still load + // otherwise, changed code should only save if valid + if (!changed) setCode(code); + const { type, url, parsedParamsSchema } = await importCode(code); setCompileError(null); - setCode(code); + if (changed) setCode(code); // batched b/c trigger rendering effect batch(() => { @@ -184,70 +175,87 @@ export function Editor(props: Props) { setParams(defaultParams(parsedParamsSchema)); } setParamsSchema(parsedParamsSchema); // always update in case different property order - - setRenderSVG(() => renderSVG ?? null); - setRenderCanvas(() => renderCanvas ?? null); + setRender((prev) => { + // TODO check perf of caching + if (prev != null) { + URL.revokeObjectURL(prev.url); + } + return { type, url }; + }); }); if (changed) { - localStorage.setItem(renderFuncKey(), code); - updateThumbnail( - renderFuncKey(), - renderSVG, - renderCanvas, - parsedParamsSchema - ); + localStorage.setItem(renderKey(), code); + updateThumbnail(renderKey(), type, url, parsedParamsSchema); } } catch (e) { - console.log("e", e!.toString()); + console.error("e", e!.toString()); setCompileError(e!.toString()); } }; - const updateThumbnail = async ( - renderKey: string, - renderSVG: RenderSVG | undefined, - renderCanvas: RenderCanvas | undefined, + const updateThumbnail = ( + key: string, + type: "svg" | "canvas", + url: string, parsedParamsSchema: ParamsSchema ) => { - try { - const defaultParams: Params = {}; - Object.entries(parsedParamsSchema).forEach(([label, props]) => { - defaultParams[label] = props.default; - }); + if (thumbWorker == null) setupThumbWorker(); + + const timeoutId = setTimeout(() => { + console.error( + `Thumbnail render took longer than 5 seconds, timed out!`, + timeoutId + ); + timeoutIdMap.delete(timeoutId); + if (thumbWorker != null) { + thumbWorker.terminate(); + thumbWorker = null; + } + }, 5000); + timeoutIdMap.set(timeoutId, key); + + thumbWorker!.postMessage({ + type, + url, + params: defaultParams(parsedParamsSchema), + timeoutId, + }); + }; + + const setupThumbWorker = () => { + thumbWorker = new Worker("thumbnailWorker.js", { type: "module" }); + + thumbWorker.onmessage = (e) => { + clearTimeout(e.data.timeoutId); + const key = timeoutIdMap.get(e.data.timeoutId)!; + timeoutIdMap.delete(e.data.timeoutId); let thumbnail; - if (renderSVG != null) { - // https://www.phpied.com/truth-encoding-svg-data-uris/ - // Only need to encode # - thumbnail = - "data:image/svg+xml," + - (await renderSVG(PREVIEW_OUTPUTQR, defaultParams).replaceAll( - "#", - "%23" - )); - } else { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d")!; - await renderCanvas!(PREVIEW_OUTPUTQR, defaultParams, ctx); + switch (e.data.type) { + case "svg": + thumbnail = "data:image/svg+xml," + e.data.svg.replaceAll("#", "%23"); + break; + case "canvas": + const size = 96; + const smallCanvas = document.createElement("canvas"); - const smallCanvas = document.createElement("canvas"); - const size = 96; - smallCanvas.width = size; - smallCanvas.height = size; - const smallCtx = smallCanvas.getContext("2d")!; - smallCtx.drawImage(canvas, 0, 0, size, size); - thumbnail = smallCanvas.toDataURL("image/jpeg", 0.5); + smallCanvas.width = size; + smallCanvas.height = size; + const smallCtx = smallCanvas.getContext("2d")!; + smallCtx.drawImage(e.data.bitmap, 0, 0, size, size); + e.data.bitmap.close(); + + thumbnail = smallCanvas.toDataURL("image/jpeg", 0.5); + break; + case "error": + console.error(e.data.error); + return; } - localStorage.setItem(`${renderKey}_thumb`, thumbnail); - - if (!presetKeys.includes(renderKey)) { - setThumbs(renderKey, thumbnail); - } - } catch (e) { - console.error(`${renderKey} thumbnail render:`, e); - } + localStorage.setItem(`${key}_thumb`, thumbnail!); + setThumbs(key, thumbnail!); + }; }; const createAndSelectFunc = (name: string, code: string) => { @@ -261,7 +269,7 @@ export function Editor(props: Props) { // TODO double setting thumbs setThumbs(key, FALLBACK_THUMB); - setRenderFuncKey(key); + setRenderKey(key); trySetCode(code, true); }; @@ -282,16 +290,16 @@ export function Editor(props: Props) { Render function
-
{renderFuncKey()}
- +
{renderKey()}
+ } onOpenAutoFocus={(e) => e.preventDefault()} > {(close) => { - const [rename, setRename] = createSignal(renderFuncKey()); + const [rename, setRename] = createSignal(renderKey()); const [duplicate, setDuplicate] = createSignal(false); let ref: HTMLInputElement; @@ -304,7 +312,7 @@ export function Editor(props: Props) { defaultValue={rename()} onChange={setRename} onInput={() => duplicate() && setDuplicate(false)} - placeholder={renderFuncKey()} + placeholder={renderKey()} />
@@ -315,30 +323,28 @@ export function Editor(props: Props) { class="px-3 py-2 float-right mt-4" // input onChange runs after focus lost, so onMouseDown is too early onClick={() => { - if (rename() === renderFuncKey()) return close(); + if (rename() === renderKey()) return close(); if (funcKeys.includes(rename())) { setDuplicate(true); return; } - localStorage.removeItem(renderFuncKey()); - localStorage.removeItem( - `${renderFuncKey()}_thumb` - ); + localStorage.removeItem(renderKey()); + localStorage.removeItem(`${renderKey()}_thumb`); - const thumb = thumbs[renderFuncKey()]; + const thumb = thumbs[renderKey()]; localStorage.setItem(rename(), code()); localStorage.setItem(`${rename()}_thumb`, thumb); setThumbs(rename(), thumb); - setThumbs(renderFuncKey(), undefined!); + setThumbs(renderKey(), undefined!); setFuncKeys( - funcKeys.indexOf(renderFuncKey()), + funcKeys.indexOf(renderKey()), rename() ); - setRenderFuncKey(rename()); + setRenderKey(rename()); close(); }} > @@ -349,7 +355,7 @@ export function Editor(props: Props) { }} } > @@ -361,14 +367,12 @@ export function Editor(props: Props) {
{ - localStorage.removeItem(renderFuncKey()); - localStorage.removeItem( - `${renderFuncKey()}_thumb` - ); - setThumbs(renderFuncKey(), undefined!); + localStorage.removeItem(renderKey()); + localStorage.removeItem(`${renderKey()}_thumb`); + setThumbs(renderKey(), undefined!); setFuncKeys((keys) => - keys.filter((key) => key !== renderFuncKey()) + keys.filter((key) => key !== renderKey()) ); setExistingKey(funcKeys[0]); @@ -391,7 +395,7 @@ export function Editor(props: Props) { setExistingKey(key)} label={key} - active={renderFuncKey() === key} + active={renderKey() === key} > @@ -437,8 +441,8 @@ export function Editor(props: Props) { { - if (presetKeys.includes(renderFuncKey())) { - createAndSelectFunc(renderFuncKey(), code); + if (presetKeys.includes(renderKey())) { + createAndSelectFunc(renderKey(), code); } else { trySetCode(code, true); } diff --git a/src/components/preview/QrPreview.tsx b/src/components/preview/QrPreview.tsx index f132b16..a05ce1f 100644 --- a/src/components/preview/QrPreview.tsx +++ b/src/components/preview/QrPreview.tsx @@ -1,13 +1,6 @@ import { QrError } from "fuqr"; import Download from "lucide-solid/icons/download"; -import { - Match, - Show, - Switch, - createEffect, - createSignal, - untrack, -} from "solid-js"; +import { Match, Show, Switch, createEffect, createSignal } from "solid-js"; import { useQrContext, type OutputQr } from "~/lib/QrContext"; import { ECL_LABELS, @@ -47,7 +40,7 @@ export default function QrPreview(props: Props) { {`Input cannot be encoded in ${ - // @ts-expect-error props.mode not null b/c InvalidEncoding implies mode + // @ts-expect-error props.mode not null b/c InvalidEncoding requires mode MODE_NAMES[inputQr.mode + 1] } mode`} @@ -71,9 +64,8 @@ export default function QrPreview(props: Props) { function RenderedQrCode() { const { outputQr: _outputQr, - getRenderSVG, - getRenderCanvas, - renderFuncKey, + render, + renderKey, params, paramsSchema, } = useQrContext(); @@ -86,52 +78,77 @@ function RenderedQrCode() { const [canvasDims, setCanvasDims] = createSignal({ width: 0, height: 0 }); - let prevFuncKey = ""; + let worker: Worker | null = null; + const timeoutIdSet = new Set(); - let renderCount = 0; createEffect(async () => { - const render = getRenderSVG(); - const fallbackRender = getRenderCanvas(); + const r = render(); - // Track store without passing store into function and leaking extra params + // Track store without leaking extra params const paramsCopy: Params = {}; Object.keys(paramsSchema()).forEach((key) => { paramsCopy[key] = params[key]; }); - if (render == null && fallbackRender == null) return; // only true on page load - const currentRender = ++renderCount; + // all reactive deps must be above early return! + // true on page load + if (r == null) return; - prevFuncKey = untrack(renderFuncKey); - try { - // users can arbitrarily manipulate function args - // outputQr (big) is frozen, and params (small) is copied + if (worker == null) setupWorker(); - if (render != null) { - const svgString = await render(outputQr(), paramsCopy); - if (currentRender !== renderCount) { - // race condition - return; - } - - svgParent.innerHTML = svgString; - } else { - const ctx = canvas.getContext("2d")!; - ctx.reset(); - - // race condition can't be solved without double buffering - await fallbackRender!(outputQr(), paramsCopy, ctx); - - setCanvasDims({ width: ctx.canvas.width, height: ctx.canvas.height }); + const timeoutId = setTimeout(() => { + console.error(`Render took longer than 5 seconds, timed out!`, timeoutId); + timeoutIdSet.delete(timeoutId); + if (worker != null) { + worker.terminate(); + worker = null } + }, 5000); + timeoutIdSet.add(timeoutId); - setRuntimeError(null); - } catch (e) { - setRuntimeError(e!.toString()); - console.error(`${prevFuncKey} render:`, e); - } + worker!.postMessage({ + type: r.type, + url: r.url, + qr: outputQr(), + params: paramsCopy, + timeoutId, + }); + + return () => { + worker?.terminate(); + timeoutIdSet.forEach((timeout) => clearTimeout(timeout)); + }; }); + const setupWorker = () => { + console.log("new worker") + worker = new Worker("renderWorker.js", { type: "module" }); + + worker.onmessage = (e) => { + clearTimeout(e.data.timeoutId); + timeoutIdSet.delete(e.data.timeoutId); + + switch (e.data.type) { + case "svg": + svgParent.innerHTML = e.data.svg; + setRuntimeError(null); + break; + case "canvas": + canvas + .getContext("bitmaprenderer")! + .transferFromImageBitmap(e.data.bitmap); + setCanvasDims({ width: canvas.width, height: canvas.height }); + setRuntimeError(null); + break; + case "error": + console.error(e.data.error); + break; + // case "canceled": + // break; + } + }; + }; + const filename = () => { const s = outputQr().text.slice(0, 32); // : and / are not valid filename chars, so it looks bad @@ -141,22 +158,24 @@ function RenderedQrCode() { return ( <>
- -
-
- - - + + +
+
+ + + +
{runtimeError()}
- +
{canvasDims().width}x{canvasDims().height} px
@@ -194,7 +213,7 @@ function RenderedQrCode() { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d")!; // TODO allow adjust resolution/aspect ratio - const size = 300 //(outputQr().version * 4 + 17) * 10; + const size = 300; //(outputQr().version * 4 + 17) * 10; canvas.width = size; canvas.height = size; @@ -214,7 +233,7 @@ function RenderedQrCode() { Download PNG - + { diff --git a/src/lib/QrContext.tsx b/src/lib/QrContext.tsx index 7b574c4..e3eddc0 100644 --- a/src/lib/QrContext.tsx +++ b/src/lib/QrContext.tsx @@ -43,14 +43,10 @@ export const QrContext = createContext<{ inputQr: InputQr; setInputQr: SetStoreFunction; outputQr: Accessor; - - getRenderSVG: Accessor; - setRenderSVG: Setter; - getRenderCanvas: Accessor; - setRenderCanvas: Setter; - - renderFuncKey: Accessor; - setRenderFuncKey: Setter; + render: Accessor; + setRender: Setter; + renderKey: Accessor; + setRenderKey: Setter; params: Params; setParams: SetStoreFunction; paramsSchema: Accessor; @@ -65,6 +61,14 @@ export type RenderCanvas = ( export type RenderSVG = (qr: OutputQr, params: Params) => string; +const renderTypes = ["svg", "canvas"] as const; +export type RenderType = (typeof renderTypes)[number]; + +type Render = { + type: RenderType; + url: string; +}; + export function QrContextProvider(props: { children: JSX.Element }) { const [inputQr, setInputQr] = createStore({ text: "https://qrframe.kylezhe.ng", @@ -76,11 +80,8 @@ export function QrContextProvider(props: { children: JSX.Element }) { mask: null, }); - const [renderCanvas, setRenderCanvas] = createSignal( - null - ); - const [renderSVG, setRenderSVG] = createSignal(null); - const [renderFuncKey, setRenderFuncKey] = createSignal(""); + const [renderKey, setRenderKey] = createSignal("Square"); + const [render, setRender] = createSignal(null); const [paramsSchema, setParamsSchema] = createSignal({}); const [params, setParams] = createStore({}); @@ -122,12 +123,10 @@ export function QrContextProvider(props: { children: JSX.Element }) { inputQr, setInputQr, outputQr, - getRenderSVG: renderSVG, - setRenderSVG, - getRenderCanvas: renderCanvas, - setRenderCanvas, - renderFuncKey, - setRenderFuncKey, + render, + setRender, + renderKey, + setRenderKey, params, setParams, paramsSchema, @@ -147,13 +146,4 @@ export function useQrContext() { return context; } -// pre generated b/c needed often for thumbnails (generated on every save/load) -export const PREVIEW_OUTPUTQR = { - text: "https://qrfra.me", - // prettier-ignore - matrix: [3,3,3,3,3,3,3,12,8,0,0,0,0,12,3,3,3,3,3,3,3,3,2,2,2,2,2,3,12,9,0,0,1,1,12,3,2,2,2,2,2,3,3,2,3,3,3,2,3,12,8,1,0,1,1,12,3,2,3,3,3,2,3,3,2,3,3,3,2,3,12,9,1,0,1,0,12,3,2,3,3,3,2,3,3,2,3,3,3,2,3,12,8,0,1,0,0,12,3,2,3,3,3,2,3,3,2,2,2,2,2,3,12,9,1,1,1,1,12,3,2,2,2,2,2,3,3,3,3,3,3,3,3,12,7,6,7,6,7,12,3,3,3,3,3,3,3,12,12,12,12,12,12,12,12,8,0,1,0,0,12,12,12,12,12,12,12,12,9,9,9,9,9,8,7,9,9,1,0,1,0,9,8,9,8,9,8,9,8,0,0,0,1,0,0,6,1,0,0,1,0,0,1,1,1,1,1,1,1,1,1,1,0,0,1,0,7,1,0,0,1,0,1,1,1,1,0,0,1,1,0,0,1,1,0,0,0,6,0,0,1,1,1,1,1,0,0,1,1,1,0,0,1,0,0,1,0,0,7,1,1,0,0,0,1,1,1,0,1,1,0,0,1,12,12,12,12,12,12,12,12,9,1,0,0,0,0,0,1,1,1,1,0,1,3,3,3,3,3,3,3,12,9,1,1,1,1,1,0,1,0,0,1,1,0,3,2,2,2,2,2,3,12,8,1,0,0,0,1,0,1,1,1,1,0,0,3,2,3,3,3,2,3,12,9,0,0,0,1,1,1,1,1,1,0,0,0,3,2,3,3,3,2,3,12,9,0,1,1,0,0,0,0,1,0,1,1,0,3,2,3,3,3,2,3,12,9,1,1,1,1,0,1,1,0,0,1,0,0,3,2,2,2,2,2,3,12,9,0,0,0,0,1,0,0,1,1,1,0,0,3,3,3,3,3,3,3,12,9,1,0,1,1,0,1,1,0,1,0,1,0], - version: 1, - ecl: ECL.Low, - mode: Mode.Byte, - mask: Mask.M2, -}; + diff --git a/src/lib/presets/Halftone.ts b/src/lib/presets/Halftone.ts index 0c56692..0092b4c 100644 --- a/src/lib/presets/Halftone.ts +++ b/src/lib/presets/Halftone.ts @@ -58,7 +58,7 @@ const Module = { SeparatorOFF: 12, }; -export async function renderCanvas(qr, params, ctx) { +export async function renderCanvas(qr, params, canvas) { const unit = 3; const pixel = 1; @@ -68,10 +68,17 @@ export async function renderCanvas(qr, params, ctx) { const bg = params["Background"]; const alignment = params["Alignment pattern"]; const timing = params["Timing pattern"]; - const file = params["Image"]; + let file = params["Image"]; + if (file == null) { + file = await fetch( + "https://upload.wikimedia.org/wikipedia/commons/1/14/The_Widow_%28Boston_Public_Library%29_%28cropped%29.jpg" + ).then((res) => res.blob()); + } + const image = await createImageBitmap(file) const pixelWidth = matrixWidth + 2 * margin; const canvasSize = pixelWidth * unit; + const ctx = canvas.getContext("2d"); ctx.canvas.width = canvasSize; ctx.canvas.height = canvasSize; @@ -91,27 +98,10 @@ export async function renderCanvas(qr, params, ctx) { } } - const image = new Image(); - - if (file != null) { - image.src = URL.createObjectURL(file); - } else { - // if canvas tainted, need to reload - // https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image - image.crossOrigin = "anonymous"; - image.src = - "https://upload.wikimedia.org/wikipedia/commons/1/14/The_Widow_%28Boston_Public_Library%29_%28cropped%29.jpg"; - } - await image.decode(); - ctx.filter = \`brightness(\${params["Brightness"]}) contrast(\${params["Contrast"]})\`; ctx.drawImage(image, 0, 0, canvasSize, canvasSize); ctx.filter = "none"; - if (file != null) { - URL.revokeObjectURL(image.src); - } - const imageData = ctx.getImageData(0, 0, canvasSize, canvasSize); const data = imageData.data; diff --git a/src/lib/presets/Quantum.ts b/src/lib/presets/Quantum.ts index df8e56c..b72e510 100644 --- a/src/lib/presets/Quantum.ts +++ b/src/lib/presets/Quantum.ts @@ -89,7 +89,7 @@ export function renderSVG(qr, params) { svg += \`\`; switch (params["Finder pattern"]) { - case "Atom": { + case "Atom": let r1 = 0.98; let r2 = 1.5; @@ -101,11 +101,11 @@ export function renderSVG(qr, params) { svg += \`M\${x + 3.5},\${y + 3.5 - 3 * r2}a\${r1},\${r2} 0,0,1 0,\${6 * r2}a\${r1},\${r2} 0,0,1 0,\${-6 * r2}\`; break; - } - case "Planet": { + + case "Planet": svg += \`\`; }