diff --git a/src/Elastic.Markdown/Assets/main.ts b/src/Elastic.Markdown/Assets/main.ts index 97b9d92b8..45b3f2846 100644 --- a/src/Elastic.Markdown/Assets/main.ts +++ b/src/Elastic.Markdown/Assets/main.ts @@ -1,5 +1,8 @@ import {initNav} from "./pages-nav"; +import {initTocNav} from "./toc-nav"; + import {initHighlight} from "./hljs"; initNav(); +initTocNav(); initHighlight(); diff --git a/src/Elastic.Markdown/Assets/markdown/list.css b/src/Elastic.Markdown/Assets/markdown/list.css new file mode 100644 index 000000000..1d9e79e0e --- /dev/null +++ b/src/Elastic.Markdown/Assets/markdown/list.css @@ -0,0 +1,25 @@ +#elastic-docs-v3 { + ol,ul { + font-family: "Inter", sans-serif; + @apply text-base text-body mb-6; + line-height: 1.5em; + letter-spacing: 0; + margin-left: 1.5em; + } + + ol { + list-style-type: decimal; + } + + ul { + list-style-type: disc; + } + + li { + margin-bottom: calc(var(--spacing) * 3); + + p { + margin-bottom: 0; + } + } +} diff --git a/src/Elastic.Markdown/Assets/markdown/typography.css b/src/Elastic.Markdown/Assets/markdown/typography.css index 0fc01be64..920a5fe55 100644 --- a/src/Elastic.Markdown/Assets/markdown/typography.css +++ b/src/Elastic.Markdown/Assets/markdown/typography.css @@ -1,34 +1,35 @@ #elastic-docs-v3 { + h1 { font-family: "Mier B", "Inter", sans-serif; - @apply text-4xl text-ink font-bold mb-6; + @apply text-4xl text-black mb-6 mt-4; line-height: 1.2em; letter-spacing: -0.04em; } h2 { font-family: "Mier B", "Inter", sans-serif; - @apply text-2xl text-ink mb-6; + @apply text-2xl text-black mb-6 mt-4; line-height: 1.2em; letter-spacing: -0.02em; } h3 { font-family: "Mier B", "Inter", sans-serif; - @apply text-xl text-ink font-bold mb-6; + @apply text-xl text-black font-bold mb-6 mt-4; line-height: 1.2em; letter-spacing: -0.02em; } p { font-family: "Inter", sans-serif; - @apply text-base text-body mb-6; + @apply text-base text-ink text-body mb-6; line-height: 1.5em; letter-spacing: 0; } a { font-family: "Inter", sans-serif; - @apply text-blue-elastic hover:underline underline-offset-4; + @apply text-blue-elastic underline hover:text-blue-800; } } diff --git a/src/Elastic.Markdown/Assets/pages-nav.ts b/src/Elastic.Markdown/Assets/pages-nav.ts index 96f763bbc..efef84b43 100644 --- a/src/Elastic.Markdown/Assets/pages-nav.ts +++ b/src/Elastic.Markdown/Assets/pages-nav.ts @@ -1,8 +1,8 @@ -import {$, $$} from "select-dom/strict"; +import {$, $$} from "select-dom"; type NavExpandState = { [key: string]: boolean }; const PAGE_NAV_EXPAND_STATE_KEY = 'pagesNavState'; -const navState = JSON.parse(sessionStorage.getItem(PAGE_NAV_EXPAND_STATE_KEY)) as NavExpandState +const navState = JSON.parse(sessionStorage.getItem(PAGE_NAV_EXPAND_STATE_KEY) ?? "{}") as NavExpandState // Initialize the nav state from the session storage // Return a function to keep the nav state in the session storage that should be called before the page is unloaded @@ -14,7 +14,9 @@ function keepNavState(nav: HTMLElement): () => void { if ('shouldExpand' in input.dataset && input.dataset['shouldExpand'] === 'true') { input.checked = true; } else { - input.checked = navState[key]; + if (key in navState) { + input.checked = navState[key]; + } } }); } @@ -68,6 +70,9 @@ function isElementInViewport(el: HTMLElement): boolean { export function initNav() { const pagesNav = $('#pages-nav'); + if (!pagesNav) { + return; + } const keepNavStateCallback = keepNavState(pagesNav); const keepNavPositionCallback = keepNavPosition(pagesNav); scrollCurrentNaviItemIntoView(pagesNav, 100); diff --git a/src/Elastic.Markdown/Assets/styles.css b/src/Elastic.Markdown/Assets/styles.css index a663b8466..bf677292a 100644 --- a/src/Elastic.Markdown/Assets/styles.css +++ b/src/Elastic.Markdown/Assets/styles.css @@ -3,6 +3,7 @@ @import "./theme.css"; @import "highlight.js/styles/atom-one-dark.css"; @import "./markdown/typography.css"; +@import "./markdown/list.css"; #default-search::-webkit-search-cancel-button { padding-right: calc(var(--spacing) * 2); @@ -15,25 +16,6 @@ background-repeat: no-repeat; } -#pages-nav { - &::-webkit-scrollbar-track { - background-color: transparent; - } - &:hover::-webkit-scrollbar-thumb { - background-color: var(--color-gray-light); - } - &::-webkit-scrollbar { - width: calc(var(--spacing) * 2); - height: calc(var(--spacing) * 2); - } - &::-webkit-scrollbar-thumb { - border-radius: var(--spacing); - } - - scrollbar-gutter: stable; -} - - #pages-nav li.current { position: relative; &::before { @@ -46,3 +28,59 @@ background-color: var(--color-gray-200); } } + +#toc-nav a.current { + color: var(--color-blue-elastic); + &:hover { + color: var(--color-blue-elastic); + } +} + +@layer components { + .link { + font-family: "Mier B", "Inter", sans-serif; + @apply + text-blue-elastic + text-nowrap + font-semibold + hover:text-blue-800 + inline-flex + justify-center + items-center; + + .link-arrow { + @apply + shrink-0 + size-7 + ml-2 + transition-transform + ease-out; + } + + &:hover{ + svg { + @apply translate-x-2; + } + } + } + + .sidebar { + .sidebar-nav { + @apply sticky top-22 z-30 overflow-y-auto; + max-height: calc(100vh - var(--spacing) * 22); + } + + .sidebar-link { + @apply + text-ink-light + hover:text-black + text-sm + leading-[1.2em] + tracking-[-0.02em]; + } + } +} + +* { + scroll-margin-top: calc(var(--spacing) * 26); +} diff --git a/src/Elastic.Markdown/Assets/toc-nav.ts b/src/Elastic.Markdown/Assets/toc-nav.ts new file mode 100644 index 000000000..21d1ad5cc --- /dev/null +++ b/src/Elastic.Markdown/Assets/toc-nav.ts @@ -0,0 +1,138 @@ +import { $$, $ } from 'select-dom'; + +interface TocElements { + headings: Element[]; + tocLinks: HTMLAnchorElement[]; + tocContainer: HTMLUListElement | null; + progressIndicator: HTMLDivElement; +} + +// 34 is the height of the header + some padding +// 4 is the base spacing unit +const HEADING_OFFSET = 34 * 4; + +function initializeTocElements(): TocElements { + const headings = $$('h2, h3'); + const tocLinks = $$('#toc-nav li>a') as HTMLAnchorElement[]; + const tocContainer = $('#toc-nav ul') as HTMLUListElement; + const progressIndicator = $('.toc-progress-indicator', tocContainer) as HTMLDivElement; + return { headings, tocLinks, tocContainer,progressIndicator }; +} + +// Find the current TOC links based on visible headings +// It can return multiple links because headings in a tab can have the same position +function findCurrentTocLinks(elements: TocElements): HTMLAnchorElement[] { + let currentTocLinks: HTMLAnchorElement[] = []; + let currentTop: number | null = null; + for (const heading of elements.headings) { + const rect = heading.getBoundingClientRect(); + if (rect.top <= HEADING_OFFSET) { + if (currentTop !== null && Math.abs(rect.top - currentTop) > 1) { + currentTocLinks = []; + } + currentTop = rect.top; + const foundLink = elements.tocLinks.find(link => + link.getAttribute('href') === `#${heading.closest('section')?.id}` + ); + if (foundLink) { + currentTocLinks.push(foundLink); + } + } + } + return currentTocLinks; +} + +// Get visible headings in viewport +function getVisibleHeadings(elements: TocElements) { + return elements.headings.filter(heading => { + const rect = heading.getBoundingClientRect(); + return rect.top - HEADING_OFFSET + 64 >= 0 && rect.top <= window.innerHeight; + }); +} + +// If the user has scrolled to the bottom of the page, +// and there are still multiple headings visible, we need to +// handle the progress indicator differently. +// In this case it sets the indicator for all visible headings. +function handleBottomScroll(elements: TocElements) { + const visibleHeadings = getVisibleHeadings(elements); + if (visibleHeadings.length === 0) return; + const firstHeading = visibleHeadings[0]; + const lastHeading = visibleHeadings[visibleHeadings.length - 1]; + const firstLink = elements.tocLinks.find(link => + link.getAttribute('href') === `#${firstHeading.parentElement?.id}` + )?.closest('li'); + const lastLink = elements.tocLinks.find(link => + link.getAttribute('href') === `#${lastHeading.parentElement?.id}` + )?.closest('li'); + if (firstLink && lastLink && elements.tocContainer) { + const tocRect = elements.tocContainer.getBoundingClientRect(); + const firstRect = firstLink.getBoundingClientRect(); + const lastRect = lastLink.getBoundingClientRect(); + updateProgressIndicatorPosition( + elements.progressIndicator, + firstRect.top - tocRect.top, + (lastRect.top + lastRect.height) - firstRect.top + ); + } +} + +function updateProgressIndicatorPosition( + indicator: HTMLDivElement, + top: number, + height: number +) { + indicator.style.top = `${top}px`; + indicator.style.height = `${height}px`; +} + +function updateIndicator(elements: TocElements) { + if (!elements.tocContainer) return; + + const isAtBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 10; + const currentTocLinks = findCurrentTocLinks(elements); + + if (isAtBottom) { + handleBottomScroll(elements); + } else if (currentTocLinks.length > 0) { + const tocRect = elements.tocContainer.getBoundingClientRect(); + const linkElements = currentTocLinks + .map(link => link.closest('li')) + .filter((li): li is HTMLLIElement => li !== null); + if (linkElements.length === 0) return; + const firstLinkRect = linkElements[0].getBoundingClientRect(); + const lastLinkRect = linkElements[linkElements.length - 1].getBoundingClientRect(); + updateProgressIndicatorPosition( + elements.progressIndicator, + firstLinkRect.top - tocRect.top, + (lastLinkRect.top + lastLinkRect.height) - firstLinkRect.top + ); + } +} + +function setupSmoothScrolling(elements: TocElements) { + elements.tocLinks.forEach(link => { + link.addEventListener('click', (e) => { + const href = link.getAttribute('href'); + if (href?.charAt(0) === '#') { + e.preventDefault(); + const target = $(href.replace('.', '\\.')); + if (target) { + target.scrollIntoView({ behavior: 'smooth' }); + history.pushState(null, '', href); + } + } + }); + }); +} + +export function initTocNav() { + const elements = initializeTocElements(); + elements.progressIndicator.style.height = '0'; + elements.progressIndicator.style.top = '0'; + const update = () => updateIndicator(elements) + update(); + window.addEventListener('scroll', update); + window.addEventListener('resize', update); + setupSmoothScrolling(elements); +} diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index 15b337c15..d4a8701ca 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -40,10 +40,10 @@ - - - - + + + + diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index c141126a9..a3dcd45ef 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -150,13 +150,13 @@ private void ReadDocumentInstructions(MarkdownDocument document) var contents = document .Descendants() .Where(block => block is { Level: >= 2 }) - .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string)) + .Select(h => (h.GetData("header") as string, h.GetData("anchor") as string, h.Level)) .Select(h => { var header = h.Item1!.StripMarkdown(); if (header.AsSpan().ReplaceSubstitutions(subs, out var replacement)) header = replacement; - return new PageTocItem { Heading = header!, Slug = (h.Item2 ?? header).Slugify() }; + return new PageTocItem { Heading = header!, Slug = (h.Item2 ?? header).Slugify(), Level = h.Level }; }) .ToList(); diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml index 3abb070d1..14cd141e3 100644 --- a/src/Elastic.Markdown/Slices/Index.cshtml +++ b/src/Elastic.Markdown/Slices/Index.cshtml @@ -5,7 +5,7 @@ public LayoutViewModel LayoutModel => new() { Title = $"Elastic Documentation: {Model.Title}", - PageTocItems = Model.PageTocItems, + PageTocItems = Model.PageTocItems.Where(i => i is { Level: 2 or 3 }).ToList(), Tree = Model.Tree, CurrentDocument = Model.CurrentDocument, Previous = Model.PreviousDocument, diff --git a/src/Elastic.Markdown/Slices/Layout/_Breadcrumbs.cshtml b/src/Elastic.Markdown/Slices/Layout/_Breadcrumbs.cshtml index 1fd63a0d9..790c0baca 100644 --- a/src/Elastic.Markdown/Slices/Layout/_Breadcrumbs.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_Breadcrumbs.cshtml @@ -1,5 +1,5 @@ @inherits RazorSlice -
    +
    1. Elastic @@ -16,4 +16,11 @@
    2. } +
    3. + / + + @Model.CurrentDocument.NavigationTitle + + +
    diff --git a/src/Elastic.Markdown/Slices/Layout/_Footer.cshtml b/src/Elastic.Markdown/Slices/Layout/_Footer.cshtml index 1d55b038c..80999f6d0 100644 --- a/src/Elastic.Markdown/Slices/Layout/_Footer.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_Footer.cshtml @@ -1,12 +1,19 @@ - + +} diff --git a/src/Elastic.Markdown/Slices/Layout/_Header.cshtml b/src/Elastic.Markdown/Slices/Layout/_Header.cshtml index 739db0d21..08c58a81a 100644 --- a/src/Elastic.Markdown/Slices/Layout/_Header.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_Header.cshtml @@ -1,5 +1,5 @@ @inherits RazorSlice -
    +
    diff --git a/src/Elastic.Markdown/Slices/Layout/_PagesNav.cshtml b/src/Elastic.Markdown/Slices/Layout/_PagesNav.cshtml index 5edb1853b..e0e9455a2 100644 --- a/src/Elastic.Markdown/Slices/Layout/_PagesNav.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_PagesNav.cshtml @@ -1,6 +1,6 @@ @inherits RazorSlice - + +} diff --git a/src/Elastic.Markdown/Slices/Layout/_TocTree.cshtml b/src/Elastic.Markdown/Slices/Layout/_TocTree.cshtml index 6751687bd..23016b5e8 100644 --- a/src/Elastic.Markdown/Slices/Layout/_TocTree.cshtml +++ b/src/Elastic.Markdown/Slices/Layout/_TocTree.cshtml @@ -3,6 +3,7 @@ @if (Model.IsRedesign) {
    +
    Elastic Docs
    -@(await RenderPartialAsync<_Footer>()) +@(await RenderPartialAsync(_Footer.Create(Model))) @(await RenderPartialAsync(_Scripts.Create(Model))) @await RenderSectionAsync("scripts") diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs index 65d74724a..99d14929b 100644 --- a/src/Elastic.Markdown/Slices/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/_ViewModels.cs @@ -73,6 +73,7 @@ public class PageTocItem { public required string Heading { get; init; } public required string Slug { get; init; } + public required int Level { get; init; } }