kopia lustrzana https://github.com/zhengkyl/qrframe
right click rename/delete + paste warning dialog
rodzic
7a7c3853a3
commit
fe7d2fbb69
|
@ -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
|
||||
|
||||
|
|
110
presets/Basic.js
110
presets/Basic.js
|
@ -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}`;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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()}>
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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}\`;
|
||||
}
|
||||
`
|
||||
|
|
Ładowanie…
Reference in New Issue