support ui parameters by switching to import module

main
Kyle Zheng 2024-07-23 05:12:04 -04:00
rodzic 60b4a76d60
commit f9c6998d4f
12 zmienionych plików z 666 dodań i 485 usunięć

Wyświetl plik

@ -2989,8 +2989,8 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod@3.23.6:
resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==}
zod@3.23.8:
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
snapshots:
@ -5980,7 +5980,7 @@ snapshots:
unenv: 1.9.0
unstorage: 1.10.2(ioredis@5.4.1)
vite: 5.2.11(@types/node@20.12.8)(terser@5.31.0)
zod: 3.23.6
zod: 3.23.8
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@ -6129,4 +6129,4 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.5.2
zod@3.23.6: {}
zod@3.23.8: {}

Wyświetl plik

@ -1,16 +1,16 @@
type Props = {
color: string;
setColor: (c: string) => void;
value: string;
setValue: (c: string) => void;
};
export function ColorInput(props: Props) {
return (
<label class="border rounded-md font-mono inline-flex items-center py-1.5 px-2 gap-2 cursor-pointer hover:bg-fore-base/5">
{props.color}
{props.value}
<input
class="rounded-sm border-none w-6 h-6 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)"
type="color"
value={props.color}
onInput={(e) => props.setColor(e.target.value)}
value={props.value}
onInput={(e) => props.setValue(e.target.value)}
/>
</label>
);

Wyświetl plik

@ -13,8 +13,7 @@ export function ImageInput(props: Props) {
<div class="inline-flex items-center gap-1">
<input
class="border rounded-md text-sm px-1 py-2 file:(bg-transparent border-none text-fore-base) hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)"
// @ts-expect-error idk why this is angry
ref={input}
ref={input!}
type="file"
accept=".jpeg, .jpg, .png"
onChange={(e) => {

Wyświetl plik

@ -14,7 +14,7 @@ import { vim } from "@replit/codemirror-vim";
import { Button } from "@kobalte/core/button";
import { debounce } from "~/lib/util";
import { Switch } from "./Switch";
import { Switch } from "../Switch";
type Props = {
onSave: (s: string) => void;
@ -25,7 +25,7 @@ type Props = {
const INITIAL_VIM_MODE = false;
export function CodeInput(props: Props) {
export function CodeEditor(props: Props) {
let parent: HTMLDivElement;
let view: EditorView;
let modeComp = new Compartment();

Wyświetl plik

@ -1,27 +1,24 @@
import { For, Show, createSignal, onMount, type JSX } from "solid-js";
import { useQrContext, type RenderFunc } from "~/lib/QrContext";
import {
ECL_NAMES,
ECL_VALUE,
MASK_KEY,
MASK_NAMES,
MASK_VALUE,
MODE_KEY,
MODE_NAMES,
MODE_VALUE,
} from "~/lib/options";
import { ButtonGroup, ButtonGroupItem } from "../ButtonGroup";
import { TextInput, TextareaInput } from "../TextInput";
import { NumberInput } from "../NumberInput";
import { GroupedSelect, Select } from "../Select";
import { createStore } from "solid-js/store";
import { CodeInput } from "../CodeInput";
import Trash2 from "lucide-solid/icons/trash-2";
import Pencil from "lucide-solid/icons/pencil";
import { IconButtonDialog } from "../Dialog";
import Trash2 from "lucide-solid/icons/trash-2";
import { For, Show, batch, createSignal, onMount } from "solid-js";
import { createStore } from "solid-js/store";
import { Dynamic } from "solid-js/web";
import {
PARAM_COMPONENTS,
PARAM_DEFAULTS,
PARAM_TYPES,
type Params,
type ParamsSchema,
} from "~/lib/params";
import { PRESET_FUNCS } from "~/lib/presetFuncs";
import { useQrContext } from "~/lib/QrContext";
import { FillButton, FlatButton } from "../Button";
import { Collapsible } from "../Collapsible";
import { IconButtonDialog } from "../Dialog";
import { GroupedSelect } from "../Select";
import { TextInput, TextareaInput } from "../TextInput";
import { CodeEditor } from "./CodeEditor";
import { Settings } from "./Settings";
type Props = {
class?: string;
@ -32,11 +29,14 @@ const USER_FUNC_KEYS_KEY = "userFuncKeys";
export function Editor(props: Props) {
const {
inputQr,
setInputQr,
setRenderFunc,
renderFuncKey,
setRenderFuncKey,
paramsSchema,
setParamsSchema,
params,
setParams,
} = useQrContext();
const [code, setCode] = createSignal(PRESET_FUNCS.Square);
@ -55,21 +55,88 @@ export function Editor(props: Props) {
if (funcCode == null) continue;
setUserFuncKeys(userFuncKeys.length, key);
}
trySetCode(PRESET_FUNCS.Square)
});
const trySetCode = (newCode: string) => {
const trySetCode = async (newCode: string) => {
let url;
try {
const render = new Function("qr", "ctx", newCode) as RenderFunc;
const blob = new Blob([newCode], { type: "text/javascript" });
url = URL.createObjectURL(blob);
const {
renderCanvas,
renderSVG,
paramsSchema: rawParamsSchema,
} = await import(/* @vite-ignore */ url);
if (renderCanvas == null && renderSVG == null) {
throw new Error("One of renderCanvas and renderSVG must be exported");
} else if (renderCanvas != null && renderSVG != null) {
throw new Error("renderCanvas and renderSVG cannot both be exported");
}
// TODO
// refactor to parsing instead of validating...
// maybe use zod?
// for now this is easier and prevents obvious accidental crashing
let parsedParamsSchema: ParamsSchema = {};
if (typeof rawParamsSchema === "object") {
for (const [key, value] of Object.entries(rawParamsSchema)) {
if (
value == null ||
typeof value !== "object" ||
!("type" in value) ||
typeof value.type !== "string" ||
!PARAM_TYPES.includes(value.type)
) {
continue;
} else if (value.type === "Select") {
if (
!("options" in value) ||
!Array.isArray(value.options) ||
value.options.length === 0
) {
continue;
}
}
// @ts-expect-error prop types aren't validated yet, see above TODO
parsedParamsSchema[key] = value;
}
}
setCode(newCode);
setRenderFunc(() => render);
setCompileError(null);
setParamsSchema(parsedParamsSchema);
batch(() => {
const defaultParams: Params = {};
Object.entries(parsedParamsSchema).forEach(([label, props]) => {
// null is a valid default value for ImageInput
if (props.default !== undefined) {
defaultParams[label] = props.default;
} else if (props.type === "Select") {
defaultParams[label] = props.options[0];
} else {
defaultParams[label] = PARAM_DEFAULTS[props.type];
}
});
// todo we shouldn't override if paramSchema doesn't change
setParams(defaultParams); // todo init with default values from schema
setRenderFunc(() => renderCanvas);
});
if (!PRESET_FUNCS.hasOwnProperty(renderFuncKey())) {
localStorage.setItem(renderFuncKey(), newCode);
}
setCompileError(null);
} catch (e) {
console.log("e", e!.toString());
setCompileError(e!.toString());
}
URL.revokeObjectURL(url!);
};
const createAndSelectFunc = (name: string, code: string) => {
@ -93,55 +160,7 @@ export function Editor(props: Props) {
setValue={(s) => setInputQr("text", s)}
/>
<Collapsible trigger="Settings">
<div class="flex justify-between">
<div class="text-sm py-2">Encoding mode</div>
<Select
options={MODE_NAMES}
value={MODE_KEY[inputQr.mode!]}
setValue={(name) => setInputQr("mode", MODE_VALUE[name])}
/>
</div>
<Row title="Min version">
<NumberInput
min={1}
max={40}
value={inputQr.minVersion}
setValue={(v) => setInputQr("minVersion", v)}
/>
</Row>
<Row title="Min error tolerance">
<ButtonGroup
value={ECL_NAMES[inputQr.minEcl]}
setValue={(v) => setInputQr("minEcl", ECL_VALUE[v])}
>
<For each={ECL_NAMES}>
{(name) => <ButtonGroupItem value={name}>{name}</ButtonGroupItem>}
</For>
</ButtonGroup>
</Row>
<Row title="Mask pattern">
<ButtonGroup
value={MASK_KEY[inputQr.mask!]}
setValue={(name) => setInputQr("mask", MASK_VALUE[name])}
>
<For each={MASK_NAMES}>
{(value) => (
<ButtonGroupItem value={value}>{value}</ButtonGroupItem>
)}
</For>
</ButtonGroup>
</Row>
<Row title="Margin">
<NumberInput
min={0}
max={10}
step={1}
value={inputQr.margin.top}
setValue={(v) =>
setInputQr("margin", { top: v, bottom: v, left: v, right: v })
}
/>
</Row>
<Settings />
</Collapsible>
<Collapsible trigger="Rendering" defaultOpen>
<div class="mb-4">
@ -278,7 +297,27 @@ export function Editor(props: Props) {
</Show>
</div>
</div>
<CodeInput
<div class="flex flex-col gap-2 mb-4">
<For each={Object.entries(paramsSchema())}>
{([label, { type, ...props }]) => {
return (
<>
<div class="flex justify-between">
<div class="text-sm py-2 w-48">{label}</div>
{/* @ts-expect-error lose type b/c type and props destructured */}
<Dynamic
component={PARAM_COMPONENTS[type]}
{...props}
value={params[label]}
setValue={(v: any) => setParams(label, v)}
/>
</div>
</>
);
}}
</For>
</div>
<CodeEditor
initialValue={code()}
onSave={(code) => {
if (Object.keys(PRESET_FUNCS).includes(renderFuncKey())) {
@ -294,368 +333,3 @@ export function Editor(props: Props) {
</div>
);
}
function Row(props: {
tooltip?: string;
title: string;
children: JSX.Element;
}) {
return (
<div>
<div class="text-sm py-2" title={props.tooltip}>
{props.title}
</div>
{props.children}
</div>
);
}
const PRESET_FUNCS = {
Square: `// qr, ctx are args
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "rgb(0, 0, 0)";
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if (module & 1) {
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
`,
Circle: `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const gradient = ctx.createRadialGradient(
ctx.canvas.width / 2,
ctx.canvas.height / 2,
2 * pixelSize,
ctx.canvas.width / 2,
ctx.canvas.height / 2,
20 * pixelSize,
);
gradient.addColorStop(0, "red");
gradient.addColorStop(1, "blue");
ctx.fillStyle = gradient;
const radius = pixelSize / 2;
const finderPos = [
[qr.margin.left, qr.margin.top],
[qr.matrixWidth - qr.margin.right - 7, qr.margin.top],
[qr.margin.left, qr.matrixHeight - qr.margin.bottom - 7],
];
for (const [x, y] of finderPos) {
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 3.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 2.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 1.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
}
const xMid = qr.matrixWidth / 2;
const yMid = qr.matrixHeight / 2;
const maxDist = Math.sqrt(xMid * xMid + yMid + yMid);
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if (module & 1) {
if (module === Module.FinderON) continue;
if (module === Module.AlignmentON) {
// Find top left corner of alignment square
if (qr.matrix[(y - 1) * qr.matrixWidth + x] !== Module.AlignmentON &&
qr.matrix[y * qr.matrixWidth + x - 1] !== Module.AlignmentON &&
qr.matrix[y * qr.matrixWidth + x + 1] === Module.AlignmentON
) {
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 2.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 1.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 0.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
}
continue;
};
const xCenter = x * pixelSize + radius;
const yCenter = y * pixelSize + radius;
const xDist = Math.abs(xMid - x);
const yDist = Math.abs(yMid - y);
const scale = Math.sqrt(xDist * xDist + yDist * yDist) / maxDist * 0.7 + 0.5;
ctx.beginPath();
ctx.arc(xCenter, yCenter, radius * scale, 0, 2 * Math.PI);
ctx.fill();
}
}
}
`,
"Camouflage": `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
function splitmix32(a) {
return function() {
a |= 0;
a = a + 0x9e3779b9 | 0;
let t = a ^ a >>> 16;
t = Math.imul(t, 0x21f0aaad);
t = t ^ t >>> 15;
t = Math.imul(t, 0x735a2d97);
return ((t = t ^ t >>> 15) >>> 0) / 4294967296;
}
}
const seededRand = splitmix32(1 /* change seed to change pattern */);
// Randomly set pixels in margin
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
if (y > qr.margin.top - 2 &&
y < qr.matrixHeight - qr.margin.bottom + 1 &&
x > qr.margin.left - 2 &&
x < qr.matrixWidth - qr.margin.right + 1) {
continue;
}
if (seededRand() > 0.5) qr.matrix[y * qr.matrixWidth + x] = Module.DataON;
}
}
const pixelSize = 20;
const radius = pixelSize / 2;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const fg = "rgb(40, 70, 10)";
const bg = "rgb(200, 200, 100)";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const xMax = qr.matrixWidth - 1;
const yMax = qr.matrixHeight - 1;
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
const top = y > 0 && (qr.matrix[(y - 1) * qr.matrixWidth + x] & 1);
const bottom = y < yMax && (qr.matrix[(y + 1) * qr.matrixWidth + x] & 1);
const left = x > 0 && (qr.matrix[y * qr.matrixWidth + x - 1] & 1);
const right = x < xMax && (qr.matrix[y * qr.matrixWidth + x + 1] & 1);
ctx.fillStyle = fg;
if (module & 1) {
ctx.beginPath();
ctx.roundRect(
x * pixelSize,
y * pixelSize,
pixelSize,
pixelSize,
[
!left && !top && radius,
!top && !right && radius,
!right && !bottom && radius,
!bottom && !left && radius,
]
);
ctx.fill();
} else {
// Draw rounded concave corners
const topLeft = y > 0 && x > 0 && (qr.matrix[(y - 1) * qr.matrixWidth + x - 1] & 1);
const topRight = y > 0 && x < xMax && (qr.matrix[(y - 1) * qr.matrixWidth + x + 1] & 1);
const bottomRight = y < yMax && x < xMax && (qr.matrix[(y + 1) * qr.matrixWidth + x + 1] & 1);
const bottomLeft = y < yMax && x > 0 && (qr.matrix[(y + 1) * qr.matrixWidth + x - 1] & 1);
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctx.beginPath();
ctx.fillStyle = bg;
ctx.roundRect(
x * pixelSize,
y * pixelSize,
pixelSize,
pixelSize,
[
left && top && topLeft && radius,
top && right && topRight && radius,
right && bottom && bottomRight && radius,
bottom && left && bottomLeft && radius,
]
);
ctx.fill();
}
}
}
`,
Minimal: `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
const pixelSize = 12;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const finderPos = [
[qr.margin.left, qr.margin.top],
[qr.matrixWidth - qr.margin.right - 7, qr.margin.top],
[qr.margin.left, qr.matrixHeight - qr.margin.bottom - 7],
];
ctx.fillStyle = "rgb(0, 0, 0)";
for (const [x, y] of finderPos) {
ctx.fillRect((x + 3) * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 3) * pixelSize, (y + 6) * pixelSize, pixelSize, pixelSize);
ctx.fillRect(x * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 6) * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 2) * pixelSize, (y + 2) * pixelSize, 3 * pixelSize, 3 * pixelSize);
}
const minSize = pixelSize / 2;
const offset = (pixelSize - minSize) / 2;
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module | 1) === Module.FinderON) {
continue;
}
if (module & 1) {
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, minSize, minSize);
}
}
}
`,
"Lover (Animated)": `// qr, ctx are args
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const period = 3000; // ms
const amplitude = 0.8; // maxSize - minSize
const minSize = 0.6;
let counter = 0;
let prevTimestamp;
let req;
function frame(timestamp) {
// performance.now() and requestAnimationFrame's timestamp are not consistent together
if (prevTimestamp != null) {
counter += timestamp - prevTimestamp;
}
prevTimestamp = timestamp;
if (counter >= period) {
counter -= period;
}
ctx.fillStyle = "rgb(0, 0, 0)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module & 1) === 0) continue;
const xBias = Math.abs(5 - (x % 10));
const biasCounter = counter + (x + y) * (period / 20) + xBias * (period / 10);
const ratio = Math.abs((period / 2) - (biasCounter % period)) / (period / 2);
const size = (ratio * amplitude + minSize) * pixelSize;
const offset = (pixelSize - size) / 2;
ctx.fillStyle = \`rgb(\${100 + ratio * 150}, \${200 + xBias * 10}, 255)\`;
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, size, size);
}
}
req = requestAnimationFrame(frame);
}
req = requestAnimationFrame(frame);
return () => cancelAnimationFrame(req);`,
};

Wyświetl plik

@ -0,0 +1,87 @@
import { For, type JSX } from "solid-js";
import { useQrContext } from "~/lib/QrContext";
import {
ECL_NAMES,
ECL_VALUE,
MASK_KEY,
MASK_NAMES,
MASK_VALUE,
MODE_KEY,
MODE_NAMES,
MODE_VALUE,
} from "~/lib/options";
import { ButtonGroup, ButtonGroupItem } from "../ButtonGroup";
import { NumberInput } from "../NumberInput";
import { Select } from "../Select";
export function Settings() {
const { inputQr, setInputQr } = useQrContext();
return (
<>
<div class="flex justify-between">
<div class="text-sm py-2">Encoding mode</div>
<Select
options={MODE_NAMES}
value={MODE_KEY[inputQr.mode!]}
setValue={(name) => setInputQr("mode", MODE_VALUE[name])}
/>
</div>
<Row title="Min version">
<NumberInput
min={1}
max={40}
value={inputQr.minVersion}
setValue={(v) => setInputQr("minVersion", v)}
/>
</Row>
<Row title="Min error tolerance">
<ButtonGroup
value={ECL_NAMES[inputQr.minEcl]}
setValue={(v) => setInputQr("minEcl", ECL_VALUE[v])}
>
<For each={ECL_NAMES}>
{(name) => <ButtonGroupItem value={name}>{name}</ButtonGroupItem>}
</For>
</ButtonGroup>
</Row>
<Row title="Mask pattern">
<ButtonGroup
value={MASK_KEY[inputQr.mask!]}
setValue={(name) => setInputQr("mask", MASK_VALUE[name])}
>
<For each={MASK_NAMES}>
{(value) => (
<ButtonGroupItem value={value}>{value}</ButtonGroupItem>
)}
</For>
</ButtonGroup>
</Row>
<Row title="Margin">
<NumberInput
min={0}
max={10}
step={1}
value={inputQr.margin.top}
setValue={(v) =>
setInputQr("margin", { top: v, bottom: v, left: v, right: v })
}
/>
</Row>
</>
);
}
function Row(props: {
title: string;
children: JSX.Element;
}) {
return (
<div>
<div class="text-sm py-2">
{props.title}
</div>
{props.children}
</div>
);
}

Wyświetl plik

@ -33,7 +33,7 @@ type Props = {
};
export default function QrPreview(props: Props) {
const { inputQr, outputQr, renderFuncKey } = useQrContext();
const { inputQr, outputQr } = useQrContext();
return (
<div class={props.class}>
@ -69,7 +69,7 @@ export default function QrPreview(props: Props) {
* Running the effect in the ref function caused double rendering for future mounts.
*/
function RenderedQrCode() {
const { outputQr: _outputQr, renderFunc, renderFuncKey } = useQrContext();
const { outputQr: _outputQr, renderFunc, renderFuncKey, params } = useQrContext();
const outputQr = _outputQr as () => OutputQr;
const fullWidth = () => {
@ -112,9 +112,9 @@ function RenderedQrCode() {
// matrix isn't cloned without this line... this disables some optimization i think
const output = { ...outputQr() };
// Allow renderFunc to modify matrix with reset between renders
output.matrix = [...output.matrix]
output.matrix = [...output.matrix];
cleanupFunc = renderFunc()(output, ctx);
cleanupFunc = renderFunc()(output, params, ctx);
setRuntimeError(null);
} catch (e) {
setRuntimeError(e!.toString());

Wyświetl plik

@ -19,6 +19,7 @@ import {
get_matrix,
} from "fuqr";
import { createStore, type SetStoreFunction } from "solid-js/store";
import type { Params, ParamsSchema } from "./params";
type InputQr = {
text: string;
@ -63,10 +64,15 @@ export const QrContext = createContext<{
setRenderFunc: Setter<RenderFunc>;
renderFuncKey: Accessor<string>;
setRenderFuncKey: Setter<string>;
params: Params;
setParams: SetStoreFunction<Params>;
paramsSchema: Accessor<ParamsSchema>;
setParamsSchema: Setter<ParamsSchema>;
}>();
export type RenderFunc = (
qr: OutputQr,
params: {},
ctx: CanvasRenderingContext2D
) => void | (() => void);
@ -89,9 +95,12 @@ export function QrContextProvider(props: { children: JSX.Element }) {
QrError.InvalidEncoding
);
const [renderFunc, setRenderFunc] = createSignal<RenderFunc>(defaultRender);
const [renderFunc, setRenderFunc] = createSignal<RenderFunc>(()=>{});
const [renderFuncKey, setRenderFuncKey] = createSignal("Square");
const [paramsSchema, setParamsSchema] = createSignal<ParamsSchema>({});
const [params, setParams] = createStore({});
createEffect(() => {
try {
// NOTE: Version and Margin cannot be reused, so must be created each time
@ -141,7 +150,11 @@ export function QrContextProvider(props: { children: JSX.Element }) {
renderFunc,
setRenderFunc,
renderFuncKey,
setRenderFuncKey
setRenderFuncKey,
params,
setParams,
paramsSchema,
setParamsSchema,
}}
>
{props.children}
@ -156,24 +169,3 @@ export function useQrContext() {
}
return context;
}
function defaultRender(qr: OutputQr, ctx: CanvasRenderingContext2D) {
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "rgb(0, 0, 0)";
// ctx.imageSmoothingEnabled
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if (module & 1) {
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
}

57
src/lib/params.ts 100644
Wyświetl plik

@ -0,0 +1,57 @@
import { ColorInput } from "~/components/ColorInput";
import { ImageInput } from "~/components/ImageInput";
import { NumberInput } from "~/components/NumberInput";
import { Select } from "~/components/Select";
import { Switch } from "~/components/Switch";
export const PARAM_TYPES = ["boolean", "number", "Color", "Select", "File"]
export const PARAM_COMPONENTS = {
boolean: Switch,
number: NumberInput,
Color: ColorInput,
Select: Select,
File: ImageInput,
};
export const PARAM_DEFAULTS = {
boolean: false,
number: 0,
Color: "rgb(0,0,0)",
// Select: default is first option
File: null,
};
export type Params = {
[label: string]: Exclude<ParamsSchema[string]["default"], undefined>;
};
export type ParamsSchema = SchemaFromMapping<typeof PARAM_COMPONENTS>;
/**
* Given object mapping keys to components, returns union of [key, props]
*/
type Step1<T extends { [key: keyof any]: (...args: any) => any }> = {
[K in keyof T]: [K, Parameters<T[K]>[0]];
}[keyof T];
/**
* Given union of [key, props]:
*
* adds default to props based on type of value and removes value and setValue from props
*/
type Step2<T extends [keyof any, { [prop: string]: any }]> = T extends any
? [T[0], Omit<T[1], "value" | "setValue"> & { default?: T[1]["value"] }]
: never;
/**
* Converts each tuple to an object with a type corresponding to key
* (discriminated union of component props) assignable to any label
*/
type Step3<T extends [keyof any, { [prop: string]: any }]> = {
[label: string]: T extends any ? { type: T[0] } & T[1] : never;
};
type SchemaFromMapping<T extends { [key: string]: (...args: any) => any }> =
Step3<Step2<Step1<T>>>;

Wyświetl plik

@ -0,0 +1,374 @@
export const PRESET_FUNCS = {
Square: `export const paramsSchema = {
"Pixel size": {
type: "number",
min: 1,
max: 20,
default: 10
},
"Foreground": {
type: "Color",
default: "#000000",
},
"Background": {
type: "Color",
default: "#ffffff"
}
}
export function renderCanvas(qr, params, ctx) {
const pixelSize = params["Pixel size"];
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = params["Background"];
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = params["Foreground"];
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if (module & 1) {
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
}
}
`,
Circle: `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
export function renderCanvas(qr, params, ctx) {
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const gradient = ctx.createRadialGradient(
ctx.canvas.width / 2,
ctx.canvas.height / 2,
2 * pixelSize,
ctx.canvas.width / 2,
ctx.canvas.height / 2,
20 * pixelSize,
);
gradient.addColorStop(0, "red");
gradient.addColorStop(1, "blue");
ctx.fillStyle = gradient;
const radius = pixelSize / 2;
const finderPos = [
[qr.margin.left, qr.margin.top],
[qr.matrixWidth - qr.margin.right - 7, qr.margin.top],
[qr.margin.left, qr.matrixHeight - qr.margin.bottom - 7],
];
for (const [x, y] of finderPos) {
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 3.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 2.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc((x + 3.5) * pixelSize, (y + 3.5) * pixelSize, 1.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
}
const xMid = qr.matrixWidth / 2;
const yMid = qr.matrixHeight / 2;
const maxDist = Math.sqrt(xMid * xMid + yMid + yMid);
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if (module & 1) {
if (module === Module.FinderON) continue;
if (module === Module.AlignmentON) {
// Find top left corner of alignment square
if (qr.matrix[(y - 1) * qr.matrixWidth + x] !== Module.AlignmentON &&
qr.matrix[y * qr.matrixWidth + x - 1] !== Module.AlignmentON &&
qr.matrix[y * qr.matrixWidth + x + 1] === Module.AlignmentON
) {
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 2.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 1.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc((x + 2.5) * pixelSize, (y + 2.5) * pixelSize, 0.5 * pixelSize, 0, 2 * Math.PI);
ctx.fill();
}
continue;
};
const xCenter = x * pixelSize + radius;
const yCenter = y * pixelSize + radius;
const xDist = Math.abs(xMid - x);
const yDist = Math.abs(yMid - y);
const scale = Math.sqrt(xDist * xDist + yDist * yDist) / maxDist * 0.7 + 0.5;
ctx.beginPath();
ctx.arc(xCenter, yCenter, radius * scale, 0, 2 * Math.PI);
ctx.fill();
}
}
}
}
`,
Camouflage: `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
function splitmix32(a) {
return function() {
a |= 0;
a = a + 0x9e3779b9 | 0;
let t = a ^ a >>> 16;
t = Math.imul(t, 0x21f0aaad);
t = t ^ t >>> 15;
t = Math.imul(t, 0x735a2d97);
return ((t = t ^ t >>> 15) >>> 0) / 4294967296;
}
}
export function renderCanvas(qr, params, ctx) {
const seededRand = splitmix32(1 /* change seed to change pattern */);
// Randomly set pixels in margin
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
if (y > qr.margin.top - 2 &&
y < qr.matrixHeight - qr.margin.bottom + 1 &&
x > qr.margin.left - 2 &&
x < qr.matrixWidth - qr.margin.right + 1) {
continue;
}
if (seededRand() > 0.5) qr.matrix[y * qr.matrixWidth + x] = Module.DataON;
}
}
const pixelSize = 20;
const radius = pixelSize / 2;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const fg = "rgb(40, 70, 10)";
const bg = "rgb(200, 200, 100)";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const xMax = qr.matrixWidth - 1;
const yMax = qr.matrixHeight - 1;
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
const top = y > 0 && (qr.matrix[(y - 1) * qr.matrixWidth + x] & 1);
const bottom = y < yMax && (qr.matrix[(y + 1) * qr.matrixWidth + x] & 1);
const left = x > 0 && (qr.matrix[y * qr.matrixWidth + x - 1] & 1);
const right = x < xMax && (qr.matrix[y * qr.matrixWidth + x + 1] & 1);
ctx.fillStyle = fg;
if (module & 1) {
ctx.beginPath();
ctx.roundRect(
x * pixelSize,
y * pixelSize,
pixelSize,
pixelSize,
[
!left && !top && radius,
!top && !right && radius,
!right && !bottom && radius,
!bottom && !left && radius,
]
);
ctx.fill();
} else {
// Draw rounded concave corners
const topLeft = y > 0 && x > 0 && (qr.matrix[(y - 1) * qr.matrixWidth + x - 1] & 1);
const topRight = y > 0 && x < xMax && (qr.matrix[(y - 1) * qr.matrixWidth + x + 1] & 1);
const bottomRight = y < yMax && x < xMax && (qr.matrix[(y + 1) * qr.matrixWidth + x + 1] & 1);
const bottomLeft = y < yMax && x > 0 && (qr.matrix[(y + 1) * qr.matrixWidth + x - 1] & 1);
ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctx.beginPath();
ctx.fillStyle = bg;
ctx.roundRect(
x * pixelSize,
y * pixelSize,
pixelSize,
pixelSize,
[
left && top && topLeft && radius,
top && right && topRight && radius,
right && bottom && bottomRight && radius,
bottom && left && bottomLeft && radius,
]
);
ctx.fill();
}
}
}
}
`,
Minimal: `export function renderCanvas(qr, params, ctx) {
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
const pixelSize = 12;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const finderPos = [
[qr.margin.left, qr.margin.top],
[qr.matrixWidth - qr.margin.right - 7, qr.margin.top],
[qr.margin.left, qr.matrixHeight - qr.margin.bottom - 7],
];
ctx.fillStyle = "rgb(0, 0, 0)";
for (const [x, y] of finderPos) {
ctx.fillRect((x + 3) * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 3) * pixelSize, (y + 6) * pixelSize, pixelSize, pixelSize);
ctx.fillRect(x * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 6) * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 2) * pixelSize, (y + 2) * pixelSize, 3 * pixelSize, 3 * pixelSize);
}
const minSize = pixelSize / 2;
const offset = (pixelSize - minSize) / 2;
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module | 1) === Module.FinderON) {
continue;
}
if (module & 1) {
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, minSize, minSize);
}
}
}
}
`,
"Lover (Animated)": `export function renderCanvas(qr, params, ctx) {
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const period = 3000; // ms
const amplitude = 0.8; // maxSize - minSize
const minSize = 0.6;
let counter = 0;
let prevTimestamp;
let req;
function frame(timestamp) {
// performance.now() and requestAnimationFrame's timestamp are not consistent together
if (prevTimestamp != null) {
counter += timestamp - prevTimestamp;
}
prevTimestamp = timestamp;
if (counter >= period) {
counter -= period;
}
ctx.fillStyle = "rgb(0, 0, 0)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module & 1) === 0) continue;
const xBias = Math.abs(5 - (x % 10));
const biasCounter = counter + (x + y) * (period / 20) + xBias * (period / 10);
const ratio = Math.abs((period / 2) - (biasCounter % period)) / (period / 2);
const size = (ratio * amplitude + minSize) * pixelSize;
const offset = (pixelSize - size) / 2;
ctx.fillStyle = \`rgb(\${100 + ratio * 150}, \${200 + xBias * 10}, 255)\`;
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, size, size);
}
}
req = requestAnimationFrame(frame);
}
req = requestAnimationFrame(frame);
return () => cancelAnimationFrame(req);
}
`,
};

Wyświetl plik

@ -1,7 +1,5 @@
import { createSignal } from "solid-js";
import { clientOnly } from "@solidjs/start";
import { Editor } from "~/components/editor/QrEditor";
import { FlatButton } from "~/components/Button";
import QrPreview from "~/components/preview/QrPreview";
import init from "fuqr";