上傳檔案到「/」
This commit is contained in:
109
TypingEffect.jsx
Normal file
109
TypingEffect.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user