Table of Content functionality
This commit is contained in:
@@ -85,13 +85,28 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.reading-content h1,
|
||||
.reading-content h2,
|
||||
.reading-content h3,
|
||||
.reading-content h4,
|
||||
.reading-content h5,
|
||||
.reading-content h1 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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 {
|
||||
@@ -118,4 +133,54 @@
|
||||
.reading-content em {
|
||||
@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 LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||
import TableOfContents from '../../../../components/stories/TableOfContents';
|
||||
import { calculateReadingTime } from '../../../../lib/settings';
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
@@ -366,6 +367,15 @@ export default function StoryDetailPage() {
|
||||
</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 */}
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<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 StoryRating from '../../../components/stories/StoryRating';
|
||||
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||
import TableOfContents from '../../../components/stories/TableOfContents';
|
||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||
|
||||
export default function StoryReadingPage() {
|
||||
@@ -21,6 +22,8 @@ export default function StoryReadingPage() {
|
||||
const [readingProgress, setReadingProgress] = useState(0);
|
||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const [hasHeadings, setHasHeadings] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -112,9 +115,20 @@ export default function StoryReadingPage() {
|
||||
|
||||
setStory(storyData);
|
||||
|
||||
// Sanitize story content
|
||||
// Sanitize story content and add IDs to headings
|
||||
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
|
||||
if (storyData.seriesId) {
|
||||
@@ -134,12 +148,29 @@ export default function StoryReadingPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||
// Use a small delay to ensure content is rendered
|
||||
const timeout = setTimeout(() => {
|
||||
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) {
|
||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||
scrollToCharacterPosition(story.readingPosition);
|
||||
@@ -266,6 +297,16 @@ export default function StoryReadingPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
rating={story.rating || 0}
|
||||
onRatingChange={handleRatingUpdate}
|
||||
@@ -280,6 +321,35 @@ export default function StoryReadingPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<article data-reading-content>
|
||||
|
||||
Reference in New Issue
Block a user