kopia lustrzana https://github.com/zhengkyl/qrframe
web workers but sometimes flaky
rodzic
ce52b78911
commit
9ac333bfd9
Plik binarny nie jest wyświetlany.
Przed Szerokość: | Wysokość: | Rozmiar: 2.9 KiB Po Szerokość: | Wysokość: | Rozmiar: 2.1 KiB |
|
@ -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;
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@ export function renderSVG(qr, params) {
|
|||
svg += `<circle cx="${x + 3.5}" cy="${y + 6.5}" r="0.5"/>`;
|
||||
|
||||
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 += `<path fill="none" stroke-width="0.1" stroke="${fg}" stroke-dasharray="0.5 0.65" d="`;
|
||||
svg += `M${x + 3.5},${y + 0.5}a3,3 0,0,1 0,6a3,3 0,0,1 0-6`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
svg += `"/></g>`;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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<NodeJS.Timeout, string>();
|
||||
|
||||
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
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center font-bold">{renderFuncKey()}</div>
|
||||
<Show when={!presetKeys.includes(renderFuncKey())}>
|
||||
<div class="flex items-center font-bold">{renderKey()}</div>
|
||||
<Show when={!presetKeys.includes(renderKey())}>
|
||||
<IconButtonDialog
|
||||
title={`Rename ${renderFuncKey()}`}
|
||||
title={`Rename ${renderKey()}`}
|
||||
triggerTitle="Rename"
|
||||
triggerChildren={<Pencil class="w-5 h-5" />}
|
||||
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()}
|
||||
/>
|
||||
<div class="absolute p-1 text-sm text-red-600">
|
||||
<Show when={duplicate()}>
|
||||
|
@ -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) {
|
|||
}}
|
||||
</IconButtonDialog>
|
||||
<IconButtonDialog
|
||||
title={`Delete ${renderFuncKey()}`}
|
||||
title={`Delete ${renderKey()}`}
|
||||
triggerTitle="Delete"
|
||||
triggerChildren={<Trash2 class="w-5 h-5" />}
|
||||
>
|
||||
|
@ -361,14 +367,12 @@ export function Editor(props: Props) {
|
|||
<div class="flex justify-end gap-2">
|
||||
<FillButton
|
||||
onMouseDown={() => {
|
||||
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) {
|
|||
<Preview
|
||||
onClick={() => setExistingKey(key)}
|
||||
label={key}
|
||||
active={renderFuncKey() === key}
|
||||
active={renderKey() === key}
|
||||
>
|
||||
<img class="rounded-sm" src={thumbs[key]} />
|
||||
</Preview>
|
||||
|
@ -437,8 +441,8 @@ export function Editor(props: Props) {
|
|||
<CodeEditor
|
||||
initialValue={code()}
|
||||
onSave={(code) => {
|
||||
if (presetKeys.includes(renderFuncKey())) {
|
||||
createAndSelectFunc(renderFuncKey(), code);
|
||||
if (presetKeys.includes(renderKey())) {
|
||||
createAndSelectFunc(renderKey(), code);
|
||||
} else {
|
||||
trySetCode(code, true);
|
||||
}
|
||||
|
|
|
@ -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) {
|
|||
</Match>
|
||||
<Match when={outputQr() === QrError.InvalidEncoding}>
|
||||
{`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`}
|
||||
</Match>
|
||||
|
@ -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<NodeJS.Timeout>();
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div class="checkboard aspect-[1/1] border rounded-md relative overflow-hidden">
|
||||
<Show when={getRenderSVG() != null}>
|
||||
<div ref={svgParent!}></div>
|
||||
</Show>
|
||||
<Show when={getRenderCanvas() != null}>
|
||||
<canvas
|
||||
class="w-full h-full image-render-pixel"
|
||||
ref={canvas!}
|
||||
></canvas>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={render()?.type === "svg"}>
|
||||
<div ref={svgParent!}></div>
|
||||
</Match>
|
||||
<Match when={render()?.type === "canvas"}>
|
||||
<canvas
|
||||
class="w-full h-full image-render-pixel"
|
||||
ref={canvas!}
|
||||
></canvas>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={runtimeError() != null}>
|
||||
<div class="text-red-100 bg-red-950 px-2 py-1 rounded-md">
|
||||
{runtimeError()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={getRenderCanvas() != null}>
|
||||
<Show when={render()?.type === "canvas"}>
|
||||
<div class="text-center">
|
||||
{canvasDims().width}x{canvasDims().height} px
|
||||
</div>
|
||||
|
@ -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 size={20} />
|
||||
Download PNG
|
||||
</FlatButton>
|
||||
<Show when={getRenderSVG() != null}>
|
||||
<Show when={render()?.type === "svg"}>
|
||||
<FlatButton
|
||||
class="inline-flex justify-center items-center gap-1 flex-1 px-3 py-2"
|
||||
onClick={async () => {
|
||||
|
|
|
@ -43,14 +43,10 @@ export const QrContext = createContext<{
|
|||
inputQr: InputQr;
|
||||
setInputQr: SetStoreFunction<InputQr>;
|
||||
outputQr: Accessor<OutputQr | QrError>;
|
||||
|
||||
getRenderSVG: Accessor<RenderSVG | null>;
|
||||
setRenderSVG: Setter<RenderSVG | null>;
|
||||
getRenderCanvas: Accessor<RenderCanvas | null>;
|
||||
setRenderCanvas: Setter<RenderCanvas | null>;
|
||||
|
||||
renderFuncKey: Accessor<string>;
|
||||
setRenderFuncKey: Setter<string>;
|
||||
render: Accessor<Render | null>;
|
||||
setRender: Setter<Render | null>;
|
||||
renderKey: Accessor<string>;
|
||||
setRenderKey: Setter<string>;
|
||||
params: Params;
|
||||
setParams: SetStoreFunction<Params>;
|
||||
paramsSchema: Accessor<ParamsSchema>;
|
||||
|
@ -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<InputQr>({
|
||||
text: "https://qrframe.kylezhe.ng",
|
||||
|
@ -76,11 +80,8 @@ export function QrContextProvider(props: { children: JSX.Element }) {
|
|||
mask: null,
|
||||
});
|
||||
|
||||
const [renderCanvas, setRenderCanvas] = createSignal<RenderCanvas | null>(
|
||||
null
|
||||
);
|
||||
const [renderSVG, setRenderSVG] = createSignal<RenderSVG | null>(null);
|
||||
const [renderFuncKey, setRenderFuncKey] = createSignal("");
|
||||
const [renderKey, setRenderKey] = createSignal<string>("Square");
|
||||
const [render, setRender] = createSignal<Render | null>(null);
|
||||
|
||||
const [paramsSchema, setParamsSchema] = createSignal<ParamsSchema>({});
|
||||
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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@ export function renderSVG(qr, params) {
|
|||
svg += \`<circle cx="\${x + 3.5}" cy="\${y + 6.5}" r="0.5"/>\`;
|
||||
|
||||
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 += \`<path fill="none" stroke-width="0.1" stroke="\${fg}" stroke-dasharray="0.5 0.65" d="\`;
|
||||
svg += \`M\${x + 3.5},\${y + 0.5}a3,3 0,0,1 0,6a3,3 0,0,1 0-6\`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
svg += \`"/></g>\`;
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue