reactjs-tiptap-editor
reactjs-tiptap-editor copied to clipboard
How to get the content of TOC
I need the outline content to be on the left or right side of the editor, rather than inside the editor. Are there corresponding events exposed that I can use directly?
you can check this https://github.com/hunghg255/reactjs-tiptap-editor/blob/main/src/extensions/TableOfContent/TableOfContent.ts
Should expose a method like this
Should expose a method like this
![]()
look great!
I don't quite understand the logic of this extension, so I can't help you implement it, it's up to you. Thank you.
@condorheroblog This editor is built on top of Tiptap. You can create a custom hook to pull the ToC from the editor and then build a separate component to display it.
I built something similar for myself --
### useTableOfContents.ts (hook) `import { Editor } from '@tiptap/react' import { useCallback, useEffect, useState } from 'react'
export interface TableOfContentsItem { level: number text: string id: string }
export function useTableOfContents(editor: Editor | null) { const [items, setItems] = useState<TableOfContentsItem[]>([])
const getItems = useCallback(() => { if (!editor) return []
const headings: TableOfContentsItem[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
headings.push({
level: node.attrs.level,
text: node.textContent,
id: node.attrs.id || `heading-${pos}`
})
}
})
return headings
}, [editor])
useEffect(() => { if (!editor) return
const updateItems = () => {
setItems(getItems())
}
// Initial update
updateItems()
// Update on content change
editor.on('update', updateItems)
return () => {
editor.off('update', updateItems)
}
}, [editor, getItems])
return items } `
ToC (Component -- This is a floating component I created for testing but you should be able to edit it into whatever you want)
`import React, { useState } from 'react' import { BookMarked } from 'lucide-react' import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" import { useTableOfContents, TableOfContentsItem } from '@/hooks/useTableOfContents' import { Editor } from '@tiptap/react'
interface TableOfContentsProps { className?: string editor: Editor | null }
export function TableOfContents({ className, editor }: TableOfContentsProps) { const [expanded, setExpanded] = useState(true) const items = useTableOfContents(editor)
const handleItemClick = (id: string) => { const element = document.getElementById(id) element?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }
const renderHeadingItem = (item: TableOfContentsItem, index: number) => { const indentLevel = item.level - 1 const hasLeadingLine = item.level > 1
// Calculate padding based on a consistent progression
const getPadding = (level: number) => {
switch(level) {
case 1: return "pl-2" // Base padding
case 2: return "pl-8" // 32px
case 3: return "pl-12" // 48px
case 4: return "pl-16" // 64px
case 5: return "pl-20" // 80px
case 6: return "pl-24" // 96px
default: return "pl-2"
}
}
return (
<div key={index} className="relative">
{hasLeadingLine && (
<div
className="absolute left-2 top-0 bottom-0 border-l border-muted-foreground/20"
style={{
marginLeft: `${(indentLevel - 1) * 20}px`, // Adjusted to match new padding
}}
/>
)}
<button
onClick={() => handleItemClick(item.id)}
className={cn(
"relative block w-full text-left px-2 py-1 hover:bg-accent rounded-md transition-colors",
"text-sm text-muted-foreground hover:text-foreground",
{
"font-bold": item.level === 1,
[getPadding(item.level)]: true
}
)}
>
{hasLeadingLine && (
<div
className="absolute w-2 h-[1px] bg-muted-foreground/20"
style={{
left: `${(indentLevel - 1) * 20 + 8}px`, // Adjusted to match new padding
top: '50%',
}}
/>
)}
{item.text}
</button>
</div>
)
}
return ( <> {expanded ? ( <div className={cn( "w-64 bg-background shadow-lg transition-all duration-300 ease-in-out", "border border-border rounded-lg", className )}> <div className="p-4 flex items-center justify-between border-b"> <h2 className="text-lg font-semibold">Contents <Button variant="ghost" size="sm" onClick={() => setExpanded(false)} aria-label="Collapse table of contents" > <BookMarked className="h-4 w-4" /> </Button> <ScrollArea className="p-4" style={{ maxHeight: 'calc(100vh - 100px)' }}> <nav className="space-y-0.5"> {items.map((item, index) => renderHeadingItem(item, index))} </ScrollArea> ) : ( <Button variant="outline" size="sm" onClick={() => setExpanded(true)} aria-label="Expand table of contents" className="fixed bottom-4 right-4 shadow-md rounded-full p-3 bg-background z-50" > <BookMarked className="h-5 w-5" /> </Button> )} </> ) } `
Good job👍
For the convenience of users, invite you to submit a PR😁