feat: add table of contents in dynamic activities

This commit is contained in:
swve 2025-05-25 21:20:55 +02:00
parent f712d68e28
commit 9bb5953959
5 changed files with 192 additions and 33 deletions

View file

@ -0,0 +1,29 @@
import Heading from '@tiptap/extension-heading'
// Custom Heading extension that adds IDs
export const CustomHeading = Heading.extend({
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: any }) {
const hasLevel = this.options.levels.includes(node.attrs.level)
const level = hasLevel ? node.attrs.level : this.options.levels[0]
// Generate ID from heading text
const headingText = node.textContent || ''
const slug = headingText
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
const id = slug ? `heading-${slug}` : `heading-${Math.random().toString(36).substr(2, 9)}`
return [
`h${level}`,
{
...HTMLAttributes,
id,
},
0,
]
},
})

View file

@ -33,12 +33,16 @@ import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
import TableOfContents from './TableOfContents'
import { CustomHeading } from './CustomHeadingExtenstion'
interface Editor {
content: string
activity: any
}
function Canva(props: Editor) {
/**
* Important Note : This is a workaround to enable user interaction features to be implemented easily, like text selection, AI features and other planned features, this is set to true but otherwise it should be set to false.
@ -59,6 +63,7 @@ function Canva(props: Editor) {
editable: isEditable,
extensions: [
StarterKit.configure({
heading: false,
bulletList: {
HTMLAttributes: {
class: 'bullet-list',
@ -70,6 +75,7 @@ function Canva(props: Editor) {
},
},
}),
CustomHeading,
NoTextInput,
// Custom Extensions
InfoCallout.configure({
@ -137,7 +143,10 @@ function Canva(props: Editor) {
<EditorOptionsProvider options={{ isEditable: false }}>
<CanvaWrapper>
<AICanvaToolkit activity={props.activity} editor={editor} />
<EditorContent editor={editor} />
<ContentWrapper>
<TableOfContents editor={editor} />
<EditorContent editor={editor} />
</ContentWrapper>
</CanvaWrapper>
</EditorOptionsProvider>
)
@ -146,33 +155,17 @@ function Canva(props: Editor) {
const CanvaWrapper = styled.div`
width: 100%;
margin: 0 auto;
`
.bubble-menu {
display: flex;
background-color: #0d0d0d;
padding: 0.2rem;
border-radius: 0.5rem;
button {
border: none;
background: none;
color: #fff;
font-size: 0.85rem;
font-weight: 500;
padding: 0 0.2rem;
opacity: 0.6;
&:hover,
&.is-active {
opacity: 1;
}
}
}
// disable chrome outline
const ContentWrapper = styled.div`
display: flex;
width: 100%;
height: 100%;
.ProseMirror {
// Workaround to disable editor from being edited by the user.
flex: 1;
padding: 1rem;
// disable chrome outline
caret-color: transparent;
h1 {

View file

@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import styled from 'styled-components'
import { Editor } from '@tiptap/react'
import { Check } from 'lucide-react'
interface TableOfContentsProps {
editor: Editor | null
}
interface HeadingItem {
level: number
text: string
id: string
}
const TableOfContents = ({ editor }: TableOfContentsProps) => {
const [headings, setHeadings] = useState<HeadingItem[]>([])
const [open, setOpen] = useState(true)
useEffect(() => {
if (!editor) return
const updateHeadings = () => {
const items: HeadingItem[] = []
editor.state.doc.descendants((node) => {
if (node.type.name.startsWith('heading')) {
const level = node.attrs.level || 1
const headingText = node.textContent || ''
// Create slug from heading text (same logic as CustomHeading in DynamicCanva)
const slug = headingText
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
const id = slug ? `heading-${slug}` : `heading-${Math.random().toString(36).substr(2, 9)}`
items.push({
level,
text: node.textContent,
id,
})
}
})
setHeadings(items)
}
editor.on('update', updateHeadings)
updateHeadings()
return () => {
editor.off('update', updateHeadings)
}
}, [editor])
if (headings.length === 0) return null
return (
<TOCCard>
<TOCList>
{headings.map((heading, index) => (
<TOCItem key={index} level={heading.level}>
<span className="toc-check"><Check size={15} strokeWidth={1.7} /></span>
<a className={`toc-link toc-link-h${heading.level}`} href={`#${heading.id}`}>{heading.text}</a>
</TOCItem>
))}
</TOCList>
</TOCCard>
)
}
const TOCCard = styled.div`
width: 20%;
background: none;
border: none;
box-shadow: none;
padding: 0;
margin: 0;
font-family: inherit;
display: flex;
flex-direction: column;
align-items: stretch;
height: fit-content;
`
const TOCList = styled.ul`
list-style: none !important;
padding: 0 !important;
margin: 0;
`
const TOCItem = styled.li<{ level: number }>`
margin: 0.5rem 0;
padding-left: ${({ level }) => `${(level - 1) * 1.2}rem`};
list-style: none !important;
display: flex;
align-items: flex-start;
gap: 0.5rem;
.toc-check {
display: flex;
align-items: center;
color: #23272f;
flex-shrink: 0;
margin-top: 0.1rem;
}
.toc-link {
color: #23272f;
text-decoration: none;
display: block;
font-size: ${({ level }) => (level === 1 ? '1rem' : level === 2 ? '0.97rem' : '0.95rem')};
font-weight: ${({ level }) => (level === 1 ? 500 : 400)};
line-height: 1.4;
padding: 0;
background: none;
border-radius: 0;
transition: none;
flex: 1;
min-width: 0;
word-break: break-word;
hyphens: auto;
&:hover {
color: #007acc;
}
}
`
export default TableOfContents