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"; 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"}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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