/** * Terminal Keyboard Handler * * Handles keyboard navigation and interaction for the terminal. */ import type { DisplayedLine } from './types'; import { browser } from '$app/environment'; import { goto } from '$app/navigation'; export interface KeyboardHandlerOptions { getIsTyping: () => boolean; getIsComplete: () => boolean; getInteractive: () => boolean; getButtonIndices: () => number[]; getSelectedIndex: () => number; setSelectedIndex: (index: number) => void; getDisplayedLines: () => DisplayedLine[]; getBodyElement: () => HTMLDivElement | undefined; skipAnimation: () => void; scrollMargin?: number; } /** * Scroll selected button into view with margin for context */ export function scrollToSelected( bodyElement: HTMLDivElement | undefined, selectedIndex: number, displayedLines: DisplayedLine[], scrollMargin: number = 80 ): void { if (!bodyElement || selectedIndex < 0) return; // Get all buttons in DOM order (including nested ones in groups) const allButtons = bodyElement.querySelectorAll('.tui-button'); const selectedButton = allButtons[selectedIndex] as HTMLElement | undefined; if (selectedButton && bodyElement) { const containerRect = bodyElement.getBoundingClientRect(); const buttonRect = selectedButton.getBoundingClientRect(); if (buttonRect.top < containerRect.top + scrollMargin) { const scrollAmount = buttonRect.top - containerRect.top - scrollMargin; bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' }); } else if (buttonRect.bottom > containerRect.bottom - scrollMargin) { const scrollAmount = buttonRect.bottom - containerRect.bottom + scrollMargin; bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' }); } } } /** * Handle button/link click navigation */ export function handleNavigation(line: { action?: () => void; href?: string; external?: boolean } | undefined): void { if (!line) return; if (line.action) { line.action(); } else if (line.href) { const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://'); if (isExternal) { window.open(line.href, '_blank', 'noopener,noreferrer'); } else { // Use SvelteKit's goto for client-side navigation (no page reload) goto(line.href); } } } /** * Create a keyboard event handler for the terminal */ export function createKeyboardHandler(options: KeyboardHandlerOptions) { const { getIsTyping, getIsComplete, getInteractive, getButtonIndices, getSelectedIndex, setSelectedIndex, getDisplayedLines, getBodyElement, skipAnimation, scrollMargin = 80 } = options; return function handleKeydown(event: KeyboardEvent): void { // Ignore key events originating from editable fields (e.g. command input) const target = event.target as HTMLElement | null; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { return; } // Skip animation on Y key if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) { event.preventDefault(); skipAnimation(); return; } if (!getInteractive() || !getIsComplete()) return; const bodyElement = getBodyElement(); if (!bodyElement) return; // Get all buttons from the DOM (including nested ones in groups) const allButtons = bodyElement.querySelectorAll('.tui-button'); const buttonCount = allButtons.length; if (buttonCount === 0) return; const selectedIndex = getSelectedIndex(); const displayedLines = getDisplayedLines(); if (event.key === 'ArrowDown' || event.key === 'j') { event.preventDefault(); const nextIdx = selectedIndex < 0 ? 0 : (selectedIndex + 1) % buttonCount; setSelectedIndex(nextIdx); scrollToSelected(bodyElement, nextIdx, displayedLines, scrollMargin); // Update visual selection on buttons allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === nextIdx)); } else if (event.key === 'ArrowUp' || event.key === 'k') { event.preventDefault(); const prevIdx = selectedIndex < 0 ? buttonCount - 1 : (selectedIndex - 1 + buttonCount) % buttonCount; setSelectedIndex(prevIdx); scrollToSelected(bodyElement, prevIdx, displayedLines, scrollMargin); // Update visual selection on buttons allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === prevIdx)); } else if (event.key === 'Enter') { event.preventDefault(); if (selectedIndex >= 0 && selectedIndex < buttonCount) { const selectedButton = allButtons[selectedIndex] as HTMLElement; if (selectedButton) { // Read navigation data from button data attributes const href = selectedButton.dataset.href; const isExternal = selectedButton.dataset.external === 'true'; if (href) { handleNavigation({ href, external: isExternal }); } else { // Fallback to click for buttons with actions (non-navigational) selectedButton.click(); } } } } }; } /** * Scroll to hash target (anchor link like #skills) */ export function scrollToHash(bodyElement: HTMLDivElement | undefined): void { if (!browser || !bodyElement) return; const hash = window.location.hash; if (!hash) return; const targetId = hash.slice(1); // Remove the # const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`); if (targetElement) { // Small delay to ensure layout is complete setTimeout(() => { targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); } }