Files
awoooi/apps/web/src/hooks/useKeyboardShortcuts.ts
OG T b13b063282 feat(web): Phase 11 對話式 AI UI/UX (#47-59)
Phase 11.1 對話式容器:
- ConversationalView 雙欄佈局 (左側列表 + 右側詳情)
- ApprovalThreadItem 風險等級 + 相對時間顯示
- SSE 即時更新整合

Phase 11.2 批次處理:
- BatchModeSelector 組件 (全部接受/逐一審核/CRITICAL Only)
- POST /api/v1/approvals/bulk-approve API 端點
- CRITICAL + DESTRUCTIVE 安全過濾 (禁止批次核准)

Phase 11.4 鍵盤快捷鍵:
- useKeyboardShortcuts hook (Y/N/方向鍵/Esc)
- Y 鍵長按 2 秒核准 + 頂部進度指示器
- 快捷鍵說明 Modal (Y/N 高亮顯示)

i18n: 100% next-intl 覆蓋

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 10:31:35 +08:00

209 lines
4.9 KiB
TypeScript

'use client'
/**
* useKeyboardShortcuts - 全局鍵盤快捷鍵 Hook
* ===========================================
* Phase 11: #56 鍵盤快捷鍵支援
*
* Features:
* - Y: 長按核准 (需按住指定時間)
* - N: 拒絕
* - 方向鍵: 導航
* - ?: 顯示快捷鍵說明
* - Esc: 關閉面板
*
* 安全機制:
* - Y 鍵需要長按,防止誤觸
* - 輸入框內不觸發
*/
import { useEffect, useCallback, useRef, useState } from 'react'
// =============================================================================
// Types
// =============================================================================
export interface KeyboardShortcutsConfig {
/** 長按確認時間 (毫秒),預設 2000ms */
holdDuration?: number
/** Y 鍵長按核准回調 */
onApprove?: () => void
/** N 鍵拒絕回調 */
onReject?: () => void
/** 上/左 導航回調 */
onPrev?: () => void
/** 下/右 導航回調 */
onNext?: () => void
/** ? 顯示快捷鍵說明 */
onShowShortcuts?: () => void
/** Esc 關閉回調 */
onClose?: () => void
/** 是否啟用 */
enabled?: boolean
}
export interface UseKeyboardShortcutsReturn {
/** Y 鍵是否正在按住 */
isYKeyHolding: boolean
/** Y 鍵長按進度 (0-100) */
yKeyProgress: number
}
// =============================================================================
// Hook
// =============================================================================
export function useKeyboardShortcuts(
config: KeyboardShortcutsConfig
): UseKeyboardShortcutsReturn {
const {
holdDuration = 2000,
onApprove,
onReject,
onPrev,
onNext,
onShowShortcuts,
onClose,
enabled = true,
} = config
const [isYKeyHolding, setIsYKeyHolding] = useState(false)
const [yKeyProgress, setYKeyProgress] = useState(0)
const yKeyStartTime = useRef<number | null>(null)
const yKeyAnimationFrame = useRef<number | null>(null)
const yKeyTriggered = useRef(false)
// 更新 Y 鍵進度
const updateYKeyProgress = useCallback(() => {
if (!yKeyStartTime.current) return
const elapsed = Date.now() - yKeyStartTime.current
const progress = Math.min((elapsed / holdDuration) * 100, 100)
setYKeyProgress(progress)
if (progress >= 100 && !yKeyTriggered.current) {
yKeyTriggered.current = true
onApprove?.()
// 重置狀態
setIsYKeyHolding(false)
setYKeyProgress(0)
yKeyStartTime.current = null
return
}
if (progress < 100) {
yKeyAnimationFrame.current = requestAnimationFrame(updateYKeyProgress)
}
}, [holdDuration, onApprove])
// Y 鍵按下
const handleYKeyDown = useCallback(() => {
if (yKeyStartTime.current) return // 已經在按住
yKeyStartTime.current = Date.now()
yKeyTriggered.current = false
setIsYKeyHolding(true)
setYKeyProgress(0)
yKeyAnimationFrame.current = requestAnimationFrame(updateYKeyProgress)
}, [updateYKeyProgress])
// Y 鍵釋放
const handleYKeyUp = useCallback(() => {
if (yKeyAnimationFrame.current) {
cancelAnimationFrame(yKeyAnimationFrame.current)
}
yKeyStartTime.current = null
setIsYKeyHolding(false)
setYKeyProgress(0)
}, [])
// 主要鍵盤事件處理
useEffect(() => {
if (!enabled) return
const handleKeyDown = (e: KeyboardEvent) => {
// 忽略輸入框內的按鍵
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target as HTMLElement).isContentEditable
) {
return
}
// 忽略組合鍵
if (e.ctrlKey || e.metaKey || e.altKey) {
return
}
switch (e.key.toLowerCase()) {
case 'y':
e.preventDefault()
handleYKeyDown()
break
case 'n':
e.preventDefault()
onReject?.()
break
case 'arrowup':
case 'arrowleft':
e.preventDefault()
onPrev?.()
break
case 'arrowdown':
case 'arrowright':
e.preventDefault()
onNext?.()
break
case '?':
e.preventDefault()
onShowShortcuts?.()
break
case 'escape':
e.preventDefault()
onClose?.()
break
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'y') {
handleYKeyUp()
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
// 清理動畫
if (yKeyAnimationFrame.current) {
cancelAnimationFrame(yKeyAnimationFrame.current)
}
}
}, [
enabled,
handleYKeyDown,
handleYKeyUp,
onReject,
onPrev,
onNext,
onShowShortcuts,
onClose,
])
return {
isYKeyHolding,
yKeyProgress,
}
}