mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-22 18:43:29 +00:00
Generalise keyboard shortcuts, add Cmd+J to AI Chat (#3457)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Generalise keyboard shortcuts, add Cmd+J to AI Chat
|
||||
@@ -3,6 +3,7 @@
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
type AIChatController,
|
||||
type AIChatState,
|
||||
@@ -20,19 +21,40 @@ import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||
export function AIChat(props: { trademark: boolean }) {
|
||||
const { trademark } = props;
|
||||
const chat = useAIChatState();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
useHotkeys(
|
||||
'mod+j',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
chatController.open();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
chatController.close();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!chat.opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AIChatWindow chat={chat} trademark={trademark} />;
|
||||
return <AIChatWindow trademark={trademark} chatController={chatController} chat={chat} />;
|
||||
}
|
||||
|
||||
export function AIChatWindow(props: { chat: AIChatState; trademark: boolean }) {
|
||||
const { chat, trademark } = props;
|
||||
export function AIChatWindow(props: {
|
||||
chatController: AIChatController;
|
||||
chat: AIChatState;
|
||||
trademark: boolean;
|
||||
}) {
|
||||
const { chatController, chat, trademark } = props;
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import AIChatIcon from './AIChatIcon';
|
||||
|
||||
/**
|
||||
@@ -22,9 +23,12 @@ export function AIChatButton(props: { trademark: boolean }) {
|
||||
variant="secondary"
|
||||
className="!px-3 bg-tint-base py-2.5"
|
||||
label={
|
||||
trademark
|
||||
? tString(language, 'ai_chat_assistant_name')
|
||||
: tString(language, 'ai_chat_assistant_name_unbranded')
|
||||
<div className="flex items-center gap-2">
|
||||
{trademark
|
||||
? t(language, 'ai_chat_assistant_name')
|
||||
: t(language, 'ai_chat_assistant_name_unbranded')}
|
||||
<KeyboardShortcut keys={['mod', 'j']} className="border-tint-11 text-tint-1" />
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
if (chat.opened) {
|
||||
|
||||
@@ -22,9 +22,9 @@ const AIChatIcon = ({
|
||||
icon="sparkle"
|
||||
className={tcls(
|
||||
className,
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: ''
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -65,7 +65,10 @@ const AIChatIcon = ({
|
||||
{/* Error */}
|
||||
<g
|
||||
clipPath="url(#clip0_153_2034)"
|
||||
className={tcls(state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden')}
|
||||
className={tcls(
|
||||
'text-danger-subtle',
|
||||
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M13.0312 1.42059L13.0312 3.95184"
|
||||
@@ -91,7 +94,8 @@ const AIChatIcon = ({
|
||||
className={tcls(
|
||||
state === 'done'
|
||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
||||
: 'animate-[fadeOut_.5s_both]'
|
||||
: 'animate-[fadeOut_.5s_both]',
|
||||
state === 'intro' && 'hidden'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -135,8 +139,8 @@ const AIChatIcon = ({
|
||||
strokeLinejoin="round"
|
||||
className={tcls(
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
'animate-[pathLoading_2s_infinite_forwards]',
|
||||
state === 'intro' && 'animate-[pathEnter_2s_forwards]',
|
||||
'animate-[pathLoading_2s_infinite_both]',
|
||||
state === 'intro' && 'animate-[pathEnter_2s_both]',
|
||||
state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Button } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { Tooltip } from '../primitives/Tooltip';
|
||||
|
||||
export function AIChatInput(props: {
|
||||
@@ -31,12 +33,19 @@ export function AIChatInput(props: {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 300);
|
||||
if (!disabled && !loading) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [disabled]);
|
||||
}, [disabled, loading]);
|
||||
|
||||
useHotkeys(
|
||||
'mod+j',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base">
|
||||
@@ -54,6 +63,7 @@ export function AIChatInput(props: {
|
||||
'pb-12',
|
||||
'h-auto',
|
||||
'bg-transparent',
|
||||
'peer',
|
||||
'max-h-64',
|
||||
'placeholder:text-tint/8',
|
||||
'transition-colors',
|
||||
@@ -69,6 +79,12 @@ export function AIChatInput(props: {
|
||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||
onChange={handleInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.style.height = 'auto';
|
||||
@@ -76,6 +92,9 @@ export function AIChatInput(props: {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2.5 right-4 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
||||
<KeyboardShortcut keys={['mod', 'j']} />
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 flex items-center px-2 py-2">
|
||||
<Tooltip
|
||||
label={
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
@@ -100,45 +100,10 @@ export function SearchButton(props: { children?: React.ReactNode; style?: ClassV
|
||||
className={tcls('text-tint-subtle', 'shrink-0', 'size-4')}
|
||||
/>
|
||||
<div className={tcls('w-full', 'hidden', 'md:block', 'text-left')}>{children}</div>
|
||||
<Shortcut />
|
||||
<KeyboardShortcut
|
||||
keys={['mod', 'k']}
|
||||
className="theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut() {
|
||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function getOperatingSystem() {
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
||||
if (platform.includes('mac')) return 'mac';
|
||||
if (platform.includes('win')) return 'win';
|
||||
|
||||
return 'win';
|
||||
}
|
||||
|
||||
setOperatingSystem(getOperatingSystem());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||
className={tcls(
|
||||
`shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex`,
|
||||
operatingSystem
|
||||
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<kbd
|
||||
className={`flex h-5 min-w-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
||||
</kbd>
|
||||
<kbd className="flex size-5 items-center justify-center rounded border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type ButtonProps = {
|
||||
iconOnly?: boolean;
|
||||
size?: 'default' | 'medium' | 'small';
|
||||
className?: ClassValue;
|
||||
label?: string;
|
||||
label?: string | React.ReactNode;
|
||||
} & LinkInsightsProps &
|
||||
HTMLAttributes<HTMLElement>;
|
||||
|
||||
@@ -94,7 +94,7 @@ export function Button({
|
||||
className={domClassName}
|
||||
classNames={['ButtonStyles']}
|
||||
insights={insights}
|
||||
aria-label={label}
|
||||
aria-label={label?.toString()}
|
||||
target={target}
|
||||
{...rest}
|
||||
>
|
||||
@@ -107,7 +107,7 @@ export function Button({
|
||||
<button
|
||||
type="button"
|
||||
className={tcls(buttonOnlyClassNames, domClassName)}
|
||||
aria-label={label}
|
||||
aria-label={label?.toString()}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
|
||||
function getOperatingSystem() {
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
||||
if (platform.includes('mac')) return 'mac';
|
||||
if (platform.includes('win')) return 'win';
|
||||
|
||||
return 'win';
|
||||
}
|
||||
|
||||
export function KeyboardShortcut(props: { keys: string[]; className?: ClassValue }) {
|
||||
const { keys, className } = props;
|
||||
|
||||
const [operatingSystem, setOperatingSystem] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOperatingSystem(getOperatingSystem());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||
className={tcls(
|
||||
'shortcut -mr-1 hidden justify-end gap-0.5 whitespace-nowrap text-tint text-xs [font-feature-settings:"calt",_"case"] contrast-more:text-tint-strong md:flex',
|
||||
operatingSystem
|
||||
? 'motion-safe:animate-fadeIn motion-reduce:opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
>
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={tcls(
|
||||
'flex h-5 min-w-5 items-center justify-center rounded-md border border-tint-subtle px-1',
|
||||
key === 'mod'
|
||||
? operatingSystem === 'mac'
|
||||
? 'text-sm'
|
||||
: 'text-xs'
|
||||
: 'uppercase',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{key === 'mod' ? (operatingSystem === 'mac' ? '⌘' : 'Ctrl') : key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user