react-hook-toolkit 4.0.0 → 5.0.0

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,783 @@
1
+ import { useState, useEffect, useCallback, useRef, useLayoutEffect, useReducer } from 'react';
2
+ // ─── useIsomorphicEffect ──────────────────────────────────────────────────────
3
+ /**
4
+ * A drop-in replacement for useLayoutEffect that falls back to useEffect on
5
+ * the server (SSR), preventing the "useLayoutEffect does nothing on the server"
6
+ * warning in Next.js / Remix environments.
7
+ */
8
+ export const useIsomorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
9
+ /**
10
+ * Manages a boolean state with explicit setTrue, setFalse, toggle, and set helpers.
11
+ */
12
+ export function useBoolean(initialValue = false) {
13
+ const [value, setValue] = useState(initialValue);
14
+ const setTrue = useCallback(() => setValue(true), []);
15
+ const setFalse = useCallback(() => setValue(false), []);
16
+ const toggle = useCallback(() => setValue(v => !v), []);
17
+ const set = useCallback((v) => setValue(v), []);
18
+ return { value, setTrue, setFalse, toggle, set };
19
+ }
20
+ // ─── useForceUpdate ───────────────────────────────────────────────────────────
21
+ /**
22
+ * Returns a function that, when called, forces the component to re-render.
23
+ * Useful when external mutable data changes outside of React state.
24
+ */
25
+ export function useForceUpdate() {
26
+ const [, dispatch] = useReducer((x) => x + 1, 0);
27
+ return useCallback(() => dispatch(), []);
28
+ }
29
+ // ─── useClock ─────────────────────────────────────────────────────────────────
30
+ /**
31
+ * Returns a Date that updates every `intervalMs` milliseconds (default 1000).
32
+ * Useful for displaying a live clock.
33
+ */
34
+ export function useClock(intervalMs = 1000) {
35
+ const [now, setNow] = useState(() => new Date());
36
+ useEffect(() => {
37
+ const id = setInterval(() => setNow(new Date()), intervalMs);
38
+ return () => clearInterval(id);
39
+ }, [intervalMs]);
40
+ return now;
41
+ }
42
+ // ─── useDebouncedState ────────────────────────────────────────────────────────
43
+ /**
44
+ * Like useState but the exposed value only updates after the setter hasn't
45
+ * been called for `delay` milliseconds.
46
+ * Returns [debouncedValue, setter, immediateValue].
47
+ */
48
+ export function useDebouncedState(initialValue, delay) {
49
+ const [immediate, setImmediate] = useState(initialValue);
50
+ const [debounced, setDebounced] = useState(initialValue);
51
+ const timerRef = useRef();
52
+ const set = useCallback((value) => {
53
+ setImmediate(value);
54
+ clearTimeout(timerRef.current);
55
+ timerRef.current = setTimeout(() => setDebounced(value), delay);
56
+ }, [delay]);
57
+ useEffect(() => () => clearTimeout(timerRef.current), []);
58
+ return [debounced, set, immediate];
59
+ }
60
+ /**
61
+ * Calls `callback` on every animation frame while running.
62
+ * The callback receives the elapsed time in milliseconds since start.
63
+ * Returns start/stop controls and an isRunning flag.
64
+ */
65
+ export function useAnimationFrame(callback) {
66
+ const frameRef = useRef(0);
67
+ const startTimeRef = useRef(0);
68
+ const callbackRef = useRef(callback);
69
+ const [isRunning, setIsRunning] = useState(false);
70
+ useIsomorphicEffect(() => {
71
+ callbackRef.current = callback;
72
+ });
73
+ const stop = useCallback(() => {
74
+ cancelAnimationFrame(frameRef.current);
75
+ setIsRunning(false);
76
+ }, []);
77
+ const start = useCallback(() => {
78
+ cancelAnimationFrame(frameRef.current);
79
+ startTimeRef.current = performance.now();
80
+ setIsRunning(true);
81
+ const loop = (timestamp) => {
82
+ callbackRef.current(timestamp - startTimeRef.current);
83
+ frameRef.current = requestAnimationFrame(loop);
84
+ };
85
+ frameRef.current = requestAnimationFrame(loop);
86
+ }, []);
87
+ useEffect(() => () => cancelAnimationFrame(frameRef.current), []);
88
+ return { start, stop, isRunning };
89
+ }
90
+ /**
91
+ * Wraps an async function with loading (isPending), data, and error states.
92
+ * Unlike useAsync, this is imperative — it does NOT run on mount.
93
+ */
94
+ export function useAsyncCallback(asyncFn) {
95
+ const [data, setData] = useState(null);
96
+ const [error, setError] = useState(null);
97
+ const [isPending, setIsPending] = useState(false);
98
+ const isMounted = useRef(true);
99
+ useEffect(() => {
100
+ isMounted.current = true;
101
+ return () => { isMounted.current = false; };
102
+ }, []);
103
+ const execute = useCallback(async (...args) => {
104
+ setIsPending(true);
105
+ setError(null);
106
+ try {
107
+ const result = await asyncFn(...args);
108
+ if (isMounted.current)
109
+ setData(result);
110
+ }
111
+ catch (err) {
112
+ if (isMounted.current)
113
+ setError(err instanceof Error ? err : new Error(String(err)));
114
+ }
115
+ finally {
116
+ if (isMounted.current)
117
+ setIsPending(false);
118
+ }
119
+ }, [asyncFn]);
120
+ const reset = useCallback(() => {
121
+ setData(null);
122
+ setError(null);
123
+ setIsPending(false);
124
+ }, []);
125
+ return { execute, data, error, isPending, reset };
126
+ }
127
+ /**
128
+ * Uses the AnalyserNode API to capture and expose real-time audio frequency
129
+ * and time-domain data from the user's microphone.
130
+ */
131
+ export function useAudioAnalyser(options = {}) {
132
+ const { fftSize = 256, smoothingTimeConstant = 0.8 } = options;
133
+ const [analyser, setAnalyser] = useState(null);
134
+ const [frequencyData, setFrequencyData] = useState(new Uint8Array(0));
135
+ const [timeDomainData, setTimeDomainData] = useState(new Uint8Array(0));
136
+ const [audioLevel, setAudioLevel] = useState(0);
137
+ const [isListening, setIsListening] = useState(false);
138
+ const [error, setError] = useState(null);
139
+ const contextRef = useRef(null);
140
+ const streamRef = useRef(null);
141
+ const frameRef = useRef(0);
142
+ const analyserRef = useRef(null);
143
+ const stop = useCallback(() => {
144
+ var _a, _b;
145
+ cancelAnimationFrame(frameRef.current);
146
+ (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach(t => t.stop());
147
+ streamRef.current = null;
148
+ (_b = contextRef.current) === null || _b === void 0 ? void 0 : _b.close().catch(() => { });
149
+ contextRef.current = null;
150
+ analyserRef.current = null;
151
+ setAnalyser(null);
152
+ setAudioLevel(0);
153
+ setIsListening(false);
154
+ }, []);
155
+ const start = useCallback(async () => {
156
+ try {
157
+ setError(null);
158
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
159
+ streamRef.current = stream;
160
+ const ctx = new AudioContext();
161
+ contextRef.current = ctx;
162
+ const node = ctx.createAnalyser();
163
+ node.fftSize = fftSize;
164
+ node.smoothingTimeConstant = smoothingTimeConstant;
165
+ ctx.createMediaStreamSource(stream).connect(node);
166
+ analyserRef.current = node;
167
+ setAnalyser(node);
168
+ setIsListening(true);
169
+ const freqArr = new Uint8Array(node.frequencyBinCount);
170
+ const timeArr = new Uint8Array(node.fftSize);
171
+ const tick = () => {
172
+ if (!analyserRef.current)
173
+ return;
174
+ analyserRef.current.getByteFrequencyData(freqArr);
175
+ analyserRef.current.getByteTimeDomainData(timeArr);
176
+ setFrequencyData(new Uint8Array(freqArr));
177
+ setTimeDomainData(new Uint8Array(timeArr));
178
+ const avg = freqArr.reduce((a, b) => a + b, 0) / freqArr.length;
179
+ setAudioLevel(Math.min(100, avg * 2));
180
+ frameRef.current = requestAnimationFrame(tick);
181
+ };
182
+ tick();
183
+ }
184
+ catch (err) {
185
+ setError(err instanceof Error ? err : new Error('Failed to access microphone'));
186
+ setIsListening(false);
187
+ }
188
+ }, [fftSize, smoothingTimeConstant]);
189
+ useEffect(() => () => stop(), [stop]);
190
+ return { analyser, frequencyData, timeDomainData, audioLevel, isListening, start, stop, error };
191
+ }
192
+ /**
193
+ * Handles drag-and-drop interactions on a container element.
194
+ * Provides props for both the container and each draggable item.
195
+ * Calls onDrop(fromIndex, toIndex) when a drop completes.
196
+ */
197
+ export function useDragAndDrop(onDrop) {
198
+ const [isDragging, setIsDragging] = useState(false);
199
+ const [isOver, setIsOver] = useState(false);
200
+ const [dragIndex, setDragIndex] = useState(null);
201
+ const [dropIndex, setDropIndex] = useState(null);
202
+ const dragIndexRef = useRef(null);
203
+ const containerProps = {
204
+ onDragOver: useCallback((e) => {
205
+ e.preventDefault();
206
+ setIsOver(true);
207
+ }, []),
208
+ onDragLeave: useCallback(() => setIsOver(false), []),
209
+ onDrop: useCallback((e) => {
210
+ e.preventDefault();
211
+ setIsOver(false);
212
+ setIsDragging(false);
213
+ const from = dragIndexRef.current;
214
+ if (from !== null && dropIndex !== null && from !== dropIndex) {
215
+ onDrop === null || onDrop === void 0 ? void 0 : onDrop(from, dropIndex);
216
+ }
217
+ setDragIndex(null);
218
+ setDropIndex(null);
219
+ dragIndexRef.current = null;
220
+ }, [dropIndex, onDrop]),
221
+ };
222
+ const draggableProps = useCallback((index) => ({
223
+ draggable: true,
224
+ onDragStart: (_e) => {
225
+ setIsDragging(true);
226
+ setDragIndex(index);
227
+ dragIndexRef.current = index;
228
+ },
229
+ onDragEnd: (_e) => {
230
+ setIsDragging(false);
231
+ setDragIndex(null);
232
+ dragIndexRef.current = null;
233
+ },
234
+ }), []);
235
+ return { containerProps, draggableProps, state: { isDragging, isOver }, dragIndex, dropIndex };
236
+ }
237
+ /**
238
+ * Handles file drag-drop and file-input selection on a drop area element.
239
+ * Validates file type and size before calling onDrop.
240
+ */
241
+ export function useFileDropArea(options = {}) {
242
+ const { accept = [], maxSize, multiple = true, onDrop, onError } = options;
243
+ const [files, setFiles] = useState([]);
244
+ const [isOver, setIsOver] = useState(false);
245
+ const validateFiles = useCallback((rawFiles) => {
246
+ return rawFiles.filter(file => {
247
+ if (accept.length > 0) {
248
+ const ok = accept.some(type => {
249
+ if (type.endsWith('/*'))
250
+ return file.type.startsWith(type.slice(0, -2));
251
+ if (type.startsWith('.'))
252
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
253
+ return file.type === type;
254
+ });
255
+ if (!ok) {
256
+ onError === null || onError === void 0 ? void 0 : onError(new Error(`File type not accepted: ${file.type}`));
257
+ return false;
258
+ }
259
+ }
260
+ if (maxSize && file.size > maxSize) {
261
+ onError === null || onError === void 0 ? void 0 : onError(new Error(`File too large: ${file.name}`));
262
+ return false;
263
+ }
264
+ return true;
265
+ });
266
+ }, [accept, maxSize, onError]);
267
+ const handleFiles = useCallback((rawFiles) => {
268
+ if (!rawFiles)
269
+ return;
270
+ const arr = Array.from(rawFiles);
271
+ const valid = validateFiles(multiple ? arr : arr.slice(0, 1));
272
+ setFiles(prev => (multiple ? [...prev, ...valid] : valid));
273
+ onDrop === null || onDrop === void 0 ? void 0 : onDrop(valid);
274
+ }, [validateFiles, multiple, onDrop]);
275
+ const dropProps = {
276
+ onDragOver: useCallback((e) => { e.preventDefault(); setIsOver(true); }, []),
277
+ onDragLeave: useCallback(() => setIsOver(false), []),
278
+ onDrop: useCallback((e) => {
279
+ e.preventDefault();
280
+ setIsOver(false);
281
+ handleFiles(e.dataTransfer.files);
282
+ }, [handleFiles]),
283
+ };
284
+ const inputProps = {
285
+ type: 'file',
286
+ accept: accept.join(','),
287
+ multiple,
288
+ onChange: useCallback((e) => {
289
+ handleFiles(e.target.files);
290
+ }, [handleFiles]),
291
+ style: { display: 'none' },
292
+ };
293
+ const clear = useCallback(() => setFiles([]), []);
294
+ return { dropProps, inputProps, files, isOver, clear };
295
+ }
296
+ /**
297
+ * Handles Gamepad API connections, disconnections, and button/axis state.
298
+ * Polls gamepad state via requestAnimationFrame.
299
+ */
300
+ export function useGamepad() {
301
+ const [gamepads, setGamepads] = useState([]);
302
+ const frameRef = useRef(0);
303
+ const isSupported = typeof navigator !== 'undefined' && 'getGamepads' in navigator;
304
+ useEffect(() => {
305
+ if (!isSupported)
306
+ return;
307
+ const poll = () => {
308
+ setGamepads(Array.from(navigator.getGamepads()));
309
+ frameRef.current = requestAnimationFrame(poll);
310
+ };
311
+ const onConnect = () => { frameRef.current = requestAnimationFrame(poll); };
312
+ const onDisconnect = () => setGamepads(Array.from(navigator.getGamepads()));
313
+ window.addEventListener('gamepadconnected', onConnect);
314
+ window.addEventListener('gamepaddisconnected', onDisconnect);
315
+ return () => {
316
+ cancelAnimationFrame(frameRef.current);
317
+ window.removeEventListener('gamepadconnected', onConnect);
318
+ window.removeEventListener('gamepaddisconnected', onDisconnect);
319
+ };
320
+ }, [isSupported]);
321
+ return { gamepads, isConnected: gamepads.some(g => g !== null) };
322
+ }
323
+ /**
324
+ * Tracks the number of CSS grid columns and rows of a ref'd element.
325
+ * Updates whenever the element resizes.
326
+ */
327
+ export function useGridLayout(ref) {
328
+ const [info, setInfo] = useState({ columns: 0, rows: 0 });
329
+ useEffect(() => {
330
+ const el = ref.current;
331
+ if (!el)
332
+ return;
333
+ const update = () => {
334
+ const style = window.getComputedStyle(el);
335
+ const cols = style.getPropertyValue('grid-template-columns').split(' ').filter(Boolean).length;
336
+ const rows = style.getPropertyValue('grid-template-rows').split(' ').filter(Boolean).length;
337
+ setInfo({ columns: cols, rows });
338
+ };
339
+ update();
340
+ const observer = new ResizeObserver(update);
341
+ observer.observe(el);
342
+ return () => observer.disconnect();
343
+ }, [ref]);
344
+ return info;
345
+ }
346
+ /**
347
+ * Convenience wrapper over IntersectionObserver that returns a [ref, inView] pair.
348
+ * When once is true, stops observing after the element enters the viewport.
349
+ */
350
+ export function useInView(options = {}) {
351
+ const { once = false, threshold = 0, root = null, rootMargin = '0%' } = options;
352
+ const ref = useRef(null);
353
+ const [inView, setInView] = useState(false);
354
+ const [entry, setEntry] = useState();
355
+ const frozen = useRef(false);
356
+ useEffect(() => {
357
+ if (frozen.current || !ref.current)
358
+ return;
359
+ const observer = new IntersectionObserver(([e]) => {
360
+ setEntry(e);
361
+ setInView(e.isIntersecting);
362
+ if (e.isIntersecting && once) {
363
+ frozen.current = true;
364
+ observer.disconnect();
365
+ }
366
+ }, { threshold, root, rootMargin });
367
+ observer.observe(ref.current);
368
+ return () => observer.disconnect();
369
+ }, [threshold, root, rootMargin, once]);
370
+ return { ref, inView, entry };
371
+ }
372
+ /**
373
+ * Binds keyboard hotkeys to handler functions.
374
+ * Key format: 'ctrl+s', 'alt+shift+k', 'Escape', 'ArrowUp', etc.
375
+ */
376
+ export function useKeyboard(bindings, element) {
377
+ const bindingsRef = useRef(bindings);
378
+ useIsomorphicEffect(() => { bindingsRef.current = bindings; });
379
+ useEffect(() => {
380
+ var _a;
381
+ const target = (_a = element === null || element === void 0 ? void 0 : element.current) !== null && _a !== void 0 ? _a : window;
382
+ const handler = (e) => {
383
+ for (const [combo, fn] of Object.entries(bindingsRef.current)) {
384
+ const parts = combo.toLowerCase().split('+');
385
+ const key = parts[parts.length - 1];
386
+ const needsCtrl = parts.includes('ctrl');
387
+ const needsAlt = parts.includes('alt');
388
+ const needsShift = parts.includes('shift');
389
+ const needsMeta = parts.includes('meta');
390
+ if (e.key.toLowerCase() === key &&
391
+ e.ctrlKey === needsCtrl &&
392
+ e.altKey === needsAlt &&
393
+ e.shiftKey === needsShift &&
394
+ e.metaKey === needsMeta) {
395
+ e.preventDefault();
396
+ fn(e);
397
+ }
398
+ }
399
+ };
400
+ target.addEventListener('keydown', handler);
401
+ return () => target.removeEventListener('keydown', handler);
402
+ }, [element]);
403
+ }
404
+ /**
405
+ * Provides access to the user's camera and microphone via getUserMedia.
406
+ * Enumerates available devices and exposes the active MediaStream.
407
+ */
408
+ export function useMediaDevices() {
409
+ const [state, setState] = useState({
410
+ stream: null,
411
+ devices: [],
412
+ isLoading: false,
413
+ error: null,
414
+ hasCamera: false,
415
+ hasMicrophone: false,
416
+ });
417
+ const streamRef = useRef(null);
418
+ const stopStream = useCallback(() => {
419
+ var _a;
420
+ (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach(t => t.stop());
421
+ streamRef.current = null;
422
+ setState(prev => (Object.assign(Object.assign({}, prev), { stream: null })));
423
+ }, []);
424
+ const requestAccess = useCallback(async (constraints = { audio: true, video: true }) => {
425
+ setState(prev => (Object.assign(Object.assign({}, prev), { isLoading: true, error: null })));
426
+ try {
427
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
428
+ streamRef.current = stream;
429
+ const devices = await navigator.mediaDevices.enumerateDevices();
430
+ setState({
431
+ stream,
432
+ devices,
433
+ isLoading: false,
434
+ error: null,
435
+ hasCamera: devices.some(d => d.kind === 'videoinput'),
436
+ hasMicrophone: devices.some(d => d.kind === 'audioinput'),
437
+ });
438
+ }
439
+ catch (err) {
440
+ setState(prev => (Object.assign(Object.assign({}, prev), { isLoading: false, error: err instanceof Error ? err : new Error('Media access denied') })));
441
+ }
442
+ }, []);
443
+ useEffect(() => () => stopStream(), [stopStream]);
444
+ return Object.assign(Object.assign({}, state), { requestAccess, stopStream });
445
+ }
446
+ // ─── useMutationObserver ──────────────────────────────────────────────────────
447
+ /**
448
+ * Observes DOM mutations on a ref'd element via MutationObserver.
449
+ * Calls the callback whenever the observed mutations are recorded.
450
+ */
451
+ export function useMutationObserver(ref, callback, options = {
452
+ childList: true,
453
+ subtree: true,
454
+ attributes: true,
455
+ characterData: true,
456
+ }) {
457
+ const callbackRef = useRef(callback);
458
+ useIsomorphicEffect(() => { callbackRef.current = callback; });
459
+ useEffect(() => {
460
+ const el = ref.current;
461
+ if (!el || !('MutationObserver' in window))
462
+ return;
463
+ const observer = new MutationObserver((...args) => callbackRef.current(...args));
464
+ observer.observe(el, options);
465
+ return () => observer.disconnect();
466
+ }, [ref, options]);
467
+ }
468
+ /**
469
+ * Full NetworkInformation API integration.
470
+ * Returns online status plus detailed connection metadata when available.
471
+ * Tracks when the online status last changed via `since`.
472
+ */
473
+ export function useNetworkState() {
474
+ const getConn = () => navigator.connection ||
475
+ navigator.mozConnection ||
476
+ navigator.webkitConnection;
477
+ const getState = () => {
478
+ var _a, _b, _c, _d, _f, _g;
479
+ const conn = getConn();
480
+ return {
481
+ isOnline: navigator.onLine,
482
+ effectiveType: (_a = conn === null || conn === void 0 ? void 0 : conn.effectiveType) !== null && _a !== void 0 ? _a : null,
483
+ downlink: (_b = conn === null || conn === void 0 ? void 0 : conn.downlink) !== null && _b !== void 0 ? _b : null,
484
+ downlinkMax: (_c = conn === null || conn === void 0 ? void 0 : conn.downlinkMax) !== null && _c !== void 0 ? _c : null,
485
+ rtt: (_d = conn === null || conn === void 0 ? void 0 : conn.rtt) !== null && _d !== void 0 ? _d : null,
486
+ saveData: (_f = conn === null || conn === void 0 ? void 0 : conn.saveData) !== null && _f !== void 0 ? _f : null,
487
+ type: (_g = conn === null || conn === void 0 ? void 0 : conn.type) !== null && _g !== void 0 ? _g : null,
488
+ };
489
+ };
490
+ const [state, setState] = useState(Object.assign(Object.assign({}, getState()), { since: null }));
491
+ useEffect(() => {
492
+ var _a;
493
+ const update = () => setState(Object.assign(Object.assign({}, getState()), { since: new Date() }));
494
+ window.addEventListener('online', update);
495
+ window.addEventListener('offline', update);
496
+ (_a = getConn()) === null || _a === void 0 ? void 0 : _a.addEventListener('change', update);
497
+ return () => {
498
+ var _a;
499
+ window.removeEventListener('online', update);
500
+ window.removeEventListener('offline', update);
501
+ (_a = getConn()) === null || _a === void 0 ? void 0 : _a.removeEventListener('change', update);
502
+ };
503
+ }, []);
504
+ return state;
505
+ }
506
+ /**
507
+ * Tracks the device screen orientation type and angle.
508
+ * Uses the ScreenOrientation API with a matchMedia fallback.
509
+ */
510
+ export function useOrientation() {
511
+ const getState = () => {
512
+ const o = screen.orientation;
513
+ if (o) {
514
+ return {
515
+ type: o.type,
516
+ angle: o.angle,
517
+ isPortrait: o.type.startsWith('portrait'),
518
+ isLandscape: o.type.startsWith('landscape'),
519
+ };
520
+ }
521
+ const portrait = window.matchMedia('(orientation: portrait)').matches;
522
+ return {
523
+ type: null,
524
+ angle: 0,
525
+ isPortrait: portrait,
526
+ isLandscape: !portrait,
527
+ };
528
+ };
529
+ const [state, setState] = useState(getState);
530
+ useEffect(() => {
531
+ var _a;
532
+ const update = () => setState(getState());
533
+ (_a = screen.orientation) === null || _a === void 0 ? void 0 : _a.addEventListener('change', update);
534
+ const mq = window.matchMedia('(orientation: portrait)');
535
+ mq.addEventListener('change', update);
536
+ return () => {
537
+ var _a;
538
+ (_a = screen.orientation) === null || _a === void 0 ? void 0 : _a.removeEventListener('change', update);
539
+ mq.removeEventListener('change', update);
540
+ };
541
+ }, []);
542
+ return state;
543
+ }
544
+ // ─── usePageVisibility ────────────────────────────────────────────────────────
545
+ /**
546
+ * Returns true when the current browser tab is visible to the user.
547
+ * Wraps the Page Visibility API (document.visibilityState).
548
+ */
549
+ export function usePageVisibility() {
550
+ const [isVisible, setIsVisible] = useState(typeof document !== 'undefined' ? document.visibilityState === 'visible' : true);
551
+ useEffect(() => {
552
+ const onChange = () => setIsVisible(document.visibilityState === 'visible');
553
+ document.addEventListener('visibilitychange', onChange);
554
+ return () => document.removeEventListener('visibilitychange', onChange);
555
+ }, []);
556
+ return isVisible;
557
+ }
558
+ /**
559
+ * Handles all pointer events at once (mouse, touch, stylus).
560
+ * Tracks every active pointer by its pointerId.
561
+ */
562
+ export function usePointers() {
563
+ const [pointers, setPointers] = useState(new Map());
564
+ const upsert = useCallback((e) => {
565
+ setPointers(prev => new Map(prev).set(e.pointerId, {
566
+ pointerId: e.pointerId,
567
+ x: e.clientX,
568
+ y: e.clientY,
569
+ pressure: e.pressure,
570
+ type: e.pointerType,
571
+ }));
572
+ }, []);
573
+ const remove = useCallback((e) => {
574
+ setPointers(prev => {
575
+ const next = new Map(prev);
576
+ next.delete(e.pointerId);
577
+ return next;
578
+ });
579
+ }, []);
580
+ const containerProps = {
581
+ onPointerDown: upsert,
582
+ onPointerMove: upsert,
583
+ onPointerUp: remove,
584
+ onPointerCancel: remove,
585
+ onPointerLeave: remove,
586
+ };
587
+ return { pointers, activeCount: pointers.size, containerProps };
588
+ }
589
+ // ─── useRect ──────────────────────────────────────────────────────────────────
590
+ /**
591
+ * Tracks the full bounding DOMRect of a ref'd element reactively.
592
+ * Re-measures on scroll and resize using a ResizeObserver.
593
+ */
594
+ export function useRect() {
595
+ const ref = useRef(null);
596
+ const [rect, setRect] = useState(null);
597
+ useEffect(() => {
598
+ const el = ref.current;
599
+ if (!el)
600
+ return;
601
+ const measure = () => setRect(el.getBoundingClientRect());
602
+ measure();
603
+ const observer = new ResizeObserver(measure);
604
+ observer.observe(el);
605
+ window.addEventListener('scroll', measure, { passive: true });
606
+ return () => {
607
+ observer.disconnect();
608
+ window.removeEventListener('scroll', measure);
609
+ };
610
+ });
611
+ return [ref, rect];
612
+ }
613
+ /**
614
+ * Uses the Screen Capture API (getDisplayMedia) to capture the screen,
615
+ * a specific window, or the current browser tab.
616
+ */
617
+ export function useScreenCapture() {
618
+ const [stream, setStream] = useState(null);
619
+ const [isCapturing, setIsCapturing] = useState(false);
620
+ const [error, setError] = useState(null);
621
+ const streamRef = useRef(null);
622
+ const stop = useCallback(() => {
623
+ var _a;
624
+ (_a = streamRef.current) === null || _a === void 0 ? void 0 : _a.getTracks().forEach(t => t.stop());
625
+ streamRef.current = null;
626
+ setStream(null);
627
+ setIsCapturing(false);
628
+ }, []);
629
+ const start = useCallback(async (options) => {
630
+ var _a;
631
+ setError(null);
632
+ try {
633
+ const s = await navigator.mediaDevices.getDisplayMedia(options !== null && options !== void 0 ? options : { video: true, audio: false });
634
+ streamRef.current = s;
635
+ setStream(s);
636
+ setIsCapturing(true);
637
+ (_a = s.getVideoTracks()[0]) === null || _a === void 0 ? void 0 : _a.addEventListener('ended', stop);
638
+ }
639
+ catch (err) {
640
+ setError(err instanceof Error ? err : new Error('Screen capture failed'));
641
+ }
642
+ }, [stop]);
643
+ useEffect(() => () => stop(), [stop]);
644
+ return { stream, isCapturing, error, start, stop };
645
+ }
646
+ /**
647
+ * Returns true when the window (or a given element) has been scrolled
648
+ * past the specified pixel threshold.
649
+ */
650
+ export function useScrollThreshold(options = {}) {
651
+ const { threshold = 100, element } = options;
652
+ const [isPast, setIsPast] = useState(false);
653
+ useEffect(() => {
654
+ var _a;
655
+ const target = (_a = element === null || element === void 0 ? void 0 : element.current) !== null && _a !== void 0 ? _a : window;
656
+ const check = () => {
657
+ const scrollY = (element === null || element === void 0 ? void 0 : element.current)
658
+ ? element.current.scrollTop
659
+ : window.scrollY;
660
+ setIsPast(scrollY > threshold);
661
+ };
662
+ check();
663
+ target.addEventListener('scroll', check, { passive: true });
664
+ return () => target.removeEventListener('scroll', check);
665
+ }, [threshold, element]);
666
+ return isPast;
667
+ }
668
+ /**
669
+ * Returns the currently selected text along with its bounding DOMRect.
670
+ * Updates whenever the user changes their text selection.
671
+ */
672
+ export function useSelection(element) {
673
+ const [info, setInfo] = useState({ text: '', rect: null, isCollapsed: true });
674
+ useEffect(() => {
675
+ const update = () => {
676
+ const sel = window.getSelection();
677
+ if (!sel || sel.isCollapsed) {
678
+ setInfo({ text: '', rect: null, isCollapsed: true });
679
+ return;
680
+ }
681
+ if ((element === null || element === void 0 ? void 0 : element.current) && !element.current.contains(sel.anchorNode)) {
682
+ setInfo({ text: '', rect: null, isCollapsed: true });
683
+ return;
684
+ }
685
+ const range = sel.getRangeAt(0);
686
+ setInfo({
687
+ text: sel.toString(),
688
+ rect: range.getBoundingClientRect(),
689
+ isCollapsed: false,
690
+ });
691
+ };
692
+ document.addEventListener('selectionchange', update);
693
+ return () => document.removeEventListener('selectionchange', update);
694
+ }, [element]);
695
+ return info;
696
+ }
697
+ /**
698
+ * Detects swipe gestures (left/right/up/down) on a touch surface.
699
+ * Fires directional callbacks when the swipe exceeds the pixel threshold.
700
+ */
701
+ export function useSwiping(options = {}) {
702
+ const { threshold = 50, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown } = options;
703
+ const startRef = useRef(null);
704
+ const [state, setState] = useState({
705
+ isSwiping: false,
706
+ direction: null,
707
+ deltaX: 0,
708
+ deltaY: 0,
709
+ distance: 0,
710
+ });
711
+ const handlers = {
712
+ onTouchStart: useCallback((e) => {
713
+ const t = e.touches[0];
714
+ startRef.current = { x: t.clientX, y: t.clientY };
715
+ setState({ isSwiping: false, direction: null, deltaX: 0, deltaY: 0, distance: 0 });
716
+ }, []),
717
+ onTouchMove: useCallback((e) => {
718
+ if (!startRef.current)
719
+ return;
720
+ const t = e.touches[0];
721
+ const dx = t.clientX - startRef.current.x;
722
+ const dy = t.clientY - startRef.current.y;
723
+ const distance = Math.sqrt(dx * dx + dy * dy);
724
+ const direction = Math.abs(dx) > Math.abs(dy)
725
+ ? dx > 0 ? 'right' : 'left'
726
+ : dy > 0 ? 'down' : 'up';
727
+ setState({ isSwiping: true, direction, deltaX: dx, deltaY: dy, distance });
728
+ }, []),
729
+ onTouchEnd: useCallback((_e) => {
730
+ setState(prev => {
731
+ if (prev.distance >= threshold) {
732
+ if (prev.direction === 'left')
733
+ onSwipeLeft === null || onSwipeLeft === void 0 ? void 0 : onSwipeLeft();
734
+ else if (prev.direction === 'right')
735
+ onSwipeRight === null || onSwipeRight === void 0 ? void 0 : onSwipeRight();
736
+ else if (prev.direction === 'up')
737
+ onSwipeUp === null || onSwipeUp === void 0 ? void 0 : onSwipeUp();
738
+ else if (prev.direction === 'down')
739
+ onSwipeDown === null || onSwipeDown === void 0 ? void 0 : onSwipeDown();
740
+ }
741
+ return Object.assign(Object.assign({}, prev), { isSwiping: false });
742
+ });
743
+ startRef.current = null;
744
+ }, [threshold, onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown]),
745
+ };
746
+ return { state, handlers };
747
+ }
748
+ /**
749
+ * Runs a function in a Web Worker. The workerFn is serialized and executed
750
+ * in a background thread. Pass data via postMessage; receive results via lastMessage.
751
+ *
752
+ * The workerFn receives a MessageEvent and should call postMessage() to send results back.
753
+ */
754
+ export function useWorker(workerFn) {
755
+ const [lastMessage, setLastMessage] = useState(null);
756
+ const [error, setError] = useState(null);
757
+ const [isReady, setIsReady] = useState(false);
758
+ const workerRef = useRef(null);
759
+ useEffect(() => {
760
+ const blob = new Blob([`self.onmessage = ${workerFn.toString()}`], { type: 'application/javascript' });
761
+ const url = URL.createObjectURL(blob);
762
+ const worker = new Worker(url);
763
+ workerRef.current = worker;
764
+ setIsReady(true);
765
+ worker.onmessage = (e) => setLastMessage(e.data);
766
+ worker.onerror = (e) => setError(e);
767
+ return () => {
768
+ worker.terminate();
769
+ URL.revokeObjectURL(url);
770
+ };
771
+ }, []);
772
+ const postMessage = useCallback((data) => {
773
+ var _a;
774
+ (_a = workerRef.current) === null || _a === void 0 ? void 0 : _a.postMessage(data);
775
+ }, []);
776
+ const terminate = useCallback(() => {
777
+ var _a;
778
+ (_a = workerRef.current) === null || _a === void 0 ? void 0 : _a.terminate();
779
+ workerRef.current = null;
780
+ setIsReady(false);
781
+ }, []);
782
+ return { postMessage, lastMessage, error, isReady, terminate };
783
+ }