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 27 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
1 change: 1 addition & 0 deletions e2e/scripts/st_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.header("This header is awesome!")
st.header("This header is awesome too!", anchor="awesome-header")
4 changes: 4 additions & 0 deletions e2e/scripts/st_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@
$$
"""
)

st.markdown("# Some header 1")
st.markdown("## Some header 2")
st.markdown("### Some header 3")
1 change: 1 addition & 0 deletions e2e/scripts/st_subheader.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.subheader("This subheader is awesome!")
st.subheader("This subheader is awesome too!", anchor="awesome-subheader")
1 change: 1 addition & 0 deletions e2e/scripts/st_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
import streamlit as st

st.title("This title is awesome!")
st.title("This title is awesome too!", anchor="awesome-title")
12 changes: 8 additions & 4 deletions e2e/specs/st_header.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ describe("st.header", () => {
});

it("displays a header", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h2").should(
"contain",
"This header is awesome!"
);
cy.get(".element-container .stMarkdown h2").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h2").then(els => {
expect(els[0].textContent).to.eq("This header is awesome!");
expect(els[1].textContent).to.eq("This header is awesome too!");

cy.wrap(els[0]).should("have.attr", "id", "this-header-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-header");
});
});
});
16 changes: 14 additions & 2 deletions e2e/specs/st_markdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe("st.markdown", () => {
});

it("displays markdown", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown").should("have.length", 8);
cy.get(".element-container .stMarkdown").should("have.length", 11);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown").then(els => {
expect(els[0].textContent).to.eq("This markdown is awesome! 😎");
expect(els[1].textContent).to.eq("This <b>HTML tag</b> is escaped!");
Expand All @@ -33,14 +33,26 @@ describe("st.markdown", () => {
expect(els[7].textContent).to.eq(
"ax2+bx+c=0ax^2 + bx + c = 0ax2+bx+c=0"
);
expect(els[8].textContent).to.eq("Some header 1");
expect(els[9].textContent).to.eq("Some header 2");
expect(els[10].textContent).to.eq("Some header 3");

cy.wrap(els[3])
.find("a")
.should("not.exist");

cy.wrap(els[4])
.find("a")
.should("have.attr", "href");

cy.wrap(els[8])
.find("h1")
.should("have.attr", "id", "some-header-1");
cy.wrap(els[9])
.find("h2")
.should("have.attr", "id", "some-header-2");
cy.wrap(els[10])
.find("h3")
.should("have.attr", "id", "some-header-3");
});
});
});
12 changes: 8 additions & 4 deletions e2e/specs/st_subheader.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ describe("st.subheader", () => {
});

it("displays a subheader", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h3").should(
"contain",
"This subheader is awesome!"
);
cy.get(".element-container .stMarkdown h3").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h3").then(els => {
expect(els[0].textContent).to.eq("This subheader is awesome!");
expect(els[1].textContent).to.eq("This subheader is awesome too!");

cy.wrap(els[0]).should("have.attr", "id", "this-subheader-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-subheader");
});
});
});
12 changes: 8 additions & 4 deletions e2e/specs/st_title.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ describe("st.title", () => {
});

it("displays a title", () => {
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h1").should(
"contain",
"This title is awesome!"
);
cy.get(".element-container .stMarkdown h1").should("have.length", 2);
This conversation was marked as resolved.
Show resolved Hide resolved
cy.get(".element-container .stMarkdown h1").then(els => {
expect(els[0].textContent).to.eq("This title is awesome!");
expect(els[1].textContent).to.eq("This title is awesome too!");

cy.wrap(els[0]).should("have.attr", "id", "this-title-is-awesome");
cy.wrap(els[1]).should("have.attr", "id", "awesome-title");
});
});
});
22 changes: 22 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
IGitInfo,
GitInfo,
} from "autogen/proto"
import { without, concat } from "lodash"

import { RERUN_PROMPT_MODAL_DIALOG } from "lib/baseconsts"
import { SessionInfo } from "lib/SessionInfo"
Expand Down Expand Up @@ -102,6 +103,7 @@ interface State {
layout: PageConfig.Layout
initialSidebarState: PageConfig.SidebarState
allowRunOnSave: boolean
reportFinishedHandlers: (() => void)[]
gitInfo?: IGitInfo | null
}

Expand Down Expand Up @@ -159,6 +161,7 @@ export class App extends PureComponent<Props, State> {
layout: PageConfig.Layout.CENTERED,
initialSidebarState: PageConfig.SidebarState.AUTO,
allowRunOnSave: true,
reportFinishedHandlers: [],
gitInfo: null,
}

Expand Down Expand Up @@ -572,6 +575,11 @@ export class App extends PureComponent<Props, State> {
*/
handleReportFinished(status: ForwardMsg.ReportFinishedStatus): void {
if (status === ForwardMsg.ReportFinishedStatus.FINISHED_SUCCESSFULLY) {
// Notify any subscribers of this event (and do it next event loop)
This conversation was marked as resolved.
Show resolved Hide resolved
window.setTimeout(() => {
this.state.reportFinishedHandlers.map(handler => handler())
}, 0)

// Clear any stale elements left over from the previous run.
// (We don't do this if our script had a compilation error and didn't
// finish successfully.)
Expand Down Expand Up @@ -924,6 +932,18 @@ export class App extends PureComponent<Props, State> {
this.setState({ isFullScreen })
}

addReportFinshedHandler = (func: () => void): void => {
This conversation was marked as resolved.
Show resolved Hide resolved
this.setState({
reportFinishedHandlers: concat(this.state.reportFinishedHandlers, func),
})
}

removeReportFinshedHandler = (func: () => void): void => {
This conversation was marked as resolved.
Show resolved Hide resolved
this.setState({
reportFinishedHandlers: without(this.state.reportFinishedHandlers, func),
})
}

render(): JSX.Element {
const {
allowRunOnSave,
Expand Down Expand Up @@ -960,6 +980,8 @@ export class App extends PureComponent<Props, State> {
embedded: isEmbeddedInIFrame(),
isFullScreen,
setFullScreen: this.handleFullScreen,
addReportFinshedHandler: this.addReportFinshedHandler,
removeReportFinshedHandler: this.removeReportFinshedHandler,
}}
>
<GlobalHotKeys keyMap={this.keyMap} handlers={this.keyHandlers}>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/core/PageLayoutContext/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ export default React.createContext({
embedded: false,
isFullScreen: false,
setFullScreen: (value: boolean) => {},
addReportFinshedHandler: (func: () => void) => {},
This conversation was marked as resolved.
Show resolved Hide resolved
removeReportFinshedHandler: (func: () => void) => {},
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import React, { ReactElement, ReactNode, Fragment, PureComponent } from "react"
import ReactMarkdown from "react-markdown"
import { once } from "lodash"
// @ts-ignore
import htmlParser from "react-markdown/plugins/html-parser"
// @ts-ignore
Expand All @@ -25,6 +26,7 @@ import { BlockMath, InlineMath } from "react-katex"
import RemarkMathPlugin from "remark-math"
// @ts-ignore
import RemarkEmoji from "remark-emoji"
import PageLayoutContext from "components/core/PageLayoutContext"
import CodeBlock from "components/elements/CodeBlock/"
import { StyledStreamlitMarkdown } from "./styled-components"

Expand All @@ -43,6 +45,87 @@ 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 || ""
}

// wrapping in `once` ensures we only scroll once
const scrollNodeIntoView = once((node: HTMLElement): void => {
node.scrollIntoView(true)
})

function HeadingWithAnchor({
tag,
anchor: propsAnchor,
children,
}: any): ReactElement {
This conversation was marked as resolved.
Show resolved Hide resolved
const [elementId, setElementId] = React.useState(propsAnchor)
const [target, setTarget] = React.useState<HTMLElement | null>(null)

const {
addReportFinshedHandler,
removeReportFinshedHandler,
} = React.useContext(PageLayoutContext)

const onReportFinished = React.useCallback(() => {
if (target !== null) {
// wait a bit for everything on page to finish loading
window.setTimeout(() => {
scrollNodeIntoView(target)
}, 300)
}
}, [target])

React.useEffect(() => {
addReportFinshedHandler(onReportFinished)
return () => {
removeReportFinshedHandler(onReportFinished)
}
}, [addReportFinshedHandler, removeReportFinshedHandler, onReportFinished])

const ref = React.useCallback(
node => {
if (node === null) return
This conversation was marked as resolved.
Show resolved Hide resolved

const anchor = propsAnchor || createAnchorFromText(node.textContent)
setElementId(anchor)
if (window.location.hash.slice(1) === anchor) {
setTarget(node)
}
},
[propsAnchor]
)

return React.createElement(tag, { ref, id: elementId }, children)
}

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: elementProps },
} = 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
}

const { "data-anchor": anchor, children } = elementProps
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 +154,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 Expand Up @@ -105,8 +190,13 @@ interface LinkReferenceProps {
// Using target="_blank" without rel="noopener noreferrer" is a security risk:
// see https://mathiasbynens.github.io/rel-noopener
export function linkWithTargetBlank(props: LinkProps): ReactElement {
const { href, title, children } = props
// if it's a #hash link, don't open in new tab
if (props.href.startsWith("#")) {
const { children, ...rest } = props
return <a {...rest}>{children}</a>
}

const { href, title, children } = props
return (
<a href={href} title={title} target="_blank" rel="noopener noreferrer">
{children}
Expand Down