Files
awoooi/apps/web/src/hooks/useHoldToConfirm.ts
OG T e5ded3b3f2 feat(phase19): OmniTerminal + GenUI + Hybrid SSE 架構實作 (Wave 0-2)
Phase 19 OmniTerminal MVP 完成:
- Wave 0: Backend (Hybrid SSE POST→GET 架構)
- Wave 1: Frontend (OmniTerminal 狀態機 + GenUI Registry)
- Wave 2: UI 組件 (8 個 GenUI 動態卡片)

ADR 文檔:
- ADR-031: OmniTerminal SSE 架構
- ADR-032: GenUI 動態渲染框架
- ADR-033: K3s HA 架構設計

GenUI 組件:
- GenUIRenderer, K8sPodStatusCard, SentryErrorCard
- MetricsSummaryCard, IncidentTimelineCard
- TraceWaterfallCard, ApprovalCard, NuclearKeyButton

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-28 00:17:26 +08:00

206 lines
5.0 KiB
TypeScript

/**
* useHoldToConfirm - Nuclear Key UX Hook
* =======================================
* Phase 19.5 - 核鑰授權互動 Hook
*
* 實現「長按確認」模式:
* - 長按 N 秒才能觸發動作
* - 進度條視覺回饋
* - 支援鍵盤 (Y 鍵) 和滑鼠
*
* @see AWOOOI_AGENTIC_WORKSPACE_ROADMAP.md - Nuclear Key UX
* @author Claude Code (首席架構師)
* @version 1.0.0
* @date 2026-03-28 (台北時間)
*/
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseHoldToConfirmOptions {
/** 需要長按的時間 (毫秒) */
duration?: number
/** 確認後的回調 */
onConfirm: () => void
/** 取消後的回調 */
onCancel?: () => void
/** 進度更新回調 */
onProgress?: (progress: number) => void
/** 是否啟用鍵盤 (Y 鍵) */
enableKeyboard?: boolean
/** 風險等級 (影響所需時間) */
riskLevel?: 'low' | 'medium' | 'high' | 'critical'
}
interface UseHoldToConfirmReturn {
/** 當前進度 (0-1) */
progress: number
/** 是否正在長按中 */
isHolding: boolean
/** 是否已確認 */
isConfirmed: boolean
/** 開始長按 */
startHold: () => void
/** 結束長按 */
endHold: () => void
/** 重置狀態 */
reset: () => void
/** 綁定到按鈕的事件處理器與無障礙屬性 */
buttonProps: {
onMouseDown: () => void
onMouseUp: () => void
onMouseLeave: () => void
onTouchStart: () => void
onTouchEnd: () => void
'aria-pressed': boolean
'aria-busy': boolean
role: 'button'
}
}
/** 根據風險等級決定長按時間 */
const RISK_DURATIONS: Record<string, number> = {
low: 500, // 0.5 秒
medium: 1500, // 1.5 秒
high: 2000, // 2 秒
critical: 3000, // 3 秒
}
export function useHoldToConfirm(
options: UseHoldToConfirmOptions
): UseHoldToConfirmReturn {
const {
duration: customDuration,
onConfirm,
onCancel,
onProgress,
enableKeyboard = true,
riskLevel = 'medium',
} = options
// 根據風險等級決定持續時間
const duration = customDuration ?? RISK_DURATIONS[riskLevel] ?? 1500
const [progress, setProgress] = useState(0)
const [isHolding, setIsHolding] = useState(false)
const [isConfirmed, setIsConfirmed] = useState(false)
const startTimeRef = useRef<number | null>(null)
const animationFrameRef = useRef<number | null>(null)
// 更新進度的動畫循環
const updateProgress = useCallback(() => {
if (!startTimeRef.current) return
const elapsed = Date.now() - startTimeRef.current
const newProgress = Math.min(elapsed / duration, 1)
setProgress(newProgress)
onProgress?.(newProgress)
if (newProgress >= 1) {
// 完成!
setIsConfirmed(true)
setIsHolding(false)
onConfirm()
} else {
// 繼續動畫
animationFrameRef.current = requestAnimationFrame(updateProgress)
}
}, [duration, onConfirm, onProgress])
// 開始長按
const startHold = useCallback(() => {
if (isConfirmed) return
setIsHolding(true)
setProgress(0)
startTimeRef.current = Date.now()
animationFrameRef.current = requestAnimationFrame(updateProgress)
}, [isConfirmed, updateProgress])
// 結束長按
const endHold = useCallback(() => {
if (!isHolding) return
// 清理動畫
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
// 如果還沒完成,取消
if (progress < 1) {
setProgress(0)
setIsHolding(false)
startTimeRef.current = null
onCancel?.()
}
}, [isHolding, progress, onCancel])
// 重置狀態
const reset = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
setProgress(0)
setIsHolding(false)
setIsConfirmed(false)
startTimeRef.current = null
}, [])
// 鍵盤支援 (Y 鍵)
useEffect(() => {
if (!enableKeyboard) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'y' && !e.repeat) {
startHold()
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'y') {
endHold()
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [enableKeyboard, startHold, endHold])
// 清理
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [])
return {
progress,
isHolding,
isConfirmed,
startHold,
endHold,
reset,
buttonProps: {
onMouseDown: startHold,
onMouseUp: endHold,
onMouseLeave: endHold,
onTouchStart: startHold,
onTouchEnd: endHold,
// Accessibility attributes
'aria-pressed': isHolding,
'aria-busy': isHolding,
role: 'button' as const,
},
}
}