Skip to content

Commit 8756574

Browse files
authored
feat: refactor context menu to support sub menu (#122)
* add option list and favor it over context menu * replace context with option list * change the context menu usage * fix some bug in flipping * add copy as json * toolbar now no longer rely on useContextMenuProvider * fixing code smell * fixing Sonar error
1 parent c03ce67 commit 8756574

File tree

18 files changed

+534
-317
lines changed

18 files changed

+534
-317
lines changed

src/renderer/App.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ body {
1616

1717
--color-text: #000;
1818
--color-text-link: #1a64f4;
19-
--color-text-disabled: #555;
19+
--color-text-disabled: #aaa;
2020

2121
/* For Button */
2222
--color-surface-light: #ecf0f1;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import {
2+
PropsWithChildren,
3+
useCallback,
4+
useEffect,
5+
useRef,
6+
useState,
7+
} from 'react';
8+
9+
export default function AvoidOffscreen({ children }: PropsWithChildren) {
10+
const ref = useRef<HTMLDivElement>(null);
11+
const [computed, setComputed] = useState(false);
12+
const [offsetTop, setOffsetTop] = useState(0);
13+
const [flip, setFlip] = useState(false);
14+
15+
const computePosition = useCallback(() => {
16+
if (ref.current) {
17+
const bound = ref.current.getBoundingClientRect();
18+
const viewportWidth =
19+
window.innerWidth || document.documentElement.clientWidth;
20+
const viewportHeight =
21+
window.innerHeight || document.documentElement.clientHeight;
22+
23+
if (offsetTop === 0) {
24+
setOffsetTop(Math.min(0, viewportHeight - bound.bottom));
25+
}
26+
27+
if (bound.width === 0) return false;
28+
29+
if (!flip) {
30+
setFlip(bound.right > viewportWidth);
31+
}
32+
33+
setComputed(true);
34+
}
35+
}, [ref, setOffsetTop, setFlip, flip, offsetTop, setComputed]);
36+
37+
useEffect(() => {
38+
if (ref.current) {
39+
const resizeObserver = new ResizeObserver(() => {
40+
computePosition();
41+
});
42+
43+
resizeObserver.observe(ref.current);
44+
return () => resizeObserver.disconnect();
45+
}
46+
}, [ref, computePosition]);
47+
48+
return (
49+
<div
50+
ref={ref}
51+
style={{
52+
visibility: computed ? 'visible' : 'hidden',
53+
position: 'absolute',
54+
top: offsetTop,
55+
...(flip ? { right: '100%' } : { left: '100%' }),
56+
}}
57+
>
58+
{children}
59+
</div>
60+
);
61+
}

src/renderer/components/ContextMenu/AttachedContextMenu.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactElement, useEffect, useRef, useState } from 'react';
1+
import { ReactElement, useRef, useEffect, useState } from 'react';
22
import ContextMenu, { ContextMenuItemProps } from '.';
33

