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 9 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,69 @@ 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 HeadingWithAnchor({
tag,
anchor: propsAnchor,
children,
}: any): ReactElement {
This conversation was marked as resolved.
Show resolved Hide resolved
const [anchor, setAnchor] = React.useState<string | null>(null)
const ref = React.useRef<HTMLElement>(null)

React.useEffect(() => {
if (anchor === null && ref.current !== null) {
const newAnchor =
propsAnchor || createAnchorFromText(ref.current.textContent)
if (newAnchor) {
setAnchor(newAnchor)
if (window.location.hash.slice(1) === newAnchor) {
ref.current.scrollIntoView(true)
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this would work with cloud. I assume we will want the hash to be in the parent window url bar but the app is in a iframe. Shouldn't block us but is something we should raise to them to support.

Also, if for some reason we have 2 headers with the same anchor, this could scroll through each one until the last one. Not sure if thats desirable? I think this could be good for now but I could see later where we would want to move the scrolling to a global action.

Copy link
Author

Choose a reason for hiding this comment

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

The issue I ran into was that we need to use refs to grab the header elements after they're mounted (so we can use element.textContent to get the text to generate the anchor), and there's not really a callback like "when all refs have been received," so there's no natural place to call the global action.

I fixed this for now by just keeping track of an alreadyScrolled variable and only scrolling the first time. Good concern, we'll definitely circle back to this.

}
}
}
}, [ref.current])

return React.createElement(
tag,
{ ref },
<>
{anchor && <a data-anchor={anchor} />}
{children}
</>
Copy link
Contributor

Choose a reason for hiding this comment

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

Could be id or name on the tag instead? See below for more

Copy link
Author

Choose a reason for hiding this comment

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

)
}

function CustomHeading(props: any): ReactElement {
const { level, children } = props
return <HeadingWithAnchor tag={`h${level}`}>{children}</HeadingWithAnchor>
}

function CustomParsedHtml(props: any): ReactElement {
This conversation was marked as resolved.
Show resolved Hide resolved
const { element } = props

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

return (
<HeadingWithAnchor
tag={element.type}
anchor={element.props["data-anchor"]}
>
{element.props.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 +134,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
25 changes: 19 additions & 6 deletions lib/streamlit/elements/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ 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
Expand All @@ -96,10 +96,14 @@ def header(self, body):

"""
header_proto = MarkdownProto()
header_proto.body = "## %s" % clean_text(body)
if anchor is None:
header_proto.body = '## %s' % clean_text(body)
else:
header_proto.body = '<h2 data-anchor="%s">%s</h2>' % (anchor, clean_text(body))
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe anchors are generally populated as the id or name attribute. Multipage app is something we will want to do later and we may need to switch to id later depending how we proceed.

Copy link
Author

Choose a reason for hiding this comment

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

Addressing this comment and your other comment together:

Here all we're doing is finding a way to pass the anchor to the frontend, which eventually intercepts the data-anchor in the ReactMarkdown renderer, and never actually prints it out in the <h2> element.

The <a data-anchor="..."> is because I used name at first, but apparently React doesn't support the name property on <a> tags. Don't know why.

Can you say more about multipage app and how that relates to using id?

Copy link
Author

Choose a reason for hiding this comment

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

Update: I just removed the <a data-anchor> altogether as it wasn't doing anything.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can you say more about multipage app and how that relates to using id?

My first thought for multipage app is using react router and I believe the hash link from react router would scroll to the element matching the ID. Then if we passed the ID here we could just spit out the html as is without doing any modifications.

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
Expand All @@ -117,7 +121,12 @@ def subheader(self, body):

"""
subheader_proto = MarkdownProto()
subheader_proto.body = "### %s" % clean_text(body)
if anchor is None:
subheader_proto.body = '### %s' % clean_text(body)
else:
subheader_proto.body = '<h3 data-anchor="%s">%s</h3>' % (anchor, clean_text(body))
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 +162,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 @@ -174,7 +183,11 @@ def title(self, body):

"""
title_proto = MarkdownProto()
title_proto.body = "# %s" % clean_text(body)
if anchor is None:
title_proto.body = '# %s' % clean_text(body)
else:
title_proto.body = '<h1 data-anchor="%s">%s</h1>' % (anchor, clean_text(body))
title_proto.allow_html = True
return self.dg._enqueue("markdown", title_proto)

def latex(self, body):
Expand Down