Table of Content functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
158
frontend/src/components/stories/TableOfContents.tsx
Normal file
158
frontend/src/components/stories/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user