right click rename/delete + paste warning dialog

main
Kyle Zheng 2024-09-02 23:33:29 -04:00
rodzic 7a7c3853a3
commit fe7d2fbb69
11 zmienionych plików z 507 dodań i 168 usunięć

Wyświetl plik

@ -85,9 +85,12 @@ I'm working on more examples.
- Customize appearance:
- Choose any preset, customize or even create a new one from scratch via code editor.
- Define arbitrary ui parameters in code
- Supports SVG (string) and PNG (canvas)
- Define arbitrary UI parameters in code
- Supports SVG and PNG
- All code runs _directly_ in browser in a web worker with no restrictions.
- There is no sandbox, whitelist, blacklist, or anything besides a 5s timeout to stop infinite loops.
- Generated SVGs are not sanitized. This is an impossible task and attempting it breaks perfectly fine SVGs, makes debugging harder, and adds latency to previewing changes.
- These should be non-issues, but even if you copy-and-paste and run malware there's no secrets to leak.
## Use existing presets

Wyświetl plik

@ -14,6 +14,10 @@ export const paramsSchema = {
type: "color",
default: "#ffffff",
},
Shape: {
type: "select",
options: ["Square-Circle", "Diamond-Squircle"],
},
Roundness: {
type: "number",
min: 0,
@ -39,6 +43,10 @@ export const paramsSchema = {
step: 0.01,
default: 0.25,
},
"Show data behind logo": {
type: "boolean",
default: true,
},
};
const Module = {
@ -57,16 +65,16 @@ const Module = {
SeparatorOFF: 12,
};
// unformatted floats can bloat file size
const fmt = (n) => n.toFixed(2).replace(/\.00$/, "");
export async function renderSVG(qr, params) {
const matrixWidth = qr.version * 4 + 17;
const margin = params["Margin"];
const fg = params["Foreground"];
const bg = params["Background"];
const defaultShape = params["Shape"] === "Square-Circle";
const roundness = params["Roundness"];
const file = params["Logo"];
const logoRatio = params["Logo size"];
const showLogoData = params["Show data behind logo"];
const size = matrixWidth + 2 * margin;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-margin} ${-margin} ${size} ${size}">`;
@ -77,38 +85,61 @@ export async function renderSVG(qr, params) {
const lgRadius = 3.5 * roundness;
const mdRadius = 2.5 * roundness;
const smRadius = 1.5 * roundness;
const lgSide = fmt(7 - 2 * lgRadius);
const mdSide = fmt(5 - 2 * mdRadius);
const smSide = fmt(3 - 2 * smRadius);
const corner = (radius, xDir, yDir, cw) =>
`a${fmt(radius)},${fmt(radius)} 0,0,${cw ? "1" : "0"} ${fmt(xDir * radius)},${fmt(yDir * radius)}`;
for (const [x, y] of [
[0, 0],
[matrixWidth - 7, 0],
[0, matrixWidth - 7],
]) {
svg += `M${fmt(x + lgRadius)},${y}h${lgSide}${corner(lgRadius, 1, 1, true)}v${lgSide}${corner(lgRadius, -1, 1, true)}h-${lgSide}${corner(lgRadius, -1, -1, true)}v-${lgSide}${corner(lgRadius, 1, -1, true)}`;
svg += `M${fmt(x + 1 + mdRadius)},${y + 1}${corner(mdRadius, -1, 1, false)}v${mdSide}${corner(mdRadius, 1, 1, false)}h${mdSide}${corner(mdRadius, 1, -1, false)}v-${mdSide}${corner(mdRadius, -1, -1, false)}`;
svg += `M${fmt(x + 2 + smRadius)},${y + 2}h${smSide}${corner(smRadius, 1, 1, true)}v${smSide}${corner(smRadius, -1, 1, true)}h-${smSide}${corner(smRadius, -1, -1, true)}v-${smSide}${corner(smRadius, 1, -1, true)}`;
if (defaultShape) {
svg += roundedRect(x, y, 7, lgRadius, true);
svg += roundedRect(x + 1, y + 1, 5, mdRadius, false);
svg += roundedRect(x + 2, y + 2, 3, smRadius, true);
} else {
svg += squircle(x, y, 7, lgRadius, true);
svg += squircle(x + 1, y + 1, 5, mdRadius, false);
svg += squircle(x + 2, y + 2, 3, smRadius, true);
}
}
svg += `"/>`;
const dataSize = params["Data size"];
const dataRadius = fmt((roundness * dataSize) / 2);
const dataRadius = (roundness * dataSize) / 2;
const dataOffset = (1 - dataSize) / 2;
if (!defaultShape) svg += `<path d="`;
const logoInner = Math.floor(((1 - logoRatio) * size) / 2 - margin);
const logoUpper = matrixWidth - logoInner;
for (let y = 0; y < matrixWidth; y++) {
for (let x = 0; x < matrixWidth; x++) {
if (
!showLogoData &&
x >= logoInner &&
y >= logoInner &&
x < logoUpper &&
y < logoUpper
) {
continue;
}
const module = qr.matrix[y * matrixWidth + x];
if (module & 1) {
if (module === Module.FinderON) continue;
svg += `<rect x="${fmt(x + dataOffset)}" y="${fmt(y + dataOffset)}" width="${dataSize}" height="${dataSize}" rx="${dataRadius}"/>`;
if (!(module & 1)) continue;
if (module === Module.FinderON) continue;
if (defaultShape) {
svg += `<rect x="${fmt(x + dataOffset)}" y="${fmt(y + dataOffset)}" width="${dataSize}" height="${dataSize}" rx="${fmt(dataRadius)}"/>`;
} else {
svg += squircle(
x + dataOffset,
y + dataOffset,
dataSize,
dataRadius,
true
);
}
}
}
if (!defaultShape) svg += `"/>`;
svg += `</g>`;
if (file != null) {
@ -116,11 +147,50 @@ export async function renderSVG(qr, params) {
const b64 = btoa(
Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("")
);
const logoSize = fmt(params["Logo size"] * size);
const logoOffset = fmt(((1 - params["Logo size"]) * size) / 2 - margin);
const logoSize = fmt(logoRatio * size);
const logoOffset = fmt(((1 - logoRatio) * size) / 2 - margin);
svg += `<image x="${logoOffset}" y="${logoOffset}" width="${logoSize}" height="${logoSize}" href="data:${file.type};base64,${b64}"/>`;
}
svg += `</svg>`;
return svg;
}
// reduce file bloat from floating point math
const fmt = (n) => n.toFixed(2).replace(/.00$/, "");
function squircle(x, y, width, handle, cw) {
const half = fmt(width / 2);
if (handle === 0) {
return cw ? `M${fmt(x + width / 2)},${fmt(y)}l${half},${half}l-${half},${half}l-${half},-${half}z` :
`M${fmt(x + width / 2)},${fmt(y)}l-${half},${half}l${half},${half}l${half},-${half}z`
}
const h = fmt(handle);
const hInv1 = fmt(half - handle);
const hInv2 = fmt(-(half - handle));
return cw
? `M${fmt(x + width / 2)},${fmt(y)}c${h},0 ${half},${hInv1} ${half},${half}s${hInv2},${half} -${half},${half}s-${half},${hInv2} -${half},-${half}s${hInv1},-${half} ${half},-${half}`
: `M${fmt(x + width / 2)},${fmt(y)}c-${h},0 -${half},${hInv1} -${half},${half}s${hInv1},${half} ${half},${half}s${half},${hInv2} ${half},-${half}s${hInv2},-${half} -${half},-${half}`;
}
function roundedRect(x, y, width, radius, cw) {
if (radius === 0) {
return cw
? `M${fmt(x)},${fmt(y)}h${width}v${width}h-${width}z`
: `M${fmt(x)},${fmt(y)}v${width}h${width}v-${width}z`;
}
if (radius === width / 2) {
const r = fmt(radius);
const cwFlag = cw ? "1" : "0";
return `M${fmt(x + radius)},${fmt(y)}a${r},${r} 0,0,${cwFlag} 0,${width}a${r},${r} 0,0,${cwFlag} ${0},-${width}`;
}
const r = fmt(radius);
const side = fmt(width - 2 * radius);
return cw
? `M${fmt(x + radius)},${fmt(y)}h${side}a${r},${r} 0,0,1 ${r},${r}v${side}a${r},${r} 0,0,1 -${r},${r}h-${side}a${r},${r} 0,0,1 -${r},-${r}v-${side}a${r},${r} 0,0,1 ${r},-${r}`
: `M${fmt(x + radius)},${fmt(y)}a${r},${r} 0,0,0 -${r},${r}v${side}a${r},${r} 0,0,0 ${r},${r}h${side}a${r},${r} 0,0,0 ${r},-${r}v-${side}a${r},${r} 0,0,0 -${r},-${r}`;
}

Wyświetl plik

@ -6,13 +6,13 @@ type Props = {
onClick?: () => void;
onMouseDown?: () => void;
children: JSX.Element;
tooltip?: string;
title?: string;
disabled?: boolean;
};
export function FlatButton(props: Props) {
return (
<Button
title={props.tooltip}
title={props.title}
classList={{
"leading-tight border rounded-md hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) disabled:(pointer-events-none opacity-50)":
true,
@ -30,7 +30,7 @@ export function FlatButton(props: Props) {
export function FillButton(props: Props) {
return (
<Button
title={props.tooltip}
title={props.title}
classList={{
"leading-tight bg-fore-base text-back-base border rounded-md hover:bg-fore-base/90 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) disabled:(pointer-events-none opacity-50)":
true,

Wyświetl plik

@ -0,0 +1,60 @@
import { ContextMenu } from "@kobalte/core/context-menu";
import { For, type JSX } from "solid-js";
type Props = {
children: JSX.Element;
onRename: () => void;
onDelete: () => void;
disabled?: boolean;
};
export function ContextMenuProvider(props: Props) {
return (
<ContextMenu>
{props.children}
<ContextMenu.Portal>
<ContextMenu.Content
classList={{
"leading-tight bg-back-base rounded-md border p-1": true,
"cursor-not-allowed": props.disabled,
}}
>
<ContextMenu.Item
classList={{
"p-2 rounded select-none data-[highlighted]:(bg-fore-base/10 outline-none)":
true,
"pointer-events-none opacity-50": props.disabled,
}}
onClick={props.onRename}
disabled={props.disabled}
>
Rename
</ContextMenu.Item>
<ContextMenu.Item
classList={{
"p-2 rounded select-none data-[highlighted]:(bg-fore-base/10 outline-none)":
true,
"pointer-events-none opacity-50": props.disabled,
}}
onClick={props.onDelete}
disabled={props.disabled}
>
Delete
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
);
}
type TriggerProps = {
children: JSX.Element;
};
export function ContentMenuTrigger(props: TriggerProps) {
return (
<ContextMenu.Trigger onClick={() => console.log("cte")}>
{props.children}
</ContextMenu.Trigger>
);
}

Wyświetl plik

@ -1,26 +1,18 @@
import { Dialog } from "@kobalte/core/dialog";
import X from "lucide-solid/icons/x";
import { createSignal, type JSX } from "solid-js";
import { type JSX } from "solid-js";
import { FlatButton } from "./Button";
type Props = {
triggerTitle: string;
triggerChildren: JSX.Element;
title: string;
children: (close: () => void) => JSX.Element;
onOpenAutoFocus?: (event: Event) => void;
open: boolean;
setOpen: (b: boolean) => void;
};
export function IconButtonDialog(props: Props) {
const [open, setOpen] = createSignal(false);
export function ControlledDialog(props: Props) {
return (
<Dialog open={open()} onOpenChange={setOpen}>
<Dialog.Trigger
title={props.triggerTitle}
class="border rounded-md hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) disabled:(pointer-events-none opacity-50) p-2"
>
{props.triggerChildren}
</Dialog.Trigger>
<Dialog open={props.open} onOpenChange={props.setOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-10 bg-black/20" />
<div class="fixed inset-0 z-10 flex justify-center items-center">
@ -36,10 +28,25 @@ export function IconButtonDialog(props: Props) {
<X />
</Dialog.CloseButton>
</div>
{props.children(() => setOpen(false))}
{props.children(() => props.setOpen(false))}
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
);
}
type ButtonProps = {
title: string;
children: JSX.Element;
onClick?: () => void;
};
// Dialog.Trigger toggles the open state, so
// it cannot be used with onClick that modifies the open state
export function DialogButton(props: ButtonProps) {
return (
<FlatButton title={props.title} class="p-2" onClick={props.onClick}>
{props.children}
</FlatButton>
);
}

Wyświetl plik

@ -46,7 +46,7 @@ export function Select(props: Props) {
break;
}
}}
class="w-[160px]"
class="min-w-40"
options={props.options}
gutter={4}
itemComponent={(itemProps) => (
@ -64,7 +64,7 @@ export function Select(props: Props) {
</KSelect.Item>
)}
>
<KSelect.Trigger class="leading-tight w-full inline-flex justify-between items-center rounded-md border pl-3 pr-2 py-2 focus:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) bg-back-base hover:bg-fore-base/5">
<KSelect.Trigger class="leading-tight w-full inline-flex justify-between items-center gap-1 rounded-md border pl-3 pr-2 py-2 focus:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) bg-back-base hover:bg-fore-base/5">
<KSelect.Value>
{(state) => state.selectedOption() as string}
</KSelect.Value>

Wyświetl plik

@ -21,10 +21,10 @@ export function TextareaInput(props: TextareaProps) {
type InputProps = {
placeholder?: string;
defaultValue: string;
onChange: (s: string) => void;
onInput: (s: string) => void;
ref?: HTMLInputElement;
class?: string;
onKeyDown?: (e: KeyboardEvent) => void
};
/** UNCONTROLLED */
@ -38,8 +38,8 @@ export function TextInput(props: InputProps) {
type="text"
value={props.defaultValue}
placeholder={props.placeholder}
onChange={(e) => props.onChange(e.target.value)}
onInput={(e) => props.onInput(e.target.value)}
onKeyDown={props.onKeyDown}
/>
);
}

Wyświetl plik

@ -0,0 +1,54 @@
import { AlertDialog } from "@kobalte/core/alert-dialog";
import X from "lucide-solid/icons/x";
import { FillButton, FlatButton } from "../Button";
type Props = {
open: boolean;
setClosed: () => void;
onAllow: () => void;
};
export function AllowPasteDialog(props: Props) {
return (
<AlertDialog open={props.open} onOpenChange={props.setClosed}>
<AlertDialog.Portal>
<AlertDialog.Overlay class="fixed inset-0 z-10 bg-black/20" />
<div class="fixed inset-0 z-10 flex justify-center items-center">
<AlertDialog.Content class="border rounded-md p-4 m-4 min-w-[min(calc(100vw-16px),400px)] max-w-[600px] bg-back-base">
<div class="flex justify-between items-center -mt-2 -mr-2">
<AlertDialog.Title class="text-lg font-semibold">
Allow pasting code?
</AlertDialog.Title>
<AlertDialog.CloseButton class="p-2">
<X />
</AlertDialog.CloseButton>
</div>
<div class="flex flex-col gap-2 mb-4 text-sm">
<p>Using code you don't understand could be dangerous.</p>
<p>
There are no secrets or passwords that can be leaked from this
website, but you may be trolled. The page may break, you could
be redirected to another URL, or any number of things could
happen.
</p>
<p>Do you accept these risks?</p>
</div>
<div class="flex justify-end gap-2">
<FillButton
onMouseDown={() => {
props.onAllow();
props.setClosed();
}}
>
Yes
</FillButton>
<FlatButton onMouseDown={props.setClosed}>
No, I'm sorry I wasted your time
</FlatButton>
</div>
</AlertDialog.Content>
</div>
</AlertDialog.Portal>
</AlertDialog>
);
}

Wyświetl plik

@ -15,6 +15,7 @@ import { vim } from "@replit/codemirror-vim";
import { Button } from "@kobalte/core/button";
import { debounce } from "~/lib/util";
import { Switch } from "../Switch";
import { AllowPasteDialog } from "./AllowPasteDialog";
type Props = {
onSave: (s: string, thumbnail: boolean) => void;
@ -22,11 +23,13 @@ type Props = {
};
const VIM_MODE_KEY = "vimMode";
const ALLOW_PASTE_KEY = "allowPaste";
export function CodeEditor(props: Props) {
let parent: HTMLDivElement;
let view: EditorView;
let modeComp = new Compartment();
let allowPaste;
const [vimMode, _setVimMode] = createSignal(false);
const setVimMode = (v: boolean) => {
@ -60,7 +63,22 @@ export function CodeEditor(props: Props) {
return true;
},
},
{
key: "Mod-v",
run: () => {
if (allowPaste) return false;
setShowDialog(true);
return true;
},
},
]),
EditorView.domEventHandlers({
paste() {
if (allowPaste) return false;
setShowDialog(true);
return true;
},
}),
javascript(),
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
@ -87,6 +105,8 @@ export function CodeEditor(props: Props) {
effects: modeComp.reconfigure(vim()),
});
}
allowPaste = localStorage.getItem(ALLOW_PASTE_KEY) === "true";
});
// Track props.initialValue
@ -121,9 +141,20 @@ export function CodeEditor(props: Props) {
});
const [showCode, setShowCode] = createSignal(false);
const [showDialog, setShowDialog] = createSignal(false);
return (
<div>
<AllowPasteDialog
open={showDialog()}
setClosed={() => {
setShowDialog(false);
}}
onAllow={() => {
allowPaste = true;
localStorage.setItem(ALLOW_PASTE_KEY, "true");
}}
/>
<div class="flex justify-between pb-2 h-11">
<Switch label="Show code" value={showCode()} setValue={setShowCode} />
<Show when={showCode()}>

Wyświetl plik

@ -12,13 +12,14 @@ import { PRESET_CODE } from "~/lib/presets";
import { useQrContext, type RenderType } from "~/lib/QrContext";
import { FillButton, FlatButton } from "../Button";
import { Collapsible } from "../Collapsible";
import { IconButtonDialog } from "../Dialog";
import { DialogButton, ControlledDialog } from "../Dialog";
import { TextInput, TextareaInput } from "../TextInput";
import { CodeEditor } from "./CodeEditor";
import { Settings } from "./Settings";
import { clearToasts, toastError } from "../ErrorToasts";
import { ParamsEditor } from "./ParamsEditor";
import { Tutorial } from "~/lib/presets/Tutorial";
import { ContentMenuTrigger, ContextMenuProvider } from "../ContextMenu";
type Props = {
class?: string;
@ -55,6 +56,12 @@ export function Editor(props: Props) {
const [funcKeys, _setFuncKeys] = createStore<string[]>([]);
const [thumbs, setThumbs] = createStore<Thumbs>({} as Thumbs);
// Dialog open state must be separate from render state,
// to allow animating in/out of existence
const [renameOpen, setRenameOpen] = createSignal(false);
const [deleteOpen, setDeleteOpen] = createSignal(false);
const [dialogKey, setDialogKey] = createSignal<string>("");
let thumbWorker: Worker | null = null;
const timeoutIdMap = new Map<NodeJS.Timeout, string>();
@ -283,6 +290,104 @@ export function Editor(props: Props) {
<Settings />
</Collapsible>
<Collapsible trigger="Render" defaultOpen>
<ControlledDialog
open={renameOpen()}
setOpen={setRenameOpen}
title={`Rename ${dialogKey()}`}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{(close) => {
const key = dialogKey();
const [rename, setRename] = createSignal(key);
const [duplicate, setDuplicate] = createSignal(false);
let ref: HTMLInputElement;
onMount(() => ref.focus());
const onSubmit = () => {
if (rename() === key) return close();
if (funcKeys.includes(rename())) {
setDuplicate(true);
return;
}
const thumb = thumbs[key];
localStorage.setItem(rename(), code());
localStorage.setItem(`${rename()}_thumb`, thumb);
localStorage.removeItem(key);
localStorage.removeItem(`${key}_thumb`);
setThumbs(rename(), thumb);
setThumbs(key, undefined!);
setFuncKeys(funcKeys.indexOf(key), rename());
setRenderKey(rename());
close();
};
return (
<>
<TextInput
class="mt-2"
ref={ref!}
defaultValue={rename()}
onInput={(s) => {
if (duplicate()) setDuplicate(false);
setRename(s);
}}
placeholder={key}
onKeyDown={(e) => e.key === "Enter" && onSubmit()}
/>
<div class="absolute p-1 text-sm text-red-600">
<Show when={duplicate()}>{rename()} already exists.</Show>
</div>
<FillButton
class="px-3 py-2 float-right mt-4"
// input onChange runs after focus lost, so onMouseDown is too early
onClick={onSubmit}
>
Confirm
</FillButton>
</>
);
}}
</ControlledDialog>
<ControlledDialog
open={deleteOpen()}
// This is controlled, so it will never be called with true
setOpen={setDeleteOpen}
title={`Delete ${dialogKey()}`}
>
{(close) => {
const key = dialogKey();
return (
<>
<p class="mb-4 text-sm">
Are you sure you want to delete this function?
</p>
<div class="flex justify-end gap-2">
<FillButton
onMouseDown={() => {
localStorage.removeItem(key);
localStorage.removeItem(`${key}_thumb`);
setThumbs(key, undefined!);
setFuncKeys((keys) => keys.filter((k) => k !== key));
setExistingKey(funcKeys[0]);
close();
}}
>
Confirm
</FillButton>
<FlatButton onMouseDown={close}>Cancel</FlatButton>
</div>
</>
);
}}
</ControlledDialog>
<div class="py-4">
<div class="mb-4 h-[180px] md:(h-unset)">
<div class="flex justify-between">
@ -292,115 +397,52 @@ export function Editor(props: Props) {
<div class="flex gap-2">
<div class="flex items-center font-bold">{renderKey()}</div>
<Show when={!presetKeys.includes(renderKey())}>
<IconButtonDialog
title={`Rename ${renderKey()}`}
triggerTitle="Rename"
triggerChildren={<Pencil class="w-5 h-5" />}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{(close) => {
const [rename, setRename] = createSignal(renderKey());
const [duplicate, setDuplicate] = createSignal(false);
let ref: HTMLInputElement;
onMount(() => ref.focus());
return (
<>
<TextInput
class="mt-2"
ref={ref!}
defaultValue={rename()}
onChange={setRename}
onInput={() => duplicate() && setDuplicate(false)}
placeholder={renderKey()}
/>
<div class="absolute p-1 text-sm text-red-600">
<Show when={duplicate()}>
{rename()} already exists.
</Show>
</div>
<FillButton
class="px-3 py-2 float-right mt-4"
// input onChange runs after focus lost, so onMouseDown is too early
onClick={() => {
if (rename() === renderKey()) return close();
if (funcKeys.includes(rename())) {
setDuplicate(true);
return;
}
localStorage.removeItem(renderKey());
localStorage.removeItem(`${renderKey()}_thumb`);
const thumb = thumbs[renderKey()];
localStorage.setItem(rename(), code());
localStorage.setItem(`${rename()}_thumb`, thumb);
setThumbs(rename(), thumb);
setThumbs(renderKey(), undefined!);
setFuncKeys(
funcKeys.indexOf(renderKey()),
rename()
);
setRenderKey(rename());
close();
}}
>
Confirm
</FillButton>
</>
);
<DialogButton
title="Rename"
onClick={() => {
batch(() => {
setDialogKey(renderKey());
setRenameOpen(true);
});
}}
</IconButtonDialog>
<IconButtonDialog
title={`Delete ${renderKey()}`}
triggerTitle="Delete"
triggerChildren={<Trash2 class="w-5 h-5" />}
>
{(close) => (
<>
<p class="mb-4 text-sm">
Are you sure you want to delete this function?
</p>
<div class="flex justify-end gap-2">
<FillButton
onMouseDown={() => {
localStorage.removeItem(renderKey());
localStorage.removeItem(`${renderKey()}_thumb`);
setThumbs(renderKey(), undefined!);
setFuncKeys((keys) =>
keys.filter((key) => key !== renderKey())
);
setExistingKey(funcKeys[0]);
close();
}}
>
Confirm
</FillButton>
<FlatButton onMouseDown={close}>Cancel</FlatButton>
</div>
</>
)}
</IconButtonDialog>
<Pencil class="w-5 h-5" />
</DialogButton>
<DialogButton
title="Delete"
onClick={() => {
batch(() => {
setDialogKey(renderKey());
setDeleteOpen(true);
});
}}
>
<Trash2 class="w-5 h-5" />
</DialogButton>
</Show>
</div>
</div>
<div class="flex gap-3 pt-2 pb-4 md:(flex-wrap static ml-0 px-0 overflow-x-visible) absolute max-w-full overflow-x-auto -ml-6 px-6">
<For each={funcKeys}>
{(key) => (
<Preview
onClick={() => setExistingKey(key)}
label={key}
active={renderKey() === key}
>
<img class="rounded-sm" src={thumbs[key]} />
</Preview>
)}
</For>
<ContextMenuProvider
disabled={isPreset(dialogKey())}
onRename={() => setRenameOpen(true)}
onDelete={() => setDeleteOpen(true)}
>
<For each={funcKeys}>
{(key) => (
<ContentMenuTrigger>
<Preview
onContextMenu={() => setDialogKey(key)}
onClick={() => setExistingKey(key)}
label={key}
active={renderKey() === key}
>
<img class="rounded-sm" src={thumbs[key]} />
</Preview>
</ContentMenuTrigger>
)}
</For>
</ContextMenuProvider>
<Preview
onClick={() => createAndSelectFunc("custom", Tutorial)}
label="Create new"
@ -438,12 +480,14 @@ type PreviewProps = {
children: JSX.Element;
onClick: () => void;
active: boolean;
onContextMenu?: () => void;
};
function Preview(props: PreviewProps) {
return (
<button
class="rounded-sm focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)"
onClick={props.onClick}
onContextMenu={props.onContextMenu}
>
<div
classList={{

Wyświetl plik

@ -14,6 +14,10 @@ export const Basic = `export const paramsSchema = {
type: "color",
default: "#ffffff",
},
Shape: {
type: "select",
options: ["Square-Circle", "Diamond-Squircle"],
},
Roundness: {
type: "number",
min: 0,
@ -39,6 +43,10 @@ export const Basic = `export const paramsSchema = {
step: 0.01,
default: 0.25,
},
"Show data behind logo": {
type: "boolean",
default: true,
},
};
const Module = {
@ -57,16 +65,16 @@ const Module = {
SeparatorOFF: 12,
};
// unformatted floats can bloat file size
const fmt = (n) => n.toFixed(2).replace(/\.00$/, "");
export async function renderSVG(qr, params) {
const matrixWidth = qr.version * 4 + 17;
const margin = params["Margin"];
const fg = params["Foreground"];
const bg = params["Background"];
const defaultShape = params["Shape"] === "Square-Circle";
const roundness = params["Roundness"];
const file = params["Logo"];
const logoRatio = params["Logo size"];
const showLogoData = params["Show data behind logo"];
const size = matrixWidth + 2 * margin;
let svg = \`<svg xmlns="http://www.w3.org/2000/svg" viewBox="\${-margin} \${-margin} \${size} \${size}">\`;
@ -77,38 +85,61 @@ export async function renderSVG(qr, params) {
const lgRadius = 3.5 * roundness;
const mdRadius = 2.5 * roundness;
const smRadius = 1.5 * roundness;
const lgSide = fmt(7 - 2 * lgRadius);
const mdSide = fmt(5 - 2 * mdRadius);
const smSide = fmt(3 - 2 * smRadius);
const corner = (radius, xDir, yDir, cw) =>
\`a\${fmt(radius)},\${fmt(radius)} 0,0,\${cw ? "1" : "0"} \${fmt(xDir * radius)},\${fmt(yDir * radius)}\`;
for (const [x, y] of [
[0, 0],
[matrixWidth - 7, 0],
[0, matrixWidth - 7],
]) {
svg += \`M\${fmt(x + lgRadius)},\${y}h\${lgSide}\${corner(lgRadius, 1, 1, true)}v\${lgSide}\${corner(lgRadius, -1, 1, true)}h-\${lgSide}\${corner(lgRadius, -1, -1, true)}v-\${lgSide}\${corner(lgRadius, 1, -1, true)}\`;
svg += \`M\${fmt(x + 1 + mdRadius)},\${y + 1}\${corner(mdRadius, -1, 1, false)}v\${mdSide}\${corner(mdRadius, 1, 1, false)}h\${mdSide}\${corner(mdRadius, 1, -1, false)}v-\${mdSide}\${corner(mdRadius, -1, -1, false)}\`;
svg += \`M\${fmt(x + 2 + smRadius)},\${y + 2}h\${smSide}\${corner(smRadius, 1, 1, true)}v\${smSide}\${corner(smRadius, -1, 1, true)}h-\${smSide}\${corner(smRadius, -1, -1, true)}v-\${smSide}\${corner(smRadius, 1, -1, true)}\`;
if (defaultShape) {
svg += roundedRect(x, y, 7, lgRadius, true);
svg += roundedRect(x + 1, y + 1, 5, mdRadius, false);
svg += roundedRect(x + 2, y + 2, 3, smRadius, true);
} else {
svg += squircle(x, y, 7, lgRadius, true);
svg += squircle(x + 1, y + 1, 5, mdRadius, false);
svg += squircle(x + 2, y + 2, 3, smRadius, true);
}
}
svg += \`"/>\`;
const dataSize = params["Data size"];
const dataRadius = fmt((roundness * dataSize) / 2);
const dataRadius = (roundness * dataSize) / 2;
const dataOffset = (1 - dataSize) / 2;
if (!defaultShape) svg += \`<path d="\`;
const logoInner = Math.floor(((1 - logoRatio) * size) / 2 - margin);
const logoUpper = matrixWidth - logoInner;
for (let y = 0; y < matrixWidth; y++) {
for (let x = 0; x < matrixWidth; x++) {
if (
!showLogoData &&
x >= logoInner &&
y >= logoInner &&
x < logoUpper &&
y < logoUpper
) {
continue;
}
const module = qr.matrix[y * matrixWidth + x];
if (module & 1) {
if (module === Module.FinderON) continue;
svg += \`<rect x="\${fmt(x + dataOffset)}" y="\${fmt(y + dataOffset)}" width="\${dataSize}" height="\${dataSize}" rx="\${dataRadius}"/>\`;
if (!(module & 1)) continue;
if (module === Module.FinderON) continue;
if (defaultShape) {
svg += \`<rect x="\${fmt(x + dataOffset)}" y="\${fmt(y + dataOffset)}" width="\${dataSize}" height="\${dataSize}" rx="\${fmt(dataRadius)}"/>\`;
} else {
svg += squircle(
x + dataOffset,
y + dataOffset,
dataSize,
dataRadius,
true
);
}
}
}
if (!defaultShape) svg += \`"/>\`;
svg += \`</g>\`;
if (file != null) {
@ -116,12 +147,51 @@ export async function renderSVG(qr, params) {
const b64 = btoa(
Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("")
);
const logoSize = fmt(params["Logo size"] * size);
const logoOffset = fmt(((1 - params["Logo size"]) * size) / 2 - margin);
const logoSize = fmt(logoRatio * size);
const logoOffset = fmt(((1 - logoRatio) * size) / 2 - margin);
svg += \`<image x="\${logoOffset}" y="\${logoOffset}" width="\${logoSize}" height="\${logoSize}" href="data:\${file.type};base64,\${b64}"/>\`;
}
svg += \`</svg>\`;
return svg;
}
// reduce file bloat from floating point math
const fmt = (n) => n.toFixed(2).replace(/.00$/, "");
function squircle(x, y, width, handle, cw) {
const half = fmt(width / 2);
if (handle === 0) {
return cw ? \`M\${fmt(x + width / 2)},\${fmt(y)}l\${half},\${half}l-\${half},\${half}l-\${half},-\${half}z\` :
\`M\${fmt(x + width / 2)},\${fmt(y)}l-\${half},\${half}l\${half},\${half}l\${half},-\${half}z\`
}
const h = fmt(handle);
const hInv1 = fmt(half - handle);
const hInv2 = fmt(-(half - handle));
return cw
? \`M\${fmt(x + width / 2)},\${fmt(y)}c\${h},0 \${half},\${hInv1} \${half},\${half}s\${hInv2},\${half} -\${half},\${half}s-\${half},\${hInv2} -\${half},-\${half}s\${hInv1},-\${half} \${half},-\${half}\`
: \`M\${fmt(x + width / 2)},\${fmt(y)}c-\${h},0 -\${half},\${hInv1} -\${half},\${half}s\${hInv1},\${half} \${half},\${half}s\${half},\${hInv2} \${half},-\${half}s\${hInv2},-\${half} -\${half},-\${half}\`;
}
function roundedRect(x, y, width, radius, cw) {
if (radius === 0) {
return cw
? \`M\${fmt(x)},\${fmt(y)}h\${width}v\${width}h-\${width}z\`
: \`M\${fmt(x)},\${fmt(y)}v\${width}h\${width}v-\${width}z\`;
}
if (radius === width / 2) {
const r = fmt(radius);
const cwFlag = cw ? "1" : "0";
return \`M\${fmt(x + radius)},\${fmt(y)}a\${r},\${r} 0,0,\${cwFlag} 0,\${width}a\${r},\${r} 0,0,\${cwFlag} \${0},-\${width}\`;
}
const r = fmt(radius);
const side = fmt(width - 2 * radius);
return cw
? \`M\${fmt(x + radius)},\${fmt(y)}h\${side}a\${r},\${r} 0,0,1 \${r},\${r}v\${side}a\${r},\${r} 0,0,1 -\${r},\${r}h-\${side}a\${r},\${r} 0,0,1 -\${r},-\${r}v-\${side}a\${r},\${r} 0,0,1 \${r},-\${r}\`
: \`M\${fmt(x + radius)},\${fmt(y)}a\${r},\${r} 0,0,0 -\${r},\${r}v\${side}a\${r},\${r} 0,0,0 \${r},\${r}h\${side}a\${r},\${r} 0,0,0 \${r},-\${r}v-\${side}a\${r},\${r} 0,0,0 -\${r},-\${r}\`;
}
`