mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 11:26:31 +00:00
Toggleable layout
This commit is contained in:
@@ -1,21 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
import { useSearch, useSearchLink } from './useSearch';
|
||||
|
||||
export type SearchAskState =
|
||||
| {
|
||||
type: 'answer';
|
||||
@@ -88,13 +86,22 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
}, [setAskState]);
|
||||
|
||||
const loading = (
|
||||
<div className={tcls('w-full', 'flex', 'items-center', 'justify-center')}>
|
||||
<Loading className={tcls('w-6', 'py-8', 'text-primary-subtle')} />
|
||||
<div key="loading" className={tcls('flex', 'flex-wrap', 'gap-2')}>
|
||||
{[...Array(9)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-4 animate-[fadeIn_0.5s_ease-in-out_both,pulse_2s_ease-in-out_infinite] rounded straight-corners:rounded-none bg-tint-active"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s,${0.5 + index * 0.1}s`,
|
||||
width: `${((index % 5) + 1) * 15}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div className={tcls('mx-auto w-full max-w-prose')} layout="position">
|
||||
{askState?.type === 'answer' ? (
|
||||
<React.Suspense fallback={loading}>
|
||||
<TransitionAnswerBody answer={askState.answer} placeholder={loading} />
|
||||
@@ -104,7 +111,7 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
<div className={tcls('p-4')}>{t(language, 'search_ask_error')}</div>
|
||||
) : null}
|
||||
{askState?.type === 'loading' ? loading : null}
|
||||
</>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,10 +145,7 @@ function AnswerBody(props: { answer: AskAnswerResult }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-testid="search-ask-answer"
|
||||
className={tcls('my-4', 'sm:mt-6', 'px-4', 'sm:px-12', 'text-tint-strong')}
|
||||
>
|
||||
<div data-testid="search-ask-answer" className={tcls('text-tint-strong')}>
|
||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||
{answer.followupQuestions.length > 0 ? (
|
||||
<AnswerFollowupQuestions followupQuestions={answer.followupQuestions} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Button } from '../primitives/Button';
|
||||
import { LoadingPane } from '../primitives/LoadingPane';
|
||||
import { SearchAskAnswer } from './SearchAskAnswer';
|
||||
import { SearchAskProvider, useSearchAskState } from './SearchAskContext';
|
||||
@@ -33,7 +34,7 @@ export function SearchModal(props: SearchModalProps) {
|
||||
'mod+k',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setSearchState({ ask: false, query: '', global: false });
|
||||
setSearchState({ mode: 'both', query: '', global: false });
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -177,7 +178,7 @@ function SearchModalBody(
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchState({
|
||||
ask: false, // When typing, we go back to the default search mode
|
||||
mode: 'both', // When typing, we go back to the default search mode
|
||||
query: event.target.value,
|
||||
global: state.global,
|
||||
});
|
||||
@@ -288,18 +289,59 @@ function SearchModalBody(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto md:col-start-1 md:row-start-2">
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto border-tint-subtle bg-tint-subtle max-md:border-t md:col-start-2 md:row-start-2 md:border-l">
|
||||
<SearchAskAnswer query={normalizedQuery} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{state.mode !== 'chat' ? (
|
||||
<motion.div
|
||||
key="results"
|
||||
layout
|
||||
className={tcls(
|
||||
'overflow-y-auto md:col-start-1 md:row-start-2',
|
||||
state.mode === 'results' && 'md:-col-end-1'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: '100%' }}
|
||||
exit={{ width: 0 }}
|
||||
>
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
global={isMultiVariants && state.global}
|
||||
query={normalizedQuery}
|
||||
withAsk={withAsk}
|
||||
onSwitchToAsk={onSwitchToAsk}
|
||||
/>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
{state.mode !== 'results' ? (
|
||||
<motion.div
|
||||
key="chat"
|
||||
layout
|
||||
className={tcls(
|
||||
'-col-end-1 flex items-start gap-4 overflow-y-auto overflow-x-hidden border-tint-subtle bg-tint-subtle p-8 max-md:border-t md:row-start-2 md:border-l',
|
||||
state.mode === 'chat' && 'md:col-start-1'
|
||||
)}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: '100%' }}
|
||||
exit={{ width: 0 }}
|
||||
>
|
||||
{state.mode === 'chat' ? (
|
||||
<Button
|
||||
icon="right-from-line"
|
||||
iconOnly
|
||||
label="Show results"
|
||||
variant="blank"
|
||||
className="px-2"
|
||||
onClick={() => {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, mode: 'both', manual: true } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SearchAskAnswer query={normalizedQuery} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import React from 'react';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Loading } from '../primitives';
|
||||
import { SearchPageResultItem } from './SearchPageResultItem';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
searchSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -61,6 +63,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
}>({ results: [], fetching: true });
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const refs = React.useRef<(null | HTMLAnchorElement)[]>([]);
|
||||
const [searchState, setSearchState] = useSearch();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!query) {
|
||||
@@ -157,9 +160,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
setCursor(null);
|
||||
} else if (results.length > 0) {
|
||||
// Auto-focus the first result
|
||||
setSearchState((prev) => (prev ? { ...prev, mode: 'both' } : null));
|
||||
setCursor(0);
|
||||
} else if (results.length === 0 && !resultsState.fetching && !searchState?.manual) {
|
||||
setSearchState((prev) => (prev ? { ...prev, mode: 'chat' } : null));
|
||||
}
|
||||
}, [results, query]);
|
||||
}, [results, query, setSearchState, resultsState.fetching, searchState?.manual]);
|
||||
|
||||
// Scroll to the active result.
|
||||
React.useEffect(() => {
|
||||
@@ -210,9 +216,12 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
|
||||
if (resultsState.fetching) {
|
||||
return (
|
||||
<div className={tcls('flex', 'items-center', 'justify-center', 'p-8')}>
|
||||
<motion.div
|
||||
className={tcls('flex', 'items-center', 'justify-center', 'p-8')}
|
||||
layout="position"
|
||||
>
|
||||
<Loading className={tcls('w-6', 'text-primary-subtle')} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,16 +302,3 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a "Ask <question>" item at the top of the results list.
|
||||
*/
|
||||
function withQuestionResult(results: ResultType[], query: string): ResultType[] {
|
||||
const without = results.filter((result) => result.type !== 'question');
|
||||
|
||||
if (query.length === 0) {
|
||||
return without;
|
||||
}
|
||||
|
||||
return [{ type: 'question', id: 'question', query }, ...(without ?? [])];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
ask: boolean;
|
||||
global: boolean;
|
||||
mode: 'results' | 'chat' | 'both';
|
||||
manual?: boolean;
|
||||
}
|
||||
|
||||
// KeyMap needs to be statically defined to avoid `setRawState` being redefined on every render.
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsBoolean,
|
||||
mode: parseAsStringEnum(['both', 'results', 'chat']).withDefault('both'),
|
||||
global: parseAsBoolean,
|
||||
manual: parseAsBoolean,
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -33,7 +35,12 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { query: rawState.q, ask: !!rawState.ask, global: !!rawState.global };
|
||||
return {
|
||||
query: rawState.q,
|
||||
mode: rawState.mode,
|
||||
global: !!rawState.global,
|
||||
manual: !!rawState.manual,
|
||||
};
|
||||
}, [rawState]);
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
@@ -52,14 +59,16 @@ export function useSearch(): [SearchState | null, UpdateSearchState] {
|
||||
if (update === null) {
|
||||
return setRawState({
|
||||
q: null,
|
||||
ask: null,
|
||||
mode: null,
|
||||
global: null,
|
||||
manual: null,
|
||||
});
|
||||
}
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask ? true : null,
|
||||
mode: update.mode,
|
||||
global: update.global ? true : null,
|
||||
manual: update.manual ? true : null,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -78,8 +87,9 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
(query) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('q', query.query ?? '');
|
||||
query.ask ? searchParams.set('ask', 'on') : searchParams.delete('ask');
|
||||
query.mode ? searchParams.set('mode', query.mode) : searchParams.delete('mode');
|
||||
query.global ? searchParams.set('global', 'on') : searchParams.delete('global');
|
||||
searchParams.delete('manual');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -87,7 +97,7 @@ export function useSearchLink(): (query: Partial<SearchState>) => LinkProps {
|
||||
event.preventDefault();
|
||||
setSearch((prev) => ({
|
||||
query: '',
|
||||
ask: false,
|
||||
mode: 'both',
|
||||
global: false,
|
||||
...(prev ?? {}),
|
||||
...query,
|
||||
|
||||
Reference in New Issue
Block a user