11import { useCallback } from 'react'
2+ import { useSortable } from '@dnd-kit/sortable'
3+ import { twMerge } from 'tailwind-merge'
24import { CaretRight , ChatLeft } from '@icons'
35import { useStore , useChatStore } from '@stores'
46import type { TocItem as TocItemType } from '@types'
@@ -12,33 +14,55 @@ import {
1214import { scrollToHeading , buildNestedToc } from './utils'
1315import { useModal } from '@components/ui/ModalDrawer'
1416import AvatarStack from '@components/AvatarStack'
17+ import { MdDragIndicator } from 'react-icons/md'
1518
1619interface TocItemProps {
1720 item : TocItemType
1821 children : TocItemType [ ]
1922 variant : 'desktop' | 'mobile'
2023 onToggle : ( id : string ) => void
24+ // Optional DnD props - only used when draggable
25+ draggable ?: boolean
26+ activeId ?: string | null
27+ collapsedIds ?: Set < string >
2128}
2229
23- export function TocItem ( { item, children, variant, onToggle } : TocItemProps ) {
30+ export function TocItem ( {
31+ item,
32+ children,
33+ variant,
34+ onToggle,
35+ draggable = false ,
36+ activeId = null ,
37+ collapsedIds = new Set ( )
38+ } : TocItemProps ) {
39+ // Only use sortable when draggable
40+ const sortable = draggable
41+ ? useSortable ( { id : item . id , disabled : collapsedIds . has ( item . id ) } )
42+ : null
43+
2444 const { headingId } = useChatStore ( ( state ) => state . chatRoom )
2545 const {
2646 editor : { instance : editor }
2747 } = useStore ( ( state ) => state . settings )
2848
29- // Use the scroll spy store to determine if this heading is in focus
3049 const focusedHeadingId = useFocusedHeadingStore ( ( s ) => s . focusedHeadingId )
3150 const isFocused = focusedHeadingId === item . id
3251
3352 const presentUsers = usePresentUsers ( item . id )
3453 const [ , setActiveHeading ] = useActiveHeading ( )
3554 const unreadCount = useUnreadCount ( item . id )
3655 const { openChatroom } = useTocActions ( )
37- const { close : closeModal } = variant === 'mobile' ? useModal ( ) || { } : { }
56+ const modal = variant === 'mobile' ? useModal ( ) : null
3857
3958 const isActive = headingId === item . id
4059 const hasChildren = children . length > 0
4160
61+ // Drag state
62+ const isDragging = sortable ?. isDragging ?? false
63+ const isDescendantOfDragged = activeId ? collapsedIds . has ( item . id ) : false
64+ const isGhosted = isDragging || isDescendantOfDragged
65+
4266 const handleClick = useCallback (
4367 ( e : React . MouseEvent ) => {
4468 e . preventDefault ( )
@@ -48,10 +72,10 @@ export function TocItem({ item, children, variant, onToggle }: TocItemProps) {
4872 scrollToHeading ( editor , item . id , { openChatRoom : variant === 'desktop' } )
4973
5074 if ( variant === 'mobile' ) {
51- closeModal ?.( )
75+ modal ?. close ?.( )
5276 }
5377 } ,
54- [ editor , item . id , variant , setActiveHeading , closeModal ]
78+ [ editor , item . id , variant , setActiveHeading , modal ]
5579 )
5680
5781 const handleToggle = useCallback (
@@ -69,28 +93,53 @@ export function TocItem({ item, children, variant, onToggle }: TocItemProps) {
6993 e . stopPropagation ( )
7094 openChatroom ( item . id , { focusEditor : variant === 'desktop' } )
7195 if ( variant === 'mobile' ) {
72- closeModal ?.( )
96+ modal ?. close ?.( )
7397 }
7498 } ,
75- [ item . id , variant , openChatroom , closeModal ]
99+ [ item . id , variant , openChatroom , modal ]
76100 )
77101
78102 if ( ! editor ) return null
79103
80104 const nestedChildren = buildNestedToc ( children )
81105
106+ // Build class names
107+ const liClassName = twMerge (
108+ 'toc__item relative w-full' ,
109+ ! item . open && 'closed' ,
110+ isFocused && 'focusSight' ,
111+ isGhosted && 'is-ghosted' ,
112+ isDragging && 'is-dragging'
113+ )
114+
115+ const aClassName = twMerge (
116+ 'group relative' ,
117+ isActive && 'active activeTocBorder bg-gray-300' ,
118+ draggable && 'has-drag-handle' ,
119+ variant === 'mobile' && '!py-2' ,
120+ variant === 'mobile' && item . level === 1 && 'ml-3'
121+ )
122+
82123 return (
83- < li
84- className = { `toc__item relative w-full ${ ! item . open ? 'closed' : '' } ${ isFocused ? 'focusSight' : '' } ` }
85- data-id = { item . id } >
86- < a
87- className = { `group relative ${ isActive ? 'active activeTocBorder bg-gray-300' : '' } ${ variant === 'mobile' ? '!py-2' : '' } ${ variant === 'mobile' && item . level === 1 ? 'ml-3' : '' } ` }
88- onClick = { handleClick }
89- href = { `?${ item . id } ` }
90- data-id = { item . id } >
124+ < li ref = { sortable ?. setNodeRef } className = { liClassName } data-id = { item . id } >
125+ < a className = { aClassName } onClick = { handleClick } href = { `?${ item . id } ` } data-id = { item . id } >
126+ { /* Level badge - visible during drag (desktop only) */ }
127+ { draggable && < span className = "toc-item-level" > H{ item . level } </ span > }
128+
129+ { /* Drag handle (desktop only) */ }
130+ { draggable && sortable && (
131+ < button
132+ type = "button"
133+ className = "toc-drag-handle"
134+ { ...sortable . attributes }
135+ { ...sortable . listeners } >
136+ < MdDragIndicator size = { 16 } />
137+ </ button >
138+ ) }
139+
91140 { /* Fold/Unfold button */ }
92141 < span
93- className = { ` btnFold tooltip tooltip-top ${ item . open ? 'opened' : 'closed' } ` }
142+ className = { twMerge ( ' btnFold tooltip tooltip-top' , item . open ? 'opened' : 'closed' ) }
94143 onClick = { handleToggle }
95144 data-tip = "Toggle" >
96145 < CaretRight size = { 17 } fill = "#363636" />
@@ -111,7 +160,10 @@ export function TocItem({ item, children, variant, onToggle }: TocItemProps) {
111160 </ div >
112161 ) }
113162 < ChatLeft
114- className = { `btnChat ${ unreadCount > 0 && 'hidden' } group-hover:fill-docsy cursor-pointer transition-all hover:fill-indigo-900` }
163+ className = { twMerge (
164+ 'btnChat group-hover:fill-docsy cursor-pointer transition-all hover:fill-indigo-900' ,
165+ unreadCount > 0 && 'hidden'
166+ ) }
115167 size = { 18 }
116168 />
117169 </ span >
@@ -123,7 +175,7 @@ export function TocItem({ item, children, variant, onToggle }: TocItemProps) {
123175 onClick = { handleChatClick }
124176 data-unread-count = { unreadCount > 0 ? unreadCount : '' } >
125177 < ChatLeft
126- className = { ` chatLeft fill-neutral-content ${ isActive && '!fill-accent' } ` }
178+ className = { twMerge ( ' chatLeft fill-neutral-content' , isActive && '!fill-accent' ) }
127179 size = { 14 }
128180 />
129181 </ span >
@@ -145,19 +197,21 @@ export function TocItem({ item, children, variant, onToggle }: TocItemProps) {
145197
146198 { /* Nested children */ }
147199 { hasChildren && (
148- < ul className = { ` childrenWrapper ${ ! item . open ? 'hidden' : '' } ` } >
200+ < ul className = { twMerge ( ' childrenWrapper' , ! item . open && 'hidden' ) } >
149201 { nestedChildren . map ( ( { item : childItem , children : grandChildren } ) => (
150202 < TocItem
151203 key = { childItem . id }
152204 item = { childItem }
153205 children = { grandChildren }
154206 variant = { variant }
155207 onToggle = { onToggle }
208+ draggable = { draggable }
209+ activeId = { activeId }
210+ collapsedIds = { collapsedIds }
156211 />
157212 ) ) }
158213 </ ul >
159214 ) }
160215 </ li >
161216 )
162217}
163-
0 commit comments