Skip to content

Commit 58f6b57

Browse files
authored
Refactoring Viewer's Context Menu (#686)
1 parent 737d622 commit 58f6b57

File tree

13 files changed

+562
-35
lines changed

13 files changed

+562
-35
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function ContextMenuContainer() {}

frontend/apps/ui/src/features/document/components/ContextMenu/index.tsx

Whitespace-only changes.

frontend/apps/ui/src/features/document/components/Viewer.tsx

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import {useAppDispatch, useAppSelector} from "@/app/hooks"
22
import {useCurrentDoc} from "@/features/document/hooks"
33
import {Flex, Group, Loader} from "@mantine/core"
4-
import {useContext, useEffect} from "react"
4+
import {useContext} from "react"
55
import {useNavigate} from "react-router-dom"
66

77
import Breadcrumbs from "@/components/Breadcrumbs"
88
import PanelContext from "@/contexts/PanelContext"
99

1010
import useGeneratePreviews from "@/features/document/hooks/useGeneratePreviews"
11-
import {useRef, useState} from "react"
11+
import {useRef} from "react"
1212

13-
import {HIDDEN} from "@/cconstants"
1413
import ActionButtons from "@/components/document/ActionButtons"
15-
import ContextMenu from "@/components/document/Contextmenu"
14+
import ContextMenu from "@/components/document/ContextMenu"
1615
import DocumentDetails from "@/components/document/DocumentDetails/DocumentDetails"
1716
import DocumentDetailsToggle from "@/components/document/DocumentDetailsToggle"
1817
import ThumbnailsToggle from "@/components/document/ThumbnailsToggle"
@@ -24,10 +23,10 @@ import {
2423
selectContentHeight,
2524
selectThumbnailsPanelOpen
2625
} from "@/features/ui/uiSlice"
27-
import type {Coord, NType, PanelMode} from "@/types"
28-
import {useDisclosure} from "@mantine/hooks"
26+
import type {NType, PanelMode} from "@/types"
2927
import {DOC_VER_PAGINATION_PAGE_BATCH_SIZE} from "../constants"
3028

29+
import useContextMenu from "@/features/document/hooks/useContextMenu"
3130
import PagesHaveChangedDialog from "./PageHaveChangedDialog"
3231
import PageList from "./PageList"
3332
import ThumbnailList from "./ThumbnailList"
@@ -48,23 +47,16 @@ export default function Viewer() {
4847
pageSize: DOC_VER_PAGINATION_PAGE_BATCH_SIZE,
4948
imageSize: "md"
5049
})
51-
52-
const [contextMenuPosition, setContextMenuPosition] = useState<Coord>(HIDDEN)
53-
const [opened, {open, close}] = useDisclosure()
50+
const {
51+
opened,
52+
options: {close},
53+
position
54+
} = useContextMenu({ref})
5455

5556
const thumbnailsIsOpen = useAppSelector(s =>
5657
selectThumbnailsPanelOpen(s, mode)
5758
)
5859

