Skip to content
Open
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
2 changes: 0 additions & 2 deletions src/components/ArticleNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { accelerator } from '@lib/accelerator';
import { SITE } from '@config';
import { Translations, Lang } from '@util/Languages';
import Separator from './Separator.astro';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be deleted now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah looks like it's unused except for the components demo page.

I think I'd prefer to leave it though, in case we need to put it back for some reason, and file a card to clean it up at the end, if that's OK?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, given that the spelling error caused me to need to re-spin the PR, I deleted the component.

Claude keeps writing comments in the CSS which say "colour" but our spelling requires AMERICAN :-(


const stats = new accelerator.statistics('octopus/components/ArticleNav.astro');
stats.start();
Expand Down Expand Up @@ -31,7 +30,6 @@ stats.stop();
<summary class="article-nav__title">
{_(Translations.toc.title)}
</summary>
<Separator />
<ol class="article-nav__list">
{headings.map((heading) => (
<li
Expand Down
5 changes: 0 additions & 5 deletions src/components/Separator.astro

This file was deleted.

3 changes: 1 addition & 2 deletions src/pages/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import Card from 'src/components/Card.astro';
import IconTile from 'src/components/IconTile.astro';
import Image from 'src/components/Image.astro';
import Link from 'src/components/Link.astro';
import Separator from 'src/components/Separator.astro';

## Component Usage Guide

Expand All @@ -34,7 +33,7 @@ To use Astro components like `Card` and `Separator` in your articles, you will n

```javascript
import Card from 'src/components/Card.astro';
import Separator from 'src/components/Separator.astro';
import Link from 'src/components/Link.astro';
```

## Components usage
Expand Down
131 changes: 82 additions & 49 deletions src/scripts/modules/toc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,82 +2,115 @@

import { qsa } from './query.js';

let links = [];
let current = '';
const headings = [];
const highlightClass = 'highlight';

/**
* Makes an entire block clickable based on a data-attribute, usually "data-destination"
* Marks the link in a table of contents whose section the reader is in, and
* keeps it in step as the page scrolls.
*
* Example: You have a list of blog posts, including featured images. If you make the title
* clickable, clicks on the image won't open the blog. Adding links to the images means
* keyboard users have to tab twice as much to get through the list.
*
* Use clickable blocks to allow keyboard users to tab through the real links, but still
* capture clicks elsewhere on the block.
* Every table of contents on the page gets its own call, and each keeps its
* own state — a page can show more than one at a time.
*
* @param {string} tocSelector selector for the links of one table of contents
*/
function highlightCurrentHeading(tocSelector) {
links = qsa(tocSelector);
/** @type {{link: HTMLElement, heading: HTMLElement}[]} */
const entries = [];

qsa(tocSelector).forEach((link) => {
const id = getBookmarkLink(link.href);
const heading = id ? document.getElementById(id) : null;

links.forEach((link) => {
const bookmarkLink = getBookmarkLink(link.href);
if (bookmarkLink) {
headings.push(document.getElementById(bookmarkLink));
// A link can outlive its heading — a stale anchor, or a heading rendered
// conditionally. Those links just never highlight.
if (heading) {
entries.push({ link, heading });
}
});

recheck();
}

function getBookmarkLink(link) {
const linkParts = link.split('#');
if (linkParts.length === 2) {
return linkParts[1];
if (entries.length === 0) {
return;
}

return '';
}
/** @type {{link: HTMLElement, heading: HTMLElement} | undefined} */
let current;
let queued = false;

function highlight(id) {
links.forEach((link) => {
link.classList.remove(highlightClass);
const update = () => {
queued = false;

const bookmarkLink = getBookmarkLink(link.href);
if (bookmarkLink === id) {
link.classList.add(highlightClass);
const entry = currentEntry(entries);
if (entry === current) {
return;
}
});

current = entry;
entries.forEach((candidate) => {
candidate.link.classList.toggle(highlightClass, candidate === entry);
});
};

// Scroll fires far more often than the page can paint, so the reading is
// taken once per frame at most.
const queue = () => {
if (!queued) {
queued = true;
window.requestAnimationFrame(update);
}
};

update();
window.addEventListener('scroll', queue, { passive: true });
window.addEventListener('resize', queue);
}

function recheck() {
const docTop = Math.floor(document.documentElement.scrollTop);
const vh = Math.max(
document.documentElement.clientHeight || 0,
window.innerHeight || 0
);
/**
* The section the reader is in: the last heading to have passed the line that
* `scroll-padding-block-start` parks a clicked anchor on. Clicking a link in
* the table of contents therefore always highlights the link that was clicked.
*
* Above the first heading the first section is used, and at the bottom of the
* page the last one is, so a final section too short to scroll to the line
* still gets its turn.
*
* @param {{link: HTMLElement, heading: HTMLElement}[]} entries
*/
function currentEntry(entries) {
const doc = document.documentElement;

if (Math.ceil(window.scrollY + window.innerHeight) >= doc.scrollHeight) {
return entries[entries.length - 1];
}

const validItems = [];
// `scrollPaddingTop` is `auto` when the page sets no scroll padding.
const line = parseFloat(getComputedStyle(doc).scrollPaddingTop) || 0;

headings.forEach((elem) => {
const hasPassed = elem.offsetTop < docTop;
const inView = elem.offsetTop > docTop && elem.offsetTop < docTop + vh;
const isValid = docTop + vh - elem.offsetTop > vh / 1.5;
let current = entries[0];

if (isValid) {
validItems.push(elem);
entries.forEach((entry) => {
// A clicked heading lands exactly on the line, so allow a pixel for the
// browser's rounding of the scroll position.
if (entry.heading.getBoundingClientRect().top <= line + 1) {
current = entry;
}
});

const item = validItems.pop();
return current;
}

if (item && item.id !== current) {
current = item.id;
highlight(item.id);
/**
* The fragment of a link's href, if it has one.
*
* @param {string} link
*/
function getBookmarkLink(link) {
const linkParts = link.split('#');

if (linkParts.length === 2) {
return linkParts[1];
}

window.setTimeout(recheck, 1000);
return '';
}

export { highlightCurrentHeading };
Loading
Loading