diff --git a/docs/src/modules/components/AppTableOfContents.js b/docs/src/modules/components/AppTableOfContents.js index 80cdcadaa87ef4..a9de27ac196411 100644 --- a/docs/src/modules/components/AppTableOfContents.js +++ b/docs/src/modules/components/AppTableOfContents.js @@ -5,44 +5,13 @@ 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 textToHash from '@material-ui/docs/MarkdownElement/textToHash'; 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: { @@ -64,6 +33,7 @@ const styles = theme => ({ }, contents: { marginTop: theme.spacing(2), + paddingLeft: theme.spacing(1.5), }, ul: { padding: 0, @@ -92,6 +62,40 @@ const styles = theme => ({ active: {}, }); +const renderer = new marked.Renderer(); + +function setRenderer(itemsCollectorRef) { + renderer.heading = (text, level) => { + if (level === 2) { + itemsCollectorRef.current.push({ + text, + level, + hash: textToHash(text), + children: [], + }); + } else if (level === 3) { + if (!itemsCollectorRef.current[itemsCollectorRef.current.length - 1]) { + throw new Error(`Missing parent level for: ${text}`); + } + + itemsCollectorRef.current[itemsCollectorRef.current.length - 1].children.push({ + text, + level, + hash: textToHash(text), + }); + } + }; +} + +function getItemsServer(contents, itemsCollectorRef) { + itemsCollectorRef.current = []; + marked(contents.join(''), { + renderer, + }); + + return itemsCollectorRef.current; +} + function checkDuplication(uniq, item) { warning(!uniq[item.hash], `Table of content: duplicated \`${item.hash}\` item`); @@ -100,79 +104,69 @@ function checkDuplication(uniq, item) { } } -class AppTableOfContents extends React.Component { - handleScroll = throttle(() => { - this.findActiveIndex(); - }, 166); // Corresponds to 10 frames at 60 Hz. - - clicked = false; - - constructor(props) { - super(); - this.itemsServer = getItems(props.contents); - } - - state = { - active: null, - }; - - componentDidMount() { - this.itemsClient = []; - const uniq = {}; - - this.itemsServer.forEach(item2 => { - checkDuplication(uniq, item2); - this.itemsClient.push({ - ...item2, - node: document.getElementById(item2.hash), - }); +function getItemsClient(items) { + const itemsClient = []; + const unique = {}; - if (item2.children.length > 0) { - item2.children.forEach(item3 => { - checkDuplication(uniq, item3); - this.itemsClient.push({ - ...item3, - node: document.getElementById(item3.hash), - }); - }); - } + items.forEach(item2 => { + checkDuplication(unique, item2); + itemsClient.push({ + ...item2, + node: document.getElementById(item2.hash), }); - window.addEventListener('hashchange', this.handleHashChange); - } - - componentWillUnmount() { - this.handleScroll.cancel(); - clearTimeout(this.unsetClicked); - window.removeEventListener('hashchange', this.handleHashChange); - } - - // Update the active TOC entry if the hash changes through click on '#' icon - handleHashChange = () => { - const hash = window.location.hash.substring(1); - if (this.state.active !== hash) { - this.setState({ - active: hash, + if (item2.children.length > 0) { + item2.children.forEach(item3 => { + checkDuplication(unique, item3); + itemsClient.push({ + ...item3, + node: document.getElementById(item3.hash), + }); }); } - }; + }); + return itemsClient; +} + +function useThrottledOnScroll(callback, delay) { + const throttledCallback = React.useMemo(() => throttle(callback, delay), [delay]); + + /* eslint-disable-next-line consistent-return */ + React.useEffect(() => { + if (delay != null) { + window.addEventListener('scroll', throttledCallback); + return () => { + throttledCallback.cancel(); + window.removeEventListener('scroll', throttledCallback); + }; + } + }, [throttledCallback]); +} - findActiveIndex = () => { +function AppTableOfContents(props) { + const { classes, contents, t } = props; + const itemsCollectorRef = React.useRef([]); + const [itemsServer, setItemsServer] = React.useState([]); + const itemsClientRef = React.useRef([]); + const [activeState, setActiveState] = React.useState(null); + const clickedRef = React.useRef(false); + const unsetClickedRef = React.useRef(null); + + const findActiveIndex = () => { // Don't set the active index based on scroll if a link was just clicked - if (this.clicked) { + if (clickedRef.current) { return; } let active; - - for (let i = this.itemsClient.length - 1; i >= 0; i -= 1) { + for (let i = itemsClientRef.current.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 = itemsClientRef.current[i]; warning(item.node, `Missing node on the item ${JSON.stringify(item, null, 2)}`); @@ -186,10 +180,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, @@ -201,83 +193,113 @@ 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); + } + }; + + // Corresponds to 10 frames at 60 Hz + useThrottledOnScroll(findActiveIndex, itemsServer.length > 0 ? 166 : null); + + React.useEffect(() => { + setRenderer(itemsCollectorRef); + + window.addEventListener('hashchange', handleHashChange); + + return function componentWillUnmount() { + clearTimeout(unsetClickedRef.current); + window.removeEventListener('hashchange', handleHashChange); + }; + }, []); + + React.useEffect(() => { + setItemsServer(getItemsServer(contents, itemsCollectorRef)); + }, [contents]); + + React.useEffect(() => { + itemsClientRef.current = getItemsClient(itemsCollectorRef.current); + findActiveIndex(); + }, [itemsServer]); + + const handleClick = hash => () => { // Used to disable findActiveIndex if the page scrolls due to a click - this.clicked = true; - this.unsetClicked = setTimeout(() => { - this.clicked = false; + clickedRef.current = true; + unsetClickedRef.current = setTimeout(() => { + clickedRef.current = 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 ( - - ); - } + return ( + + ); } 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); diff --git a/docs/translations/translations-de.json b/docs/translations/translations-de.json index b15e4e167f4823..745a7cd5d11f59 100644 --- a/docs/translations/translations-de.json +++ b/docs/translations/translations-de.json @@ -28,6 +28,7 @@ "thanks": "Vielen Dank!", "adTitle": "Diese Werbung soll Open Source unterstützen.", "editPage": "Helfen Sie, diese Seite zu übersetzen", + "tableOfContents": "Inhaltsverzeichnis", "component": "Komponenten-Kit", "mdbProDescr": "Material Dashboard Pro React is a Premium Material-UI Admin.", "mkProDescr": "EIn tolles Material-UI Kit basierend auf Material Design.", diff --git a/docs/translations/translations-es.json b/docs/translations/translations-es.json index c4e513fec08792..f75e73db28421d 100644 --- a/docs/translations/translations-es.json +++ b/docs/translations/translations-es.json @@ -28,6 +28,7 @@ "thanks": "¡Gracias!", "adTitle": "Este anuncio está diseñado para apoyar al Open Source.", "editPage": "Ayuda a traducir esta página", + "tableOfContents": "Tabla de Contenido", "component": "Kit de Componentes", "mdbProDescr": "Material Dashboard Pro React es un tema de administración premium de Material-UI.", "mkProDescr": "Un fantástico Kit con Material-UI basado en Material Design.", diff --git a/docs/translations/translations-fr.json b/docs/translations/translations-fr.json index 7892b767d6a4cc..7211b1a9ec6309 100644 --- a/docs/translations/translations-fr.json +++ b/docs/translations/translations-fr.json @@ -28,6 +28,7 @@ "thanks": "Merci !", "adTitle": "Cette annonce est conçu pour soutenir l'Open Source.", "editPage": "Aidez à traduire cette page", + "tableOfContents": "Matières", "component": "Kit de composants", "mdbProDescr": "Material Dashboard Pro React est un theme Admin Material-UI premium.", "mkProDescr": "Un theme de composants premium Material-UI basé sur Material Design.", diff --git a/docs/translations/translations-ja.json b/docs/translations/translations-ja.json index e372954cc0c596..9cb9c758cb97bd 100644 --- a/docs/translations/translations-ja.json +++ b/docs/translations/translations-ja.json @@ -28,6 +28,7 @@ "thanks": "感謝します!", "adTitle": "この広告はオープンソースを支援するように設計されています。", "editPage": "このページの翻訳を手伝ってください", + "tableOfContents": "目次", "component": "コンポーネントキット", "mdbProDescr": "Material Dashboard Pro React is a Premium Material-UI Admin.", "mkProDescr": "Material Designによる最高にクールなMaterial-UI", diff --git a/docs/translations/translations-pt.json b/docs/translations/translations-pt.json index dc61a5275a1962..ce7793ec0f9ae7 100644 --- a/docs/translations/translations-pt.json +++ b/docs/translations/translations-pt.json @@ -28,6 +28,7 @@ "thanks": "Obrigado!", "adTitle": "Este anúncio é projetado para apoiar o Código Aberto.", "editPage": "Ajude a traduzir esta página", + "tableOfContents": "Índice", "component": "Kit de Componente", "mdbProDescr": "Material Dashboard Pro React is a Premium Material-UI Admin.", "mkProDescr": "Um badass Material-UI Kit baseado em Material Design.", diff --git a/docs/translations/translations-ru.json b/docs/translations/translations-ru.json index 7a8f1ca19d60d4..62f80ed25034d5 100644 --- a/docs/translations/translations-ru.json +++ b/docs/translations/translations-ru.json @@ -28,6 +28,7 @@ "thanks": "Спасибо!", "adTitle": "Эта реклама предназначена для поддержки Open Source.", "editPage": "Помогите перевести эту страницу", + "tableOfContents": "Содержание", "component": "Набор компонентов", "mdbProDescr": "Material Dashboard Pro React is a Premium Material-UI Admin.", "mkProDescr": "Badass Material-UI Kit основывается на Material Design.", diff --git a/docs/translations/translations-zh.json b/docs/translations/translations-zh.json index e8c6bbbe291657..eb310c3ff518c9 100644 --- a/docs/translations/translations-zh.json +++ b/docs/translations/translations-zh.json @@ -28,6 +28,7 @@ "thanks": "感谢!", "adTitle": "该广告旨在支持开源代码。", "editPage": "帮助改进此页面的翻译", + "tableOfContents": "目录", "component": "组件包", "mdbProDescr": "Material Dashboard Pro React 是一个专业版的 Material-UI 管理员主题。", "mkProDescr": "一个根据 Material Design 开发的超棒的 Material-UI 组件。", diff --git a/docs/translations/translations.json b/docs/translations/translations.json index ace4e58207e45b..cdb1ac2995ca37 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -30,6 +30,7 @@ "thanks": "Thank you!", "adTitle": "This ad is designed to support Open Source.", "editPage": "Edit this page", + "tableOfContents": "Contents", "component": "Component Kit", "mdbProDescr": "Material Dashboard Pro React is a premium Material-UI admin theme.", "mkProDescr": "A Badass Material-UI Kit based on Material Design.", diff --git a/packages/material-ui-docs/src/MarkdownElement/MarkdownElement.js b/packages/material-ui-docs/src/MarkdownElement/MarkdownElement.js index 0cee732e16dd92..9077a7806ed954 100644 --- a/packages/material-ui-docs/src/MarkdownElement/MarkdownElement.js +++ b/packages/material-ui-docs/src/MarkdownElement/MarkdownElement.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import marked from 'marked'; import { withStyles } from '@material-ui/core/styles'; +import textToHash from '@material-ui/docs/MarkdownElement/textToHash'; import prism from './prism'; // Monkey patch to preserve non-breaking spaces @@ -18,14 +19,6 @@ marked.Lexer.prototype.lex = function lex(src) { const renderer = new marked.Renderer(); -export function textToHash(text) { - return text - .toLowerCase() - .replace(/=>|<| \/>||<\/code>/g, '') - .replace(/\W+/g, '-') - .replace(/-$/g, ''); -} - renderer.heading = (text, level) => { // Small title. No need for an anchor. // It's reducing the risk of duplicated id and it's fewer elements in the DOM. diff --git a/packages/material-ui-docs/src/MarkdownElement/textToHash.js b/packages/material-ui-docs/src/MarkdownElement/textToHash.js new file mode 100644 index 00000000000000..a2d1ae56debb5e --- /dev/null +++ b/packages/material-ui-docs/src/MarkdownElement/textToHash.js @@ -0,0 +1,11 @@ +export default function textToHash(text) { + return encodeURI( + text + .toLowerCase() + .replace(/=>|<| \/>||<\/code>|'/g, '') + // eslint-disable-next-line no-useless-escape + .replace(/[!@#\$%\^&\*\(\)=_\+\[\]{}`~;:'"\|,\.<>\/\?\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/-$/g, ''), + ); +} diff --git a/packages/material-ui-docs/src/MarkdownElement/textToHash.test.js b/packages/material-ui-docs/src/MarkdownElement/textToHash.test.js new file mode 100644 index 00000000000000..2113ffd108eefe --- /dev/null +++ b/packages/material-ui-docs/src/MarkdownElement/textToHash.test.js @@ -0,0 +1,13 @@ +import { assert } from 'chai'; +import textToHash from './textToHash'; + +describe('textToHash', () => { + it('should hash correctly', () => { + assert.strictEqual( + textToHash('createMuiTheme(options) => theme'), + 'createmuitheme-options-theme', + ); + assert.strictEqual(textToHash('Typography - Font family'), 'typography-font-family'); + assert.strictEqual(textToHash('barre d'application'), 'barre-dapplication'); + }); +});