@myst-theme/site 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myst-theme/site",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -21,23 +21,31 @@
21
21
  "dependencies": {
22
22
  "@headlessui/react": "^1.7.15",
23
23
  "@heroicons/react": "^2.0.18",
24
- "@myst-theme/common": "^0.11.0",
25
- "@myst-theme/diagrams": "^0.11.0",
26
- "@myst-theme/frontmatter": "^0.11.0",
27
- "@myst-theme/jupyter": "^0.11.0",
28
- "@myst-theme/providers": "^0.11.0",
24
+ "@myst-theme/common": "^0.13.0",
25
+ "@myst-theme/diagrams": "^0.13.0",
26
+ "@myst-theme/frontmatter": "^0.13.0",
27
+ "@myst-theme/jupyter": "^0.13.0",
28
+ "@myst-theme/providers": "^0.13.0",
29
+ "@myst-theme/search": "^0.13.0",
29
30
  "@radix-ui/react-collapsible": "^1.0.3",
31
+ "@radix-ui/react-dialog": "^1.0.3",
32
+ "@radix-ui/react-radio-group": "^1.2.0",
33
+ "@radix-ui/react-roving-focus": "^1.1.0",
34
+ "@radix-ui/react-slot": "^1.1.0",
35
+ "@radix-ui/react-visually-hidden": "^1.1.0",
30
36
  "classnames": "^2.3.2",
31
37
  "lodash.throttle": "^4.1.1",
32
- "myst-common": "^1.6.0",
33
- "myst-config": "^1.6.0",
34
- "myst-demo": "^0.11.0",
35
- "myst-spec-ext": "^1.6.0",
36
- "myst-to-react": "^0.11.0",
38
+ "myst-common": "^1.7.0",
39
+ "myst-config": "^1.7.0",
40
+ "myst-demo": "^0.13.0",
41
+ "myst-spec-ext": "^1.7.0",
42
+ "myst-to-react": "^0.13.0",
37
43
  "nbtx": "^0.2.3",
38
44
  "node-cache": "^5.1.2",
39
45
  "node-fetch": "^2.6.11",
40
- "thebe-react": "0.4.7",
46
+ "react-merge-refs": "^2.1.1",
47
+ "string.prototype.matchall": "^4.0.11",
48
+ "thebe-react": "0.4.10",
41
49
  "unist-util-select": "^4.0.1"
42
50
  },
