proper scrolling in docs

This commit is contained in:
2026-02-23 20:51:40 +01:00
parent cd394bcacb
commit 3840b928ba
10 changed files with 554 additions and 34 deletions

View File

@@ -1,3 +1,6 @@
@import '@highlightjs/cdn-assets/styles/github.min.css' screen and (prefers-color-scheme: light);
@import '@highlightjs/cdn-assets/styles/github-dark.min.css' screen and (prefers-color-scheme: dark);
.documentation-view {
--doc-bg: var(--pico-background-color);
--doc-surface: var(--pico-card-background-color);
@@ -94,6 +97,35 @@
overflow: auto;
}
.documentation-code-block {
position: relative;
}
.documentation-code-copy-button {
position: absolute;
top: 8px;
right: 8px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-surface);
color: var(--doc-text);
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1;
}
.documentation-code-copy-button:hover {
background: var(--doc-code-bg);
}
.documentation-code-block pre {
margin-top: 0;
}
.documentation-content.markdown-body blockquote {
margin: 10px 0;
padding: 0 0 0 12px;

View File

@@ -1,15 +1,248 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import Markdown from 'marked-react';
import hljs from '@highlightjs/cdn-assets/es/highlight.min.js';
import type { ReactNode } from 'react';
import documentationContent from '../../../../DOCUMENTATION.md?raw';
import { useAppStore } from '../../store';
import { useI18n } from '../../i18n';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
import './DocumentationView.css';
const HEADING_LEVELS = new Set([1, 2, 3, 4, 5, 6]);
function extractText(content: ReactNode): string {
if (typeof content === 'string' || typeof content === 'number') {
return String(content);
}
if (Array.isArray(content)) {
return content.map(extractText).join('');
}
if (React.isValidElement(content)) {
return extractText((content.props as { children?: ReactNode }).children);
}
return '';
}
function slugifyHeading(value: string): string {
return value
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
function isScrollable(element: HTMLElement): boolean {
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
return canScroll && element.scrollHeight > element.clientHeight;
}
function resolveScrollContainer(target: HTMLElement, preferred: HTMLElement | null): HTMLElement | null {
if (preferred && preferred.contains(target)) {
return preferred;
}
let current: HTMLElement | null = target.parentElement;
while (current) {
if (isScrollable(current)) {
return current;
}
current = current.parentElement;
}
if (preferred) {
return preferred;
}
const scrollingElement = document.scrollingElement;
return scrollingElement instanceof HTMLElement ? scrollingElement : null;
}
function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: string): HTMLElement | null {
for (const candidate of articleElement.querySelectorAll<HTMLElement>('[id]')) {
if (candidate.id === targetId) {
return candidate;
}
}
const headingCandidates = articleElement.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6');
for (const heading of headingCandidates) {
const headingSlug = slugifyHeading(heading.textContent ?? '');
if (headingSlug === targetId) {
heading.id = targetId;
return heading;
}
}
return null;
}
export const DocumentationView: React.FC = () => {
const { t: tr } = useI18n();
const { picoTheme } = useAppStore();
const resolvedTheme = getRendererPicoTheme(picoTheme);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const articleRef = useRef<HTMLElement | null>(null);
const headingSlugCounts = new Map<string, number>();
let rendererKeyIndex = 0;
const jumpToDocumentationHash = (href: string): boolean => {
if (!href.startsWith('#') || href.length < 2) {
return false;
}
const targetId = decodeURIComponent(href.slice(1));
const articleElement = articleRef.current;
const preferredScrollContainer = scrollContainerRef.current;
if (!articleElement) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'article-not-available' });
return false;
}
const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId);
if (!targetHeading) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'target-not-found-or-outside-article' });
return false;
}
const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer);
if (!scrollContainer) {
console.info('[DocumentationView] hash jump skipped', { href, targetId, reason: 'no-scroll-container' });
return false;
}
const beforeTop = scrollContainer.scrollTop;
const containerRect = scrollContainer.getBoundingClientRect();
const headingRect = targetHeading.getBoundingClientRect();
const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12);
scrollContainer.scrollTop = 0;
scrollContainer.scrollTop = targetTop;
window.location.hash = href;
articleElement.dataset.lastJumpTarget = targetId;
articleElement.dataset.lastJumpTop = String(targetTop);
articleElement.dataset.lastJumpContainer = scrollContainer.className || scrollContainer.tagName;
console.info('[DocumentationView] hash jump applied', {
href,
targetId,
beforeTop,
targetTop,
afterTop: scrollContainer.scrollTop,
container: scrollContainer.className || scrollContainer.tagName,
});
return true;
};
const getRendererKey = (prefix: string): string => {
rendererKeyIndex += 1;
return `${prefix}-${rendererKeyIndex}`;
};
const markdownRenderer = {
heading(children: ReactNode, level: number) {
const levelNumber = HEADING_LEVELS.has(level as 1 | 2 | 3 | 4 | 5 | 6) ? level : 2;
const headingText = extractText(children);
const baseId = slugifyHeading(headingText);
const existingCount = headingSlugCounts.get(baseId) ?? 0;
const nextCount = existingCount + 1;
headingSlugCounts.set(baseId, nextCount);
const headingId = existingCount === 0 ? baseId : `${baseId}-${nextCount}`;
return React.createElement(`h${levelNumber}` as keyof JSX.IntrinsicElements, { id: headingId, key: getRendererKey('heading') }, children);
},
link(href: string, text: ReactNode) {
if (!href.startsWith('#')) {
return <a href={href} key={getRendererKey('link')}>{text}</a>;
}
return (
<a
href={href}
key={getRendererKey('hash-link')}
onClick={(event) => {
if (jumpToDocumentationHash(href)) {
event.preventDefault();
}
}}
>
{text}
</a>
);
},
code(code: ReactNode, lang: string | undefined) {
const normalizedLanguage = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
const sourceCode = extractText(code);
const codeBlockKey = getRendererKey('code-block');
let highlightedHtml = '';
let languageClass = '';
if (normalizedLanguage.length > 0 && hljs.getLanguage(normalizedLanguage)) {
highlightedHtml = hljs.highlight(sourceCode, { language: normalizedLanguage }).value;
languageClass = `language-${normalizedLanguage}`;
} else {
const autoDetected = hljs.highlightAuto(sourceCode);
highlightedHtml = autoDetected.value;
languageClass = autoDetected.language ? `language-${autoDetected.language}` : 'language-plaintext';
}
return (
<div className="documentation-code-block" key={codeBlockKey}>
<button
type="button"
className="documentation-code-copy-button"
aria-label={tr('docs.copyCode')}
title={tr('docs.copyCode')}
onClick={() => {
const copyToClipboard = async () => {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(sourceCode);
return;
}
const textArea = document.createElement('textarea');
textArea.value = sourceCode;
textArea.setAttribute('readonly', '');
textArea.style.position = 'absolute';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
};
copyToClipboard()
.then(() => undefined)
.catch((error) => {
console.error('Failed to copy documentation code block:', error);
});
}}
>
📋
</button>
<pre>
<code
className={`hljs ${languageClass}`}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
</pre>
</div>
);
},
};
useEffect(() => {
ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => {
@@ -23,10 +256,13 @@ export const DocumentationView: React.FC = () => {
<h1>{tr('docs.title')}</h1>
<p>{tr('docs.subtitle')}</p>
</div>
<main className="documentation-scroll">
<main
className="documentation-scroll"
ref={scrollContainerRef}
>
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
<article className="documentation-article">
<Markdown>{documentationContent}</Markdown>
<article className="documentation-article" ref={articleRef}>
<Markdown renderer={markdownRenderer}>{documentationContent}</Markdown>
</article>
</div>
</main>

View File

@@ -244,6 +244,7 @@
"postLinks.openTitle": "Öffnen: {title}",
"docs.title": "Dokumentation",
"docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.",
"docs.copyCode": "Code kopieren",
"gitDiff.header": "Unterschied: {target}",
"gitDiff.noProject": "Kein aktives Projekt ausgewählt.",
"gitDiff.noProjectPath": "Projektpfad konnte nicht ermittelt werden.",

View File

@@ -244,6 +244,7 @@
"postLinks.openTitle": "Open: {title}",
"docs.title": "Documentation",
"docs.subtitle": "User guide for this installed bDS version.",
"docs.copyCode": "Copy code",
"gitDiff.header": "Diff: {target}",
"gitDiff.noProject": "No active project selected.",
"gitDiff.noProjectPath": "Unable to resolve project path.",

View File

@@ -244,6 +244,7 @@
"postLinks.openTitle": "Abrir: {title}",
"docs.title": "Documentación",
"docs.subtitle": "Guía de usuario para esta versión instalada de bDS.",
"docs.copyCode": "Copiar código",
"gitDiff.header": "Diferencia: {target}",
"gitDiff.noProject": "No hay un proyecto activo seleccionado.",
"gitDiff.noProjectPath": "No se pudo resolver la ruta del proyecto.",

View File

@@ -244,6 +244,7 @@
"postLinks.openTitle": "Ouvrir: {title}",
"docs.title": "Guide utilisateur",
"docs.subtitle": "Guide utilisateur pour cette version installée de bDS.",
"docs.copyCode": "Copier le code",
"gitDiff.header": "Diff : {target}",
"gitDiff.noProject": "Aucun projet actif sélectionné.",
"gitDiff.noProjectPath": "Impossible de résoudre le chemin du projet.",

View File

@@ -244,6 +244,7 @@
"postLinks.openTitle": "Apri: {title}",
"docs.title": "Documentazione",
"docs.subtitle": "Guida utente per questa versione installata di bDS.",
"docs.copyCode": "Copia codice",
"gitDiff.header": "Differenza: {target}",
"gitDiff.noProject": "Nessun progetto attivo selezionato.",
"gitDiff.noProjectPath": "Impossibile risolvere il percorso del progetto.",