Terminal UI API Update
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
import { toggleMode } from '$lib/stores/theme';
|
||||
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
import TuiHeader from './tui/TuiHeader.svelte';
|
||||
import TuiBody from './tui/TuiBody.svelte';
|
||||
import TuiFooter from './tui/TuiFooter.svelte';
|
||||
import { parseColorText, getPlainText } from './tui/utils';
|
||||
import '$lib/assets/css/terminal-tui.css';
|
||||
|
||||
import type { TerminalLine, ParsedLine, DisplayedLine } from './tui/types';
|
||||
// Import extracted modules
|
||||
import { parseLine, createTerminalAPI } from './tui/terminal-api';
|
||||
import { runTypingAnimation, skipTypingAnimation } from './tui/terminal-typing';
|
||||
import { createKeyboardHandler, handleNavigation, scrollToHash } from './tui/terminal-keyboard';
|
||||
|
||||
import type { TerminalLine, ParsedLine, DisplayedLine, TerminalAPI } from './tui/types';
|
||||
|
||||
interface Props {
|
||||
lines?: TerminalLine[];
|
||||
@@ -21,16 +22,18 @@
|
||||
interactive?: boolean;
|
||||
speed?: SpeedPreset | number;
|
||||
autoscroll?: boolean;
|
||||
terminal?: TerminalAPI;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = [],
|
||||
lines = $bindable([]),
|
||||
title = 'terminal',
|
||||
class: className = '',
|
||||
onComplete,
|
||||
interactive = true,
|
||||
speed = 'normal',
|
||||
autoscroll = true
|
||||
autoscroll = true,
|
||||
terminal = $bindable()
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate speed multiplier from preset or number
|
||||
@@ -43,14 +46,7 @@
|
||||
|
||||
// Pre-parse all lines upfront (segments + plain text)
|
||||
const parsedLines = $derived<ParsedLine[]>(
|
||||
lines.map(line => {
|
||||
const segments = parseColorText(line.content, colorMap);
|
||||
return {
|
||||
line,
|
||||
segments,
|
||||
plainText: getPlainText(segments)
|
||||
};
|
||||
})
|
||||
lines.map(line => parseLine(line, colorMap))
|
||||
);
|
||||
|
||||
let displayedLines = $state<DisplayedLine[]>([]);
|
||||
@@ -58,6 +54,7 @@
|
||||
let isTyping = $state(false);
|
||||
let isComplete = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
let skipRequested = $state(false);
|
||||
let terminalElement: HTMLDivElement;
|
||||
let bodyElement = $state<HTMLDivElement>();
|
||||
|
||||
@@ -86,259 +83,112 @@
|
||||
lastColorMapId = colorMapId;
|
||||
});
|
||||
|
||||
// Autoscroll to bottom (respects autoscroll prop)
|
||||
function scrollToBottom() {
|
||||
if (!autoscroll || !bodyElement) return;
|
||||
bodyElement.scrollTo({
|
||||
top: bodyElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to hash target (anchor link like #skills)
|
||||
function scrollToHash() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all interactive button indices
|
||||
let buttonIndices = $derived(
|
||||
const buttonIndices = $derived(
|
||||
displayedLines
|
||||
.map((item, i) => item.parsed.line.type === 'button' ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
);
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
// ========================================================================
|
||||
// TYPING ANIMATION
|
||||
// ========================================================================
|
||||
|
||||
function typeText() {
|
||||
runTypingAnimation({
|
||||
parsedLines,
|
||||
speedMultiplier,
|
||||
terminalSettings,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
autoscroll,
|
||||
getBodyElement: () => bodyElement,
|
||||
getState: () => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }),
|
||||
setState: (updates) => {
|
||||
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines;
|
||||
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
|
||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
||||
if (updates.isComplete !== undefined) isComplete = updates.isComplete;
|
||||
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
|
||||
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
|
||||
},
|
||||
onComplete,
|
||||
scrollToHash: () => scrollToHash(bodyElement)
|
||||
});
|
||||
}
|
||||
|
||||
async function typeText() {
|
||||
if (parsedLines.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
|
||||
// Apply speed multiplier to start delay
|
||||
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
|
||||
await sleep(startDelayMs);
|
||||
if (skipRequested) return;
|
||||
|
||||
for (let i = 0; i < parsedLines.length; i++) {
|
||||
if (skipRequested) return;
|
||||
|
||||
const parsed = parsedLines[i];
|
||||
const line = parsed.line;
|
||||
const plainLength = parsed.plainText.length;
|
||||
|
||||
displayedLines = [...displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
|
||||
currentLineIndex = i;
|
||||
|
||||
if (line.delay && speedMultiplier > 0) {
|
||||
await sleep(line.delay * speedMultiplier);
|
||||
if (skipRequested) return;
|
||||
}
|
||||
|
||||
// Handle different line types
|
||||
if (line.type === 'image') {
|
||||
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: true };
|
||||
scrollToBottom();
|
||||
} else if (line.type === 'blank' || line.type === 'divider') {
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
} else if (line.type === 'button') {
|
||||
// Buttons appear instantly
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
scrollToBottom();
|
||||
} else if (speedMultiplier === 0) {
|
||||
// Instant mode - no typing animation
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
if (i % 5 === 0) scrollToBottom();
|
||||
} else if (line.type === 'header') {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
};
|
||||
if (j < plainLength) await sleep(typeSpeed);
|
||||
}
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
|
||||
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
};
|
||||
|
||||
if (j % 10 === 0) scrollToBottom();
|
||||
|
||||
if (j < plainLength) {
|
||||
await sleep(typeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skipRequested) return;
|
||||
displayedLines[i].complete = true;
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
|
||||
await sleep(terminalSettings.lineDelay * speedMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
isTyping = false;
|
||||
isComplete = true;
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
selectedIndex = buttonIndices[0];
|
||||
}
|
||||
|
||||
// Scroll to hash anchor if present in URL
|
||||
scrollToHash();
|
||||
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
// Scroll selected button into view with margin for context
|
||||
function scrollToSelected() {
|
||||
if (bodyElement && selectedIndex >= 0) {
|
||||
const buttons = bodyElement.querySelectorAll('.tui-button');
|
||||
const selectedButton = Array.from(buttons).find((_, i) => {
|
||||
const btnIndices = displayedLines
|
||||
.map((item, idx) => item.parsed.line.type === 'button' ? idx : -1)
|
||||
.filter(idx => idx !== -1);
|
||||
return btnIndices[i] === selectedIndex;
|
||||
}) as HTMLElement | undefined;
|
||||
|
||||
if (selectedButton && bodyElement) {
|
||||
const containerRect = bodyElement.getBoundingClientRect();
|
||||
const buttonRect = selectedButton.getBoundingClientRect();
|
||||
const margin = 80;
|
||||
|
||||
if (buttonRect.top < containerRect.top + margin) {
|
||||
const scrollAmount = buttonRect.top - containerRect.top - margin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
else if (buttonRect.bottom > containerRect.bottom - margin) {
|
||||
const scrollAmount = buttonRect.bottom - containerRect.bottom + margin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip animation and show all content instantly
|
||||
let skipRequested = $state(false);
|
||||
|
||||
function skipAnimation() {
|
||||
if (!isTyping) return;
|
||||
skipRequested = true;
|
||||
|
||||
displayedLines = parsedLines.map(parsed => ({
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: parsed.line.type === 'image'
|
||||
}));
|
||||
|
||||
isTyping = false;
|
||||
isComplete = true;
|
||||
currentLineIndex = parsedLines.length - 1;
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
selectedIndex = buttonIndices[0];
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
onComplete?.();
|
||||
skipTypingAnimation(
|
||||
parsedLines,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
() => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }),
|
||||
(updates) => {
|
||||
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines;
|
||||
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
|
||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
||||
if (updates.isComplete !== undefined) isComplete = updates.isComplete;
|
||||
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
|
||||
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
|
||||
},
|
||||
() => bodyElement,
|
||||
autoscroll,
|
||||
onComplete
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isTyping && (event.key === 'y' || event.key === 'Y')) {
|
||||
event.preventDefault();
|
||||
skipAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interactive || !isComplete || buttonIndices.length === 0) return;
|
||||
// ========================================================================
|
||||
// TERMINAL API
|
||||
// ========================================================================
|
||||
|
||||
const currentButtonIdx = buttonIndices.indexOf(selectedIndex);
|
||||
terminal = createTerminalAPI({
|
||||
getState: () => ({
|
||||
lines,
|
||||
displayedLines,
|
||||
isTyping,
|
||||
isComplete,
|
||||
currentLineIndex,
|
||||
selectedIndex,
|
||||
skipRequested
|
||||
}),
|
||||
setState: (updates) => {
|
||||
if (updates.lines !== undefined) lines = updates.lines;
|
||||
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines;
|
||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
||||
if (updates.isComplete !== undefined) isComplete = updates.isComplete;
|
||||
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
|
||||
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
|
||||
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
|
||||
},
|
||||
getColorMap: () => colorMap,
|
||||
getBodyElement: () => bodyElement,
|
||||
typeText
|
||||
});
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j') {
|
||||
event.preventDefault();
|
||||
const nextIdx = (currentButtonIdx + 1) % buttonIndices.length;
|
||||
selectedIndex = buttonIndices[nextIdx];
|
||||
scrollToSelected();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'k') {
|
||||
event.preventDefault();
|
||||
const prevIdx = (currentButtonIdx - 1 + buttonIndices.length) % buttonIndices.length;
|
||||
selectedIndex = buttonIndices[prevIdx];
|
||||
scrollToSelected();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const selectedLine = displayedLines[selectedIndex]?.parsed.line;
|
||||
if (selectedLine?.action) {
|
||||
selectedLine.action();
|
||||
} else if (selectedLine?.href) {
|
||||
const isExternal = selectedLine.external || selectedLine.href.startsWith('http://') || selectedLine.href.startsWith('https://');
|
||||
if (isExternal) {
|
||||
window.open(selectedLine.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.location.href = selectedLine.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ========================================================================
|
||||
// KEYBOARD HANDLING
|
||||
// ========================================================================
|
||||
|
||||
const handleKeydown = createKeyboardHandler({
|
||||
getIsTyping: () => isTyping,
|
||||
getIsComplete: () => isComplete,
|
||||
getInteractive: () => interactive,
|
||||
getButtonIndices: () => buttonIndices,
|
||||
getSelectedIndex: () => selectedIndex,
|
||||
setSelectedIndex: (index) => { selectedIndex = index; },
|
||||
getDisplayedLines: () => displayedLines,
|
||||
getBodyElement: () => bodyElement,
|
||||
skipAnimation,
|
||||
scrollMargin: terminalSettings.scrollMargin
|
||||
});
|
||||
|
||||
function handleButtonClick(index: number) {
|
||||
selectedIndex = index;
|
||||
const line = displayedLines[index]?.parsed.line;
|
||||
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 {
|
||||
window.location.href = line.href;
|
||||
}
|
||||
}
|
||||
handleNavigation(displayedLines[index]?.parsed.line);
|
||||
}
|
||||
|
||||
function handleLinkClick(index: number) {
|
||||
const line = displayedLines[index]?.parsed.line;
|
||||
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 {
|
||||
window.location.href = line.href;
|
||||
}
|
||||
}
|
||||
handleNavigation(displayedLines[index]?.parsed.line);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
Reference in New Issue
Block a user