mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-18 20:09:25 +00:00
feat: add table of contents in dynamic activities
This commit is contained in:
parent
f712d68e28
commit
9bb5953959
5 changed files with 192 additions and 33 deletions
|
|
@ -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,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"@tiptap/core": "^2.11.7",
|
||||
"@tiptap/extension-bullet-list": "^2.11.7",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.11.7",
|
||||
"@tiptap/extension-heading": "^2.12.0",
|
||||
"@tiptap/extension-link": "^2.11.7",
|
||||
"@tiptap/extension-list-item": "^2.11.7",
|
||||
"@tiptap/extension-ordered-list": "^2.11.7",
|
||||
|
|
|
|||
19
apps/web/pnpm-lock.yaml
generated
19
apps/web/pnpm-lock.yaml
generated
|
|
@ -84,6 +84,9 @@ importers:
|
|||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@tiptap/extension-heading':
|
||||
specifier: ^2.12.0
|
||||
version: 2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
|
|
@ -1590,8 +1593,8 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-heading@2.11.7':
|
||||
resolution: {integrity: sha512-8kWh7y4Rd2fwxfWOhFFWncHdkDkMC1Z60yzIZWjIu72+6yQxvo8w3yeb7LI7jER4kffbMmadgcfhCHC/fkObBA==}
|
||||
'@tiptap/extension-heading@2.12.0':
|
||||
resolution: {integrity: sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
|
|
@ -3441,8 +3444,8 @@ packages:
|
|||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwind-merge@3.2.0:
|
||||
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
|
||||
tailwind-merge@3.3.0:
|
||||
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
|
|
@ -4860,7 +4863,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||
|
||||
'@tiptap/extension-heading@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
|
||||
'@tiptap/extension-heading@2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||
|
||||
|
|
@ -4980,7 +4983,7 @@ snapshots:
|
|||
'@tiptap/extension-dropcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-gapcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-hard-break': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-heading': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-heading': 2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-history': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-horizontal-rule': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-italic': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
|
|
@ -5499,7 +5502,7 @@ snapshots:
|
|||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
tailwind-merge: 3.2.0
|
||||
tailwind-merge: 3.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
|
@ -7021,7 +7024,7 @@ snapshots:
|
|||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwind-merge@3.2.0: {}
|
||||
tailwind-merge@3.3.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
|
||||
dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue