Skip to content

Commit f87db08

Browse files
committed
Add AIActionsDropdown
1 parent 09b689b commit f87db08

File tree

5 files changed

+208
-18
lines changed

5 files changed

+208
-18
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
'use client';
2+
import { useAIChatController, useAIChatState } from '@/components/AI/useAIChat';
3+
import AIChatIcon from '@/components/AIChat/AIChatIcon';
4+
import { Button } from '@/components/primitives/Button';
5+
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives/DropdownMenu';
6+
import { tString, useLanguage } from '@/intl/client';
7+
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
8+
import { useEffect, useRef } from 'react';
9+
10+
type Action = {
11+
icon?: IconName | React.ReactNode;
12+
label: string;
13+
description?: string;
14+
/**
15+
* Whether the action is an external link.
16+
*/
17+
isExternal?: boolean;
18+
onClick?: () => void;
19+
};
20+
21+
export function AIActionsDropdown() {
22+
const chatController = useAIChatController();
23+
const chat = useAIChatState();
24+
const language = useLanguage();
25+
const ref = useRef<HTMLDivElement>(null);
26+
27+
const handleOpenAIAssistant = () => {
28+
// Open the chat if it's not already open
29+
if (!chat.opened) {
30+
chatController.open();
31+
}
32+
33+
// Send the "What is this page about?" message
34+
chatController.postMessage({
35+
message: tString(language, 'ai_chat_suggested_questions_about_this_page'),
36+
});
37+
};
38+
39+
const handleCopyPage = async () => {
40+
const markdownUrl = `${window.location.href}.md`;
41+
42+
// Get the page content
43+
const markdown = await fetch(markdownUrl).then((res) => res.text());
44+
45+
// Copy the markdown to the clipboard
46+
navigator.clipboard.writeText(markdown);
47+
};
48+
49+
const handleViewAsMarkdown = () => {
50+
// Open the page in Markdown format
51+
const currentUrl = window.location.href;
52+
const markdownUrl = `${currentUrl}.md`;
53+
window.open(markdownUrl, '_blank');
54+
};
55+
56+
const actions: Action[] = [
57+
{
58+
icon: 'copy',
59+
label: 'Copy page',
60+
description: 'Copy the page content',
61+
onClick: handleCopyPage,
62+
},
63+
{
64+
icon: 'markdown',
65+
label: 'View as Markdown',
66+
description: 'Open a Markdown version of this page',
67+
isExternal: true,
68+
onClick: handleViewAsMarkdown,
69+
},
70+
];
71+
72+
// Get the header width with title and check if there is enough space to show the dropdown
73+
useEffect(() => {
74+
const getHeaderAvailableSpace = () => {
75+
const header = document.getElementById('page-header');
76+
const headerTitle = header?.getElementsByTagName('h1')[0];
77+
78+
return (
79+
(header?.getBoundingClientRect().width ?? 0) -
80+
(headerTitle?.getBoundingClientRect().width ?? 0)
81+
);
82+
};
83+
84+
const dropdownWidth = 202;
85+
86+
window.addEventListener('resize', () => {
87+
const headerAvailableSpace = getHeaderAvailableSpace();
88+
if (ref.current) {
89+
if (headerAvailableSpace <= dropdownWidth) {
90+
ref.current.classList.add('-mt-3');
91+
ref.current.classList.remove('mt-3');
92+
} else {
93+
ref.current.classList.remove('-mt-3');
94+
ref.current.classList.add('mt-3');
95+
}
96+
}
97+
});
98+
99+
window.addEventListener('load', () => {
100+
const headerAvailableSpace = getHeaderAvailableSpace();
101+
if (ref.current) {
102+
if (headerAvailableSpace <= dropdownWidth) {
103+
ref.current.classList.add('-mt-3');
104+
ref.current.classList.remove('mt-3');
105+
} else {
106+
ref.current.classList.remove('-mt-3');
107+
ref.current.classList.add('mt-3');
108+
}
109+
}
110+
});
111+
}, []);
112+
113+
return (
114+
<div ref={ref} className="hidden items-stretch justify-start md:flex">
115+
<Button
116+
icon={<AIChatIcon className="size-3.5" />}
117+
size="small"
118+
variant="secondary"
119+
label="Ask Docs Assistant"
120+
className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm"
121+
onClick={handleOpenAIAssistant}
122+
/>
123+
<DropdownMenu
124+
contentProps={{
125+
align: 'end',
126+
}}
127+
button={
128+
<Button
129+
icon="chevron-down"
130+
iconOnly
131+
size="small"
132+
variant="secondary"
133+
className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm"
134+
/>
135+
}
136+
>
137+
{actions.map((action, index) => (
138+
<DropdownMenuItem
139+
onClick={action.onClick}
140+
key={index}
141+
className="flex items-stretch gap-2 p-1.5"
142+
>
143+
{action.icon ? (
144+
<div className="mt-0.5 flex size-3.5 items-center">
145+
{typeof action.icon === 'string' ? (
146+
<Icon
147+
icon={action.icon as IconName}
148+
iconStyle={IconStyle.Light}
149+
className="size-full"
150+
/>
151+
) : (
152+
action.icon
153+
)}
154+
</div>
155+
) : null}
156+
<div className="flex flex-1 flex-col">
157+
<span className="flex items-center gap-1.5 text-tint-strong">
158+
<span className="truncate font-medium">{action.label}</span>
159+
{action.isExternal ? (
160+
<Icon icon="arrow-up-right" className="size-3" />
161+
) : null}
162+
</span>
163+
{action.description && (
164+
<span className="truncate text-tint text-xs">
165+
{action.description}
166+
</span>
167+
)}
168+
</div>
169+
</DropdownMenuItem>
170+
))}
171+
</DropdownMenu>
172+
</div>
173+
);
174+
}

