mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 10:33:22 +00:00
e5fc5c2d2a
* V1 code blocks fix * equalize modal text label + fix safari bug with fixed position stroke * code block adjustments * fix search result spacing
256 lines
8.4 KiB
TypeScript
256 lines
8.4 KiB
TypeScript
'use client';
|
|
|
|
import IconSearch from '@geist-ui/icons/search';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { useRouter } from 'next/navigation';
|
|
import React from 'react';
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
|
import { useRecoilValue } from 'recoil';
|
|
|
|
import { tString, useLanguage } from '@/intl/client';
|
|
import { tcls } from '@/lib/tailwind';
|
|
|
|
import { SearchAskAnswer, searchAskState } from './SearchAskAnswer';
|
|
import { SearchResults, SearchResultsRef } from './SearchResults';
|
|
import { SearchScopeToggle } from './SearchScopeToggle';
|
|
import { SearchState, useSearch } from './useSearch';
|
|
import { LoadingPane } from '../primitives/LoadingPane';
|
|
|
|
interface SearchModalProps {
|
|
spaceId: string;
|
|
spaceTitle: string;
|
|
collectionId: string | null;
|
|
withAsk: boolean;
|
|
}
|
|
|
|
export function SearchModal(props: SearchModalProps) {
|
|
const [state, setSearchState] = useSearch();
|
|
const askState = useRecoilValue(searchAskState);
|
|
const router = useRouter();
|
|
|
|
useHotkeys(
|
|
'mod+k',
|
|
(e) => {
|
|
e.preventDefault();
|
|
setSearchState({ ask: false, query: '', global: false });
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Add a global class on the body when the search modal is open
|
|
const isSearchOpened = state !== null;
|
|
React.useEffect(() => {
|
|
if (isSearchOpened) {
|
|
document.body.classList.add('search-open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
return () => {
|
|
document.body.classList.remove('search-open');
|
|
document.body.style.overflow = 'auto';
|
|
};
|
|
}, [isSearchOpened]);
|
|
|
|
if (state === null) {
|
|
return null;
|
|
}
|
|
|
|
const onChangeQuery = (newQuery: SearchState) => {
|
|
setSearchState(newQuery);
|
|
};
|
|
|
|
const onClose = async (to?: string) => {
|
|
await setSearchState(null);
|
|
if (to) {
|
|
router.push(to);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
role="dialog"
|
|
className={tcls(
|
|
'flex',
|
|
'items-start',
|
|
'justify-center',
|
|
'fixed',
|
|
'inset-0',
|
|
'bg-dark/4',
|
|
'backdrop-blur-2xl',
|
|
'opacity-[1]',
|
|
'z-30',
|
|
'px-4',
|
|
'pt-4',
|
|
'dark:bg-dark/8',
|
|
'md:pt-[min(8vw,_6rem)]',
|
|
)}
|
|
onClick={() => {
|
|
onClose();
|
|
}}
|
|
>
|
|
<AnimatePresence>
|
|
{askState?.type === 'loading' ? (
|
|
<motion.div
|
|
key="loading"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 1 }}
|
|
className={tcls(
|
|
'w-[100vw]',
|
|
'h-[100vh]',
|
|
'fixed',
|
|
'inset-0',
|
|
'z-10',
|
|
'pointer-events-none',
|
|
)}
|
|
>
|
|
<LoadingPane
|
|
gridStyle={['h-[100vh]', 'aspect-auto', 'top-[-30%]']}
|
|
pulse
|
|
tile={96}
|
|
style={['grid']}
|
|
/>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
<SearchModalBody
|
|
{...props}
|
|
state={state}
|
|
onChangeQuery={onChangeQuery}
|
|
onClose={onClose}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SearchModalBody(
|
|
props: SearchModalProps & {
|
|
state: SearchState;
|
|
onChangeQuery: (newQuery: SearchState) => void;
|
|
onClose: (to?: string) => void;
|
|
},
|
|
) {
|
|
const { spaceId, spaceTitle, withAsk, collectionId, state, onChangeQuery, onClose } = props;
|
|
|
|
const language = useLanguage();
|
|
const resultsRef = React.useRef<SearchResultsRef>(null);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
React.useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === 'Escape') {
|
|
onClose();
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
resultsRef.current?.moveUp();
|
|
} else if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
resultsRef.current?.moveDown();
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
resultsRef.current?.select();
|
|
}
|
|
};
|
|
|
|
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
onChangeQuery({
|
|
ask: false, // When typing, we go back to the default search mode
|
|
query: event.target.value,
|
|
global: state.global,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label={tString(language, 'search')}
|
|
className={tcls(
|
|
'z-40',
|
|
'flex',
|
|
'flex-col',
|
|
'bg-white',
|
|
'max-w-[768px]',
|
|
'mt-[-1px]',
|
|
'w-full',
|
|
'max-h',
|
|
'rounded-lg',
|
|
'ring-1',
|
|
'ring-dark/1',
|
|
'shadow-2xl',
|
|
'backdrop-blur-lg',
|
|
'overflow-hidden',
|
|
'dark:ring-inset',
|
|
'dark:bg-dark-4',
|
|
'dark:ring-light/2',
|
|
)}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
>
|
|
<div className={tcls('flex', 'flex-row', 'items-center')}>
|
|
<div className={tcls('text-dark/4', 'p-3', 'dark:text-light/5')}>
|
|
<IconSearch className={tcls('w-6', 'h-6')} />
|
|
</div>
|
|
<div className={tcls('flex-1')}>
|
|
<input
|
|
ref={inputRef}
|
|
value={state.query}
|
|
onKeyDown={onKeyDown}
|
|
onChange={onChange}
|
|
className={tcls(
|
|
'text-dark',
|
|
'placeholder:text-dark/7',
|
|
'flex',
|
|
'resize-none',
|
|
'w-full',
|
|
'h-12',
|
|
'p-2',
|
|
'focus:outline-none',
|
|
'bg-transparent',
|
|
'whitespace-pre-line',
|
|
'dark:text-light',
|
|
'dark:placeholder:text-light/7',
|
|
)}
|
|
placeholder={tString(language, 'search_input_placeholder')}
|
|
spellCheck="false"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!state.ask || !withAsk ? (
|
|
<>
|
|
<SearchResults
|
|
ref={resultsRef}
|
|
spaceId={spaceId}
|
|
collectionId={state.global ? collectionId : null}
|
|
query={state.query}
|
|
withAsk={withAsk}
|
|
onSwitchToAsk={() => {
|
|
onChangeQuery({
|
|
ask: true,
|
|
query: state.query,
|
|
global: state.global,
|
|
});
|
|
}}
|
|
onClose={onClose}
|
|
>
|
|
{collectionId && state.query ? (
|
|
<SearchScopeToggle spaceTitle={spaceTitle} />
|
|
) : null}
|
|
</SearchResults>
|
|
</>
|
|
) : null}
|
|
{state.query && state.ask && withAsk ? (
|
|
<SearchAskAnswer spaceId={spaceId} query={state.query} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|