proper scrolling in docs
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
246
tests/renderer/components/DocumentationView.test.tsx
Normal file
246
tests/renderer/components/DocumentationView.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user