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

[docs] Localize the table of contents #14548

Merged
merged 14 commits into from
Feb 19, 2019
325 changes: 161 additions & 164 deletions docs/src/modules/components/AppTableOfContents.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
/* eslint-disable react/no-danger */

import React from 'react';
import React, { useState, useEffect } from 'react';
mbrookes marked this conversation as resolved.
Show resolved Hide resolved
import PropTypes from 'prop-types';
import marked from 'marked';
import warning from 'warning';
import throttle from 'lodash/throttle';
import EventListener from 'react-event-listener';
import clsx from 'clsx';
import { connect } from 'react-redux';
import { withStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import { textToHash } from '@material-ui/docs/MarkdownElement/MarkdownElement';
import Link from 'docs/src/modules/components/Link';

let itemsCollector;
const renderer = new marked.Renderer();
renderer.heading = (text, level) => {
if (level === 2) {
itemsCollector.push({
text,
level,
hash: textToHash(text),
children: [],
});
} else if (level === 3) {
if (!itemsCollector[itemsCollector.length - 1]) {
throw new Error(`Missing parent level for: ${text}`);
}

itemsCollector[itemsCollector.length - 1].children.push({
text,
level,
hash: textToHash(text),
});
}
};

function getItems(contents) {
itemsCollector = [];
marked(contents.join(''), {
renderer,
});

return itemsCollector;
}
import compose from 'docs/src/modules/utils/compose';

const styles = theme => ({
root: {
Expand All @@ -64,6 +34,7 @@ const styles = theme => ({
},
contents: {
marginTop: theme.spacing(2),
paddingLeft: theme.spacing(1.5),
},
ul: {
padding: 0,
Expand Down Expand Up @@ -92,87 +63,69 @@ const styles = theme => ({
active: {},
});

function checkDuplication(uniq, item) {
warning(!uniq[item.hash], `Table of content: duplicated \`${item.hash}\` item`);

if (!uniq[item.hash]) {
uniq[item.hash] = true;
}
}

class AppTableOfContents extends React.Component {
handleScroll = throttle(() => {
this.findActiveIndex();
}, 166); // Corresponds to 10 frames at 60 Hz.

clicked = false;
let itemsCollector;
const renderer = new marked.Renderer();
renderer.heading = (text, level) => {
if (level === 2) {
itemsCollector.push({
text,
level,
hash: textToHash(text),
children: [],
});
} else if (level === 3) {
if (!itemsCollector[itemsCollector.length - 1]) {
throw new Error(`Missing parent level for: ${text}`);
}

constructor(props) {
super();
this.itemsServer = getItems(props.contents);
itemsCollector[itemsCollector.length - 1].children.push({
text,
level,
hash: textToHash(text),
});
}
};

state = {
active: null,
};

componentDidMount() {
this.itemsClient = [];
const uniq = {};
function getItems(contents) {
itemsCollector = [];
marked(contents.join(''), {
renderer,
});

this.itemsServer.forEach(item2 => {
checkDuplication(uniq, item2);
this.itemsClient.push({
...item2,
node: document.getElementById(item2.hash),
});
return itemsCollector;
}

if (item2.children.length > 0) {
item2.children.forEach(item3 => {
checkDuplication(uniq, item3);
this.itemsClient.push({
...item3,
node: document.getElementById(item3.hash),
});
});
}
});
window.addEventListener('hashchange', this.handleHashChange);
}
function checkDuplication(uniq, item) {
warning(!uniq[item.hash], `Table of content: duplicated \`${item.hash}\` item`);

componentWillUnmount() {
this.handleScroll.cancel();
clearTimeout(this.unsetClicked);
window.removeEventListener('hashchange', this.handleHashChange);
if (!uniq[item.hash]) {
uniq[item.hash] = true;
}
}

// Update the active TOC entry if the hash changes through click on '#' icon
handleHashChange = () => {
const hash = window.location.hash.substring(1);
function AppTableOfContents(props) {
const [activeState, setActiveState] = useState(null);
const [itemsServerState, setItemsServerState] = useState([]);

if (this.state.active !== hash) {
this.setState({
active: hash,
});
}
};
let clicked = false;
let unsetClicked;
let itemsClient = [];

findActiveIndex = () => {
const findActiveIndex = () => {
// Don't set the active index based on scroll if a link was just clicked
if (this.clicked) {
if (clicked) {
return;
}

let active;

for (let i = this.itemsClient.length - 1; i >= 0; i -= 1) {
for (let i = itemsClient.length - 1; i >= 0; i -= 1) {
// No hash if we're near the top of the page
if (document.documentElement.scrollTop < 200) {
active = { hash: null };
break;
}

const item = this.itemsClient[i];
const item = itemsClient[i];

warning(item.node, `Missing node on the item ${JSON.stringify(item, null, 2)}`);

Expand All @@ -186,10 +139,8 @@ class AppTableOfContents extends React.Component {
}
}

if (active && this.state.active !== active.hash) {
this.setState({
active: active.hash,
});
if (active && activeState !== active.hash) {
setActiveState(active.hash);

window.history.replaceState(
null,
Expand All @@ -201,83 +152,129 @@ class AppTableOfContents extends React.Component {
}
};

handleClick = hash => () => {
// Update the active TOC entry if the hash changes through click on '#' icon
const handleHashChange = () => {
const hash = window.location.hash.substring(1);

if (activeState !== hash) {
setActiveState(hash);
}
};

const handleScroll = throttle(() => {
findActiveIndex();
}, 166); // Corresponds to 10 frames at 60 Hz.

useEffect(() => {
setItemsServerState(getItems(props.contents));
itemsClient = [];
const unique = {};

itemsServerState.forEach(item2 => {
checkDuplication(unique, item2);
itemsClient.push({
...item2,
node: document.getElementById(item2.hash),
});

if (item2.children.length > 0) {
item2.children.forEach(item3 => {
checkDuplication(unique, item3);
itemsClient.push({
...item3,
node: document.getElementById(item3.hash),
});
});
}
});

window.addEventListener('hashchange', handleHashChange);

return function componentWillUnmount() {
mbrookes marked this conversation as resolved.
Show resolved Hide resolved
handleScroll.cancel();
clearTimeout(unsetClicked);
window.removeEventListener('hashchange', handleHashChange);
};
});

const handleClick = hash => () => {
// Used to disable findActiveIndex if the page scrolls due to a click
this.clicked = true;
this.unsetClicked = setTimeout(() => {
this.clicked = false;
clicked = true;
unsetClicked = setTimeout(() => {
clicked = false;
}, 1000);

if (this.state.active !== hash) {
this.setState({
active: hash,
});
if (activeState !== hash) {
setActiveState(hash);
}
};

render() {
const { classes } = this.props;
const { active } = this.state;

return (
<nav className={classes.root}>
{this.itemsServer.length > 0 ? (
<React.Fragment>
<Typography gutterBottom className={classes.contents}>
Contents
</Typography>
<EventListener target="window" onScroll={this.handleScroll} />
<Typography component="ul" className={classes.ul}>
{this.itemsServer.map(item2 => (
<li key={item2.text}>
<Link
block
color={active === item2.hash ? 'textPrimary' : 'textSecondary'}
href={`#${item2.hash}`}
underline="none"
onClick={this.handleClick(item2.hash)}
className={clsx(
classes.item,
active === item2.hash ? classes.active : undefined,
)}
>
<span dangerouslySetInnerHTML={{ __html: item2.text }} />
</Link>
{item2.children.length > 0 ? (
<ul className={classes.ul}>
{item2.children.map(item3 => (
<li key={item3.text}>
<Link
block
color={active === item3.hash ? 'textPrimary' : 'textSecondary'}
href={`#${item3.hash}`}
underline="none"
onClick={this.handleClick(item3.hash)}
className={clsx(
classes.item,
classes.secondaryItem,
active === item3.hash ? classes.active : undefined,
)}
>
<span dangerouslySetInnerHTML={{ __html: item3.text }} />
</Link>
</li>
))}
</ul>
) : null}
</li>
))}
</Typography>
</React.Fragment>
) : null}
</nav>
);
}
const { classes, t } = props;

return (
<nav className={classes.root}>
{itemsServerState.length > 0 ? (
<React.Fragment>
<Typography gutterBottom className={classes.contents}>
{t('tableOfContents')}
</Typography>
<EventListener target="window" onScroll={handleScroll} />
<Typography component="ul" className={classes.ul}>
{itemsServerState.map(item2 => (
<li key={item2.text}>
<Link
block
color={activeState === item2.hash ? 'textPrimary' : 'textSecondary'}
href={`#${item2.hash}`}
underline="none"
onClick={handleClick(item2.hash)}
className={clsx(
classes.item,
activeState === item2.hash ? classes.active : undefined,
)}
>
<span dangerouslySetInnerHTML={{ __html: item2.text }} />
</Link>
{item2.children.length > 0 ? (
<ul className={classes.ul}>
{item2.children.map(item3 => (
<li key={item3.text}>
<Link
block
color={activeState === item3.hash ? 'textPrimary' : 'textSecondary'}
href={`#${item3.hash}`}
underline="none"
onClick={handleClick(item3.hash)}
className={clsx(
classes.item,
classes.secondaryItem,
activeState === item3.hash ? classes.active : undefined,
)}
>
<span dangerouslySetInnerHTML={{ __html: item3.text }} />
</Link>
</li>
))}
</ul>
) : null}
</li>
))}
</Typography>
</React.Fragment>
) : null}
</nav>
);
}

AppTableOfContents.propTypes = {
classes: PropTypes.object.isRequired,
contents: PropTypes.array.isRequired,
t: PropTypes.func.isRequired,
};

export default withStyles(styles)(AppTableOfContents);
export default compose(
connect(state => ({
t: state.options.t,
})),
withStyles(styles),
)(AppTableOfContents);
Loading