@hrnec06/react_utils 1.6.0 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hrnec06/react_utils",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A debugger component for react.",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -0,0 +1,235 @@
1
+ import React, { Children, createContext, forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useState } from "react";
2
+ import CTXM from "./ContextMenu.types";
3
+ import { Optional, Vector2 } from "@hrnec06/util";
4
+ import useContextMenu, { ContextMenuContext, type ContextMenuContext as TContextMenuContext } from "./ContextMenuCtx";
5
+ import useDefaultValue from "../../hooks/useDefaultValue";
6
+ import { v4 as uuidv4} from 'uuid';
7
+ import { createPortal } from "react-dom";
8
+ import useListener from "../../hooks/useListener";
9
+ import clsx from "clsx";
10
+ import ContextMenuSection from "./ContextMenuSection";
11
+ import ContextMenuRoot from "./ContextMenuRoot";
12
+ import useSignal, { Signal } from "../../hooks/useSignal";
13
+
14
+ type InheritCallback = (parent: TContextMenuContext) => (CTXM.ContextMenuRoot | { parent: string, children: CTXM.ContextMenuRoot });
15
+
16
+ interface ContextMenuWrapperProps {
17
+ open: boolean,
18
+ position: Vector2,
19
+ menu: CTXM.ContextMenuRoot
20
+ }
21
+ function ContextMenuWrapper({
22
+ open,
23
+ position,
24
+ menu
25
+ }: ContextMenuWrapperProps)
26
+ {
27
+ if (!open)
28
+ return undefined;
29
+
30
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
31
+ e.stopPropagation();
32
+ e.preventDefault();
33
+ }
34
+
35
+ return createPortal((
36
+ <div
37
+ onMouseDownCapture={handleMouseDown}
38
+ onContextMenu={handleMouseDown}
39
+ className={clsx(
40
+ "fixed z-600 select-none"
41
+ )}
42
+ style={{
43
+ left: position[0],
44
+ top: position[1]
45
+ }}
46
+ >
47
+ <ContextMenuRoot menu={menu} />
48
+ </div>
49
+ ), document.body);
50
+ }
51
+
52
+
53
+ interface ContextMenuReference {
54
+ isOpen: boolean,
55
+ close: () => void,
56
+ open: (position: Vector2) => void,
57
+ }
58
+
59
+ interface ContextMenuProps extends React.HTMLProps<HTMLDivElement> {
60
+ $menu: CTXM.ContextMenuRoot,
61
+ $open?: boolean,
62
+ $id?: string,
63
+ $inherit?: boolean | InheritCallback | Array<string | { id: string, parent: string }>,
64
+ /**
65
+ * If disabled, context event is captured using useCTXMListener()
66
+ */
67
+ $autoGenerateWrapper?: boolean,
68
+ $onOpen?: () => void,
69
+ $onClose?: () => void,
70
+ $onSelect?: (id: string, owner: unknown) => void,
71
+ }
72
+ const ContextMenu = forwardRef<ContextMenuReference, ContextMenuProps>(({
73
+ $open,
74
+ $menu,
75
+ $id,
76
+ $inherit = false,
77
+ $autoGenerateWrapper = false,
78
+ $onOpen,
79
+ $onClose,
80
+ $onSelect,
81
+
82
+ ...props
83
+ }, ref) => {
84
+ const parent = useContextMenu();
85
+
86
+ const open = useSignal(false);
87
+ const [position, setPosition] = useState<Vector2>([0, 0]);
88
+
89
+ const assignedID = useDefaultValue(() => $id ?? uuidv4());
90
+
91
+ const finalMenu = useMemo(() => {
92
+ if ($inherit === false)
93
+ return $menu;
94
+
95
+ const menu: CTXM.ContextMenuRoot = [
96
+ ...$menu
97
+ ];
98
+
99
+ let current = parent;
100
+ while (current !== undefined)
101
+ {
102
+ if ($inherit === true)
103
+ {
104
+ menu.push(...current.menu);
105
+ }
106
+ else if (Array.isArray($inherit))
107
+ {
108
+ for (const query of $inherit)
109
+ {
110
+ if (query === current.id)
111
+ {
112
+ menu.push(...current.menu);
113
+ }
114
+ else if (typeof query === "object" && query.id === current.id)
115
+ {
116
+ menu.push([
117
+ {
118
+ id: `inherit-${current.id}.${query.id}`,
119
+ label: query.parent,
120
+ children: current.menu
121
+ }
122
+ ]);
123
+ }
124
+ }
125
+ }
126
+ else if (typeof $inherit === "function")
127
+ {
128
+ const result = $inherit(current);
129
+
130
+ if (Array.isArray(result))
131
+ {
132
+ menu.push(...result);
133
+ }
134
+ else
135
+ {
136
+ menu.push([
137
+ {
138
+ id: `inherit-${current.id}.callback`,
139
+ label: result.parent,
140
+ children: result.children
141
+ }
142
+ ]);
143
+ }
144
+ }
145
+
146
+ current = current?.parent;
147
+ }
148
+
149
+ return menu;
150
+ }, [$menu, parent, $inherit]);
151
+
152
+ useImperativeHandle(ref, () => ({
153
+ isOpen: false,
154
+ close: () => {},
155
+ open: () => {}
156
+ }), []);
157
+
158
+ useListener(document, 'mousedown', () => {
159
+ if (!open.value)
160
+ return;
161
+
162
+ open.value = false;
163
+ }, [open]);
164
+
165
+ const close = () => {
166
+ open.value = false;
167
+ }
168
+
169
+ const handleContextmenu = (e: React.MouseEvent<HTMLElement>) => {
170
+ if (e.isPropagationStopped())
171
+ return;
172
+
173
+ e.stopPropagation();
174
+ e.preventDefault();
175
+
176
+ open.value = true;
177
+ setPosition([e.clientX, e.clientY]);
178
+ }
179
+
180
+ return (
181
+ <ContextMenuContext.Provider
182
+ value={{
183
+ exposed: {
184
+ onContextMenu: handleContextmenu
185
+ },
186
+ parent: parent,
187
+ id: assignedID,
188
+ menu: $menu,
189
+ close: close
190
+ }}
191
+ >
192
+ <ContextCapture
193
+ autoGenerateWrapper={$autoGenerateWrapper}
194
+ handleContextMenu={handleContextmenu}
195
+
196
+ {...props}
197
+ >
198
+ {props.children}
199
+ </ContextCapture>
200
+
201
+ <ContextMenuWrapper
202
+ open={open.value}
203
+ position={position}
204
+ menu={finalMenu}
205
+ />
206
+ </ContextMenuContext.Provider>
207
+ )
208
+ });
209
+
210
+ interface ContextCaptureProps extends React.HTMLProps<HTMLDivElement> {
211
+ autoGenerateWrapper: boolean,
212
+ handleContextMenu: (event: React.MouseEvent<HTMLElement>) => void,
213
+ }
214
+ function ContextCapture({
215
+ autoGenerateWrapper,
216
+ handleContextMenu,
217
+ ...props
218
+ }: ContextCaptureProps)
219
+ {
220
+ if (!autoGenerateWrapper)
221
+ return props.children;
222
+
223
+ return (
224
+ <div
225
+ {...props}
226
+
227
+ onContextMenu={handleContextMenu}
228
+ >
229
+ {props.children}
230
+
231
+ </div>
232
+ );
233
+ }
234
+
235
+ export default ContextMenu;
@@ -0,0 +1,17 @@
1
+ import { Nullable } from "@hrnec06/util";
2
+
3
+ namespace CTXM {
4
+ export interface ContextMenuItem {
5
+ id: string,
6
+ label: string,
7
+ description?: string,
8
+ children?: ContextMenuRoot,
9
+ onClick?: () => void
10
+ }
11
+
12
+ export type ContextMenuSection = Array<Nullable<ContextMenuItem>>;
13
+
14
+ export type ContextMenuRoot = Array<Nullable<ContextMenuSection>>;
15
+ }
16
+
17
+ export default CTXM;
@@ -0,0 +1,33 @@
1
+ import { Optional } from "@hrnec06/util";
2
+ import { createContext, useContext } from "react";
3
+ import ContextError from "../../lib/errors/ContextError";
4
+ import CTXM from "./ContextMenu.types";
5
+
6
+ export interface ContextMenuContextExpose {
7
+ onContextMenu: (event: React.MouseEvent<HTMLElement>) => void
8
+ }
9
+
10
+ export interface ContextMenuContext {
11
+ exposed: ContextMenuContextExpose,
12
+ parent?: ContextMenuContext,
13
+ menu: CTXM.ContextMenuRoot,
14
+ id: string,
15
+ close: () => void
16
+ }
17
+
18
+ export const ContextMenuContext = createContext<Optional<ContextMenuContext>>(undefined);
19
+
20
+ export function useCTXMListener(): ContextMenuContextExpose['onContextMenu']
21
+ {
22
+ const ctx = useContextMenu();
23
+
24
+ if (!ctx)
25
+ throw new ContextError("CTXMListener");
26
+
27
+ return ctx.exposed.onContextMenu;
28
+ }
29
+
30
+ export default function useContextMenu()
31
+ {
32
+ return useContext(ContextMenuContext);
33
+ }
@@ -0,0 +1,147 @@
1
+ import { ChevronRightIcon } from "lucide-react";
2
+ import CTXM from "./ContextMenu.types";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import ContextMenuRoot from "./ContextMenuRoot";
5
+ import useTransition, { transitionDataAttributes } from "../../hooks/useTransition";
6
+ import clsx from "clsx";
7
+ import useSyncRef from "../../hooks/useSyncRef";
8
+ import { matchMouseEvent, MouseButton } from "@hrnec06/util";
9
+ import useContextMenu from "./ContextMenuCtx";
10
+ import ContextError from "../../lib/errors/ContextError";
11
+ import useDebounce from "../../hooks/useDebounce";
12
+
13
+ interface ContextMenuItemProps {
14
+ item: CTXM.ContextMenuItem
15
+ }
16
+ export default function ContextMenuItem({
17
+ item
18
+ }: ContextMenuItemProps)
19
+ {
20
+ const menu = useContextMenu();
21
+ if (!menu)
22
+ throw new ContextError("ContextMenu");
23
+
24
+ const [hover, setHover] = useState(false);
25
+ const expanded = useDebounce(hover && !!item.children?.length, !hover ? 200 : 300);
26
+
27
+ const isClickable = item.onClick !== undefined;
28
+ const isEnabled = isClickable || !!item.children?.length;
29
+
30
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
31
+ e.stopPropagation();
32
+
33
+ if (!isClickable || !matchMouseEvent(e.nativeEvent, MouseButton.Left)) return;
34
+
35
+ item.onClick?.();
36
+ menu.close();
37
+ }
38
+
39
+ const handleMouseOver = () => {
40
+ setHover(true);
41
+ }
42
+
43
+ const handleMouseOut = () => {
44
+ setHover(false);
45
+ }
46
+
47
+ return (
48
+ <div
49
+ onMouseEnter={handleMouseOver}
50
+ onMouseLeave={handleMouseOut}
51
+ role="menu"
52
+ className="relative"
53
+ >
54
+ <div
55
+ onClick={handleClick}
56
+ role="menuitem"
57
+ className={clsx(
58
+ "whitespace-nowrap px-1",
59
+ isEnabled && "cursor-pointer"
60
+ )}
61
+ >
62
+ <div
63
+ className={clsx(
64
+ "flex items-center rounded-sm py-0.5",
65
+ hover && isEnabled && "bg-ctxm-item-hover"
66
+ )}
67
+ >
68
+ <div
69
+ className={clsx(
70
+ "flex-1 text-sm py-0.5 px-6",
71
+ isEnabled ? clsx(
72
+ "text-ctxm-item-text",
73
+ hover && "text-ctxm-item-text-hover"
74
+ ) : "text-ctxm-item-disabled"
75
+ )}
76
+ >
77
+ {item.label}
78
+ </div>
79
+ <div
80
+ className="flex-2 pl-8 pr-1"
81
+ >
82
+ {item.description && (
83
+ <div
84
+ className={clsx(
85
+ "text-sm text-right",
86
+ isEnabled ? clsx(
87
+ "text-ctxm-item-description",
88
+ hover && "text-ctxm-item-text-hover"
89
+ ) : "text-ctxm-item-disabled",
90
+ )}
91
+ >
92
+ {item.description}
93
+ </div>
94
+ )}
95
+
96
+ {item.children && (
97
+ <ChevronRightIcon className="size-5 inline-block float-end text-ctxm-item-text-hover" />
98
+ )}
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ { item.children && (
104
+ <ContextMenuItemChildren
105
+ open={expanded && isEnabled}
106
+ children={item.children}
107
+ />
108
+ ) }
109
+ </div>
110
+ )
111
+ }
112
+
113
+ interface ContextMenuItemChildrenProps {
114
+ children: CTXM.ContextMenuRoot,
115
+ open: boolean
116
+ }
117
+ function ContextMenuItemChildren({ children, open }: ContextMenuItemChildrenProps)
118
+ {
119
+ const [localDivRef, setLocalDivRef] = useState<HTMLElement | null>(null)
120
+ const divRef = useRef<HTMLDivElement>(null);
121
+
122
+ const divRefSync = useSyncRef(
123
+ divRef,
124
+ setLocalDivRef
125
+ );
126
+
127
+ const [visible, transition] = useTransition(true, localDivRef, open);
128
+
129
+ if (!visible)
130
+ return undefined;
131
+
132
+ return (
133
+ <div
134
+ ref={divRefSync}
135
+ {...transitionDataAttributes(transition)}
136
+
137
+ className={clsx(
138
+ "absolute left-full -top-[5px] pl-1",
139
+ "transition duration-300 data-leave:duration-200 data-closed:opacity-0 data-leave:sm:scale-95 data-enter:ease-out data-leave:ease-in"
140
+ )}
141
+ >
142
+ <ContextMenuRoot
143
+ menu={children}
144
+ />
145
+ </div>
146
+ );
147
+ }
@@ -0,0 +1,28 @@
1
+ import clsx from "clsx"
2
+ import CTXM from "./ContextMenu.types"
3
+ import ContextMenuSection from "./ContextMenuSection"
4
+
5
+ interface ContextMenuRootProps {
6
+ menu: CTXM.ContextMenuRoot
7
+ }
8
+ export default function ContextMenuRoot({
9
+ menu
10
+ }: ContextMenuRootProps)
11
+ {
12
+ return (
13
+ <div
14
+ className={clsx(
15
+ "bg-ctxm-primary border border-ctxm-border shadow-lg shadow-ctxm-shadow rounded-md"
16
+ )}
17
+ >
18
+ {
19
+ menu.map((section, sectionIx) => (
20
+ section && <ContextMenuSection
21
+ key={sectionIx}
22
+ section={section}
23
+ />
24
+ ))
25
+ }
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,28 @@
1
+ import clsx from "clsx";
2
+ import CTXM from "./ContextMenu.types";
3
+ import ContextMenuItem from "./ContextMenuItem";
4
+
5
+ interface ContextMenuSectionProps {
6
+ section: CTXM.ContextMenuSection
7
+ }
8
+ export default function ContextMenuSection({
9
+ section
10
+ }: ContextMenuSectionProps)
11
+ {
12
+ return (
13
+ <div
14
+ className={clsx(
15
+ "border-b border-b-ctxm-border last:border-none py-1"
16
+ )}
17
+ >
18
+ {
19
+ section.map((item) => (
20
+ item && <ContextMenuItem
21
+ key={item.id}
22
+ item={item}
23
+ />
24
+ ))
25
+ }
26
+ </div>
27
+ )
28
+ }
@@ -1,4 +1,4 @@
1
- import { minmax, Nullable, Vector2 } from "@hrnec06/util";
1
+ import { matchMouseEvent, minmax, MouseButton, Nullable, Vector2 } from "@hrnec06/util";
2
2
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { createPortal } from 'react-dom';
4
4
  import {DebuggerContext} from "./DebuggerContext";
@@ -6,16 +6,49 @@ import clsx from "clsx";
6
6
  import ScrollBar from "./DebuggerScrollBar";
7
7
  import ParseValue from "./parser/DebugParser";
8
8
  import useKeyListener from "../../hooks/useKeyListener";
9
- import useUpdatedRef from "../../hooks/useUpdatedRef";
9
+ import useUpdatedRef from "../../hooks/useLatestRef";
10
10
  import ResizeableBox from "../ResizeableBox/ResizeableBox";
11
11
  import useNamespacedId from "../../hooks/useNamespacedId";
12
12
  import useLocalStorage from "../../hooks/useLocalStorage";
13
13
  import z from "zod";
14
+ import useDefaultValue from "../../hooks/useDefaultValue";
14
15
 
15
16
  const DEBUG_NAMESPACE = "debugger";
16
17
 
18
+ function useDebuggerID(_customID?: string): string
19
+ {
20
+ const customID = useDefaultValue(_customID);
21
+
22
+ if (customID)
23
+ return customID;
24
+
25
+ return useNamespacedId(DEBUG_NAMESPACE);
26
+ }
27
+
28
+ interface DebuggerLabelProps {
29
+ label: string
30
+ }
31
+ function DebuggerLabel({ label }: DebuggerLabelProps)
32
+ {
33
+ return (
34
+ <div
35
+ className={clsx(
36
+ "px-3 py-1",
37
+ "text font-semibold text-white bg-[#181818]"
38
+ )}
39
+ >
40
+ {label}
41
+ </div>
42
+ )
43
+ }
44
+
17
45
  interface DebugProps {
18
46
  value: unknown,
47
+ /**
48
+ * Assign a persistant ID if saved position breaks because of remounts.
49
+ */
50
+ id?: string,
51
+ label?: string,
19
52
  openPaths?: string[],
20
53
  excludePaths?: string[],
21
54
  size?: 'normal' | 'big' | 'tiny',
@@ -25,6 +58,8 @@ interface DebugProps {
25
58
  }
26
59
  function Debug({
27
60
  value,
61
+ id: customID,
62
+ label,
28
63
  openPaths,
29
64
  excludePaths,
30
65
  size = 'normal',
@@ -34,7 +69,7 @@ function Debug({
34
69
  }: DebugProps) {
35
70
  // Config
36
71
 
37
- const id = useNamespacedId(DEBUG_NAMESPACE);
72
+ const id = useDebuggerID(customID);
38
73
 
39
74
  const { LS_POS_KEY, LS_SIZE_KEY, LS_EXPAND_KEY } = useMemo(() => ({
40
75
  LS_POS_KEY: id + ".position",
@@ -146,6 +181,8 @@ function Debug({
146
181
 
147
182
  // Handlers
148
183
  const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
184
+ if (!matchMouseEvent(e.nativeEvent, MouseButton.Left)) return;
185
+
149
186
  e.preventDefault();
150
187
 
151
188
  setGrab({
@@ -213,7 +250,7 @@ function Debug({
213
250
  }
214
251
  }}
215
252
  >
216
- <div className="fixed pointer-events-none w-full h-full left-0 top-0 overflow-hidden z-999999">
253
+ <div className="fixed pointer-events-none w-full h-full left-0 top-0 overflow-hidden z-500">
217
254
  <div
218
255
  className={clsx(
219
256
  "absolute font-jetbrains pointer-events-auto",
@@ -235,25 +272,40 @@ function Debug({
235
272
  $onResize={handleResize}
236
273
  >
237
274
  <div
238
- onWheel={handleWheel}
239
- className="bg-[#1f1f1f] shadow-lg rounded-md border border-[#4b4b4b] w-full h-full overflow-hidden flex"
275
+ // onMouseDown={handleMouseDown}
276
+ className={clsx(
277
+ "flex flex-col w-full h-full",
278
+ "bg-[#1f1f1f] border border-[#4b4b4b] shadow-lg rounded-md"
279
+ )}
240
280
  >
281
+ {label && (
282
+ <DebuggerLabel
283
+ label={label}
284
+ />
285
+ )}
286
+
241
287
  <div
242
- ref={containerRef}
243
- onMouseDown={handleMouseDown}
244
- className="cursor-grab w-full"
245
- style={{
246
- transform: `translateY(${-(scrollProgress * scrollHeight)}px)`
247
- }}
288
+ onWheel={handleWheel}
289
+ className="w-full h-full overflow-hidden flex"
248
290
  >
249
- <div className="p-3 pl-5">
250
- <ParseValue value={value} />
291
+ <div
292
+ ref={containerRef}
293
+ onMouseDown={handleMouseDown}
294
+ className="cursor-grab w-full"
295
+ style={{
296
+ transform: `translateY(${-(scrollProgress * scrollHeight)}px)`
297
+ }}
298
+ >
299
+ <div className="p-3 pl-5">
300
+ <ParseValue value={value} />
301
+ </div>
251
302
  </div>
303
+ <ScrollBar
304
+ containerHeight={containerHeight}
305
+ scrollHeight={scrollHeight}
306
+ />
252
307
  </div>
253
- <ScrollBar
254
- containerHeight={containerHeight}
255
- scrollHeight={scrollHeight}
256
- />
308
+
257
309
  </div>
258
310
  </ResizeableBox>
259
311
  </div>
@@ -282,9 +282,9 @@ function useTerminal(
282
282
  // const history = useState<LogItem[]>(storage);
283
283
  const inputHistory = useState<string[]>([]);
284
284
 
285
- const terminal = useMemo(() => {
286
- return new Terminal(id, name, history, inputHistory, onInput);
287
- }, [name, history, onInput]);
285
+ const terminal = useMemo(() =>
286
+ new Terminal(id, name, history, inputHistory, onInput)
287
+ , [name, history, onInput]);
288
288
 
289
289
  // useUpdateEffect(() => {
290
290
  // setStorage(history[0]);