diff --git a/package.json b/package.json index 4e58b32b48..3ede035f9d 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "@ant-design/bisheng-plugin": "^2.3.0", "@ant-design/hitu": "^0.0.0-alpha.13", "@ant-design/tools": "^13.4.1-beta.0", + "@docsearch/react": "^3.0.0-alpha.36", "@qixian.cs/github-contributors-list": "^1.0.3", "@stackblitz/sdk": "^1.3.0", "@types/classnames": "^2.2.8", diff --git a/site/theme/static/dark.less b/site/theme/static/dark.less index 3d063a2c9a..ea83b30f61 100644 --- a/site/theme/static/dark.less +++ b/site/theme/static/dark.less @@ -1,3 +1,5 @@ +@import '../template/Layout/Header/DocSearch.less'; + [data-theme='dark'] { /* Change autocomplete styles in WebKit */ input:-webkit-autofill, @@ -366,4 +368,22 @@ color: rgba(255, 255, 255, 0.65); } } + + // customization DocSearch Modal component in dark mode + #antd_algolia_search_modal { + .customDocSearch(); + + // additional style reset in dark mode + --docsearch-key-gradient: @background-color-light; + --docsearch-logo-color: @white; + + .DocSearch-Modal, + .DocSearch-Commands-Key { + box-shadow: none; + } + + .DocSearch-Hit a { + box-shadow: none; + } + } } diff --git a/site/theme/static/docsearch.less b/site/theme/static/docsearch.less index e5bd893e35..681e13aece 100644 --- a/site/theme/static/docsearch.less +++ b/site/theme/static/docsearch.less @@ -21,6 +21,7 @@ } .algolia-docsearch-suggestion--highlight { + padding: 0; color: @primary-color; } } diff --git a/site/theme/template/Layout/Header/DocSearch.less b/site/theme/template/Layout/Header/DocSearch.less new file mode 100644 index 0000000000..7cdb92c6ad --- /dev/null +++ b/site/theme/template/Layout/Header/DocSearch.less @@ -0,0 +1,32 @@ +// theme customization for DocSearch Modal +.customDocSearch() { + .DocSearch { + --docsearch-primary-color: @primary-color; + --docsearch-text-color: @text-color; + --docsearch-muted-color: @text-color-secondary; + --docsearch-container-background: @modal-mask-bg; + --docsearch-searchbox-shadow: inset 0 0 0 2px @primary-color; + + // Modal + --docsearch-modal-background: @modal-content-bg; + + // Search box + --docsearch-searchbox-background: @input-bg; + --docsearch-searchbox-focus-background: @input-bg; + + // Footer + --docsearch-footer-background: @modal-content-bg; + + // Hits + --docsearch-hit-background: @modal-content-bg; + --docsearch-hit-color: @primary-color; + --docsearch-hit-active-color: @text-color-inverse; + --docsearch-highlight-color: @primary-color; + + // apply antd modal style + .DocSearch-Footer { + border-top: @modal-footer-border-width @modal-footer-border-style + @modal-footer-border-color-split; + } + } +} diff --git a/site/theme/template/Layout/Header/SearchBox.less b/site/theme/template/Layout/Header/SearchBar.less similarity index 71% rename from site/theme/template/Layout/Header/SearchBox.less rename to site/theme/template/Layout/Header/SearchBar.less index 8c3b090f36..f1b4a5ac9f 100644 --- a/site/theme/template/Layout/Header/SearchBox.less +++ b/site/theme/template/Layout/Header/SearchBar.less @@ -1,6 +1,7 @@ @import '../../../static/theme.less'; @import '../../../../../components/style/themes/default.less'; @import './index.less'; +@import './DocSearch.less'; @search-icon-color: #ced4d9; @@ -17,6 +18,23 @@ border-left: 1px solid @site-border-color-split; transition: width 0.5s; + .keybindings { + cursor: pointer; + } + + .keybinding { + color: @search-icon-color; + kbd { + display: inline-block; + width: 20px; + height: 20px; + padding: 0; + // better keybinding font display using `Arial` + font-family: Arial; /* stylelint-disable-line font-family-no-missing-generic-family-keyword */ + text-align: center; + } + } + .ant-row-rtl & { margin: 0 0 0 auto !important; padding-right: 16px; @@ -39,6 +57,12 @@ pointer-events: none; } + .ant-input-affix-wrapper { + background: transparent; + border: 0; + box-shadow: none; + } + input { width: 100%; max-width: 200px; @@ -136,3 +160,28 @@ display: none; } } + +.DocSearch { + font-family: @font-family; +} + +#antd_algolia_search_modal { + // avoid the veritcal scrollbar(looks like a white block) + .DocSearch-Hit-title { + overflow-y: hidden; + + mark { + padding: 0; + } + } + + // reset original `box-shadow` style + .DocSearch-Footer { + box-shadow: none; + } +} + +// customization DocSearch Modal component in light mode +body[data-theme='default'] #antd_algolia_search_modal { + .customDocSearch(); +} diff --git a/site/theme/template/Layout/Header/SearchBar.tsx b/site/theme/template/Layout/Header/SearchBar.tsx new file mode 100644 index 0000000000..195ca28fe5 --- /dev/null +++ b/site/theme/template/Layout/Header/SearchBar.tsx @@ -0,0 +1,196 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Link, browserHistory } from 'bisheng/router'; +import classNames from 'classnames'; +import { Helmet } from 'react-helmet-async'; +import canUseDom from 'rc-util/lib/Dom/canUseDom'; +import { Input, Tooltip, Typography } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import { DocSearchProps, useDocSearchKeyboardEvents, DocSearchModalProps } from '@docsearch/react'; +import '@docsearch/css'; + +import { SharedProps } from './interface'; +import { IAlgoliaConfig, transformHitUrl } from './algolia-config'; + +import './SearchBar.less'; + +const { Text } = Typography; + +export interface SearchBarProps extends SharedProps { + onTriggerFocus?: (focus: boolean) => void; + responsive: null | 'narrow' | 'crowded'; + algoliaConfig: IAlgoliaConfig; +} + +let SearchModal: React.FC | null = null; + +const Hit: DocSearchProps['hitComponent'] = ({ hit, children }) => { + const toUrl = React.useMemo(() => transformHitUrl(hit.url), [hit.url]); + return {children}; +}; + +const CTRL_KEY = 'Ctrl'; +const CMD_KEY = '⌘'; + +function isAppleDevice() { + return /(mac|iphone|ipod|ipad)/i.test(navigator.platform); +} + +/** + * Recompose for algolia DocSearch Component Inspiring by + * + * - [@docusaurus-theme-search-algolia](https://docusaurus.io/docs/api/themes/@docusaurus/theme-search-algolia) + * - [DocSearchModal Docs](https://autocomplete-experimental.netlify.app/docs/DocSearchModal) + */ +export const SearchBar = ({ + isZhCN, + responsive, + onTriggerFocus, + algoliaConfig, +}: SearchBarProps) => { + const [isInputFocus, setInputFocus] = React.useState(false); + const [inputSearch, setInputSearch] = React.useState(''); + + const [isModalOpen, setModalOpen] = React.useState(false); + const [searchModalQuery, setSearchModalQuery] = React.useState(''); + const searchPlaceholder = isZhCN ? '在 ant.design 中搜索' : 'Search in ant.design'; + const searchInputPlaceholder = isZhCN ? '搜索' : 'Search'; + + const triggerSearchModalImport = React.useCallback(() => { + if (SearchModal) { + return Promise.resolve(); + } + + return import('@docsearch/react/modal').then(({ DocSearchModal }) => { + SearchModal = DocSearchModal; + }); + }, []); + + const handleInputFocus = React.useCallback((focus: boolean) => { + setInputFocus(focus); + onTriggerFocus?.(focus); + }, []); + + const handleInputChange = React.useCallback((event: React.ChangeEvent) => { + triggerSearchModalImport(); + setInputSearch(event.target.value); + }, []); + + const searchModalContainer = React.useMemo(() => { + if (!canUseDom()) { + return; + } + const id = 'antd_algolia_search_modal'; + let searchModalContainer$ = document.querySelector(`#${id}`); + if (!searchModalContainer$) { + const containerDiv = document.createElement('div'); + containerDiv.id = id; + document.body.appendChild(containerDiv); + searchModalContainer$ = containerDiv; + } + return searchModalContainer$; + }, []); + + const handleModalOpen = React.useCallback(() => { + triggerSearchModalImport().then(() => { + handleInputFocus(true); + setModalOpen(true); + }); + }, []); + + const handleModalClose = React.useCallback(() => { + // clear search value in SearchModal + setSearchModalQuery(''); + setModalOpen(false); + }, []); + + useDocSearchKeyboardEvents({ + isOpen: isModalOpen, + onOpen: handleModalOpen, + onClose: handleModalClose, + }); + + const searchParameters = React.useMemo(() => algoliaConfig.getSearchParams(isZhCN), [isZhCN]); + + const navigator = React.useRef({ + navigate({ itemUrl }: { itemUrl: string }) { + browserHistory.push(itemUrl); + }, + }).current; + + return ( + + ); +}; diff --git a/site/theme/template/Layout/Header/SearchBox.tsx b/site/theme/template/Layout/Header/SearchBox.tsx deleted file mode 100644 index 05179577fd..0000000000 --- a/site/theme/template/Layout/Header/SearchBox.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from 'react'; -import { Input } from 'antd'; -import classNames from 'classnames'; -import { SearchOutlined } from '@ant-design/icons'; -import { SharedProps } from './interface'; - -import './SearchBox.less'; - -export interface SearchBoxProps extends SharedProps { - onTriggerFocus?: (focus: boolean) => void; - responsive: null | 'narrow' | 'crowded'; -} - -export default ({ isZhCN, responsive, onTriggerFocus }: SearchBoxProps) => { - const inputRef = React.useRef(null); - const [focused, setFocused] = React.useState(false); - const searchPlaceholder = isZhCN ? '在 ant.design 中搜索' : 'Search in ant.design'; - - function triggerFocus(focus: boolean) { - setFocused(focus); - onTriggerFocus?.(focus); - } - - React.useEffect(() => { - function keyupHandler(event: KeyboardEvent) { - if (event.keyCode === 83 && event.target === document.body) { - inputRef.current.focus(); - } - } - - document.addEventListener('keyup', keyupHandler); - - return () => { - document.removeEventListener('keyup', keyupHandler); - }; - }, []); - - return ( - - ); -}; diff --git a/site/theme/template/Layout/Header/algolia-config.ts b/site/theme/template/Layout/Header/algolia-config.ts new file mode 100644 index 0000000000..659475ac61 --- /dev/null +++ b/site/theme/template/Layout/Header/algolia-config.ts @@ -0,0 +1,28 @@ +import { DocSearchHit } from '@docsearch/react/dist/esm/types'; + +let _internalATag: HTMLAnchorElement | null; + +export function transformHitUrl(hitUrl: string) { + _internalATag = _internalATag || document.createElement('a'); + // `new URL` is not supported in IE + _internalATag.href = hitUrl; + return `${_internalATag.pathname}${window.location.search || ''}${_internalATag.hash}`; +} + +export const AlgoliaConfig = { + appId: 'BH4D9OD16A', + apiKey: '60ac2c1a7d26ab713757e4a081e133d0', + indexName: 'ant_design', + getSearchParams(isZhCN: boolean) { + return { facetFilters: [`tags:${isZhCN ? 'cn' : 'en'}`] }; + }, + transformData(hits: DocSearchHit[]) { + hits.forEach(hit => { + hit.url = transformHitUrl(hit.url); + }); + return hits; + }, + debug: false, // Set debug to true if you want to inspect the dropdown +}; + +export type IAlgoliaConfig = typeof AlgoliaConfig; diff --git a/site/theme/template/Layout/Header/index.tsx b/site/theme/template/Layout/Header/index.tsx index 5172581786..b7a63c8ec1 100644 --- a/site/theme/template/Layout/Header/index.tsx +++ b/site/theme/template/Layout/Header/index.tsx @@ -3,16 +3,19 @@ import { FormattedMessage, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { UnorderedListOutlined } from '@ant-design/icons'; import { Select, Row, Col, Popover, Button } from 'antd'; +import canUseDom from 'rc-util/lib/Dom/canUseDom'; +// import { browserHistory } from 'bisheng/router'; import * as utils from '../../utils'; import packageJson from '../../../../../package.json'; import Logo from './Logo'; -import SearchBox from './SearchBox'; +import { SearchBar } from './SearchBar'; import More from './More'; import Navigation from './Navigation'; import Github from './Github'; import SiteContext from '../SiteContext'; import { ping } from '../../utils'; +import { AlgoliaConfig } from './algolia-config'; import './index.less'; @@ -23,35 +26,6 @@ const { Option } = Select; const antdVersion: string = packageJson.version; -let docsearch: any; -if (typeof window !== 'undefined') { - // eslint-disable-next-line global-require - docsearch = require('docsearch.js'); -} - -function initDocSearch(locale: string) { - if (!docsearch) { - return; - } - const lang = locale === 'zh-CN' ? 'cn' : 'en'; - docsearch({ - apiKey: '60ac2c1a7d26ab713757e4a081e133d0', - indexName: 'ant_design', - inputSelector: '#search-box input', - algoliaOptions: { facetFilters: [`tags:${lang}`] }, - transformData(hits: { url: string }[]) { - const a = document.createElement('a'); - hits.forEach(hit => { - // `new URL` is not supported in IE - a.href = hit.url; - hit.url = `${a.pathname}${window.location.search || ''}${a.hash}`; - }); - return hits; - }, - debug: false, // Set debug to true if you want to inspect the dropdown - }); -} - export interface HeaderProps { intl: { locale: string; @@ -62,6 +36,44 @@ export interface HeaderProps { changeDirection: (direction: string) => void; } +let docsearch: any; +const triggerDocSearchImport = () => { + if (docsearch) { + return Promise.resolve(); + } + + return import('docsearch.js').then(ds => { + docsearch = ds.default; + }); +}; + +function initDocSearch(isZhCN: boolean) { + if (!canUseDom()) { + return; + } + + triggerDocSearchImport().then(() => { + docsearch({ + appId: AlgoliaConfig.appId, + apiKey: AlgoliaConfig.apiKey, + indexName: AlgoliaConfig.indexName, + inputSelector: '#search-box input', + algoliaOptions: AlgoliaConfig.getSearchParams(isZhCN), + transformData: AlgoliaConfig.transformData, + debug: AlgoliaConfig.debug, + // https://docsearch.algolia.com/docs/behavior#handleselected + // handleSelected: (input, _$1, suggestion, _$2, context) => { + // doesn't refresh + // // Prevents the default behavior on click + // if (context.selectionMethod === 'click') { + // input.setVal(''); + // browserHistory.push(suggestion.url); + // } + // }, + }); + }); +} + interface HeaderState { menuVisible: boolean; windowWidth: number; @@ -84,7 +96,8 @@ class Header extends React.Component { componentDidMount() { const { intl, router } = this.props; router.listen(this.handleHideMenu); - initDocSearch(intl.locale); + + initDocSearch(intl.locale === 'zh'); window.addEventListener('resize', this.onWindowResize); this.onWindowResize(); @@ -237,15 +250,6 @@ class Header extends React.Component { isRTL, }; - const searchBox = ( - - ); - const navigationNode = ( { - {searchBox} + {!isMobile && menu} diff --git a/tsconfig.json b/tsconfig.json index cf511ab415..f594bea7bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "antd/es/*": ["components/*"] }, "strictNullChecks": true, + "module": "esnext", "moduleResolution": "node", "esModuleInterop": true, "experimentalDecorators": true, diff --git a/typings/custom-typings.d.ts b/typings/custom-typings.d.ts index 2e0693981c..6a204cd51b 100644 --- a/typings/custom-typings.d.ts +++ b/typings/custom-typings.d.ts @@ -51,3 +51,12 @@ declare module '*.json' { export const version: string; export default value; } + +declare module '@docsearch/react/style/modal'; + +declare module '@docsearch/react/modal' { + import { DocSearchModal as value } from '@docsearch/react'; + export const DocSearchModal = value; +} + +declare module 'docsearch.js';