231 lines
7.4 KiB
HTML
231 lines
7.4 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>体育场形裂变动画 Demo</title>
|
||
<style>
|
||
:root {
|
||
font-family: "SF Pro Display", "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
background: #12100f;
|
||
color: #f3efe6;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 42px 16px;
|
||
background: transparent;
|
||
}
|
||
|
||
.demo-card {
|
||
width: min(880px, 95vw);
|
||
padding: 0;
|
||
background: transparent;
|
||
border-radius: 0;
|
||
box-shadow: none;
|
||
border: none;
|
||
}
|
||
|
||
.demo-card h1,
|
||
.demo-card p,
|
||
.hint {
|
||
display: none;
|
||
}
|
||
|
||
.shell-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
.input-shell {
|
||
position: relative;
|
||
width: 100%;
|
||
background: linear-gradient(180deg, #fdf7ea 0%, #f2c46e 100%);
|
||
padding: 14px 70px;
|
||
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.55);
|
||
min-height: 64px;
|
||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||
transition: padding 0.25s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||
clip-path: url(#stadiumClip);
|
||
-webkit-clip-path: url(#stadiumClip);
|
||
}
|
||
|
||
.input-shell.expanded {
|
||
padding-top: 22px;
|
||
padding-bottom: 22px;
|
||
box-shadow: 0 45px 90px rgba(0, 0, 0, 0.65);
|
||
border-color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
|
||
textarea {
|
||
width: 100%;
|
||
min-height: 28px;
|
||
max-height: 240px;
|
||
border: none;
|
||
resize: none;
|
||
background: transparent;
|
||
font-size: 16px;
|
||
line-height: 1.6;
|
||
font-family: inherit;
|
||
color: #1f160c;
|
||
outline: none;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
textarea::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
textarea::-webkit-scrollbar-thumb {
|
||
background: rgba(60, 53, 40, 0.25);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
svg.clip-defs {
|
||
width: 0;
|
||
height: 0;
|
||
position: absolute;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="demo-card">
|
||
<h1>体育场形 → 圆角矩形(裂解动画示例)</h1>
|
||
<p>单行时保持典型体育场形,输入多行后,左右半圆沿水平中线裂开成四个四分之一圆,并拉伸出上下两条直边。</p>
|
||
|
||
<div class="shell-wrapper">
|
||
<div class="input-shell" id="shell">
|
||
<textarea id="demoInput" rows="1" placeholder="粘贴或输入多行内容,观察动画..."></textarea>
|
||
</div>
|
||
|
||
<svg class="clip-defs">
|
||
<clipPath id="stadiumClip" clipPathUnits="userSpaceOnUse">
|
||
<path id="stadiumPath" d="" />
|
||
</clipPath>
|
||
</svg>
|
||
</div>
|
||
<div class="hint">提示:Shift+Enter 换行;窗口尺寸改变也会重新计算形状。</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const textarea = document.getElementById('demoInput');
|
||
const shell = document.getElementById('shell');
|
||
const path = document.getElementById('stadiumPath');
|
||
|
||
if (!textarea || !shell || !path) {
|
||
return;
|
||
}
|
||
|
||
let animationFrame = null;
|
||
let currentProgress = 0;
|
||
let targetProgress = 0;
|
||
let baseHeight = 0;
|
||
let baseRadius = 0;
|
||
|
||
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||
|
||
const ensureBase = () => {
|
||
if (baseHeight > 0) {
|
||
return;
|
||
}
|
||
baseHeight = shell.offsetHeight;
|
||
baseRadius = baseHeight / 2;
|
||
shell.dataset.baseHeight = String(baseHeight);
|
||
shell.dataset.baseRadius = String(baseRadius);
|
||
};
|
||
|
||
const buildPath = (width, height, progress) => {
|
||
if (width <= 0 || height <= 0) {
|
||
return '';
|
||
}
|
||
|
||
ensureBase();
|
||
const radius = baseRadius;
|
||
const midY = height / 2;
|
||
const targetTopCenter = radius;
|
||
const targetBottomCenter = Math.max(height - radius, radius);
|
||
|
||
const cyTop = midY - (midY - targetTopCenter) * progress;
|
||
const cyBottom = midY + (targetBottomCenter - midY) * progress;
|
||
|
||
const cxLeft = radius;
|
||
const cxRight = width - radius;
|
||
const topY = cyTop - radius;
|
||
const bottomY = cyBottom + radius;
|
||
|
||
return [
|
||
`M ${cxLeft} ${topY}`,
|
||
`H ${cxRight}`,
|
||
`A ${radius} ${radius} 0 0 1 ${width} ${cyTop}`,
|
||
`V ${cyBottom}`,
|
||
`A ${radius} ${radius} 0 0 1 ${cxRight} ${bottomY}`,
|
||
`H ${cxLeft}`,
|
||
`A ${radius} ${radius} 0 0 1 0 ${cyBottom}`,
|
||
`V ${cyTop}`,
|
||
`A ${radius} ${radius} 0 0 1 ${cxLeft} ${topY}`,
|
||
'Z'
|
||
].join(' ');
|
||
};
|
||
|
||
const animateProgress = () => {
|
||
if (Math.abs(currentProgress - targetProgress) < 0.002) {
|
||
currentProgress = targetProgress;
|
||
animationFrame = null;
|
||
} else {
|
||
currentProgress += (targetProgress - currentProgress) * 0.18;
|
||
animationFrame = requestAnimationFrame(animateProgress);
|
||
}
|
||
|
||
updatePath();
|
||
};
|
||
|
||
const updatePath = () => {
|
||
ensureBase();
|
||
const rect = shell.getBoundingClientRect();
|
||
const width = rect.width;
|
||
const height = shell.offsetHeight;
|
||
const d = buildPath(width, height, currentProgress);
|
||
path.setAttribute('d', d);
|
||
};
|
||
|
||
const resizeTextarea = () => {
|
||
ensureBase();
|
||
textarea.style.height = 'auto';
|
||
const computed = window.getComputedStyle(textarea);
|
||
const lineHeight = parseFloat(computed.lineHeight || '20') || 20;
|
||
const maxHeight = lineHeight * 6;
|
||
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||
textarea.style.height = `${nextHeight}px`;
|
||
|
||
const lineCount = Math.max(1, Math.round(nextHeight / lineHeight));
|
||
targetProgress = clamp((lineCount - 1) / 4, 0, 1);
|
||
|
||
shell.classList.toggle('expanded', lineCount > 1);
|
||
|
||
if (!animationFrame) {
|
||
animationFrame = requestAnimationFrame(animateProgress);
|
||
}
|
||
};
|
||
|
||
textarea.addEventListener('input', resizeTextarea);
|
||
window.addEventListener('resize', () => {
|
||
updatePath();
|
||
});
|
||
|
||
// 初始化
|
||
textarea.value = '';
|
||
resizeTextarea();
|
||
updatePath();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|