main
Kyle Zheng 2024-08-27 02:13:59 -04:00
rodzic a702380045
commit 3046a2d103
4 zmienionych plików z 180 dodań i 122 usunięć

Wyświetl plik

@ -17,7 +17,7 @@ import { debounce } from "~/lib/util";
import { Switch } from "../Switch";
type Props = {
onSave: (s: string) => void;
onSave: (s: string, thumbnail: boolean) => void;
initialValue: string;
};
@ -37,6 +37,8 @@ export function CodeEditor(props: Props) {
localStorage.setItem(VIM_MODE_KEY, v ? "true" : "false");
};
const [updateThumbnail, setUpdateThumbnail] = createSignal(true);
const [dirty, setDirty] = createSignal(false);
const extensions = [
@ -54,7 +56,7 @@ export function CodeEditor(props: Props) {
key: "Mod-s",
linux: "Ctrl-s", // untested, but might be necessary
run: (view) => {
props.onSave(view.state.doc.toString());
props.onSave(view.state.doc.toString(), updateThumbnail());
return true;
},
},
@ -134,9 +136,20 @@ export function CodeEditor(props: Props) {
onChange={(e) => setVimMode(e.target.checked)}
/>
</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
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"
>
{dirty() ? "Save" : "No changes"}

Wyświetl plik

@ -6,7 +6,7 @@ import { Dynamic } from "solid-js/web";
import {
PARAM_COMPONENTS,
defaultParams,
paramsEqual,
deepEqualObj,
parseParamsSchema,
type ParamsSchema,
} from "~/lib/params";
@ -19,6 +19,8 @@ import { TextInput, TextareaInput } from "../TextInput";
import { CodeEditor } from "./CodeEditor";
import { Settings } from "./Settings";
import { clearToasts, toastError } from "../ErrorToasts";
import Minus from "lucide-solid/icons/minus";
import Plus from "lucide-solid/icons/plus";
type Props = {
class?: string;
@ -129,13 +131,13 @@ export function Editor(props: Props) {
const setExistingKey = (key: string) => {
setRenderKey(key);
if (isPreset(key)) {
saveAndRun(PRESET_CODE[key], false);
saveAndRun(PRESET_CODE[key], false, false);
} else {
let storedCode = localStorage.getItem(key);
if (storedCode == null) {
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 };
};
const saveAndRun = async (code: string, changed: boolean) => {
const saveAndRun = async (
code: string,
save: boolean,
thumbnail: boolean
) => {
try {
setCode(code);
if (changed) {
if (save) {
localStorage.setItem(renderKey(), code);
}
const { type, url, parsedParamsSchema } = await importCode(code);
clearToasts()
clearToasts();
// batched b/c trigger rendering effect
batch(() => {
if (!paramsEqual(parsedParamsSchema, paramsSchema())) {
if (!deepEqualObj(parsedParamsSchema, paramsSchema())) {
setParams(defaultParams(parsedParamsSchema));
}
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);
}
} catch (e) {
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);
setRenderKey(key);
saveAndRun(code, true);
saveAndRun(code, true, true);
};
return (
@ -431,11 +437,64 @@ export function Editor(props: Props) {
<div class="flex flex-col gap-2 mb-4">
<For each={Object.entries(paramsSchema())}>
{([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 (
<>
<div class="flex justify-between">
<div class="text-sm py-2 w-48">{label}</div>
{/* @ts-expect-error lose type b/c type and props destructured */}
<div class="text-sm py-2 w-36 shrink-0">{label}</div>
<Dynamic
component={PARAM_COMPONENTS[type]}
{...props}
@ -450,11 +509,11 @@ export function Editor(props: Props) {
</div>
<CodeEditor
initialValue={code()}
onSave={(code) => {
onSave={(code, updateThumbnail) => {
if (presetKeys.includes(renderKey())) {
createAndSelectFunc(renderKey(), code);
} else {
saveAndRun(code, true);
saveAndRun(code, true, updateThumbnail);
}
}}
/>

Wyświetl plik

@ -12,6 +12,7 @@ import {
import type { Params } from "~/lib/params";
import { FlatButton } from "../Button";
import { clearToasts, toastError } from "../ErrorToasts";
import { unwrap } from "solid-js/store";
function download(href: string, name: string) {
const a = document.createElement("a");
@ -85,8 +86,10 @@ function RenderedQrCode() {
// Track store without leaking extra params
const paramsCopy: Params = {};
const unwrapped = unwrap(params)
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!

Wyświetl plik

@ -4,7 +4,7 @@ import { NumberInput } from "~/components/NumberInput";
import { Select } from "~/components/Select";
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 = {
boolean: Switch,
@ -17,38 +17,51 @@ export const PARAM_COMPONENTS = {
export const PARAM_DEFAULTS = {
boolean: false,
number: 0,
Color: "rgb(0,0,0)",
Color: "#000000",
// Select: //default is first option
File: null,
Array: [],
};
export function paramsEqual(
paramsSchemaA: ParamsSchema,
paramsSchemaB: ParamsSchema
) {
const labelsA = Object.keys(paramsSchemaA);
if (labelsA.length !== Object.keys(paramsSchemaB).length) return false;
type PARAM_VALUE_TYPES = {
boolean: boolean;
number: number;
Color: string;
Select: any;
File: File | null;
Array: any[];
};
for (const label of labelsA) {
if (!paramsSchemaB.hasOwnProperty(label)) return false;
export type Params = {
[key: string]: PARAM_VALUE_TYPES[keyof PARAM_VALUE_TYPES];
};
const propsA = Object.keys(paramsSchemaA[label]);
if (propsA.length != Object.keys(paramsSchemaB[label]).length) return false;
export type ParamsSchema = {
[label: string]: Required<RawParamsSchema>;
};
for (const prop of propsA) {
if (prop === "options") {
// @ts-expect-error Object.keys() returns keys
const optionsA = paramsSchemaA[label][prop] as string[];
// @ts-expect-error Object.keys() returns keys
const optionsB = paramsSchemaA[label][prop] as string[];
export type RawParamsSchema = {
type: keyof PARAM_VALUE_TYPES;
default?: any;
[key: string]: any;
};
if (optionsA.length !== optionsB.length) return false;
if (optionsA.some((option) => !optionsB.includes(option))) return false;
} else {
// @ts-expect-error Object.keys() returns keys
if (paramsSchemaA[label][prop] !== paramsSchemaB[label][prop])
return false;
}
export function deepEqualObj(a: any, b: any) {
if (Object.keys(a).length !== Object.keys(b).length) {
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,42 +76,59 @@ export function parseParamsSchema(rawParamsSchema: any) {
let parsedParamsSchema: ParamsSchema = {};
if (typeof rawParamsSchema === "object") {
for (const [key, value] of Object.entries(rawParamsSchema)) {
if (
value == null ||
typeof value !== "object" ||
!("type" in value) ||
typeof value.type !== "string" ||
!PARAM_TYPES.includes(value.type)
) {
continue;
} else if (value.type === "Select") {
if (
!("options" in value) ||
!Array.isArray(value.options) ||
value.options.length === 0
) {
continue;
}
}
// === 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.type === "Select") {
// @ts-expect-error adding default, options validated above
value.default = value.options[0];
} else {
// @ts-expect-error adding default, type validated above
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;
const parsedField = parseField(value);
if (parsedField == null) continue;
parsedParamsSchema[key] = parsedField;
}
}
return parsedParamsSchema;
}
function parseField(value: any) {
if (
value == null ||
typeof value !== "object" ||
!("type" in value) ||
typeof value.type !== "string" ||
!PARAM_TYPES.includes(value.type)
) {
return null;
} else if (value.type === "Select") {
if (
!("options" in value) ||
!Array.isArray(value.options) ||
value.options.length === 0
) {
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
if (value.default === undefined) {
if (value.type === "Select") {
value.default = value.options[0];
} else if (value.type === "Array") {
value.default = Array.from(
{ length: value.defaultLength ?? 1 },
() => value.props.default
);
} else {
// @ts-expect-error adding default, type validated above
value.default = PARAM_DEFAULTS[value.type];
}
}
return parsedParamsSchema;
return value;
}
export function defaultParams(paramsSchema: ParamsSchema) {
@ -108,50 +138,3 @@ export function defaultParams(paramsSchema: ParamsSchema) {
});
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>>>;