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>
206 lines
5.0 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|