44
interface AttachedContextMenuProps {
@@ -39,16 +39,15 @@ export default function AttachedContextMenu({
3939
{activator({ isOpened: open })}
4040
</div>
4141
<ContextMenu
42-
status={{ open, x, y }}
4342
minWidth={minWidth}
43+
items={items}
44+
open={open}
45+
x={x}
46+
y={y}
4447
onClose={() => {
4548
setOpen(false);
4649
}}
47-
>
48-
{items.map((item, idx) => (
49-
<ContextMenu.Item {...item} key={idx} />
50-
))}
51-
</ContextMenu>
50+
/>
5251
</>
5352
);
5453
}
Lines changed: 64 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,68 @@
1-
import {
2-
CSSProperties,
3-
PropsWithChildren,
4-
ReactNode,
5-
createContext,
6-
useCallback,
7-
useContext,
8-
useEffect,
9-
useRef,
10-
useState,
11-
} from 'react';
12-
import styles from './styles.module.scss';
13-
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
14-
import { faCheck } from '@fortawesome/free-solid-svg-icons';
1+
import { useEffect } from 'react';
2+
import AvoidOffscreen from '../AvoidOffscreen';
3+
import OptionList from '../OptionList';
4+
import OptionListItem, {
5+
OptionListItemProps,
6+
} from '../OptionList/OptionListItem';
7+
import DropContainer from '../DropContainer';
158

16-
export interface ContextMenuItemProps {
17-
text: string;
18-
icon?: ReactNode;
19-
hotkey?: string;
20-
disabled?: boolean;
21-
destructive?: boolean;
22-
onClick?: () => void;
23-
separator?: boolean;
24-
tick?: boolean;
9+
export interface ContextMenuItemProps extends OptionListItemProps {
10+
children?: ContextMenuItemProps[];
2511
}
2612

27-
interface ContextMenuStatus {
13+
interface ContextMenuProps {
14+
items: ContextMenuItemProps[];
2815
x: number;
2916
y: number;
30-
open: boolean;
17+
open?: boolean;
18+
minWidth?: number;
19+
onClose?: () => void;
3120
}
3221

33-
const ContextMenuContext = createContext<{ handleClose: () => void }>({
34-
handleClose: () => {
35-
return;
36-
},
37-
});
22+
function renderArrayOfMenu(
23+
items: ContextMenuItemProps[],
24+
onClose?: () => void,
25+
minWidth?: number
26+
) {
27+
return (
28+
<DropContainer>
29+
<OptionList minWidth={minWidth}>
30+
{items.map((value, idx) => {
31+
const key = value.text + idx;
32+
const { children, onClick, ...props } = value;
33+
34+
const overrideOnClick = (e: React.MouseEvent) => {
35+
if (onClick) onClick(e);
36+
if (onClose) onClose();
37+
};
38+
39+
if (children && children.length > 0) {
40+
return (
41+
<OptionListItem {...props} key={key} onClick={overrideOnClick}>
42+
{renderArrayOfMenu(children, onClose)}
43+
</OptionListItem>
44+
);
45+
}
46+
return (
47+
<OptionListItem key={key} {...props} onClick={overrideOnClick} />
48+
);
49+
})}
50+
</OptionList>
51+
</DropContainer>
52+
);
53+
}
3854

3955
export default function ContextMenu({
40-
children,
41-
status,
56+
items,
57+
x,
58+
y,
4259
onClose,
60+
open,
4361
minWidth,
44-
}: PropsWithChildren<{
45-
status: ContextMenuStatus;
46-
onClose: () => void;
47-
minWidth?: number;
48-
}>) {
49-
const menuRef = useRef<HTMLDivElement>(null);
50-
const [menuWidth, setMenuWidth] = useState(0);
51-
const [menuHeight, setMenuHeight] = useState(0);
52-
62+
}: ContextMenuProps) {
5363
useEffect(() => {
5464
const onDocumentClicked = () => {
55-
onClose();
65+
if (onClose) onClose();
5666
};
5767

5868
document.addEventListener('mousedown', onDocumentClicked);
@@ -61,79 +71,16 @@ export default function ContextMenu({
6171
};
6272
}, [onClose]);
6373

64-
useEffect(() => {
65-
if (menuRef.current) {
66-
const { width, height } = menuRef.current.getBoundingClientRect();
67-
setMenuWidth(width);
68-
setMenuHeight(height);
69-
}
70-
}, [status.open]);
71-
72-
const viewportWidth =
73-
window.innerWidth || document.documentElement.clientWidth;
74-
const viewportHeight =
75-
window.innerHeight || document.documentElement.clientHeight;
76-
77-
const menuStyle: CSSProperties = {
78-
visibility: status.open ? 'visible' : 'hidden',
79-
top: Math.min(status.y, viewportHeight - menuHeight - 10),
80-
left: Math.min(status.x, viewportWidth - menuWidth - 10),
81-
minWidth,
82-
};
83-
84-
return (
85-
<ContextMenuContext.Provider value={{ handleClose: onClose }}>
86-
<div
87-
ref={menuRef}
88-
className={styles.contextMenu}
89-
style={menuStyle}
90-
onMouseDown={(e) => e.stopPropagation()}
91-
>
92-
<ul>{children}</ul>
93-
</div>
94-
</ContextMenuContext.Provider>
95-
);
96-
}
97-
98-
ContextMenu.Item = function ({
99-
text,
100-
onClick,
101-
icon,
102-
tick,
103-
disabled,
104-
destructive,
105-
separator,
106-
hotkey,
107-
}: ContextMenuItemProps) {
108-
const { handleClose } = useContext(ContextMenuContext);
109-
110-
const onMenuClicked = useCallback(() => {
111-
if (onClick) {
112-
onClick();
113-
}
114-
handleClose();
115-
}, [handleClose, onClick]);
116-
117-
return (
118-
<li
119-
onClick={!disabled ? onMenuClicked : undefined}
120-
className={[
121-
disabled ? styles.disabled : undefined,
122-
separator ? styles.separator : undefined,
123-
!disabled && destructive ? styles.destructive : undefined,
124-
]
125-
.filter(Boolean)
126-
.join(' ')}
74+
return open ? (
75+
<div
76+
style={{ position: 'fixed', zIndex: 90000, left: x, top: y }}
77+
onMouseDown={(e) => {
78+
e.stopPropagation();
79+
}}
12780
>
128-
<div className={styles.icon}>
129-
<span>{tick ? <FontAwesomeIcon icon={faCheck} /> : icon}</span>
130-
</div>
131-
<div className={styles.text}>
132-
<span>{text}</span>
133-
</div>
134-
<div className={styles.hotkey}>
135-
<span>{hotkey}</span>
136-
</div>
137-
</li>
138-
);
139-
};
81+
<AvoidOffscreen>
82+
{renderArrayOfMenu(items, onClose, minWidth)}
83+
</AvoidOffscreen>
84+
</div>
85+
) : null;
86+
}

src/renderer/components/ContextMenu/styles.module.scss

Lines changed: 0 additions & 97 deletions
This file was deleted.

0 commit comments

Comments
 (0)