mobile ; heading scroll broken

This commit is contained in:
2025-06-21 20:39:22 +02:00
parent 7b556b2d09
commit 1cc864e4f0
12 changed files with 1117 additions and 325 deletions

View File

@@ -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>
);
}