59-
const onContextMenu = (ev: MouseEvent) => {
60-
ev.preventDefault() // prevents default context menu
61-
62-
let new_y = ev.clientY
63-
let new_x = ev.clientX
64-
setContextMenuPosition({y: new_y, x: new_x})
65-
open()
66-
}
67-
6860
const onClick = (node: NType) => {
6961
if (mode == "secondary" && node.ctype == "folder") {
7062
dispatch(
@@ -82,19 +74,6 @@ export default function Viewer() {
8274
}
8375
}
8476

85-
useEffect(() => {
86-
// detect right click outside
87-
if (ref.current) {
88-
ref.current.addEventListener("contextmenu", onContextMenu)
89-
}
90-
91-
return () => {
92-
if (ref.current) {
93-
ref.current.removeEventListener("contextmenu", onContextMenu)
94-
}
95-
}
96-
}, [])
97-
9877
if (!doc) {
9978
return <Loader />
10079
}
@@ -108,13 +87,13 @@ export default function Viewer() {
10887
}
10988

11089
return (
111-
<div>
90+
<div ref={ref}>
11291
<ActionButtons doc={doc} isFetching={false} isError={false} />
11392
<Group justify="space-between">
11493
<Breadcrumbs breadcrumb={doc?.breadcrumb} onClick={onClick} />
11594
<DocumentDetailsToggle />
11695
</Group>
117-
<Flex ref={ref} className={classes.inner} style={{height: `${height}px`}}>
96+
<Flex className={classes.inner} style={{height: `${height}px`}}>
11897
{thumbnailsIsOpen && <ThumbnailList />}
11998
<ThumbnailsToggle />
12099
<PageList />
@@ -129,7 +108,7 @@ export default function Viewer() {
129108
isFetching={false}
130109
isError={false}
131110
opened={opened}
132-
position={contextMenuPosition}
111+
position={position}
133112
onChange={onContextMenuChange}
134113
/>
135114
</Flex>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {HIDDEN} from "@/cconstants"
2+
import type {Coord} from "@/types"
3+
import {useDisclosure} from "@mantine/hooks"
4+
import {RefObject, useCallback, useEffect, useState} from "react"
5+
6+
interface Args {
7+
ref: RefObject<HTMLDivElement | null>
8+
}
9+
10+
interface ReturnType {
11+
opened: boolean
12+
options: {
13+
open: () => void
14+
close: () => void
15+
}
16+
position: Coord
17+
}
18+
19+
export default function useContextMenu({ref}: Args): ReturnType {
20+
const [contextMenuPosition, setContextMenuPosition] = useState<Coord>(HIDDEN)
21+
const [opened, {open, close}] = useDisclosure()
22+
23+
const onContextMenu = useCallback(
24+
(ev: MouseEvent) => {
25+
ev.preventDefault() // prevents default context menu
26+
27+
let new_y = ev.clientY
28+
let new_x = ev.clientX
29+
setContextMenuPosition({y: new_y, x: new_x})
30+
open()
31+
},
32+
[open]
33+
)
34+
useEffect(() => {
35+
/* ref.current may be null when the hook runs,
36+
* so addEventListener won’t work unless we wait until the element
37+
* appears in the DOM. This code uses a MutationObserver to detect when
38+
* DOM nodes are added — and attaches the listener as soon
39+
* as ref.current becomes available.
40+
*/
41+
const observer = new MutationObserver(() => {
42+
const el = ref.current
43+
if (!el) return
44+
el.addEventListener("contextmenu", onContextMenu)
45+
observer.disconnect() // only once
46+
})
47+
48+
if (ref.current) {
49+
ref.current.addEventListener("contextmenu", onContextMenu)
50+
} else {
51+
// listen for entire document.body DOM changes
52+
observer.observe(document.body, {
53+
childList: true,
54+
subtree: true
55+
})
56+
}
57+
58+
return () => {
59+
const el = ref.current
60+
if (el) {
61+
el.removeEventListener("contextmenu", onContextMenu)
62+
}
63+
observer.disconnect()
64+
}
65+
}, [ref, onContextMenu])
66+
67+
useEffect(() => {
68+
const handleClick = () => {
69+
if (opened) {
70+
close()
71+
setContextMenuPosition(HIDDEN)
72+
}
73+
}
74+
75+
window.addEventListener("click", handleClick)
76+
return () => window.removeEventListener("click", handleClick)
77+
}, [opened, close])
78+
79+
return {
80+
opened,
81+
options: {open, close},
82+
position: contextMenuPosition
83+
}
84+
}

frontend/apps/viewer.dev/src/components/NavBar/NavBar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export default function Navbar() {
3232
<NavLink to="/extract-pages-modal" end>
3333
&lt;ExtractPagesModal /&gt;
3434
</NavLink>
35+
<NavLink to="/context-menu" end>
36+
&lt;ContextMenu /&gt;
37+
</NavLink>
3538
</Stack>
3639
</nav>
3740
)

frontend/apps/viewer.dev/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {createRoot} from "react-dom/client"
44
import {BrowserRouter, Route, Routes} from "react-router"
55
import AppShell from "./app/AppShell"
66
import "./index.css"
7+
import ContextMenuPage from "./pages/ContextMenu"
78
import DownloadButton from "./pages/DownloadButton"
89
import ExtractPagesModalPage from "./pages/ExtractPagesModal"
910
import OnePage from "./pages/OnePage"
@@ -38,6 +39,7 @@ createRoot(document.getElementById("root")!).render(
3839
path="extract-pages-modal"
3940
element={<ExtractPagesModalPage />}
4041
/>
42+
<Route path="context-menu" element={<ContextMenuPage />} />
4143
</Route>
4244
</Routes>
4345
</BrowserRouter>

0 commit comments

Comments
 (0)