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

@@ -151,41 +151,37 @@ Macros let you insert dynamic content blocks directly inside post/page Markdown
Use macros when you need reusable rich blocks (for example embedded videos, media galleries, archive grids, or computed tag clouds) without writing raw HTML. Use macros when you need reusable rich blocks (for example embedded videos, media galleries, archive grids, or computed tag clouds) without writing raw HTML.
### Supported macros ### YouTube macro
- `[[youtube id="VIDEO_ID" title="Optional title"]]` Use `[[youtube id="VIDEO_ID" title="Optional title"]]` when you want to embed a YouTube clip directly in a post or page. This macro is best for video references, walkthroughs, and embedded talks that should stay in the editorial flow instead of linking out to another tab.
- Embeds a YouTube video.
- `id` is required.
- `title` is optional (used for accessibility label).
- `[[vimeo id="VIDEO_ID" title="Optional title"]]` The `id` parameter is required and should contain the YouTube video ID. The `title` parameter is optional, but recommended for accessibility because it becomes the reader-facing label for the embedded frame.
- Embeds a Vimeo video.
- `id` is required.
- `title` is optional.
- `[[gallery columns="3" caption="Optional caption"]]` ### Vimeo macro
- Renders a lightbox-enabled gallery using media linked to the current post.
- `columns` is optional (`1` to `6`, default `3`).
- `caption` is optional.
- `[[photo_archive year="2025" month="2"]]` Use `[[vimeo id="VIDEO_ID" title="Optional title"]]` for Vimeo-hosted video content. It behaves similarly to the YouTube macro, but targets Vimeo as the video source.
- Renders a photo archive grid from media dates.
- `year` is optional (when omitted, recent months are shown).
- `month` is optional (used with `year` for a single month view).
- Legacy alias `[[photo_album ...]]` is also supported.
- `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` As with YouTube, `id` is required and `title` is optional. Use `title` whenever possible so screen-reader and assistive-technology users receive useful context.
- Builds a word cloud from published tag usage counts.
- Word size scales by usage quantity. ### Gallery macro
- Word color is theme-aware and uses Pico CSS semantic colors.
- Colors are distributed by quantity quantiles with easing, so dense datasets still show visible variation. Use `[[gallery columns="3" caption="Optional caption"]]` to render a lightbox-enabled media gallery from assets linked to the current post. This macro is appropriate when several related images belong together and should be browsed as one visual group.
- Visual order remains least-to-most: blue → green → yellow → orange → red.
- Clicking a word opens that tag archive route. The `columns` parameter controls layout density and accepts values from `1` to `6` (default is `3`). The optional `caption` parameter adds context above or below the gallery depending on theme presentation.
- `orientation` is optional and supports:
- `horizontal` (all words horizontal) ### Photo archive macro
- `mixed_hv` (mix of horizontal and vertical)
- `mixed_diagonal` (mix of horizontal/vertical/diagonal angles) Use `[[photo_archive year="2025" month="2"]]` when you want an archive-style grid based on media dates. This is useful for timeline-oriented projects where readers should navigate image collections by month or year.
- `width` and `height` are optional (defaults `900` and `420`).
Both `year` and `month` are optional. If `year` is omitted, bDS shows recent months. If `year` is provided without `month`, bDS presents the year scope. The legacy alias `[[photo_album ...]]` is still supported for compatibility.
### Tag cloud macro
Use `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` to visualize published tag usage as a weighted cloud. This macro is best for discovery pages, thematic overviews, and archive entry points where content density matters.
Word size scales with usage counts. Colors are theme-aware and distributed by quantity quantiles using eased interpolation so high-volume datasets stay readable. The color progression remains least-to-most (blue → green → yellow → orange → red), and clicking a word opens that tag archive route.
The optional `orientation` parameter supports `horizontal`, `mixed_hv`, and `mixed_diagonal`. The optional `width` and `height` parameters control canvas size and default to `900` and `420`.
### Key takeaways ### Key takeaways

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 { .documentation-view {
--doc-bg: var(--pico-background-color); --doc-bg: var(--pico-background-color);
--doc-surface: var(--pico-card-background-color); --doc-surface: var(--pico-card-background-color);
@@ -94,6 +97,35 @@
overflow: auto; 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 { .documentation-content.markdown-body blockquote {
margin: 10px 0; margin: 10px 0;
padding: 0 0 0 12px; 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 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 documentationContent from '../../../../DOCUMENTATION.md?raw';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
import './DocumentationView.css'; 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 = () => { export const DocumentationView: React.FC = () => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const { picoTheme } = useAppStore(); const { picoTheme } = useAppStore();
const resolvedTheme = getRendererPicoTheme(picoTheme); 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(() => { useEffect(() => {
ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => { ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => {
@@ -23,10 +256,13 @@ export const DocumentationView: React.FC = () => {
<h1>{tr('docs.title')}</h1> <h1>{tr('docs.title')}</h1>
<p>{tr('docs.subtitle')}</p> <p>{tr('docs.subtitle')}</p>
</div> </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}> <div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
<article className="documentation-article"> <article className="documentation-article" ref={articleRef}>
<Markdown>{documentationContent}</Markdown> <Markdown renderer={markdownRenderer}>{documentationContent}</Markdown>
</article> </article>
</div> </div>
</main> </main>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,246 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { DocumentationView } from '../../../src/renderer/components/DocumentationView/DocumentationView';
import { I18nProvider } from '../../../src/renderer/i18n';
import { useAppStore } from '../../../src/renderer/store';
const openSpy = vi.fn();
Object.defineProperty(window, 'open', {
value: openSpy,
writable: true,
});
describe('DocumentationView', () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'location', {
value: {
...window.location,
hash: '',
},
writable: true,
});
useAppStore.setState({
picoTheme: 'slate',
} as never);
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
});
});
it('renders fenced code blocks with language-specific classes for syntax highlighting', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
await screen.findByText('bDS User Guide');
const pythonBlock = container.querySelector('pre > code.language-python');
expect(pythonBlock).not.toBeNull();
});
it('renders heading anchors and keeps in-document toc links in-page', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
expect(targetHeading).toHaveAttribute('id', 'who-this-guide-is-for');
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]');
expect(tocLink).not.toBeNull();
const scrollContainer = container.querySelector('.documentation-scroll');
expect(scrollContainer).not.toBeNull();
Object.defineProperty(scrollContainer as HTMLElement, 'scrollTop', {
value: 10,
writable: true,
});
Object.defineProperty(scrollContainer as HTMLElement, 'getBoundingClientRect', {
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
writable: true,
});
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
value: () => ({ top: 340, left: 0, width: 100, height: 20, right: 100, bottom: 360, x: 0, y: 340, toJSON: () => ({}) }),
writable: true,
});
fireEvent.click(tocLink as HTMLAnchorElement);
expect((scrollContainer as HTMLElement).scrollTop).toBe(238);
const article = container.querySelector('.documentation-article') as HTMLElement;
expect(article.dataset.lastJumpTarget).toBe('who-this-guide-is-for');
expect(article.dataset.lastJumpTop).toBe('238');
expect(window.location.hash).toBe('#who-this-guide-is-for');
expect(window.open).not.toHaveBeenCalled();
});
it('keeps scroll position unchanged for unresolved in-document links', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
const tocLink = container.querySelector('a[href="#does-not-exist"]');
if (!tocLink) {
const article = container.querySelector('.documentation-article') as HTMLElement;
const missingAnchor = document.createElement('a');
missingAnchor.setAttribute('href', '#does-not-exist');
missingAnchor.textContent = 'Missing';
article.appendChild(missingAnchor);
}
const unresolvedLink = container.querySelector('a[href="#does-not-exist"]');
expect(unresolvedLink).not.toBeNull();
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
expect(scrollContainer).not.toBeNull();
Object.defineProperty(scrollContainer, 'scrollTop', {
value: 222,
writable: true,
});
expect(() => fireEvent.click(unresolvedLink as HTMLAnchorElement)).not.toThrow();
expect(scrollContainer.scrollTop).toBe(222);
});
it('handles toc clicks when clicking nested content inside the anchor', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Who this guide is for' });
const tocLink = container.querySelector('a[href="#who-this-guide-is-for"]') as HTMLAnchorElement;
expect(tocLink).not.toBeNull();
const nested = document.createElement('span');
nested.textContent = 'Who this guide is for';
tocLink.appendChild(nested);
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
Object.defineProperty(scrollContainer, 'scrollTop', {
value: 5,
writable: true,
});
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
writable: true,
});
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
value: () => ({ top: 300, left: 0, width: 100, height: 20, right: 100, bottom: 320, x: 0, y: 300, toJSON: () => ({}) }),
writable: true,
});
fireEvent.click(nested);
expect(scrollContainer.scrollTop).toBe(193);
});
it('resolves hash target within the clicked documentation instance', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
<DocumentationView />
</I18nProvider>
);
await screen.findAllByRole('heading', { level: 2, name: 'Using macros' });
const wrappers = container.querySelectorAll('.documentation-view');
expect(wrappers.length).toBe(2);
const firstScroll = wrappers[0].querySelector('.documentation-scroll') as HTMLElement;
const secondScroll = wrappers[1].querySelector('.documentation-scroll') as HTMLElement;
const secondLink = wrappers[1].querySelector('a[href="#using-macros"]') as HTMLAnchorElement;
const secondArticle = wrappers[1].querySelector('.documentation-article') as HTMLElement;
const secondHeading = Array.from(secondArticle.querySelectorAll<HTMLElement>('[id]')).find((node) => node.id === 'using-macros') as HTMLElement;
Object.defineProperty(firstScroll, 'scrollTop', { value: 7, writable: true });
Object.defineProperty(secondScroll, 'scrollTop', { value: 11, writable: true });
Object.defineProperty(secondScroll, 'getBoundingClientRect', {
value: () => ({ top: 120, left: 0, width: 700, height: 500, right: 700, bottom: 620, x: 0, y: 120, toJSON: () => ({}) }),
writable: true,
});
Object.defineProperty(secondHeading, 'getBoundingClientRect', {
value: () => ({ top: 420, left: 0, width: 300, height: 30, right: 300, bottom: 450, x: 0, y: 420, toJSON: () => ({}) }),
writable: true,
});
fireEvent.click(secondLink);
expect(firstScroll.scrollTop).toBe(7);
expect(secondScroll.scrollTop).toBe(299);
expect(secondArticle.dataset.lastJumpTarget).toBe('using-macros');
});
it('falls back to heading text slug when heading id does not match hash', async () => {
const { container } = render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
const targetHeading = await screen.findByRole('heading', { level: 2, name: 'Using scripting (early access)' });
targetHeading.id = 'unexpected-id';
const tocLink = container.querySelector('a[href="#using-scripting-early-access"]') as HTMLAnchorElement;
expect(tocLink).not.toBeNull();
const scrollContainer = container.querySelector('.documentation-scroll') as HTMLElement;
Object.defineProperty(scrollContainer, 'scrollTop', {
value: 50,
writable: true,
});
Object.defineProperty(scrollContainer, 'getBoundingClientRect', {
value: () => ({ top: 100, left: 0, width: 800, height: 600, right: 800, bottom: 700, x: 0, y: 100, toJSON: () => ({}) }),
writable: true,
});
Object.defineProperty(targetHeading, 'getBoundingClientRect', {
value: () => ({ top: 450, left: 0, width: 200, height: 24, right: 200, bottom: 474, x: 0, y: 450, toJSON: () => ({}) }),
writable: true,
});
fireEvent.click(tocLink);
expect(scrollContainer.scrollTop).toBe(388);
expect(targetHeading.id).toBe('using-scripting-early-access');
});
it('shows a copy button for fenced code blocks and copies the block content', async () => {
render(
<I18nProvider>
<DocumentationView />
</I18nProvider>
);
const copyButtons = await screen.findAllByRole('button', { name: /copy code/i });
expect(copyButtons.length).toBeGreaterThan(0);
fireEvent.click(copyButtons[0]);
expect(navigator.clipboard.writeText).toHaveBeenCalled();
expect(window.open).not.toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,11 @@ describe('documentation structure and presentation', () => {
const markdown = readFileSync(docPath, 'utf8'); const markdown = readFileSync(docPath, 'utf8');
expect(markdown).toContain('## Using macros'); expect(markdown).toContain('## Using macros');
expect(markdown).toContain('### YouTube macro');
expect(markdown).toContain('### Vimeo macro');
expect(markdown).toContain('### Gallery macro');
expect(markdown).toContain('### Photo archive macro');
expect(markdown).toContain('### Tag cloud macro');
expect(markdown).toContain('[[youtube'); expect(markdown).toContain('[[youtube');
expect(markdown).toContain('[[vimeo'); expect(markdown).toContain('[[vimeo');
expect(markdown).toContain('[[gallery'); expect(markdown).toContain('[[gallery');