@myst-theme/site 1.0.1 → 1.1.1

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": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -25,11 +25,11 @@
25
25
  "dependencies": {
26
26
  "@headlessui/react": "^1.7.15",
27
27
  "@heroicons/react": "^2.0.18",
28
- "@myst-theme/common": "^1.0.1",
29
- "@myst-theme/diagrams": "^1.0.1",
30
- "@myst-theme/frontmatter": "^1.0.1",
31
- "@myst-theme/providers": "^1.0.1",
32
- "@myst-theme/search": "^1.0.1",
28
+ "@myst-theme/common": "^1.1.1",
29
+ "@myst-theme/diagrams": "^1.1.1",
30
+ "@myst-theme/frontmatter": "^1.1.1",
31
+ "@myst-theme/providers": "^1.1.1",
32
+ "@myst-theme/search": "^1.1.1",
33
33
  "@radix-ui/react-collapsible": "^1.0.3",
34
34
  "@radix-ui/react-dialog": "^1.0.3",
35
35
  "@radix-ui/react-visually-hidden": "^1.1.0",
@@ -37,9 +37,9 @@
37
37
  "lodash.throttle": "^4.1.1",
38
38
  "myst-common": "^1.8.1",
39
39
  "myst-config": "^1.7.9",
40
- "myst-demo": "^1.0.1",
40
+ "myst-demo": "^1.1.1",
41
41
  "myst-spec-ext": "^1.8.1",
42
- "myst-to-react": "^1.0.1",
42
+ "myst-to-react": "^1.1.1",
43
43
  "nbtx": "^0.2.3",
44
44
  "node-cache": "^5.1.2",
45
45
  "node-fetch": "^2.6.11",
@@ -2,12 +2,13 @@ import {
2
2
  useBaseurl,
3
3
  useNavLinkProvider,
4
4
  useSiteManifest,
5
+ useThemeTop,
5
6
  withBaseurl,
6
7
  } from '@myst-theme/providers';
7
8
  import { useNavigation } from '@remix-run/react';
8
9
  import classNames from 'classnames';
9
10
  import throttle from 'lodash.throttle';
10
- import React, { useCallback, useEffect, useRef, useState } from 'react';
11
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
12
  import type { RefObject } from 'react';
12
13
  import { DocumentChartBarIcon } from '@heroicons/react/24/outline';
13
14
  import { ChevronRightIcon } from '@heroicons/react/24/solid';
@@ -148,13 +149,24 @@ const useIntersectionObserver = (elements: Element[], options?: Record<string, a
148
149
 
149
150
  if (!onClient) return { observer };
150
151
  useEffect(() => {
152
+ // We want a list of all header elements on screen to loop through, but:
153
+ // IntersectionObserver returns elements that have *changed state*, not the full list of elements on screen.
154
+ // So we maintain a set of on-screen elements and add/remove as new intersection events happen.
151
155
  const cb: IntersectionObserverCallback = (entries) => {
152
- setIntersecting(entries.filter((e) => e.isIntersecting).map((e) => e.target));
156
+ setIntersecting((prev) => {
157
+ const next = new Set(prev);
158
+ // Add or remove from our set based on intersection state
159
+ entries.forEach((e) => {
160
+ if (e.isIntersecting) next.add(e.target);
161
+ else next.delete(e.target);
162
+ });
163
+ return Array.from(next);
164
+ });
153
165
  };
154
166
  const o = new IntersectionObserver(cb, options ?? {});
155
167
  setObserver(o);
156
168
  return () => o.disconnect();
157
- }, []);
169
+ }, [options]);
158
170
 
159
171
  // Changes to the DOM mean we need to update our intersection observer
160
172
  useEffect(() => {
@@ -163,12 +175,18 @@ const useIntersectionObserver = (elements: Element[], options?: Record<string, a
163
175
  }
164
176
  // Observe all heading elements
165
177
  const toWatch = elements;
166
- toWatch.map((e) => observer.observe(e));
178
+ toWatch.forEach((e) => {
179
+ observer.observe(e);
180
+ });
167
181
  // Cleanup afterwards
168
182
  return () => {
169
- toWatch.map((e) => observer.unobserve(e));
183
+ toWatch.forEach((e) => {
184
+ observer.unobserve(e);
185
+ });
186
+ // Ensure we don't keep stale references when elements are removed/unobserved.
187
+ setIntersecting((prev) => prev.filter((e) => !toWatch.includes(e)));
170
188
  };
171
- }, [elements]);
189
+ }, [elements, observer]);
172
190
 
173
191
  return { observer, intersecting };
