275 lines
7.5 KiB
JavaScript
275 lines
7.5 KiB
JavaScript
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
const TerminalAnimation = ({ startAnimation = false }) => {
|
|
const [animationState, setAnimationState] = useState({
|
|
lineI: 0,
|
|
charI: 0,
|
|
curOn: true
|
|
});
|
|
|
|
const animationRef = useRef(null);
|
|
|
|
const lines = [
|
|
'git clone https://gitlink.org.cn/Gitlink/forgeplus.git',
|
|
'cd forgeplus',
|
|
'git remote -v',
|
|
'git pull origin master',
|
|
'git add .',
|
|
'git commit -m "feat: add new feature"',
|
|
'git push origin master',
|
|
'git checkout -b feature/new-ui',
|
|
'git merge develop',
|
|
'git status ',
|
|
'git log --oneline -10',
|
|
];
|
|
|
|
const keyColor = {
|
|
'git clone': '#fff700',
|
|
'git commit': '#fff700',
|
|
'git log': '#fff700',
|
|
'git merge': '#fff700',
|
|
'git remote': '#fff700',
|
|
'git pull': '#d24a6f',
|
|
'git push': '#d24a6f',
|
|
'git status': '#2977ff',
|
|
'git fetch': '#2977ff',
|
|
'git add': '#9aff41',
|
|
'cd forgeplus': '#9aff41',
|
|
'git checkout': '#9aff41'
|
|
};
|
|
|
|
const fontSize = 15;
|
|
const lineH = 36;
|
|
const startY = 40;
|
|
const charGap = 30;
|
|
const lineGap = 300;
|
|
const lineNumberWidth = 40;
|
|
|
|
// 字符宽度估算函数
|
|
const getCharWidth = (charCount) => charCount * 9.6;
|
|
|
|
// 渲染带颜色的文本段
|
|
const renderColoredText = (text, x, y, availableChars) => {
|
|
const elements = [];
|
|
let currentX = x;
|
|
let charsRendered = 0;
|
|
|
|
const sortedKeys = Object.keys(keyColor).sort((a, b) => b.length - a.length);
|
|
let remainingText = text;
|
|
|
|
while (remainingText.length > 0 && charsRendered < availableChars) {
|
|
let colored = false;
|
|
|
|
for (const key of sortedKeys) {
|
|
if (remainingText.startsWith(key)) {
|
|
const charsToRender = Math.min(key.length, availableChars - charsRendered);
|
|
if (charsToRender > 0) {
|
|
const displayText = key.substring(0, charsToRender);
|
|
elements.push(
|
|
<text
|
|
key={`colored-${currentX}-${charsRendered}`}
|
|
x={currentX}
|
|
y={y}
|
|
fill={keyColor[key]}
|
|
fontFamily="'Courier New', monospace"
|
|
fontSize={fontSize}
|
|
dominantBaseline="middle"
|
|
fontWeight="bold"
|
|
>
|
|
{displayText}
|
|
</text>
|
|
);
|
|
currentX += getCharWidth(displayText.length);
|
|
charsRendered += charsToRender;
|
|
remainingText = remainingText.substring(charsToRender);
|
|
colored = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!colored && remainingText.length > 0) {
|
|
const charsToRender = Math.min(1, availableChars - charsRendered);
|
|
if (charsToRender > 0) {
|
|
const char = remainingText[0];
|
|
elements.push(
|
|
<text
|
|
key={`normal-${currentX}-${charsRendered}`}
|
|
x={currentX}
|
|
y={y}
|
|
fill="#ffffff"
|
|
fontFamily="'Courier New', monospace"
|
|
fontSize={fontSize}
|
|
dominantBaseline="middle"
|
|
fontWeight="bold"
|
|
>
|
|
{char}
|
|
</text>
|
|
);
|
|
currentX += getCharWidth(1);
|
|
charsRendered += charsToRender;
|
|
remainingText = remainingText.substring(charsToRender);
|
|
}
|
|
}
|
|
|
|
if (charsRendered >= availableChars) break;
|
|
}
|
|
|
|
return { elements, currentX, charsRendered };
|
|
};
|
|
|
|
// 渲染文本行
|
|
const renderTextSegments = (line, charLimit, y, isCurrentLine, lineIndex) => {
|
|
let x = lineNumberWidth;
|
|
|
|
const lineNumberElement = (
|
|
<text
|
|
key="line-number"
|
|
x={lineNumberWidth / 2}
|
|
y={y + 1}
|
|
fill="#ffffff"
|
|
fontFamily="'Courier New', monospace"
|
|
fontSize={fontSize}
|
|
dominantBaseline="middle"
|
|
textAnchor="middle"
|
|
fontWeight="bold"
|
|
>
|
|
{lineIndex + 1}.
|
|
</text>
|
|
);
|
|
|
|
const availableChars = charLimit;
|
|
const { elements: commandElements } = renderColoredText(
|
|
line,
|
|
x,
|
|
y,
|
|
availableChars
|
|
);
|
|
|
|
const allElements = [lineNumberElement, ...commandElements];
|
|
|
|
if (isCurrentLine && animationState.curOn) {
|
|
const cursorX = x + getCharWidth(charLimit);
|
|
allElements.push(
|
|
<rect
|
|
key="cursor"
|
|
x={cursorX + 2}
|
|
y={y - fontSize/2 + 2}
|
|
width={2}
|
|
height={fontSize - 4}
|
|
fill="#ffffff"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return allElements;
|
|
};
|
|
|
|
// 重置动画
|
|
const resetAnimation = () => {
|
|
setAnimationState({
|
|
lineI: 0,
|
|
charI: 0,
|
|
curOn: true
|
|
});
|
|
};
|
|
|
|
// 监听 startAnimation 变化
|
|
useEffect(() => {
|
|
if (startAnimation) {
|
|
resetAnimation();
|
|
}
|
|
}, [startAnimation]);
|
|
|
|
// 动画循环
|
|
useEffect(() => {
|
|
if (!startAnimation) return;
|
|
|
|
let lastCharTime = Date.now();
|
|
let lastLineTime = Date.now();
|
|
let lastBlinkTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const now = Date.now();
|
|
let shouldUpdate = false;
|
|
let newState = { ...animationState };
|
|
|
|
// 光标闪烁
|
|
if (now - lastBlinkTime > 500) {
|
|
newState.curOn = !newState.curOn;
|
|
lastBlinkTime = now;
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
// 第一行字符逐个显示
|
|
if (newState.lineI === 0 && newState.charI < lines[0].length && now - lastCharTime > charGap) {
|
|
newState.charI++;
|
|
lastCharTime = now;
|
|
shouldUpdate = true;
|
|
}
|
|
// 第一行完成后,后续行整行显示
|
|
else if (newState.lineI === 0 && newState.charI >= lines[0].length && now - lastLineTime > lineGap) {
|
|
newState.lineI++;
|
|
if (newState.lineI < lines.length) {
|
|
newState.charI = lines[newState.lineI].length;
|
|
}
|
|
lastLineTime = now;
|
|
shouldUpdate = true;
|
|
}
|
|
// 后续行之间的间隔
|
|
else if (newState.lineI > 0 && newState.lineI < lines.length && now - lastLineTime > lineGap) {
|
|
newState.lineI++;
|
|
if (newState.lineI < lines.length) {
|
|
newState.charI = lines[newState.lineI].length;
|
|
}
|
|
lastLineTime = now;
|
|
shouldUpdate = true;
|
|
}
|
|
|
|
if (shouldUpdate) {
|
|
setAnimationState(newState);
|
|
}
|
|
|
|
// 如果动画未完成,继续下一帧
|
|
if (newState.lineI < lines.length && startAnimation) {
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [animationState]); // 包含 animationState 以确保状态更新
|
|
|
|
return (
|
|
<div className='center pl15'>
|
|
<svg
|
|
width="755"
|
|
height="420"
|
|
viewBox="0 0 755 420"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
style={{ backgroundColor: 'transparent' }}
|
|
>
|
|
{/* 渲染已完成的行 */}
|
|
{lines.slice(0, animationState.lineI).map((line, index) => (
|
|
<g key={index}>
|
|
{renderTextSegments(line, line.length, startY + index * lineH, false, index)}
|
|
</g>
|
|
))}
|
|
|
|
{/* 渲染当前正在输入的行 */}
|
|
{animationState.lineI < lines.length && (
|
|
<g key="current-line">
|
|
{renderTextSegments(lines[animationState.lineI], animationState.charI, startY + animationState.lineI * lineH, true, animationState.lineI)}
|
|
</g>
|
|
)}
|
|
</svg>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TerminalAnimation; |