easyproctor 0.0.9 → 0.0.13
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/README.md +32 -39
- package/dtos/StartProctoringResponse.d.ts +9 -0
- package/esm/index.js +290 -13
- package/index.d.ts +15 -4
- package/index.js +301 -22
- package/modules/database.d.ts +6 -0
- package/modules/http.d.ts +6 -0
- package/modules/recorder.d.ts +1 -0
- package/modules/startCameraCapture.d.ts +1 -1
- package/modules/startScreenCapture.d.ts +1 -1
- package/modules/upload.d.ts +6 -0
- package/package.json +14 -7
- package/unpkg/easyproctor.min.js +22 -1
- package/test.d.ts +0 -1
package/README.md
CHANGED
|
@@ -21,14 +21,22 @@ Em um bundler
|
|
|
21
21
|
```javascript
|
|
22
22
|
import { useProctoring } from "easyproctor";
|
|
23
23
|
|
|
24
|
-
const { start, finish
|
|
24
|
+
const { start, finish } = useProctoring({
|
|
25
|
+
examId: "00001",
|
|
26
|
+
clientId: "000001",
|
|
27
|
+
token: "..."
|
|
28
|
+
});
|
|
25
29
|
```
|
|
26
30
|
|
|
27
31
|
Via CDN: A função "useProctoring" é injetada para ser utilizada globalmente
|
|
28
32
|
```html
|
|
29
33
|
<script src="https://cdn.jsdelivr.net/npm/easyproctor/unpkg/easyproctor.min.js"></script>
|
|
30
34
|
<script>
|
|
31
|
-
const { start, finish
|
|
35
|
+
const { start, finish } = useProctoring({
|
|
36
|
+
examId: "00001",
|
|
37
|
+
clientId: "000001",
|
|
38
|
+
token: "..."
|
|
39
|
+
});
|
|
32
40
|
</script>
|
|
33
41
|
```
|
|
34
42
|
|
|
@@ -43,45 +51,39 @@ Via CDN: A função "useProctoring" é injetada para ser utilizada globalmente
|
|
|
43
51
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
44
52
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
45
53
|
<title>Document</title>
|
|
46
|
-
|
|
47
|
-
<script src="/dist/unpkg/easyproctor.min.js"></script>
|
|
54
|
+
<script src="dist/unpkg/easyproctor.min.js"></script>
|
|
48
55
|
<script>
|
|
49
|
-
|
|
56
|
+
|
|
57
|
+
const { start, finish } = useProctoring({
|
|
58
|
+
examId: "00001",
|
|
59
|
+
clientId: "000001",
|
|
60
|
+
token: "..."
|
|
61
|
+
});
|
|
50
62
|
|
|
51
63
|
async function startExam() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
try {
|
|
65
|
+
const resp = await start();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
alert(error);
|
|
68
|
+
}
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
async function pauseExam() {
|
|
59
|
-
await pause();
|
|
60
|
-
// or pause().then(() => {})
|
|
61
|
-
// My personal logic
|
|
62
|
-
console.log("Exam paused")
|
|
63
|
-
}
|
|
64
|
-
async function resumeExam() {
|
|
65
|
-
await resume();
|
|
66
|
-
// or resume().then(() => {})
|
|
67
|
-
// My personal logic
|
|
68
|
-
console.log("Exam resumed")
|
|
69
|
-
}
|
|
70
71
|
async function finishExam() {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
try {
|
|
73
|
+
await finish({ onProgress: (percentage) => console.log(percentage) });
|
|
74
|
+
console.log("EXAME FINALIZADO");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
alert(error);
|
|
77
|
+
}
|
|
75
78
|
}
|
|
76
79
|
</script>
|
|
77
|
-
|
|
78
80
|
</head>
|
|
79
81
|
|
|
80
82
|
<body>
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
<div style="height: 100vh; display: flex; align-items: center; justify-content: center;">
|
|
84
|
+
<button onclick="startExam()">Iniciar</button>
|
|
85
|
+
<button onclick="finishExam()">Finalizar</button>
|
|
86
|
+
</div>
|
|
85
87
|
</body>
|
|
86
88
|
|
|
87
89
|
</html>
|
|
@@ -96,15 +98,6 @@ const {
|
|
|
96
98
|
// Finaliza a gravação da prova retornando os arquivos gerados
|
|
97
99
|
finish,
|
|
98
100
|
|
|
99
|
-
// Pausa a gravação, os dados são persistidos para a gravação poder ser continuada em outra tela
|
|
100
|
-
pause,
|
|
101
|
-
|
|
102
|
-
// Reinicia a gravação
|
|
103
|
-
resume,
|
|
104
|
-
|
|
105
|
-
// Registra uma função callback para execução de alguma lógica caso o usuária perca o foco com oa tela
|
|
106
|
-
onLostFocus
|
|
107
|
-
|
|
108
101
|
} = useProctoring("cac34c6d-63ec-4f86-b5b0-bdeb52e8c146");
|
|
109
102
|
```
|
|
110
103
|
## License
|
package/esm/index.js
CHANGED
|
@@ -1,24 +1,301 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// src/modules/database.ts
|
|
2
|
+
var databaseName = "EasyProctorPlugin";
|
|
3
|
+
var databaseVersion = 1;
|
|
4
|
+
function initializeDatabase(table) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const request = indexedDB.open(databaseName, databaseVersion);
|
|
7
|
+
request.onupgradeneeded = () => {
|
|
8
|
+
request.result.createObjectStore("cameraBuffers", { keyPath: "id", autoIncrement: true });
|
|
9
|
+
request.result.createObjectStore("screenBuffers", { keyPath: "id", autoIncrement: true });
|
|
10
|
+
};
|
|
11
|
+
request.onerror = (e) => {
|
|
12
|
+
console.log(e);
|
|
13
|
+
reject("N\xE3o foi poss\xEDvel inicializar a biblioteca, por favor, entre em contato com o suporte e informe o erro acima");
|
|
14
|
+
};
|
|
15
|
+
request.onsuccess = () => {
|
|
16
|
+
const db = request.result;
|
|
17
|
+
const tableRef = db.transaction(table, "readwrite");
|
|
18
|
+
const store = tableRef.objectStore(table);
|
|
19
|
+
resolve(store);
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function getBuffers(table) {
|
|
24
|
+
const store = await initializeDatabase(table);
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const request = store.getAll();
|
|
27
|
+
request.onsuccess = () => {
|
|
28
|
+
const data = request.result;
|
|
29
|
+
const blobs = data.reduce((acc, el) => [...acc, ...el.data], []);
|
|
30
|
+
resolve(blobs);
|
|
31
|
+
};
|
|
32
|
+
request.onerror = (e) => reject(e);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function clearBuffers(table) {
|
|
36
|
+
const store = await initializeDatabase(table);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const request = store.clear();
|
|
39
|
+
request.onsuccess = () => resolve();
|
|
40
|
+
request.onerror = (e) => reject(e);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/modules/recorder.ts
|
|
45
|
+
function recorder(stream, buffer) {
|
|
46
|
+
let resolvePromise;
|
|
47
|
+
const options = {
|
|
48
|
+
mimeType: "video/webm; codecs=vp9",
|
|
49
|
+
videoBitsPerSecond: 128e3,
|
|
50
|
+
audioBitsPerSecond: 64 * 1e3
|
|
5
51
|
};
|
|
6
|
-
const
|
|
52
|
+
const mediaRecorder = new MediaRecorder(stream, options);
|
|
53
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
54
|
+
if (e.data.size > 0) {
|
|
55
|
+
buffer.push(e.data);
|
|
56
|
+
}
|
|
57
|
+
resolvePromise && resolvePromise();
|
|
58
|
+
};
|
|
59
|
+
mediaRecorder.start();
|
|
60
|
+
function stopRecording() {
|
|
7
61
|
return new Promise((resolve) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
62
|
+
resolvePromise = resolve;
|
|
63
|
+
mediaRecorder.stop();
|
|
64
|
+
stream.getTracks().forEach((el) => {
|
|
65
|
+
el.stop();
|
|
66
|
+
});
|
|
11
67
|
});
|
|
68
|
+
}
|
|
69
|
+
return stopRecording;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/modules/startCameraCapture.ts
|
|
73
|
+
async function startCameraCapture(buffer) {
|
|
74
|
+
const constraints = {
|
|
75
|
+
audio: true,
|
|
76
|
+
video: {
|
|
77
|
+
width: { max: 1280, ideal: 640 },
|
|
78
|
+
height: { max: 720, ideal: 480 },
|
|
79
|
+
frameRate: 15
|
|
80
|
+
}
|
|
12
81
|
};
|
|
13
|
-
const
|
|
14
|
-
|
|
82
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
83
|
+
const stopRecording = recorder(stream, buffer);
|
|
84
|
+
return stopRecording;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/modules/startScreenCapture.ts
|
|
88
|
+
async function startScreenCapture(buffer) {
|
|
89
|
+
const displayMediaStreamConstraints = {
|
|
90
|
+
video: {
|
|
91
|
+
cursor: "always"
|
|
92
|
+
},
|
|
93
|
+
audio: false
|
|
15
94
|
};
|
|
16
|
-
const
|
|
17
|
-
|
|
95
|
+
const stream = await navigator.mediaDevices.getDisplayMedia(displayMediaStreamConstraints);
|
|
96
|
+
const stopRecording = recorder(stream, buffer);
|
|
97
|
+
return stopRecording;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/modules/upload.ts
|
|
101
|
+
import { BlobServiceClient } from "@azure/storage-blob";
|
|
102
|
+
var account = "iarisprod";
|
|
103
|
+
var containerName = "iaris";
|
|
104
|
+
var sas = "?sv=2020-08-04&ss=bfqt&srt=sco&sp=rwdlacupitfx&se=2025-12-28T06:34:02Z&st=2021-12-27T22:34:02Z&spr=https&sig=1rsgx389pHZCnJYd44peuWSfeCUdN8bQ9EfcLoMOdDc%3D";
|
|
105
|
+
var blobServiceClient = new BlobServiceClient(`https://${account}.blob.core.windows.net${sas}`);
|
|
106
|
+
async function upload(data) {
|
|
107
|
+
const { file, onProgress } = data;
|
|
108
|
+
const containerClient = blobServiceClient.getContainerClient(containerName);
|
|
109
|
+
const blockBlobClient = containerClient.getBlockBlobClient(file.name);
|
|
110
|
+
const progressCallback = (e) => {
|
|
111
|
+
const progress = e.loadedBytes / file.size * 100;
|
|
112
|
+
onProgress && onProgress(Math.round(progress));
|
|
18
113
|
};
|
|
114
|
+
await blockBlobClient.upload(file, file.size, { onProgress: progressCallback });
|
|
115
|
+
}
|
|
116
|
+
var upload_default = upload;
|
|
117
|
+
|
|
118
|
+
// src/modules/http.ts
|
|
119
|
+
var baseUrl = "https://iaris.easyproctor.tech/api";
|
|
120
|
+
async function makeRequest(data) {
|
|
121
|
+
const { url, method, body, jwt } = data;
|
|
122
|
+
const resp = await fetch(baseUrl + url, {
|
|
123
|
+
method,
|
|
124
|
+
body: body != null ? JSON.stringify(body) : void 0,
|
|
125
|
+
headers: {
|
|
126
|
+
"Authorization": `Bearer ${jwt}`,
|
|
127
|
+
"Content-Type": "application/json"
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if (resp.status >= 400) {
|
|
131
|
+
throw "N\xE3o foi poss\xEDvel realizar a requisi\xE7\xE3o, tente novamente mais tarde";
|
|
132
|
+
}
|
|
133
|
+
const isJson = resp.headers.get("content-type")?.includes("application/json");
|
|
134
|
+
const responseData = isJson ? await resp.json() : null;
|
|
135
|
+
return responseData;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/index.ts
|
|
139
|
+
var azureBlobUrl = "https://iarisprod.azureedge.net/iaris";
|
|
140
|
+
function useProctoring(proctoringOptions) {
|
|
141
|
+
["examId", "clientId", "token"].forEach((el) => {
|
|
142
|
+
const key = el;
|
|
143
|
+
if (!proctoringOptions[key]) {
|
|
144
|
+
throw `O campo ${key} \xE9 obrigat\xF3rio`;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
let focusCallback;
|
|
148
|
+
let lostFocusCallback;
|
|
149
|
+
if (!navigator.mediaDevices.getDisplayMedia) {
|
|
150
|
+
throw "Voc\xEA est\xE1 utilizando uma vers\xE3o muito antiga do navegador, por favor, atualize a vers\xE3o";
|
|
151
|
+
}
|
|
152
|
+
if (!window.indexedDB) {
|
|
153
|
+
throw "Voc\xEA est\xE1 usando uma vers\xE3o muito antiga do navegador, n\xE3o \xE9 poss\xEDvel relizar a requisi\xE7\xE3o";
|
|
154
|
+
}
|
|
155
|
+
function download(file) {
|
|
156
|
+
const url = URL.createObjectURL(file);
|
|
157
|
+
const a = document.createElement("a");
|
|
158
|
+
document.body.appendChild(a);
|
|
159
|
+
a.style.display = "none";
|
|
160
|
+
a.href = url;
|
|
161
|
+
a.download = file.name;
|
|
162
|
+
a.click();
|
|
163
|
+
window.URL.revokeObjectURL(url);
|
|
164
|
+
}
|
|
165
|
+
let cameraBuffer = [];
|
|
166
|
+
let screenBuffer = [];
|
|
167
|
+
let proctoringId = "";
|
|
168
|
+
let cancelCallback = null;
|
|
169
|
+
async function _startCapture() {
|
|
170
|
+
if (!document.body) {
|
|
171
|
+
throw "A execu\xE7\xE3o do script deve ser feita por algum elemento dentro do <body> da p\xE1gina html";
|
|
172
|
+
}
|
|
173
|
+
if (cancelCallback != null) {
|
|
174
|
+
throw "Uma grava\xE7\xE3o ja est\xE1 em andamento";
|
|
175
|
+
}
|
|
176
|
+
let cancelCameraCapture = null;
|
|
177
|
+
let cancelScreenCapture = null;
|
|
178
|
+
try {
|
|
179
|
+
if (focusCallback)
|
|
180
|
+
window.removeEventListener("focus", focusCallback);
|
|
181
|
+
if (lostFocusCallback)
|
|
182
|
+
window.removeEventListener("blur", lostFocusCallback);
|
|
183
|
+
cancelScreenCapture = await startScreenCapture(screenBuffer);
|
|
184
|
+
if (focusCallback)
|
|
185
|
+
window.addEventListener("focus", () => window.addEventListener("focus", focusCallback), { once: true });
|
|
186
|
+
if (lostFocusCallback)
|
|
187
|
+
window.addEventListener("blur", lostFocusCallback);
|
|
188
|
+
cancelCameraCapture = await startCameraCapture(cameraBuffer);
|
|
189
|
+
cancelCallback = async function() {
|
|
190
|
+
await Promise.all([cancelCameraCapture(), cancelScreenCapture()]);
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
cancelCallback = null;
|
|
194
|
+
cancelScreenCapture && await cancelScreenCapture();
|
|
195
|
+
cancelCameraCapture && await cancelCameraCapture();
|
|
196
|
+
cameraBuffer = [];
|
|
197
|
+
screenBuffer = [];
|
|
198
|
+
throw "N\xE3o foi poss\xEDvel iniciar a captura, por favor, verifique as permiss\xF5es de camera e microfone";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function start(options = { override: false }) {
|
|
202
|
+
const { override } = options;
|
|
203
|
+
if (override) {
|
|
204
|
+
await Promise.all([clearBuffers("cameraBuffers"), clearBuffers("screenBuffers")]);
|
|
205
|
+
} else {
|
|
206
|
+
const [storedCameraBuffers, storedScreenBuffers] = await Promise.all([getBuffers("cameraBuffers"), getBuffers("screenBuffers")]);
|
|
207
|
+
if (storedCameraBuffers.length > 0 || storedScreenBuffers.length > 0) {
|
|
208
|
+
throw "Existe uma grava\xE7\xE3o iniciada, por favor, execute o m\xE9todo resume() para retomar, ou utilize o parametro start({ override: true }) para limpar os dados";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await _startCapture();
|
|
212
|
+
try {
|
|
213
|
+
const resp = await makeRequest({
|
|
214
|
+
url: `/proctoring/start/${proctoringOptions.examId}`,
|
|
215
|
+
method: "POST",
|
|
216
|
+
body: { clientId: proctoringOptions.clientId },
|
|
217
|
+
jwt: proctoringOptions.token
|
|
218
|
+
});
|
|
219
|
+
proctoringId = resp.id;
|
|
220
|
+
return resp;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
cancelCallback && cancelCallback();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function pause() {
|
|
227
|
+
throw "Ainda n\xE3o implementado";
|
|
228
|
+
}
|
|
229
|
+
async function resume() {
|
|
230
|
+
throw "Ainda n\xE3o implementado";
|
|
231
|
+
}
|
|
232
|
+
async function finish(options = {}) {
|
|
233
|
+
const { onProgress } = options;
|
|
234
|
+
if (cancelCallback) {
|
|
235
|
+
await cancelCallback();
|
|
236
|
+
}
|
|
237
|
+
const time = new Date().toISOString();
|
|
238
|
+
const finalCameraBuffer = cameraBuffer;
|
|
239
|
+
const finalScreenBuffer = screenBuffer;
|
|
240
|
+
if (finalCameraBuffer.length == 0 || finalScreenBuffer.length == 0) {
|
|
241
|
+
throw "N\xE3o existe nenhuma grava\xE7\xE3o iniciada";
|
|
242
|
+
}
|
|
243
|
+
const cameraFileName = `EP_${proctoringOptions.examId}_${time}_camera_0.webm`;
|
|
244
|
+
const screenFIleName = `EP_${proctoringOptions.examId}_${time}_screen_0.webm`;
|
|
245
|
+
const cameraFile = new File(finalCameraBuffer, cameraFileName, { type: "video/webm" });
|
|
246
|
+
const screenFile = new File(finalScreenBuffer, screenFIleName, { type: "video/webm" });
|
|
247
|
+
let cameraProgress = 0;
|
|
248
|
+
const screenProgress = 0;
|
|
249
|
+
const handleOnProgress = () => {
|
|
250
|
+
onProgress && onProgress((cameraProgress + screenProgress) / 2);
|
|
251
|
+
};
|
|
252
|
+
await Promise.all([
|
|
253
|
+
upload_default({ file: cameraFile, onProgress: (progress) => {
|
|
254
|
+
cameraProgress = progress;
|
|
255
|
+
handleOnProgress();
|
|
256
|
+
} }),
|
|
257
|
+
upload_default({ file: screenFile, onProgress: (progress) => {
|
|
258
|
+
cameraProgress = progress;
|
|
259
|
+
handleOnProgress();
|
|
260
|
+
} })
|
|
261
|
+
]);
|
|
262
|
+
await makeRequest({
|
|
263
|
+
url: "/proctoring/save-screen",
|
|
264
|
+
method: "POST",
|
|
265
|
+
jwt: proctoringOptions.token,
|
|
266
|
+
body: {
|
|
267
|
+
proctoringId,
|
|
268
|
+
alerts: []
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
await makeRequest({
|
|
272
|
+
url: `/proctoring/finish/${proctoringOptions.examId}`,
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: {
|
|
275
|
+
endDate: time,
|
|
276
|
+
videoCameraUrl: `${azureBlobUrl}/${cameraFileName}`,
|
|
277
|
+
videoScreenUrl: `${azureBlobUrl}/${screenFIleName}`
|
|
278
|
+
},
|
|
279
|
+
jwt: proctoringOptions.token
|
|
280
|
+
});
|
|
281
|
+
await Promise.all([clearBuffers("cameraBuffers"), clearBuffers("screenBuffers")]);
|
|
282
|
+
cameraBuffer = [];
|
|
283
|
+
screenBuffer = [];
|
|
284
|
+
cancelCallback = null;
|
|
285
|
+
}
|
|
19
286
|
const onLostFocus = (cb) => {
|
|
287
|
+
lostFocusCallback = cb;
|
|
288
|
+
window.addEventListener("blur", lostFocusCallback);
|
|
289
|
+
const dispose = () => window.removeEventListener("blur", lostFocusCallback);
|
|
290
|
+
return dispose;
|
|
291
|
+
};
|
|
292
|
+
const onReturnFocus = (cb) => {
|
|
293
|
+
focusCallback = cb;
|
|
294
|
+
window.addEventListener("focus", focusCallback);
|
|
295
|
+
const dispose = () => window.removeEventListener("focus", focusCallback);
|
|
296
|
+
return dispose;
|
|
20
297
|
};
|
|
21
|
-
return { start, finish, pause, resume, onLostFocus };
|
|
298
|
+
return { start, finish, pause, resume, onLostFocus, download, onReturnFocus };
|
|
22
299
|
}
|
|
23
300
|
if (typeof window !== "undefined") {
|
|
24
301
|
window.useProctoring = useProctoring;
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import StartProctoringResponse from "./dtos/StartProctoringResponse";
|
|
2
|
+
export declare function useProctoring(proctoringOptions: {
|
|
3
|
+
examId: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
token: string;
|
|
6
|
+
}): {
|
|
7
|
+
start: (options?: {
|
|
8
|
+
override: boolean;
|
|
9
|
+
}) => Promise<StartProctoringResponse>;
|
|
10
|
+
finish: (options?: {
|
|
11
|
+
onProgress?: ((percentage: number) => void) | undefined;
|
|
12
|
+
}) => Promise<void>;
|
|
4
13
|
pause: () => Promise<void>;
|
|
5
14
|
resume: () => Promise<void>;
|
|
6
|
-
onLostFocus: (cb: () => void) => void;
|
|
15
|
+
onLostFocus: (cb: () => void) => () => void;
|
|
16
|
+
download: (file: File) => void;
|
|
17
|
+
onReturnFocus: (cb: () => void) => () => void;
|
|
7
18
|
};
|