43
51
  "peerDependencies": {
@@ -0,0 +1 @@
1
+ export * from './theme.js';
@@ -0,0 +1,8 @@
1
+ import type { Theme } from '@myst-theme/common';
2
+
3
+ export function postThemeToAPI(theme: Theme) {
4
+ const xmlhttp = new XMLHttpRequest();
5
+ xmlhttp.open('POST', '/api/theme');
6
+ xmlhttp.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
7
+ xmlhttp.send(JSON.stringify({ theme }));
8
+ }
@@ -8,10 +8,12 @@ import { useNavigation } from '@remix-run/react';
8
8
  import classNames from 'classnames';
9
9
  import throttle from 'lodash.throttle';
10
10
  import React, { useCallback, useEffect, useRef, useState } from 'react';
11
+ import type { RefObject } from 'react';
11
12
  import { DocumentChartBarIcon } from '@heroicons/react/24/outline';
13
+ import { ChevronRightIcon } from '@heroicons/react/24/solid';
14
+ import * as Collapsible from '@radix-ui/react-collapsible';
12
15
 
13
16
  const SELECTOR = [1, 2, 3, 4].map((n) => `main h${n}`).join(', ');
14
- const HIGHLIGHT_CLASS = 'highlight';
15
17
 
16
18
  const onClient = typeof document !== 'undefined';
17
19
 
@@ -25,15 +27,13 @@ export type Heading = {
25
27
 
26
28
  type Props = {
27
29
  headings: Heading[];
28
- selector: string;
29
30
  activeId?: string;
30
- highlight?: () => void;
31
31
  };
32
32
  /**
33
33
  * This renders an item in the table of contents list.
34
34
  * scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll.
35
35
  */
36
- const Headings = ({ headings, activeId, highlight, selector }: Props) => (
36
+ const Headings = ({ headings, activeId }: Props) => (
37
37
  <ul className="text-sm leading-6 text-slate-400">
38
38
  {headings.map((heading) => (
39
39
  <li
@@ -62,11 +62,7 @@ const Headings = ({ headings, activeId, highlight, selector }: Props) => (
62
62
  e.preventDefault();
63
63
  const el = document.querySelector(`#${heading.id}`);
64
64
  if (!el) return;
65
- getHeaders(selector).forEach((h) => {
66
- h.classList.remove(HIGHLIGHT_CLASS);
67
- });
68
- el.classList.add(HIGHLIGHT_CLASS);
69
- highlight?.();
65
+
70
66
  el.scrollIntoView({ behavior: 'smooth' });
71
67
  history.replaceState(undefined, '', `#${heading.id}`);
72
68
  }}
@@ -105,15 +101,111 @@ function getHeaders(selector: string): HTMLHeadingElement[] {
105
101
  return headers as HTMLHeadingElement[];
106
102
  }
107
103
 
104
+ type MutationCallback = (mutations: MutationRecord[], observer: MutationObserver) => void;
105
+
106
+ function useMutationObserver(
107
+ targetRef: RefObject<Element>,
108
+ callback: MutationCallback,
109
+ options: Record<string, any>,
110
+ ) {
111
+ const [observer, setObserver] = useState<MutationObserver | null>(null);
112
+
113
+ if (!onClient) return { observer };
114
+
115
+ // Create observer
116
+ useEffect(() => {
117
+ const obs = new MutationObserver(callback);
118
+ setObserver(obs);
119
+ }, [callback, setObserver]);
120
+
121
+ // Setup observer
122
+ useEffect(() => {
123
+ if (!observer || !targetRef.current) {
124
+ return;
125
+ }
126
+
127
+ try {
128
+ observer.observe(targetRef.current, options);
129
+ } catch (e) {
130
+ console.error(e);
131
+ }
132
+ return () => {
133
+ if (observer) {
134
+ observer.disconnect();
135
+ }
136
+ };
137
+ }, [observer]);
138
+ }
139
+
140
+ const useIntersectionObserver = (elements: Element[], options?: Record<string, any>) => {
141
+ const [observer, setObserver] = useState<IntersectionObserver | null>(null);
142
+ const [intersecting, setIntersecting] = useState<Element[]>([]);
143
+
144
+ if (!onClient) return { observer };
145
+ useEffect(() => {
146
+ const cb: IntersectionObserverCallback = (entries) => {
147
+ setIntersecting(entries.filter((e) => e.isIntersecting).map((e) => e.target));
148
+ };
149
+ const o = new IntersectionObserver(cb, options ?? {});
150
+ setObserver(o);
151
+ return () => o.disconnect();
152
+ }, []);
153
+
154
+ // Changes to the DOM mean we need to update our intersection observer
155
+ useEffect(() => {
156
+ if (!observer) {
157
+ return;
158
+ }
159
+ // Observe all heading elements
160
+ const toWatch = elements;
161
+ toWatch.map((e) => observer.observe(e));
162
+ // Cleanup afterwards
163
+ return () => {
164
+ toWatch.map((e) => observer.unobserve(e));
165
+ };
166
+ }, [elements]);
167
+
168
+ return { observer, intersecting };
169
+ };
170
+
171
+ /**
172
+ * Keep track of which headers are visible, and which header is active
173
+ */
108
174
  export function useHeaders(selector: string, maxdepth: number) {
109
175
  if (!onClient) return { activeId: '', headings: [] };
110
- const onScreen = useRef<Set<HTMLHeadingElement>>(new Set());
176
+ // Keep track of main manually for now
177
+ const mainElementRef = useRef<HTMLElement | null>(null);
178
+ useEffect(() => {
179
+ mainElementRef.current = document.querySelector('main');
180
+ }, []);
181
+
182
+ // Track changes to the DOM
183
+ const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
184
+ const onMutation = useCallback(
185
+ throttle(
186
+ () => {
187
+ setElements(getHeaders(selector));
188
+ },
189
+ 500,
190
+ { trailing: false },
191
+ ),
192
+ [selector],
193
+ );
194
+ useMutationObserver(mainElementRef, onMutation, {
195
+ attributes: true,
196
+ childList: true,
197
+ subtree: true,
198
+ });
199
+
200
+ // Trigger initial update
201
+ useEffect(onMutation, []);
202
+
203
+ // Watch intersections with headings
204
+ const { intersecting } = useIntersectionObserver(elements);
111
205
  const [activeId, setActiveId] = useState<string>();
112
- const headingsSet = useRef<Set<HTMLHeadingElement>>(new Set());
113
206
 
114
- const highlight = useCallback(() => {
115
- const current = [...onScreen.current];
116
- const highlighted = current.reduce(
207
+ useEffect(() => {
208
+ const highlighted = intersecting!.reduce(
117
209
  (a, b) => {
118
210
  if (a) return a;
119
211
  if (b.classList.contains('highlight')) return b.id;
@@ -121,80 +213,43 @@ export function useHeaders(selector: string, maxdepth: number) {
121
213
  },
122
214
  null as string | null,
123
215
  );
124
- const active = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0];
216
+ const active = [...(intersecting as HTMLElement[])].sort(
217
+ (a, b) => a.offsetTop - b.offsetTop,
218
+ )[0];
125
219
  if (highlighted || active) setActiveId(highlighted || active.id);
126
- }, []);
127
-
128
- const { observer } = useIntersectionObserver(highlight, onScreen.current);
129
- const [elements, setElements] = useState<HTMLHeadingElement[]>([]);
220
+ }, [intersecting]);
130
221
 
131
- const render = throttle(() => setElements(getHeaders(selector)), 500);
222
+ const [headings, setHeadings] = useState<Heading[]>([]);
132
223
  useEffect(() => {
133
- // We have to look at the document changes for reloads/mutations
134
- const main = document.querySelector('main');
135
- const mutations = new MutationObserver(render);
136
- // Fire when added to the dom
137
- render();
138
- if (main) {
139
- mutations.observe(main, { attributes: true, childList: true, subtree: true });
140
- }
141
- return () => mutations.disconnect();
142
- }, []);
143
-
144
- useEffect(() => {
145
- // Re-observe all elements when the observer changes
146
- Array.from(elements).map((e) => observer.current?.observe(e));
147
- }, [observer]);
224
+ let minLevel = 10;
225
+ const thisHeadings: Heading[] = elements
226
+ .map((element) => {
227
+ return {
228
+ element,
229
+ level: Number(element.tagName.slice(1)),
230
+ id: element.id,
231
+ text: element.querySelector('.heading-text'),
232
+ };
233
+ })
234
+ .filter((h) => !!h.text)
235
+ .map(({ element, level, text, id }) => {
236
+ const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
237
+ text as HTMLSpanElement,
238
+ );
239
+ minLevel = Math.min(minLevel, level);
240
+ return { element, title, titleHTML, id, level };
241
+ })
242
+ .filter((heading) => {
243
+ heading.level = heading.level - minLevel + 1;
244
+ return heading.level < maxdepth + 1;
245
+ });
148
246
 
149
- let minLevel = 10;
150
- const headings: Heading[] = elements
151
- .map((element) => {
152
- return {
153
- element,
154
- level: Number(element.tagName.slice(1)),
155
- id: element.id,
156
- text: element.querySelector('.heading-text'),
157
- };
158
- })
159
- .filter((h) => !!h.text)
160
- .map(({ element, level, text, id }) => {
161
- const { innerText: title, innerHTML: titleHTML } = cloneHeadingElement(
162
- text as HTMLSpanElement,
163
- );
164
- minLevel = Math.min(minLevel, level);
165
- return { element, title, titleHTML, id, level };
166
- })
167
- .filter((heading) => {
168
- heading.level = heading.level - minLevel + 1;
169
- return heading.level < maxdepth + 1;
170
- });
171
-
172
- headings.forEach(({ element: e }) => {
173
- if (headingsSet.current.has(e)) return;
174
- observer.current?.observe(e);
175
- headingsSet.current.add(e);
176
- });
247
+ setHeadings(thisHeadings);
248
+ }, [elements]);
177
249
 
178
- return { activeId, highlight, headings };
250
+ return { activeId, headings };
179
251
  }
180
252
 
181
- const useIntersectionObserver = (highlight: () => void, onScreen: Set<HTMLHeadingElement>) => {
182
- const observer = useRef<IntersectionObserver | null>(null);
183
- if (!onClient) return { observer };
184
- useEffect(() => {
185
- const callback: IntersectionObserverCallback = (entries) => {
186
- entries.forEach((entry) => {
187
- onScreen[entry.isIntersecting ? 'add' : 'delete'](entry.target as HTMLHeadingElement);
188
- });
189
- highlight();
190
- };
191
- const o = new IntersectionObserver(callback);
192
- observer.current = o;
193
- return () => o.disconnect();
194
- }, [highlight, onScreen]);
195
- return { observer };
196
- };
197
-
198
253
  export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
199
254
  existingContainer?: React.RefObject<T>,
200
255
  ) {
@@ -226,6 +281,71 @@ export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
226
281
  return { container, outline };
227
282
  }
228
283
 
284
+ /**
285
+ * Determine whether the margin outline should be occluded by margin elements
286
+ */
287
+ function useMarginOccluder() {
288
+ const [occluded, setOccluded] = useState(false);
289
+ const [elements, setElements] = useState<Element[]>([]);
290
+
291
+ // Keep track of main manually for now
292
+ const mainElementRef = useRef<HTMLElement | null>(null);
293
+ useEffect(() => {
294
+ mainElementRef.current = document.querySelector('main');
295
+ }, []);
296
+
297
+ // Update list of margin elements
298
+ const onMutation = useCallback(
299
+ throttle(
300
+ () => {
301
+ if (!mainElementRef.current) {
302
+ return;
303
+ }
304
+ // Watch margin elements, or their direct descendents (as some margin elements have height set to zero)
305
+ const classes = [
306
+ 'col-margin-right',
307
+ 'col-margin-right-inset',
308
+ 'col-gutter-outset-right',
309
+ 'col-screen-right',
310
+ 'col-screen-inset-right',
311
+ 'col-page-right',
312
+ 'col-page-inset-right',
313
+ 'col-body-outset-right',
314
+ 'col-gutter-page-right',
315
+ // 'col-screen', // This is on everything!
316
+ 'col-page',
317
+ 'col-page-inset',
318
+ 'col-body-outset',
319
+ ];
320
+ const selector = classes
321
+ .map((cls) => [`.${cls}`, `.${cls} > *`])
322
+ .flat()
323
+ .join(', ');
324
+ const marginElements = mainElementRef.current.querySelectorAll(selector);
325
+ setElements(Array.from(marginElements));
326
+ },
327
+ 500,
328
+ { trailing: false },
329
+ ),
330
+ [],
331
+ );
332
+ useMutationObserver(mainElementRef, onMutation, {
333
+ attributes: true,
334
+ childList: true,
335
+ subtree: true,
336
+ });
337
+
338
+ // Trigger initial update
339
+ useEffect(onMutation, []);
340
+ // Keep tabs of margin elements on screen
341
+ const { intersecting } = useIntersectionObserver(elements, { rootMargin: '0px 0px -33% 0px' });
342
+ useEffect(() => {
343
+ setOccluded(intersecting!.length > 0);
344
+ }, [intersecting]);
345
+
346
+ return { occluded };
347
+ }
348
+
229
349
  export const DocumentOutline = ({
230
350
  outlineRef,
231
351
  top = 0,
@@ -233,6 +353,7 @@ export const DocumentOutline = ({
233
353
  selector = SELECTOR,
234
354
  children,
235
355
  maxdepth = 4,
356
+ isMargin,
236
357
  }: {
237
358
  outlineRef?: React.RefObject<HTMLElement>;
238
359
  top?: number;
@@ -241,31 +362,63 @@ export const DocumentOutline = ({
241
362
  selector?: string;
242
363
  children?: React.ReactNode;
243
364
  maxdepth?: number;
365
+ isMargin: boolean;
244
366
  }) => {
245
- const { activeId, headings, highlight } = useHeaders(selector, maxdepth);
367
+ const { activeId, headings } = useHeaders(selector, maxdepth);
368
+ const [open, setOpen] = useState(false);
369
+
370
+ // Keep track of changing occlusion
371
+ const { occluded } = useMarginOccluder();
372
+
373
+ // Handle transition between margin and non-margin
374
+ useEffect(() => {
375
+ setOpen(true);
376
+ }, [isMargin]);
377
+
378
+ // Handle occlusion when outline is in margin
379
+ useEffect(() => {
380
+ if (isMargin) {
381
+ setOpen(!occluded);
382
+ }
383
+ }, [occluded, isMargin]);
384
+
246
385
  if (headings.length <= 1 || !onClient) {
247
386
  return <nav suppressHydrationWarning>{children}</nav>;
248
387
  }
388
+
249
389
  return (
250
- <nav
251
- ref={outlineRef}
252
- aria-label="Document Outline"
253
- className={classNames(
254
- 'not-prose overflow-y-auto',
255
- 'transition-opacity duration-700', // Animation on load
256
- className,
257
- )}
258
- style={{
259
- top: top,
260
- maxHeight: `calc(100vh - ${top + 20}px)`,
261
- }}
262
- >
263
- <div className="mb-4 text-sm leading-6 uppercase text-slate-900 dark:text-slate-100">
264
- In this article
265
- </div>
266
- <Headings headings={headings} activeId={activeId} highlight={highlight} selector={selector} />
267
- {children}
268
- </nav>
390
+ <Collapsible.Root open={open} onOpenChange={setOpen}>
391
+ <nav
392
+ ref={outlineRef}
393
+ aria-label="Document Outline"
394
+ className={classNames(
395
+ 'not-prose overflow-y-auto',
396
+ 'transition-opacity duration-700', // Animation on load
397
+ className,
398
+ )}
399
+ style={{
400
+ top: top,
401
+ maxHeight: `calc(100vh - ${top + 20}px)`,
402
+ }}
403
+ >
404
+ <div className="flex flex-row gap-2 mb-4 text-sm leading-6 uppercase rounded-lg text-slate-900 dark:text-slate-100">
405
+ In this article
406
+ <Collapsible.Trigger asChild>
407
+ <button className="self-center flex-none rounded-md group hover:bg-slate-300/30 focus:outline outline-blue-200 outline-2">
408
+ <ChevronRightIcon
409
+ className="transition-transform duration-300 group-data-[state=open]:rotate-90 text-text-slate-700 dark:text-slate-100"
410
+ height="1.5rem"
411
+ width="1.5rem"
412
+ />
413
+ </button>
414
+ </Collapsible.Trigger>
415
+ </div>
416
+ <Collapsible.Content className="CollapsibleContent">
417
+ <Headings headings={headings} activeId={activeId} />
418
+ {children}
419
+ </Collapsible.Content>
420
+ </nav>
421
+ </Collapsible.Root>
269
422
  );
270
423
  };
271
424
 
@@ -6,6 +6,7 @@ import {
6
6
  useSiteManifest,
7
7
  useGridSystemProvider,
8
8
  useThemeTop,
9
+ useIsWide,
9
10
  } from '@myst-theme/providers';
10
11
  import type { Heading } from '@myst-theme/common';
11
12
  import { Toc } from './TableOfContentsItems.js';
@@ -95,10 +96,15 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
95
96
  const container = useRef<T>(null);
96
97
  const toc = useRef<HTMLDivElement>(null);
97
98
  const transitionState = useNavigation().state;
99
+ const wide = useIsWide();
98
100
  const setHeight = () => {
99
101
  if (!container.current || !toc.current) return;
100
102
  const height = container.current.offsetHeight - window.scrollY;
101
103
  const div = toc.current.firstChild as HTMLDivElement;
104
+ if (div)
105
+ div.style.height = wide
106
+ ? `min(calc(100vh - ${top}px), ${height + inset}px)`
107
+ : `calc(100vh - ${top}px)`;
102
108
  if (div) div.style.height = `min(calc(100vh - ${top}px), ${height + inset}px)`;
103
109
  const nav = toc.current.querySelector('nav');
104
110
  if (nav) nav.style.opacity = height > 150 ? '1' : '0';
@@ -111,7 +117,7 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
111
117
  return () => {
112
118
  window.removeEventListener('scroll', handleScroll);
113
119
  };
114
- }, [container, toc, transitionState]);
120
+ }, [container, toc, transitionState, wide]);
115
121
  return { container, toc };
116
122
  }
117
123