@adriansteffan/reactive 0.0.24 → 0.0.27

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.
Files changed (36) hide show
  1. package/dist/{mod-DFT88PVZ.js → mod-CbGhKi2f.js} +11270 -9987
  2. package/dist/mod.d.ts +188 -48
  3. package/dist/reactive.es.js +26 -15
  4. package/dist/reactive.umd.js +37 -39
  5. package/dist/style.css +1 -1
  6. package/dist/{web-BkxeXT_o.js → web-BFGLx41c.js} +1 -1
  7. package/dist/{web-DLRbsabT.js → web-DOFokKz7.js} +1 -1
  8. package/package.json +7 -2
  9. package/src/components/canvasblock.tsx +519 -0
  10. package/src/components/checkdevice.tsx +158 -0
  11. package/src/components/enterfullscreen.tsx +114 -31
  12. package/src/components/exitfullscreen.tsx +98 -21
  13. package/src/components/experimentprovider.tsx +34 -20
  14. package/src/components/experimentrunner.tsx +387 -0
  15. package/src/components/index.ts +13 -0
  16. package/src/components/mobilefilepermission.tsx +63 -0
  17. package/src/components/plaininput.tsx +7 -8
  18. package/src/components/prolificending.tsx +10 -4
  19. package/src/components/quest.tsx +27 -31
  20. package/src/components/settingsscreen.tsx +770 -0
  21. package/src/components/text.tsx +48 -3
  22. package/src/components/upload.tsx +218 -47
  23. package/src/mod.tsx +3 -11
  24. package/src/types/array.d.ts +6 -0
  25. package/src/utils/array.ts +79 -0
  26. package/src/utils/bytecode.ts +178 -0
  27. package/src/utils/common.ts +170 -39
  28. package/template/.env.template +2 -1
  29. package/template/README.md +20 -5
  30. package/template/package.json +1 -0
  31. package/template/shared.vite.config.js +18 -0
  32. package/template/src/{App.tsx → Experiment.tsx} +4 -4
  33. package/template/src/main.tsx +4 -4
  34. package/template/tsconfig.json +4 -2
  35. package/tsconfig.json +1 -0
  36. package/src/components/experiment.tsx +0 -369
