Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add anchors to Markdown headers #2513

Merged
32 commits merged into from Jan 12, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,60 @@ export interface Props {
allowHTML: boolean
}

function createAnchorFromText(text: string | null): string {
This conversation was marked as resolved.
Show resolved Hide resolved
const newAnchor = text
?.toLowerCase()
.split(/[^A-Za-z0-9]/)
.filter(Boolean)
.join("-")
return newAnchor || ""
}

function createHeadingWithAnchor() {
let alreadyScrolled = false
return function({ tag, anchor, children }: any): ReactElement {
const ref = React.useCallback(
node => {
if (node !== null && !alreadyScrolled) {
const hash = window.location.hash.slice(1)
if (hash === (anchor || createAnchorFromText(node.textContent))) {
node.scrollIntoView(true)
alreadyScrolled = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm so this alreadyScrolled would prevent scrolling back to the same element when the anchor value changes. But if we somehow changed the hash and then changed it back, I don't believe this will get called / scroll into view. Alternatively if we changed the anchor and changed it back to match the current hash, it wouldn't scroll. Not sure how many scrolling use cases we want to get into with all this scrolling business. Could be something we refine as part of a future task but defer to @asaini.

I'm not sure if this would also prevent us from scrolling to multiple elements? alreadyScrolled looks to be locally defined so each tag with an anchor would get it's own.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I fixed this by just passing the anchor as the header's id. The dynamic scrolling handles scrolling on render, and after that, the browser handles everything else for us (clicking on a #link, manually changing the url, etc.)

I'm not sure if this would also prevent us from scrolling to multiple elements? alreadyScrolled looks to be locally defined so each tag with an anchor would get it's own.

createHeadingWithAnchor() is only called once to initialize HeaderWithAnchor, so all instances of <HeaderWithAnchor> will use that variable stored in the closure. You're right though, it's confusing, I'll just make it global.

}
}
},
[anchor]
)
return React.createElement(tag, { ref }, children)
}
}

const HeadingWithAnchor = createHeadingWithAnchor()

function CustomHeading({ level, children }: any): ReactElement {
This conversation was marked as resolved.
Show resolved Hide resolved
return <HeadingWithAnchor tag={`h${level}`}>{children}</HeadingWithAnchor>
}

function CustomParsedHtml(props: any): ReactElement {
This conversation was marked as resolved.
Show resolved Hide resolved
const {
element: {
type,
props: { "data-anchor": anchor, children },
},
} = props

const headingElements = ["h1", "h2", "h3", "h4", "h5", "h6"]
if (!headingElements.includes(type)) {
return (ReactMarkdown.renderers.parsedHtml as any)(props)
kantuni marked this conversation as resolved.
Show resolved Hide resolved
}

return (
<HeadingWithAnchor tag={type} anchor={anchor}>
{children}
</HeadingWithAnchor>
)
}

/**
* Wraps the <ReactMarkdown> component to include our standard
* renderers and AST plugins (for syntax highlighting, HTML support, etc).
Expand Down Expand Up @@ -71,6 +125,8 @@ class StreamlitMarkdown extends PureComponent<Props> {
math: (props: { value: string }): ReactElement => (
<BlockMath>{props.value}</BlockMath>
),
heading: CustomHeading,
parsedHtml: CustomParsedHtml,
}

const plugins = [RemarkMathPlugin, RemarkEmoji]
Expand Down
37 changes: 31 additions & 6 deletions lib/streamlit/elements/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,18 @@ def markdown(self, body, unsafe_allow_html=False):

return self.dg._enqueue("markdown", markdown_proto)

def header(self, body):
def header(self, body, anchor=None):
This conversation was marked as resolved.
Show resolved Hide resolved
"""Display text in header formatting.

Parameters
----------
body : str
The text to display.

anchor : str
The anchor name of the header that can be accessed with #anchor
in the URL. If omitted, it generates an anchor using the body.

Example
-------
>>> st.header('This is a header')
Expand All @@ -96,17 +100,25 @@ def header(self, body):

"""
header_proto = MarkdownProto()
header_proto.body = "## %s" % clean_text(body)
if anchor is None:
header_proto.body = f'## {clean_text(body)}'
This conversation was marked as resolved.
Show resolved Hide resolved
else:
header_proto.body = f'<h2 data-anchor="{anchor}">{clean_text(body)}</h2>'
header_proto.allow_html = True
return self.dg._enqueue("markdown", header_proto)

def subheader(self, body):
def subheader(self, body, anchor=None):
"""Display text in subheader formatting.

Parameters
----------
body : str
The text to display.

anchor : str
The anchor name of the header that can be accessed with #anchor
in the URL. If omitted, it generates an anchor using the body.

Example
-------
>>> st.subheader('This is a subheader')
Expand All @@ -117,7 +129,12 @@ def subheader(self, body):

"""
subheader_proto = MarkdownProto()
subheader_proto.body = "### %s" % clean_text(body)
if anchor is None:
subheader_proto.body = f'### {clean_text(body)}'
This conversation was marked as resolved.
Show resolved Hide resolved
else:
subheader_proto.body = f'<h3 data-anchor="{anchor}">I{clean_text(body)}</h3>'
This conversation was marked as resolved.
Show resolved Hide resolved
subheader_proto.allow_html = True

return self.dg._enqueue("markdown", subheader_proto)

def code(self, body, language="python"):
Expand Down Expand Up @@ -153,7 +170,7 @@ def code(self, body, language="python"):
code_proto.body = clean_text(markdown)
return self.dg._enqueue("markdown", code_proto)

def title(self, body):
def title(self, body, anchor=None):
"""Display text in title formatting.

Each document should have a single `st.title()`, although this is not
Expand All @@ -164,6 +181,10 @@ def title(self, body):
body : str
The text to display.

anchor : str
The anchor name of the header that can be accessed with #anchor
in the URL. If omitted, it generates an anchor using the body.

Example
-------
>>> st.title('This is a title')
Expand All @@ -174,7 +195,11 @@ def title(self, body):

"""
title_proto = MarkdownProto()
title_proto.body = "# %s" % clean_text(body)
if anchor is None:
title_proto.body = f'# {clean_text(body)}'
This conversation was marked as resolved.
Show resolved Hide resolved
else:
title_proto.body = f'<h1 data-anchor="{anchor}">{clean_text(body)}</h1>'
title_proto.allow_html = True
return self.dg._enqueue("markdown", title_proto)

def latex(self, body):
Expand Down