Table of Content functionality
This commit is contained in:
@@ -85,13 +85,28 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-content h1,
|
.reading-content h1 {
|
||||||
.reading-content h2,
|
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||||
.reading-content h3,
|
}
|
||||||
.reading-content h4,
|
|
||||||
.reading-content h5,
|
.reading-content h2 {
|
||||||
|
@apply text-xl font-bold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h3 {
|
||||||
|
@apply text-lg font-semibold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h4 {
|
||||||
|
@apply text-base font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h5 {
|
||||||
|
@apply text-sm font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
.reading-content h6 {
|
.reading-content h6 {
|
||||||
@apply font-bold mt-8 mb-4 theme-header;
|
@apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-content p {
|
.reading-content p {
|
||||||
@@ -118,4 +133,54 @@
|
|||||||
.reading-content em {
|
.reading-content em {
|
||||||
@apply italic;
|
@apply italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Editor content styling - same as reading content but for the rich text editor */
|
||||||
|
.editor-content h1 {
|
||||||
|
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h2 {
|
||||||
|
@apply text-xl font-bold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h3 {
|
||||||
|
@apply text-lg font-semibold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h4 {
|
||||||
|
@apply text-base font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h5 {
|
||||||
|
@apply text-sm font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h6 {
|
||||||
|
@apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content p {
|
||||||
|
@apply mb-4 theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content blockquote {
|
||||||
|
@apply border-l-4 pl-4 italic my-6 theme-border theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content ul,
|
||||||
|
.editor-content ol {
|
||||||
|
@apply mb-4 pl-6 theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content li {
|
||||||
|
@apply mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content strong {
|
||||||
|
@apply font-semibold theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content em {
|
||||||
|
@apply italic;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import AppLayout from '../../../../components/layout/AppLayout';
|
|||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
import TagDisplay from '../../../../components/tags/TagDisplay';
|
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||||
|
import TableOfContents from '../../../../components/stories/TableOfContents';
|
||||||
import { calculateReadingTime } from '../../../../lib/settings';
|
import { calculateReadingTime } from '../../../../lib/settings';
|
||||||
|
|
||||||
export default function StoryDetailPage() {
|
export default function StoryDetailPage() {
|
||||||
@@ -366,6 +367,15 @@ export default function StoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Table of Contents */}
|
||||||
|
<TableOfContents
|
||||||
|
htmlContent={story.contentHtml || ''}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
// Scroll to the story reading view with the specific heading
|
||||||
|
window.location.href = `/stories/${story.id}#${item.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="theme-card theme-shadow rounded-lg p-4">
|
<div className="theme-card theme-shadow rounded-lg p-4">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
|||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
import StoryRating from '../../../components/stories/StoryRating';
|
import StoryRating from '../../../components/stories/StoryRating';
|
||||||
import TagDisplay from '../../../components/tags/TagDisplay';
|
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||||
|
import TableOfContents from '../../../components/stories/TableOfContents';
|
||||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||||
|
|
||||||
export default function StoryReadingPage() {
|
export default function StoryReadingPage() {
|
||||||
@@ -21,6 +22,8 @@ export default function StoryReadingPage() {
|
|||||||
const [readingProgress, setReadingProgress] = useState(0);
|
const [readingProgress, setReadingProgress] = useState(0);
|
||||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||||
|
const [showToc, setShowToc] = useState(false);
|
||||||
|
const [hasHeadings, setHasHeadings] = useState(false);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@@ -112,9 +115,20 @@ export default function StoryReadingPage() {
|
|||||||
|
|
||||||
setStory(storyData);
|
setStory(storyData);
|
||||||
|
|
||||||
// Sanitize story content
|
// Sanitize story content and add IDs to headings
|
||||||
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
||||||
setSanitizedContent(sanitized);
|
|
||||||
|
// Parse and add IDs to headings for TOC functionality
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(sanitized, 'text/html');
|
||||||
|
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
|
||||||
|
headings.forEach((heading, index) => {
|
||||||
|
heading.id = `heading-${index}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSanitizedContent(doc.body.innerHTML);
|
||||||
|
setHasHeadings(headings.length > 0);
|
||||||
|
|
||||||
// Load series stories if part of a series
|
// Load series stories if part of a series
|
||||||
if (storyData.seriesId) {
|
if (storyData.seriesId) {
|
||||||
@@ -134,12 +148,29 @@ export default function StoryReadingPage() {
|
|||||||
}
|
}
|
||||||
}, [storyId]);
|
}, [storyId]);
|
||||||
|
|
||||||
// Auto-scroll to saved reading position when story content is loaded
|
// Auto-scroll to saved reading position or URL hash when story content is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||||
// Use a small delay to ensure content is rendered
|
// Use a small delay to ensure content is rendered
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
||||||
|
|
||||||
|
// Check if there's a hash in the URL (for TOC navigation)
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash && hash.startsWith('heading-')) {
|
||||||
|
console.log('Auto-scrolling to heading from URL hash:', hash);
|
||||||
|
const element = document.getElementById(hash);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
setHasScrolledToPosition(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use saved reading position
|
||||||
if (story.readingPosition && story.readingPosition > 0) {
|
if (story.readingPosition && story.readingPosition > 0) {
|
||||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||||
scrollToCharacterPosition(story.readingPosition);
|
scrollToCharacterPosition(story.readingPosition);
|
||||||
@@ -266,6 +297,16 @@ export default function StoryReadingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{hasHeadings && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowToc(!showToc)}
|
||||||
|
className="text-sm theme-text hover:theme-accent transition-colors"
|
||||||
|
title="Table of Contents"
|
||||||
|
>
|
||||||
|
📋 TOC
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<StoryRating
|
<StoryRating
|
||||||
rating={story.rating || 0}
|
rating={story.rating || 0}
|
||||||
onRatingChange={handleRatingUpdate}
|
onRatingChange={handleRatingUpdate}
|
||||||
@@ -280,6 +321,35 @@ export default function StoryReadingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Table of Contents Modal */}
|
||||||
|
{showToc && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||||
|
onClick={() => setShowToc(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TOC Modal */}
|
||||||
|
<div className="fixed top-20 right-4 left-4 md:left-auto md:w-80 max-h-96 z-50">
|
||||||
|
<TableOfContents
|
||||||
|
htmlContent={sanitizedContent}
|
||||||
|
collapsible={false}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
const element = document.getElementById(item.id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
setShowToc(false); // Close TOC after navigation
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Story Content */}
|
{/* Story Content */}
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
<article data-reading-content>
|
<article data-reading-content>
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ export default function RichTextEditor({
|
|||||||
contentEditable
|
contentEditable
|
||||||
onInput={handleVisualContentChange}
|
onInput={handleVisualContentChange}
|
||||||
onPaste={handlePaste}
|
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}
|
suppressContentEditableWarning={true}
|
||||||
/>
|
/>
|
||||||
{!value && (
|
{!value && (
|
||||||
@@ -732,7 +732,7 @@ export default function RichTextEditor({
|
|||||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
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 }}
|
dangerouslySetInnerHTML={{ __html: value }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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