feat: 为文章大纲组件添加平滑动画的活动项指示器
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-28 11:30:18 +08:00
1 parent 93eac7e146
commit 8dfe2624ab
1 file changed
+94 -3
+94 -3
View File
@@ -1,12 +1,80 @@
<script lang="ts">
import { tick } from 'svelte';
import type { EditorOutline } from '$lib/editor/outline.svelte';
let { outline, onselect }: { outline: EditorOutline; onselect?: () => void } = $props();
let baseLevel = $derived(Math.min(...outline.headings.map((heading) => heading.level), 6));
let listEl: HTMLUListElement | undefined = $state();
let enableAnimation = $state(false);
let indicatorStyle = $state({ top: 0, height: 0, visible: false });
function updateIndicator(withAnimation = true) {
if (!listEl || outline.active === undefined || outline.active === null || outline.active < 0) {
indicatorStyle = { top: 0, height: 0, visible: false };
return;
}
const buttons = listEl.querySelectorAll<HTMLButtonElement>('button');
const activeBtn = buttons[outline.active];
if (!activeBtn) {
indicatorStyle = { top: 0, height: 0, visible: false };
return;
}
if (!withAnimation) {
enableAnimation = false;
}
indicatorStyle = {
top: activeBtn.offsetTop,
height: activeBtn.offsetHeight,
visible: true
};
if (!withAnimation) {
// 在下一帧恢复动画能力,避免后续切换时丢失平滑过渡
requestAnimationFrame(() => {
enableAnimation = true;
});
} else {
enableAnimation = true;
}
}
$effect(() => {
// 监听大纲激活索引与标题列表变化
void outline.active;
void outline.headings;
tick().then(() => {
// 初次挂载静默定位,后续章节变动启用平滑动效
updateIndicator(enableAnimation);
});
});
$effect(() => {
if (!listEl) return;
// 监听容器尺寸变化(如侧边栏宽度改变或窗口缩放),静默重校准指示块
const ro = new ResizeObserver(() => {
updateIndicator(false);
});
ro.observe(listEl);
return () => ro.disconnect();
});
</script>
<nav aria-label="正文大纲" class="article-outline">
{#if outline.headings.length}
<ul>
<ul bind:this={listEl}>
<div
class="active-indicator"
class:visible={indicatorStyle.visible}
class:animate={enableAnimation}
style:transform="translateY({indicatorStyle.top}px)"
style:height="{indicatorStyle.height}px"
></div>
{#each outline.headings as heading, index (heading.from)}
<li>
<button
@@ -41,6 +109,7 @@
font-size: 0.85rem;
}
ul {
position: relative;
list-style: none;
padding: 0;
margin: 0;
@@ -49,6 +118,8 @@
margin: 0 0 4px;
}
button {
position: relative;
z-index: 1;
display: block;
width: 100%;
border: 0;
@@ -60,19 +131,39 @@
font-size: 0.85rem;
text-align: left;
cursor: pointer;
transition: color 0.2s ease;
}
button:hover {
background: rgba(65, 155, 56, 0.06);
}
button.active {
border-left-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
button:focus-visible {
outline: 2px solid var(--accent-color, #419b38);
outline-offset: -2px;
}
.active-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
pointer-events: none;
z-index: 0;
border-left: 2px solid var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
opacity: 0;
will-change: transform, height;
}
.active-indicator.visible {
opacity: 1;
}
.active-indicator.animate {
transition:
transform 240ms cubic-bezier(0.16, 1, 0.3, 1),
height 240ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 160ms ease;
}
span {
display: -webkit-box;
-webkit-line-clamp: 2;