Skip to content

Commit b8843f3

Browse files
committed
feat(toc): drag-and-drop for TOC, Second 🖐️2️⃣
1 parent aee1ac5 commit b8843f3

File tree

19 files changed

+1088
-1194
lines changed

19 files changed

+1088
-1194
lines changed

packages/webapp/src/components/AvatarStack.tsx

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import React from 'react'
21
import { twMerge } from 'tailwind-merge'
3-
42
import { Avatar } from './ui/Avatar'
53

64
type StackUser = {
@@ -11,55 +9,58 @@ type StackUser = {
119
status?: string | null
1210
}
1311

12+
type TooltipPosition = 'tooltip-top' | 'tooltip-bottom' | 'tooltip-left' | 'tooltip-right'
13+
1414
type AvatarStackProps = {
1515
users?: StackUser[]
1616
size?: number
17-
tooltipPosition?: string
17+
tooltipPosition?: TooltipPosition
1818
showStatus?: boolean
1919
clickable?: boolean
2020
maxDisplay?: number
2121
className?: string
2222
}
2323

24-
const AvatarStack: React.FC<AvatarStackProps> = ({
24+
export function AvatarStack({
2525
users = [],
2626
size = 9,
2727
tooltipPosition = 'tooltip-bottom',
2828
showStatus = false,
2929
clickable = true,
3030
maxDisplay = 4,
3131
className
32-
}) => {
33-
const safeUsers = Array.isArray(users)
34-
? users.filter((user): user is StackUser => Boolean(user))
35-
: []
36-
const visibleUsers = safeUsers.slice(0, maxDisplay)
37-
const remainingUsers = Math.max(safeUsers.length - maxDisplay, 0)
32+
}: AvatarStackProps) {
33+
// Filter out null/undefined users
34+
const validUsers = users.filter(Boolean) as StackUser[]
35+
36+
// Ensure maxDisplay is a positive number
37+
const limit = Math.max(1, Number(maxDisplay) || 4)
38+
const visibleUsers = validUsers.slice(0, limit)
39+
const remainingCount = Math.max(0, validUsers.length - limit)
3840

39-
const groupClassName = twMerge('avatar-group -space-x-5', className)
40-
const tooltipClasses = tooltipPosition ? `tooltip ${tooltipPosition}` : undefined
4141
const sizeClass = `size-${size}`
4242

4343
return (
44-
<div className={groupClassName}>
45-
{visibleUsers.map((user, index) => (
44+
<div className={twMerge('avatar-group -space-x-5 !overflow-visible', className)}>
45+
{visibleUsers.map((user, idx) => (
4646
<Avatar
47-
key={user?.id ?? `avatar-${index}`}
48-
avatarUpdatedAt={user?.avatar_updated_at}
49-
className={twMerge('bg-gray-300 shadow-xl', sizeClass, tooltipClasses)}
50-
data-tip={user?.display_name || 'Anonymous'}
51-
id={user?.id}
52-
src={user?.avatar_url ?? undefined}
53-
alt={user?.display_name ?? undefined}
54-
status={showStatus ? user?.status ?? undefined : undefined}
47+
key={user.id ?? `avatar-${idx}`}
48+
id={user.id}
49+
src={user.avatar_url ?? undefined}
50+
alt={user.display_name ?? undefined}
51+
avatarUpdatedAt={user.avatar_updated_at}
52+
status={showStatus ? (user.status ?? undefined) : undefined}
5553
clickable={clickable}
54+
className={twMerge('bg-gray-300 shadow-xl', sizeClass)}
55+
tooltip={user.display_name || 'Anonymous'}
56+
tooltipPosition={tooltipPosition}
5657
/>
5758
))}
5859

59-
{remainingUsers > 0 && (
60+
{remainingCount > 0 && (
6061
<div className={twMerge('avatar avatar-placeholder border', sizeClass)}>
6162
<div className={twMerge('bg-neutral text-neutral-content text-sm', sizeClass)}>
62-
+{remainingUsers}
63+
+{remainingCount}
6364
</div>
6465
</div>
6566
)}

packages/webapp/src/components/TipTap/extentions/plugins/headingButtonsPlugin.ts

Lines changed: 43 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { copyToClipboard } from '../helper'
1414
import { CHAT_OPEN } from '@services/eventsHub'
1515
import { ChatLeftSVG, ArrowDownSVG } from '@icons'
1616
import { db } from '@db/headingCrinckleDB'
17+
import { useStore } from '@stores'
1718

1819
// Plugin-specific types
1920
interface HeadingBlock {
@@ -159,94 +160,74 @@ const appendButtonsDec = (doc: ProseMirrorNode, editor: TipTapEditor): Decoratio
159160
return DecorationSet.create(doc, decos)
160161
}
161162

162-
const handleHeadingToggle = (editor: TipTapEditor, { headingId }: EditorEventData): void => {
163-
// Safety check: ensure editor view is available
164-
if (!editor?.view?.dom) {
165-
console.warn('[headingButtonsPlugin] Editor view not available')
166-
return
167-
}
168-
169-
if (isProcessing) return
163+
const handleHeadingToggle = (_editor: TipTapEditor, { headingId }: EditorEventData): void => {
164+
if (isProcessing || !headingId) return
170165
isProcessing = true
171166

172-
const { tr } = editor.state
173-
const headingNodeEl = editor.view.dom.querySelector(
167+
// Get editor from store - safer than using passed reference
168+
const editor = useStore.getState().settings.editor.instance
169+
const headingNodeEl = document.querySelector(
174170
`.ProseMirror .heading[data-id="${headingId}"]`
175-
)
171+
) as HTMLElement | null
176172

177-
if (!headingNodeEl) {
173+
if (!editor?.view || !headingNodeEl) {
178174
isProcessing = false
179175
return
180176
}
181177

182-
let nodePos
183-
try {
184-
nodePos = editor.view.state.doc.resolve(editor.view.posAtDOM(headingNodeEl, 0))
185-
} catch {
186-
isProcessing = false
187-
return
188-
}
178+
const view = editor.view
179+
const tr = editor.state.tr
180+
const nodePos = view.state.doc.resolve(view.posAtDOM(headingNodeEl, 0))
181+
const currentNode = tr.doc.nodeAt(nodePos.pos)
189182

190-
if (!nodePos) {
183+
if (!currentNode) {
191184
isProcessing = false
192185
return
193186
}
194187

195-
// if (editor.isEditable) {
196-
const pos = nodePos.pos
197-
const currentNode = tr.doc.nodeAt(pos)
198-
// Mark as fold/unfold transaction - TOC will be updated via PubSub with delay
199188
tr.setMeta(TRANSACTION_META.FOLD_AND_UNFOLD, true)
200189

201190
const documentId = localStorage.getItem('docId')
202191
const headingMapString = localStorage.getItem('headingMap')
203192
const headingMap: HeadingState[] = headingMapString ? JSON.parse(headingMapString) : []
204-
const nodeState = headingMap.find((h: HeadingState) => h.headingId === headingId) || {
205-
crinkleOpen: true
206-
}
193+
const nodeState = headingMap.find((h) => h.headingId === headingId) || { crinkleOpen: true }
207194
const filterMode = document.body.classList.contains('filter-mode')
208-
let database = filterMode ? db.docFilter : db.meta
195+
const database = filterMode ? db.docFilter : db.meta
209196

210-
// In filter mode, avoid saving the heading map to prevent overwriting the primary heading filter.
211-
if (filterMode) {
212-
if (editor.view) {
213-
editor.view.dispatch(tr)
214-
dispatchToggleHeadingSection(headingNodeEl)
215-
}
197+
const dispatch = () => {
198+
view.dispatch(tr)
199+
dispatchToggleHeadingSection(headingNodeEl)
216200
isProcessing = false
201+
}
202+
203+
// In filter mode, skip saving to DB
204+
if (filterMode) {
205+
dispatch()
217206
return
218207
}
219208

220-
if (documentId && currentNode && headingId) {
221-
database
222-
.put({
223-
docId: documentId,
224-
headingId,
225-
crinkleOpen: !nodeState.crinkleOpen,
226-
level: currentNode.attrs.level
227-
})
228-
.then(() => {
229-
database.toArray().then((data: any[]) => {
230-
localStorage.setItem('headingMap', JSON.stringify(data))
231-
})
232-
// Safety check: editor view may be destroyed during async operation
233-
if (editor.view) {
234-
editor.view.dispatch(tr)
235-
dispatchToggleHeadingSection(headingNodeEl)
236-
}
237-
isProcessing = false
238-
})
239-
.catch((err: Error) => {
240-
console.error(err)
241-
isProcessing = false
242-
})
243-
} else {
244-
console.error('headingId is not defined')
209+
if (!documentId) {
245210
isProcessing = false
211+
return
246212
}
247-
// } else {
248-
// isProcessing = false
249-
// }
213+
214+
database
215+
.put({
216+
docId: documentId,
217+
headingId,
218+
crinkleOpen: !nodeState.crinkleOpen,
219+
level: currentNode.attrs.level
220+
})
221+
.then(() => {
222+
database.toArray().then((data: any[]) => {
223+
localStorage.setItem('headingMap', JSON.stringify(data))
224+
})
225+
dispatch()
226+
})
227+
.catch((err: Error) => {
228+
console.error(err)
229+
isProcessing = false
230+
})
250231
}
251232

252233
/**

packages/webapp/src/components/pages/document/components/Toc.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const TOC = ({ className = '' }: { className?: string }) => {
1717

1818
return (
1919
<TocDesktop
20-
className={`${className} tiptap__toc h-full w-full overflow-hidden overflow-y-auto scroll-smooth pr-10 pb-4 hover:overscroll-contain sm:py-4 sm:pb-14`}
20+
className={`${className} tiptap__toc h-full w-full overflow-hidden overflow-y-auto scroll-smooth hover:overscroll-contain`}
2121
/>
2222
)
2323
}

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

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { MdAccountTree } from 'react-icons/md'
66
import { useToc, useTocAutoScroll, useTocDrag } from './hooks'
77
import { buildNestedToc } from './utils'
88
import { TocHeader } from './TocHeader'
9-
import { TocItem } from './TocItem'
9+
import { TocItemDesktop } from './TocItemDesktop'
1010
import { TocContextMenu } from './TocContextMenu'
1111
import { ContextMenu } from '@components/ui/ContextMenu'
1212
import AppendHeadingButton from '@components/pages/document/components/AppendHeadingButton'
@@ -30,8 +30,15 @@ export function TocDesktop({ className = '' }: TocDesktopProps) {
3030
useTocAutoScroll()
3131

3232
const { state, activeItem, flatItems, sensors, handlers } = useTocDrag(items)
33-
const { activeId, projectedLevel, originalLevel, collapsedIds, descendantCount, dropTarget } =
34-
state
33+
const {
34+
activeId,
35+
projectedLevel,
36+
originalLevel,
37+
collapsedIds,
38+
descendantCount,
39+
dropTarget,
40+
sourceRect
41+
} = state
3542

3643
const [contextMenuState, setContextMenuState] = useState<{
3744
headingId: string | null
@@ -93,13 +100,11 @@ export function TocDesktop({ className = '' }: TocDesktopProps) {
93100
/>
94101
</ContextMenu>
95102
{nestedItems.map(({ item, children }) => (
96-
<TocItem
103+
<TocItemDesktop
97104
key={item.id}
98105
item={item}
99-
children={children}
100-
variant="desktop"
106+
childItems={children}
101107
onToggle={toggleSection}
102-
draggable
103108
activeId={activeId}
104109
collapsedIds={collapsedIds}
105110
/>
@@ -127,20 +132,22 @@ export function TocDesktop({ className = '' }: TocDesktopProps) {
127132
))}
128133
</div>
129134

130-
{/* Drag card */}
135+
{/* Drag card - matches source element size */}
131136
<div
132137
ref={overlayRef}
133-
className={`toc-drag-card ${descendantCount > 0 ? 'has-children' : ''}`}>
138+
className="toc-drag-card"
139+
style={
140+
sourceRect
141+
? { width: sourceRect.width, minHeight: sourceRect.height, height: 'auto' }
142+
: undefined
143+
}>
134144
{descendantCount > 0 && <MdAccountTree className="toc-tree-icon" size={14} />}
135145
<span className="toc__link wrap-anywhere">{activeItem.textContent}</span>
136146
{descendantCount > 0 && (
137147
<span className="toc-descendant-badge">+{descendantCount}</span>
138148
)}
139149
{descendantCount > 0 && (
140-
<>
141-
<div className="toc-stack-card toc-stack-card--back" />
142-
<div className="toc-stack-card toc-stack-card--mid" />
143-
</>
150+
<div className="toc-stack-indicator" data-count={Math.min(descendantCount, 3)} />
144151
)}
145152
</div>
146153
</div>

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,17 @@ export function TocHeader({ variant }: TocHeaderProps) {
6060
}
6161

6262
return (
63-
<div className="relative border-b border-gray-300 pb-1">
63+
<div className="relative w-full border-b border-gray-300 pb-1">
6464
<div
65-
className={`group flex cursor-pointer items-center justify-between rounded-md p-1 px-2 pr-3 hover:bg-gray-300 hover:bg-opacity-50 ${isActive && 'activeTocBorder bg-gray-300'}`}
65+
className={`group hover:bg-opacity-50 flex cursor-pointer items-center justify-between gap-0.5 rounded-md p-1 px-2 pr-3 hover:bg-gray-300 ${isActive && 'activeTocBorder bg-gray-300'}`}
6666
onClick={handleClick}>
6767
<span className="text-lg font-bold">{docMetadata?.title}</span>
6868
<span
6969
className="btn_chat tooltip tooltip-top relative ml-auto"
7070
onClick={handleChatClick}
7171
data-tip="Chat Room">
7272
{unreadCount > 0 && (
73-
<div className="badge badge-docsy badge-sm bg-docsy border-docsy absolute top-1/2 z-[1] -translate-y-1/2 scale-90 border border-none text-white">
73+
<div className="badge badge-docsy badge-sm bg-docsy border-docsy scale-90 border border-none text-white">
7474
{unreadCount > 99 ? '99+' : unreadCount}
7575
</div>
7676
)}

0 commit comments

Comments
 (0)