diff --git a/src/components/markdown/markdown.scss b/src/components/markdown/markdown.scss index a57f96f..8c546bf 100644 --- a/src/components/markdown/markdown.scss +++ b/src/components/markdown/markdown.scss @@ -3,6 +3,36 @@ @use '../../style/mixins.scss'; @use '../../style/functions.scss'; +// Anchor links on headings +h1, +h2, +h3, +h4, +h5, +h6 { + position: relative; + + .anchor-link { + position: absolute; + left: -1.5em; + padding-right: 0.5em; + color: rgb(var(--kompendium-contrast-700)); + text-decoration: none; + font-weight: normal; + opacity: 0; + transition: opacity 0.15s ease-in-out; + + &:hover { + color: rgb(var(--kompendium-color-turquoise)); + } + } + + &:hover .anchor-link, + .anchor-link:focus { + opacity: 1; + } +} + .admonition { --size-of-admonition-icon: 2.5rem; --border-radius-of-admonition-icon: 0.5rem; diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index a459bab..480dd6f 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -1,7 +1,7 @@ import { Component, h, Prop, Element } from '@stencil/core'; import { markdownToHtml } from '../../kompendium/markdown'; import { getTypes } from './markdown-types'; -import { scrollToAnchor } from '../anchor-scroll'; +import { getRoute, scrollToAnchor } from '../anchor-scroll'; /** * This component renders markdown @@ -62,10 +62,68 @@ export class Markdown { this.host.shadowRoot.querySelector('#root').innerHTML = file?.toString(); + // Add anchor links to headings + this.addAnchorLinks(); + // After content renders, scroll to anchor if present in URL scrollToAnchor(this.host.shadowRoot); } + private addAnchorLinks(): void { + const root = this.host.shadowRoot.querySelector('#root'); + const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6'); + + headings.forEach((heading: HTMLElement) => { + if (!heading.id) { + return; + } + + // Skip if anchor link already exists + if (heading.querySelector('.anchor-link')) { + return; + } + + const anchor = document.createElement('a'); + anchor.className = 'anchor-link'; + anchor.href = this.getAnchorHref(heading.id); + anchor.setAttribute('aria-label', `Link to ${heading.textContent}`); + anchor.innerHTML = '#'; + anchor.addEventListener('click', (event) => { + this.handleAnchorClick(event, heading.id); + }); + + heading.appendChild(anchor); + }); + } + + private getAnchorHref(id: string): string { + const route = getRoute(); + const routeWithoutAnchor = route.split('#')[0]; + + if (!routeWithoutAnchor) { + return `#${id}`; + } + + return `#${routeWithoutAnchor}#${id}`; + } + + private handleAnchorClick(event: MouseEvent, id: string): void { + event.preventDefault(); + + const url = this.getAnchorHref(id); + const fullUrl = `${window.location.origin}${window.location.pathname}${window.location.search}${url}`; + + // Update the URL + window.history.pushState(null, '', url); + + // Copy to clipboard + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(fullUrl).catch(() => { + // Silently fail if clipboard write fails + }); + } + } + render(): HTMLElement { return
; }