import { useLayoutEffect, useRef, useState, type RefObject, } from 'react'; import { createPortal } from 'react-dom'; import type { InputMethod } from '../types'; import type { SemanticTarget } from '../utils/blockTargeting'; import { resolveTextPosition, type VimSelectionState, } from '../utils/vimNavigation'; import { getVimHudPhase, type VimHudCommand } from '../utils/vimHud'; import { VimKeyHud } from './VimKeyHud'; import { VimTargetReticle } from './VimTargetReticle'; interface CursorPosition { readonly top: number; readonly left: number; readonly height: number; } /** Props for Vim's cursor, status, and contextual-help feedback. */ export interface VimModeOverlayProps { containerRef: RefObject; inputMethod: InputMethod; state: VimSelectionState; focused: boolean; hudEnabled: boolean; keyPanelEnabled: boolean; hudCommand: VimHudCommand | null; activeTarget: SemanticTarget | null; helpOpen: boolean; onHelpOpenChange: (open: boolean) => void; onKeyPanelHide?: () => void; onHudFocusLeave: () => void; } /** * Render immediate, non-interactive feedback for Vim mode without changing * document layout or intercepting pointer selection. */ export function VimModeOverlay({ containerRef, inputMethod, state, focused, hudEnabled, keyPanelEnabled, hudCommand, activeTarget, helpOpen, onHelpOpenChange, onKeyPanelHide, onHudFocusLeave, }: VimModeOverlayProps) { const [cursorPosition, setCursorPosition] = useState(null); const rafRef = useRef(0); useLayoutEffect(() => { const container = containerRef.current; if ( !container || !focused || state.phase !== 'text' ) { setCursorPosition(null); return; } const cursor = state.cursor; const update = () => { const point = resolveTextPosition(container, cursor); if (!point) { setCursorPosition(null); return; } const range = document.createRange(); range.setStart(point.node, point.offset); range.collapse(true); const rect = range.getClientRects()[0] ?? range.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const parent = point.node.parentElement; const lineHeight = parent ? Number.parseFloat(getComputedStyle(parent).lineHeight) : Number.NaN; setCursorPosition({ top: rect.top - containerRect.top, left: rect.left - containerRect.left, height: rect.height || lineHeight || 18, }); }; const scheduleUpdate = () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(update); }; update(); window.addEventListener('resize', scheduleUpdate, { passive: true }); window.addEventListener('scroll', scheduleUpdate, { passive: true, capture: true }); return () => { cancelAnimationFrame(rafRef.current); window.removeEventListener('resize', scheduleUpdate); window.removeEventListener('scroll', scheduleUpdate, true); }; }, [containerRef, focused, state]); const active = state.phase !== 'inactive' && (focused || state.phase === 'action'); const keyPanelVisible = hudEnabled && (helpOpen || (keyPanelEnabled && active)); const phaseLabel = state.phase === 'visual-block' ? 'VISUAL BLOCK' : state.phase === 'text' ? 'NORMAL' : state.phase.toUpperCase(); const inputLabel = inputMethod === 'pinpoint' ? 'PINPOINT' : 'SELECT'; const hudPhase = getVimHudPhase(state.phase, hudCommand?.actionId); return ( <> {cursorPosition && (