From e3ab7cd3ccf4c6e581d0740784c20c92a947b8a1 Mon Sep 17 00:00:00 2001 From: Rowan Cockett Date: Wed, 5 Jun 2024 15:28:30 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=B6=F0=9F=8E=B5=20Add=20footnotes=20to?= =?UTF-8?q?=20bottom=20of=20page=20(#397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See: executablebooks/mystmd#926 --- .changeset/four-tips-swim.md | 9 ++ packages/myst-to-react/src/basic.tsx | 2 +- packages/myst-to-react/src/crossReference.tsx | 18 +--- packages/myst-to-react/src/exercise.tsx | 2 +- packages/myst-to-react/src/footnotes.tsx | 9 +- packages/myst-to-react/src/hashLink.tsx | 95 +++++++++++++++++++ packages/myst-to-react/src/heading.tsx | 50 +--------- packages/myst-to-react/src/index.tsx | 2 +- packages/myst-to-react/src/math.tsx | 2 +- packages/myst-to-react/src/proof.tsx | 2 +- packages/site/src/components/Footnotes.tsx | 54 +++++++++++ packages/site/src/components/index.ts | 1 + styles/app.css | 1 + styles/backmatter.css | 9 ++ themes/article/app/components/Article.tsx | 2 + themes/book/app/components/ArticlePage.tsx | 3 +- 16 files changed, 188 insertions(+), 73 deletions(-) create mode 100644 .changeset/four-tips-swim.md create mode 100644 packages/myst-to-react/src/hashLink.tsx create mode 100644 packages/site/src/components/Footnotes.tsx create mode 100644 styles/backmatter.css diff --git a/.changeset/four-tips-swim.md b/.changeset/four-tips-swim.md new file mode 100644 index 000000000..ec2a204df --- /dev/null +++ b/.changeset/four-tips-swim.md @@ -0,0 +1,9 @@ +--- +'myst-to-react': patch +'@myst-theme/article': patch +'@myst-theme/site': patch +'@myst-theme/book': patch +'@myst-theme/styles': patch +--- + +Add footnotes to bottom of page in addition to hover diff --git a/packages/myst-to-react/src/basic.tsx b/packages/myst-to-react/src/basic.tsx index 724a67608..6939f361d 100644 --- a/packages/myst-to-react/src/basic.tsx +++ b/packages/myst-to-react/src/basic.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type * as spec from 'myst-spec'; -import { HashLink } from './heading.js'; +import { HashLink } from './hashLink.js'; import type { NodeRenderer } from '@myst-theme/providers'; import classNames from 'classnames'; import { Tooltip } from './components/index.js'; diff --git a/packages/myst-to-react/src/crossReference.tsx b/packages/myst-to-react/src/crossReference.tsx index 50509ef0f..9d5de55b6 100644 --- a/packages/myst-to-react/src/crossReference.tsx +++ b/packages/myst-to-react/src/crossReference.tsx @@ -13,6 +13,7 @@ import { default as useSWR } from 'swr'; import { HoverPopover } from './components/index.js'; import { MyST } from './MyST.js'; import { selectMdastNodes } from 'myst-common'; +import { scrollToElement } from './hashLink.js'; const fetcher = (...args: Parameters) => fetch(...args).then((res) => { @@ -40,14 +41,6 @@ function XrefChildren({ load, identifier }: { load?: boolean; identifier: string return ; } -function openDetails(el: HTMLElement | null) { - if (!el) return; - if (el.nodeName === 'DETAILS') { - (el as HTMLDetailsElement).open = true; - } - openDetails(el.parentElement); -} - function createRemoteBaseUrl(url?: string, remoteBaseUrl?: string): string { if (remoteBaseUrl && url?.startsWith(remoteBaseUrl)) { // The remoteBaseUrl is included in the url @@ -144,14 +137,7 @@ export function CrossReferenceHover({ e.preventDefault(); if (!htmlId) return; const el = document.getElementById(htmlId); - openDetails(el); - el?.scrollIntoView({ behavior: 'smooth' }); - history.replaceState(undefined, '', `#${htmlId}`); - if (el) { - // Changes keyboard tab-index location - if (el.tabIndex === -1) el.tabIndex = -1; - el.focus({ preventScroll: true }); - } + scrollToElement(el, { htmlId }); }; return ( { openDelay={0} card={} > - - [{node.number ?? node.identifier}] + + + + [{node.enumerator ?? node.number ?? node.identifier}] + + ); diff --git a/packages/myst-to-react/src/hashLink.tsx b/packages/myst-to-react/src/hashLink.tsx new file mode 100644 index 000000000..674dee10d --- /dev/null +++ b/packages/myst-to-react/src/hashLink.tsx @@ -0,0 +1,95 @@ +import { useXRefState } from '@myst-theme/providers'; +import classNames from 'classnames'; + +export type HashLinkBehavior = { + /** When scrolling, is this `instant`, `auto` or `scroll`? */ + scrollBehavior?: ScrollBehavior; + /** When updating the URL, do you push state or replace it? */ + historyState?: 'replace' | 'push' | null; + /** Change the keyboard tab-index location to the new element */ + focusTarget?: boolean; +}; + +function openDetails(el: HTMLElement | null) { + if (!el) return; + if (el.nodeName === 'DETAILS') { + (el as HTMLDetailsElement).open = true; + } + openDetails(el.parentElement); +} + +export function scrollToElement( + el: HTMLElement | null, + { + htmlId = el?.id, + scrollBehavior = 'smooth', + historyState = 'replace', + focusTarget = true, + }: { + /** Update the URL fragment to this ID */ + htmlId?: string; + } & HashLinkBehavior = {}, +) { + if (!el) return; + openDetails(el); + el.scrollIntoView({ behavior: scrollBehavior }); + if (historyState === 'push') { + history.pushState(undefined, '', `#${htmlId}`); + } else if (historyState === 'replace') { + history.replaceState(undefined, '', `#${htmlId}`); + } + if (focusTarget) { + // Changes keyboard tab-index location + if (el.tabIndex === -1) el.tabIndex = -1; + el.focus({ preventScroll: true }); + } +} + +export function HashLink({ + id, + kind, + title = `Link to this ${kind}`, + children = '¶', + hover, + className = 'font-normal', + hideInPopup, + scrollBehavior, + historyState, + focusTarget, +}: { + id?: string; + kind?: string; + title?: string; + hover?: boolean; + children?: '#' | '¶' | React.ReactNode; + className?: string; + hideInPopup?: boolean; +} & HashLinkBehavior) { + const { inCrossRef } = useXRefState(); + if (inCrossRef || !id) { + // If we are in a cross-reference pop-out, either hide hash link + // or return something that is **not** a link + return hideInPopup ? null : ( + {children} + ); + } + const scroll: React.MouseEventHandler = (evt) => { + evt.preventDefault(); + const el = document.getElementById(id); + scrollToElement(el, { scrollBehavior, historyState, focusTarget }); + }; + return ( + + {children} + + ); +} diff --git a/packages/myst-to-react/src/heading.tsx b/packages/myst-to-react/src/heading.tsx index c8fd1db96..125b4ba7d 100644 --- a/packages/myst-to-react/src/heading.tsx +++ b/packages/myst-to-react/src/heading.tsx @@ -1,56 +1,8 @@ import { Heading } from 'myst-spec'; import type { NodeRenderer } from '@myst-theme/providers'; -import { useXRefState } from '@myst-theme/providers'; import { createElement as e } from 'react'; -import classNames from 'classnames'; import { MyST } from './MyST.js'; - -export function HashLink({ - id, - kind, - title = `Link to this ${kind}`, - children = '¶', - hover, - className = 'font-normal', - hideInPopup, -}: { - id?: string; - kind?: string; - title?: string; - hover?: boolean; - children?: '#' | '¶' | React.ReactNode; - className?: string; - hideInPopup?: boolean; -}) { - const { inCrossRef } = useXRefState(); - if (inCrossRef || !id) { - // If we are in a cross-reference pop-out, either hide hash link - // or return something that is **not** a link - return hideInPopup ? null : ( - {children} - ); - } - const scroll: React.MouseEventHandler = (evt) => { - evt.preventDefault(); - const el = document.getElementById(id); - el?.scrollIntoView({ behavior: 'smooth' }); - history.replaceState(undefined, '', `#${id}`); - }; - return ( - - {children} - - ); -} +import { HashLink } from './hashLink.js'; const Heading: NodeRenderer = ({ node }) => { const { enumerator, depth, key, identifier, html_id } = node; diff --git a/packages/myst-to-react/src/index.tsx b/packages/myst-to-react/src/index.tsx index 0ae7642bf..765a6eac3 100644 --- a/packages/myst-to-react/src/index.tsx +++ b/packages/myst-to-react/src/index.tsx @@ -24,7 +24,7 @@ import UNKNOWN_MYST_RENDERERS from './unknown.js'; export { CopyIcon, HoverPopover, Tooltip, LinkCard } from './components/index.js'; export { CodeBlock } from './code.js'; -export { HashLink } from './heading.js'; +export { HashLink, scrollToElement } from './hashLink.js'; export { Admonition, AdmonitionKind } from './admonitions.js'; export { Details } from './dropdown.js'; export { TabSet, TabItem } from './tabs.js'; diff --git a/packages/myst-to-react/src/math.tsx b/packages/myst-to-react/src/math.tsx index 1b1b90db5..321e4d6c0 100644 --- a/packages/myst-to-react/src/math.tsx +++ b/packages/myst-to-react/src/math.tsx @@ -1,7 +1,7 @@ import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; import type { InlineMath, Math } from 'myst-spec'; import { InlineError } from './inlineError.js'; -import { HashLink } from './heading.js'; +import { HashLink } from './hashLink.js'; import type { NodeRenderer } from '@myst-theme/providers'; // function Math({ value, html }: { value: string; html: string }) { diff --git a/packages/myst-to-react/src/proof.tsx b/packages/myst-to-react/src/proof.tsx index 81a167edf..60c1980ce 100644 --- a/packages/myst-to-react/src/proof.tsx +++ b/packages/myst-to-react/src/proof.tsx @@ -3,7 +3,7 @@ import React from 'react'; import type { NodeRenderer } from '@myst-theme/providers'; import { ChevronRightIcon } from '@heroicons/react/24/solid'; import classNames from 'classnames'; -import { HashLink } from './heading.js'; +import { HashLink } from './hashLink.js'; import type { GenericNode } from 'myst-common'; import { MyST } from './MyST.js'; diff --git a/packages/site/src/components/Footnotes.tsx b/packages/site/src/components/Footnotes.tsx new file mode 100644 index 000000000..bc6c198bc --- /dev/null +++ b/packages/site/src/components/Footnotes.tsx @@ -0,0 +1,54 @@ +import { useGridSystemProvider, useReferences } from '@myst-theme/providers'; +import classNames from 'classnames'; +import type { GenericNode } from 'myst-common'; +import type { FootnoteDefinition, FootnoteReference } from 'myst-spec-ext'; +import { HashLink, MyST } from 'myst-to-react'; +import { selectAll } from 'unist-util-select'; + +export function Footnotes() { + const references = useReferences(); + const grid = useGridSystemProvider(); + const defs = selectAll('footnoteDefinition', references?.article) as FootnoteDefinition[]; + const refs = selectAll('footnoteReference', references?.article) as FootnoteReference[]; + if (defs.length === 0) return null; + return ( +
+
+
+ Footnotes + +
+
+
+
    + {defs.map((fn) => { + return ( +
  1. +
    +
    + +
    +
    + {refs + .filter((ref) => ref.identifier === fn.identifier) + .map((ref) => ( + + ))} +
    +
    +
  2. + ); + })} +
+
+
+ ); +} diff --git a/packages/site/src/components/index.ts b/packages/site/src/components/index.ts index d2810f8eb..22d65ec47 100644 --- a/packages/site/src/components/index.ts +++ b/packages/site/src/components/index.ts @@ -3,6 +3,7 @@ export { DocumentOutline, useOutlineHeight, SupportingDocuments } from './Docume export { FooterLinksBlock } from './FooterLinksBlock.js'; export { ContentReload } from './ContentReload.js'; export { Bibliography } from './Bibliography.js'; +export { Footnotes } from './Footnotes.js'; export { ArticleHeader } from './Headers.js'; export { FrontmatterParts, Abstract, Keywords } from './Abstract.js'; export { BackmatterParts, Backmatter } from './Backmatter.js'; diff --git a/styles/app.css b/styles/app.css index 3f27905af..0e47bfe4c 100644 --- a/styles/app.css +++ b/styles/app.css @@ -14,3 +14,4 @@ @import './hover.css'; @import './proof.css'; @import './toc.css'; +@import './backmatter.css'; diff --git a/styles/backmatter.css b/styles/backmatter.css new file mode 100644 index 000000000..cad12738d --- /dev/null +++ b/styles/backmatter.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + #footnotes p { + margin: 0.25rem; + } +} diff --git a/themes/article/app/components/Article.tsx b/themes/article/app/components/Article.tsx index 853b99c8b..3268d98eb 100644 --- a/themes/article/app/components/Article.tsx +++ b/themes/article/app/components/Article.tsx @@ -7,6 +7,7 @@ import { FrontmatterParts, BackmatterParts, extractKnownParts, + Footnotes, } from '@myst-theme/site'; import { ErrorTray, NotebookToolbar, useComputeOptions } from '@myst-theme/jupyter'; import { FrontmatterBlock } from '@myst-theme/frontmatter'; @@ -59,6 +60,7 @@ export function Article({ + diff --git a/themes/book/app/components/ArticlePage.tsx b/themes/book/app/components/ArticlePage.tsx index 755a21abb..4d8cb24f6 100644 --- a/themes/book/app/components/ArticlePage.tsx +++ b/themes/book/app/components/ArticlePage.tsx @@ -8,6 +8,7 @@ import { BackmatterParts, DocumentOutline, extractKnownParts, + Footnotes, } from '@myst-theme/site'; import type { SiteManifest } from 'myst-config'; import type { PageLoader } from '@myst-theme/common'; @@ -95,6 +96,7 @@ export const ArticlePage = React.memo(function ({ + {!hide_footer_links && !hide_all_footer_links && ( @@ -105,4 +107,3 @@ export const ArticlePage = React.memo(function ({ ); }); -