170 lines
5.4 KiB
TypeScript
170 lines
5.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|