Skip to content

add link icon to section headers #1755

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions assets/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@ section.prose {
@apply text-lg font-medium pb-3 border-b border-b-redis-pen-700 border-opacity-50;
}

/* Header link styles */
.header-link {
@apply text-slate-400 hover:text-slate-600 transition-all duration-200 no-underline cursor-pointer;
text-decoration: none !important;
vertical-align: baseline;
}

.header-link:hover {
@apply text-slate-600;
text-decoration: none !important;
}

.header-link svg {
@apply w-4 h-4 inline-block;
vertical-align: baseline;
}

/* Ensure header links don't interfere with prose styling */
.prose h1 .header-link,
.prose h2 .header-link,
.prose h3 .header-link,
.prose h4 .header-link,
.prose h5 .header-link,
.prose h6 .header-link {
@apply text-slate-400 hover:text-slate-600;
text-decoration: none !important;
}

/* Feedback state for copied links */
.header-link.copied {
@apply text-green-500;
}

.prose p, .prose ol, .prose ul {
@apply text-base;
}
Expand Down
11 changes: 11 additions & 0 deletions layouts/_default/_markup/render-heading.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{- $anchor := .Anchor | safeURL -}}
{{- $level := .Level -}}
{{- $text := .Text | safeHTML -}}
<h{{ $level }} id="{{ $anchor }}" class="group relative">
{{ $text }}
<a href="#{{ $anchor }}" class="header-link opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-1 align-baseline" aria-label="Link to this section" title="Copy link to clipboard">
<svg class="inline-block w-4 h-4 align-baseline" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"></path>
</svg>
</a>
</h{{ $level }}>
6 changes: 4 additions & 2 deletions layouts/partials/docs-toc.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{{ $showEmbedHeaders := .Params.tocEmbedHeaders }}
{{ $headerRange := .Params.headerRange | default "[1-3]" }}
<!-- Ignore empty links with + -->
{{ $headers := findRE ( print "<h" $headerRange ".*?>(.|\n])+?</h" $headerRange ">") .Content }}
{{ $headers := findRE ( print "<h" $headerRange ".*?>(.|\n)+?</h" $headerRange ">") .Content }}
<!-- Must have at least one header to link to -->
{{ $has_headers := ge (len $headers) 1 }}

Expand All @@ -31,7 +31,9 @@ <h2 class="font-medium my-3">On this page</h2>
<!-- Close previous list item -->
</li>
{{ end }}
<li><a href="#{{ $anchorID }}">{{ $header | plainify | safeHTML }}</a>
<!-- Extract text content, excluding the header link icon -->
{{ $headerText := $header | replaceRE `<a[^>]*class="header-link"[^>]*>.*?</a>` "" | plainify | safeHTML }}
<li><a href="#{{ $anchorID }}">{{ $headerText }}</a>
{{ $prevLevel = $level }}
{{ end }}
<!-- Close remaining open lists -->
Expand Down
65 changes: 65 additions & 0 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,74 @@ const mobileMenu = (() => {
toggleMenu('products-mobile-menu', 'productsMobileMenuState')
} else if (event.target.closest('[data-resources-mobile-menu-toggle]')) {
toggleMenu('resources-mobile-menu', 'resourcesMobileMenuState')
} else if (event.target.closest('.header-link')) {
// Handle header link clicks
event.preventDefault()
copyHeaderLinkToClipboard(event.target.closest('.header-link'))
}
}

// Copy header link URL to clipboard
function copyHeaderLinkToClipboard(linkElement) {
const href = linkElement.getAttribute('href')
const fullUrl = window.location.origin + window.location.pathname + href

// Update the URL hash to provide immediate visual feedback
window.location.hash = href

// Copy to clipboard
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(fullUrl).then(() => {
showCopyFeedback(linkElement)
}).catch(err => {
console.error('Failed to copy link: ', err)
fallbackCopyToClipboard(fullUrl, linkElement)
})
} else {
// Fallback for older browsers
fallbackCopyToClipboard(fullUrl, linkElement)
}
}

// Show visual feedback when link is copied
function showCopyFeedback(linkElement) {
const originalTitle = linkElement.getAttribute('title')

linkElement.setAttribute('title', 'Copied!')
linkElement.classList.add('copied')

setTimeout(() => {
linkElement.setAttribute('title', originalTitle)
linkElement.classList.remove('copied')
}, 2000)
}

// Fallback copy method for older browsers
function fallbackCopyToClipboard(text, linkElement) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()

try {
const successful = document.execCommand('copy')
if (successful) {
showCopyFeedback(linkElement)
console.log('Link copied to clipboard (fallback)')
} else {
console.error('Fallback copy failed')
}
} catch (err) {
console.error('Fallback copy failed: ', err)
}

document.body.removeChild(textArea)
}

function allowFocus(selector, state) {
const container = document.querySelector(selector)
const focusable = container.querySelectorAll('button, [href], input, select, textarea')
Expand Down