Skip to content

Commit dd25483

Browse files
committed
feat(toc): drag-and-drop for TOC, frist 🖐️
1 parent 54ad96f commit dd25483

File tree

14 files changed

+979
-47
lines changed

14 files changed

+979
-47
lines changed

bun.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/webapp/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"dependencies": {
2727
"@dicebear/collection": "^9.2.4",
2828
"@dicebear/core": "^9.2.4",
29+
"@dnd-kit/core": "^6.3.1",
30+
"@dnd-kit/modifiers": "^9.0.0",
31+
"@dnd-kit/sortable": "^10.0.0",
32+
"@dnd-kit/utilities": "^3.2.2",
2933
"@docs.plus/extension-hyperlink": "^3.0.0",
3034
"@docs.plus/extension-hypermultimedia": "^1.4.0",
3135
"@docs.plus/extension-indent": "^0.1.1",

packages/webapp/src/components/toc/TocDesktop.tsx

Lines changed: 93 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,38 @@
11
import React, { useRef, useState, useCallback } from 'react'
2-
import { useToc, useTocAutoScroll } from './hooks'
2+
import { DndContext, DragOverlay } from '@dnd-kit/core'
3+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
4+
import { MdAccountTree } from 'react-icons/md'
5+
6+
import { useToc, useTocAutoScroll, useTocDrag } from './hooks'
37
import { buildNestedToc } from './utils'
48
import { TocHeader } from './TocHeader'
59
import { TocItem } from './TocItem'
610
import { TocContextMenu } from './TocContextMenu'
711
import { ContextMenu } from '@components/ui/ContextMenu'
812
import AppendHeadingButton from '@components/pages/document/components/AppendHeadingButton'
13+
import { tocDragModifier, pointerYCollision, DropIndicatorPortal } from './dnd'
914

1015
interface TocDesktopProps {
1116
className?: string
1217
}
1318

1419
function removeContextMenuActiveClass() {
15-
const tocItems = document.querySelectorAll('.toc__item a.context-menu-active')
16-
tocItems.forEach((item) => {
20+
document.querySelectorAll('.toc__item a.context-menu-active').forEach((item) => {
1721
item.classList.remove('context-menu-active')
1822
})
1923
}
2024

2125
export function TocDesktop({ className = '' }: TocDesktopProps) {
2226
const { items, toggleSection } = useToc()
2327
const contextMenuRef = useRef<HTMLDivElement>(null)
28+
const overlayRef = useRef<HTMLDivElement>(null)
2429

25-
// Auto-scroll TOC when focused heading changes
2630
useTocAutoScroll()
31+
32+
const { state, activeItem, flatItems, sensors, handlers } = useTocDrag(items)
33+
const { activeId, projectedLevel, originalLevel, collapsedIds, descendantCount, dropTarget } =
34+
state
35+
2736
const [contextMenuState, setContextMenuState] = useState<{
2837
headingId: string | null
2938
isOpen: boolean
@@ -42,8 +51,6 @@ export function TocDesktop({ className = '' }: TocDesktopProps) {
4251
const isOpen = !tocItem.classList.contains('closed')
4352

4453
setContextMenuState({ headingId: tocId, isOpen })
45-
46-
// Highlight the toc item
4754
tocItem.querySelector(`a[data-id="${tocId}"]`)?.classList.add('context-menu-active')
4855

4956
return tocItem
@@ -67,28 +74,87 @@ export function TocDesktop({ className = '' }: TocDesktopProps) {
6774
return (
6875
<div className={className} style={{ scrollbarGutter: 'stable' }} ref={contextMenuRef}>
6976
<TocHeader variant="desktop" />
70-
<ul className="toc__list menu w-full p-0">
71-
<ContextMenu
72-
className="menu bg-base-100 absolute z-20 m-0 rounded-md p-2 shadow-md outline-none"
73-
parrentRef={contextMenuRef}
74-
onBeforeShow={handleBeforeShow}
75-
onClose={handleContextMenuClose}>
76-
<TocContextMenu
77-
headingId={contextMenuState.headingId}
78-
isOpen={contextMenuState.isOpen}
79-
onToggle={toggleSection}
80-
/>
81-
</ContextMenu>
82-
{nestedItems.map(({ item, children }) => (
83-
<TocItem
84-
key={item.id}
85-
item={item}
86-
children={children}
87-
variant="desktop"
88-
onToggle={toggleSection}
77+
<DndContext
78+
sensors={sensors}
79+
collisionDetection={pointerYCollision}
80+
modifiers={[tocDragModifier]}
81+
{...handlers}>
82+
<SortableContext items={flatItems.map((f) => f.id)} strategy={verticalListSortingStrategy}>
83+
<ul className={`toc__list menu w-full p-0 ${activeId ? 'is-dragging' : ''}`}>
84+
<ContextMenu
85+
className="menu bg-base-100 absolute z-20 m-0 rounded-md p-2 shadow-md outline-none"
86+
parrentRef={contextMenuRef}
87+
onBeforeShow={handleBeforeShow}
88+
onClose={handleContextMenuClose}>
89+
<TocContextMenu
90+
headingId={contextMenuState.headingId}
91+
isOpen={contextMenuState.isOpen}
92+
onToggle={toggleSection}
93+
/>
94+
</ContextMenu>
95+
{nestedItems.map(({ item, children }) => (
96+
<TocItem
97+
key={item.id}
98+
item={item}
99+
children={children}
100+
variant="desktop"
101+
onToggle={toggleSection}
102+
draggable
103+
activeId={activeId}
104+
collapsedIds={collapsedIds}
105+
/>
106+
))}
107+
</ul>
108+
</SortableContext>
109+
110+
<DragOverlay dropAnimation={null} style={{ zIndex: 10000 }}>
111+
{activeItem && (
112+
<div className="toc-drag-wrapper">
113+
{/* Level picker */}
114+
<div className="toc-drag-levels">
115+
{Array.from({ length: 7 }, (_, i) => i + 1)
116+
.filter(
117+
(level) =>
118+
level >= Math.max(1, originalLevel - 3) &&
119+
level <= Math.min(10, originalLevel + 3)
120+
)
121+
.map((level) => (
122+
<span
123+
key={level}
124+
className={`toc-drag-level ${level === projectedLevel ? 'active' : ''} ${level === originalLevel ? 'original' : ''}`}>
125+
H{level}
126+
</span>
127+
))}
128+
</div>
129+
130+
{/* Drag card */}
131+
<div
132+
ref={overlayRef}
133+
className={`toc-drag-card ${descendantCount > 0 ? 'has-children' : ''}`}>
134+
{descendantCount > 0 && <MdAccountTree className="toc-tree-icon" size={14} />}
135+
<span className="toc__link wrap-anywhere">{activeItem.textContent}</span>
136+
{descendantCount > 0 && (
137+
<span className="toc-descendant-badge">+{descendantCount}</span>
138+
)}
139+
{descendantCount > 0 && (
140+
<>
141+
<div className="toc-stack-card toc-stack-card--back" />
142+
<div className="toc-stack-card toc-stack-card--mid" />
143+
</>
144+
)}
145+
</div>
146+
</div>
147+
)}
148+
</DragOverlay>
149+
150+
{activeId && dropTarget.id && dropTarget.position && dropTarget.rect && (
151+
<DropIndicatorPortal
152+
targetRect={dropTarget.rect}
153+
position={dropTarget.position}
154+
indentLevel={dropTarget.level - 1}
89155
/>
90-
))}
91-
</ul>
156+
)}
157+
</DndContext>
92158
<AppendHeadingButton className="mt-4" />
93159
</div>
94160
)

packages/webapp/src/components/toc/TocItem.tsx

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { useCallback } from 'react'
2+
import { useSortable } from '@dnd-kit/sortable'
3+
import { twMerge } from 'tailwind-merge'
24
import { CaretRight, ChatLeft } from '@icons'
35
import { useStore, useChatStore } from '@stores'
46
import type { TocItem as TocItemType } from '@types'
@@ -12,33 +14,55 @@ import {
1214
import { scrollToHeading, buildNestedToc } from './utils'
1315
import { useModal } from '@components/ui/ModalDrawer'
1416
import AvatarStack from '@components/AvatarStack'
17+
import { MdDragIndicator } from 'react-icons/md'
1518

1619
interface 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

Comments
 (0)