diff --git a/src/components/Toc/Toc.scss b/src/components/Toc/Toc.scss index 782a4f86..d541c517 100644 --- a/src/components/Toc/Toc.scss +++ b/src/components/Toc/Toc.scss @@ -97,47 +97,14 @@ $leftOffset: 57px; cursor: pointer; user-select: none; - &-link { - display: block; - text-decoration: none; - } - - &-text { - position: relative; - padding: 7px 12px 7px 20px; - word-break: break-word; - - color: var(--yc-color-text-primary); - - &::before { - content: ''; - position: absolute; - top: 0; - right: 0; - // hack: to be shure that it will always start from the left of the TOC - left: -100vw; - height: 100%; - } - - &:hover { - border-radius: 3px; - background: var(--yc-color-base-simple-hover); - } - } - - &-icon { - position: absolute; - left: 0; - } - &_main > *:first-child { color: var(--yc-color-text-primary); } - &_active { - border-radius: 3px; - background: var(--yc-color-base-selection); - } + //&_active { + // border-radius: 3px; + // background: var(--yc-color-base-selection); + //} &:not(&_opened) > #{$class}__list { display: none; diff --git a/src/components/Toc/Toc.tsx b/src/components/Toc/Toc.tsx index 8fdaaa0d..7a021026 100644 --- a/src/components/Toc/Toc.tsx +++ b/src/components/Toc/Toc.tsx @@ -1,12 +1,14 @@ import React from 'react'; import block from 'bem-cn-lite'; +import {parse} from 'url'; +import {omit} from 'lodash'; import {ControlSizes, Lang, Router, TocData, TocItem} from '../../models'; -import {ToggleArrow} from '../ToggleArrow'; +import {TocItem as Item} from '../TocItem'; import {HTML} from '../HTML'; import {Controls} from '../Controls'; -import {isActiveItem, isExternalHref} from '../../utils'; +import {isActiveItem, normalizeHash, normalizePath} from '../../utils'; import './Toc.scss'; import {PopperPosition} from '../../hooks'; @@ -14,6 +16,10 @@ import {PopperPosition} from '../../hooks'; const b = block('dc-toc'); const HEADER_DEFAULT_HEIGHT = 0; +function zip(array: string[], fill: T): Record { + return array.reduce((acc, item) => Object.assign(acc, {[item]: fill}), {}); +} + export interface TocProps extends TocData { router: Router; headerHeight?: number; @@ -25,50 +31,106 @@ export interface TocProps extends TocData { pdfLink?: string; } -interface FlatTocItem { - name: string; - href?: string; - parents: string[]; - opened?: boolean; -} - interface TocState { - flatToc: Record; - filteredItemIds: string[]; - filterName: string; + activeId: string | null | undefined; + fixedById: Record; contentScrolled: boolean; - activeId?: string | null; +} + +function linkTocItems( + parent: TocItem | null, + items: TocItem[], + itemById: Map, + parentById: Map, + itemIdByUrl: Map, + singlePage?: boolean, +) { + items.forEach((item) => { + itemById.set(item.id, item); + + if (item.href) { + const {pathname, hash} = parse(item.href); + const url = singlePage ? normalizeHash(hash) : normalizePath(pathname); + + itemIdByUrl.set(url as string, item.id); + } + + if (parent) { + parentById.set(item.id, parent); + } + + if (item.items) { + linkTocItems(item, item.items, itemById, parentById, itemIdByUrl, singlePage); + } + }); +} + +function getChildIds(item: TocItem): string[] { + return (item.items || ([] as TocItem[])).reduce((acc, child) => { + return acc.concat([child.id], getChildIds(child)); + }, [] as string[]); +} + +function getParentIds(item: TocItem, parents: Map) { + const result = []; + + let parent = parents.get(item.id); + while (parent) { + result.push(parent.id); + parent = parents.get(parent.id); + } + + return result; } class Toc extends React.Component { contentRef = React.createRef(); rootRef = React.createRef(); + activeRef = React.createRef(); containerEl: HTMLElement | null = null; footerEl: HTMLElement | null = null; - state: TocState = { - flatToc: {}, - filteredItemIds: [], - filterName: '', - contentScrolled: false, - activeId: null, - }; + private itemById: Map = new Map(); + + private parentById: Map = new Map(); + + private itemIdByUrl: Map = new Map(); + + constructor(props: TocProps) { + super(props); + + linkTocItems( + null, + props.items, + this.itemById, + this.parentById, + this.itemIdByUrl, + props.singlePage, + ); + + this.state = this.computeState({ + fixedById: {}, + activeId: null, + contentScrolled: false, + }); + } componentDidMount() { this.containerEl = document.querySelector('.Layout__content'); this.footerEl = document.querySelector('.Footer'); this.setTocHeight(); - this.setState(this.getState(this.props, this.state), () => this.scrollToActiveItem()); window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize); if (this.contentRef && this.contentRef.current) { this.contentRef.current.addEventListener('scroll', this.handleContentScroll); } + + this.scrollToActiveItem(); } - componentDidUpdate(prevProps: TocProps) { + componentDidUpdate(prevProps: TocProps, prevState: TocState) { const {router, singlePage} = this.props; if ( @@ -77,7 +139,9 @@ class Toc extends React.Component { prevProps.singlePage !== singlePage ) { this.setTocHeight(); - this.setState(this.getState(this.props, this.state), () => this.scrollToActiveItem()); + this.setState(this.computeState(prevState)); + } else if (prevState.activeId !== this.state.activeId) { + this.scrollToActiveItem(); } } @@ -91,14 +155,7 @@ class Toc extends React.Component { render() { const {items, hideTocHeader} = this.props; - const {filterName, filteredItemIds} = this.state; - let content; - - if (filterName.length !== 0 && filteredItemIds.length === 0) { - content = this.renderEmpty(''); - } else { - content = items ? this.renderList(items) : this.renderEmpty(''); - } + const content = items ? this.renderList(items) : this.renderEmpty(''); return (
@@ -111,118 +168,76 @@ class Toc extends React.Component { ); } - private renderList(items: TocItem[], isMain = true) { + computeState(prevState: TocState) { + const {singlePage, router} = this.props; + const {pathname, hash} = router; + + const activeUrl = singlePage ? normalizeHash(hash) : normalizePath(pathname); + const activeId = activeUrl && this.itemIdByUrl.get(activeUrl as string); + const activeItem = activeId && (this.itemById.get(activeId) as TocItem); + + let fixedById = prevState.fixedById; + + if (activeItem && prevState.activeId && activeId !== prevState.activeId) { + const expandedIds = [activeId].concat(getParentIds(activeItem, this.parentById)); + const dropClosedSign = expandedIds.filter((id) => prevState.fixedById[id] === 'closed'); + + if (dropClosedSign.length) { + fixedById = omit(fixedById, dropClosedSign); + } + } + + return {...prevState, activeId, fixedById}; + } + + private renderList = (items: TocItem[]) => { + const {openItem, closeItem} = this; const {singlePage} = this.props; - const {flatToc, filteredItemIds, filterName, activeId} = this.state; + const {activeId, fixedById} = this.state; + + const activeItem = activeId && this.itemById.get(activeId); + const activeScope: Record = activeItem + ? zip([activeId].concat(getParentIds(activeItem, this.parentById)), true) + : {}; - /* eslint-disable complexity */ return (
    - {items.map(({id, name, href, items: subItems}, index) => { - const opened = flatToc[id] ? flatToc[id].opened : false; - let isOpenFilteredItem = false; - let active = false; - let visibleChildren = Boolean(subItems); - let icon = null; - let text; - - if (filteredItemIds.length > 0) { - filteredItemIds.forEach((itemId) => { - if (flatToc[itemId].parents.includes(id)) { - isOpenFilteredItem = true; - } - }); - } - - if (subItems && subItems.length > 0) { - icon = ( - - ); - } - - if (filteredItemIds.includes(id)) { - const firstEntry = name.toLowerCase().indexOf(filterName.toLowerCase()); - isOpenFilteredItem = true; - - text = ( - - {name.substring(0, firstEntry)} - - {name.substring(firstEntry, firstEntry + filterName.length)} - - {name.substring(firstEntry + filterName.length)} - - ); - } else { - text = {name}; - } - - let content = ( -
    0 - ? this.handleItemClick.bind(this, id) - : undefined - } - > - {icon} - {text} -
    - ); - - if (filterName.length > 0 && !isOpenFilteredItem) { - return null; - } - - if (href) { - const isExternal = isExternalHref(href); - const linkAttributes = { - href, - target: isExternal ? '_blank' : '_self', - rel: isExternal ? 'noopener noreferrer' : undefined, - }; - - content = ( - - {content} - - ); - - active = id === activeId; - - if (singlePage && !activeId && index === 0 && isMain) { - active = true; - } - } - - if (subItems && (active || opened)) { - visibleChildren = true; - } + {items.map((item, index) => { + const main = !this.parentById.get(item.id); + const active = + (singlePage && !activeId && index === 0 && main) || item.id === activeId; + const opened = fixedById[item.id] === 'opened'; + const closed = fixedById[item.id] === 'closed'; + const expandable = Boolean(item.items && item.items.length > 0); + const expanded = + expandable && !closed && (item.expanded || activeScope[item.id] || opened); + + const ref = active ? {ref: this.activeRef} : {}; return (
  • - {content} - {subItems && visibleChildren && this.renderList(subItems, false)} + + {expanded && this.renderList(item.items as TocItem[])}
  • ); })}
); - /* eslint-enable complexity */ - } + }; private renderEmpty(text: string) { return
{text}
; @@ -283,49 +298,6 @@ class Toc extends React.Component { ); } - private getState(props: TocProps, state: TocState) { - const {singlePage} = this.props; - const flatToc: Record = {}; - let activeId; - - function processItems(items: TocItem[], parentId?: string) { - items.forEach(({id, href, name, items: subItems, expanded}) => { - flatToc[id] = state.flatToc[id] - ? {...state.flatToc[id]} - : {name, href, parents: []}; - - if (parentId) { - flatToc[id].parents = [parentId, ...flatToc[parentId].parents]; - } - - if (href && isActiveItem(props.router, href, singlePage)) { - activeId = id; - } - - if (subItems) { - if (typeof flatToc[id].opened === 'undefined') { - const isFirstLevel = flatToc[id].parents.length === 0; - - flatToc[id].opened = - isFirstLevel && typeof expanded !== 'undefined' ? expanded : false; - } - - processItems(subItems, id); - } - }); - } - - processItems(props.items); - - if (activeId) { - flatToc[activeId].parents.forEach((id) => { - flatToc[id].opened = true; - }); - } - - return {flatToc, activeId}; - } - private setTocHeight() { const {headerHeight = HEADER_DEFAULT_HEIGHT} = this.props; const containerHeight = this.containerEl?.offsetHeight ?? 0; @@ -347,36 +319,6 @@ class Toc extends React.Component { } } - private scrollToActiveItem() { - const {activeId} = this.state; - const activeElement = activeId && document.getElementById(activeId); - - if (!activeElement) { - return; - } - - const itemElement = activeElement.querySelector(`.${b('list-item-text')}`); - - const itemHeight = itemElement?.offsetHeight ?? 0; - const itemOffset = activeElement.offsetTop; - const scrollableParent = activeElement.offsetParent as HTMLDivElement | null; - - if (!scrollableParent) { - return; - } - - const scrollableHeight = scrollableParent.offsetHeight; - const scrollableOffset = scrollableParent.scrollTop; - - const itemVisible = - itemOffset >= scrollableOffset && - itemOffset <= scrollableOffset + scrollableHeight - itemHeight; - - if (!itemVisible) { - scrollableParent.scrollTop = itemOffset - Math.floor(scrollableHeight / 2) + itemHeight; - } - } - private handleScroll = () => { this.setTocHeight(); }; @@ -385,16 +327,12 @@ class Toc extends React.Component { this.setTocHeight(); }; - private handleItemClick = (id: string) => { - this.setState((prevState) => ({ - flatToc: { - ...prevState.flatToc, - [id]: { - ...prevState.flatToc[id], - opened: !prevState.flatToc[id].opened, - }, - }, - })); + private scrollToActiveItem = () => { + if (!this.activeRef.current) { + return; + } + + this.activeRef.current.scrollToItem(); }; private handleContentScroll = () => { @@ -404,6 +342,29 @@ class Toc extends React.Component { this.setState({contentScrolled}); } }; + + private openItem = (id: string) => { + this.setState((prevState) => ({ + ...prevState, + fixedById: { + ...prevState.fixedById, + [id]: 'opened', + }, + })); + }; + + private closeItem = (id: string) => { + const item = this.itemById.get(id) as TocItem; + const ids = getChildIds(item); + + this.setState((prevState) => ({ + ...prevState, + fixedById: { + ...omit(prevState.fixedById, ids), + [id]: 'closed', + }, + })); + }; } export default Toc; diff --git a/src/components/TocItem/TocItem.scss b/src/components/TocItem/TocItem.scss new file mode 100644 index 00000000..2698ac12 --- /dev/null +++ b/src/components/TocItem/TocItem.scss @@ -0,0 +1,45 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +.dc-toc-item { + cursor: pointer; + user-select: none; + + &__link { + display: block; + text-decoration: none; + } + + &__text { + position: relative; + padding: 7px 12px 7px 20px; + word-break: break-word; + + color: var(--yc-color-text-primary); + + &_active { + border-radius: 3px; + background: var(--yc-color-base-selection); + } + + &::before { + content: ''; + position: absolute; + top: 0; + right: 0; + // hack: to be shure that it will always start from the left of the TOC + left: -100vw; + height: 100%; + } + + &:hover { + border-radius: 3px; + background: var(--yc-color-base-simple-hover); + } + } + + &__icon { + position: absolute; + left: 0; + } +} diff --git a/src/components/TocItem/TocItem.tsx b/src/components/TocItem/TocItem.tsx new file mode 100644 index 00000000..f85eaf69 --- /dev/null +++ b/src/components/TocItem/TocItem.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import block from 'bem-cn-lite'; + +import {TocItem as ITocItem} from '../../models'; +import {ToggleArrow} from '../ToggleArrow'; + +import {isExternalHref} from '../../utils'; + +import './TocItem.scss'; + +const b = block('dc-toc-item'); + +export interface TocItemProps extends ITocItem { + id: string; + name: string; + href?: string; + items?: ITocItem[]; + active: boolean; + expandable: boolean; + expanded: boolean; + openItem: (id: string) => void; + closeItem: (id: string) => void; +} + +class TocItem extends React.Component { + contentRef = React.createRef(); + + render() { + const {name, href, active, expandable, expanded} = this.props; + const text = {name}; + const icon = expandable ? ( + + ) : null; + const content = ( +
+ {icon} + {text} +
+ ); + + if (!href) { + return content; + } + + const isExternal = isExternalHref(href); + const linkAttributes = { + href, + target: isExternal ? '_blank' : '_self', + rel: isExternal ? 'noopener noreferrer' : undefined, + }; + + return ( + + {content} + + ); + } + + scrollToItem = () => { + if (!this.contentRef.current) { + return; + } + + const itemElement = this.contentRef.current; + const itemHeight = itemElement.offsetHeight ?? 0; + const itemOffset = itemElement.offsetTop; + const scrollableParent = itemElement.offsetParent as HTMLDivElement | null; + + if (!scrollableParent) { + return; + } + + const scrollableHeight = scrollableParent.offsetHeight; + const scrollableOffset = scrollableParent.scrollTop; + + const itemVisible = + itemOffset >= scrollableOffset && + itemOffset <= scrollableOffset + scrollableHeight - itemHeight; + + if (!itemVisible) { + scrollableParent.scrollTop = itemOffset - Math.floor(scrollableHeight / 2) + itemHeight; + } + }; + + private handleClick = () => { + const {id, href, active, expanded, openItem, closeItem} = this.props; + + if (!active && href) { + return; + } + + if (expanded) { + closeItem(id); + return; + } + + if (!expanded) { + openItem(id); + return; + } + }; +} + +export default TocItem; diff --git a/src/components/TocItem/index.ts b/src/components/TocItem/index.ts new file mode 100644 index 00000000..ec20d8c1 --- /dev/null +++ b/src/components/TocItem/index.ts @@ -0,0 +1,2 @@ +export {default as TocItem} from './TocItem'; +export * from './TocItem';