kopia lustrzana https://github.com/zhengkyl/qrframe
fix undo, cleanup func + animated preset, improve styles
rodzic
a83f3a6325
commit
15bc9f00a6
|
@ -11,7 +11,7 @@ export default function App() {
|
|||
<Router root={(props) => <Suspense>{props.children}</Suspense>}>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
<footer class="text-sm text-center p-4">
|
||||
<footer class="text-sm text-center px-4 py-8">
|
||||
made with ⬛⬜ by{" "}
|
||||
<a
|
||||
class="font-semibold hover:text-fore-base/80 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)"
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { createEffect, createSignal, onMount, Show } from "solid-js";
|
||||
import { createEffect, createSignal, onMount, Show, untrack } from "solid-js";
|
||||
|
||||
import { basicSetup } from "codemirror";
|
||||
import { historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { Compartment, Transaction } from "@codemirror/state";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, type ViewUpdate } from "@codemirror/view";
|
||||
import {
|
||||
oneDarkHighlightStyle,
|
||||
|
@ -23,12 +23,14 @@ type Props = {
|
|||
clearError: () => void;
|
||||
};
|
||||
|
||||
const INITIAL_VIM_MODE = false;
|
||||
|
||||
export function CodeInput(props: Props) {
|
||||
let parent: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
let modeComp = new Compartment();
|
||||
|
||||
const [vimMode, _setVimMode] = createSignal(false);
|
||||
const [vimMode, _setVimMode] = createSignal(INITIAL_VIM_MODE);
|
||||
const setVimMode = (v: boolean) => {
|
||||
_setVimMode(v);
|
||||
view.dispatch({
|
||||
|
@ -38,9 +40,7 @@ export function CodeInput(props: Props) {
|
|||
|
||||
const [dirty, setDirty] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
extensions: [
|
||||
const extensions = [
|
||||
modeComp.of(vimMode() ? vim() : []),
|
||||
basicSetup,
|
||||
EditorView.lineWrapping,
|
||||
|
@ -71,25 +71,34 @@ export function CodeInput(props: Props) {
|
|||
setDirty(newDirty);
|
||||
|
||||
if (!newDirty && props.error) {
|
||||
props.clearError()
|
||||
props.clearError();
|
||||
}
|
||||
}, 300)
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
view = new EditorView({
|
||||
extensions,
|
||||
parent,
|
||||
});
|
||||
});
|
||||
|
||||
// Track props.initialValue
|
||||
createEffect(() => {
|
||||
setDirty(false);
|
||||
|
||||
// Saving should not reset editor state (cursor pos etc)
|
||||
if (view.state.doc.toString() === props.initialValue) return;
|
||||
|
||||
view.setState(EditorState.create({ doc: props.initialValue, extensions }));
|
||||
|
||||
const currVimMode = untrack(vimMode);
|
||||
if (currVimMode !== INITIAL_VIM_MODE) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: props.initialValue,
|
||||
},
|
||||
// This seems to prevent extra undos just fine, much simpler than toggling history extension
|
||||
annotations: Transaction.addToHistory.of(false),
|
||||
effects: modeComp.reconfigure(currVimMode ? vim() : []),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
|
@ -31,13 +31,19 @@ const ADD_NEW_FUNC_KEY = "Add new function";
|
|||
const USER_FUNC_KEYS_KEY = "userFuncKeys";
|
||||
|
||||
export function Editor(props: Props) {
|
||||
const { inputQr, setInputQr, setRenderFunc } = useQrContext();
|
||||
const {
|
||||
inputQr,
|
||||
setInputQr,
|
||||
setRenderFunc,
|
||||
renderFuncKey,
|
||||
setRenderFuncKey,
|
||||
} = useQrContext();
|
||||
|
||||
const [code, setCode] = createSignal(PRESET_FUNCS.Square);
|
||||
|
||||
const [compileError, setCompileError] = createSignal<string | null>(null);
|
||||
|
||||
const [userFuncKeys, setUserFuncKeys] = createStore<string[]>([]);
|
||||
const [funcKey, setFuncKey] = createSignal("Square");
|
||||
|
||||
onMount(() => {
|
||||
const storedFuncKeys = localStorage.getItem(USER_FUNC_KEYS_KEY);
|
||||
|
@ -58,8 +64,8 @@ export function Editor(props: Props) {
|
|||
setRenderFunc(() => render);
|
||||
setCompileError(null);
|
||||
|
||||
if (!PRESET_FUNCS.hasOwnProperty(funcKey())) {
|
||||
localStorage.setItem(funcKey(), newCode);
|
||||
if (!PRESET_FUNCS.hasOwnProperty(renderFuncKey())) {
|
||||
localStorage.setItem(renderFuncKey(), newCode);
|
||||
}
|
||||
} catch (e) {
|
||||
setCompileError(e!.toString());
|
||||
|
@ -76,7 +82,7 @@ export function Editor(props: Props) {
|
|||
|
||||
setUserFuncKeys(userFuncKeys.length, key);
|
||||
localStorage.setItem(USER_FUNC_KEYS_KEY, userFuncKeys.join(","));
|
||||
setFuncKey(key);
|
||||
setRenderFuncKey(key);
|
||||
trySetCode(code);
|
||||
};
|
||||
|
||||
|
@ -86,7 +92,7 @@ export function Editor(props: Props) {
|
|||
placeholder="https://qrcode.kylezhe.ng"
|
||||
setValue={(s) => setInputQr("text", s)}
|
||||
/>
|
||||
<Collapsible trigger="Settings" defaultOpen>
|
||||
<Collapsible trigger="Settings">
|
||||
<div class="flex justify-between">
|
||||
<div class="text-sm py-2">Encoding mode</div>
|
||||
<Select
|
||||
|
@ -137,7 +143,7 @@ export function Editor(props: Props) {
|
|||
/>
|
||||
</Row>
|
||||
</Collapsible>
|
||||
<Collapsible trigger="Rendering">
|
||||
<Collapsible trigger="Rendering" defaultOpen>
|
||||
<div class="mb-4">
|
||||
<div class="text-sm py-2">Render function</div>
|
||||
<div class="flex gap-2">
|
||||
|
@ -152,7 +158,7 @@ export function Editor(props: Props) {
|
|||
options: [...userFuncKeys, ADD_NEW_FUNC_KEY],
|
||||
},
|
||||
]}
|
||||
value={funcKey()}
|
||||
value={renderFuncKey()}
|
||||
setValue={(key) => {
|
||||
if (key === ADD_NEW_FUNC_KEY) {
|
||||
createAndSelectFunc("render function", PRESET_FUNCS.Square);
|
||||
|
@ -166,20 +172,20 @@ export function Editor(props: Props) {
|
|||
storedCode = `Failed to load ${key}`;
|
||||
}
|
||||
}
|
||||
setFuncKey(key);
|
||||
setRenderFuncKey(key);
|
||||
trySetCode(storedCode);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={userFuncKeys.includes(funcKey())}>
|
||||
<Show when={userFuncKeys.includes(renderFuncKey())}>
|
||||
<IconButtonDialog
|
||||
title={`Rename ${funcKey()}`}
|
||||
title={`Rename ${renderFuncKey()}`}
|
||||
triggerTitle="Rename"
|
||||
triggerChildren={<Pencil class="w-5 h-5" />}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{(close) => {
|
||||
const [rename, setRename] = createSignal(funcKey());
|
||||
const [rename, setRename] = createSignal(renderFuncKey());
|
||||
const [duplicate, setDuplicate] = createSignal(false);
|
||||
|
||||
let ref: HTMLInputElement;
|
||||
|
@ -192,7 +198,7 @@ export function Editor(props: Props) {
|
|||
defaultValue={rename()}
|
||||
onChange={setRename}
|
||||
onInput={() => duplicate() && setDuplicate(false)}
|
||||
placeholder={funcKey()}
|
||||
placeholder={renderFuncKey()}
|
||||
/>
|
||||
<div class="absolute p-1 text-sm text-red-600">
|
||||
<Show when={duplicate()}>
|
||||
|
@ -203,7 +209,7 @@ 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() === funcKey()) return close();
|
||||
if (rename() === renderFuncKey()) return close();
|
||||
|
||||
if (
|
||||
Object.keys(PRESET_FUNCS).includes(rename()) ||
|
||||
|
@ -211,10 +217,10 @@ export function Editor(props: Props) {
|
|||
) {
|
||||
setDuplicate(true);
|
||||
} else {
|
||||
localStorage.removeItem(funcKey());
|
||||
localStorage.removeItem(renderFuncKey());
|
||||
localStorage.setItem(rename(), code());
|
||||
setUserFuncKeys(
|
||||
userFuncKeys.indexOf(funcKey()),
|
||||
userFuncKeys.indexOf(renderFuncKey()),
|
||||
rename()
|
||||
);
|
||||
localStorage.setItem(
|
||||
|
@ -222,7 +228,7 @@ export function Editor(props: Props) {
|
|||
userFuncKeys.join(",")
|
||||
);
|
||||
|
||||
setFuncKey(rename());
|
||||
setRenderFuncKey(rename());
|
||||
close();
|
||||
}
|
||||
}}
|
||||
|
@ -234,7 +240,7 @@ export function Editor(props: Props) {
|
|||
}}
|
||||
</IconButtonDialog>
|
||||
<IconButtonDialog
|
||||
title={`Delete ${funcKey()}`}
|
||||
title={`Delete ${renderFuncKey()}`}
|
||||
triggerTitle="Delete"
|
||||
triggerChildren={<Trash2 class="w-5 h-5" />}
|
||||
>
|
||||
|
@ -247,10 +253,10 @@ export function Editor(props: Props) {
|
|||
<FillButton
|
||||
onMouseDown={() => {
|
||||
setUserFuncKeys((keys) =>
|
||||
keys.filter((key) => key !== funcKey())
|
||||
keys.filter((key) => key !== renderFuncKey())
|
||||
);
|
||||
localStorage.removeItem(funcKey());
|
||||
setFuncKey("Square");
|
||||
localStorage.removeItem(renderFuncKey());
|
||||
setRenderFuncKey("Square");
|
||||
|
||||
localStorage.setItem(
|
||||
USER_FUNC_KEYS_KEY,
|
||||
|
@ -275,10 +281,10 @@ export function Editor(props: Props) {
|
|||
<CodeInput
|
||||
initialValue={code()}
|
||||
onSave={(code) => {
|
||||
if (Object.keys(PRESET_FUNCS).includes(funcKey())){
|
||||
createAndSelectFunc(funcKey(), code)
|
||||
if (Object.keys(PRESET_FUNCS).includes(renderFuncKey())) {
|
||||
createAndSelectFunc(renderFuncKey(), code);
|
||||
} else {
|
||||
trySetCode(code)
|
||||
trySetCode(code);
|
||||
}
|
||||
}}
|
||||
error={compileError()}
|
||||
|
@ -294,7 +300,6 @@ function Row(props: {
|
|||
title: string;
|
||||
children: JSX.Element;
|
||||
}) {
|
||||
// This should be <label/> but clicking selects first button in buttongroup
|
||||
return (
|
||||
<div>
|
||||
<div class="text-sm py-2" title={props.tooltip}>
|
||||
|
@ -393,4 +398,113 @@ for (let y = 0; y < qr.matrixHeight; y++) {
|
|||
}
|
||||
}
|
||||
`,
|
||||
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;
|
||||
|
||||
ctx.fillStyle = "rgb(255, 255, 255)";
|
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.fillStyle = "rgb(0, 0, 0)";
|
||||
|
||||
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.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);`
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { QrError } from "fuqr";
|
||||
|
||||
import { Match, Show, Switch, createEffect, createSignal } from "solid-js";
|
||||
import { Match, Show, Switch, createEffect, createSignal, untrack } from "solid-js";
|
||||
import { useQrContext, type OutputQr } from "~/lib/QrContext";
|
||||
import {
|
||||
ECL_LABELS,
|
||||
|
@ -26,7 +26,7 @@ type Props = {
|
|||
};
|
||||
|
||||
export default function QrPreview(props: Props) {
|
||||
const { inputQr, outputQr } = useQrContext();
|
||||
const { inputQr, outputQr, renderFuncKey } = useQrContext();
|
||||
|
||||
return (
|
||||
<div class={props.class}>
|
||||
|
@ -62,7 +62,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 } = useQrContext();
|
||||
const { outputQr: _outputQr, renderFunc, renderFuncKey } = useQrContext();
|
||||
const outputQr = _outputQr as () => OutputQr;
|
||||
|
||||
const fullWidth = () => {
|
||||
|
@ -77,18 +77,38 @@ function RenderedQrCode() {
|
|||
let qrCanvas: HTMLCanvasElement;
|
||||
|
||||
const [runtimeError, setRuntimeError] = createSignal<string | null>(null);
|
||||
const [cleanupError, setCleanupError] = createSignal<string | null>(null);
|
||||
|
||||
const [canvasDims, setCanvasDims] = createSignal({ width: 0, height: 0 });
|
||||
|
||||
let cleanupFunc: void | (() => void);
|
||||
let cleanupFuncKey = ""
|
||||
let prevFuncKey = ""
|
||||
|
||||
createEffect(() => {
|
||||
try {
|
||||
if (typeof cleanupFunc === "function") {
|
||||
cleanupFunc();
|
||||
}
|
||||
setCleanupError(null);
|
||||
} catch (e) {
|
||||
setCleanupError(e!.toString());
|
||||
cleanupFuncKey = prevFuncKey
|
||||
console.error(`${cleanupFuncKey} cleanup:`, e)
|
||||
}
|
||||
|
||||
const ctx = qrCanvas.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, qrCanvas.width, qrCanvas.height);
|
||||
|
||||
prevFuncKey = untrack(renderFuncKey)
|
||||
try {
|
||||
renderFunc()(outputQr(), ctx);
|
||||
cleanupFunc = renderFunc()(outputQr(), ctx);
|
||||
setRuntimeError(null);
|
||||
} catch (e) {
|
||||
setRuntimeError(e!.toString());
|
||||
console.error(`${prevFuncKey} render:`, e)
|
||||
}
|
||||
|
||||
setCanvasDims({ width: qrCanvas.width, height: qrCanvas.height });
|
||||
});
|
||||
|
||||
|
@ -107,6 +127,12 @@ function RenderedQrCode() {
|
|||
>
|
||||
<canvas class="w-full h-full" ref={qrCanvas!}></canvas>
|
||||
</div>
|
||||
<Show when={cleanupError() != null}>
|
||||
<div class="text-purple-100 bg-purple-950 px-2 py-1 rounded-md">
|
||||
<div class="font-bold">{cleanupFuncKey} cleanup</div>
|
||||
{cleanupError()}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={runtimeError() != null}>
|
||||
<div class="text-red-100 bg-red-950 px-2 py-1 rounded-md">
|
||||
{runtimeError()}
|
||||
|
|
|
@ -11,7 +11,7 @@ export default createHandler(() => (
|
|||
<link rel="icon" href="/favicon.svg" />
|
||||
{assets}
|
||||
</head>
|
||||
<body class="bg-back-base text-fore-base my-8 [--un-default-border-color:fg-subtle]">
|
||||
<body class="bg-back-base text-fore-base [--un-default-border-color:fg-subtle]">
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
|
|
|
@ -61,9 +61,14 @@ export const QrContext = createContext<{
|
|||
setOutputQr: Setter<OutputQr | QrError>;
|
||||
renderFunc: Accessor<RenderFunc>;
|
||||
setRenderFunc: Setter<RenderFunc>;
|
||||
renderFuncKey: Accessor<string>;
|
||||
setRenderFuncKey: Setter<string>;
|
||||
}>();
|
||||
|
||||
export type RenderFunc = (qr: OutputQr, ctx: CanvasRenderingContext2D) => void;
|
||||
export type RenderFunc = (
|
||||
qr: OutputQr,
|
||||
ctx: CanvasRenderingContext2D
|
||||
) => void | (() => void);
|
||||
|
||||
export function QrContextProvider(props: { children: JSX.Element }) {
|
||||
const [inputQr, setInputQr] = createStore<InputQr>({
|
||||
|
@ -85,6 +90,7 @@ export function QrContextProvider(props: { children: JSX.Element }) {
|
|||
);
|
||||
|
||||
const [renderFunc, setRenderFunc] = createSignal<RenderFunc>(defaultRender);
|
||||
const [renderFuncKey, setRenderFuncKey] = createSignal("Square");
|
||||
|
||||
createEffect(() => {
|
||||
try {
|
||||
|
@ -134,6 +140,8 @@ export function QrContextProvider(props: { children: JSX.Element }) {
|
|||
setOutputQr,
|
||||
renderFunc,
|
||||
setRenderFunc,
|
||||
renderFuncKey,
|
||||
setRenderFuncKey
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
@ -15,10 +15,10 @@ const QrContextProvider = clientOnly(async () => {
|
|||
export default function Home() {
|
||||
return (
|
||||
<QrContextProvider>
|
||||
<main class="max-w-screen-2xl mx-auto p-4">
|
||||
<main class="max-w-screen-2xl mx-auto px-4">
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<Editor class="flex-1 flex-grow-3 flex flex-col gap-2 p-4" />
|
||||
<QrPreview class="flex-1 flex-grow-2 min-w-300px sticky top-0 self-start p-4 flex flex-col gap-4" />
|
||||
<Editor class="flex-1 flex-grow-3 flex flex-col gap-2 px-4 py-8" />
|
||||
<QrPreview class="flex-1 flex-grow-2 min-w-300px sticky top-0 self-start px-4 py-8 flex flex-col gap-4" />
|
||||
</div>
|
||||
</main>
|
||||
</QrContextProvider>
|
||||
|
|
Ładowanie…
Reference in New Issue