diff --git a/.changeset/heavy-bees-ring.md b/.changeset/heavy-bees-ring.md
new file mode 100644
index 00000000..52132c7a
--- /dev/null
+++ b/.changeset/heavy-bees-ring.md
@@ -0,0 +1,7 @@
+---
+"@myst-theme/site": patch
+"@myst-theme/article": patch
+"@myst-theme/book": patch
+---
+
+🔍 Make document outline collapsible
diff --git a/packages/search-minisearch/package.json b/packages/search-minisearch/package.json
index 02fb299f..1d8da2e5 100644
--- a/packages/search-minisearch/package.json
+++ b/packages/search-minisearch/package.json
@@ -13,8 +13,6 @@
"clean": "rimraf dist",
"lint": "eslint \"src/**/*.ts*\" -c ./.eslintrc.cjs",
"lint:format": "prettier --check \"src/**/*.{ts,tsx,md}\"",
- "test": "vitest run",
- "test:watch": "vitest watch",
"build:esm": "tsc --project ./tsconfig.json --module Node16 --outDir dist --declaration",
"build": "npm-run-all -l clean -p build:esm"
},
diff --git a/packages/search/package.json b/packages/search/package.json
index 20f7ccf4..ea3d9a4a 100644
--- a/packages/search/package.json
+++ b/packages/search/package.json
@@ -13,8 +13,6 @@
"clean": "rimraf dist",
"lint": "eslint \"src/**/*.ts*\" -c ./.eslintrc.cjs",
"lint:format": "prettier --check \"src/**/*.{ts,tsx,md}\"",
- "test": "vitest run",
- "test:watch": "vitest watch",
"build:esm": "tsc --project ./tsconfig.json --module Node16 --outDir dist --declaration",
"build": "npm-run-all -l clean -p build:esm"
},
diff --git a/packages/site/src/components/DocumentOutline.tsx b/packages/site/src/components/DocumentOutline.tsx
index 494bca3a..d3cd63a3 100644
--- a/packages/site/src/components/DocumentOutline.tsx
+++ b/packages/site/src/components/DocumentOutline.tsx
@@ -8,10 +8,12 @@ import { useNavigation } from '@remix-run/react';
import classNames from 'classnames';
import throttle from 'lodash.throttle';
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import type { RefObject } from 'react';
import { DocumentChartBarIcon } from '@heroicons/react/24/outline';
+import { ChevronRightIcon } from '@heroicons/react/24/solid';
+import * as Collapsible from '@radix-ui/react-collapsible';
const SELECTOR = [1, 2, 3, 4].map((n) => `main h${n}`).join(', ');
-const HIGHLIGHT_CLASS = 'highlight';
const onClient = typeof document !== 'undefined';
@@ -25,15 +27,13 @@ export type Heading = {
type Props = {
headings: Heading[];
- selector: string;
activeId?: string;
- highlight?: () => void;
};
/**
* This renders an item in the table of contents list.
* scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll.
*/
-const Headings = ({ headings, activeId, highlight, selector }: Props) => (
+const Headings = ({ headings, activeId }: Props) => (
{headings.map((heading) => (
- (
e.preventDefault();
const el = document.querySelector(`#${heading.id}`);
if (!el) return;
- getHeaders(selector).forEach((h) => {
- h.classList.remove(HIGHLIGHT_CLASS);
- });
- el.classList.add(HIGHLIGHT_CLASS);
- highlight?.();
+
el.scrollIntoView({ behavior: 'smooth' });
history.replaceState(undefined, '', `#${heading.id}`);
}}
@@ -105,15 +101,111 @@ function getHeaders(selector: string): HTMLHeadingElement[] {
return headers as HTMLHeadingElement[];
}
+type MutationCallback = (mutations: MutationRecord[], observer: MutationObserver) => void;
+
+function useMutationObserver(
+ targetRef: RefObject,
+ callback: MutationCallback,
+ options: Record,
+) {
+ const [observer, setObserver] = useState(null);
+
+ if (!onClient) return { observer };
+
+ // Create observer
+ useEffect(() => {
+ const obs = new MutationObserver(callback);
+ setObserver(obs);
+ }, [callback, setObserver]);
+
+ // Setup observer
+ useEffect(() => {
+ if (!observer || !targetRef.current) {
+ return;
+ }
+
+ try {
+ observer.observe(targetRef.current, options);
+ } catch (e) {
+ console.error(e);
+ }
+ return () => {
+ if (observer) {
+ observer.disconnect();
+ }
+ };
+ }, [observer]);
+}
+
+const useIntersectionObserver = (elements: Element[], options?: Record) => {
+ const [observer, setObserver] = useState(null);
+ const [intersecting, setIntersecting] = useState([]);
+
+ if (!onClient) return { observer };
+ useEffect(() => {
+ const cb: IntersectionObserverCallback = (entries) => {
+ setIntersecting(entries.filter((e) => e.isIntersecting).map((e) => e.target));
+ };
+ const o = new IntersectionObserver(cb, options ?? {});
+ setObserver(o);
+ return () => o.disconnect();
+ }, []);
+
+ // Changes to the DOM mean we need to update our intersection observer
+ useEffect(() => {
+ if (!observer) {
+ return;
+ }
+ // Observe all heading elements
+ const toWatch = elements;
+ toWatch.map((e) => observer.observe(e));
+ // Cleanup afterwards
+ return () => {
+ toWatch.map((e) => observer.unobserve(e));
+ };
+ }, [elements]);
+
+ return { observer, intersecting };
+};
+
+/**
+ * Keep track of which headers are visible, and which header is active
+ */
export function useHeaders(selector: string, maxdepth: number) {
if (!onClient) return { activeId: '', headings: [] };
- const onScreen = useRef>(new Set());
+ // Keep track of main manually for now
+ const mainElementRef = useRef(null);
+ useEffect(() => {
+ mainElementRef.current = document.querySelector('main');
+ }, []);
+
+ // Track changes to the DOM
+ const [elements, setElements] = useState([]);
+ const onMutation = useCallback(
+ throttle(
+ () => {
+ setElements(getHeaders(selector));
+ },
+ 500,
+ { trailing: false },
+ ),
+ [selector],
+ );
+ useMutationObserver(mainElementRef, onMutation, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+
+ // Trigger initial update
+ useEffect(onMutation, []);
+
+ // Watch intersections with headings
+ const { intersecting } = useIntersectionObserver(elements);
const [activeId, setActiveId] = useState();
- const headingsSet = useRef>(new Set());
- const highlight = useCallback(() => {
- const current = [...onScreen.current];
- const highlighted = current.reduce(
+ useEffect(() => {
+ const highlighted = intersecting!.reduce(
(a, b) => {
if (a) return a;
if (b.classList.contains('highlight')) return b.id;
@@ -121,80 +213,43 @@ export function useHeaders(selector: string, maxdepth: number) {
},
null as string | null,
);
- const active = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0];
+ const active = [...(intersecting as HTMLElement[])].sort(
+ (a, b) => a.offsetTop - b.offsetTop,
+ )[0];
if (highlighted || active) setActiveId(highlighted || active.id);
- }, []);
-
- const { observer } = useIntersectionObserver(highlight, onScreen.current);
- const [elements, setElements] = useState([]);
+ }, [intersecting]);
- const render = throttle(() => setElements(getHeaders(selector)), 500);
+ const [headings, setHeadings] = useState([]);
useEffect(() => {
- // We have to look at the document changes for reloads/mutations
- const main = document.querySelector('main');
- const mutations = new MutationObserver(render);
- // Fire when added to the dom
- render();
- if (main) {
- mutations.observe(main, { attributes: true, childList: true, subtree: true });
- }
- return () => mutations.disconnect();
- }, []);
-
- useEffect(() => {
- // Re-observe all elements when the observer changes
- Array.from(elements).map((e) => observer.current?.observe(e));
- }, [observer]);
+ let minLevel = 10;
+ const thisHeadings: Heading[] = elements
+ .map((element) => {
+ return {
+ element,
+ level: Number(element.tagName.slice(1)),
+ id: element.id,
+ text: element.querySelector('.heading-text'),
+ };
+ })
+ .filter((h) => !!h.text)
+ .map(({ element, level, text, id }) => {
+ const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
+ text as HTMLSpanElement,
+ );
+ minLevel = Math.min(minLevel, level);
+ return { element, title, titleHTML, id, level };
+ })
+ .filter((heading) => {
+ heading.level = heading.level - minLevel + 1;
+ return heading.level < maxdepth + 1;
+ });
- let minLevel = 10;
- const headings: Heading[] = elements
- .map((element) => {
- return {
- element,
- level: Number(element.tagName.slice(1)),
- id: element.id,
- text: element.querySelector('.heading-text'),
- };
- })
- .filter((h) => !!h.text)
- .map(({ element, level, text, id }) => {
- const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
- text as HTMLSpanElement,
- );
- minLevel = Math.min(minLevel, level);
- return { element, title, titleHTML, id, level };
- })
- .filter((heading) => {
- heading.level = heading.level - minLevel + 1;
- return heading.level < maxdepth + 1;
- });
-
- headings.forEach(({ element: e }) => {
- if (headingsSet.current.has(e)) return;
- observer.current?.observe(e);
- headingsSet.current.add(e);
- });
+ setHeadings(thisHeadings);
+ }, [elements]);
- return { activeId, highlight, headings };
+ return { activeId, headings };
}
-const useIntersectionObserver = (highlight: () => void, onScreen: Set) => {
- const observer = useRef(null);
- if (!onClient) return { observer };
- useEffect(() => {
- const callback: IntersectionObserverCallback = (entries) => {
- entries.forEach((entry) => {
- onScreen[entry.isIntersecting ? 'add' : 'delete'](entry.target as HTMLHeadingElement);
- });
- highlight();
- };
- const o = new IntersectionObserver(callback);
- observer.current = o;
- return () => o.disconnect();
- }, [highlight, onScreen]);
- return { observer };
-};
-
export function useOutlineHeight(
existingContainer?: React.RefObject,
) {
@@ -226,6 +281,71 @@ export function useOutlineHeight(
return { container, outline };
}
+/**
+ * Determine whether the margin outline should be occluded by margin elements
+ */
+function useMarginOccluder() {
+ const [occluded, setOccluded] = useState(false);
+ const [elements, setElements] = useState([]);
+
+ // Keep track of main manually for now
+ const mainElementRef = useRef(null);
+ useEffect(() => {
+ mainElementRef.current = document.querySelector('main');
+ }, []);
+
+ // Update list of margin elements
+ const onMutation = useCallback(
+ throttle(
+ () => {
+ if (!mainElementRef.current) {
+ return;
+ }
+ // Watch margin elements, or their direct descendents (as some margin elements have height set to zero)
+ const classes = [
+ 'col-margin-right',
+ 'col-margin-right-inset',
+ 'col-gutter-outset-right',
+ 'col-screen-right',
+ 'col-screen-inset-right',
+ 'col-page-right',
+ 'col-page-inset-right',
+ 'col-body-outset-right',
+ 'col-gutter-page-right',
+ // 'col-screen', // This is on everything!
+ 'col-page',
+ 'col-page-inset',
+ 'col-body-outset',
+ ];
+ const selector = classes
+ .map((cls) => [`.${cls}`, `.${cls} > *`])
+ .flat()
+ .join(', ');
+ const marginElements = mainElementRef.current.querySelectorAll(selector);
+ setElements(Array.from(marginElements));
+ },
+ 500,
+ { trailing: false },
+ ),
+ [],
+ );
+ useMutationObserver(mainElementRef, onMutation, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+
+ // Trigger initial update
+ useEffect(onMutation, []);
+ // Keep tabs of margin elements on screen
+ const { intersecting } = useIntersectionObserver(elements, { rootMargin: '0px 0px -33% 0px' });
+ useEffect(() => {
+ setOccluded(intersecting!.length > 0);
+ }, [intersecting]);
+
+ return { occluded };
+}
+
export const DocumentOutline = ({
outlineRef,
top = 0,
@@ -233,6 +353,7 @@ export const DocumentOutline = ({
selector = SELECTOR,
children,
maxdepth = 4,
+ isMargin,
}: {
outlineRef?: React.RefObject;
top?: number;
@@ -241,31 +362,63 @@ export const DocumentOutline = ({
selector?: string;
children?: React.ReactNode;
maxdepth?: number;
+ isMargin: boolean;
}) => {
- const { activeId, headings, highlight } = useHeaders(selector, maxdepth);
+ const { activeId, headings } = useHeaders(selector, maxdepth);
+ const [open, setOpen] = useState(false);
+
+ // Keep track of changing occlusion
+ const { occluded } = useMarginOccluder();
+
+ // Handle transition between margin and non-margin
+ useEffect(() => {
+ setOpen(true);
+ }, [isMargin]);
+
+ // Handle occlusion when outline is in margin
+ useEffect(() => {
+ if (isMargin) {
+ setOpen(!occluded);
+ }
+ }, [occluded, isMargin]);
+
if (headings.length <= 1 || !onClient) {
return ;
}
+
return (
-
+
+
+
);
};
diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx
index 7d551480..04acab57 100644
--- a/themes/article/app/components/Article.tsx
+++ b/themes/article/app/components/Article.tsx
@@ -11,7 +11,7 @@ import {
} from '@myst-theme/site';
import { ErrorTray, NotebookToolbar, useComputeOptions } from '@myst-theme/jupyter';
import { FrontmatterBlock } from '@myst-theme/frontmatter';
-import { ReferencesProvider, useThemeTop } from '@myst-theme/providers';
+import { ReferencesProvider, useThemeTop, useMediaQuery } from '@myst-theme/providers';
import type { GenericParent } from 'myst-common';
import { copyNode } from 'myst-common';
import { BusyScopeProvider, ConnectionStatusTray, ExecuteScopeProvider } from '@myst-theme/jupyter';
@@ -38,7 +38,7 @@ export function Article({
const { title, subtitle } = article.frontmatter;
const compute = useComputeOptions();
const top = useThemeTop();
-
+ const isOutlineMargin = useMediaQuery('(min-width: 1024px)');
return (
-
+
diff --git a/themes/book/app/components/ArticlePage.tsx b/themes/book/app/components/ArticlePage.tsx
index b50a303c..63e29bcc 100644
--- a/themes/book/app/components/ArticlePage.tsx
+++ b/themes/book/app/components/ArticlePage.tsx
@@ -4,6 +4,7 @@ import {
useProjectManifest,
useSiteManifest,
useThemeTop,
+ useMediaQuery,
} from '@myst-theme/providers';
import {
Bibliography,
@@ -14,7 +15,6 @@ import {
DocumentOutline,
extractKnownParts,
Footnotes,
- DEFAULT_NAV_HEIGHT,
} from '@myst-theme/site';
import type { SiteManifest } from 'myst-config';
import type { PageLoader } from '@myst-theme/common';
@@ -75,7 +75,7 @@ export const ArticlePage = React.memo(function ({
const tree = copyNode(article.mdast);
const keywords = article.frontmatter?.keywords ?? [];
const parts = extractKnownParts(tree);
-
+ const isOutlineMargin = useMediaQuery('(min-width: 1024px)');
return (
-
+
)}
{compute?.enabled &&