packages/gitbook/src/components/PageBody/PageHeader.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import type { RevisionPageDocument } from '@gitbook/api';
2-
import { Icon } from '@gitbook/icons';
3-
import { Fragment } from 'react';
4-
1+
import { AIActionsDropdown } from '@/components/AIActions/AIActionsDropdown';
52
import type { GitBookSiteContext } from '@/lib/context';
63
import type { AncestorRevisionPage } from '@/lib/pages';
74
import { tcls } from '@/lib/tailwind';
8-
5+
import type { RevisionPageDocument } from '@gitbook/api';
6+
import { Icon } from '@gitbook/icons';
7+
import { Fragment } from 'react';
98
import { PageIcon } from '../PageIcon';
109
import { StyledLink } from '../primitives';
1110

@@ -23,13 +22,15 @@ export async function PageHeader(props: {
2322

2423
return (
2524
<header
25+
id="page-header"
2626
className={tcls(
2727
'max-w-3xl',
2828
'page-full-width:max-w-screen-2xl',
2929
'mx-auto',
3030
'mb-6',
3131
'space-y-3',
32-
'page-api-block:ml-0'
32+
'page-api-block:ml-0',
33+
'relative'
3334
)}
3435
>
3536
{ancestors.length > 0 && (
@@ -79,14 +80,26 @@ export async function PageHeader(props: {
7980
</nav>
8081
)}
8182
{page.layout.title ? (
82-
<h1 className={tcls('text-4xl', 'font-bold', 'flex', 'items-center', 'gap-4')}>
83+
<h1
84+
className={tcls(
85+
'text-4xl',
86+
'font-bold',
87+
'flex',
88+
'items-center',
89+
'gap-4',
90+
'w-fit'
91+
)}
92+
>
8393
<PageIcon page={page} style={['text-tint-subtle ', 'shrink-0']} />
8494
{page.title}
8595
</h1>
8696
) : null}
8797
{page.description && page.layout.description ? (
8898
<p className={tcls('text-lg', 'text-tint')}>{page.description}</p>
8999
) : null}
100+
<div className="!mt-0 absolute top-0 right-0">
101+
<AIActionsDropdown />
102+
</div>
90103
</header>
91104
);
92105
}

packages/gitbook/src/components/primitives/Button.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ export const variantClasses = {
2626
'text-contrast-primary-solid',
2727
'hover:bg-primary-solid-hover',
2828
'hover:text-contrast-primary-solid-hover',
29-
'ring-0',
30-
'contrast-more:ring-1',
29+
'border-0',
30+
'contrast-more:border-1',
3131
],
3232
blank: [
3333
'bg-transparent',
3434
'text-tint',
35-
'ring-0',
35+
'border-0',
3636
'shadow-none',
3737
'hover:bg-primary-hover',
3838
'hover:text-primary',
@@ -115,5 +115,5 @@ export function Button({
115115
</button>
116116
);
117117

118-
return iconOnly ? <Tooltip label={label}>{button}</Tooltip> : button;
118+
return iconOnly && label ? <Tooltip label={label}>{button}</Tooltip> : button;
119119
}

packages/gitbook/src/components/primitives/DropdownMenu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ export function DropdownMenu(props: {
2727
className?: ClassValue;
2828
/** Open the dropdown on hover */
2929
openOnHover?: boolean;
30+
/** Props to pass to the content */
31+
contentProps?: RadixDropdownMenu.DropdownMenuContentProps;
3032
}) {
31-
const { button, children, className, openOnHover = false } = props;
33+
const { button, children, className, openOnHover = false, contentProps } = props;
3234
const [hovered, setHovered] = useState(false);
3335
const [clicked, setClicked] = useState(false);
3436

@@ -57,6 +59,7 @@ export function DropdownMenu(props: {
5759
onMouseLeave={() => setHovered(false)}
5860
align="start"
5961
className="z-40 animate-scaleIn pt-2"
62+
{...contentProps}
6063
>
6164
<div
6265
className={tcls(

packages/gitbook/src/components/primitives/styles.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export const ButtonStyles = [
1010
'circular-corners:rounded-full',
1111
// 'place-self-start',
1212

13-
'ring-1',
14-
'ring-tint',
15-
'hover:ring-tint-hover',
13+
'border',
14+
'border-tint',
15+
'hover:border-tint-hover',
1616

1717
'shadow-sm',
1818
'shadow-tint',
@@ -21,9 +21,9 @@ export const ButtonStyles = [
2121
'active:shadow-none',
2222
'depth-flat:shadow-none',
2323

24-
'contrast-more:ring-tint-12',
25-
'contrast-more:hover:ring-2',
26-
'contrast-more:hover:ring-tint-12',
24+
'contrast-more:border-tint-12',
25+
'contrast-more:hover:border-2',
26+
'contrast-more:hover:border-tint-12',
2727

2828
'hover:scale-104',
2929
'depth-flat:hover:scale-100',

0 commit comments

Comments
 (0)