fix undo, cleanup func + animated preset, improve styles

main
Kyle Zheng 2024-07-08 23:57:24 -04:00
rodzic a83f3a6325
commit 15bc9f00a6
7 zmienionych plików z 242 dodań i 85 usunięć

Wyświetl plik

@ -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)"

Wyświetl plik

@ -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,
@ -20,15 +20,17 @@ type Props = {
onSave: (s: string) => void;
initialValue: string;
error: string | null;
clearError: ()=>void;
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,58 +40,65 @@ export function CodeInput(props: Props) {
const [dirty, setDirty] = createSignal(false);
const extensions = [
modeComp.of(vimMode() ? vim() : []),
basicSetup,
EditorView.lineWrapping,
keymap.of([
indentWithTab,
{
win: "Mod-Shift-z",
// Dirty hack, but undo/redo commands are not exposed
run: historyKeymap[1].run,
},
{
key: "Mod-s",
linux: "Ctrl-s", // untested, but might be necessary
run: (view) => {
props.onSave(view.state.doc.toString());
return true;
},
},
]),
javascript(),
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.updateListener.of(
debounce((u: ViewUpdate) => {
// docChanged (aka changes.empty) doesn't work when debounced
// if (!u.docChanged) return;
const newDirty = u.state.doc.toString() !== props.initialValue;
setDirty(newDirty);
if (!newDirty && props.error) {
props.clearError();
}
}, 300)
),
];
onMount(() => {
view = new EditorView({
extensions: [
modeComp.of(vimMode() ? vim() : []),
basicSetup,
EditorView.lineWrapping,
keymap.of([
indentWithTab,
{
win: "Mod-Shift-z",
// Dirty hack, but undo/redo commands are not exposed
run: historyKeymap[1].run,
},
{
key: "Mod-s",
linux: "Ctrl-s", // untested, but might be necessary
run: (view) => {
props.onSave(view.state.doc.toString());
return true;
},
},
]),
javascript(),
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.updateListener.of(
debounce((u: ViewUpdate) => {
// docChanged (aka changes.empty) doesn't work when debounced
// if (!u.docChanged) return;
const newDirty = u.state.doc.toString() !== props.initialValue;
setDirty(newDirty);
if (!newDirty && props.error) {
props.clearError()
}
}, 300)
),
],
extensions,
parent,
});
});
// Track props.initialValue
createEffect(() => {
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),
});
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({
effects: modeComp.reconfigure(currVimMode ? vim() : []),
});
}
});
return (

Wyświetl plik

@ -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);`
};

Wyświetl plik

@ -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()}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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}
@ -156,7 +164,7 @@ function defaultRender(qr: OutputQr, ctx: CanvasRenderingContext2D) {
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++) {

Wyświetl plik

@ -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>