diff --git a/assets/icons/search-bar-arrow.svg b/assets/icons/search-bar-arrow.svg new file mode 100644 index 00000000..a26e8eb0 --- /dev/null +++ b/assets/icons/search-bar-arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e6e99cab..2e503156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10379,6 +10379,11 @@ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, + "hotkeys-js": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.8.3.tgz", + "integrity": "sha512-rUmoryG4lEAtkjF5tcYaihrVoE86Fdw1BLqO/UiBWOOF56h32a6ax8oV4urBlinVtNNtArLlBq8igGfZf2tQnw==" + }, "hsl-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", @@ -13924,6 +13929,11 @@ "object-visit": "^1.0.0" } }, + "mark.ts": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mark.ts/-/mark.ts-1.0.5.tgz", + "integrity": "sha1-PPvbWJLwCU+VP6O2fd0OrYAiySI=" + }, "markdown-it": { "version": "12.3.2", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", @@ -18385,6 +18395,14 @@ "prop-types": "^15.6.1" } }, + "react-hotkeys-hook": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-3.3.1.tgz", + "integrity": "sha512-y6eCMEysh09lIgRQvIC76L7ehqUWf/7h8VlqT6w7RRnim5CX5CXldo64+CjWanIcgr++q5yTNwr7A/8DXA1u9A==", + "requires": { + "hotkeys-js": "3.8.3" + } + }, "react-i18next": { "version": "11.15.6", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.15.6.tgz", diff --git a/package.json b/package.json index cbd066af..d67de258 100644 --- a/package.json +++ b/package.json @@ -50,12 +50,14 @@ "pre-commit": "lint-staged" }, "dependencies": { - "@popperjs/core": "2.11.2", - "bem-cn-lite": "4.1.0", - "i18next": "19.9.2", - "lodash": "4.17.21", + "@popperjs/core": "^2.11.2", + "i18next": "^19.9.2", + "lodash": "^4.17.21", + "mark.ts": "^1.0.5", + "react-hotkeys-hook": "^3.3.1", + "react-popper": "^2.2.5", + "bem-cn-lite": "4.1.0", , "react-i18next": "11.15.6", - "react-popper": "2.2.5", "scroll-into-view-if-needed": "2.2.29" }, "peerDependencies": { diff --git a/src/components/DocPage/DocPage.scss b/src/components/DocPage/DocPage.scss index 54e465e3..ced6a172 100644 --- a/src/components/DocPage/DocPage.scss +++ b/src/components/DocPage/DocPage.scss @@ -134,6 +134,24 @@ margin-bottom: 20px; } + &__search-bar { + background: var(--yc-color-base); + width: 100%; + height: 40px; + position: sticky; + top: calc(var(--dc-header-height, #{$headerHeight}) + 6px); + z-index: 101; + padding: 0 36px; + + @media (max-width: map-get($screenBreakpoints, 'md') - 1) { + padding: 0 10px; + } + } + + &__search-bar + &__breadcrumbs { + margin-top: 12px; + } + &_full-screen { --dc-header-height: 0px; diff --git a/src/components/DocPage/DocPage.tsx b/src/components/DocPage/DocPage.tsx index 9d7ed634..a5d3a6f8 100644 --- a/src/components/DocPage/DocPage.tsx +++ b/src/components/DocPage/DocPage.tsx @@ -21,10 +21,18 @@ import {Breadcrumbs} from '../Breadcrumbs'; import {TocNavPanel} from '../TocNavPanel'; import {Controls} from '../Controls'; import {EditButton} from '../EditButton'; +import {SearchBar} from '../SearchBar'; import {Feedback, FeedbackView} from '../Feedback'; import Contributors from '../Contributors/Contributors'; -import {callSafe, createElementFromHTML, getHeaderTag, getStateKey, InnerProps} from '../../utils'; +import { + callSafe, + createElementFromHTML, + getHeaderTag, + getStateKey, + InnerProps, + getRandomKey, +} from '../../utils'; import {DEFAULT_SETTINGS} from '../../constants'; import LinkIcon from '../../../assets/icons/link.svg'; @@ -34,13 +42,22 @@ import './DocPage.scss'; const b = block('dc-doc-page'); -export interface DocPageProps extends DocPageData, Partial { +export interface DocPageProps extends DocPageData, DocSettings { lang: Lang; router: Router; headerHeight?: number; tocTitleIcon?: React.ReactNode; hideTocHeader?: boolean; hideToc?: boolean; + + showSearchBar?: boolean; + searchQuery?: string; + onClickPrevSearch?: () => void; + onClickNextSearch?: () => void; + onCloseSearchBar?: () => void; + searchCurrentIndex?: number; + searchCountResults?: number; + hideControls?: boolean; hideEditControl?: boolean; hideFeedbackControls?: boolean; @@ -57,28 +74,36 @@ export interface DocPageProps extends DocPageData, Partial { onChangeTheme?: (theme: Theme) => void; onChangeTextSize?: (textSize: TextSizes) => void; onSendFeedback?: (data: FeedbackSendData) => void; + onContentMutation?: () => void; + onContentLoaded?: () => void; } type DocPageInnerProps = InnerProps; type DocPageState = { loading: boolean; + keyDOM: number; }; class DocPage extends React.Component { static defaultProps: DocSettings = DEFAULT_SETTINGS; state: DocPageState = { - loading: false, + loading: true, + keyDOM: getRandomKey(), }; bodyRef: HTMLDivElement | null = null; bodyObserver: MutationObserver | null = null; componentDidMount(): void { - if (this.props.singlePage) { + const {singlePage} = this.props; + + if (singlePage) { this.addLinksToOriginalArticle(); } + this.setState({loading: false}); + this.addBodyObserver(); } @@ -86,6 +111,10 @@ class DocPage extends React.Component { if (prevProps.singlePage !== this.props.singlePage) { this.setState({loading: true}); } + + if (prevProps.html !== this.props.html) { + this.setState({keyDOM: getRandomKey()}); + } } componentWillUnmount(): void { @@ -135,6 +164,7 @@ class DocPage extends React.Component { footer={footer} > + {this.renderSearchBar()} {this.renderBreadcrumbs()} {this.renderControls()}
@@ -162,17 +192,40 @@ class DocPage extends React.Component { ); } - private handleBodyMutation = () => { - this.setState({loading: false}); + private handleBodyMutation = (mutationsList: MutationRecord[]) => { + const {onContentMutation, onContentLoaded} = this.props; - if (this.props.singlePage) { - this.bodyObserver!.disconnect(); + if (this.props.singlePage && this.bodyObserver && this.bodyRef) { + this.bodyObserver.disconnect(); this.addLinksToOriginalArticle(); - this.bodyObserver!.observe(this.bodyRef!, { + this.bodyObserver.observe(this.bodyRef, { childList: true, subtree: true, }); } + + if (onContentMutation) { + setTimeout(onContentMutation, 0); + } + + this.setState({loading: false}); + + if (!onContentLoaded) { + return; + } + + setTimeout(() => { + mutationsList + .filter(({type}) => type === 'childList') + .forEach((mutation) => { + const yfmRoot = mutation.target as HTMLElement; + const yfmImages = [...yfmRoot.querySelectorAll('img')]; + + yfmImages.forEach((img) => { + img.addEventListener('load', onContentLoaded, false); + }); + }); + }, 0); }; private addBodyObserver() { @@ -396,6 +449,7 @@ class DocPage extends React.Component { private renderAsideMiniToc() { const {headings, router, headerHeight, lang, toc} = this.props; + const {keyDOM} = this.state; const emptyHeaderOrSinglePage = headings.length === 0 || toc.singlePage; const soloHeaderWithChildren = @@ -412,6 +466,7 @@ class DocPage extends React.Component { router={router} headerHeight={headerHeight} lang={lang} + key={keyDOM} />
); @@ -485,6 +540,38 @@ class DocPage extends React.Component { return !this.showMiniToc || fullScreen; }; + private renderSearchBar = () => { + const { + showSearchBar, + searchQuery, + searchCurrentIndex, + searchCountResults, + onClickPrevSearch, + onClickNextSearch, + onCloseSearchBar, + lang, + singlePage, + } = this.props; + + if (!showSearchBar || singlePage) { + return null; + } + + return ( +
+ +
+ ); + }; + private renderControls() { const { lang, diff --git a/src/components/SearchBar/SearchBar.scss b/src/components/SearchBar/SearchBar.scss new file mode 100644 index 00000000..be4a6f01 --- /dev/null +++ b/src/components/SearchBar/SearchBar.scss @@ -0,0 +1,75 @@ +@import '../../../styles/variables'; +@import '../../../styles/mixins'; + +.dc-search-bar { + width: 100%; + height: 100%; + border-radius: 5px; + box-shadow: 0px 3px 10px var(--yc-color-sfx-shadow); + padding: 11px; + display: flex; + align-items: center; + justify-content: space-between; + + font-size: var(--yc-text-body-1-short-font-size); + line-height: var(--yc-text-body-1-short-line-height); + + &__search-query-label { + color: var(--yc-color-text-secondary); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + @media (max-width: map-get($screenBreakpoints, 'md') + 1) { + display: none; + } + } + + &__search-query { + max-width: 400px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + @media (max-width: map-get($screenBreakpoints, 'md') + 1) { + display: none; + } + } + + &__left { + width: 90%; + display: flex; + align-items: center; + } + + &__navigation { + display: flex; + align-items: center; + margin-right: 11px; + } + + &__next-arrow { + transform: rotate(-180deg); + } + + &__counter { + margin: 0 4px; + } +} + +$hl-class: '.dc-search-highlighted'; + +#{$hl-class} { + background: var(--dc-text-highlight); + + &_selected { + background: var(--dc-text-highlight-selected); + } +} + +.yc-root_theme_dark { + & #{$hl-class}, + & #{$hl-class}_selected { + color: var(--yc-color-text-inverted-primary); + } +} diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx new file mode 100644 index 00000000..df5a88b4 --- /dev/null +++ b/src/components/SearchBar/SearchBar.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import block from 'bem-cn-lite'; +import {withTranslation, WithTranslation, WithTranslationProps} from 'react-i18next'; +import {useHotkeys} from 'react-hotkeys-hook'; + +import {Control} from '../Control'; + +import {Lang} from '../../models'; +import CloseIcon from '../../../assets/icons/close.svg'; +import ArrowIcon from '../../../assets/icons/search-bar-arrow.svg'; + +import './SearchBar.scss'; + +const b = block('dc-search-bar'); + +export interface SearchBarProps { + lang: Lang; + searchQuery?: string; + onClickPrevSearch?: () => void; + onClickNextSearch?: () => void; + onCloseSearchBar?: () => void; + searchCurrentIndex?: number; + searchCountResults?: number; +} + +type SearchBarInnerProps = SearchBarProps & WithTranslation & WithTranslationProps; + +const noop = () => {}; + +const SearchBar: React.FC = (props) => { + const { + t, + i18n, + lang, + searchQuery, + searchCurrentIndex, + searchCountResults, + onClickPrevSearch = noop, + onClickNextSearch = noop, + onCloseSearchBar = noop, + } = props; + + const hotkeysOptions = {filterPreventDefault: true}; + const hotkeysPrev = '⇧+enter'; + const hotkeysNext = 'enter'; + useHotkeys(hotkeysPrev, onClickPrevSearch, hotkeysOptions, [onClickPrevSearch]); + useHotkeys(hotkeysNext, onClickNextSearch, hotkeysOptions, [onClickNextSearch]); + + if (i18n.language !== lang) { + i18n.changeLanguage(lang); + } + + return ( +
+
+
+ } + /> + + {searchCurrentIndex}/{searchCountResults} + + } + /> +
+ {t('search-query-label')}:  + {searchQuery} +
+
+ } + /> +
+
+ ); +}; + +SearchBar.displayName = 'DCSearchBar'; + +export default withTranslation('search-bar')(SearchBar); diff --git a/src/components/SearchBar/constants.ts b/src/components/SearchBar/constants.ts new file mode 100644 index 00000000..a729bf59 --- /dev/null +++ b/src/components/SearchBar/constants.ts @@ -0,0 +1,9 @@ +export const CLASSNAME = 'dc-search-highlighted'; + +export const CLASSNAME_SELECTED = 'dc-search-highlighted_selected'; + +export const HIGHLIGHT_OPTIONS = { + element: 'span', + className: CLASSNAME, + exclude: [`.${CLASSNAME}`], // Exclude the elements to highlight to avoid duplicating the highlight +}; diff --git a/src/components/SearchBar/hooks.ts b/src/components/SearchBar/hooks.ts new file mode 100644 index 00000000..508ee7e5 --- /dev/null +++ b/src/components/SearchBar/hooks.ts @@ -0,0 +1,379 @@ +import {useEffect, useRef, useState, useCallback} from 'react'; +import _ from 'lodash'; + +import {getHighlightedItemIndexInView, highlight, scrollToItem} from './utils'; +import {CLASSNAME, CLASSNAME_SELECTED, HIGHLIGHT_OPTIONS} from './constants'; + +type UseHighlightedSearchWords = { + html: string; + searchWords: string[]; + showSearchBar: boolean; + onNotFoundWords?: () => void; + onContentMutation?: () => void; + onContentLoaded?: () => void; +}; + +export function useHighlightedSearchWords({ + html, + searchWords, + showSearchBar, + onContentMutation, + onContentLoaded, + onNotFoundWords, +}: UseHighlightedSearchWords) { + const highlightedHtml = useHighlightedHTMLString(html, searchWords, showSearchBar); + + const {wasChangedDOM, _onContentMutation, _onContentLoaded} = useCallbackDOMChange({ + onContentMutation, + onContentLoaded, + }); + + const highlightedDOMElements = useHighlightedDOMElements( + highlightedHtml, + wasChangedDOM, + showSearchBar, + ); + + const searchBarIsVisible = useSearchBarIsVisible({ + showSearchBar, + searchWords, + highlightedHtml, + html, + }); + + useNoSearchWordsFoundEffect({highlightedDOMElements, showSearchBar, onNotFoundWords}); + + return { + highlightedHtml, + highlightedDOMElements, + searchBarIsVisible, + wasChangedDOM, + _onContentMutation, + _onContentLoaded, + }; +} + +function useHighlightedDOMElements( + highlightedHtml: string, + wasChangedDOM: boolean, + showSearchBar: boolean, +) { + const cachedHighlightedDOMElements = useRef([] as Element[]); + + useEffect(() => { + cachedHighlightedDOMElements.current = []; + }, [highlightedHtml]); + + useEffect(() => { + if (wasChangedDOM) { + cachedHighlightedDOMElements.current = []; + } + }, [wasChangedDOM]); + + if (cachedHighlightedDOMElements.current.length) { + return cachedHighlightedDOMElements.current; + } + + if (!showSearchBar || typeof document === 'undefined') { + return []; + } + + const elements = document.querySelectorAll(`.${CLASSNAME}`); + cachedHighlightedDOMElements.current = [...elements]; + + return cachedHighlightedDOMElements.current; +} + +/* Try wrapping the search words in an html string by span elements with a highlight class */ +function useHighlightedHTMLString(html: string, searchWords: string[], showSearchBar: boolean) { + const [highlightedHtml, setHighlightedHtml] = useState(html); + + useEffect(() => { + if (!searchWords.length || !showSearchBar) { + setHighlightedHtml(html); + return; + } + + const highlightedResult = highlight({ + html, + keywords: searchWords, + options: HIGHLIGHT_OPTIONS, + }); + + if (highlightedResult.includes(CLASSNAME)) { + setHighlightedHtml(highlightedResult); + } else { + setHighlightedHtml(html); + } + }, [html, searchWords, showSearchBar]); + + return highlightedHtml; +} + +type UseSearchBarIsVisible = { + showSearchBar: boolean; + searchWords: string[]; + html: string; + highlightedHtml: string; +}; + +function useSearchBarIsVisible({ + showSearchBar, + searchWords, + highlightedHtml, + html, +}: UseSearchBarIsVisible) { + const [searchBarIsVisible, setSearchBarIsVisible] = useState(showSearchBar); + + useEffect(() => { + setSearchBarIsVisible( + showSearchBar && Boolean(searchWords && searchWords.length) && highlightedHtml !== html, + ); + }, [showSearchBar, searchWords, highlightedHtml, html]); + + return searchBarIsVisible; +} + +type UseCallbackDOMChange = { + onContentMutation?: () => void; + onContentLoaded?: () => void; +}; + +function useCallbackDOMChange({onContentMutation, onContentLoaded}: UseCallbackDOMChange) { + const [wasChangedDOM, setWasChangedDOM] = useState(false); + + useEffect(() => { + if (wasChangedDOM) { + setWasChangedDOM(false); + } + }, [wasChangedDOM]); + + /* Callback for dangerouslySetInnerHTML when inserting content */ + const _onContentMutation = useCallback(() => { + if (onContentMutation) { + onContentMutation(); + } + + setWasChangedDOM(true); + }, [onContentMutation]); + + /* Callback for loading resources after inserting content */ + const _onContentLoaded = useCallback(() => { + if (onContentLoaded) { + onContentLoaded(); + } + + setWasChangedDOM(true); + }, [onContentLoaded]); + + return { + wasChangedDOM, + _onContentMutation, + _onContentLoaded, + }; +} + +type UseNoSearchWordsFoundEffect = { + showSearchBar: boolean; + highlightedDOMElements: Element[]; + onNotFoundWords?: () => void; +}; + +export function useNoSearchWordsFoundEffect({ + showSearchBar, + highlightedDOMElements, + onNotFoundWords, +}: UseNoSearchWordsFoundEffect) { + useEffect(() => { + if (!onNotFoundWords) { + return; + } + + if (showSearchBar && !highlightedDOMElements.length) { + onNotFoundWords(); + } + }, [highlightedDOMElements, showSearchBar, onNotFoundWords]); +} + +type UseSearchBarNavigation = { + highlightedDOMElements: Element[]; + stopSyncOnScroll: () => void; + headerHeight: number; + hash?: string; +}; + +export function useSearchBarNavigation({ + highlightedDOMElements, + stopSyncOnScroll, + headerHeight, + hash, +}: UseSearchBarNavigation) { + const [searchCurrentIndex, setSearchCurrentIndex] = useState(1); + const [searchCountResults, setSearchCountResults] = useState(1); + + useEffect(() => { + const startIndex = + getHighlightedItemIndexInView({highlightedDOMElements, headerHeight, hash}) || 1; + + setSearchCurrentIndex(startIndex); + setSearchCountResults(highlightedDOMElements.length || 1); + }, [highlightedDOMElements, headerHeight, hash]); + + const onClickNextSearch = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + + stopSyncOnScroll(); + + if (!highlightedDOMElements.length) { + return; + } + + let newIndex = searchCurrentIndex + 1; + if (newIndex > highlightedDOMElements.length) { + newIndex = 1; + } + + setSearchCurrentIndex(newIndex); + }, + [highlightedDOMElements, searchCurrentIndex, setSearchCurrentIndex, stopSyncOnScroll], + ); + + const onClickPrevSearch = useCallback( + (e) => { + e.stopPropagation(); + e.preventDefault(); + + stopSyncOnScroll(); + + if (!highlightedDOMElements.length) { + return; + } + + let newIndex = searchCurrentIndex - 1; + if (newIndex < 1) { + newIndex = highlightedDOMElements.length; + } + + setSearchCurrentIndex(newIndex); + }, + [highlightedDOMElements, searchCurrentIndex, setSearchCurrentIndex, stopSyncOnScroll], + ); + + return { + searchCurrentIndex, + setSearchCurrentIndex, + searchCountResults, + onClickPrevSearch, + onClickNextSearch, + }; +} + +type UseHighlightCurrentWordEffect = { + highlightedDOMElements: Element[]; + searchCurrentIndex: number; + wasChangedDOM: boolean; + syncOnScroll: boolean; + hash?: string; +}; + +export function useCurrentWordSelectionEffect({ + searchCurrentIndex, + highlightedDOMElements, + wasChangedDOM, + syncOnScroll, + hash, +}: UseHighlightCurrentWordEffect) { + useEffect(() => { + try { + if (!highlightedDOMElements || !highlightedDOMElements.length) { + return; + } + + for (let index = 0; index < highlightedDOMElements.length; index++) { + const item = highlightedDOMElements[index]; + + item.classList.remove(CLASSNAME_SELECTED); + } + + const item = highlightedDOMElements[searchCurrentIndex - 1]; + item.classList.add(CLASSNAME_SELECTED); + } catch (e) { + console.error(e); + } + }, [highlightedDOMElements, searchCurrentIndex, wasChangedDOM]); + + useEffect(() => { + if (syncOnScroll) { + return; + } + + scrollToItem(highlightedDOMElements[searchCurrentIndex - 1]); + }, [wasChangedDOM, searchCurrentIndex, highlightedDOMElements, syncOnScroll, hash]); +} + +type UseCurrentWordSelectionSyncScrollEffect = { + highlightedDOMElements: Element[]; + searchWords: string[]; + syncOnScroll: boolean; + searchBarIsVisible: boolean; + setSearchCurrentIndex: (index: number) => void; + headerHeight: number; + setSyncOnScroll: (flag: boolean) => void; +}; + +export function useCurrentWordSelectionSyncScrollEffect({ + highlightedDOMElements, + searchWords, + syncOnScroll, + searchBarIsVisible, + setSearchCurrentIndex, + headerHeight, + setSyncOnScroll, +}: UseCurrentWordSelectionSyncScrollEffect) { + const scrollEndTimer = useRef>(); + + const handleScroll = useCallback(() => { + if (scrollEndTimer.current) { + clearTimeout(scrollEndTimer.current); + } + + scrollEndTimer.current = setTimeout(() => { + setSyncOnScroll(true); + }, 50); + + if (!syncOnScroll || !searchBarIsVisible) { + return; + } + + const highlightedItemIndexInView = getHighlightedItemIndexInView({ + highlightedDOMElements, + headerHeight, + }); + + if (isNaN(highlightedItemIndexInView as number)) { + return; + } + + setSearchCurrentIndex(highlightedItemIndexInView as number); + }, [ + setSyncOnScroll, + searchBarIsVisible, + syncOnScroll, + setSearchCurrentIndex, + highlightedDOMElements, + headerHeight, + ]); + + const handleScrollThrottled = _.throttle(handleScroll, 50); + + useEffect(() => { + if (searchBarIsVisible) { + window.addEventListener('scroll', handleScrollThrottled); + } + return () => { + window.removeEventListener('scroll', handleScrollThrottled); + }; + }, [searchBarIsVisible, searchWords, handleScrollThrottled]); +} diff --git a/src/components/SearchBar/index.ts b/src/components/SearchBar/index.ts new file mode 100644 index 00000000..44cc474f --- /dev/null +++ b/src/components/SearchBar/index.ts @@ -0,0 +1,4 @@ +export * from './SearchBar'; +export {default as SearchBar} from './SearchBar'; +export * from './withHighlightedSearchWords'; +export {default as withHighlightedSearchWords} from './withHighlightedSearchWords'; diff --git a/src/components/SearchBar/utils.ts b/src/components/SearchBar/utils.ts new file mode 100644 index 00000000..1a3d03cd --- /dev/null +++ b/src/components/SearchBar/utils.ts @@ -0,0 +1,107 @@ +import Mark from 'mark.ts'; + +export interface HighlightOptions { + html: string; + keywords: string[]; + options: { + className: string; + element: string; + exclude: string[]; + }; +} + +export function highlight({html, keywords, options}: HighlightOptions) { + const tmpDiv = document.createElement('div'); + tmpDiv.innerHTML = html; + + const markInstance = new Mark(tmpDiv); + markInstance.mark(keywords, options); + + return tmpDiv.innerHTML; +} + +export function scrollToItem(item: Element) { + if (!item) { + return; + } + + item.scrollIntoView({ + block: 'center', + inline: 'center', + }); +} + +type GetItemIndexInView = { + elements: Element[]; + offset?: number; + top?: number; + bottom?: number; + reverse?: boolean; +}; + +function getElementOffset(item: HTMLElement, offset = 0) { + return Math.floor(Number((item as HTMLElement).getBoundingClientRect().top)) - offset; +} + +export function getItemIndexInView({ + elements, + offset = 0, + top = 0, + bottom = Infinity, + reverse = false, +}: GetItemIndexInView) { + let itemIndex; + for (let index = 0; index < elements.length; index++) { + const item = elements[index]; + const itemOffset = getElementOffset(item as HTMLElement, offset); + + if (itemOffset >= top && itemOffset <= bottom) { + itemIndex = index + 1; + + if (!reverse) { + break; + } + } + } + + return itemIndex; +} + +type UseHighlightedItemInView = { + highlightedDOMElements: Element[]; + headerHeight: number; + hash?: string; +}; + +export function getHighlightedItemIndexInView({ + highlightedDOMElements, + headerHeight, + hash, +}: UseHighlightedItemInView) { + const elements = highlightedDOMElements; + const offset = headerHeight; + let top = 0; + + if (hash && typeof document !== 'undefined') { + const anchorEl = document.getElementById(hash) as HTMLElement; + if (anchorEl) { + top = getElementOffset(anchorEl, offset); + } + } + + const firstIndexInView = getItemIndexInView({top, elements, offset}); + const firstIndexOutViewBottom = getItemIndexInView({ + top: top + window.innerHeight, + elements, + offset, + }); + const lastIndexOutViewTop = getItemIndexInView({ + top: -Infinity, + bottom: top, + reverse: true, + elements, + offset, + }); + + return firstIndexInView || firstIndexOutViewBottom || lastIndexOutViewTop; +} diff --git a/src/components/SearchBar/withHighlightedSearchWords.tsx b/src/components/SearchBar/withHighlightedSearchWords.tsx new file mode 100644 index 00000000..87f9427c --- /dev/null +++ b/src/components/SearchBar/withHighlightedSearchWords.tsx @@ -0,0 +1,114 @@ +import React, {useState, useCallback, useEffect} from 'react'; + +import {DocPageData, Router} from '../../models'; + +import { + useHighlightedSearchWords, + useSearchBarNavigation, + useCurrentWordSelectionEffect, + useCurrentWordSelectionSyncScrollEffect, +} from './hooks'; + +export interface SearchWordsHighlighterProps extends DocPageData { + searchWords?: string[]; + showSearchBar?: boolean; + onCloseSearchBar?: () => void; + onNotFoundWords?: () => void; + searchQuery?: string; + onContentMutation?: () => void; + onContentLoaded?: () => void; + headerHeight?: number; + router: Router; +} + +function withHighlightedSearchWords( + Component: React.ComponentType, +) { + const SearchWordsHighlighter: React.FC = ( + props, + ): JSX.Element | null => { + const { + html, + searchWords = [], + showSearchBar = false, + onCloseSearchBar, + onNotFoundWords = () => {}, + onContentMutation, + onContentLoaded, + headerHeight = 0, + router: {hash}, + } = props; + + const [syncOnScroll, setSyncOnScroll] = useState(true); + const stopSyncOnScroll = useCallback(() => setSyncOnScroll(false), []); + + useEffect(() => { + setSyncOnScroll(false); + }, [html, searchWords]); + + const { + highlightedHtml, + highlightedDOMElements, + searchBarIsVisible, + wasChangedDOM, + _onContentMutation, + _onContentLoaded, + } = useHighlightedSearchWords({ + html, + searchWords, + showSearchBar, + onContentMutation, + onContentLoaded, + onNotFoundWords, + }); + + const { + searchCurrentIndex, + setSearchCurrentIndex, + searchCountResults, + onClickPrevSearch, + onClickNextSearch, + } = useSearchBarNavigation({highlightedDOMElements, stopSyncOnScroll, headerHeight, hash}); + + useCurrentWordSelectionEffect({ + searchCurrentIndex, + highlightedDOMElements, + wasChangedDOM, + syncOnScroll, + hash, + }); + + /* Sync scroll with a current item in viewport */ + useCurrentWordSelectionSyncScrollEffect({ + highlightedDOMElements, + searchWords, + syncOnScroll, + searchBarIsVisible, + setSearchCurrentIndex, + headerHeight, + setSyncOnScroll, + }); + + if (searchBarIsVisible) { + return ( + + ); + } + + return ; + }; + + return SearchWordsHighlighter; +} + +export default withHighlightedSearchWords; diff --git a/src/constants.ts b/src/constants.ts index e391b382..4faab468 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,7 @@ export const DEFAULT_SETTINGS = { singlePage: false, wideFormat: false, showMiniToc: true, + bookmarkedPage: false, theme: Theme.Dark, textSize: TextSizes.M, isLiked: false, diff --git a/src/demo/Components/DocPage/index.tsx b/src/demo/Components/DocPage/index.tsx index 16c01ee3..5be543c5 100644 --- a/src/demo/Components/DocPage/index.tsx +++ b/src/demo/Components/DocPage/index.tsx @@ -1,17 +1,23 @@ -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useState, useEffect} from 'react'; import cn from 'bem-cn-lite'; -import {DocPage, FeedbackSendData, FeedbackType, Theme} from '../../../index'; +import { + DocPage as DocPageBase, + FeedbackSendData, + FeedbackType, + Theme, + withHighlightedSearchWords, +} from '../../../index'; import Header from '../Header/Header'; -import {DEFAULT_SETTINGS} from '../../../constants'; +import {DEFAULT_SETTINGS, DISLIKE_VARIANTS} from '../../../constants'; import {getIsMobile} from '../../controls/settings'; import getLangControl from '../../controls/lang'; import getVcsControl from '../../controls/vcs'; -import {getIsBookmarked} from '../../decorators/bookmark'; import {getContent} from './data'; import {join} from 'path'; const layoutBlock = cn('Layout'); +const DocPage = withHighlightedSearchWords(DocPageBase); function updateBodyClassName(theme: Theme) { const bodyEl = document.body; @@ -28,7 +34,6 @@ const DocPageDemo = () => { const langValue = getLangControl(); const vcsType = getVcsControl(); const isMobile = getIsMobile(); - const isBookmarked = getIsBookmarked(); const router = {pathname: '/docs/overview/concepts/quotas-limits'}; const [fullScreen, onChangeFullScreen] = useState(DEFAULT_SETTINGS.fullScreen); @@ -41,6 +46,10 @@ const DocPageDemo = () => { const [isDisliked, setIsDisliked] = useState(DEFAULT_SETTINGS.isDisliked); const [isPinned, setIsPinned] = useState(DEFAULT_SETTINGS.isPinned); const [lang, onChangeLang] = useState(langValue); + const [showSearchBar, setShowSearchBar] = useState(true); + const onCloseSearchBar = () => setShowSearchBar(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchWords, setSearchWords] = useState([]); const onSendFeedback = useCallback((data: FeedbackSendData) => { const {type} = data; @@ -66,14 +75,31 @@ const DocPageDemo = () => { updateBodyClassName(theme); - const enableDisableBookmarks = () => { - return isBookmarked === 'true' - ? { - bookmarkedPage: isPinned, - onChangeBookmarkPage: onChangeBookmarkPage, - } - : undefined; - }; + useEffect(() => { + const newSearchWords = searchQuery.split(' ').filter((word) => { + if (!word) { + return false; + } + + if (searchQuery.length > 10) { + return word.length > 3; + } + + return true; + }); + + setSearchWords(newSearchWords); + }, [searchQuery]); + + const onNotFoundWords = useCallback(() => { + console.log(`Not found words for the query: ${searchQuery}`); + }, [searchQuery]); + const onContentMutation = useCallback(() => { + console.log('onContentMutation'); + }, []); + const onContentLoaded = useCallback(() => { + console.log('onContentLoaded'); + }, []); const props = { ...getContent(lang, singlePage), @@ -89,6 +115,7 @@ const DocPageDemo = () => { showMiniToc, onChangeShowMiniToc, theme, + onNotFoundWords, onChangeTheme: (themeValue: Theme) => { updateBodyClassName(themeValue); onChangeTheme(themeValue); @@ -100,7 +127,12 @@ const DocPageDemo = () => { isLiked, isDisliked, onSendFeedback, - ...enableDisableBookmarks(), + bookmarkedPage: isPinned, + onChangeBookmarkPage: onChangeBookmarkPage, + showSearchBar, + searchWords, + searchQuery, + onCloseSearchBar, }; const tocTitleIcon = ( @@ -116,6 +148,10 @@ const DocPageDemo = () => { const generatePathToVcs = (path: string) => join(`https://github.com/yandex-cloud/docs/blob/master/${props.lang}`, path); const renderLoader = () => 'Loading...'; + const onChangeSearch = (value: string) => { + setShowSearchBar(true); + setSearchQuery(value); + }; return (
@@ -123,8 +159,10 @@ const DocPageDemo = () => {
)}
@@ -134,6 +172,10 @@ const DocPageDemo = () => { convertPathToOriginalArticle={convertPathToOriginalArticle} generatePathToVcs={generatePathToVcs} renderLoader={renderLoader} + onNotFoundWords={onNotFoundWords} + onContentMutation={onContentMutation} + onContentLoaded={onContentLoaded} + dislikeVariants={DISLIKE_VARIANTS[lang]} />
diff --git a/src/demo/Components/Header/Header.tsx b/src/demo/Components/Header/Header.tsx index 4e1fc5e6..598b8da3 100644 --- a/src/demo/Components/Header/Header.tsx +++ b/src/demo/Components/Header/Header.tsx @@ -1,6 +1,12 @@ import React from 'react'; import cn from 'bem-cn-lite'; -import {ControlSizes, LangControl, FullScreenControl, DividerControl} from '../../../index'; +import { + ControlSizes, + LangControl, + FullScreenControl, + DividerControl, + TextInput, +} from '../../../index'; import {Lang} from '../../../models'; const headBlock = cn('Header'); @@ -9,11 +15,20 @@ const layoutBlock = cn('Layout'); export interface HeaderProps { lang: Lang; fullScreen: boolean; + searchText?: string; onChangeLang?: (lang: Lang) => void; onChangeFullScreen?: (value: boolean) => void; + onChangeSearch?: (value: string) => void; } -const Header: React.FC = ({lang, fullScreen, onChangeFullScreen, onChangeLang}) => { +const Header: React.FC = ({ + lang, + fullScreen, + searchText, + onChangeFullScreen, + onChangeLang, + onChangeSearch, +}) => { return (
@@ -31,6 +46,14 @@ const Header: React.FC = ({lang, fullScreen, onChangeFullScreen, on size={ControlSizes.M} className={headBlock('control')} /> + + {onChangeFullScreen ? ( + + ) : null}
); diff --git a/src/demo/decorators/bookmark.tsx b/src/demo/decorators/bookmark.tsx deleted file mode 100644 index 45948942..00000000 --- a/src/demo/decorators/bookmark.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {radios} from '@storybook/addon-knobs'; - -export function getIsBookmarked() { - const label = 'Bookmark page'; - const options = { - enabled: 'true', - disabled: 'false', - }; - const defaultValue = 'false'; - - return radios(label, options, defaultValue); -} diff --git a/src/demo/reset-storybook.scss b/src/demo/reset-storybook.scss index ebe3d891..62826003 100644 --- a/src/demo/reset-storybook.scss +++ b/src/demo/reset-storybook.scss @@ -27,6 +27,16 @@ body.yc-root { color: var(--yc-color-text-primary); } + &__control-input { + height: 28px; + border-radius: 4px; + border: 1px solid var(--yc-color-line-generic); + margin-right: 6px; + display: flex; + align-items: center; + justify-content: center; + } + &__divider { margin: 0 4px; } diff --git a/src/i18n/en.json b/src/i18n/en.json index f446fe9c..9c41e380 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -60,5 +60,11 @@ "button-like-text": "Yes", "button-dislike-text": "No", "main-question": "Was the article helpful?" + }, + "search-bar": { + "search-query-label": "Found on request", + "close": "Close", + "prev": "Previous", + "next": "Next" } } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index d84e933c..0e785294 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -60,5 +60,11 @@ "button-like-text": "Да", "button-dislike-text": "Нет", "main-question": "Была ли статья полезна?" + }, + "search-bar": { + "search-query-label": "Найдено по запросу", + "close": "Закрыть", + "prev": "Предыдущий", + "next": "Следующий" } } diff --git a/src/index.ts b/src/index.ts index 979ca495..10c013ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,5 +25,6 @@ export * from './components/Control'; export * from './components/Controls'; export * from './components/Checkbox'; export * from './components/BookmarkButton'; +export * from './components/SearchBar'; export * from './models'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 77693d8c..c379256e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -87,3 +87,5 @@ export function createElementFromHTML(htmlString: string) { return div.firstChild as Node; } + +export const getRandomKey = () => Math.random(); diff --git a/styles/themes.scss b/styles/themes.scss index 51cddb65..f09061a0 100644 --- a/styles/themes.scss +++ b/styles/themes.scss @@ -2,6 +2,9 @@ @import './themes/dark'; .yc-root { + --dc-text-highlight: var(--yc-color-base-warning-heavy); + --dc-text-highlight-selected: #ffab3b; + &_theme_light { @include yc-theme-light;