Skip to content

Commit ddd08fe

Browse files
committed
feat: add context menu functionality for table of contents with new hooks and components
- Implemented context menu in TOCDesktop for managing headings (open chat, toggle section, link, focus, delete). - Added ContextMenuItems component for context menu actions. - Introduced hooks for handling heading actions (open chatroom, toggle, link, focus, delete). - Enhanced global dialog for confirmation on heading deletion. - Updated styles for active context menu items.
1 parent 6091e9b commit ddd08fe

File tree

23 files changed

+596
-16
lines changed

23 files changed

+596
-16
lines changed

packages/webapp/components/TipTap/TipTap.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ const Editor = ({
199199
Hyperlink.configure({
200200
protocols: ['ftp', 'mailto'],
201201
hyperlinkOnPaste: false,
202+
autoHyperlink: true,
202203
popovers: {
203204
previewHyperlink: previewHyperlink,
204205
createHyperlink: createHyperlinkPopover

packages/webapp/components/TipTap/tableOfContents/RenderToc.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import usePresentUsers from './hooks/usePresentUsers'
88
import useActiveHeading from './hooks/useActiveHeading'
99
import useOpenChatContainer from './hooks/useOpenChatContainer'
1010
import useUnreadMessage from './hooks/useUnreadMessage'
11+
import useOpenChatroomHandler from './hooks/useOpenChatroomHandler'
1112

1213
export const RenderToc = ({ childItems, item, renderTocs }: any) => {
1314
const { headingId } = useChatStore((state) => state.chatRoom)

packages/webapp/components/TipTap/tableOfContents/TocDesktop.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
1-
import React, { useEffect, useState } from 'react'
1+
import React, { useEffect, useRef, useState } from 'react'
22
import { useChatStore } from '@stores'
33
import useHandelTocUpdate from './hooks/useHandelTocUpdate'
44
import { RenderTocs } from './RenderTocs'
55
import { DocTitleChatRoomDesktop } from './components/DocTitleChatRoom'
66
import AppendHeadingButton from '@components/pages/document/components/AppendHeadingButton'
7+
import { ContextMenu } from '@components/ui/ContextMenu'
8+
import ContextMenuItems from './components/ContextMenuItems'
9+
10+
const removeContextMenuActiveClass = () => {
11+
const tocItems = document.querySelectorAll('.toc__item a.context-menu-active')
12+
tocItems.forEach((item: Element) => {
13+
item.classList.remove('context-menu-active')
14+
})
15+
}
716

817
const TOCDesktop = ({ className }: any) => {
918
const { headingId } = useChatStore((state) => state.chatRoom)
1019
const [renderedTocs, setRenderedTocs] = useState([])
1120
const { items } = useHandelTocUpdate()
21+
const contextMenuRef = useRef<HTMLDivElement>(null)
22+
const [contextMenuState, setContextMenuState] = useState<{
23+
tocItem: Element | null
24+
}>({
25+
tocItem: null
26+
})
1227

1328
useEffect(() => {
1429
if (!items.length) return
@@ -17,6 +32,28 @@ const TOCDesktop = ({ className }: any) => {
1732
setRenderedTocs(tocs)
1833
}, [items, headingId])
1934

35+
const handleBeforeShow = (e: any) => {
36+
const tocItem = e.target.closest('.toc__item') ?? null
37+
if (!tocItem) return null
38+
setContextMenuState({ tocItem })
39+
40+
removeContextMenuActiveClass()
41+
42+
const tocId = tocItem.getAttribute('data-id')
43+
44+
// Add CSS class to highlight the toc item
45+
tocItem.querySelector(`a[data-id="${tocId}"]`)?.classList.add('context-menu-active')
46+
47+
return tocItem
48+
}
49+
const handleContextMenuClose = () => {
50+
setContextMenuState({
51+
tocItem: null
52+
})
53+
// Remove CSS class from the toc item
54+
// find all .toc__item and the remove the class
55+
removeContextMenuActiveClass()
56+
}
2057
if (!items.length)
2158
return (
2259
<div className={`${className}`} style={{ scrollbarGutter: 'stable' }}>
@@ -25,9 +62,18 @@ const TOCDesktop = ({ className }: any) => {
2562
)
2663

2764
return (
28-
<div className={`${className}`} style={{ scrollbarGutter: 'stable' }}>
65+
<div className={`${className}`} style={{ scrollbarGutter: 'stable' }} ref={contextMenuRef}>
2966
<DocTitleChatRoomDesktop className="my-1" />
30-
<ul className="toc__list menu p-0">{renderedTocs}</ul>
67+
<ul className="toc__list menu p-0">
68+
<ContextMenu
69+
className="menu bg-base-100 absolute z-20 m-0 rounded-md p-2 shadow-md outline-none"
70+
parrentRef={contextMenuRef}
71+
onBeforeShow={handleBeforeShow}
72+
onClose={handleContextMenuClose}>
73+
<ContextMenuItems tocItem={contextMenuState?.tocItem} />
74+
</ContextMenu>
75+
{renderedTocs}
76+
</ul>
3177
<AppendHeadingButton className="mt-4" />
3278
</div>
3379
)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {
2+
MdChat,
3+
MdOutlineUnfoldLess,
4+
MdCenterFocusStrong,
5+
MdLink,
6+
MdDelete,
7+
MdOutlineUnfoldMore
8+
} from 'react-icons/md'
9+
import useOpenChatroomHandler from '../hooks/useOpenChatroomHandler'
10+
import useToggleHeadingSectionHandler from '../hooks/ueToggleHeadingSectionHandler'
11+
import { MenuItem } from '@components/ui/ContextMenu'
12+
import { useContextMenuContext } from '@components/ui/ContextMenu'
13+
import useLinkHeadingSectionHandler from '../hooks/useLinkHeadingSectionHandler'
14+
import useFocusHeadingSectionHandler from '../hooks/useFocusHeadingSectionHandler'
15+
import useDeleteHeadingSectionHandler from '../hooks/useDeleteHeadingSectionHandler'
16+
import { MdOutlineInfo } from 'react-icons/md'
17+
import useInsertH1Handler from '../hooks/useInsertH1Handler'
18+
19+
const ContextMenuItems = ({ tocItem }: { tocItem: Element | null }) => {
20+
const headingId = tocItem?.getAttribute('data-id') ?? null
21+
const isOpen = tocItem?.classList.contains('closed') ? false : true
22+
const { setIsOpen } = useContextMenuContext()
23+
24+
// find closet heading
25+
const headingEl = document.querySelector(`.heading[data-id="${headingId}"]`)
26+
const headingLevel = headingEl?.getAttribute('level')
27+
28+
if (!headingId) return null
29+
30+
const openChatroomHandler = useOpenChatroomHandler(headingId)
31+
const toggleHeadingSectionHandler = useToggleHeadingSectionHandler(headingId, isOpen)
32+
const linkHeadingSectionHandler = useLinkHeadingSectionHandler(headingId)
33+
const focusHeadingSectionHandler = useFocusHeadingSectionHandler(headingId)
34+
const deleteHeadingSectionHandler = useDeleteHeadingSectionHandler(headingId)
35+
const insertH1Handler = useInsertH1Handler(headingId)
36+
37+
const tocButtonList = [
38+
{
39+
title: 'Chat Room',
40+
icon: <MdChat size={18} />,
41+
onClickFn: openChatroomHandler,
42+
display: true,
43+
className: 'text-docsy'
44+
},
45+
{
46+
title: `${isOpen ? 'Fold' : 'Unfold'} Section`,
47+
icon: isOpen ? <MdOutlineUnfoldLess size={18} /> : <MdOutlineUnfoldMore size={18} />,
48+
onClickFn: toggleHeadingSectionHandler,
49+
display: true,
50+
className: ''
51+
},
52+
{
53+
title: 'Focus Section',
54+
icon: <MdCenterFocusStrong size={18} />,
55+
onClickFn: focusHeadingSectionHandler,
56+
display: true,
57+
className: ''
58+
},
59+
{
60+
title: 'Link Section',
61+
icon: <MdLink size={18} />,
62+
onClickFn: linkHeadingSectionHandler,
63+
display: true,
64+
className: ''
65+
},
66+
{
67+
title: (
68+
<>
69+
Delete Section
70+
<span className="tooltip tooltip-right flex items-center gap-2">
71+
<span className="tooltip-content w-48 text-pretty">
72+
Delete this heading and all nested sub-headings beneath it
73+
</span>
74+
<MdOutlineInfo size={18} />
75+
</span>
76+
</>
77+
),
78+
icon: <MdDelete size={18} />,
79+
onClickFn: deleteHeadingSectionHandler,
80+
display: true,
81+
className: 'mt-1 border-t border-gray-300 pt-1 text-red-500'
82+
}
83+
]
84+
85+
const insertButtonList = [
86+
{
87+
title: 'Heading',
88+
icon: <span className="bg-docsy/80 rounded-md px-1 text-xs font-bold text-white">H1</span>,
89+
onClickFn: insertH1Handler,
90+
display: true,
91+
className: 'bg-base-300 my-1 rounded-md'
92+
},
93+
{
94+
title: 'Above',
95+
icon: (
96+
<span className="bg-docsy/80 rounded-md px-1 text-xs font-bold text-white">
97+
H{headingLevel}
98+
</span>
99+
),
100+
onClickFn: () => {
101+
// Handle add above action
102+
},
103+
display: true,
104+
className: ''
105+
},
106+
{
107+
title: 'Below',
108+
icon: (
109+
<span className="bg-docsy/80 rounded-md px-1 text-xs font-bold text-white">
110+
H{headingLevel}
111+
</span>
112+
),
113+
onClickFn: () => {
114+
// Handle add below action
115+
},
116+
display: true,
117+
className: ''
118+
}
119+
]
120+
121+
return (
122+
<>
123+
{tocButtonList
124+
.filter((button) => button.display)
125+
.map((button, index) => (
126+
<MenuItem
127+
key={index}
128+
className={button.className}
129+
onClick={(e) => {
130+
button.onClickFn()
131+
setIsOpen(false)
132+
}}>
133+
<a className="flex items-center gap-2">
134+
{button.icon}
135+
{button.title}
136+
</a>
137+
</MenuItem>
138+
))}
139+
<li className="mt-1 border-t border-gray-300 pt-1">
140+
<details>
141+
<summary className="">Insert</summary>
142+
<ul>
143+
{insertButtonList
144+
.filter((button) => button.display)
145+
.map((button, index) => (
146+
<MenuItem
147+
key={index}
148+
className={button.className}
149+
onClick={(e) => {
150+
button.onClickFn()
151+
setIsOpen(false)
152+
}}>
153+
<a className="flex items-center gap-2">
154+
{button.icon}
155+
{button.title}
156+
</a>
157+
</MenuItem>
158+
))}
159+
</ul>
160+
</details>
161+
</li>
162+
</>
163+
)
164+
}
165+
166+
export default ContextMenuItems
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useStore } from '@stores'
2+
import { ResolvedPos } from '@tiptap/pm/model'
3+
4+
type Props = {
5+
headingId: string
6+
}
7+
8+
export const DeleteHeadingDialogContnet = ({ headingId }: Props) => {
9+
const {
10+
closeDialog,
11+
settings: {
12+
editor: { instance: editor }
13+
}
14+
} = useStore()
15+
16+
const handleDeleteConfirm = async () => {
17+
closeDialog()
18+
// find the heading node
19+
const headingNodeElement = document.querySelector(`.heading[data-id="${headingId}"]`)
20+
if (!headingNodeElement) return
21+
// get the heading node pos
22+
const contentHeadingNodePos = editor?.view.posAtDOM(headingNodeElement, -4, 4)
23+
const contentHeadingNode = editor?.state.doc.nodeAt(contentHeadingNodePos as number)
24+
25+
if ((contentHeadingNodePos && contentHeadingNodePos === -1) || !contentHeadingNode) return
26+
27+
// find parent heading node
28+
const $pos = editor?.state.doc.resolve(contentHeadingNodePos as number) as ResolvedPos
29+
let parentHeadingPos: number | null = null
30+
let parentHeadingNode = null
31+
32+
// traverse up the document tree to find a parent with type "heading"
33+
for (let depth = $pos.depth; depth > 0; depth--) {
34+
const node = $pos.node(depth)
35+
if (node.type.name === 'heading') {
36+
parentHeadingPos = $pos.start(depth)
37+
parentHeadingNode = node
38+
break
39+
}
40+
}
41+
42+
// delete the entire parent heading node with all its content
43+
if (parentHeadingPos !== null && parentHeadingNode) {
44+
const tr = editor?.state.tr
45+
const startPos = parentHeadingPos - 4 // 4 is the offset of the heading node
46+
const endPos = parentHeadingPos + parentHeadingNode.nodeSize
47+
48+
tr?.delete(startPos, endPos)
49+
if (tr) {
50+
editor?.view.dispatch(tr)
51+
}
52+
}
53+
}
54+
55+
return (
56+
<div className="flex flex-col gap-3 p-4 pr-3 pb-3">
57+
<div>
58+
<p className="text-gray-600">Do you want to delete this heading section?</p>
59+
</div>
60+
<div className="flex justify-end gap-4">
61+
<button className="btn btn-ghost" onClick={closeDialog}>
62+
Cancel
63+
</button>
64+
<button className="btn btn-ghost" onClick={handleDeleteConfirm}>
65+
Delete
66+
</button>
67+
</div>
68+
</div>
69+
)
70+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useCallback } from 'react'
2+
import { toggleHeadingSection } from '../helper'
3+
4+
const useToggleHeadingSectionHandler = (tocId: string, isOpen: boolean) => {
5+
const toggleHeadingSectionHandler = useCallback(() => {
6+
if (!tocId) return
7+
8+
toggleHeadingSection({ id: tocId, open: isOpen })
9+
}, [tocId, isOpen])
10+
return toggleHeadingSectionHandler
11+
}
12+
13+
export default useToggleHeadingSectionHandler
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useStore } from '@stores'
2+
import { useCallback } from 'react'
3+
import { DeleteHeadingDialogContnet } from '../components/DeleteHeadingDialogContnet'
4+
5+
const useDeleteHeadingSectionHandler = (tocId: string) => {
6+
const { openDialog } = useStore()
7+
const deleteHeadingSectionHandler = useCallback(() => {
8+
if (!tocId) return
9+
// ask for confirmation
10+
openDialog(<DeleteHeadingDialogContnet headingId={tocId} />)
11+
}, [tocId])
12+
return deleteHeadingSectionHandler
13+
}
14+
15+
export default useDeleteHeadingSectionHandler

0 commit comments

Comments
 (0)