react-hook-toolkit 3.0.2 → 3.0.3

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.
@@ -0,0 +1,462 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
3
+ function getSpeechRecognitionCtor() {
4
+ if (typeof window === 'undefined')
5
+ return null;
6
+ const w = window;
7
+ return (w.SpeechRecognition ||
8
+ w.webkitSpeechRecognition ||
9
+ w.mozSpeechRecognition ||
10
+ w.msSpeechRecognition ||
11
+ null);
12
+ }
13
+ /**
14
+ * Word-boundary match — avoids "home" firing on "homework".
15
+ * Splits both transcript and keyword on whitespace and checks for an
16
+ * exact word (or multi-word phrase) match.
17
+ */
18
+ export function matchVoiceCommand(transcript, commands) {
19
+ const words = transcript.toLowerCase().trim().split(/\s+/);
20
+ if (!words.length)
21
+ return null;
22
+ for (const cmd of commands) {
23
+ for (const kw of cmd.keywords) {
24
+ const kwWords = kw.toLowerCase().trim().split(/\s+/);
25
+ // Sliding-window check for multi-word keywords
26
+ for (let i = 0; i <= words.length - kwWords.length; i++) {
27
+ if (kwWords.every((w, j) => words[i + j] === w))
28
+ return cmd;
29
+ }
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ /**
35
+ * Returns the command whose keywords share the most words with the transcript.
36
+ * Used for "did you mean?" hints on no-match.
37
+ */
38
+ export function closestVoiceCommand(transcript, commands) {
39
+ if (!commands.length)
40
+ return null;
41
+ const words = new Set(transcript.toLowerCase().trim().split(/\s+/));
42
+ let best = null;
43
+ let bestScore = 0;
44
+ for (const cmd of commands) {
45
+ for (const kw of cmd.keywords) {
46
+ const overlap = kw
47
+ .toLowerCase()
48
+ .split(/\s+/)
49
+ .filter((w) => words.has(w)).length;
50
+ if (overlap > bestScore) {
51
+ bestScore = overlap;
52
+ best = cmd;
53
+ }
54
+ }
55
+ }
56
+ return bestScore > 0 ? best : null;
57
+ }
58
+ /** Detects iOS Safari where continuous mode silently breaks. */
59
+ function detectContinuousSupport() {
60
+ if (typeof window === 'undefined')
61
+ return false;
62
+ const ua = navigator.userAgent;
63
+ const isIOS = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
64
+ const isSafari = /^((?!chrome|android).)*safari/i.test(ua);
65
+ return !(isIOS && isSafari);
66
+ }
67
+ // ─── Hook ─────────────────────────────────────────────────────────────────────
68
+ export function useVoice(options = {}) {
69
+ const { commands = [], language = 'en-US', continuous = false, interimResults = true, autoNavigate = true, historySize = 10, minConfidence = 0.4, idleTimeoutMs = 0, wakeWord, shortcut, navigate: navigateProp, onMatch, onNoMatch, onError, onStart, onEnd, onToast, } = options;
70
+ // ── Refs ────────────────────────────────────────────────────────────────────
71
+ const recognitionRef = useRef(null);
72
+ const audioContextRef = useRef(null);
73
+ const analyserRef = useRef(null);
74
+ const streamRef = useRef(null);
75
+ const animFrameRef = useRef(0);
76
+ const idleTimerRef = useRef(null);
77
+ const mountedRef = useRef(true);
78
+ const startingRef = useRef(false); // guard for concurrent start() calls
79
+ // Stable refs for values that must not be in effect deps
80
+ const commandsRef = useRef(commands);
81
+ const navigateRef = useRef(navigateProp);
82
+ const executeRef = useRef(null);
83
+ const callbacksRef = useRef({ onMatch, onNoMatch, onError, onStart, onEnd, onToast });
84
+ // Keep refs in sync every render (no extra re-renders, no stale closures)
85
+ useEffect(() => { commandsRef.current = commands; });
86
+ useEffect(() => { navigateRef.current = navigateProp; });
87
+ useEffect(() => {
88
+ callbacksRef.current = { onMatch, onNoMatch, onError, onStart, onEnd, onToast };
89
+ });
90
+ // ── State ───────────────────────────────────────────────────────────────────
91
+ const [status, setStatus] = useState('idle');
92
+ const [transcript, setTranscript] = useState('');
93
+ const [audioLevel, setAudioLevel] = useState(0);
94
+ const [history, setHistory] = useState([]);
95
+ const [lastMatch, setLastMatch] = useState(null);
96
+ const [pendingConfirm, setPendingConfirm] = useState(null);
97
+ const isSupported = useMemo(() => !!getSpeechRecognitionCtor(), []);
98
+ const isContinuousSupported = useMemo(() => detectContinuousSupport(), []);
99
+ // ── Idle timer ──────────────────────────────────────────────────────────────
100
+ const clearIdleTimer = useCallback(() => {
101
+ if (idleTimerRef.current !== null) {
102
+ clearTimeout(idleTimerRef.current);
103
+ idleTimerRef.current = null;
104
+ }
105
+ }, []);
106
+ const resetIdleTimer = useCallback(() => {
107
+ clearIdleTimer();
108
+ if (idleTimeoutMs > 0) {
109
+ idleTimerRef.current = setTimeout(() => {
110
+ var _a;
111
+ if (!mountedRef.current)
112
+ return;
113
+ try {
114
+ (_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
115
+ }
116
+ catch ( /* ignore */_b) { /* ignore */ }
117
+ }, idleTimeoutMs);
118
+ }
119
+ }, [idleTimeoutMs, clearIdleTimer]);
120
+ // ── Audio analysis ──────────────────────────────────────────────────────────
121
+ const stopAudioAnalysis = useCallback(() => {
122
+ var _a;
123
+ clearIdleTimer();
124
+ cancelAnimationFrame(animFrameRef.current);
125
+ if (mountedRef.current)
126
+ setAudioLevel(0);
127
+ (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach((t) => t.stop());
128
+ streamRef.current = null;
129
+ // Keep a local ref before nulling so the async close doesn't re-read a
130
+ // new context that was created after cleanup.
131
+ const ctx = audioContextRef.current;
132
+ audioContextRef.current = null;
133
+ analyserRef.current = null;
134
+ ctx === null || ctx === void 0 ? void 0 : ctx.close().catch(() => { });
135
+ }, [clearIdleTimer]);
136
+ const startAudioAnalysis = useCallback(async () => {
137
+ try {
138
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
139
+ if (!mountedRef.current) {
140
+ stream.getTracks().forEach((t) => t.stop());
141
+ return false;
142
+ }
143
+ streamRef.current = stream;
144
+ const ctx = new AudioContext();
145
+ audioContextRef.current = ctx;
146
+ const analyser = ctx.createAnalyser();
147
+ analyser.fftSize = 256;
148
+ analyserRef.current = analyser;
149
+ ctx.createMediaStreamSource(stream).connect(analyser);
150
+ const data = new Uint8Array(analyser.frequencyBinCount);
151
+ const tick = () => {
152
+ if (!analyserRef.current || !mountedRef.current)
153
+ return;
154
+ analyserRef.current.getByteFrequencyData(data);
155
+ let sum = 0;
156
+ for (let i = 0; i < data.length; i++)
157
+ sum += data[i];
158
+ setAudioLevel(Math.min(100, (sum / data.length) * 2));
159
+ animFrameRef.current = requestAnimationFrame(tick);
160
+ };
161
+ tick();
162
+ return true;
163
+ }
164
+ catch (_a) {
165
+ return false;
166
+ }
167
+ }, []);
168
+ // ── Execute command ─────────────────────────────────────────────────────────
169
+ const executeCommand = useCallback((text) => {
170
+ var _a, _b, _c, _d;
171
+ const trimmed = text.trim();
172
+ if (!trimmed)
173
+ return;
174
+ const cb = callbacksRef.current;
175
+ // Wake-word gate
176
+ if (wakeWord) {
177
+ const ww = wakeWord.toLowerCase();
178
+ if (!trimmed.toLowerCase().includes(ww))
179
+ return;
180
+ }
181
+ const matched = matchVoiceCommand(trimmed, commandsRef.current);
182
+ const time = new Date().toLocaleTimeString();
183
+ setHistory((prev) => [
184
+ { text: trimmed, matched: !!matched, label: matched === null || matched === void 0 ? void 0 : matched.label, time },
185
+ ...prev.slice(0, Math.max(0, historySize - 1)),
186
+ ]);
187
+ if (matched) {
188
+ setLastMatch(matched);
189
+ (_a = cb.onToast) === null || _a === void 0 ? void 0 : _a.call(cb, 'success', `Matched: ${matched.label}`);
190
+ (_b = cb.onMatch) === null || _b === void 0 ? void 0 : _b.call(cb, matched, trimmed);
191
+ if (matched.requiresConfirmation) {
192
+ setPendingConfirm(matched);
193
+ setStatus('confirming');
194
+ return; // wait for a follow-up "yes" / "confirm"
195
+ }
196
+ // Fire the command
197
+ if (matched.action) {
198
+ matched.action();
199
+ }
200
+ else if (autoNavigate && matched.path && navigateRef.current) {
201
+ navigateRef.current(matched.path);
202
+ }
203
+ }
204
+ else {
205
+ const closest = closestVoiceCommand(trimmed, commandsRef.current);
206
+ (_c = cb.onToast) === null || _c === void 0 ? void 0 : _c.call(cb, 'warn', closest ? `Did you mean: ${closest.label}?` : 'Command not recognized');
207
+ (_d = cb.onNoMatch) === null || _d === void 0 ? void 0 : _d.call(cb, trimmed, closest);
208
+ }
209
+ if (mountedRef.current)
210
+ setStatus('idle');
211
+ }, [autoNavigate, historySize, wakeWord]);
212
+ // Keep a stable ref so the recognition effect can call it without being
213
+ // in its own dep array (fixes the stale-closure / rebuild-on-every-render bug).
214
+ useEffect(() => { executeRef.current = executeCommand; }, [executeCommand]);
215
+ // ── Confirmation handler ────────────────────────────────────────────────────
216
+ const resolveConfirm = useCallback((text) => {
217
+ var _a, _b, _c, _d;
218
+ if (!pendingConfirm)
219
+ return;
220
+ const yes = /\b(yes|confirm|ok|okay|sure|do it)\b/i.test(text);
221
+ if (yes) {
222
+ if (pendingConfirm.action) {
223
+ pendingConfirm.action();
224
+ }
225
+ else if (autoNavigate && pendingConfirm.path && navigateRef.current) {
226
+ navigateRef.current(pendingConfirm.path);
227
+ }
228
+ (_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'success', `Confirmed: ${pendingConfirm.label}`);
229
+ }
230
+ else {
231
+ (_d = (_c = callbacksRef.current).onToast) === null || _d === void 0 ? void 0 : _d.call(_c, 'warn', 'Cancelled');
232
+ }
233
+ setPendingConfirm(null);
234
+ setStatus('idle');
235
+ }, [pendingConfirm, autoNavigate]);
236
+ // ── SpeechRecognition setup ─────────────────────────────────────────────────
237
+ // executeCommand is intentionally NOT in this dep array.
238
+ // We call it via executeRef.current to avoid rebuilding recognition on every render.
239
+ useEffect(() => {
240
+ const Ctor = getSpeechRecognitionCtor();
241
+ if (!Ctor) {
242
+ setStatus('unsupported');
243
+ return;
244
+ }
245
+ const recognition = new Ctor();
246
+ recognition.continuous = continuous && isContinuousSupported;
247
+ recognition.interimResults = interimResults;
248
+ recognition.lang = language;
249
+ recognition.onstart = () => {
250
+ var _a, _b;
251
+ if (!mountedRef.current)
252
+ return;
253
+ setStatus('listening');
254
+ (_b = (_a = callbacksRef.current).onStart) === null || _b === void 0 ? void 0 : _b.call(_a);
255
+ resetIdleTimer();
256
+ };
257
+ recognition.onresult = (event) => {
258
+ var _a;
259
+ if (!mountedRef.current)
260
+ return;
261
+ let interim = '';
262
+ let finalText = '';
263
+ let highestConfidence = 0;
264
+ for (let i = event.resultIndex; i < event.results.length; i++) {
265
+ const result = event.results[i];
266
+ const txt = result[0].transcript;
267
+ // Chrome often returns confidence=0 even for correct final results;
268
+ // treat 0 (and missing) as "unknown" so we don't drop every redirect.
269
+ const conf = result[0].confidence || 1;
270
+ if (result.isFinal) {
271
+ if (conf >= minConfidence)
272
+ finalText += txt;
273
+ highestConfidence = Math.max(highestConfidence, conf);
274
+ }
275
+ else {
276
+ interim += txt;
277
+ }
278
+ }
279
+ setTranscript(finalText || interim);
280
+ if (finalText) {
281
+ setStatus('processing');
282
+ stopAudioAnalysis();
283
+ clearIdleTimer();
284
+ // Resolve a pending confirmation or run normal command matching
285
+ if (pendingConfirm) {
286
+ resolveConfirm(finalText);
287
+ }
288
+ else {
289
+ (_a = executeRef.current) === null || _a === void 0 ? void 0 : _a.call(executeRef, finalText);
290
+ }
291
+ }
292
+ else {
293
+ // Interim speech resets the idle timer
294
+ resetIdleTimer();
295
+ }
296
+ };
297
+ recognition.onerror = (event) => {
298
+ var _a, _b, _c, _d;
299
+ if (!mountedRef.current)
300
+ return;
301
+ // These are non-fatal — ignore silently
302
+ if (event.error === 'aborted' || event.error === 'no-speech')
303
+ return;
304
+ setStatus('error');
305
+ const msg = event.error === 'not-allowed'
306
+ ? 'Microphone permission denied'
307
+ : `Speech error: ${event.error}`;
308
+ (_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'error', msg);
309
+ (_d = (_c = callbacksRef.current).onError) === null || _d === void 0 ? void 0 : _d.call(_c, event.error);
310
+ stopAudioAnalysis();
311
+ // Auto-recover to idle after 2s so UI isn't stuck on error
312
+ setTimeout(() => {
313
+ if (mountedRef.current)
314
+ setStatus('idle');
315
+ }, 2000);
316
+ };
317
+ recognition.onend = () => {
318
+ var _a, _b;
319
+ if (!mountedRef.current)
320
+ return;
321
+ // Only reset to idle if we weren't already in processing/error/confirming
322
+ setStatus((prev) => prev === 'listening' || prev === 'processing' ? 'idle' : prev);
323
+ stopAudioAnalysis();
324
+ (_b = (_a = callbacksRef.current).onEnd) === null || _b === void 0 ? void 0 : _b.call(_a);
325
+ };
326
+ recognitionRef.current = recognition;
327
+ return () => {
328
+ var _a;
329
+ try {
330
+ (_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.abort();
331
+ }
332
+ catch ( /* ignore */_b) { /* ignore */ }
333
+ recognitionRef.current = null;
334
+ stopAudioAnalysis();
335
+ };
336
+ // eslint-disable-next-line react-hooks/exhaustive-deps
337
+ }, [continuous, interimResults, isContinuousSupported]);
338
+ // ↑ language intentionally omitted from deps — updated via separate effect below
339
+ // ↑ executeCommand intentionally omitted — called via executeRef to avoid rebuilds
340
+ // Update lang on the existing recognition instance without rebuilding it
341
+ useEffect(() => {
342
+ if (recognitionRef.current) {
343
+ recognitionRef.current.lang = language;
344
+ }
345
+ }, [language]);
346
+ // Keep pendingConfirm and resolveConfirm accessible inside the recognition
347
+ // onresult handler without adding them to the effect deps.
348
+ const pendingConfirmRef = useRef(pendingConfirm);
349
+ const resolveConfirmRef = useRef(resolveConfirm);
350
+ useEffect(() => { pendingConfirmRef.current = pendingConfirm; }, [pendingConfirm]);
351
+ useEffect(() => { resolveConfirmRef.current = resolveConfirm; }, [resolveConfirm]);
352
+ // ── Keyboard shortcut ───────────────────────────────────────────────────────
353
+ useEffect(() => {
354
+ if (!shortcut)
355
+ return;
356
+ const handler = (e) => {
357
+ var _a, _b;
358
+ // e.g. shortcut = "alt+v"
359
+ const parts = shortcut.toLowerCase().split('+');
360
+ const key = parts[parts.length - 1];
361
+ const needsAlt = parts.includes('alt');
362
+ const needsCtrl = parts.includes('ctrl');
363
+ const needsShift = parts.includes('shift');
364
+ const needsMeta = parts.includes('meta');
365
+ if (e.key.toLowerCase() === key &&
366
+ e.altKey === needsAlt &&
367
+ e.ctrlKey === needsCtrl &&
368
+ e.shiftKey === needsShift &&
369
+ e.metaKey === needsMeta) {
370
+ e.preventDefault();
371
+ // Call toggle via refs to avoid stale closure
372
+ if (status === 'listening') {
373
+ try {
374
+ (_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
375
+ }
376
+ catch ( /* ignore */_c) { /* ignore */ }
377
+ stopAudioAnalysis();
378
+ setStatus('idle');
379
+ }
380
+ else {
381
+ (_b = startRef.current) === null || _b === void 0 ? void 0 : _b.call(startRef);
382
+ }
383
+ }
384
+ };
385
+ window.addEventListener('keydown', handler);
386
+ return () => window.removeEventListener('keydown', handler);
387
+ }, [shortcut, status, stopAudioAnalysis]);
388
+ // ── Public actions ──────────────────────────────────────────────────────────
389
+ const start = useCallback(async () => {
390
+ var _a, _b, _c, _d;
391
+ if (!recognitionRef.current)
392
+ return;
393
+ if (status === 'listening' || startingRef.current)
394
+ return;
395
+ startingRef.current = true;
396
+ setTranscript('');
397
+ const micOk = await startAudioAnalysis();
398
+ startingRef.current = false;
399
+ if (!mountedRef.current)
400
+ return;
401
+ if (!micOk) {
402
+ (_b = (_a = callbacksRef.current).onToast) === null || _b === void 0 ? void 0 : _b.call(_a, 'error', 'Microphone access denied');
403
+ (_d = (_c = callbacksRef.current).onError) === null || _d === void 0 ? void 0 : _d.call(_c, 'not-allowed');
404
+ return;
405
+ }
406
+ try {
407
+ recognitionRef.current.start();
408
+ }
409
+ catch ( /* already started */_e) { /* already started */ }
410
+ }, [status, startAudioAnalysis]);
411
+ // Keep a ref so the shortcut handler can call start() without being stale
412
+ const startRef = useRef(start);
413
+ useEffect(() => { startRef.current = start; }, [start]);
414
+ const stop = useCallback(() => {
415
+ var _a;
416
+ try {
417
+ (_a = recognitionRef.current) === null || _a === void 0 ? void 0 : _a.stop();
418
+ }
419
+ catch ( /* ignore */_b) { /* ignore */ }
420
+ stopAudioAnalysis();
421
+ if (mountedRef.current)
422
+ setStatus('idle');
423
+ }, [stopAudioAnalysis]);
424
+ const toggle = useCallback(async () => {
425
+ if (status === 'listening')
426
+ stop();
427
+ else
428
+ await start();
429
+ }, [status, start, stop]);
430
+ const reset = useCallback(() => {
431
+ setTranscript('');
432
+ setHistory([]);
433
+ setLastMatch(null);
434
+ setPendingConfirm(null);
435
+ }, []);
436
+ // ── Mount / unmount guard ───────────────────────────────────────────────────
437
+ useEffect(() => {
438
+ mountedRef.current = true;
439
+ return () => {
440
+ mountedRef.current = false;
441
+ clearIdleTimer();
442
+ };
443
+ }, [clearIdleTimer]);
444
+ // ── Result ──────────────────────────────────────────────────────────────────
445
+ return {
446
+ status,
447
+ transcript,
448
+ audioLevel,
449
+ history,
450
+ lastMatch,
451
+ pendingConfirm,
452
+ isSupported,
453
+ isContinuousSupported,
454
+ isListening: status === 'listening',
455
+ isProcessing: status === 'processing',
456
+ start,
457
+ stop,
458
+ toggle,
459
+ reset,
460
+ executeCommand,
461
+ };
462
+ }