-
Notifications
You must be signed in to change notification settings - Fork 5
/
Demo.tsx
160 lines (150 loc) · 5.15 KB
/
Demo.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { useRect } from "@reach/rect";
import { button, useControls } from "leva";
import { nanoid } from "nanoid";
import getStroke from "perfect-freehand";
import React, { useEffect, useRef, useState } from "react";
import tw, { css } from "twin.macro";
import { useReactQueryAutoSync } from "../../lib/useReactQueryAutoSync";
import { getSvgPathFromStroke } from "../utils/getSvgPathFromStroke";
import { SaveIndicator } from "./SaveIndicator";
import { Wrapper } from "./Wrapper";
export function Demo() {
const eventElementRef = useRef<SVGSVGElement | null>(null);
const rect = useRect(eventElementRef, { observe: true });
// creating a synced value is nearly as simple as useState
const { draft: strokes, setDraft: setStrokes, saveStatus } = useStrokes();
useEvents(eventElementRef, strokes, setStrokes);
useControls({
clear: button(() => fetch("/clear")),
});
return (
<Wrapper>
<svg
ref={eventElementRef}
height="100%"
width="100%"
// eslint-disable-next-line react/no-unknown-property
css={[
tw`bg-gray-100 rounded-md`,
css`
touch-action: none;
`,
]}
viewBox={`0 0 ${rect?.width ?? 100} ${rect?.height ?? 100}`}
>
<g>
{strokes !== undefined &&
Object.keys(strokes).map((strokeId) => {
return (
<path
d={getSvgPathFromStroke(
getStroke(strokes[strokeId], {
size: 16,
thinning: 0.75,
smoothing: 0.5,
streamline: 0.5,
}),
)}
key={strokeId}
/>
);
})}
</g>
</svg>
{/* render the unsaved changes indicator */}
<SaveIndicator isUnsyncedChanges={saveStatus === "unsaved"} />
</Wrapper>
);
}
function useEvents(
eventElementRef: React.MutableRefObject<SVGSVGElement | null>,
strokes: Record<string, number[][]> | undefined,
setStrokes: (newStrokes: Record<string, number[][]> | undefined) => void,
) {
const [currentStrokeId, setCurrentStrokeId] = useState<string | null>(null);
useEffect(() => {
const ref = eventElementRef.current;
if (ref) {
ref.addEventListener("pointerdown", handlePointerDown);
ref.addEventListener("pointermove", handlePointerMove);
ref.addEventListener("pointerup", handlePointerUp);
}
return () => {
if (ref) {
ref.removeEventListener("pointerdown", handlePointerDown);
ref.removeEventListener("pointermove", handlePointerMove);
ref.removeEventListener("pointerup", handlePointerUp);
}
};
function pointsFromEvent(e: PointerEvent) {
return [e.offsetX, e.offsetY, e.pressure];
}
function handlePointerDown(e: PointerEvent): void {
// on pointer down, if strokes are loaded, add a new stroke
if (strokes !== undefined) {
const newStrokeId = nanoid();
setStrokes({
...strokes,
[`${newStrokeId}`]: [pointsFromEvent(e)],
});
setCurrentStrokeId(newStrokeId);
}
}
function handlePointerMove(e: PointerEvent): void {
// on pointer move if strokes are loaded add new points to the current stroke
if (currentStrokeId !== null && strokes !== undefined) {
setStrokes({
...strokes,
[`${currentStrokeId}`]: [...strokes[currentStrokeId], pointsFromEvent(e)],
});
}
}
function handlePointerUp(e: PointerEvent): void {
// on pointer up finish the current stroke
if (currentStrokeId !== null && strokes !== undefined) {
setStrokes({
...strokes,
[`${currentStrokeId}`]: [...strokes[currentStrokeId], pointsFromEvent(e)],
});
setCurrentStrokeId(null);
}
}
}, [currentStrokeId, eventElementRef, setStrokes, strokes]);
}
// this function contains the entire integration with react query
function useStrokes() {
// hook which renders GUI controls and exposes their values as reactive properties
const { refetchInterval, wait, maxWait } = useControls({ refetchInterval: 1000, wait: 50, maxWait: 250 });
// all the logic for saving is embedded in this hook
return useReactQueryAutoSync({
// pass standard query options for loading data from the server
queryOptions: {
queryKey: ["getStrokes"],
queryFn: async () =>
await fetch(`/load`)
.then((res) => res.json())
.then((json) => json as Record<string, number[][]>),
// refetch interval for querying from the server
refetchInterval,
},
// pass standard mutation options for saving data to the server
mutationOptions: {
mutationFn: (strokes) =>
fetch("/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(strokes),
}),
},
// use parameters to control the frequency of saves
autoSaveOptions: {
wait,
maxWait,
},
// provide a function to merge local and server state
merge: (remote, local) => ({
...remote,
...local,
}),
});
}