+ {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;