reactjs-tiptap-editor icon indicating copy to clipboard operation
reactjs-tiptap-editor copied to clipboard

How to get the content of TOC

Open condorheroblog opened this issue 10 months ago • 6 comments

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?

image

condorheroblog avatar Jan 08 '25 05:01 condorheroblog

you can check this https://github.com/hunghg255/reactjs-tiptap-editor/blob/main/src/extensions/TableOfContent/TableOfContent.ts

hunghg255 avatar Jan 08 '25 06:01 hunghg255

Should expose a method like this

image

condorheroblog avatar Jan 08 '25 06:01 condorheroblog

Should expose a method like this

image

look great!

hunghg255 avatar Jan 08 '25 07:01 hunghg255

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 avatar Jan 08 '25 07:01 condorheroblog

@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> )} </> ) } `

Image

mart11-22 avatar Feb 10 '25 03:02 mart11-22

Good job👍

For the convenience of users, invite you to submit a PR😁

condorheroblog avatar Feb 11 '25 02:02 condorheroblog