/** * 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 = { 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(null) const animationFrameRef = useRef(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, }, } }