@adriansteffan/reactive 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{mod-Beb0Bz3s.js → mod-Bb_FAy0j.js} +14860 -8877
- package/dist/mod.d.ts +130 -0
- package/dist/reactive.es.js +47 -43
- package/dist/reactive.umd.js +50 -42
- package/dist/style.css +1 -1
- package/dist/{web-aMUVS_EB.js → web-BfWhpr9p.js} +1 -1
- package/dist/{web-DOXm98lr.js → web-DmsP-pmx.js} +1 -1
- package/package.json +2 -1
- package/rdk_doc.md +341 -0
- package/src/components/experimentrunner.tsx +5 -1
- package/src/components/index.ts +4 -0
- package/src/components/mobilefilepermission.tsx +2 -0
- package/src/components/randomdotkinetogram.tsx +1013 -0
- package/src/components/tutorial.tsx +232 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useContext, createContext, ReactNode } from 'react';
|
|
2
|
+
import { motion, AnimatePresence, LayoutGroup } from 'motion/react';
|
|
3
|
+
import { BaseComponentProps } from '../mod';
|
|
4
|
+
import { registerSimulation } from '../utils/simulation';
|
|
5
|
+
import { registerFlattener } from '../utils/upload';
|
|
6
|
+
|
|
7
|
+
registerFlattener('Tutorial', 'session');
|
|
8
|
+
|
|
9
|
+
registerSimulation('Tutorial', (trialProps, _experimentState, simulators, participant) => {
|
|
10
|
+
const slideCount = trialProps.slides?.length ?? 0;
|
|
11
|
+
const slideData: Record<number, Record<string, unknown>> = {};
|
|
12
|
+
let totalDuration = 0;
|
|
13
|
+
for (let i = 0; i < slideCount; i++) {
|
|
14
|
+
const result = simulators.respondToSlide(i, trialProps, participant);
|
|
15
|
+
participant = result.participantState;
|
|
16
|
+
slideData[i] = result.value ?? {};
|
|
17
|
+
totalDuration += result.duration ?? 500;
|
|
18
|
+
}
|
|
19
|
+
return { responseData: { slides: slideData }, participantState: participant, duration: totalDuration };
|
|
20
|
+
}, {
|
|
21
|
+
respondToSlide: (_slideIndex: number, _trialProps: any, participant: any) => ({
|
|
22
|
+
value: {},
|
|
23
|
+
participantState: participant,
|
|
24
|
+
duration: 500,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
interface TutorialSlideContextValue {
|
|
29
|
+
setCanProgress: (canProgress: boolean) => void;
|
|
30
|
+
autoAdvance: (delayMs?: number) => void;
|
|
31
|
+
setSlideData: (data: Record<string, unknown>) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TutorialSlideContext = createContext<TutorialSlideContextValue>({
|
|
35
|
+
setCanProgress: () => {},
|
|
36
|
+
autoAdvance: () => {},
|
|
37
|
+
setSlideData: () => {},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook for interactive slide components.
|
|
42
|
+
* Call with `{ locked: true }` to start with the next button disabled.
|
|
43
|
+
* Then call `unlock()` when the interaction is complete.
|
|
44
|
+
* Call `unlock({ autoAdvanceMs: 1000 })` to auto-advance after a delay.
|
|
45
|
+
*/
|
|
46
|
+
export const useTutorialSlide = ({ locked = false } = {}) => {
|
|
47
|
+
const ctx = useContext(TutorialSlideContext);
|
|
48
|
+
const initialized = useRef(false);
|
|
49
|
+
if (!initialized.current && locked) {
|
|
50
|
+
initialized.current = true;
|
|
51
|
+
ctx.setCanProgress(false);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
setData: ctx.setSlideData,
|
|
55
|
+
unlock: ({ autoAdvanceMs }: { autoAdvanceMs?: number } = {}) => {
|
|
56
|
+
ctx.setCanProgress(true);
|
|
57
|
+
if (autoAdvanceMs !== undefined) ctx.autoAdvance(autoAdvanceMs);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface TutorialProps extends BaseComponentProps {
|
|
63
|
+
slides: ReactNode[];
|
|
64
|
+
/** Fade duration in seconds (default: 0.3) */
|
|
65
|
+
fadeDuration?: number;
|
|
66
|
+
finishText?: string;
|
|
67
|
+
containerClass?: string;
|
|
68
|
+
/** Key to go forward (default: 'ArrowRight', set to false to disable) */
|
|
69
|
+
nextKey?: string | false;
|
|
70
|
+
/** Key to go back (default: 'ArrowLeft', set to false to disable) */
|
|
71
|
+
backKey?: string | false;
|
|
72
|
+
/** Color mode for dot indicators (default: 'light') */
|
|
73
|
+
theme?: 'light' | 'dark';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const NAV_BTN =
|
|
77
|
+
'px-4 py-3 border-2 border-black font-bold text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] transition-all duration-100 outline-none focus:outline-none focus:ring-0';
|
|
78
|
+
|
|
79
|
+
const NAV_BTN_ACTIVE = NAV_BTN + ' bg-white text-black cursor-pointer hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none';
|
|
80
|
+
|
|
81
|
+
const NAV_BTN_DISABLED = NAV_BTN + ' bg-gray-200 text-gray-400 border-gray-400 shadow-[2px_2px_0px_rgba(156,163,175,1)] cursor-default';
|
|
82
|
+
|
|
83
|
+
export const Tutorial = ({
|
|
84
|
+
next,
|
|
85
|
+
slides,
|
|
86
|
+
fadeDuration = 0.3,
|
|
87
|
+
finishText = 'Start',
|
|
88
|
+
containerClass,
|
|
89
|
+
nextKey = 'ArrowRight',
|
|
90
|
+
backKey = 'ArrowLeft',
|
|
91
|
+
theme = 'light',
|
|
92
|
+
}: TutorialProps) => {
|
|
93
|
+
const [page, setPage] = useState(0);
|
|
94
|
+
const [progressMap, setProgressMap] = useState<Record<number, boolean>>({});
|
|
95
|
+
const autoAdvanceTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
96
|
+
const dataMap = useRef<Record<number, Record<string, unknown>>>({});
|
|
97
|
+
|
|
98
|
+
const canProgress = progressMap[page] !== false;
|
|
99
|
+
|
|
100
|
+
const handleSetCanProgress = useCallback(
|
|
101
|
+
(value: boolean) => {
|
|
102
|
+
setProgressMap((prev) => ({ ...prev, [page]: value }));
|
|
103
|
+
},
|
|
104
|
+
[page],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const handleSetSlideData = useCallback(
|
|
108
|
+
(data: Record<string, unknown>) => {
|
|
109
|
+
dataMap.current[page] = { ...dataMap.current[page], ...data };
|
|
110
|
+
},
|
|
111
|
+
[page],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const goNext = useCallback(() => {
|
|
115
|
+
if (page === slides.length - 1) next({ slides: dataMap.current });
|
|
116
|
+
else setPage((p) => p + 1);
|
|
117
|
+
}, [page, slides.length, next]);
|
|
118
|
+
|
|
119
|
+
const goNextIfCan = useCallback(() => {
|
|
120
|
+
if (!canProgress) return;
|
|
121
|
+
goNext();
|
|
122
|
+
}, [canProgress, goNext]);
|
|
123
|
+
|
|
124
|
+
const handleAutoAdvance = useCallback(
|
|
125
|
+
(delayMs = 0) => {
|
|
126
|
+
if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current);
|
|
127
|
+
autoAdvanceTimer.current = setTimeout(() => goNext(), delayMs);
|
|
128
|
+
},
|
|
129
|
+
[goNext],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
useEffect(() => () => { if (autoAdvanceTimer.current) clearTimeout(autoAdvanceTimer.current); }, []);
|
|
133
|
+
|
|
134
|
+
const goBack = useCallback(() => {
|
|
135
|
+
if (page > 0) setPage((p) => p - 1);
|
|
136
|
+
}, [page]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (nextKey === false && backKey === false) return;
|
|
140
|
+
const onKey = (e: KeyboardEvent) => {
|
|
141
|
+
if (nextKey && e.key === nextKey) goNextIfCan();
|
|
142
|
+
if (backKey && e.key === backKey) goBack();
|
|
143
|
+
};
|
|
144
|
+
window.addEventListener('keydown', onKey);
|
|
145
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
146
|
+
}, [nextKey, backKey, goNextIfCan, goBack]);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<LayoutGroup>
|
|
150
|
+
<div
|
|
151
|
+
className={containerClass}
|
|
152
|
+
style={{
|
|
153
|
+
width: '100vw',
|
|
154
|
+
height: '100vh',
|
|
155
|
+
display: 'flex',
|
|
156
|
+
flexDirection: 'column',
|
|
157
|
+
overflow: 'hidden',
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<div style={{ flex: 1, position: 'relative' }}>
|
|
161
|
+
<AnimatePresence mode='sync'>
|
|
162
|
+
<motion.div
|
|
163
|
+
key={page}
|
|
164
|
+
initial={{ opacity: 0 }}
|
|
165
|
+
animate={{ opacity: 1 }}
|
|
166
|
+
exit={{ opacity: 0 }}
|
|
167
|
+
transition={{ duration: fadeDuration }}
|
|
168
|
+
style={{
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
inset: 0,
|
|
171
|
+
display: 'flex',
|
|
172
|
+
alignItems: 'center',
|
|
173
|
+
justifyContent: 'center',
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<TutorialSlideContext.Provider value={{ setCanProgress: handleSetCanProgress, autoAdvance: handleAutoAdvance, setSlideData: handleSetSlideData }}>
|
|
177
|
+
{slides[page]}
|
|
178
|
+
</TutorialSlideContext.Provider>
|
|
179
|
+
</motion.div>
|
|
180
|
+
</AnimatePresence>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div
|
|
184
|
+
style={{
|
|
185
|
+
padding: '1.5rem',
|
|
186
|
+
display: 'grid',
|
|
187
|
+
gridTemplateColumns: '1fr auto 1fr',
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<div style={{ justifySelf: 'end', marginRight: '1rem' }}>
|
|
192
|
+
<button
|
|
193
|
+
onClick={goBack}
|
|
194
|
+
className={NAV_BTN_ACTIVE}
|
|
195
|
+
tabIndex={-1}
|
|
196
|
+
style={{ visibility: page > 0 ? 'visible' : 'hidden' }}
|
|
197
|
+
>
|
|
198
|
+
←
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
203
|
+
{slides.map((_, i) => (
|
|
204
|
+
<div
|
|
205
|
+
key={i}
|
|
206
|
+
style={{
|
|
207
|
+
width: 8,
|
|
208
|
+
height: 8,
|
|
209
|
+
borderRadius: '50%',
|
|
210
|
+
backgroundColor: i === page
|
|
211
|
+
? (theme === 'dark' ? '#fff' : '#000')
|
|
212
|
+
: (theme === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)'),
|
|
213
|
+
transition: 'background-color 0.2s',
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
))}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div style={{ justifySelf: 'start', marginLeft: '1rem' }}>
|
|
220
|
+
<button
|
|
221
|
+
onClick={goNextIfCan}
|
|
222
|
+
tabIndex={-1}
|
|
223
|
+
className={(canProgress ? NAV_BTN_ACTIVE : NAV_BTN_DISABLED) + (page === slides.length - 1 ? ' px-8' : '')}
|
|
224
|
+
>
|
|
225
|
+
{page === slides.length - 1 ? finishText : '→'}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</LayoutGroup>
|
|
231
|
+
);
|
|
232
|
+
};
|