From 9cc5a787e95a2e126ffd887ce72d8300b74c471e Mon Sep 17 00:00:00 2001 From: Zeno Kapitein Date: Wed, 9 Jul 2025 14:13:36 +0200 Subject: [PATCH] Generalise keyboard shortcuts, add Cmd+J to AI Chat (#3457) --- .changeset/weak-turtles-laugh.md | 5 ++ .../gitbook/src/components/AIChat/AIChat.tsx | 30 +++++++++-- .../src/components/AIChat/AIChatButton.tsx | 12 +++-- .../src/components/AIChat/AIChatIcon.tsx | 18 ++++--- .../src/components/AIChat/AIChatInput.tsx | 29 +++++++++-- .../src/components/Search/SearchButton.tsx | 45 ++-------------- .../src/components/primitives/Button.tsx | 6 +-- .../primitives/KeyboardShortcut.tsx | 52 +++++++++++++++++++ 8 files changed, 134 insertions(+), 63 deletions(-) create mode 100644 .changeset/weak-turtles-laugh.md create mode 100644 packages/gitbook/src/components/primitives/KeyboardShortcut.tsx diff --git a/.changeset/weak-turtles-laugh.md b/.changeset/weak-turtles-laugh.md new file mode 100644 index 000000000..08b61e684 --- /dev/null +++ b/.changeset/weak-turtles-laugh.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Generalise keyboard shortcuts, add Cmd+J to AI Chat diff --git a/packages/gitbook/src/components/AIChat/AIChat.tsx b/packages/gitbook/src/components/AIChat/AIChat.tsx index 43c100c16..2e86d2d4a 100644 --- a/packages/gitbook/src/components/AIChat/AIChat.tsx +++ b/packages/gitbook/src/components/AIChat/AIChat.tsx @@ -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 ; + return ; } -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(null); const scrollContainerRef = React.useRef(null); diff --git a/packages/gitbook/src/components/AIChat/AIChatButton.tsx b/packages/gitbook/src/components/AIChat/AIChatButton.tsx index 888cd41b8..5135269fc 100644 --- a/packages/gitbook/src/components/AIChat/AIChatButton.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatButton.tsx @@ -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') +
+ {trademark + ? t(language, 'ai_chat_assistant_name') + : t(language, 'ai_chat_assistant_name_unbranded')} + +
} onClick={() => { if (chat.opened) { diff --git a/packages/gitbook/src/components/AIChat/AIChatIcon.tsx b/packages/gitbook/src/components/AIChat/AIChatIcon.tsx index 9005ccc35..51e6d50cc 100644 --- a/packages/gitbook/src/components/AIChat/AIChatIcon.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatIcon.tsx @@ -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 */} @@ -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]' )} /> diff --git a/packages/gitbook/src/components/AIChat/AIChatInput.tsx b/packages/gitbook/src/components/AIChat/AIChatInput.tsx index d259d1956..ee9ca5aca 100644 --- a/packages/gitbook/src/components/AIChat/AIChatInput.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatInput.tsx @@ -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 (
@@ -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: { } }} /> +
+ +
{children}
- + ); } - -function Shortcut() { - const [operatingSystem, setOperatingSystem] = useState(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 ( - - ); -} diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 5069db59d..6c6f1789c 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -16,7 +16,7 @@ type ButtonProps = { iconOnly?: boolean; size?: 'default' | 'medium' | 'small'; className?: ClassValue; - label?: string; + label?: string | React.ReactNode; } & LinkInsightsProps & HTMLAttributes; @@ -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({