Skip to content

Commit 744ee73

Browse files
committed
feat: better error panel and debounced input
1 parent f6c116a commit 744ee73

File tree

3 files changed

+223
-69
lines changed

3 files changed

+223
-69
lines changed

src/Preview.tsx

Lines changed: 106 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,46 @@
11
import { Button } from "@/components/Button";
2-
import {
3-
ResizableHandle,
4-
ResizablePanel,
5-
ResizablePanelGroup,
6-
} from "@/components/Resizable";
2+
import { ResizablePanel } from "@/components/Resizable";
73
import { ActivityIcon, ExternalLinkIcon, LoaderIcon } from "lucide-react";
8-
import { useRef, useState, type FC } from "react";
9-
import type { ImperativePanelHandle } from "react-resizable-panels";
4+
import { useEffect, useState, type FC } from "react";
105
import { useStore } from "@/store";
6+
import { useDebouncedValue } from "@/hooks/debounce";
7+
import { cn } from "@/utils/cn";
118

129
export const Preview: FC = () => {
1310
const $wasmState = useStore((state) => state.wasmState);
11+
const $code = useStore((state) => state.code);
12+
const $setError = useStore((state) => state.setError);
13+
14+
const [debouncedCode, isDebouncing] = useDebouncedValue($code, 1000);
15+
16+
const [output, setOutput] = useState<string | null>(() => null);
17+
18+
useEffect(() => {
19+
if (!window.go_preview) {
20+
return;
21+
}
22+
23+
const getOutput = async () => {
24+
try {
25+
const output = await window.go_preview?.(debouncedCode);
26+
27+
if (output === undefined) {
28+
console.error("Something went wrong");
29+
} else {
30+
setOutput(() => output);
31+
}
32+
} catch (e) {
33+
console.error(e);
34+
if (e instanceof Error) {
35+
$setError(`${e.name}: ${e.message}`);
36+
} else {
37+
$setError("Something went wrong");
38+
}
39+
}
40+
};
41+
42+
getOutput();
43+
}, [debouncedCode, $setError]);
1444

1545
return (
1646
<ResizablePanel className="relative">
@@ -31,37 +61,13 @@ export const Preview: FC = () => {
3161
<Button variant="destructive">Reset form</Button>
3262
</div>
3363

34-
<div className="flex h-full w-full items-center justify-center overflow-x-clip rounded-xl border p-4">
35-
<div className="flex flex-col items-center justify-center gap-3">
36-
<div className="flex items-center justify-center rounded-[6px] bg-highlight-sky p-2">
37-
<ActivityIcon
38-
className="text-content-invert"
39-
width={24}
40-
height={24}
41-
/>
42-
</div>
43-
44-
<div className="flex flex-col items-center gap-2">
45-
<div className="flex max-w-[258px] flex-col items-center gap-1">
46-
<p className="text-nowrap text-center font-semibold text-2xl text-content-primary">
47-
Parameters Playground
48-
</p>
49-
<p className="text-center font-medium text-content-secondary text-sm">
50-
Create dynamic parameters here, I need to figure out a better
51-
copy.
52-
</p>
53-
</div>
54-
<a
55-
href="#todo"
56-
className="flex items-center gap-0.5 text-content-link text-sm"
57-
>
58-
Read the docs
59-
<span className="inline">
60-
<ExternalLinkIcon width={16} />
61-
</span>
62-
</a>
63-
</div>
64-
</div>
64+
<div
65+
className="flex h-full w-full items-center justify-center overflow-x-clip rounded-xl border p-4"
66+
style={{
67+
opacity: isDebouncing ? 0.5 : 1,
68+
}}
69+
>
70+
{output ? output : <PreviewEmptyState />}
6571
</div>
6672
</div>
6773

@@ -70,15 +76,39 @@ export const Preview: FC = () => {
7076
);
7177
};
7278

73-
const ErrorPane = () => {
74-
const $error = useStore((state) => state.error);
79+
const PreviewEmptyState = () => {
80+
return (
81+
<div className="flex flex-col items-center justify-center gap-3">
82+
<div className="flex items-center justify-center rounded-[6px] bg-highlight-sky p-2">
83+
<ActivityIcon className="text-content-invert" width={24} height={24} />
84+
</div>
7585

76-
const [errorPanelSize, setErrorPanelSize] = useState(() => 50);
77-
const errorPanelRef = useRef<ImperativePanelHandle>(null);
86+
<div className="flex flex-col items-center gap-2">
87+
<div className="flex max-w-[258px] flex-col items-center gap-1">
88+
<p className="text-nowrap text-center font-semibold text-2xl text-content-primary">
89+
Parameters Playground
90+
</p>
91+
<p className="text-center font-medium text-content-secondary text-sm">
92+
Create dynamic parameters here, I need to figure out a better copy.
93+
</p>
94+
</div>
95+
<a
96+
href="#todo"
97+
className="flex items-center gap-0.5 text-content-link text-sm"
98+
>
99+
Read the docs
100+
<span className="inline">
101+
<ExternalLinkIcon width={16} />
102+
</span>
103+
</a>
104+
</div>
105+
</div>
106+
);
107+
};
78108

79-
const onCollapseError = () => {
80-
errorPanelRef.current?.collapse();
81-
};
109+
const ErrorPane = () => {
110+
const $error = useStore((state) => state.error);
111+
const $toggleShowError = useStore((state) => state.toggleShowError);
82112

83113
if (!$error) {
84114
return null;
@@ -87,34 +117,43 @@ const ErrorPane = () => {
87117
return (
88118
<>
89119
<div
90-
className="pointer-events-none absolute top-0 left-0 h-full w-full bg-black"
91-
style={{ opacity: errorPanelSize / 100 }}
120+
aria-hidden={true}
121+
className={cn(
122+
"pointer-events-none absolute top-0 left-0 h-full w-full transition-all",
123+
$error.show && "bg-black/50",
124+
)}
92125
>
93126
{/* OVERLAY */}
94127
</div>
95128

96-
<ResizablePanelGroup
97-
direction="vertical"
98-
className="pointer-events-none absolute top-0 left-0"
129+
<div
130+
className={cn(
131+
"absolute bottom-0 left-0 w-full",
132+
$error.show && "h-2/3",
133+
)}
99134
>
100-
<ResizablePanel aria-hidden className="pointer-events-none">
101-
{/* EMPTY */}
102-
</ResizablePanel>
103-
<ResizableHandle
104-
onClick={onCollapseError}
135+
<button
105136
className="flex h-4 min-h-4 w-full items-center justify-center rounded-t-xl bg-[#AA5253]"
106-
withHandle={true}
107-
/>
108-
<ResizablePanel
109-
ref={errorPanelRef}
110-
className="bg-surface-secondary"
111-
collapsible={true}
112-
collapsedSize={0}
113-
onResize={(size) => {
114-
setErrorPanelSize(() => size);
115-
}}
116-
></ResizablePanel>
117-
</ResizablePanelGroup>
137+
onClick={$toggleShowError}
138+
>
139+
<div className="h-0.5 w-2/3 max-w-32 rounded-full bg-white/40"></div>
140+
</button>
141+
142+
<div
143+
aria-hidden={!$error.show}
144+
className={cn(
145+
"flex h-full flex-col gap-6 bg-surface-secondary p-6",
146+
!$error.show && "pointer-events-none h-0 p-0",
147+
)}
148+
>
149+
<p className="font-semibold text-content-primary text-xl">
150+
An error has occurred
151+
</p>
152+
<p className="rounded-xl bg-surface-tertiary p-3 font-mono text-content-primary text-xs">
153+
{$error.message}
154+
</p>
155+
</div>
156+
</div>
118157
</>
119158
);
120159
};

src/hooks/debounce.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @file Defines hooks for created debounced versions of functions and arbitrary
3+
* values.
4+
*
5+
* It is not safe to call a general-purpose debounce utility inside a React
6+
* render. It will work on the initial render, but the memory reference for the
7+
* value will change on re-renders. Most debounce functions create a "stateful"
8+
* version of a function by leveraging closure; but by calling it repeatedly,
9+
* you create multiple "pockets" of state, rather than a centralized one.
10+
*
11+
* Debounce utilities can make sense if they can be called directly outside the
12+
* component or in a useEffect call, though.
13+
*/
14+
import { useCallback, useEffect, useRef, useState } from "react";
15+
16+
type useDebouncedFunctionReturn<Args extends unknown[]> = Readonly<{
17+
debounced: (...args: Args) => void;
18+
19+
// Mainly here to make interfacing with useEffect cleanup functions easier
20+
cancelDebounce: () => void;
21+
}>;
22+
23+
/**
24+
* Creates a debounce function that is resilient to React re-renders, as well as
25+
* a function for canceling a pending debounce.
26+
*
27+
* The returned-out functions will maintain the same memory references, but the
28+
* debounce function will always "see" the most recent versions of the arguments
29+
* passed into the hook, and use them accordingly.
30+
*
31+
* If the debounce time changes while a callback has been queued to fire, the
32+
* callback will be canceled completely. You will need to restart the debounce
33+
* process by calling the returned-out function again.
34+
*/
35+
export function useDebouncedFunction<
36+
// Parameterizing on the args instead of the whole callback function type to
37+
// avoid type contra-variance issues
38+
Args extends unknown[] = unknown[],
39+
>(
40+
callback: (...args: Args) => void | Promise<void>,
41+
debounceTimeMs: number,
42+
): useDebouncedFunctionReturn<Args> {
43+
const timeoutIdRef = useRef<number | null>(null);
44+
const cancelDebounce = useCallback(() => {
45+
if (timeoutIdRef.current !== null) {
46+
window.clearTimeout(timeoutIdRef.current);
47+
}
48+
49+
timeoutIdRef.current = null;
50+
}, []);
51+
52+
const debounceTimeRef = useRef(debounceTimeMs);
53+
useEffect(() => {
54+
cancelDebounce();
55+
debounceTimeRef.current = debounceTimeMs;
56+
}, [cancelDebounce, debounceTimeMs]);
57+
58+
const callbackRef = useRef(callback);
59+
useEffect(() => {
60+
callbackRef.current = callback;
61+
}, [callback]);
62+
63+
// Returned-out function will always be synchronous, even if the callback arg
64+
// is async. Seemed dicey to try awaiting a genericized operation that can and
65+
// will likely be canceled repeatedly
66+
const debounced = useCallback(
67+
(...args: Args): void => {
68+
cancelDebounce();
69+
70+
timeoutIdRef.current = window.setTimeout(
71+
() => void callbackRef.current(...args),
72+
debounceTimeRef.current,
73+
);
74+
},
75+
[cancelDebounce],
76+
);
77+
78+
return { debounced, cancelDebounce } as const;
79+
}
80+
81+
/**
82+
* Takes any value, and returns out a debounced version of it.
83+
*/
84+
export function useDebouncedValue<T = unknown>(
85+
value: T,
86+
debounceTimeMs: number,
87+
): [T, boolean] {
88+
const [debouncedValue, setDebouncedValue] = useState(value);
89+
const [isDebouncing, setIsDebouncing] = useState(false);
90+
91+
useEffect(() => {
92+
setIsDebouncing(() => true);
93+
const timeoutId = window.setTimeout(() => {
94+
setDebouncedValue(value);
95+
setIsDebouncing(() => false);
96+
}, debounceTimeMs);
97+
98+
return () => window.clearTimeout(timeoutId);
99+
}, [value, debounceTimeMs]);
100+
101+
return [debouncedValue, isDebouncing];
102+
}

src/store.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,29 @@ type WasmState = "loaded" | "loading" | "error";
1313
type State = {
1414
code: string;
1515
wasmState: WasmState;
16-
error?: string;
16+
error?: {
17+
message: string;
18+
show: boolean;
19+
};
1720
setCode: (code: string) => void;
1821
setError: (error: string) => void;
22+
toggleShowError: () => void;
1923
setWasmState: (wasmState: WasmState) => void;
2024
};
2125

2226
export const useStore = create<State>()((set) => ({
2327
code: defaultCode,
2428
wasmState: "loading",
29+
error: {
30+
message: "wibble: wobble",
31+
show: true,
32+
},
2533
setCode: (code) => set((_) => ({ code })),
26-
setError: (error) => set((_) => ({ error })),
34+
setError: (message) =>
35+
set((state) => ({ error: { message, show: state.error?.show ?? false } })),
36+
toggleShowError: () =>
37+
set((state) => ({
38+
error: { show: !state.error?.show, message: state.error?.message ?? "" },
39+
})),
2740
setWasmState: (wasmState) => set((_) => ({ wasmState })),
2841
}));

0 commit comments

Comments
 (0)