Table of Content functionality

This commit is contained in:
Stefan Hardegger
2025-08-22 09:03:21 +02:00
parent a660056003
commit 15708b5ab2
5 changed files with 314 additions and 11 deletions

View File

@@ -694,7 +694,7 @@ export default function RichTextEditor({
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
className="editor-content p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
suppressContentEditableWarning={true}
/>
{!value && (
@@ -732,7 +732,7 @@ export default function RichTextEditor({
<h4 className="text-sm font-medium theme-header">Preview:</h4>
<div
ref={previewRef}
className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
className="editor-content p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
dangerouslySetInnerHTML={{ __html: value }}
/>
</div>

View File

@@ -0,0 +1,158 @@
'use client';
import { useState, useEffect } from 'react';
export interface TocItem {
id: string;
level: number;
title: string;
element?: HTMLElement;
}
interface TableOfContentsProps {
htmlContent: string;
className?: string;
collapsible?: boolean;
defaultCollapsed?: boolean;
onItemClick?: (item: TocItem) => void;
}
export default function TableOfContents({
htmlContent,
className = '',
collapsible = false,
defaultCollapsed = false,
onItemClick
}: TableOfContentsProps) {
const [tocItems, setTocItems] = useState<TocItem[]>([]);
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
useEffect(() => {
// Parse HTML content to extract headings
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
const items: TocItem[] = [];
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1));
let title = heading.textContent?.trim() || '';
const id = `heading-${index}`;
// Add ID to heading for later reference
heading.id = id;
// Handle empty headings with a fallback
if (!title) {
title = `Heading ${index + 1}`;
}
// Limit title length for display
if (title.length > 60) {
title = title.substring(0, 57) + '...';
}
items.push({
id,
level,
title,
element: heading as HTMLElement
});
});
setTocItems(items);
}, [htmlContent]);
const handleItemClick = (item: TocItem) => {
if (onItemClick) {
onItemClick(item);
} else {
// Default behavior: smooth scroll to heading
const element = document.getElementById(item.id);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
};
const getIndentClass = (level: number) => {
switch (level) {
case 1: return 'pl-0';
case 2: return 'pl-4';
case 3: return 'pl-8';
case 4: return 'pl-12';
case 5: return 'pl-16';
case 6: return 'pl-20';
default: return 'pl-0';
}
};
const getFontSizeClass = (level: number) => {
switch (level) {
case 1: return 'text-base font-semibold';
case 2: return 'text-sm font-medium';
case 3: return 'text-sm';
case 4: return 'text-xs';
case 5: return 'text-xs';
case 6: return 'text-xs';
default: return 'text-sm';
}
};
if (tocItems.length === 0) {
// Don't render anything if no headings are found
return null;
}
return (
<div className={`theme-card theme-shadow rounded-lg overflow-hidden ${className}`}>
{collapsible && (
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b theme-border flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<h3 className="font-semibold theme-header">Table of Contents</h3>
<span className="theme-text">
{isCollapsed ? '▼' : '▲'}
</span>
</button>
)}
{!collapsible && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b theme-border">
<h3 className="font-semibold theme-header">Table of Contents</h3>
</div>
)}
{(!collapsible || !isCollapsed) && (
<div className="p-4 max-h-96 overflow-y-auto">
<nav>
<ul className="space-y-1">
{tocItems.map((item) => (
<li key={item.id}>
<button
onClick={() => handleItemClick(item)}
className={`
w-full text-left py-1 px-2 rounded transition-colors
hover:bg-gray-100 dark:hover:bg-gray-700
theme-text hover:theme-accent
${getIndentClass(item.level)}
${getFontSizeClass(item.level)}
`}
title={item.title}
>
<span className="block truncate">{item.title}</span>
</button>
</li>
))}
</ul>
</nav>
</div>
)}
</div>
);
}