kopia lustrzana https://github.com/zhengkyl/qrframe
array params
rodzic
a702380045
commit
3046a2d103
|
@ -17,7 +17,7 @@ import { debounce } from "~/lib/util";
|
||||||
import { Switch } from "../Switch";
|
import { Switch } from "../Switch";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSave: (s: string) => void;
|
onSave: (s: string, thumbnail: boolean) => void;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -37,6 +37,8 @@ export function CodeEditor(props: Props) {
|
||||||
localStorage.setItem(VIM_MODE_KEY, v ? "true" : "false");
|
localStorage.setItem(VIM_MODE_KEY, v ? "true" : "false");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [updateThumbnail, setUpdateThumbnail] = createSignal(true);
|
||||||
|
|
||||||
const [dirty, setDirty] = createSignal(false);
|
const [dirty, setDirty] = createSignal(false);
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
|
@ -54,7 +56,7 @@ export function CodeEditor(props: Props) {
|
||||||
key: "Mod-s",
|
key: "Mod-s",
|
||||||
linux: "Ctrl-s", // untested, but might be necessary
|
linux: "Ctrl-s", // untested, but might be necessary
|
||||||
run: (view) => {
|
run: (view) => {
|
||||||
props.onSave(view.state.doc.toString());
|
props.onSave(view.state.doc.toString(), updateThumbnail());
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -134,9 +136,20 @@ export function CodeEditor(props: Props) {
|
||||||
onChange={(e) => setVimMode(e.target.checked)}
|
onChange={(e) => setVimMode(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-1 text-sm">
|
||||||
|
Update thumbnail
|
||||||
|
<input
|
||||||
|
class="h-4 w-4"
|
||||||
|
type="checkbox"
|
||||||
|
checked={updateThumbnail()}
|
||||||
|
onChange={(e) => setUpdateThumbnail(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<Button
|
<Button
|
||||||
disabled={!dirty()}
|
disabled={!dirty()}
|
||||||
onMouseDown={() => props.onSave(view.state.doc.toString())}
|
onMouseDown={() =>
|
||||||
|
props.onSave(view.state.doc.toString(), updateThumbnail())
|
||||||
|
}
|
||||||
class="bg-green-700 border rounded-md hover:bg-green-700/90 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) disabled:(bg-transparent text-fore-base pointer-events-none opacity-50) transition-colors px-3 min-w-150px"
|
class="bg-green-700 border rounded-md hover:bg-green-700/90 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) disabled:(bg-transparent text-fore-base pointer-events-none opacity-50) transition-colors px-3 min-w-150px"
|
||||||
>
|
>
|
||||||
{dirty() ? "Save" : "No changes"}
|
{dirty() ? "Save" : "No changes"}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Dynamic } from "solid-js/web";
|
||||||
import {
|
import {
|
||||||
PARAM_COMPONENTS,
|
PARAM_COMPONENTS,
|
||||||
defaultParams,
|
defaultParams,
|
||||||
paramsEqual,
|
deepEqualObj,
|
||||||
parseParamsSchema,
|
parseParamsSchema,
|
||||||
type ParamsSchema,
|
type ParamsSchema,
|
||||||
} from "~/lib/params";
|
} from "~/lib/params";
|
||||||
|
@ -19,6 +19,8 @@ import { TextInput, TextareaInput } from "../TextInput";
|
||||||
import { CodeEditor } from "./CodeEditor";
|
import { CodeEditor } from "./CodeEditor";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { clearToasts, toastError } from "../ErrorToasts";
|
import { clearToasts, toastError } from "../ErrorToasts";
|
||||||
|
import Minus from "lucide-solid/icons/minus";
|
||||||
|
import Plus from "lucide-solid/icons/plus";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
@ -129,13 +131,13 @@ export function Editor(props: Props) {
|
||||||
const setExistingKey = (key: string) => {
|
const setExistingKey = (key: string) => {
|
||||||
setRenderKey(key);
|
setRenderKey(key);
|
||||||
if (isPreset(key)) {
|
if (isPreset(key)) {
|
||||||
saveAndRun(PRESET_CODE[key], false);
|
saveAndRun(PRESET_CODE[key], false, false);
|
||||||
} else {
|
} else {
|
||||||
let storedCode = localStorage.getItem(key);
|
let storedCode = localStorage.getItem(key);
|
||||||
if (storedCode == null) {
|
if (storedCode == null) {
|
||||||
storedCode = `Failed to load ${key}`;
|
storedCode = `Failed to load ${key}`;
|
||||||
}
|
}
|
||||||
saveAndRun(storedCode, false);
|
saveAndRun(storedCode, false, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -170,19 +172,23 @@ export function Editor(props: Props) {
|
||||||
return { type, url, parsedParamsSchema };
|
return { type, url, parsedParamsSchema };
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveAndRun = async (code: string, changed: boolean) => {
|
const saveAndRun = async (
|
||||||
|
code: string,
|
||||||
|
save: boolean,
|
||||||
|
thumbnail: boolean
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
setCode(code);
|
setCode(code);
|
||||||
if (changed) {
|
if (save) {
|
||||||
localStorage.setItem(renderKey(), code);
|
localStorage.setItem(renderKey(), code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, url, parsedParamsSchema } = await importCode(code);
|
const { type, url, parsedParamsSchema } = await importCode(code);
|
||||||
clearToasts()
|
clearToasts();
|
||||||
|
|
||||||
// batched b/c trigger rendering effect
|
// batched b/c trigger rendering effect
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!paramsEqual(parsedParamsSchema, paramsSchema())) {
|
if (!deepEqualObj(parsedParamsSchema, paramsSchema())) {
|
||||||
setParams(defaultParams(parsedParamsSchema));
|
setParams(defaultParams(parsedParamsSchema));
|
||||||
}
|
}
|
||||||
setParamsSchema(parsedParamsSchema); // always update in case different property order
|
setParamsSchema(parsedParamsSchema); // always update in case different property order
|
||||||
|
@ -195,12 +201,12 @@ export function Editor(props: Props) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (changed) {
|
if (thumbnail) {
|
||||||
asyncUpdateThumbnail(renderKey(), type, url, parsedParamsSchema);
|
asyncUpdateThumbnail(renderKey(), type, url, parsedParamsSchema);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("e", e!.toString());
|
console.error("e", e!.toString());
|
||||||
toastError("Invalid code", e!.toString())
|
toastError("Invalid code", e!.toString());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -280,7 +286,7 @@ export function Editor(props: Props) {
|
||||||
|
|
||||||
setThumbs(key, LOADING_THUMB);
|
setThumbs(key, LOADING_THUMB);
|
||||||
setRenderKey(key);
|
setRenderKey(key);
|
||||||
saveAndRun(code, true);
|
saveAndRun(code, true, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -431,11 +437,64 @@ export function Editor(props: Props) {
|
||||||
<div class="flex flex-col gap-2 mb-4">
|
<div class="flex flex-col gap-2 mb-4">
|
||||||
<For each={Object.entries(paramsSchema())}>
|
<For each={Object.entries(paramsSchema())}>
|
||||||
{([label, { type, ...props }]) => {
|
{([label, { type, ...props }]) => {
|
||||||
|
if (type === "Array") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div class="grid grid-cols-[144px_1fr] justify-items-end gap-y-2">
|
||||||
|
<div class="text-sm py-2 w-36">{label}</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Show when={props.resizable}>
|
||||||
|
<FlatButton
|
||||||
|
class="p-1.5"
|
||||||
|
onClick={() =>
|
||||||
|
setParams(label, (prev: any[]) =>
|
||||||
|
prev.slice(0, -1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
</FlatButton>
|
||||||
|
<FlatButton
|
||||||
|
class="p-1.5"
|
||||||
|
onClick={() =>
|
||||||
|
setParams(label, (prev: any[]) => [
|
||||||
|
...prev,
|
||||||
|
props.props.default,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</FlatButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<For each={params[label]}>
|
||||||
|
{(v, i) => (
|
||||||
|
<>
|
||||||
|
<div class="text-sm py-2 pl-4 w-full text-left">
|
||||||
|
{i()}
|
||||||
|
</div>
|
||||||
|
<Dynamic
|
||||||
|
component={
|
||||||
|
PARAM_COMPONENTS[
|
||||||
|
props.props
|
||||||
|
.type as keyof typeof PARAM_COMPONENTS
|
||||||
|
]
|
||||||
|
}
|
||||||
|
{...props.props}
|
||||||
|
value={v}
|
||||||
|
setValue={(v: any) => setParams(label, i(), v)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="text-sm py-2 w-48">{label}</div>
|
<div class="text-sm py-2 w-36 shrink-0">{label}</div>
|
||||||
{/* @ts-expect-error lose type b/c type and props destructured */}
|
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={PARAM_COMPONENTS[type]}
|
component={PARAM_COMPONENTS[type]}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -450,11 +509,11 @@ export function Editor(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
initialValue={code()}
|
initialValue={code()}
|
||||||
onSave={(code) => {
|
onSave={(code, updateThumbnail) => {
|
||||||
if (presetKeys.includes(renderKey())) {
|
if (presetKeys.includes(renderKey())) {
|
||||||
createAndSelectFunc(renderKey(), code);
|
createAndSelectFunc(renderKey(), code);
|
||||||
} else {
|
} else {
|
||||||
saveAndRun(code, true);
|
saveAndRun(code, true, updateThumbnail);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import type { Params } from "~/lib/params";
|
import type { Params } from "~/lib/params";
|
||||||
import { FlatButton } from "../Button";
|
import { FlatButton } from "../Button";
|
||||||
import { clearToasts, toastError } from "../ErrorToasts";
|
import { clearToasts, toastError } from "../ErrorToasts";
|
||||||
|
import { unwrap } from "solid-js/store";
|
||||||
|
|
||||||
function download(href: string, name: string) {
|
function download(href: string, name: string) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
@ -85,8 +86,10 @@ function RenderedQrCode() {
|
||||||
|
|
||||||
// Track store without leaking extra params
|
// Track store without leaking extra params
|
||||||
const paramsCopy: Params = {};
|
const paramsCopy: Params = {};
|
||||||
|
const unwrapped = unwrap(params)
|
||||||
Object.keys(paramsSchema()).forEach((key) => {
|
Object.keys(paramsSchema()).forEach((key) => {
|
||||||
paramsCopy[key] = params[key];
|
params[key]; // access to track
|
||||||
|
paramsCopy[key] = unwrapped[key];
|
||||||
});
|
});
|
||||||
|
|
||||||
// all reactive deps must be above early return!
|
// all reactive deps must be above early return!
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { NumberInput } from "~/components/NumberInput";
|
||||||
import { Select } from "~/components/Select";
|
import { Select } from "~/components/Select";
|
||||||
import { Switch } from "~/components/Switch";
|
import { Switch } from "~/components/Switch";
|
||||||
|
|
||||||
const PARAM_TYPES = ["boolean", "number", "Color", "Select", "File"];
|
const PARAM_TYPES = ["boolean", "number", "Color", "Select", "File", "Array"];
|
||||||
|
|
||||||
export const PARAM_COMPONENTS = {
|
export const PARAM_COMPONENTS = {
|
||||||
boolean: Switch,
|
boolean: Switch,
|
||||||
|
@ -17,38 +17,51 @@ export const PARAM_COMPONENTS = {
|
||||||
export const PARAM_DEFAULTS = {
|
export const PARAM_DEFAULTS = {
|
||||||
boolean: false,
|
boolean: false,
|
||||||
number: 0,
|
number: 0,
|
||||||
Color: "rgb(0,0,0)",
|
Color: "#000000",
|
||||||
// Select: //default is first option
|
// Select: //default is first option
|
||||||
File: null,
|
File: null,
|
||||||
|
Array: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function paramsEqual(
|
type PARAM_VALUE_TYPES = {
|
||||||
paramsSchemaA: ParamsSchema,
|
boolean: boolean;
|
||||||
paramsSchemaB: ParamsSchema
|
number: number;
|
||||||
) {
|
Color: string;
|
||||||
const labelsA = Object.keys(paramsSchemaA);
|
Select: any;
|
||||||
if (labelsA.length !== Object.keys(paramsSchemaB).length) return false;
|
File: File | null;
|
||||||
|
Array: any[];
|
||||||
|
};
|
||||||
|
|
||||||
for (const label of labelsA) {
|
export type Params = {
|
||||||
if (!paramsSchemaB.hasOwnProperty(label)) return false;
|
[key: string]: PARAM_VALUE_TYPES[keyof PARAM_VALUE_TYPES];
|
||||||
|
};
|
||||||
|
|
||||||
const propsA = Object.keys(paramsSchemaA[label]);
|
export type ParamsSchema = {
|
||||||
if (propsA.length != Object.keys(paramsSchemaB[label]).length) return false;
|
[label: string]: Required<RawParamsSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
for (const prop of propsA) {
|
export type RawParamsSchema = {
|
||||||
if (prop === "options") {
|
type: keyof PARAM_VALUE_TYPES;
|
||||||
// @ts-expect-error Object.keys() returns keys
|
default?: any;
|
||||||
const optionsA = paramsSchemaA[label][prop] as string[];
|
[key: string]: any;
|
||||||
// @ts-expect-error Object.keys() returns keys
|
};
|
||||||
const optionsB = paramsSchemaA[label][prop] as string[];
|
|
||||||
|
|
||||||
if (optionsA.length !== optionsB.length) return false;
|
export function deepEqualObj(a: any, b: any) {
|
||||||
if (optionsA.some((option) => !optionsB.includes(option))) return false;
|
if (Object.keys(a).length !== Object.keys(b).length) {
|
||||||
} else {
|
|
||||||
// @ts-expect-error Object.keys() returns keys
|
|
||||||
if (paramsSchemaA[label][prop] !== paramsSchemaB[label][prop])
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const key in a) {
|
||||||
|
if (!b.hasOwnProperty(key)) return false;
|
||||||
|
if (
|
||||||
|
typeof a[key] === "object" &&
|
||||||
|
a[key] != null &&
|
||||||
|
typeof b[key] === "object" &&
|
||||||
|
b[key] != null
|
||||||
|
) {
|
||||||
|
if (!deepEqualObj(a[key], b[key])) return false;
|
||||||
|
} else if (a[key] !== b[key]) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,6 +76,16 @@ export function parseParamsSchema(rawParamsSchema: any) {
|
||||||
let parsedParamsSchema: ParamsSchema = {};
|
let parsedParamsSchema: ParamsSchema = {};
|
||||||
if (typeof rawParamsSchema === "object") {
|
if (typeof rawParamsSchema === "object") {
|
||||||
for (const [key, value] of Object.entries(rawParamsSchema)) {
|
for (const [key, value] of Object.entries(rawParamsSchema)) {
|
||||||
|
// TODO so user set default could be wrong type
|
||||||
|
const parsedField = parseField(value);
|
||||||
|
if (parsedField == null) continue;
|
||||||
|
parsedParamsSchema[key] = parsedField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsedParamsSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseField(value: any) {
|
||||||
if (
|
if (
|
||||||
value == null ||
|
value == null ||
|
||||||
typeof value !== "object" ||
|
typeof value !== "object" ||
|
||||||
|
@ -70,35 +93,42 @@ export function parseParamsSchema(rawParamsSchema: any) {
|
||||||
typeof value.type !== "string" ||
|
typeof value.type !== "string" ||
|
||||||
!PARAM_TYPES.includes(value.type)
|
!PARAM_TYPES.includes(value.type)
|
||||||
) {
|
) {
|
||||||
continue;
|
return null;
|
||||||
} else if (value.type === "Select") {
|
} else if (value.type === "Select") {
|
||||||
if (
|
if (
|
||||||
!("options" in value) ||
|
!("options" in value) ||
|
||||||
!Array.isArray(value.options) ||
|
!Array.isArray(value.options) ||
|
||||||
value.options.length === 0
|
value.options.length === 0
|
||||||
) {
|
) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
|
} else if (value.type === "Array") {
|
||||||
|
if (!("props" in value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const innerProps = parseField(value.props);
|
||||||
|
if (innerProps == null || innerProps.default === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
value.props = innerProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === undefined b/c null is a valid default value for ImageInput
|
// === undefined b/c null is a valid default value for ImageInput
|
||||||
// @ts-expect-error yes .default might be undefined thanks typescript
|
|
||||||
if (value.default === undefined) {
|
if (value.default === undefined) {
|
||||||
if (value.type === "Select") {
|
if (value.type === "Select") {
|
||||||
// @ts-expect-error adding default, options validated above
|
|
||||||
value.default = value.options[0];
|
value.default = value.options[0];
|
||||||
|
} else if (value.type === "Array") {
|
||||||
|
value.default = Array.from(
|
||||||
|
{ length: value.defaultLength ?? 1 },
|
||||||
|
() => value.props.default
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// @ts-expect-error adding default, type validated above
|
// @ts-expect-error adding default, type validated above
|
||||||
value.default = PARAM_DEFAULTS[value.type];
|
value.default = PARAM_DEFAULTS[value.type];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO so user set default could be wrong type
|
|
||||||
// @ts-expect-error prop types not validated
|
|
||||||
parsedParamsSchema[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedParamsSchema;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultParams(paramsSchema: ParamsSchema) {
|
export function defaultParams(paramsSchema: ParamsSchema) {
|
||||||
|
@ -108,50 +138,3 @@ export function defaultParams(paramsSchema: ParamsSchema) {
|
||||||
});
|
});
|
||||||
return defaultParams;
|
return defaultParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PARAM_VALUE_TYPES = {
|
|
||||||
boolean: boolean;
|
|
||||||
number: number;
|
|
||||||
Color: string;
|
|
||||||
Select: never; // see Params, uses options field instead of this mapping
|
|
||||||
File: File | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Params<T extends RawParamsSchema = ParamsSchema> = {
|
|
||||||
[K in keyof T]: T[K] extends { type: "Select" }
|
|
||||||
? T[K]["options"][number]
|
|
||||||
: PARAM_VALUE_TYPES[T[K]["type"]];
|
|
||||||
} & {}; // & {} necessary for readable typehints, see ts "prettify"
|
|
||||||
|
|
||||||
export type ParamsSchema = {
|
|
||||||
[label: string]: Required<RawParamsSchema[string]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RawParamsSchema = SchemaFromMapping<typeof PARAM_COMPONENTS>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given object mapping keys to components, returns union of [key, props]
|
|
||||||
*/
|
|
||||||
type Step1<T extends { [key: keyof any]: (...args: any) => any }> = {
|
|
||||||
[K in keyof T]: [K, Parameters<T[K]>[0]];
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given union of [key, props]:
|
|
||||||
*
|
|
||||||
* adds default to props based on type of value and removes value and setValue from props
|
|
||||||
*/
|
|
||||||
type Step2<T extends [keyof any, { [prop: string]: any }]> = T extends any
|
|
||||||
? [T[0], Omit<T[1], "value" | "setValue"> & { default?: T[1]["value"] }]
|
|
||||||
: never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts each tuple to an object with a type corresponding to key
|
|
||||||
* (discriminated union of component props) assignable to any label
|
|
||||||
*/
|
|
||||||
type Step3<T extends [keyof any, { [prop: string]: any }]> = {
|
|
||||||
[label: string]: T extends any ? { type: T[0] } & T[1] : never;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SchemaFromMapping<T extends { [key: string]: (...args: any) => any }> =
|
|
||||||
Step3<Step2<Step1<T>>>;
|
|
||||||
|
|
Ładowanie…
Reference in New Issue