174
192
  };
@@ -177,6 +195,7 @@ const useIntersectionObserver = (elements: Element[], options?: Record<string, a
177
195
  * Keep track of which headers are visible, and which header is active
178
196
  */
179
197
  export function useHeaders(selector: string, maxdepth: number) {
198
+ const topOffset = useThemeTop();
180
199
  if (!onClient) return { activeId: '', headings: [] };
181
200
  // Keep track of main manually for now
182
201
  const mainElementRef = useRef<HTMLElement | null>(null);
@@ -192,7 +211,8 @@ export function useHeaders(selector: string, maxdepth: number) {
192
211
  setElements(getHeaders(selector));
193
212
  },
194
213
  500,
195
- { trailing: false },
214
+ // Trailing updates help ensure we eventually process the last DOM mutation burst.
215
+ { trailing: true },
196
216
  ),
197
217
  [selector],
198
218
  );
@@ -210,6 +230,9 @@ export function useHeaders(selector: string, maxdepth: number) {
210
230
  const [activeId, setActiveId] = useState<string>();
211
231
 
212
232
  useEffect(() => {
233
+ // Use the theme's top offset (navbar height) + a bit of padding for filtering active headings to avoid over-shooting the header.
234
+ const OFFSET_PX = topOffset - 10;
235
+ // Prefer a heading marked as highlighted (e.g. focus/anchor) if one is currently intersecting.
213
236
  const highlighted = intersecting!.reduce(
214
237
  (a, b) => {
215
238
  if (a) return a;
@@ -218,11 +241,28 @@ export function useHeaders(selector: string, maxdepth: number) {
218
241
  },
219
242
  null as string | null,
220
243
  );
221
- const active = [...(intersecting as HTMLElement[])].sort(
222
- (a, b) => a.offsetTop - b.offsetTop,
223
- )[0];
224
- if (highlighted || active) setActiveId(highlighted || active.id);
225
- }, [intersecting]);
244
+ const intersectingElements = intersecting as HTMLElement[];
245
+ // Choose the heading closest to the navbar offset line within a viewport window under it.
246
+ // Using a window avoids a case where the active header is off screen the next header is way at the bottom of the screen.
247
+ let bestInActiveHeaderWindow: { el: HTMLElement; distance: number } | undefined;
248
+ const ACTIVE_WINDOW_PX = window.innerHeight * 0.33;
249
+ for (const el of intersectingElements) {
250
+ const distance = el.getBoundingClientRect().top - OFFSET_PX;
251
+ if (
252
+ // Only keep things under the navbar line
253
+ distance >= 0 &&
254
+ // Only keep things over the active window size
255
+ distance <= ACTIVE_WINDOW_PX &&
256
+ // Now if it's closer to the navbar line than the active element, update active
257
+ (!bestInActiveHeaderWindow || distance < bestInActiveHeaderWindow.distance)
258
+ ) {
259
+ bestInActiveHeaderWindow = { el, distance };
260
+ }
261
+ }
262
+ // If nothing is below the navbar line, keep the current active heading.
263
+ const active = bestInActiveHeaderWindow?.el;
264
+ if (highlighted || active) setActiveId(highlighted || active?.id);
265
+ }, [intersecting, topOffset]);
226
266
 
227
267
  const [headings, setHeadings] = useState<Heading[]>([]);