@@ -1,19 +1,64 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { BaseComponentProps, now } from '../mod';
3
+
1
4
  function Text({
2
5
  content,
3
6
  buttonText = 'Click me',
4
7
  className = '',
5
8
  next,
6
9
  animate = false,
10
+ allowedKeys = false,
7
11
  }: {
8
12
  content: React.ReactNode;
9
13
  buttonText?: string;
10
14
  onButtonClick?: () => void;
11
15
  className?: string;
12
- next: (newData: object) => void;
13
16
  animate?: boolean;
14
- }) {
17
+ allowedKeys?: string[] | boolean;
18
+ } & BaseComponentProps) {
19
+ const startTimeRef = useRef<number>(0);
20
+
21
+ useEffect(() => {
22
+ startTimeRef.current = now();
23
+
24
+ const handleKeyPress = (event: KeyboardEvent) => {
25
+ const keypressTime = now();
26
+
27
+ const isKeyAllowed =
28
+ allowedKeys === true || (Array.isArray(allowedKeys) && allowedKeys.includes(event.key));
29
+
30
+ if (isKeyAllowed) {
31
+ const reactionTime = keypressTime - startTimeRef.current;
32
+
33
+ next({
34
+ key: event.key,
35
+ time: keypressTime,
36
+ reactionTime: reactionTime,
37
+ });
38
+ }
39
+ };
40
+
41
+ if (allowedKeys) {
42
+ window.addEventListener('keydown', handleKeyPress);
43
+ }
44
+
45
+ return () => {
46
+ if (allowedKeys) {
47
+ window.removeEventListener('keydown', handleKeyPress);
48
+ }
49
+ };
50
+ }, [next, allowedKeys]);
51
+
15
52
  const handleClick = () => {
16
- next({});
53
+ const clickTime = now();
54
+
55
+ const reactionTime = clickTime - startTimeRef.current;
56
+
57
+ next({
58
+ key: 'button',
59
+ time: clickTime,
60
+ reactionTime: reactionTime,
61
+ });
17
62
  };
18
63
 
19
64
  return (
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useMutation } from '@tanstack/react-query';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
  import { post } from '../utils/request';
5
- import { BaseComponentProps, FileUpload, getParam, getPlatform, Platform, Store, TrialData } from '../utils/common';
5
+ import {
6
+ BaseComponentProps,
7
+ FileUpload,
8
+ getParam,
9
+ getPlatform,
10
+ Platform,
11
+ registerComponentParams,
12
+ Store,
13
+ TrialData,
14
+ } from '../utils/common';
6
15
  import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
7
16
 
8
17
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
@@ -17,6 +26,93 @@ interface UploadResponse {
17
26
  message?: string;
18
27
  }
19
28
 
29
+ // TODO: deduplicate values with upload function below
30
+ registerComponentParams('Upload', [
31
+ {
32
+ name: 'upload',
33
+ defaultValue: true,
34
+ type: 'boolean',
35
+ description: 'Upload the data at the end of the experiment?',
36
+ },
37
+ {
38
+ name: 'download',
39
+ defaultValue: false,
40
+ type: 'boolean',
41
+ description: 'Locally download the data at the end of the experiment?',
42
+ },
43
+ ]);
44
+
45
+ type DataObject = {
46
+ [key: string]: string | number | boolean | null | undefined;
47
+ };
48
+
49
+ function escapeCsvValue(value: any): string {
50
+ if (value === null || value === undefined) {
51
+ return '';
52
+ }
53
+
54
+ const stringValue = String(value);
55
+
56
+ if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
57
+ const escapedValue = stringValue.replace(/"/g, '""');
58
+ return `"${escapedValue}"`;
59
+ }
60
+
61
+ return stringValue;
62
+ }
63
+
64
+ function convertArrayOfObjectsToCSV(data: DataObject[]): string {
65
+ if (!data || data.length === 0) {
66
+ return '';
67
+ }
68
+
69
+ const headerSet = new Set<string>();
70
+ data.forEach((obj) => {
71
+ Object.keys(obj).forEach((key) => {
72
+ headerSet.add(key);
73
+ });
74
+ });
75
+ const headers = Array.from(headerSet);
76
+
77
+ const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
78
+
79
+ const dataRows = data.map((obj) => {
80
+ return headers
81
+ .map((header) => {
82
+ const value = obj[header];
83
+ return escapeCsvValue(value);
84
+ })
85
+ .join(',');
86
+ });
87
+
88
+ return [headerRow, ...dataRows].join('\n');
89
+ }
90
+
91
+ function combineTrialsToCsv(
92
+ data: any[],
93
+ filename: string,
94
+ names: string[],
95
+ fun?: (obj: any) => any,
96
+ ) {
97
+ const processedData = names
98
+ .flatMap((name) => {
99
+ const responseData = data.find((d) => d.name === name)?.responseData;
100
+ if (Array.isArray(responseData)) {
101
+ return responseData.map((i) => ({
102
+ block: name,
103
+ ...i,
104
+ }));
105
+ }
106
+ return [];
107
+ })
108
+ .map((x) => (fun ? fun(x) : x));
109
+
110
+ return {
111
+ filename,
112
+ encoding: 'utf8' as const,
113
+ content: convertArrayOfObjectsToCSV(processedData),
114
+ };
115
+ }
20
116
 
21
117
  interface FileBackend {
22
118
  directoryExists(path: string): Promise<boolean>;
@@ -26,7 +122,7 @@ interface FileBackend {
26
122
 
27
123
  const createElectronFileBackend = (): FileBackend => {
28
124
  const electronAPI = (window as any).electronAPI;
29
-
125
+
30
126
  return {
31
127
  directoryExists: async (path: string): Promise<boolean> => {
32
128
  const result = await electronAPI.directoryExists(path);
@@ -37,91 +133,86 @@ const createElectronFileBackend = (): FileBackend => {
37
133
  },
38
134
  saveFile: async (filename: string, content: string, directory: string): Promise<void> => {
39
135
  await electronAPI.saveFile(filename, content, directory);
40
- }
136
+ },
41
137
  };
42
138
  };
43
139
 
44
-
45
140
  const createCapacitorFileBackend = (parentFolder?: string): FileBackend => {
46
-
47
141
  const getPath = (path: string): string => {
48
142
  if (!parentFolder) {
49
143
  return path;
50
144
  }
51
145
  return path ? `${parentFolder}/${path}` : parentFolder;
52
146
  };
53
-
147
+
54
148
  return {
55
149
  directoryExists: async (path: string): Promise<boolean> => {
56
150
  try {
57
-
58
151
  if (parentFolder) {
59
152
  try {
60
153
  await Filesystem.readdir({
61
154
  path: parentFolder,
62
- directory: Directory.Documents
155
+ directory: Directory.Documents,
63
156
  });
64
157
  } catch {
65
158
  // Parent folder doesn't exist, so subpath doesn't exist either
66
159
  return false;
67
160
  }
68
161
  }
69
-
162
+
70
163
  const fullPath = getPath(path);
71
164
  const result = await Filesystem.readdir({
72
165
  path: fullPath,
73
- directory: Directory.Documents
166
+ directory: Directory.Documents,
74
167
  });
75
- return result.files.length >= 0;
168
+ return result.files.length >= 0;
76
169
  } catch {
77
170
  return false;
78
171
  }
79
172
  },
80
-
173
+
81
174
  createDirectory: async (path: string): Promise<void> => {
82
175
  try {
83
-
84
176
  if (parentFolder) {
85
177
  try {
86
178
  await Filesystem.mkdir({
87
179
  path: parentFolder,
88
180
  directory: Directory.Documents,
89
- recursive: false
181
+ recursive: false,
90
182
  });
91
183
  } catch (e) {
92
184
  // Parent directory might already exist, that's fine
93
185
  }
94
186
  }
95
-
187
+
96
188
  const fullPath = getPath(path);
97
189
  await Filesystem.mkdir({
98
190
  path: fullPath,
99
191
  directory: Directory.Documents,
100
- recursive: true
192
+ recursive: true,
101
193
  });
102
194
  } catch (e) {
103
195
  console.error('Error creating directory:', e);
104
196
  throw e;
105
197
  }
106
198
  },
107
-
199
+
108
200
  saveFile: async (filename: string, content: string, directory: string): Promise<void> => {
109
-
110
201
  const fullDirectory = getPath(directory);
111
-
202
+
112
203
  await Filesystem.writeFile({
113
204
  path: `${fullDirectory}/${filename}`,
114
205
  data: content,
115
206
  directory: Directory.Documents,
116
207
  encoding: 'utf8' as Encoding,
117
208
  });
118
- }
209
+ },
119
210
  };
120
211
  };
121
212
 
122
- const getFileBackend = (parentDir?: string): { backend: FileBackend | null, type: Platform } => {
213
+ const getFileBackend = (parentDir?: string): { backend: FileBackend | null; type: Platform } => {
123
214
  const platform = getPlatform();
124
-
215
+
125
216
  switch (platform) {
126
217
  case 'desktop':
127
218
  return { backend: createElectronFileBackend(), type: platform };
@@ -135,35 +226,45 @@ const getFileBackend = (parentDir?: string): { backend: FileBackend | null, type
135
226
  // Function to generate a unique directory name
136
227
  const getUniqueDirectoryName = async (
137
228
  backend: FileBackend,
138
- baseSessionId: string
229
+ baseSessionId: string,
139
230
  ): Promise<string> => {
140
231
  let uniqueSessionID = baseSessionId;
141
-
232
+
142
233
  if (await backend.directoryExists(uniqueSessionID)) {
143
234
  let counter = 1;
144
235
  uniqueSessionID = `${baseSessionId}_${counter}`;
145
-
236
+
146
237
  while (await backend.directoryExists(uniqueSessionID)) {
147
238
  counter++;
148
239
  uniqueSessionID = `${baseSessionId}_${counter}`;
149
240
  }
150
241
  }
151
-
242
+
152
243
  return uniqueSessionID;
153
244
  };
154
245
 
246
+ type CSVBuilder = {
247
+ filename?: string;
248
+ trials?: string[];
249
+ fun?: (row: Record<string, any>) => Record<string, any>;
250
+ };
251
+
155
252
  export default function Upload({
156
253
  data,
157
254
  next,
158
255
  store,
159
256
  sessionID,
160
257
  generateFiles,
258
+ sessionCSVBuilder,
259
+ trialCSVBuilders,
161
260
  uploadRaw = true,
162
261
  autoUpload = false,
163
262
  androidFolderName,
164
263
  }: BaseComponentProps & {
165
264
  sessionID?: string | null;
166
265
  generateFiles: (sessionID: string, data: TrialData[], store?: Store) => FileUpload[];
266
+ sessionCSVBuilder: CSVBuilder;
267
+ trialCSVBuilders: CSVBuilder[];
167
268
  uploadRaw: boolean;
168
269
  autoUpload: boolean;
169
270
  androidFolderName?: string;
@@ -212,19 +313,18 @@ export default function Upload({
212
313
  document.body.removeChild(a);
213
314
  URL.revokeObjectURL(url);
214
315
  }, []);
215
-
216
-
316
+
217
317
  const handleUpload = useCallback(async () => {
218
318
  setUploadState('uploading');
219
-
319
+
220
320
  if (uploadInitiatedRef.current) {
221
321
  return;
222
322
  }
223
-
323
+
224
324
  uploadInitiatedRef.current = true;
225
-
325
+
226
326
  const sessionIDUpload = sessionID ?? uuidv4();
227
-
327
+
228
328
  const files: FileUpload[] = generateFiles ? generateFiles(sessionIDUpload, data, store) : [];
229
329
  if (uploadRaw) {
230
330
  files.push({
@@ -233,41 +333,112 @@ export default function Upload({
233
333
  encoding: 'utf8',
234
334
  });
235
335
  }
236
-
336
+
337
+ if (sessionCSVBuilder) {
338
+ type ParamDetails = {
339
+ value?: any;
340
+ defaultValue: any;
341
+ };
342
+ let paramsDict: Record<string, any> = {};
343
+ const paramsSource: Record<string, ParamDetails | any> | undefined =
344
+ data?.[0]?.responseData?.params;
345
+ if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
346
+ paramsDict = Object.entries(paramsSource).reduce(
347
+ (
348
+ accumulator: Record<string, any>,
349
+ [paramName, paramDetails]: [string, ParamDetails | any],
350
+ ) => {
351
+ if (
352
+ paramDetails &&
353
+ typeof paramDetails === 'object' &&
354
+ 'defaultValue' in paramDetails
355
+ ) {
356
+ const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
357
+ accumulator[paramName] = chosenValue;
358
+ }
359
+ return accumulator;
360
+ },
361
+ {} as Record<string, any>,
362
+ );
363
+ }
364
+
365
+ let content = {
366
+ sessionID: sessionIDUpload,
367
+ userAgent: data[0].responseData.userAgent,
368
+ ...paramsDict,
369
+ };
370
+
371
+ if (
372
+ sessionCSVBuilder.trials &&
373
+ Array.isArray(sessionCSVBuilder.trials) &&
374
+ sessionCSVBuilder.trials.length > 0
375
+ ) {
376
+ for (const trialName of sessionCSVBuilder.trials) {
377
+ const matchingDataElement = data.find((element) => element.name === trialName);
378
+
379
+ if (matchingDataElement?.responseData) {
380
+ if (
381
+ typeof matchingDataElement.responseData === 'object' &&
382
+ matchingDataElement.responseData !== null
383
+ ) {
384
+ content = { ...content, ...matchingDataElement.responseData };
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ files.push({
391
+ content: convertArrayOfObjectsToCSV([
392
+ sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
393
+ ]),
394
+ filename: `${sessionIDUpload}${sessionCSVBuilder.filename}.csv`,
395
+ encoding: 'utf8' as const,
396
+ });
397
+ }
398
+
399
+ if (trialCSVBuilders) {
400
+ for (const builder of trialCSVBuilders) {
401
+ files.push(
402
+ combineTrialsToCsv(
403
+ data,
404
+ `${sessionIDUpload}${builder.filename}.csv`,
405
+ builder.trials ?? [],
406
+ builder.fun,
407
+ ),
408
+ );
409
+ }
410
+ }
411
+
237
412
  try {
238
413
  const payload: UploadPayload = {
239
414
  sessionId: sessionIDUpload,
240
- files,
415
+ files: files.map((file) => ({ ...file, encoding: file.encoding ?? 'utf8' })),
241
416
  };
242
-
417
+
243
418
  if (shouldDownload) {
244
419
  await downloadFiles(files);
245
420
  }
246
-
421
+
247
422
  if (!shouldUpload) {
248
423
  next({});
249
424
  return;
250
425
  }
251
-
426
+
252
427
  // Get the current platform and appropriate file backend
253
428
  const { backend, type } = getFileBackend(androidFolderName);
254
-
429
+
255
430
  if (type === 'web') {
256
- // Web API case
257
431
  uploadData.mutate(payload);
258
432
  } else if (backend) {
259
433
  try {
260
- // Get a unique directory name
261
434
  const uniqueSessionID = await getUniqueDirectoryName(backend, sessionIDUpload);
262
-
263
- // Create the directory
435
+
264
436
  await backend.createDirectory(uniqueSessionID);
265
-
266
- // Save all files
437
+
267
438
  for (const file of files) {
268
439
  await backend.saveFile(file.filename, file.content, uniqueSessionID);
269
440
  }
270
-
441
+
271
442
  setUploadState('success');
272
443
  next({});
273
444
  } catch (error) {
@@ -313,7 +484,7 @@ export default function Upload({
313
484
  </p>
314
485
  <button
315
486
  onClick={handleUpload}
316
- className='mt-8 bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
487
+ className='mt-8 cursor-pointer bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
317
488
  >
318
489
  Submit Data
319
490
  </button>
@@ -335,7 +506,7 @@ export default function Upload({
335
506
  </div>
336
507
  <button
337
508
  onClick={handleUpload}
338
- className='px-4 py-2 bg-blue-500 text-white rounded-sm hover:bg-blue-600 transition-colors'
509
+ className='px-4 py-2 cursor-pointer bg-blue-500 text-white rounded-sm hover:bg-blue-600 transition-colors'
339
510
  >
340
511
  Try Again
341
512
  </button>
package/src/mod.tsx CHANGED
@@ -1,16 +1,8 @@
1
1
  import './index.css';
2
- import Text from './components/text';
3
- import PlainInput from './components/plaininput';
4
- import ProlificEnding from './components/prolificending';
5
- import MicCheck from './components/microphonecheck';
6
- import Quest from './components/quest';
7
- import Upload from './components/upload';
8
- import EnterFullscreen from './components/enterfullscreen';
9
- import ExitFullscreen from './components/exitfullscreen';
10
- import ExperimentProvider from './components/experimentprovider';
11
- import Experiment from './components/experiment';
12
2
  import { BaseComponentProps, ExperimentConfig } from './utils/common';
13
3
 
14
- export { Text, ProlificEnding, MicCheck, Quest, Upload, EnterFullscreen, ExitFullscreen, Experiment, ExperimentProvider, PlainInput};
15
4
  export type { BaseComponentProps, ExperimentConfig };
5
+
6
+ export * from './utils/array';
16
7
  export * from './utils/common';
8
+ export * from './components';
@@ -0,0 +1,6 @@
1
+ declare global {
2
+ interface Array<T> {
3
+ sample(n?: number): Array<T>;
4
+ shuffle(): Array<T>;
5
+ }
6
+ }
@@ -0,0 +1,79 @@
1
+ declare global {
2
+ interface Array<T> {
3
+ /**
4
+ * Returns random elements from the array
5
+ * @param n Number of random elements to return (defaults to 1)
6
+ * @returns Array of randomly selected elements
7
+ */
8
+ sample(n?: number): Array<T>;
9
+
10
+ /**
11
+ * Shuffles array elements using Fisher-Yates algorithm
12
+ * @returns A new shuffled array
13
+ */
14
+ shuffle(): Array<T>;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Returns random elements from an array
20
+ * @param array The source array
21
+ * @param n Number of random elements to return (defaults to 1)
22
+ * @returns Array of randomly selected elements
23
+ */
24
+ export function sample<T>(array: T[], n: number = 1): T[] {
25
+ const result: T[] = [];
26
+
27
+ if (array.length === 0) {
28
+ return result;
29
+ }
30
+
31
+ for (let i = 0; i < n; i++) {
32
+ const randomIndex = Math.floor(Math.random() * array.length);
33
+ result.push(array[randomIndex]);
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * Shuffles array elements using Fisher-Yates algorithm
41
+ * @param array The source array
42
+ * @returns A new shuffled array
43
+ */
44
+ export function shuffle<T>(array: T[]): T[] {
45
+ const result = [...array];
46
+ for (let i = result.length - 1; i >= 0; i--) {
47
+ const j = Math.floor(Math.random() * (i + 1));
48
+ [result[i], result[j]] = [result[j], result[i]];
49
+ }
50
+ return result;
51
+ }
52
+
53
+ /**
54
+ * Registers array methods on the Array prototype.
55
+ * Call this function once to make array methods available globally.
56
+ *
57
+ * Example:
58
+ * ```
59
+ * import { registerArrayExtensions } from '@adriansteffan/reactive/array';
60
+ * registerArrayExtensions();
61
+ *
62
+ * // Now you can use the methods
63
+ * const myArray = [1, 2, 3, 4, 5];
64
+ * myArray.shuffle();
65
+ * ```
66
+ */
67
+ export function registerArrayExtensions(): void {
68
+ if (typeof Array.prototype.sample !== 'function') {
69
+ Array.prototype.sample = function <T>(this: T[], n?: number): T[] {
70
+ return sample(this, n);
71
+ };
72
+ }
73
+
74
+ if (typeof Array.prototype.shuffle !== 'function') {
75
+ Array.prototype.shuffle = function <T>(this: T[]): T[] {
76
+ return shuffle(this);
77
+ };
78
+ }
79
+ }