@ksp-gonogo/kerbcast-react 1.10.2 → 1.11.0-rc.10
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 +19 -4
- package/dist/CameraFeed.d.ts +21 -0
- package/dist/CameraFeed.js +172 -33
- package/dist/CommandBrackets.d.ts +110 -0
- package/dist/CommandBrackets.js +167 -0
- package/dist/KerbalFaceFeed.js +2 -2
- package/dist/hooks/useCommandDisplayUt.d.ts +22 -0
- package/dist/hooks/useCommandDisplayUt.js +104 -0
- package/dist/hooks/useDisplayedFrameUt.d.ts +11 -0
- package/dist/hooks/useDisplayedFrameUt.js +22 -0
- package/dist/hooks/useReportDisplaySize.d.ts +49 -3
- package/dist/hooks/useReportDisplaySize.js +187 -21
- package/dist/hooks/useTargetBracketsPreference.d.ts +6 -0
- package/dist/hooks/useTargetBracketsPreference.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/package.json +2 -2
- package/src/CameraFeed.test.tsx +538 -8
- package/src/CameraFeed.tsx +520 -242
- package/src/CommandBrackets.test.tsx +259 -0
- package/src/CommandBrackets.tsx +341 -0
- package/src/KerbalFaceFeed.tsx +2 -2
- package/src/hooks/useCommandDisplayUt.test.tsx +156 -0
- package/src/hooks/useCommandDisplayUt.ts +135 -0
- package/src/hooks/useDisplayedFrameUt.ts +26 -0
- package/src/hooks/useReportDisplaySize.loop.test.tsx +290 -0
- package/src/hooks/useReportDisplaySize.test.tsx +18 -9
- package/src/hooks/useReportDisplaySize.ts +232 -23
- package/src/hooks/useTargetBracketsPreference.ts +38 -0
- package/src/index.test.ts +2 -1
- package/src/index.ts +15 -0
|
@@ -1,19 +1,86 @@
|
|
|
1
|
-
import { useEffect, useId, useLayoutEffect, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { useKerbcastDisplaySizes } from "../context";
|
|
3
3
|
/*
|
|
4
4
|
* Debounce + bucket for self-measured display-size reporting. The first
|
|
5
5
|
* measurement after mount reports promptly (so a feed never sits at a stale
|
|
6
|
-
* resolution until it is resized); later churn is debounced.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* resolution until it is resized); later churn is debounced. Auto sets the
|
|
7
|
+
* resolution, never the shape: the request keeps the camera's configured
|
|
8
|
+
* aspect and is sized to the picture of that aspect that fits the box (the
|
|
9
|
+
* feed letterboxes with object-fit: contain). The width is ceiled to a
|
|
10
|
+
* BUCKET_PX multiple: that meets-the-minimum-need (never under-serve) and
|
|
11
|
+
* gives a material-change threshold so a feed re-reports only when a resize
|
|
12
|
+
* crosses a bucket boundary. The height follows at the aspect, rounded to
|
|
13
|
+
* even for H.264.
|
|
10
14
|
*/
|
|
11
15
|
const DEBOUNCE_MS = 200;
|
|
12
16
|
const BUCKET_PX = 16;
|
|
17
|
+
/*
|
|
18
|
+
* A resolution change must never cause another one. A box sized from the
|
|
19
|
+
* stream (a frame kept at the video's aspect, a `<video>` at its intrinsic
|
|
20
|
+
* size) would otherwise close a loop: the stream sets the box, the box sets
|
|
21
|
+
* the request, the request sets the stream. A stream that arrives smaller
|
|
22
|
+
* than asked (adaptive quality, a sidecar cap) would then shrink the next
|
|
23
|
+
* request, and so on down.
|
|
24
|
+
*
|
|
25
|
+
* FOLLOW_MS: a box change that starts this soon after the stream changed
|
|
26
|
+
* shape, and matches what that change explains, is the stream's doing. It is
|
|
27
|
+
* adopted as the new baseline and not reported. Layout reacts to a stream
|
|
28
|
+
* change within a frame or two; this leaves room for a slow phone.
|
|
29
|
+
*
|
|
30
|
+
* SUSPECT_MS / HOLD_MS: the backstop for a follow the check above misses (a
|
|
31
|
+
* very late layout, or one that does not keep the stream's shape). A box
|
|
32
|
+
* change this close to a stream change may still be a loop, so only one such
|
|
33
|
+
* report is let through per HOLD_MS; further ones are refused. Box changes
|
|
34
|
+
* away from any stream change always report.
|
|
35
|
+
*/
|
|
36
|
+
const FOLLOW_MS = 1000;
|
|
37
|
+
const SUSPECT_MS = 2500;
|
|
38
|
+
const HOLD_MS = 5000;
|
|
39
|
+
const ASPECT_TOLERANCE = 0.06;
|
|
40
|
+
const MAGNITUDE_SLACK = 1.15;
|
|
13
41
|
function bucket(px) {
|
|
14
42
|
const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
|
|
15
43
|
return b < BUCKET_PX ? BUCKET_PX : b;
|
|
16
44
|
}
|
|
45
|
+
/** Shape of a camera whose configured capture size is not known. */
|
|
46
|
+
export const DEFAULT_FEED_ASPECT = 16 / 9;
|
|
47
|
+
/**
|
|
48
|
+
* Report size for a `width` x `height` box: the `aspect` picture that fits it,
|
|
49
|
+
* width bucketed. Never the box's own shape.
|
|
50
|
+
*/
|
|
51
|
+
export function displayReportSize(width, height, aspect = DEFAULT_FEED_ASPECT) {
|
|
52
|
+
const a = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_FEED_ASPECT;
|
|
53
|
+
const fit = width > 0 && height > 0 ? Math.min(width, height * a) : width;
|
|
54
|
+
const w = bucket(fit);
|
|
55
|
+
return { width: w, height: Math.max(2, Math.round(w / a / 2) * 2) };
|
|
56
|
+
}
|
|
57
|
+
function spread(a, b) {
|
|
58
|
+
return a > b ? a / b : b / a;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether a box going from `prev` to `box` is explained by `change`: the new
|
|
62
|
+
* box has the new stream's aspect, and it grew or shrank no more than the
|
|
63
|
+
* stream's own change in size or aspect accounts for. A layout that fits the
|
|
64
|
+
* stream into a fixed tile changes with the aspect; one that shows the video
|
|
65
|
+
* at its own size changes with the size. A box that jumps further (entering
|
|
66
|
+
* fullscreen at the same aspect) is a layout change.
|
|
67
|
+
*/
|
|
68
|
+
export function followsStream(prev, box, change) {
|
|
69
|
+
const { to, from } = change;
|
|
70
|
+
if (!(to.width > 0) || !(to.height > 0))
|
|
71
|
+
return false;
|
|
72
|
+
if (!(prev.width > 0) || !(prev.height > 0))
|
|
73
|
+
return false;
|
|
74
|
+
const aspect = to.width / to.height;
|
|
75
|
+
const heightFits = Math.abs(box.height - box.width / aspect) <= Math.max(2, ASPECT_TOLERANCE * box.height);
|
|
76
|
+
const widthFits = Math.abs(box.width - box.height * aspect) <= Math.max(2, ASPECT_TOLERANCE * box.width);
|
|
77
|
+
if (!heightFits && !widthFits)
|
|
78
|
+
return false;
|
|
79
|
+
if (!from)
|
|
80
|
+
return true;
|
|
81
|
+
const explained = Math.max(spread(to.width * to.height, from.width * from.height), spread(aspect, from.width / from.height));
|
|
82
|
+
return spread(box.width * box.height, prev.width * prev.height) <= explained * MAGNITUDE_SLACK;
|
|
83
|
+
}
|
|
17
84
|
/**
|
|
18
85
|
* Self-measure a feed element and drive the client's per-consumer
|
|
19
86
|
* `reportDisplaySize` (via the context display-size registry, which collapses
|
|
@@ -23,21 +90,81 @@ function bucket(px) {
|
|
|
23
90
|
* mount lifecycle: on unmount / `flightId` change / `enabled` flip it
|
|
24
91
|
* deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
|
|
25
92
|
* server-side clear.
|
|
93
|
+
*
|
|
94
|
+
* A box resize that follows a change in the incoming stream is not reported,
|
|
95
|
+
* so a layout sized from the stream cannot feed it back into its own request.
|
|
26
96
|
*/
|
|
27
|
-
export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
|
|
97
|
+
export function useReportDisplaySize(flightId, ref, { enabled = true, square = false, aspect = DEFAULT_FEED_ASPECT, videoRef, streamSize, } = {}) {
|
|
28
98
|
const displaySizes = useKerbcastDisplaySizes();
|
|
29
99
|
const instanceId = useId();
|
|
30
100
|
/*
|
|
31
|
-
* Track the
|
|
32
|
-
* swapped after the first render (conditional render, a feed that appears
|
|
33
|
-
* once a stream arrives) is observed. Checked after every commit of the
|
|
34
|
-
*
|
|
101
|
+
* Track the elements behind the refs as state so an element that mounts or
|
|
102
|
+
* is swapped after the first render (conditional render, a feed that appears
|
|
103
|
+
* once a stream arrives) is observed. Checked after every commit of the host
|
|
104
|
+
* component; the state only changes when the element does.
|
|
35
105
|
*/
|
|
36
106
|
const [el, setEl] = useState(null);
|
|
107
|
+
const [video, setVideo] = useState(null);
|
|
37
108
|
useLayoutEffect(() => {
|
|
38
109
|
if (ref.current !== el)
|
|
39
110
|
setEl(ref.current);
|
|
111
|
+
const v = videoRef?.current ?? null;
|
|
112
|
+
if (v !== video)
|
|
113
|
+
setVideo(v);
|
|
40
114
|
});
|
|
115
|
+
const changes = useRef([]);
|
|
116
|
+
const lastStream = useRef(null);
|
|
117
|
+
const lastBySource = useRef({ video: null, render: null });
|
|
118
|
+
/*
|
|
119
|
+
* Each source is compared with its own last value, so the video and the
|
|
120
|
+
* sidecar's render size reporting the same new shape at different moments
|
|
121
|
+
* are both noted. The first render size is only a baseline: a layout built
|
|
122
|
+
* from it was already in place when the first measurement went out.
|
|
123
|
+
*/
|
|
124
|
+
const noteStream = useCallback((source, width, height) => {
|
|
125
|
+
if (!(width > 0) || !(height > 0))
|
|
126
|
+
return;
|
|
127
|
+
const seen = lastBySource.current[source];
|
|
128
|
+
if (seen && seen.width === width && seen.height === height)
|
|
129
|
+
return;
|
|
130
|
+
const to = { width, height };
|
|
131
|
+
lastBySource.current[source] = to;
|
|
132
|
+
const from = lastStream.current;
|
|
133
|
+
lastStream.current = to;
|
|
134
|
+
if (!from && source === "render")
|
|
135
|
+
return;
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
changes.current = changes.current.filter((c) => now - c.at <= SUSPECT_MS + DEBOUNCE_MS);
|
|
138
|
+
changes.current.push({ at: now, from, to });
|
|
139
|
+
}, []);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!video)
|
|
142
|
+
return;
|
|
143
|
+
const sync = () => noteStream("video", video.videoWidth, video.videoHeight);
|
|
144
|
+
sync();
|
|
145
|
+
video.addEventListener("loadedmetadata", sync);
|
|
146
|
+
video.addEventListener("resize", sync);
|
|
147
|
+
return () => {
|
|
148
|
+
video.removeEventListener("loadedmetadata", sync);
|
|
149
|
+
video.removeEventListener("resize", sync);
|
|
150
|
+
};
|
|
151
|
+
}, [video, noteStream]);
|
|
152
|
+
/* A layout effect, so it is noted before the observer sees the box this commit lays out. */
|
|
153
|
+
const streamW = streamSize?.width ?? 0;
|
|
154
|
+
const streamH = streamSize?.height ?? 0;
|
|
155
|
+
useLayoutEffect(() => {
|
|
156
|
+
noteStream("render", streamW, streamH);
|
|
157
|
+
}, [streamW, streamH, noteStream]);
|
|
158
|
+
/*
|
|
159
|
+
* The aspect is read through a ref so the camera's configured size arriving
|
|
160
|
+
* after mount re-sizes the current request without a clear and re-register.
|
|
161
|
+
*/
|
|
162
|
+
const aspectRef = useRef(aspect);
|
|
163
|
+
const reapply = useRef(null);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
aspectRef.current = aspect;
|
|
166
|
+
reapply.current?.();
|
|
167
|
+
}, [aspect]);
|
|
41
168
|
useEffect(() => {
|
|
42
169
|
if (flightId === null || !enabled)
|
|
43
170
|
return;
|
|
@@ -47,32 +174,71 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
|
|
|
47
174
|
let lastW = -1;
|
|
48
175
|
let lastH = -1;
|
|
49
176
|
let firstReported = false;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
177
|
+
let baseline = null;
|
|
178
|
+
let lastSuspectAt = Number.NEGATIVE_INFINITY;
|
|
179
|
+
const sizeFor = (box) => {
|
|
180
|
+
if (!square)
|
|
181
|
+
return displayReportSize(box.width, box.height, aspectRef.current);
|
|
182
|
+
const side = bucket(Math.min(box.width, box.height));
|
|
183
|
+
return { width: side, height: side };
|
|
184
|
+
};
|
|
185
|
+
const send = ({ width, height }) => {
|
|
186
|
+
lastW = width;
|
|
187
|
+
lastH = height;
|
|
188
|
+
displaySizes.report(flightId, instanceId, width, height);
|
|
189
|
+
};
|
|
190
|
+
/*
|
|
191
|
+
* Judged when the debounce settles, so a stream change noted a little after
|
|
192
|
+
* the observer fired (a host that commits the stream and the box in a
|
|
193
|
+
* different order) still counts.
|
|
194
|
+
*/
|
|
195
|
+
const settle = (box, seenAt) => {
|
|
196
|
+
const prev = baseline;
|
|
197
|
+
baseline = box;
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const near = changes.current.filter((c) => c.at <= now && c.at >= seenAt - SUSPECT_MS);
|
|
200
|
+
if (prev && near.some((c) => c.at >= seenAt - FOLLOW_MS && followsStream(prev, box, c))) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const next = sizeFor(box);
|
|
204
|
+
if (next.width === lastW && next.height === lastH)
|
|
55
205
|
return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
206
|
+
if (near.length > 0) {
|
|
207
|
+
const held = now - lastSuspectAt < HOLD_MS;
|
|
208
|
+
lastSuspectAt = now;
|
|
209
|
+
if (held)
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
send(next);
|
|
59
213
|
};
|
|
60
214
|
const observer = new ResizeObserver((entries) => {
|
|
61
215
|
const entry = entries[0];
|
|
62
216
|
if (!entry)
|
|
63
217
|
return;
|
|
64
|
-
const { width, height
|
|
218
|
+
const box = { width: entry.contentRect.width, height: entry.contentRect.height };
|
|
65
219
|
if (!firstReported) {
|
|
66
220
|
firstReported = true;
|
|
67
|
-
|
|
221
|
+
baseline = box;
|
|
222
|
+
const next = sizeFor(box);
|
|
223
|
+
if (next.width !== lastW || next.height !== lastH)
|
|
224
|
+
send(next);
|
|
68
225
|
return;
|
|
69
226
|
}
|
|
227
|
+
const seenAt = Date.now();
|
|
70
228
|
if (timer !== null)
|
|
71
229
|
clearTimeout(timer);
|
|
72
|
-
timer = setTimeout(() =>
|
|
230
|
+
timer = setTimeout(() => settle(box, seenAt), DEBOUNCE_MS);
|
|
73
231
|
});
|
|
74
232
|
observer.observe(el);
|
|
233
|
+
reapply.current = () => {
|
|
234
|
+
if (!baseline)
|
|
235
|
+
return;
|
|
236
|
+
const next = sizeFor(baseline);
|
|
237
|
+
if (next.width !== lastW || next.height !== lastH)
|
|
238
|
+
send(next);
|
|
239
|
+
};
|
|
75
240
|
return () => {
|
|
241
|
+
reapply.current = null;
|
|
76
242
|
observer.disconnect();
|
|
77
243
|
if (timer !== null)
|
|
78
244
|
clearTimeout(timer);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether target brackets are shown for one command camera, remembered per
|
|
3
|
+
* camera in this browser. Defaults on. `key` is null for a feed with no
|
|
4
|
+
* command camera, which reads as on and ignores toggles.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useTargetBracketsPreference(key: string | null): [boolean, () => void];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
const STORAGE_PREFIX = "kerbcast:target-brackets:";
|
|
3
|
+
function readPreference(key) {
|
|
4
|
+
try {
|
|
5
|
+
return globalThis.localStorage?.getItem(STORAGE_PREFIX + key) !== "0";
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function writePreference(key, on) {
|
|
12
|
+
try {
|
|
13
|
+
if (on)
|
|
14
|
+
globalThis.localStorage?.removeItem(STORAGE_PREFIX + key);
|
|
15
|
+
else
|
|
16
|
+
globalThis.localStorage?.setItem(STORAGE_PREFIX + key, "0");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* Storage blocked or full: the toggle still works for this session. */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether target brackets are shown for one command camera, remembered per
|
|
24
|
+
* camera in this browser. Defaults on. `key` is null for a feed with no
|
|
25
|
+
* command camera, which reads as on and ignores toggles.
|
|
26
|
+
*/
|
|
27
|
+
export function useTargetBracketsPreference(key) {
|
|
28
|
+
const [overrides, setOverrides] = useState({});
|
|
29
|
+
const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
|
|
30
|
+
const on = key !== null && key in overrides ? overrides[key] : stored;
|
|
31
|
+
const toggle = useCallback(() => {
|
|
32
|
+
if (key === null)
|
|
33
|
+
return;
|
|
34
|
+
const next = !on;
|
|
35
|
+
writePreference(key, next);
|
|
36
|
+
setOverrides((o) => ({ ...o, [key]: next }));
|
|
37
|
+
}, [key, on]);
|
|
38
|
+
return [on, toggle];
|
|
39
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type { KerbcastProviderProps, KerbcastSubscriptions, KerbcastDisplaySizes
|
|
|
10
10
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
11
11
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
12
12
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
13
|
+
export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
|
|
13
14
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
14
15
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
15
16
|
export { useRecordings } from "./hooks/useRecordings";
|
|
@@ -22,6 +23,8 @@ export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
|
22
23
|
export type { CameraLifecycle } from "./lifecycle";
|
|
23
24
|
export { CameraFeed } from "./CameraFeed";
|
|
24
25
|
export type { CameraFeedHandle, CameraFeedProps, CameraStreamHook, FeedAction, } from "./CameraFeed";
|
|
26
|
+
export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
|
|
27
|
+
export type { BracketGeometry, BracketPoint, CommandBracketContext, CommandBracketCorner, CommandBracketCornerOverride, CommandBracketCornerOverrides, CommandBracketCornerStyle, CommandBracketOverlay, ContentRect, } from "./CommandBrackets";
|
|
25
28
|
export { FeedActionBar } from "./FeedActionBar";
|
|
26
29
|
export type { FeedActionBarEntry } from "./FeedActionBar";
|
|
27
30
|
export { KerbalFaceFeed } from "./KerbalFaceFeed";
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { KerbcastProvider, createClientSubscriptions, createClientDisplaySizes,
|
|
|
11
11
|
export { useKerbcastCameras } from "./hooks/useKerbcastCameras";
|
|
12
12
|
export { useKerbcastStream } from "./hooks/useKerbcastStream";
|
|
13
13
|
export { useKerbcastClock } from "./hooks/useKerbcastClock";
|
|
14
|
+
export { useDisplayedFrameUt } from "./hooks/useDisplayedFrameUt";
|
|
14
15
|
export { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
|
|
15
16
|
export { useReportDisplaySize } from "./hooks/useReportDisplaySize";
|
|
16
17
|
export { useRecordings } from "./hooks/useRecordings";
|
|
@@ -24,6 +25,8 @@ export { buildCameraLabeler } from "./cameraLabels";
|
|
|
24
25
|
export { getCameraLifecycle, isCameraDestroyed } from "./lifecycle";
|
|
25
26
|
// CameraFeed component
|
|
26
27
|
export { CameraFeed } from "./CameraFeed";
|
|
28
|
+
// Target brackets geometry and the overlay API for drawing on top of them
|
|
29
|
+
export { bracketGeometry, containedContentRect, COMMAND_BRACKET_COLOURS } from "./CommandBrackets";
|
|
27
30
|
// FeedActionBar: the shared action row (tracking, fullscreen, PiP, quality,
|
|
28
31
|
// close, REC) both CameraFeed and KerbalFaceFeed render, with the issue-#6
|
|
29
32
|
// overflow (⋮) engine.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast-react",
|
|
3
|
-
"version": "1.10
|
|
3
|
+
"version": "1.11.0-rc.10",
|
|
4
4
|
"description": "React components and hooks for kerbcast camera feeds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@ksp-gonogo/kerbcast": "^1.10
|
|
48
|
+
"@ksp-gonogo/kerbcast": "^1.11.0-rc.10"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^18 || ^19",
|