@deibid/no-hands-react 0.0.1

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/index.mjs ADDED
@@ -0,0 +1,467 @@
1
+ // src/nhcomponent.ts
2
+ import React, { forwardRef } from "react";
3
+
4
+ // src/hooks.ts
5
+ import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
6
+ import { NHEvent } from "@deibid/no-hands";
7
+
8
+ // src/context/context.tsx
9
+ import {
10
+ createEventDispatcher,
11
+ createFaceTracker
12
+ } from "@deibid/no-hands";
13
+ import {
14
+ useContext,
15
+ useEffect,
16
+ useMemo,
17
+ useRef,
18
+ useState,
19
+ createContext
20
+ } from "react";
21
+ import { jsx, jsxs } from "react/jsx-runtime";
22
+ var NHContext = createContext({
23
+ error: null,
24
+ eventDispatcher: null,
25
+ faceTracker: null,
26
+ loading: false,
27
+ videoStream: null
28
+ });
29
+ function NHProvider({ children, eventDispatcherOpts, faceTrackerOpts }) {
30
+ const [faceTracker, setFaceTracker] = useState(null);
31
+ const [eventDispatcher] = useState(
32
+ () => createEventDispatcher(eventDispatcherOpts)
33
+ );
34
+ const faceTrackerOptsRef = useRef(faceTrackerOpts);
35
+ const streamOwnerVideoRef = useRef(null);
36
+ const { error, loading, stream } = useCamera();
37
+ useEffect(() => {
38
+ faceTrackerOptsRef.current = faceTrackerOpts;
39
+ });
40
+ useEffect(() => {
41
+ const videoElement = streamOwnerVideoRef.current;
42
+ if (!videoElement || !stream) return;
43
+ let ftActive = true;
44
+ let localFaceTracker;
45
+ async function init() {
46
+ if (!videoElement) {
47
+ console.warn("has no video element");
48
+ return;
49
+ }
50
+ localFaceTracker = await createFaceTracker({
51
+ videoElement,
52
+ detectionBounds: faceTrackerOptsRef.current?.detectionBounds,
53
+ projectionBounds: faceTrackerOptsRef.current?.projectionBounds
54
+ });
55
+ if (!ftActive) {
56
+ localFaceTracker.stop();
57
+ return;
58
+ }
59
+ videoElement.srcObject = stream;
60
+ videoElement.addEventListener("loadeddata", () => {
61
+ localFaceTracker.subscribe((point) => {
62
+ eventDispatcher.computeEvents(point);
63
+ });
64
+ localFaceTracker.start();
65
+ setFaceTracker(localFaceTracker);
66
+ });
67
+ }
68
+ init();
69
+ return () => {
70
+ ftActive = false;
71
+ localFaceTracker?.stop();
72
+ };
73
+ }, [stream, eventDispatcher]);
74
+ useEffect(() => {
75
+ if (!faceTracker) return;
76
+ if (faceTrackerOpts?.detectionBounds) {
77
+ faceTracker.setDetectionBounds(faceTrackerOpts.detectionBounds);
78
+ }
79
+ if (faceTrackerOpts?.projectionBounds) {
80
+ faceTracker.setProjectionBounds(faceTrackerOpts.projectionBounds);
81
+ }
82
+ }, [faceTracker, faceTrackerOpts]);
83
+ useEffect(() => {
84
+ if (eventDispatcher && eventDispatcherOpts) {
85
+ eventDispatcher.setEventConfig(eventDispatcherOpts);
86
+ }
87
+ }, [eventDispatcher, eventDispatcherOpts]);
88
+ const contextValue = useMemo(
89
+ () => ({
90
+ error,
91
+ eventDispatcher,
92
+ faceTracker,
93
+ loading,
94
+ videoStream: stream
95
+ }),
96
+ [error, eventDispatcher, faceTracker, loading, stream]
97
+ );
98
+ return /* @__PURE__ */ jsxs(NHContext.Provider, { value: contextValue, children: [
99
+ children,
100
+ /* @__PURE__ */ jsx(
101
+ "video",
102
+ {
103
+ ref: streamOwnerVideoRef,
104
+ muted: true,
105
+ autoPlay: true,
106
+ style: {
107
+ opacity: 0,
108
+ position: "absolute",
109
+ top: 0,
110
+ left: 0,
111
+ zIndex: -999
112
+ }
113
+ }
114
+ )
115
+ ] });
116
+ }
117
+ function useNH() {
118
+ return useContext(NHContext);
119
+ }
120
+
121
+ // src/hooks.ts
122
+ function useEvents(props) {
123
+ const ref = useRef2(null);
124
+ useEffect2(() => {
125
+ if (!ref.current) {
126
+ return;
127
+ }
128
+ if (props.onNHMouseEnter) {
129
+ ref.current.addEventListener(NHEvent.MOUSE_ENTER, props.onNHMouseEnter);
130
+ }
131
+ if (props.onNHMouseLeave) {
132
+ ref.current.addEventListener(NHEvent.MOUSE_LEAVE, props.onNHMouseLeave);
133
+ }
134
+ if (props.onNHClickGestureBegin) {
135
+ ref.current.addEventListener(
136
+ NHEvent.CLICK_GESTURE_BEGIN,
137
+ props.onNHClickGestureBegin
138
+ );
139
+ }
140
+ if (props.onNHClickGestureEnd) {
141
+ ref.current.addEventListener(
142
+ NHEvent.CLICK_GESTURE_END,
143
+ props.onNHClickGestureEnd
144
+ );
145
+ }
146
+ return () => {
147
+ if (!ref.current) {
148
+ return;
149
+ }
150
+ if (props.onNHMouseEnter) {
151
+ ref.current.removeEventListener(
152
+ NHEvent.MOUSE_ENTER,
153
+ props.onNHMouseEnter
154
+ );
155
+ }
156
+ if (props.onNHMouseLeave) {
157
+ ref.current.removeEventListener(
158
+ NHEvent.MOUSE_LEAVE,
159
+ props.onNHMouseLeave
160
+ );
161
+ }
162
+ if (props.onNHClickGestureBegin) {
163
+ ref.current.removeEventListener(
164
+ NHEvent.CLICK_GESTURE_BEGIN,
165
+ props.onNHClickGestureBegin
166
+ );
167
+ }
168
+ if (props.onNHClickGestureEnd) {
169
+ ref.current.removeEventListener(
170
+ NHEvent.CLICK_GESTURE_END,
171
+ props.onNHClickGestureEnd
172
+ );
173
+ }
174
+ };
175
+ }, [
176
+ props.onNHClickGestureBegin,
177
+ props.onNHClickGestureEnd,
178
+ props.onNHMouseEnter,
179
+ props.onNHMouseLeave
180
+ ]);
181
+ return {
182
+ ref
183
+ };
184
+ }
185
+ function useCamera() {
186
+ const [stream, setStream] = useState2(null);
187
+ const [loading, setLoading] = useState2(true);
188
+ const [error, setError] = useState2(null);
189
+ useEffect2(() => {
190
+ let localStream = null;
191
+ let active = true;
192
+ async function getCamera() {
193
+ if (!navigator.mediaDevices?.getUserMedia) {
194
+ console.warn("device does not support camera");
195
+ return;
196
+ }
197
+ try {
198
+ const s = await navigator.mediaDevices.getUserMedia({
199
+ video: true
200
+ });
201
+ if (!active) {
202
+ s.getTracks().forEach((t) => t.stop());
203
+ return;
204
+ }
205
+ localStream = s;
206
+ setStream(s);
207
+ setLoading(false);
208
+ } catch (error2) {
209
+ console.error("could not initiate camera", error2);
210
+ setLoading(false);
211
+ setError(error2);
212
+ }
213
+ }
214
+ getCamera();
215
+ return () => {
216
+ active = false;
217
+ localStream?.getTracks().forEach((t) => t.stop());
218
+ };
219
+ }, []);
220
+ return {
221
+ stream,
222
+ loading,
223
+ error
224
+ };
225
+ }
226
+ function useNHPoint() {
227
+ const [point, setPoint] = useState2(null);
228
+ const { faceTracker } = useNH();
229
+ useEffect2(() => {
230
+ if (!faceTracker) return;
231
+ const subscriptionId = faceTracker.subscribe(setPoint);
232
+ return () => {
233
+ faceTracker.unsubscribe(subscriptionId);
234
+ };
235
+ }, [faceTracker]);
236
+ return point;
237
+ }
238
+ function useNHEventNotification({
239
+ onNHClickGestureBegin,
240
+ onNHClickGestureEnd,
241
+ onNHMouseEnter,
242
+ onNHMouseLeave
243
+ }) {
244
+ const { eventDispatcher } = useNH();
245
+ useEffect2(() => {
246
+ const unsubscribeFns = [];
247
+ if (eventDispatcher && onNHMouseEnter) {
248
+ const unsubscribe = eventDispatcher.onEvent(
249
+ NHEvent.MOUSE_ENTER,
250
+ onNHMouseEnter
251
+ );
252
+ unsubscribeFns.push(unsubscribe);
253
+ }
254
+ if (eventDispatcher && onNHMouseLeave) {
255
+ const unsubscribe = eventDispatcher.onEvent(
256
+ NHEvent.MOUSE_LEAVE,
257
+ onNHMouseLeave
258
+ );
259
+ unsubscribeFns.push(unsubscribe);
260
+ }
261
+ if (eventDispatcher && onNHClickGestureBegin) {
262
+ const unsubscribe = eventDispatcher.onEvent(
263
+ NHEvent.CLICK_GESTURE_BEGIN,
264
+ onNHClickGestureBegin
265
+ );
266
+ unsubscribeFns.push(unsubscribe);
267
+ }
268
+ if (eventDispatcher && onNHClickGestureEnd) {
269
+ const unsubscribe = eventDispatcher.onEvent(
270
+ NHEvent.CLICK_GESTURE_END,
271
+ onNHClickGestureEnd
272
+ );
273
+ unsubscribeFns.push(unsubscribe);
274
+ }
275
+ return () => {
276
+ unsubscribeFns.forEach((unsubscribe) => unsubscribe?.());
277
+ };
278
+ }, [
279
+ eventDispatcher,
280
+ onNHMouseEnter,
281
+ onNHMouseLeave,
282
+ onNHClickGestureBegin,
283
+ onNHClickGestureEnd
284
+ ]);
285
+ }
286
+ function useWindowSize() {
287
+ const computeWindowSize = (window2) => ({
288
+ width: window2.innerWidth,
289
+ height: window2.innerHeight,
290
+ centerX: window2.innerWidth / 2,
291
+ centerY: window2.innerHeight / 2
292
+ });
293
+ const [windowSize, setWindowSize] = useState2(computeWindowSize(window));
294
+ const handleWindowResize = useCallback(() => {
295
+ setWindowSize(computeWindowSize(window));
296
+ }, [computeWindowSize]);
297
+ useEffect2(() => {
298
+ window.addEventListener("resize", handleWindowResize);
299
+ return () => window.removeEventListener("resize", handleWindowResize);
300
+ }, [handleWindowResize]);
301
+ return windowSize;
302
+ }
303
+
304
+ // src/nhcomponent.ts
305
+ import { mergeRefs } from "react-merge-refs";
306
+ var htmlElementTags = [
307
+ "a",
308
+ "article",
309
+ "aside",
310
+ "audio",
311
+ "button",
312
+ "canvas",
313
+ "details",
314
+ "dialog",
315
+ "div",
316
+ "figure",
317
+ "footer",
318
+ "form",
319
+ "h1",
320
+ "h2",
321
+ "h3",
322
+ "h4",
323
+ "h5",
324
+ "h6",
325
+ "header",
326
+ "img",
327
+ "input",
328
+ "label",
329
+ "li",
330
+ "main",
331
+ "nav",
332
+ "ol",
333
+ "p",
334
+ "picture",
335
+ "section",
336
+ "select",
337
+ "span",
338
+ "summary",
339
+ "svg",
340
+ "table",
341
+ "td",
342
+ "textarea",
343
+ "th",
344
+ "ul",
345
+ "video"
346
+ ];
347
+ function NHComponentFactory(tag) {
348
+ return forwardRef(
349
+ (props, ref) => {
350
+ const {
351
+ onNHMouseEnter,
352
+ onNHMouseLeave,
353
+ onNHClickGestureBegin,
354
+ onNHClickGestureEnd,
355
+ ...htmlProps
356
+ } = props;
357
+ const { ref: nhRef } = useEvents({
358
+ onNHMouseEnter,
359
+ onNHMouseLeave,
360
+ onNHClickGestureBegin,
361
+ onNHClickGestureEnd
362
+ });
363
+ return React.createElement(tag, {
364
+ ...htmlProps,
365
+ ref: mergeRefs([ref, nhRef]),
366
+ "data-nh-component": "true"
367
+ });
368
+ }
369
+ );
370
+ }
371
+ var NH = htmlElementTags.reduce((result, tag) => {
372
+ const NHComponent = NHComponentFactory(tag);
373
+ NHComponent.displayName = `NH.${tag}`;
374
+ result[tag] = NHComponent;
375
+ return result;
376
+ }, {});
377
+
378
+ // src/detection-bounds-display.tsx
379
+ import { jsx as jsx2 } from "react/jsx-runtime";
380
+ function DetectionBoundsDisplay({
381
+ detectionBounds,
382
+ style
383
+ }) {
384
+ return /* @__PURE__ */ jsx2(
385
+ "div",
386
+ {
387
+ style: {
388
+ pointerEvents: "none",
389
+ position: "absolute",
390
+ width: detectionBounds.p2.x - detectionBounds.p1.x,
391
+ height: detectionBounds.p2.y - detectionBounds.p1.y,
392
+ opacity: 0.5,
393
+ zIndex: -1,
394
+ top: detectionBounds.p1.y,
395
+ left: detectionBounds.p1.x,
396
+ border: "1px solid black",
397
+ borderRadius: 12,
398
+ ...style
399
+ }
400
+ }
401
+ );
402
+ }
403
+
404
+ // src/pointer.tsx
405
+ import { useCallback as useCallback2, useState as useState3 } from "react";
406
+ import { jsx as jsx3 } from "react/jsx-runtime";
407
+ function Pointer({
408
+ children,
409
+ style
410
+ }) {
411
+ const point = useNHPoint();
412
+ const [, setColor] = useState3("red");
413
+ const handleNHClickGestureBegin = useCallback2(() => {
414
+ setColor((prevColor) => prevColor === "red" ? "blue" : "red");
415
+ }, []);
416
+ const handeNHClickGestureEnd = useCallback2(() => {
417
+ setColor("green");
418
+ }, []);
419
+ useNHEventNotification({
420
+ onNHClickGestureEnd: handeNHClickGestureEnd,
421
+ onNHClickGestureBegin: handleNHClickGestureBegin
422
+ });
423
+ return /* @__PURE__ */ jsx3(
424
+ "div",
425
+ {
426
+ style: {
427
+ pointerEvents: "none",
428
+ position: "absolute",
429
+ ...point === null && { visibility: "hidden" },
430
+ ...point !== null && { left: point.x, top: point.y },
431
+ ...style
432
+ // backgroundColor: color,
433
+ },
434
+ children: children ?? /* @__PURE__ */ jsx3(DefaultPointerIndicator, {})
435
+ }
436
+ );
437
+ }
438
+ function DefaultPointerIndicator() {
439
+ return /* @__PURE__ */ jsx3(
440
+ "div",
441
+ {
442
+ style: {
443
+ height: 24,
444
+ width: 24,
445
+ borderRadius: "50%",
446
+ transform: "translate(-50%, -50%)",
447
+ boxShadow: "rgba(0, 0, 0, 0.24) 0px 3px 8px",
448
+ background: "black"
449
+ }
450
+ }
451
+ );
452
+ }
453
+
454
+ // src/index.ts
455
+ import { NHEvent as NHEvent2 } from "@deibid/no-hands";
456
+ export {
457
+ DetectionBoundsDisplay,
458
+ NH,
459
+ NHEvent2 as NHEvent,
460
+ NHProvider,
461
+ Pointer,
462
+ useCamera,
463
+ useEvents,
464
+ useNH,
465
+ useNHPoint,
466
+ useWindowSize
467
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@deibid/no-hands-react",
3
+ "version": "0.0.1",
4
+ "description": "React adapter for @deibid/no-hands",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "test": "echo \"Error: no test specified\" && exit 1",
17
+ "build": "tsup"
18
+ },
19
+ "keywords": [],
20
+ "author": "",
21
+ "license": "ISC",
22
+ "packageManager": "pnpm@10.24.0",
23
+ "dependencies": {
24
+ "@deibid/no-hands": "workspace:*",
25
+ "react-merge-refs": "^3.0.2"
26
+ },
27
+ "peerDependencies": {
28
+ "react": "^18.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^19.2.7",
32
+ "react": "18.0.0"
33
+ }
34
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ createEventDispatcher,
3
+ createFaceTracker,
4
+ type EventDispatcher,
5
+ type FaceTracker,
6
+ } from "@deibid/no-hands";
7
+
8
+ import {
9
+ type PropsWithChildren,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ createContext
16
+ } from "react";
17
+
18
+ import { useCamera } from "../hooks";
19
+ import type { INHContext, NHProviderOptions } from "./types";
20
+
21
+
22
+ const NHContext = createContext<INHContext>({
23
+ error: null,
24
+ eventDispatcher: null,
25
+ faceTracker: null,
26
+ loading: false,
27
+ videoStream: null,
28
+ });
29
+
30
+
31
+ type NHProviderProps = PropsWithChildren<NHProviderOptions>;
32
+ export function NHProvider({ children, eventDispatcherOpts, faceTrackerOpts }: NHProviderProps) {
33
+
34
+ const [faceTracker, setFaceTracker] = useState<FaceTracker | null>(null);
35
+ const [eventDispatcher] = useState<EventDispatcher>(
36
+ () => createEventDispatcher(eventDispatcherOpts)
37
+ );
38
+ // needed to update the faceTracker without recreating it
39
+ const faceTrackerOptsRef = useRef<NHProviderOptions['faceTrackerOpts']>(faceTrackerOpts);
40
+ const streamOwnerVideoRef = useRef<HTMLVideoElement | null>(null);
41
+
42
+ const { error, loading, stream } = useCamera();
43
+
44
+ // keeps the latest value in the ref
45
+ useEffect(() => { faceTrackerOptsRef.current = faceTrackerOpts; });
46
+
47
+ // initialize and re-create face tracker if stream changes
48
+ useEffect(() => {
49
+ const videoElement = streamOwnerVideoRef.current;
50
+ if (!videoElement || !stream) return;
51
+
52
+ let ftActive = true;
53
+ let localFaceTracker: FaceTracker;
54
+
55
+ async function init() {
56
+ if (!videoElement) {
57
+ console.warn("has no video element");
58
+ return;
59
+ }
60
+
61
+ localFaceTracker = await createFaceTracker({
62
+ videoElement,
63
+ detectionBounds: faceTrackerOptsRef.current?.detectionBounds,
64
+ projectionBounds: faceTrackerOptsRef.current?.projectionBounds,
65
+ });
66
+
67
+ if (!ftActive) {
68
+ localFaceTracker.stop();
69
+ return;
70
+ }
71
+
72
+ videoElement.srcObject = stream;
73
+ videoElement.addEventListener("loadeddata", () => {
74
+ localFaceTracker.subscribe((point) => {
75
+ eventDispatcher.computeEvents(point);
76
+ });
77
+ localFaceTracker.start();
78
+ setFaceTracker(localFaceTracker);
79
+ });
80
+ }
81
+
82
+ init();
83
+
84
+ return () => {
85
+ ftActive = false;
86
+ localFaceTracker?.stop();
87
+ };
88
+ }, [stream, eventDispatcher]);
89
+
90
+ // update the face tracker bounds if they change from props
91
+ useEffect(() => {
92
+
93
+ if (!faceTracker) return;
94
+
95
+ if (faceTrackerOpts?.detectionBounds) {
96
+ faceTracker.setDetectionBounds(faceTrackerOpts.detectionBounds);
97
+ }
98
+
99
+ if (faceTrackerOpts?.projectionBounds) {
100
+ faceTracker.setProjectionBounds(faceTrackerOpts.projectionBounds);
101
+ }
102
+
103
+ }, [faceTracker, faceTrackerOpts]);
104
+
105
+
106
+ // update the event dispatcher config if the config changes
107
+ // this implementation assumes eventDispatcher to be stable (which it is)
108
+ useEffect(() => {
109
+ if (eventDispatcher && eventDispatcherOpts) {
110
+ eventDispatcher.setEventConfig(eventDispatcherOpts)
111
+ }
112
+ }, [eventDispatcher, eventDispatcherOpts]);
113
+
114
+
115
+ const contextValue: INHContext = useMemo(
116
+ () => ({
117
+ error,
118
+ eventDispatcher,
119
+ faceTracker,
120
+ loading,
121
+ videoStream: stream,
122
+ }),
123
+ [error, eventDispatcher, faceTracker, loading, stream]);
124
+
125
+ return (
126
+ <NHContext.Provider value={contextValue}>
127
+ {children}
128
+ {/* transparent video element that attaches to the video stream and face tracker */}
129
+ <video
130
+ ref={streamOwnerVideoRef}
131
+ muted
132
+ autoPlay
133
+ style={{
134
+ opacity: 0,
135
+ position: "absolute",
136
+ top: 0,
137
+ left: 0,
138
+ zIndex: -999,
139
+ }}
140
+ ></video>
141
+ </NHContext.Provider>
142
+ );
143
+ }
144
+
145
+ export function useNH() {
146
+ return useContext(NHContext);
147
+ }
@@ -0,0 +1 @@
1
+ export * from './context'
@@ -0,0 +1,22 @@
1
+ import type {
2
+ CreateEventDispatcherOpts,
3
+ EventDispatcher,
4
+ FaceTracker,
5
+ Rect
6
+ } from "@deibid/no-hands";
7
+
8
+ export interface INHContext {
9
+ error: Error | null;
10
+ eventDispatcher: EventDispatcher | null;
11
+ faceTracker: FaceTracker | null;
12
+ loading: boolean;
13
+ videoStream: MediaStream | null;
14
+ }
15
+
16
+ export interface NHProviderOptions {
17
+ faceTrackerOpts?: {
18
+ detectionBounds?: Rect;
19
+ projectionBounds?: Rect;
20
+ };
21
+ eventDispatcherOpts?: CreateEventDispatcherOpts;
22
+ }
@@ -0,0 +1,29 @@
1
+ import { Rect } from "@deibid/no-hands";
2
+
3
+ interface DetectionBoundsDisplayProps {
4
+ detectionBounds: Rect;
5
+ style?: React.CSSProperties;
6
+ }
7
+
8
+ export default function DetectionBoundsDisplay({
9
+ detectionBounds,
10
+ style
11
+ }: DetectionBoundsDisplayProps) {
12
+ return (
13
+ <div
14
+ style={{
15
+ pointerEvents: "none",
16
+ position: "absolute",
17
+ width: detectionBounds.p2.x - detectionBounds.p1.x,
18
+ height: detectionBounds.p2.y - detectionBounds.p1.y,
19
+ opacity: 0.5,
20
+ zIndex: -1,
21
+ top: detectionBounds.p1.y,
22
+ left: detectionBounds.p1.x,
23
+ border: "1px solid black",
24
+ borderRadius: 12,
25
+ ...style
26
+ }}
27
+ />
28
+ );
29
+ }