Generalise keyboard shortcuts, add Cmd+J to AI Chat (#3457)

This commit is contained in:
Zeno Kapitein
2025-07-09 14:13:36 +02:00
committed by GitHub
parent 0003030bf5
commit 9cc5a787e9
8 changed files with 134 additions and 63 deletions
+5
View File
@@ -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>
);
}