mobile ; heading scroll broken
This commit is contained in:
@@ -31,68 +31,180 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
return () => clearInterval(interval);
|
||||
}, [slugPath]);
|
||||
|
||||
// On post load or update, scroll to anchor in hash if present
|
||||
// Enhanced anchor scrolling logic
|
||||
useEffect(() => {
|
||||
// Scroll to anchor if hash is present
|
||||
const scrollToHash = () => {
|
||||
if (window.location.hash) {
|
||||
const id = window.location.hash.substring(1);
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
// On initial load
|
||||
scrollToHash();
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', scrollToHash);
|
||||
};
|
||||
}, [post]);
|
||||
if (!post) return;
|
||||
|
||||
// Intercept anchor clicks in rendered markdown to ensure smooth scrolling to headings
|
||||
useEffect(() => {
|
||||
// Find the rendered markdown container
|
||||
const prose = document.querySelector('.prose');
|
||||
if (!prose) return;
|
||||
/**
|
||||
* Handles clicks on anchor links (e.g. Table of Contents links) inside the markdown.
|
||||
* - If the link is an in-page anchor (starts with #), prevent default navigation.
|
||||
* - Try to find an element with the corresponding id and scroll to it.
|
||||
* - If not found, search all headings for one whose text matches the anchor (case-insensitive, ignoring spaces/punctuation).
|
||||
* - If a match is found, scroll to that heading.
|
||||
* - Update the URL hash without reloading the page.
|
||||
*/
|
||||
const handleClick = (e: Event) => {
|
||||
if (!(e instanceof MouseEvent)) return;
|
||||
let target = e.target as HTMLElement | null;
|
||||
// Traverse up to find the closest anchor tag
|
||||
while (target && target.tagName !== 'A') {
|
||||
target = target.parentElement;
|
||||
// Function to generate ID from text (matches markdown parser behavior)
|
||||
const generateId = (text: string): string => {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
// Function to scroll to element
|
||||
const scrollToElement = (element: HTMLElement) => {
|
||||
console.log('Attempting to scroll to element:', element.textContent);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20; // 1 second max wait time
|
||||
|
||||
// Wait for the element to be properly positioned in the DOM
|
||||
const waitForElementPosition = () => {
|
||||
attempts++;
|
||||
const rect = element.getBoundingClientRect();
|
||||
console.log('Element rect (attempt', attempts, '):', rect);
|
||||
|
||||
// If the element has no dimensions, wait a bit more
|
||||
if ((rect.height === 0 && rect.width === 0) && attempts < maxAttempts) {
|
||||
console.log('Element not positioned yet, waiting... (attempt', attempts, ')');
|
||||
setTimeout(waitForElementPosition, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've tried too many times, use fallback method
|
||||
if (attempts >= maxAttempts) {
|
||||
console.log('Max attempts reached, using fallback scroll method');
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
|
||||
// Apply offset after scroll
|
||||
setTimeout(() => {
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
const scrollOffset = isDesktop ? 120 : 100;
|
||||
window.scrollBy({
|
||||
top: -scrollOffset,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Element offsetTop:', element.offsetTop);
|
||||
console.log('Current scroll position:', window.scrollY);
|
||||
|
||||
const isDesktop = window.innerWidth >= 640;
|
||||
const scrollOffset = isDesktop ? 120 : 100;
|
||||
|
||||
// Use offsetTop which is more reliable for positioned elements
|
||||
const elementTop = element.offsetTop - scrollOffset;
|
||||
|
||||
console.log('Target scroll position:', elementTop);
|
||||
console.log('Scroll offset used:', scrollOffset);
|
||||
|
||||
// Perform the scroll
|
||||
window.scrollTo({
|
||||
top: elementTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
console.log('Scroll command executed');
|
||||
};
|
||||
|
||||
// Start the positioning check
|
||||
waitForElementPosition();
|
||||
};
|
||||
|
||||
// Function to find element by ID or generated ID
|
||||
const findElement = (id: string): HTMLElement | null => {
|
||||
console.log('Looking for element with ID:', id);
|
||||
|
||||
// Try direct ID match first
|
||||
let element = document.getElementById(id);
|
||||
|
||||
if (element) {
|
||||
console.log('Found element by direct ID:', element.textContent);
|
||||
return element;
|
||||
}
|
||||
if (target && target.tagName === 'A' && target.getAttribute('href')?.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const id = target.getAttribute('href')!.slice(1);
|
||||
let el = document.getElementById(id);
|
||||
if (!el) {
|
||||
// Try to find a heading whose text matches the id (case-insensitive, ignoring spaces/punctuation)
|
||||
const headings = prose.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
const normId = normalize(id);
|
||||
const found = Array.from(headings).find(h => normalize(h.textContent || '') === normId);
|
||||
el = (found as HTMLElement) || null;
|
||||
}
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
|
||||
// Try to find by generated ID from all headings
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
console.log('Found', headings.length, 'headings on page');
|
||||
|
||||
const found = Array.from(headings).find(heading => {
|
||||
const headingId = generateId(heading.textContent || '');
|
||||
console.log('Checking heading:', heading.textContent, '-> ID:', headingId, 'vs target:', id);
|
||||
return headingId === id;
|
||||
});
|
||||
|
||||
if (found) {
|
||||
console.log('Found element by generated ID:', found.textContent);
|
||||
return found as HTMLElement;
|
||||
}
|
||||
|
||||
console.log('Element not found for ID:', id);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Function to handle hash-based scrolling
|
||||
const handleHashScroll = () => {
|
||||
if (!window.location.hash) return;
|
||||
|
||||
const id = window.location.hash.substring(1);
|
||||
console.log('Handling hash scroll for:', id);
|
||||
|
||||
const element = findElement(id);
|
||||
|
||||
if (element) {
|
||||
console.log('Found element for hash scroll:', element.textContent);
|
||||
setTimeout(() => scrollToElement(element), 100);
|
||||
} else {
|
||||
console.log('Element not found for hash:', id);
|
||||
}
|
||||
};
|
||||
prose.addEventListener('click', handleClick);
|
||||
|
||||
// Function to handle anchor link clicks
|
||||
const handleAnchorClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest('a');
|
||||
|
||||
if (!link || !link.getAttribute('href')?.startsWith('#')) return;
|
||||
|
||||
event.preventDefault();
|
||||
const href = link.getAttribute('href')!;
|
||||
const id = href.substring(1);
|
||||
|
||||
console.log('Anchor click detected:', id);
|
||||
|
||||
const element = findElement(id);
|
||||
|
||||
if (element) {
|
||||
console.log('Found element for anchor click:', element.textContent);
|
||||
scrollToElement(element);
|
||||
|
||||
// Update URL without reload
|
||||
history.replaceState(null, '', href);
|
||||
} else {
|
||||
console.log('Element not found for anchor:', id);
|
||||
}
|
||||
};
|
||||
|
||||
// Add IDs to headings that don't have them
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
console.log('Processing', headings.length, 'headings for ID assignment');
|
||||
headings.forEach(heading => {
|
||||
if (!heading.id) {
|
||||
const id = generateId(heading.textContent || '');
|
||||
heading.id = id;
|
||||
console.log('Added ID to heading:', heading.textContent, '->', id);
|
||||
} else {
|
||||
console.log('Heading already has ID:', heading.textContent, '->', heading.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle initial hash scroll
|
||||
setTimeout(handleHashScroll, 100);
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('click', handleAnchorClick);
|
||||
window.addEventListener('hashchange', handleHashScroll);
|
||||
|
||||
return () => {
|
||||
prose.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('click', handleAnchorClick);
|
||||
window.removeEventListener('hashchange', handleHashScroll);
|
||||
};
|
||||
}, [post]);
|
||||
|
||||
@@ -111,39 +223,106 @@ export default function PostPage({ params }: { params: { slug: string[] } }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="min-h-screen px-4 py-10 mx-auto md:mx-16 rounded-2xl shadow-lg">
|
||||
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
|
||||
← Zurück zu den Beiträgen
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold mb-4 text-left">{post.title}</h1>
|
||||
<div className="text-gray-600 mb-8 text-left">
|
||||
{post.date ? (
|
||||
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex">
|
||||
<span className="text-2xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-2xl animate-spin-reverse">⚙️</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-8">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
<article className="min-h-screen">
|
||||
{/* Mobile: Full width, no borders */}
|
||||
<div className="sm:hidden">
|
||||
{/* Mobile back button */}
|
||||
<div className="sticky top-0 z-10 bg-white/95 backdrop-blur-sm border-b border-gray-100 px-4 py-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile content - full width, optimized for reading */}
|
||||
<div className="px-4 py-6">
|
||||
<h1 className="text-2xl font-bold mb-4 leading-tight">{post.title}</h1>
|
||||
|
||||
<div className="text-sm text-gray-600 mb-6">
|
||||
{post.date ? (
|
||||
<div className="mb-2">Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="text-lg animate-spin mr-2">⚙️</span>
|
||||
<span className="font-medium">In Bearbeitung</span>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile-optimized prose content */}
|
||||
<div
|
||||
className="prose prose-sm max-w-none prose-headings:scroll-mt-16 prose-p:leading-relaxed prose-p:text-base"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Wider content area with minimal borders */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-w-5xl mx-auto px-8 py-8">
|
||||
{/* Desktop back button */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 hover:underline mb-8 text-base font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück zu den Beiträgen
|
||||
</Link>
|
||||
|
||||
{/* Desktop content with minimal border */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-8 lg:p-12">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold mb-6 text-left leading-tight">{post.title}</h1>
|
||||
|
||||
<div className="text-base text-gray-600 mb-8 text-left">
|
||||
{post.date ? (
|
||||
<div className="mb-2">Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
|
||||
) : (
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="text-xl animate-spin mr-2">⚙️</span>
|
||||
<span className="text-lg font-medium">In Bearbeitung</span>
|
||||
</div>
|
||||
)}
|
||||
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop-optimized prose content */}
|
||||
<div
|
||||
className="prose prose-lg max-w-none text-left prose-headings:scroll-mt-16 prose-p:leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="prose prose-lg max-w-full text-left"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user