diff --git a/docs/conf.py b/docs/conf.py index 8d66a154..b9fd713f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -119,6 +119,7 @@ "use_issues_button": True, "use_repository_button": True, "use_download_button": True, + "use_sidenotes": True, "logo_only": True, "show_toc_level": 2, "announcement": ( diff --git a/docs/content-blocks.md b/docs/content-blocks.md index 0b8118cf..23f80230 100644 --- a/docs/content-blocks.md +++ b/docs/content-blocks.md @@ -61,19 +61,84 @@ but I'll stop here. ``` ```` -## Margin content +(margin:sidenote)= +## Sidenotes and marginnotes -You can specify content that should exist in the right margin. This will behave -like a regular sidebar until the screen hits a certain width, at which point this -content will "pop out" to the right white space. +This theme has support for [Tufte-style margin / side notes](https://edwardtufte.github.io/tufte-css/), with a UX similar to [pandoc-sidenote](https://github.com/jez/pandoc-sidenote). -There are two ways to add content to the margin: via the `{margin}` directive, and via adding CSS classes to your own content. +Sidenotes are numbered, and behave like footnotes, except they live in the margin and don’t force the reader to jump their eye to the bottom of the page. +For example, here is a sidenote[^ex]. +On narrow screens, sidenotes are hidden until a user clicks the number. +If you're on a mobile device, try clicking the sidenote number above. -### Use a `{margin}` directive to add margin content +[^ex]: Here's my sidenote text! -The `{margin}` directive allows you to create margin content with your own title and content block. + On narrow screens, this text won't show up unless you click the superscript number! + +Marginnotes are not numbered, but behave the same way as sidenotes. +On mobile devices you'll see a symbol that will show the marginnote when clicked[^exmn]. +For example, there's a marginnote in the previous sentence, and you should see a symbol show to display it on mobile screens. + +[^exmn]: {-} This is a margin note. Notice there isn’t a number preceding the note. + +:::{seealso} +Sidenotes and marginnotes are inline content - you cannot use block-level content inside of these notes. +If you'd like to use block-level content in the margins, see [](margin:block). +::: + +### Activate sidenotes and marginnotes + +The theme activates sidenotes and marginnotes by over-riding footnote syntax to instead exist in the margin. + +To convert your footnotes to *instead* be sidenotes/marginnotes, use this configuration: + +```python +html_theme_options = { + ... + "use_sidenotes": True, + ... +} +``` + +This will turn your **footnotes** into **sidenotes** or **marginnotes**. + +### Create a sidenote + +The following sentence defines a sidenote and its respective content: + +```{example} +:no-container: +:reverse: + +Here's my sentence and a sidenote[^sn1]. + +[^sn1]: And here's my sidenote content. +``` + +### Create a marginnote + +Marginnotes are defined by adding `{-}` at the beginning of the content block. +The following syntax defines a marginnote: + +```{example} +:no-container: +:reverse: + +Here's my sentence and a marginnote[^mn1]. + +[^mn1]: {-} And here's my marginnote content. +``` + +(margin:block)= +## Block margin content with the `{margin}` directive + +The `{margin}` directive allows you to create block-level margin content with an optional title. It is a wrapper around the Sphinx `{sidebar}` directive, and largely does its magic via CSS classes (see below). +:::{seealso} +If you'd like in-line margin content with numbered references, see [](margin:sidenote). +::: + Here's how you can use the `{margin}` directive: ````{example} @@ -85,7 +150,29 @@ It is pretty cool! ``` ```` -### Use CSS classes to add margin content +### Figure captions in the margin + +You can configure figures to use the margin for captions. +Here is a figure with a caption to the right. + +::::{example} +:no-container: + +```{figure} images/cool.jpg +--- +width: 60% +figclass: margin-caption +alt: My figure text +name: myfig5 +--- +And here is my figure caption, if you look to the left, you can see that COOL is in big red letters. But you probably already noticed that, really I am just taking up space to see how the margin caption looks like when it is really long :-). +``` +:::: + +We can reference the figure with {ref}`this reference `. Or a numbered reference like +{numref}`myfig5`. + +### CSS classes for custom margin content You may also directly add CSS classes to elements on your page in order to make them behave like margin content. To do so, add the `margin` CSS class to any element on the page. @@ -104,11 +191,7 @@ This note will be in the margin! This works for most elements on the page, but in general this works best for "parent containers" that are the top-most element of a bundle of content. - -For example, we can even put a whole figure in the margin like so: - - -You can also put the whole figure in the margin if you like. +For example, you can also put the whole figure in the margin if you like. Here is a figure with a caption below. We'll add a note below to create some vertical space to see better. @@ -129,37 +212,17 @@ And here is my figure caption We can reference the figure with {ref}`myfig4`. Or a numbered reference like {numref}`myfig4`. -### Figure captions in the margin -You can configure figures to use the margin for captions. -Here is a figure with a caption to the right. +### Content examples in the margin -````{example} -:no-container: -:reverse: +Margin content can include all kinds of things, such as code blocks: -```{figure} images/cool.jpg ---- -width: 60% -figclass: margin-caption -alt: My figure text -name: myfig5 ---- -And here is my figure caption, if you look to the left, you can see that COOL is in big red letters. But you probably already noticed that, really I am just taking up space to see how the margin caption looks like when it is really long :-). +````{margin} Code blocks in margins +```python +print("here is some python") ``` ```` -We can reference the figure with {ref}`this reference `. Or a numbered reference like -{numref}`myfig5`. - -### Examples of margin content - -There are many kinds of content you can put in the margin, here are some examples. - -`````{example} Code blocks in the margin -:no-container: -:reverse: - ````{margin} Code blocks in margins ```python print("here is some python") diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md index fda767b0..43eee142 100644 --- a/docs/contributing/setup.md +++ b/docs/contributing/setup.md @@ -134,3 +134,17 @@ Anything passed after `--` will be passed directly to `pytest`. :::{seealso} See [](contribute/testing) for more information. ::: + +## The `{example}` directive + +This theme uses the [sphinx-examples](https://github.com/executablebooks/sphinx-examples) extension to make it easy to quickly show off example snippets. + +Basic usage is like so: + +:::{example} +```{example} Example title +This will be both **rendered** and **shown as source code**. +``` +::: + +See [the sphinx-examples documentation](https://ebp-sphinx-examples.readthedocs.io/en/latest/) for more details. diff --git a/docs/notebooks.md b/docs/notebooks.md index 6cfb713d..a8530c97 100644 --- a/docs/notebooks.md +++ b/docs/notebooks.md @@ -201,9 +201,9 @@ df.style.\ set_table_attributes('style="font-size: 10px"') ``` -+++ {"tags": ["popout"]} ++++ {"tags": ["margin"]} -Testing popouts before headers +Testing margins before headers +++ diff --git a/docs/reference/special-theme-elements.md b/docs/reference/special-theme-elements.md index 701393f0..697fe140 100644 --- a/docs/reference/special-theme-elements.md +++ b/docs/reference/special-theme-elements.md @@ -189,14 +189,14 @@ This is my test Let's see what happens ```{code-cell} ipython3 -:tags: [popout] +:tags: [margin] ## code cell in the margin with output fig, ax = plt.subplots() ax.imshow(wide) ``` -+++ {"tags": ["popout"]} +````{margin} Markdown cell with code in margin @@ -220,25 +220,26 @@ Markdown cell with images in sidebar +```` +++ ### More content after the margin content -This is extra content after the popouts to see if cells overlap and such. -Also to make sure you can still interact with the popout content. -This is extra content after the popouts to see if cells overlap and such. -Also to make sure you can still interact with the popout content. +This is extra content after the margins to see if cells overlap and such. +Also to make sure you can still interact with the margin content. +This is extra content after the margins to see if cells overlap and such. +Also to make sure you can still interact with the margin content. ```python a = 2 ``` -This is extra content after the popouts to see if cells overlap and such. -Also to make sure you can still interact with the popout content. -This is extra content after the popouts to see if cells overlap and such. -Also to make sure you can still interact with the popout content. -This is extra content after the popouts to see if cells overlap and such. -Also to make sure you can still interact with the popout content. +This is extra content after the margins to see if cells overlap and such. +Also to make sure you can still interact with the margin content. +This is extra content after the margins to see if cells overlap and such. +Also to make sure you can still interact with the margin content. +This is extra content after the margins to see if cells overlap and such. +Also to make sure you can still interact with the margin content. ### Figures with margin captions @@ -259,7 +260,6 @@ And here is my figure caption, if you look to the left, you can see that COOL is This note should not overlap with the margin caption! ::: - Entire figures in the margin: ```{figure} ../images/cool.jpg @@ -271,6 +271,30 @@ alt: My figure text This figure should be entirely in the margin. ``` +## Sidenotes and marginnotes + +Here's a sentence[^sn1] with multiple [^sn2] sidenotes. + +[^sn1]: Test sidenote 1. +[^sn2]: Test sidenote 2. + +Here's a sentence[^mn1] with multiple marginnotes[^mn2]. + +[^mn1]: {-} Test marginnote 1. +[^mn2]: {-} Test marginnote 2. + + +Sidenotes inside of admonitions should behave the same: + +:::{note} +An admonition with a sidenote defined in the admonition[^snam1] and another defined outside of the admonition [^snam2]. + +[^snam1]: Sidenote defined in the admonition. + +::: + +[^snam2]: Sidenote defined outside the admonition. + ## Nested admonitions diff --git a/src/sphinx_book_theme/__init__.py b/src/sphinx_book_theme/__init__.py index 208099d7..4b60d0a2 100644 --- a/src/sphinx_book_theme/__init__.py +++ b/src/sphinx_book_theme/__init__.py @@ -5,13 +5,15 @@ from functools import lru_cache from docutils.parsers.rst.directives.body import Sidebar -from docutils import nodes +from docutils import nodes as docutil_nodes from sphinx.application import Sphinx from sphinx.locale import get_translation from sphinx.util import logging +from .nodes import SideNoteNode from .header_buttons import prep_header_buttons, add_header_buttons from .header_buttons.launch import add_launch_buttons +from ._transforms import HandleFootnoteTransform __version__ = "0.3.2" """sphinx-book-theme version""" @@ -43,7 +45,7 @@ def add_metadata_to_page(app, pagename, templatename, context, doctree): # Add a shortened page text to the context using the sections text if doctree: description = "" - for section in doctree.traverse(nodes.section): + for section in doctree.traverse(docutil_nodes.section): description += section.astext().replace("\n", " ") description = description[:160] context["page_description"] = description @@ -177,6 +179,9 @@ def setup(app: Sphinx): app.connect("html-page-context", add_metadata_to_page) app.connect("html-page-context", hash_html_assets) + # Nodes + SideNoteNode.add_node(app) + # Header buttons app.connect("html-page-context", prep_header_buttons) app.connect("html-page-context", add_launch_buttons) @@ -186,6 +191,9 @@ def setup(app: Sphinx): # Directives app.add_directive("margin", Margin) + # Post-transforms + app.add_post_transform(HandleFootnoteTransform) + # Update templates for sidebar app.config.templates_path.append(os.path.join(theme_dir, "components")) diff --git a/src/sphinx_book_theme/_transforms.py b/src/sphinx_book_theme/_transforms.py new file mode 100644 index 00000000..1b5dc2d1 --- /dev/null +++ b/src/sphinx_book_theme/_transforms.py @@ -0,0 +1,81 @@ +from sphinx.transforms.post_transforms import SphinxPostTransform +from typing import Any +from docutils import nodes as docutil_nodes +from sphinx import addnodes as sphinx_nodes +from .nodes import SideNoteNode +import copy + + +class HandleFootnoteTransform(SphinxPostTransform): + """Transform footnotes into side/marginnotes.""" + + default_priority = 1 + formats = ("html",) + + def run(self, **kwargs: Any) -> None: + theme_options = self.env.config.html_theme_options + if theme_options.get("use_sidenotes", False) is False: + return None + # Cycle through footnote references, and move their content next to the + # reference. This lets us display the reference in the margin, + # or just below on narrow screens. + for ref_node in self.document.traverse(docutil_nodes.footnote_reference): + parent = None + # Each footnote reference should have a single node it points to via `ids` + for foot_node in self.document.traverse(docutil_nodes.footnote): + # matching the footnote reference with footnote + if ( + len(foot_node.attributes["backrefs"]) + and foot_node.attributes["backrefs"][0] + == ref_node.attributes["ids"][0] + ): + parent = foot_node.parent + # second children of footnote node is the content text + text = foot_node.children[1].astext() + + sidenote = SideNoteNode() + para = docutil_nodes.inline() + # first children of footnote node is the label + label = foot_node.children[0].astext() + + if text.startswith("{-}"): + # marginnotes will have content starting with {-} + # remove the number so it doesn't show + para.attributes["classes"].append("marginnote") + para.append(docutil_nodes.Text(text.replace("{-}", ""))) + + sidenote.attributes["names"].append(f"marginnote-role-{label}") + else: + # sidenotes are the default behavior if no {-} + # in this case we keep the number + superscript = docutil_nodes.superscript("", label) + para.attributes["classes"].append("sidenote") + para.extend([superscript, docutil_nodes.Text(text)]) + + sidenote.attributes["names"].append(f"sidenote-role-{label}") + sidenote.append(superscript) + + # If the reference is nested (e.g. in an admonition), duplicate + # the content node And place it just before the parent container, + # so it works w/ margin. Only show one or another depending on + # screen width. + node_parent = ref_node.parent + para_dup = copy.deepcopy(para) + # looping to check parent node + while not isinstance( + node_parent, (docutil_nodes.section, sphinx_nodes.document) + ): + # if parent node is another container + if not isinstance( + node_parent, + (docutil_nodes.paragraph, docutil_nodes.footnote), + ): + node_parent.replace_self([para, node_parent]) + para_dup.attributes["classes"].append("d-n") + break + node_parent = node_parent.parent + + ref_node.replace_self([sidenote, para_dup]) + break + if parent: + parent.remove(foot_node) diff --git a/src/sphinx_book_theme/assets/scripts/index.js b/src/sphinx_book_theme/assets/scripts/index.js index 18336f70..a5b2a594 100644 --- a/src/sphinx_book_theme/assets/scripts/index.js +++ b/src/sphinx_book_theme/assets/scripts/index.js @@ -132,7 +132,10 @@ var initTocHide = () => { // Set up the intersection observer to watch all margin content let tocObserver = new IntersectionObserver(hideTocCallback); + // TODO: deprecate popout after v0.5.0 const selectorClasses = [ + "marginnote", + "sidenote", "margin", "margin-caption", "full-width", diff --git a/src/sphinx_book_theme/assets/styles/base/_base.scss b/src/sphinx_book_theme/assets/styles/base/_base.scss index bfff355f..75653d23 100644 --- a/src/sphinx_book_theme/assets/styles/base/_base.scss +++ b/src/sphinx_book_theme/assets/styles/base/_base.scss @@ -23,6 +23,11 @@ width: 1px !important; } +// We define our own display-none class since bootstrap uses !important and we want to be able to over-ride +.d-n { + display: none; +} + // Print-specific utility classes .onlyprint { display: none; diff --git a/src/sphinx_book_theme/assets/styles/content/_margin.scss b/src/sphinx_book_theme/assets/styles/content/_margin.scss index dda9dd3a..14f345ef 100644 --- a/src/sphinx_book_theme/assets/styles/content/_margin.scss +++ b/src/sphinx_book_theme/assets/styles/content/_margin.scss @@ -25,7 +25,6 @@ $content-fullwidth-width: percentage(100% / $content-max-width); background-color: unset; border-left: 1px #a4a6a7 solid; font-size: 0.9em; - @media (min-width: $breakpoint-md) { border: none; width: $content-margin-width; @@ -38,6 +37,55 @@ $content-fullwidth-width: percentage(100% / $content-max-width); } } +// Sidenotes and marginnotes +label.margin-toggle { + margin-bottom: 0em; + &.marginnote-label { + display: none; + } + sup { + user-select: none; + } + @media (max-width: $breakpoint-md) { + cursor: pointer; + color: rgb(0, 113, 188); + &.marginnote-label { + display: inline; + &:after { + content: "\2295"; + } + } + } +} + +input.margin-toggle { + display: none; + @media (max-width: $breakpoint-md) { + &:checked + .sidenote, + &:checked + .marginnote { + display: block; + float: left; + left: 1rem; + clear: both; + width: 95%; + margin: 1rem 2.5%; + position: relative; + } + } +} + +span.sidenote, +span.marginnote { + sup { + user-select: none; + } + @include margin-content(); + border-left: none; + @media (max-width: $breakpoint-md) { + display: none; + } +} + div.margin, aside.margin, figure.margin, diff --git a/src/sphinx_book_theme/nodes.py b/src/sphinx_book_theme/nodes.py new file mode 100644 index 00000000..7735652c --- /dev/null +++ b/src/sphinx_book_theme/nodes.py @@ -0,0 +1,37 @@ +from docutils import nodes +from sphinx.application import Sphinx +from typing import Any, cast + + +class SideNoteNode(nodes.Element): + """Handles rendering of side/marginnote content text for html outputs. + Inserts required html to handle both desktop and mobile.""" + + def __init__(self, rawsource="", *children, **attributes): + super().__init__("", **attributes) + + @classmethod + def add_node(cls, app: Sphinx) -> None: + add_node = cast(Any, app.add_node) # has the wrong typing for sphinx<4 + add_node(cls, override=True, html=(visit_SideNoteNode, depart_SideNoteNode)) + + +def visit_SideNoteNode(self, node): + tagid = node.attributes["names"][0] + if "marginnote" in tagid: + self.body.append( + f"