上傳檔案到「/」

This commit is contained in:
2026-04-01 21:49:55 +00:00
parent 36661beaf7
commit 016791d8fa

109
TypingEffect.jsx Normal file
View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
export default function TypingEffect({
steps = [],
loop = true,
typingSpeed = [40, 120],
deleteSpeed = [20, 60],
}) {
const [displayed, setDisplayed] = useState('');
const [showCursor, setShowCursor] = useState(true);
const [cursorBlink, setCursorBlink] = useState(true);
const configKey = JSON.stringify({ steps, loop, typingSpeed, deleteSpeed });
useEffect(() => {
let cancelled = false;
let timerId = null;
const sleep = (ms) =>
new Promise((resolve) => {
timerId = setTimeout(resolve, ms);
});
const rand = ([min, max]) =>
Math.floor(min + Math.random() * (max - min + 1));
const run = async () => {
do {
let chars = [];
setDisplayed('');
for (const step of steps) {
if (cancelled) return;
if ('text' in step) {
const speed = step.speed || typingSpeed;
for (const c of Array.from(step.text)) {
if (cancelled) return;
chars.push(c);
setDisplayed(chars.join(''));
await sleep(rand(speed));
}
} else if ('pause' in step) {
await sleep(step.pause);
} else if ('delete' in step) {
const speed = step.speed || deleteSpeed;
const count =
step.delete === 'all'
? chars.length
: Math.min(step.delete, chars.length);
for (let i = 0; i < count; i++) {
if (cancelled) return;
chars.pop();
setDisplayed(chars.join(''));
await sleep(rand(speed));
}
}
}
if (!loop) {
setShowCursor(false);
return;
}
await sleep(500);
} while (!cancelled);
};
run();
return () => {
cancelled = true;
if (timerId != null) clearTimeout(timerId);
};
}, [configKey]);
useEffect(() => {
if (!showCursor) return;
const id = setInterval(() => setCursorBlink((v) => !v), 530);
return () => clearInterval(id);
}, [showCursor]);
const parts = displayed.split('\n');
return (
<span>
{parts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <br />}
{part}
</React.Fragment>
))}
{showCursor && (
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: '2px',
height: '1em',
backgroundColor: 'currentColor',
marginLeft: '1px',
verticalAlign: 'text-bottom',
opacity: cursorBlink ? 1 : 0,
}}
/>
)}
</span>
);
}