diff options
Diffstat (limited to 'dokka-subprojects/plugin-base-frontend/src/main/components')
14 files changed, 588 insertions, 0 deletions
diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.scss b/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.scss new file mode 100644 index 00000000..9bb25de9 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.scss @@ -0,0 +1,30 @@ +/*! + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +@import "src/main/scss/index.scss"; + +html, +.app-root { + height: 100%; +} + +.search-root { + margin: 0; + padding: 0; + + background: var(--ring-content-background-color); + + font-family: var(--ring-font-family); + font-size: var(--ring-font-size); + line-height: var(--ring-line-height); +} + +.search-content { + z-index: 8; +} + +@media screen and (max-width: 759px) { + .search-content { + } +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.tsx new file mode 100644 index 00000000..ea2a2e42 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/app/index.tsx @@ -0,0 +1,15 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React from 'react'; +import {WithFuzzySearchFilter} from '../search/search'; +import './index.scss'; + +const App: React.FC = () => { + return <div className="search-content"> + <WithFuzzySearchFilter/> + </div> +} + +export default App diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/assets/clear.svg b/dokka-subprojects/plugin-base-frontend/src/main/components/assets/clear.svg new file mode 100644 index 00000000..5c652c7f --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/assets/clear.svg @@ -0,0 +1,7 @@ +<!-- + - Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + --> + +<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.1374 1.80464L6.94205 5.99996L11.1374 10.1953L10.1947 11.138L5.99935 6.94267L1.80403 11.138L0.861328 10.1953L5.05664 5.99996L0.861328 1.80464L1.80403 0.861938L5.99935 5.05725L10.1947 0.861938L11.1374 1.80464Z" fill="#637282"/> +</svg> diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/assets/searchIcon.svg b/dokka-subprojects/plugin-base-frontend/src/main/components/assets/searchIcon.svg new file mode 100644 index 00000000..ef5f9c4f --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/assets/searchIcon.svg @@ -0,0 +1,7 @@ +<!-- + - Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + --> + +<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M23.8469 14.8611C22.6607 12.4913 20.2362 10.9961 17.5861 11C14.5427 10.9989 11.8476 12.965 10.9197 15.8634C9.99166 18.7619 11.0436 21.9277 13.5217 23.6944C15.9998 25.4611 19.3356 25.4233 21.7731 23.601L27.8791 29.707L29.2931 28.293L23.1861 22.187C24.7773 20.0677 25.0332 17.2309 23.8469 14.8611ZM22.5861 18C22.5861 20.7614 20.3475 23 17.5861 23C16.26 23 14.9882 22.4732 14.0506 21.5355C13.1129 20.5979 12.5861 19.3261 12.5861 18C12.5861 15.2386 14.8247 13 17.5861 13C20.3475 13 22.5861 15.2386 22.5861 18Z" fill="white"/> +</svg> diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/root.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/root.tsx new file mode 100644 index 00000000..93d070ce --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/root.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React from 'react'; +import {render} from 'react-dom'; + +import App from "./app"; +import './app/index.scss'; + + +const renderMainSearch = () => { + render(<App/>, document.getElementById('searchBar')); +} + +let renderApp = () => { + renderMainSearch(); + + document.removeEventListener('DOMContentLoaded', renderApp); +}; + +document.addEventListener('DOMContentLoaded', renderApp); diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaFuzzyFilter.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaFuzzyFilter.tsx new file mode 100644 index 00000000..0a7edcb3 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaFuzzyFilter.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import Select from '@jetbrains/ring-ui/components/select/select'; +import {Option, OptionWithHighlightComponent, OptionWithSearchResult} from "./types"; +import fuzzyHighlight from '@jetbrains/ring-ui/components/global/fuzzy-highlight.js' +import React from "react"; +import {SearchResultRow} from "./searchResultRow"; +import _ from "lodash"; + +const orderRecords = (records: OptionWithSearchResult[], searchPhrase: string): OptionWithSearchResult[] => { + return records.sort((a: OptionWithSearchResult, b: OptionWithSearchResult) => { + //Prefer higher rank + const byRank = a.rank - b.rank + if(byRank !== 0){ + return byRank + } + //Prefer exact matches + const aIncludes = a.name.toLowerCase().includes(searchPhrase.toLowerCase()) ? 1 : 0 + const bIncludes = b.name.toLowerCase().includes(searchPhrase.toLowerCase()) ? 1 : 0 + const byIncludes = bIncludes - aIncludes + if(byIncludes != 0){ + return byIncludes + } + + //Prefer matches that are closer + const byFirstMatchedPosition = a.highlight.indexOf("**") - b.highlight.indexOf("**") + if(byFirstMatchedPosition == 0) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + } + return byFirstMatchedPosition + }) +} + +const highlightMatchedPhrases = (records: OptionWithSearchResult[]): OptionWithHighlightComponent[] => { + // @ts-ignore + return records.map(record => { + return { + ...record, + template: <SearchResultRow searchResult={record}/> + } + }) +} +export class DokkaFuzzyFilterComponent extends Select { + componentDidUpdate(prevProps, prevState) { + super.componentDidUpdate(prevProps, prevState) + if(this.props.filter && this.state.filterValue != this.props.filter.value){ + this.setState({ + filterValue: this.props.filter.value, + }) + } + } + + _showPopup(){ + if(this.props.shouldShowPopup){ + if (!this.node) { + return; + } + + const shownData = this.getListItems(this.filterValue()); + this.setState({ + showPopup: this.props.shouldShowPopup(this.filterValue()), + shownData + }) + } else { + super._showPopup() + } + } + + getListItems(rawFilterString: string, e: Option[]) { + const filterPhrase = (rawFilterString ? rawFilterString : '').trim() + const matchedRecords = this.props.data + .map((record: Option) => { + const searched = record.searchKeys.map((value, index) => { + return { + ...fuzzyHighlight(filterPhrase, value, false), + ...record, + rank: index + } + }).filter((e) => e.matched) + + const first = _.head(searched) + + if(first){ + return first + } + + return { + matched: false, + ...record, + } + + }) + .filter((record: OptionWithSearchResult) => record.matched) + + this.props.onFilter(filterPhrase) + + return highlightMatchedPhrases(orderRecords(matchedRecords, filterPhrase)) + } +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaSearchAnchor.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaSearchAnchor.tsx new file mode 100644 index 00000000..f7c6cf46 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/dokkaSearchAnchor.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React from "react"; +import Tooltip from '@jetbrains/ring-ui/components/tooltip/tooltip'; +import SearchIcon from 'react-svg-loader!../assets/searchIcon.svg'; +import {CustomAnchorProps} from "./types"; +import {Hotkey} from "../utils/hotkey"; + +const HOTKEY_LETTER = 'k' +const HOTKEY_TOOLTIP_DISPLAY_DELAY = 0.5 * 1000 // seconds + +export const DokkaSearchAnchor = ({wrapperProps, buttonProps, popup}: CustomAnchorProps) => { + const hotkeys = new Hotkey() + hotkeys.registerHotkeyWithAccel(buttonProps.onClick, HOTKEY_LETTER) + + return ( + <span {...wrapperProps}> + <Tooltip + title={`${hotkeys.getOsAccelKeyName()} + ${HOTKEY_LETTER.toUpperCase()}`} + delay={HOTKEY_TOOLTIP_DISPLAY_DELAY} + popupProps={{className: "search-hotkey-popup"}} + > + <button type="button" {...buttonProps}> + <SearchIcon/> + </button> + </Tooltip> + {popup} + </span> + ) +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.scss b/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.scss new file mode 100644 index 00000000..6dd07d5b --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.scss @@ -0,0 +1,118 @@ +/*! + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +$font-color: hsla(0, 0%, 100%, 0.8); +$secondary-font-color: hsla(0, 0%, 100%, 0.6); + +#pages-search { + cursor: pointer; + border: none; + border-radius: 50%; + background: transparent; + fill: #fff; + fill: var(--dark-mode-and-search-icon-color); + + &:focus { + outline: none; + } + + &:hover { + background: var(--white-10); + } +} + +.search { + &, [data-test="ring-select"], [data-test="ring-tooltip"], [data-test="ring-select_focus"], #pages-search { + display: inline-block; + padding: 0; + margin: 0; + font-size: 0; + line-height: 0; + } +} + +.search-hotkey-popup { + background-color: var(--background-color) !important; + padding: 4px; +} + +.popup-wrapper { + min-width: calc(100% - 322px) !important; + + border: 1px solid hsla(0, 0%, 100%, 0.2) !important; + + background-color: #27282c !important; + + [class^="filterWrapper"] { + border-bottom: 1px solid hsla(0, 0%, 100%, 0.2); + } + + input { + color: $font-color !important; + + font-weight: normal !important; + } + + span[data-test-custom="ring-select-popup-filter-icon"] { + color: #fff; + } + + button[data-test="ring-input-clear"] { + color: #fff !important; + } +} + +@media screen and (max-width: 759px) { + .popup-wrapper { + min-width: 100% !important; + } +} + +.template-wrapper { + display: grid; + + height: 32px; + grid-template-columns: auto auto; + + strong { + color: $font-color; + } + + span { + color: $font-color; + + line-height: 32px; + + &.template-description { + color: $secondary-font-color; + justify-self: end; + } + } +} + +@media screen and (max-width: 759px) { + .template-wrapper { + display: flex; + flex-direction: column; + + height: auto; + + span { + line-height: unset; + } + } +} + +.template-name { + justify-self: start; +} + +/* remove fade at the bottom */ +[class^="fade"] { + display: none; +} + +[class*="hover"] { + background-color: hsla(0, 0%, 100%, 0.1) !important; +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.tsx new file mode 100644 index 00000000..24545671 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/search.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React, {useCallback, useEffect, useState} from 'react'; +import List from '@jetbrains/ring-ui/components/list/list'; +import Select from '@jetbrains/ring-ui/components/select/select'; +import '@jetbrains/ring-ui/components/input-size/input-size.css'; +import './search.scss'; +import {CustomAnchorProps, IWindow, Option, Props} from "./types"; +import {DokkaSearchAnchor} from "./dokkaSearchAnchor"; +import {DokkaFuzzyFilterComponent} from "./dokkaFuzzyFilter"; +import {relativizeUrlForRequest} from '../utils/requests'; + +const WithFuzzySearchFilterComponent: React.FC<Props> = ({ data }: Props) => { + const [selected, onSelected] = useState<Option>(data[0]); + const onChangeSelected = useCallback( + (option: Option) => { + window.location.replace(`${(window as IWindow).pathToRoot}${option.location}?query=${option.name}`) + onSelected(option); + }, + [data] + ); + + return ( + <div className="search-container"> + <div className="search"> + <DokkaFuzzyFilterComponent + id="pages-search" + selectedLabel="Search" + label="Please type page name" + filter={true} + type={Select.Type.CUSTOM} + clear + renderOptimization + disableScrollToActive + selected={selected} + data={data} + popupClassName={"popup-wrapper"} + onSelect={onChangeSelected} + customAnchor={({ wrapperProps, buttonProps, popup }: CustomAnchorProps) => + <DokkaSearchAnchor wrapperProps={wrapperProps} buttonProps={buttonProps} popup={popup} /> + } + /> + </div> + </div> + ) +} + +export const WithFuzzySearchFilter = () => { + const [navigationList, setNavigationList] = useState<Option[]>([]); + + useEffect(() => { + fetch(relativizeUrlForRequest('scripts/pages.json')) + .then(response => response.json()) + .then((result) => { + setNavigationList(result.map((record: Option, idx: number) => { + return { + ...record, + label: record.name, + key: idx, + type: record.kind, + rgItemType: List.ListProps.Type.CUSTOM + } + })) + }, + (error) => { + console.error('failed to fetch pages data', error) + setNavigationList([]) + }) + }, []) + + return <WithFuzzySearchFilterComponent data={navigationList} />; +}; diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/searchResultRow.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/search/searchResultRow.tsx new file mode 100644 index 00000000..e8b91519 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/searchResultRow.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React from "react"; +import {OptionWithSearchResult, SearchProps} from "./types"; +import _ from "lodash"; + +type HighlighterProps = { + label: string +} + +const Highlighter: React.FC<HighlighterProps> = ({label}: HighlighterProps) => { + return <strong>{label}</strong> +} + +export const signatureFromSearchResult = (searchResult: OptionWithSearchResult): string => { + return searchResult.name.replace(searchResult.searchKeys[searchResult.rank], searchResult.highlight) +} + +export const SearchResultRow: React.FC<SearchProps> = ({searchResult}: SearchProps) => { + /* + This is a work-around for an issue: https://youtrack.jetbrains.com/issue/RG-2108 + */ + const out = _.chunk(signatureFromSearchResult(searchResult).split('**'), 2).flatMap(([txt, label]) => [ + txt, + label ? <Highlighter label={label}></Highlighter> : null, + ]); + + return ( + <div className="template-wrapper"> + <span>{out}</span> + <span className="template-description">{searchResult.description}</span> + </div> + ) +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/search/types.ts b/dokka-subprojects/plugin-base-frontend/src/main/components/search/types.ts new file mode 100644 index 00000000..3e390dde --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/search/types.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import React, {ButtonHTMLAttributes, HTMLAttributes, ReactNode, RefCallback} from "react"; + +export type Page = { + name: string; + kind: string; + location: string; + searchKeys: string[]; + description: string; +} + +export type Option = Page & { + label: string; + key: number; + location: string; + name: string; +} + +export type IWindow = typeof window & { + pathToRoot: string + pages: Page[] +} + +export type Props = { + data: Option[] +}; + +export type OptionWithSearchResult = Option & { + matched: boolean, + highlight: string, + rank: number +} + +export type OptionWithHighlightComponent = Option & { + name: React.FC<SearchProps> +} + +export type SearchProps = { + searchResult: OptionWithSearchResult, +} + +export interface DataTestProps { + 'data-test'?: string | null | undefined +} + +export interface CustomAnchorProps { + wrapperProps: HTMLAttributes<HTMLElement> & DataTestProps & {ref: RefCallback<HTMLElement>} + buttonProps: Pick<ButtonHTMLAttributes<HTMLButtonElement>, 'id' | 'disabled' | 'children'> & + {onClick: () => void} & + DataTestProps, + popup: ReactNode +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/utils/hotkey.ts b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/hotkey.ts new file mode 100644 index 00000000..ae813166 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/hotkey.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import {detectOsKind, OsKind} from "./os"; + +type ModifierKey = { + name: string + keyArg: string +} + +class ModifierKeys { + static metaKey: ModifierKey = {name: "Command", keyArg: "Meta"} + static ctrlKey: ModifierKey = {name: "Ctrl", keyArg: "Control"} + static altKey: ModifierKey = {name: "Alt", keyArg: "Alt"} + static shiftKey: ModifierKey = {name: "Shift", keyArg: "Shift"} +} + +const setOfKeys = [ModifierKeys.altKey, ModifierKeys.shiftKey, ModifierKeys.ctrlKey, ModifierKeys.metaKey] + +export class Hotkey { + private readonly osKind: OsKind; + + constructor() { + this.osKind = detectOsKind() + } + + public getOsAccelKeyName() { + return this.getOsAccelKey().name + } + + /** + * Register a hotkey of combination Accel key (Cmd/Ctrl depending on OS). + * The method also checks that other modifiers key is not pressed to avoid shortcuts intersection. + * E.g. don't trigger [Ctrl+K] if [Ctrl + Shift + K] pressed + */ + public registerHotkeyWithAccel = (event: () => void, letter: string) => { + const osMetaKey = this.getOsAccelKey() + document.onkeydown = (keyDownEvent) => { + const isMetaKeyPressed = keyDownEvent.getModifierState(osMetaKey.keyArg) + const isOtherModifierKeyPressed = setOfKeys + .filter(key => key !== osMetaKey) + .map((otherKeys: ModifierKey) => keyDownEvent.getModifierState(otherKeys.keyArg)) + .some(value => value) + + if (isMetaKeyPressed && !isOtherModifierKeyPressed && keyDownEvent.key === letter) { + keyDownEvent.preventDefault() + event() + } + }; + } + + private getOsAccelKey(): ModifierKey { + switch (this.osKind) { + case OsKind.MACOS: + return ModifierKeys.metaKey + default: + return ModifierKeys.ctrlKey + } + } +} + diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/utils/os.ts b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/os.ts new file mode 100644 index 00000000..6130ef67 --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/os.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +export enum OsKind{ + WINDOWS, + MACOS, + LINUX, + OTHER +} + +export const detectOsKind = (): OsKind => { + const userAgent = navigator.userAgent + if(userAgent.includes("Mac")) return OsKind.MACOS + else if (userAgent.includes("Win")) return OsKind.WINDOWS + else if (userAgent.includes("Linux")) return OsKind.LINUX + else return OsKind.OTHER +} diff --git a/dokka-subprojects/plugin-base-frontend/src/main/components/utils/requests.tsx b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/requests.tsx new file mode 100644 index 00000000..568c331b --- /dev/null +++ b/dokka-subprojects/plugin-base-frontend/src/main/components/utils/requests.tsx @@ -0,0 +1,11 @@ +/* + * Copyright 2014-2023 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license. + */ + +import {IWindow} from "../search/types" + +export const relativizeUrlForRequest = (filePath: string) : string => { + const pathToRoot = (window as IWindow).pathToRoot + const relativePath = pathToRoot == "" ? "." : pathToRoot + return relativePath.endsWith('/') ? `${relativePath}${filePath}` : `${relativePath}/${filePath}` +} |