diff --git a/src/components/App/Page.tsx b/src/components/App/Page.tsx index 31ca4b4..af163f1 100644 --- a/src/components/App/Page.tsx +++ b/src/components/App/Page.tsx @@ -71,7 +71,7 @@ export function RichNavPage({data, props, controls}: PageProps) const CustomSuggest = useCallback(() => , []); const CustomControls = useCallback(() => , [controls]); - const navigation = useNavigation(data, CustomControls, CustomSuggest); + const navigation = useNavigation(data, controls, CustomControls, CustomSuggest); const CustomPage = useCallback( () => ( diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index 0310253..9e11231 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -1,8 +1,10 @@ import type {NavigationData, PageContent} from '@gravity-ui/page-constructor'; import type {ReactElement} from 'react'; import type {Props as HeaderControlsProps} from '../HeaderControls'; +import type {SearchConfig} from '../Search'; +import type {RouterConfig} from '../Router'; -import React, {useEffect} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {ThemeProvider} from '@gravity-ui/uikit'; import { ConsentPopup, @@ -11,11 +13,12 @@ import { DocLeadingPageData, DocPageData, Lang, - Router, configure, } from '@diplodoc/components'; import '@diplodoc/transform/dist/js/yfm'; +import {SearchProvider} from '../Search'; +import {RouterProvider} from '../Router'; import {getDirection, updateRootClassName, updateThemeClassName} from '../../utils'; import {LangProvider} from '../../hooks/useLang'; import '../../interceptors/leading-page-links'; @@ -38,7 +41,8 @@ export type DocAnalytics = { export interface AppProps { lang: Lang; langs: Lang[]; - router: Router; + router: RouterConfig; + search?: SearchConfig; analytics?: DocAnalytics; } @@ -63,7 +67,7 @@ function hasNavigation( } export function App(props: DocInnerProps): ReactElement { - const {data, router, lang, analytics} = props; + const {data, router, lang, search, analytics} = props; configure({ lang, @@ -75,20 +79,26 @@ export function App(props: DocInnerProps): ReactElement { const {theme, textSize, wideFormat, fullScreen, showMiniToc} = settings; - const page = { - router, - - theme, - textSize, - wideFormat, - fullScreen, - showMiniToc, - }; - const controls: HeaderControlsProps = { - ...settings, - ...langs, - mobileView, - }; + const page = useMemo( + () => ({ + router, + + theme, + textSize, + wideFormat, + fullScreen, + showMiniToc, + }), + [router, theme, textSize, wideFormat, fullScreen, showMiniToc], + ); + const controls: HeaderControlsProps = useMemo( + () => ({ + ...settings, + ...langs, + mobileView, + }), + [langs, settings, mobileView], + ); const direction = getDirection(lang); useEffect(() => { @@ -100,19 +110,23 @@ export function App(props: DocInnerProps): ReactElement {
- {hasNavigation(data) ? ( - - ) : ( - - )} - {analytics && ( - - )} - + + + {hasNavigation(data) ? ( + + ) : ( + + )} + {analytics && ( + + )} + + +
diff --git a/src/components/ConstructorPage/useNavigation.tsx b/src/components/ConstructorPage/useNavigation.tsx index a1f4207..ee4ecaf 100644 --- a/src/components/ConstructorPage/useNavigation.tsx +++ b/src/components/ConstructorPage/useNavigation.tsx @@ -2,15 +2,19 @@ import type {ReactNode} from 'react'; import type {NavigationData} from '@gravity-ui/page-constructor'; import type {DocBasePageData} from '@diplodoc/components'; import type {WithNavigation} from '../App'; +import type {Props as HeaderControlsProps} from '../HeaderControls'; -import React, { useMemo } from 'react'; -import { ControlSizes, CustomNavigation, MobileDropdown } from '@diplodoc/components'; +import React, {useMemo} from 'react'; +import {ControlSizes, CustomNavigation, MobileDropdown} from '@diplodoc/components'; import {HEADER_HEIGHT} from '../../constants'; +import {useRouter} from '../'; export const useNavigation = ( data: DocBasePageData, + controls: HeaderControlsProps, CustomControls: () => ReactNode, + CustomSuggest: () => ReactNode, ) => { const {toc} = data; const {navigation} = toc; @@ -20,22 +24,30 @@ export const useNavigation = ( const withControls = rightItems.some((item: {type: string}) => item.type === 'controls'); const router = useRouter(); - const userSettings = useSettings(); - const navigationData = useMemo(() => ({ - withBorder: true, - leftItems: leftItems, - rightItems: rightItems, - }), [leftItems, rightItems]); - const navigationTocData = useMemo(() => ({ - toc, - router, - headerHeight: HEADER_HEIGHT - }), [toc, router]); - const mobileControlsData = useMemo(() => ({ - controlSize: ControlSizes.L, - userSettings - }), [userSettings]); + const navigationData = useMemo( + () => ({ + withBorder: true, + leftItems: leftItems, + rightItems: rightItems, + }), + [leftItems, rightItems], + ); + const navigationTocData = useMemo( + () => ({ + toc, + router, + headerHeight: HEADER_HEIGHT, + }), + [toc, router], + ); + const mobileControlsData = useMemo( + () => ({ + controlSize: ControlSizes.L, + userSettings: controls, + }), + [controls], + ); const layout = useMemo( () => ({ @@ -58,13 +70,14 @@ export const useNavigation = ( const config = useMemo( () => ({ custom: { + search: CustomSuggest, controls: CustomControls, MobileDropdown: MobileDropdown, }, layout, withControls, }), - [CustomControls, layout, withControls], + [CustomSuggest, CustomControls, layout, withControls], ); return config; diff --git a/src/components/Router/index.ts b/src/components/Router/index.ts new file mode 100644 index 0000000..009b803 --- /dev/null +++ b/src/components/Router/index.ts @@ -0,0 +1,20 @@ +import type {Router} from '@diplodoc/components'; + +import {createContext, useContext} from 'react'; + +export interface RouterConfig extends Router { + depth: number; +} + +export const RouterContext = createContext({ + pathname: '/', + depth: 0, +}); + +RouterContext.displayName = 'RouterContext'; + +export const RouterProvider = RouterContext.Provider; + +export function useRouter() { + return useContext(RouterContext); +} diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 0000000..85010cb --- /dev/null +++ b/src/components/Search/Search.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const Search = () => { + return
Initial
; +}; diff --git a/src/components/Search/Suggest.scss b/src/components/Search/Suggest.scss new file mode 100644 index 0000000..295ed94 --- /dev/null +++ b/src/components/Search/Suggest.scss @@ -0,0 +1,17 @@ +.Suggest { + margin-right: 20px; + min-width: 200px; + transition: min-width 0.3s; + + .dc-root_focused-search & { + min-width: 500px; + } + + &__Item { + &__Marker { + background: var(--g-color-base-neutral-medium); + padding: 0 3px 1px; + border-radius: 4px; + } + } +} diff --git a/src/components/Search/Suggest.tsx b/src/components/Search/Suggest.tsx new file mode 100644 index 0000000..3d3cfa0 --- /dev/null +++ b/src/components/Search/Suggest.tsx @@ -0,0 +1,41 @@ +import type {ISearchProvider, SearchSuggestApi} from '@diplodoc/components'; + +import React, {useCallback, useRef} from 'react'; +import {SearchSuggest} from '@diplodoc/components'; + +import {updateRootClassName} from '../../utils'; + +import {useProvider} from './useProvider'; +import './Suggest.scss'; + +export function Suggest() { + const provider: ISearchProvider | null = useProvider(); + const suggest = useRef(null); + + const onFocus = useCallback(() => { + updateRootClassName({focusSearch: true}); + }, []); + + const onBlur = useCallback(() => { + updateRootClassName({focusSearch: false}); + setTimeout(() => { + if (suggest.current) { + suggest.current.close(); + } + }, 100); + }, []); + + if (!provider) { + return null; + } + + return ( + + ); +} diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 0000000..90e9162 --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1,13 @@ +import type {SearchConfig, WorkerApi, WorkerConfig} from './types'; + +import {createContext} from 'react'; + +export type {SearchConfig, WorkerConfig, WorkerApi}; + +export const SearchContext = createContext(null); + +SearchContext.displayName = 'SearchContext'; + +export const SearchProvider = SearchContext.Provider; + +export {Search} from './Search'; diff --git a/src/components/Search/provider/index.ts b/src/components/Search/provider/index.ts new file mode 100644 index 0000000..32e6dd1 --- /dev/null +++ b/src/components/Search/provider/index.ts @@ -0,0 +1,86 @@ +import type {ISearchProvider, ISearchResult} from '@diplodoc/components'; +import type {SearchConfig, WorkerConfig} from '../types'; + +export class SearchProvider implements ISearchProvider { + private worker!: Promise; + + private config: SearchConfig; + + constructor(config: SearchConfig) { + this.config = config; + } + + init = () => { + this.worker = initWorker({ + ...this.config, + base: this.base, + mark: 'Suggest__Item__Marker', + }); + }; + + async suggest(query: string) { + return this.request({ + type: 'suggest', + query, + }) as Promise; + } + + async search(query: string) { + return this.request({ + type: 'search', + query, + }) as Promise; + } + + // Temporary disable link to search page + // TODO: Implement search page + link = () => null; + + // link = (query: string) => { + // const params = query ? `?query=${encodeURIComponent(query)}` : ''; + // + // return `${this.base}/${this.config.link}${params}`; + // }; + + private get base() { + return window.location.pathname + .split('/') + .slice(0, -(this.config.depth + 1)) + .join('/'); + } + + private async request(message: object) { + return request(await this.worker, message); + } +} + +async function initWorker(config: WorkerConfig) { + const worker = new Worker(new URL('../worker/index.ts', import.meta.url)); + + await request(worker, {...config, type: 'init'}); + + return worker; +} + +function request(worker: Worker, message: object) { + const channel = new MessageChannel(); + + return new Promise((resolve, reject) => { + channel.port1.onmessage = (message) => { + if (message.data.error) { + // eslint-disable-next-line no-console + console.error(message.data.error); + + reject(message.data.error); + } else { + resolve(message.data.result); + } + }; + + channel.port1.onmessageerror = (message) => { + reject(message.data.error); + }; + + worker.postMessage(message, [channel.port2]); + }); +} diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts new file mode 100644 index 0000000..e953e67 --- /dev/null +++ b/src/components/Search/types.ts @@ -0,0 +1,41 @@ +import {SearchSuggestPageItem} from '@diplodoc/components'; + +export interface SearchConfig { + api: string; + link: string; + lang: string; + depth: number; +} + +export interface WorkerConfig { + api: string; + base: string; + mark: string; +} + +export interface WorkerApi { + init?(): void | Promise; + suggest(query: string, count: number): Promise; + search(query: string, count: number, page: number): Promise; +} + +export type InitMessage = { + type: 'init'; +} & WorkerConfig; + +export type SuggestMessage = { + type: 'suggest'; + query: string; + count?: number; +}; + +export type SearchMessage = { + type: 'search'; + query: string; + page?: number; + count?: number; +}; + +export type Message = InitMessage | SuggestMessage | SearchMessage; + +export type MessageType = Message['type']; diff --git a/src/components/Search/useProvider.ts b/src/components/Search/useProvider.ts new file mode 100644 index 0000000..1b18ca7 --- /dev/null +++ b/src/components/Search/useProvider.ts @@ -0,0 +1,24 @@ +import {useContext, useMemo} from 'react'; + +import {RouterContext, SearchContext} from '../index'; +import {useLang} from '../../hooks/useLang'; + +import {SearchProvider} from './provider'; + +export function useProvider() { + const lang = useLang(); + const {depth = 0} = useContext(RouterContext); + const search = useContext(SearchContext); + + return useMemo(() => { + if (!search) { + return null; + } + + return new SearchProvider({ + ...search, + depth, + lang, + }); + }, [lang, depth, search]); +} diff --git a/src/components/Search/worker/index.ts b/src/components/Search/worker/index.ts new file mode 100644 index 0000000..ebaab71 --- /dev/null +++ b/src/components/Search/worker/index.ts @@ -0,0 +1,89 @@ +/// +/// +/// + +/* eslint-disable new-cap */ +import type { + InitMessage, + MessageType, + SearchMessage, + SuggestMessage, + WorkerApi, + WorkerConfig, +} from '../types'; + +// Default type of `self` is `WorkerGlobalScope & typeof globalThis` +// https://github.com/microsoft/TypeScript/issues/14877 +declare const self: ServiceWorkerGlobalScope & { + config?: WorkerConfig; + api?: WorkerApi; +}; + +const UNKNOWN_HANDLER = { + message: 'Unknown message type!', + code: 'UNKNOWN_HANDLER', +}; +const NOT_INITIALIZED_CONFIG = { + message: 'Worker is not initialized with required config!', + code: 'NOT_INITIALIZED', +}; +const NOT_INITIALIZED_API = { + message: 'Worker is not initialized with required api!', + code: 'NOT_INITIALIZED', +}; + +export function AssertConfig(config: unknown): asserts config is WorkerConfig { + if (!config) { + throw NOT_INITIALIZED_CONFIG; + } +} + +export function AssertApi(api: unknown): asserts api is WorkerApi { + if (!api) { + throw NOT_INITIALIZED_API; + } +} + +const HANDLERS = { + async init(config: InitMessage) { + self.config = config; + + importScripts(self.config.api); + + AssertApi(self.api); + + return self.api.init?.(); + }, + + async suggest({query, count = 10}: SuggestMessage) { + AssertConfig(self.config); + AssertApi(self.api); + + return self.api.suggest(query, count); + }, + + async search({query, count = 10, page = 1}: SearchMessage) { + AssertConfig(self.config); + AssertApi(self.api); + + return self.api.search(query, count, page); + }, +} as const; + +self.onmessage = async (message) => { + const [port] = message.ports; + const {type} = message.data; + + const handler = HANDLERS[type as MessageType]; + if (!handler) { + port.postMessage({error: UNKNOWN_HANDLER}); + } + + try { + const result = await handler(message.data); + + port.postMessage({result}); + } catch (error) { + port.postMessage({error}); + } +}; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..beb50d2 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export {SearchContext, SearchProvider} from './Search'; +export {RouterContext, RouterProvider, useRouter} from './Router'; + +export {App} from './App'; diff --git a/src/index.server.tsx b/src/index.server.tsx index 1a54958..b67fbba 100644 --- a/src/index.server.tsx +++ b/src/index.server.tsx @@ -6,6 +6,11 @@ import {LINK_KEYS, LINK_KEYS_LEADING_CONFIG, LINK_KEYS_PAGE_CONSTRUCTOR_CONFIG} import {preprocess} from './preprocess'; export type {DocInnerProps, DocPageData, DocLeadingPageData, DocAnalytics}; +export type { + SearchConfig as ISearchProviderConfig, + WorkerConfig as ISearchWorkerConfig, + WorkerApi as ISearchWorkerApi, +} from './components/Search'; export {LINK_KEYS, LINK_KEYS_LEADING_CONFIG, LINK_KEYS_PAGE_CONSTRUCTOR_CONFIG, preprocess}; export const render = (props: DocInnerProps) => renderToString(); diff --git a/src/index.tsx b/src/index.tsx index f451971..37ea63b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,11 @@ import {createRoot, hydrateRoot} from 'react-dom/client'; import {App, DocAnalytics, DocInnerProps, DocLeadingPageData, DocPageData} from './components/App'; export type {DocInnerProps, DocPageData, DocLeadingPageData, DocAnalytics}; +export type { + SearchConfig as ISearchProviderConfig, + WorkerConfig as ISearchWorkerConfig, + WorkerApi as ISearchWorkerApi, +} from './components/Search'; declare global { interface Window { diff --git a/src/search.tsx b/src/search.tsx new file mode 100644 index 0000000..00a12d1 --- /dev/null +++ b/src/search.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; + +import {Search} from './components/Search'; + +const root = document.getElementById('root'); + +if (!root) { + throw new Error('Root element not found!'); +} + +createRoot(root).render(); diff --git a/webpack/config.js b/webpack/config.js index 8bffc20..fe1ea52 100644 --- a/webpack/config.js +++ b/webpack/config.js @@ -6,36 +6,40 @@ const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); const RtlCssPlugin = require('./rtl-css'); +const EMPTY_MODULE = require.resolve('../src/stub/empty-module'); + function config({isServer, isDev, analyze = false}) { const mode = isServer ? 'server' : 'client'; const root = (...path) => resolve(__dirname, join('..', ...path)); const src = (...path) => root(join('src', ...path)); + const valuable = (object) => + Object.entries(object) + .filter(([, value]) => value) + .reduce((acc, [key, value]) => Object.assign(acc, {[key]: value}), {}); return { mode: isDev ? 'development' : 'production', target: isServer ? 'node' : 'web', devtool: 'source-map', - entry: { + entry: valuable({ app: isServer ? src('index.server.tsx') : src('index.tsx'), - }, + search: isServer ? null : src('search.tsx'), + }), cache: isDev && { type: 'filesystem', cacheDirectory: root(`cache`, mode), }, - output: { + output: valuable({ path: root('build', mode), filename: `[name].js`, - ...(isServer - ? { - libraryTarget: 'commonjs2', - } - : {}), - }, + libraryTarget: isServer ? 'commonjs2' : null, + }), resolve: { - alias: { + alias: valuable({ react: require.resolve('react'), - 'react-player': require.resolve('../src/stub/empty-module'), - }, + 'react-player': EMPTY_MODULE, + '@diplodoc/transform/dist/js/yfm': isServer ? null : EMPTY_MODULE, + }), fallback: { stream: false, crypto: false, @@ -48,7 +52,6 @@ function config({isServer, isDev, analyze = false}) { '.scss', ]), }, - externals: isServer ? ['@diplodoc/transform/dist/js/yfm'] : [], optimization: { minimize: !isServer, splitChunks: { @@ -56,13 +59,14 @@ function config({isServer, isDev, analyze = false}) { cacheGroups: { react: { test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, + priority: 10, name: 'react', chunks: 'all', }, vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendor', - chunks: 'all', + chunks: 'initial', }, }, }, @@ -85,18 +89,66 @@ function config({isServer, isDev, analyze = false}) { new WebpackManifestPlugin({ generate: (seed, files) => { const name = ({name}) => name; + const not = + (actor) => + (...args) => + !actor(...args); + const allOf = + (...actors) => + (...args) => + actors.every((actor) => actor(...args)); + const oneOf = + (...actors) => + (...args) => + actors.some((actor) => actor(...args)); + const isInitial = ({isInitial}) => isInitial; const endsWith = (tail) => ({name}) => name.endsWith(tail); + const byRuntime = (runtime) => (file) => { + if (!file.chunk || !file.chunk.runtime) { + return true; + } + + if (file.chunk.runtime.size) { + return file.chunk.runtime.has(runtime); + } + + return file.chunk?.runtime === runtime; + }; const runtimeLast = (a, b) => b.chunk?.id - a.chunk?.id; const appLast = (a, b) => a.chunk?.name.includes('app') - b.chunk?.name.includes('app'); - return { - js: files.filter(endsWith('.js')).sort(runtimeLast).map(name), - css: files.filter(endsWith('.css')).sort(appLast).map(name), - }; + const runtimes = {}; + for (const runtime of ['search', 'app']) { + runtimes[runtime] = { + async: files + .filter( + allOf( + not(isInitial), + not(oneOf(endsWith('.rtl.css'), endsWith('.rtl.js'))), + ), + ) + .filter(oneOf(endsWith('.css'), endsWith('.js'))) + .map(name), + js: files + .filter(oneOf(isInitial, endsWith('.rtl.js'))) + .filter(endsWith('.js')) + .filter(byRuntime(runtime)) + .sort(runtimeLast) + .map(name), + css: files + .filter(oneOf(isInitial, endsWith('.rtl.css'))) + .filter(endsWith('.css')) + .filter(byRuntime(runtime)) + .sort(appLast) + .map(name), + }; + } + + return runtimes; }, }), new RtlCssPlugin({