228
268
  useEffect(() => {
@@ -292,6 +332,8 @@ export function useOutlineHeight<T extends HTMLElement = HTMLElement>(
292
332
  function useMarginOccluder() {
293
333
  const [occluded, setOccluded] = useState(false);
294
334
  const [elements, setElements] = useState<Element[]>([]);
335
+ // Memoize options so `useIntersectionObserver(..., options)` doesn't recreate the observer each render.
336
+ const intersectionOptions = useMemo(() => ({ rootMargin: '0px 0px -33% 0px' }), []);
295
337
 
296
338
  // Keep track of main manually for now
297
339
  const mainElementRef = useRef<HTMLElement | null>(null);
@@ -330,7 +372,8 @@ function useMarginOccluder() {
330
372
  setElements(Array.from(marginElements));
331
373
  },
332
374
  500,
333
- { trailing: false },
375
+ // Trailing updates help ensure we eventually process the last DOM mutation burst.
376
+ { trailing: true },
334
377
  ),
335
378
  [],
336
379
  );
@@ -343,7 +386,7 @@ function useMarginOccluder() {
343
386
  // Trigger initial update
344
387
  useEffect(onMutation, []);
345
388
  // Keep tabs of margin elements on screen
346
- const { intersecting } = useIntersectionObserver(elements, { rootMargin: '0px 0px -33% 0px' });
389
+ const { intersecting } = useIntersectionObserver(elements, intersectionOptions);
347
390
  useEffect(() => {
348
391
  setOccluded(intersecting!.length > 0);
349
392
  }, [intersecting]);
@@ -401,6 +444,7 @@ export const DocumentOutline = ({
401
444
  className={classNames(
402
445
  'myst-outline not-prose overflow-y-auto',
403
446
  'transition-opacity duration-700', // Animation on load
447
+ 'bg-white/95 dark:bg-stone-900/95 backdrop-blur-sm rounded-lg p-2 -m-2', // Solid background to avoid overlap with margin content
404
448
  className,
405
449
  )}
406
450
  style={{
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { useLinkProvider, useNavLinkProvider } from '@myst-theme/providers';
2
+ import { isExternalUrl, useLinkProvider, useNavLinkProvider } from '@myst-theme/providers';
3
3
 
4
4
  export function ExternalOrInternalLink({
5
5
  to,
@@ -19,7 +19,7 @@ export function ExternalOrInternalLink({
19
19
  const Link = useLinkProvider();
20
20
  const NavLink = useNavLinkProvider();
21
21
  const staticClass = typeof className === 'function' ? className({ isActive: false }) : className;
22
- if (to.startsWith('http') || to.startsWith('mailto:')) {
22
+ if (isExternalUrl(to)) {
23
23
  return (
24
24
  <a
25
25
  href={to}
@@ -174,7 +174,7 @@ function SearchShortcut() {
174
174
  return (
175
175
  <div
176
176
  aria-hidden
177
- className="myst-search-shortcut items-center hidden mx-1 font-mono text-sm text-gray-600 sm:flex gap-x-1"
177
+ className="myst-search-shortcut items-center hidden mx-1 font-mono text-sm text-gray-600 dark:text-gray-300 sm:flex gap-x-1"
178
178
  >
179
179
  <kbd
180
180
  className={classNames(
@@ -507,7 +507,7 @@ function SearchForm({
507
507
  <form onSubmit={onSubmit}>
508
508
  <div className="relative flex w-full h-10 flow-row gap-x-1 ">
509
509
  <label id={searchLabelID} htmlFor={searchInputID}>
510
- <MagnifyingGlassIcon className="absolute text-gray-600 inset-y-0 start-0 h-10 w-10 p-2.5 aspect-square flex items-center pointer-events-none" />
510
+ <MagnifyingGlassIcon className="absolute text-gray-600 dark:text-gray-300 inset-y-0 start-0 h-10 w-10 p-2.5 aspect-square flex items-center pointer-events-none" />
511
511
  <span className="hidden">Search query</span>
512
512
  </label>
513
513
  <input
@@ -563,7 +563,7 @@ const SearchPlaceholderButton = forwardRef<
563
563
  className={classNames(
564
564
  'myst-search-bar',
565
565
  className,
566
- 'flex items-center h-10 aspect-square sm:w-64 text-left text-gray-600',
566
+ 'flex items-center h-10 aspect-square sm:w-64 text-left text-gray-600 dark:text-gray-300',
567
567
  'border border-gray-300 dark:border-gray-600',
568
568
  'rounded-lg bg-gray-50 dark:bg-gray-700',
569
569
  {
@@ -172,7 +172,7 @@ const NestedToc = ({ heading }: { heading: NestedHeading }) => {
172
172
  <Collapsible.Root className="w-full" open={open} onOpenChange={setOpen}>
173
173
  <div
174
174
  className={classNames(
175
- 'myst-toc-item flex flex-row w-full gap-2 px-2 my-1 text-left rounded-lg outline-none',
175
+ 'myst-toc-item flex flex-row w-full gap-2 pl-2 my-1 text-left rounded-lg outline-none',
176
176
  {
177
177
  'myst-toc-item-exact bg-blue-300/30': exact,
178
178
  'hover:bg-slate-300/30': !exact,
@@ -189,7 +189,7 @@ const NestedToc = ({ heading }: { heading: NestedHeading }) => {
189
189
  />
190
190
  <Collapsible.Trigger asChild>
191
191
  <button
192
- className="self-center flex-none rounded-md group hover:bg-slate-300/30 focus:outline outline-blue-200 outline-2"
192
+ className="self-stretch flex items-center flex-none px-1 rounded-l-md group hover:bg-slate-300/30 focus-visible:outline outline-blue-200 outline-2"
193
193
  aria-label="Open Folder"
194
194
  >
195
195
  <ChevronRightIcon