From 52f6135d377e2775a6021053357456462cc26495 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 1 Jan 2026 15:00:42 +0800 Subject: [PATCH] fix: sync stacked more animation with container --- static/src/components/chat/StackedBlocks.vue | 130 +++++++++++++++--- .../styles/components/chat/_chat-area.scss | 4 - 2 files changed, 109 insertions(+), 25 deletions(-) diff --git a/static/src/components/chat/StackedBlocks.vue b/static/src/components/chat/StackedBlocks.vue index d40670f..a309bce 100644 --- a/static/src/components/chat/StackedBlocks.vue +++ b/static/src/components/chat/StackedBlocks.vue @@ -153,6 +153,11 @@ const moreHeight = ref(0); const innerOffset = ref(0); const viewportHeight = ref(0); const contentHeights = ref>({}); +const ANIMATION_SYNC_MS = 360; +const MORE_ANIMATION_MS = 300; +let syncRaf: number | null = null; +let syncUntil = 0; +const isMoreAnimating = ref(false); const stackableActions = computed(() => (props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))); @@ -181,18 +186,13 @@ const isToolProcessing = (action: any) => { const isToolCompleted = (action: any) => action?.tool?.status === 'completed'; const toggleMore = () => { - showAll.value = !showAll.value; - nextTick(() => { - setShellMetrics(); - }); + animateMoreToggle(!showAll.value); }; const toggleBlock = (blockId: string) => { if (typeof props.toggleBlock === 'function') { props.toggleBlock(blockId); - nextTick(() => { - setShellMetrics(); - }); + nextTick(() => runSync()); } }; @@ -222,7 +222,7 @@ const moreBaseHeight = () => { return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56)); }; -const setShellMetrics = (expandedOverride: Record = {}) => { +const setShellMetrics = () => { const innerEl = resolveEl(inner.value); const shellEl = resolveEl(shell.value); if (!shellEl || !innerEl) return; @@ -234,18 +234,13 @@ const setShellMetrics = (expandedOverride: Record = {}) => { children.forEach((el, idx) => { const action = stackableActions.value[idx]; const key = blockKey(action, idx); - const header = el.querySelector('.collapsible-header') as HTMLElement | null; const content = el.querySelector('.collapsible-content') as HTMLElement | null; - const progress = el.querySelector('.progress-indicator') as HTMLElement | null; - const expanded = typeof expandedOverride[key] === 'boolean' ? expandedOverride[key] : isExpanded(action, idx); - const headerH = header ? header.getBoundingClientRect().height : 0; const contentHeight = content ? Math.ceil(content.scrollHeight) : 0; nextContentHeights[key] = contentHeight; - const contentH = expanded ? contentHeight : 0; - // 进度条是绝对定位的,不应计入布局高度,否则会导致块高度在运行时膨胀几像素 - const progressH = 0; - const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0; - heights.push(Math.ceil(headerH + contentH + progressH + borderH)); + + // 使用当前真实高度而非「展开/折叠」状态推导值,避免在折叠动画过程中出现高度骤减导致的闪烁 + const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0); + heights.push(liveHeight); }); contentHeights.value = nextContentHeights; @@ -265,6 +260,97 @@ const setShellMetrics = (expandedOverride: Record = {}) => { viewportHeight.value = Math.max(0, targetShell - moreHeight.value); }; +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); + +const animateMoreToggle = (nextShowAll: boolean) => { + if (isMoreAnimating.value) return; + + // 停掉其他同步,避免冲突 + if (syncRaf) { + cancelAnimationFrame(syncRaf); + syncRaf = null; + } + + const innerEl = resolveEl(inner.value); + const shellEl = resolveEl(shell.value); + if (!innerEl || !shellEl) { + showAll.value = nextShowAll; + nextTick(() => runSync()); + return; + } + + // 先计算当前状态 + setShellMetrics(); + const start = { + shell: shellHeight.value, + offset: innerOffset.value, + viewport: viewportHeight.value, + more: moreHeight.value + }; + + // 切换到目标状态并计算目标值 + showAll.value = nextShowAll; + setShellMetrics(); + const target = { + shell: shellHeight.value, + offset: innerOffset.value, + viewport: viewportHeight.value, + more: moreHeight.value + }; + + // 回到起点数值,准备动画 + shellHeight.value = start.shell; + innerOffset.value = start.offset; + viewportHeight.value = start.viewport; + moreHeight.value = start.more; + + isMoreAnimating.value = true; + const startedAt = performance.now(); + + const step = () => { + const now = performance.now(); + const t = Math.min(1, (now - startedAt) / MORE_ANIMATION_MS); + const eased = easeOutCubic(t); + + shellHeight.value = lerp(start.shell, target.shell, eased); + innerOffset.value = lerp(start.offset, target.offset, eased); + viewportHeight.value = lerp(start.viewport, target.viewport, eased); + moreHeight.value = lerp(start.more, target.more, eased); + + if (t < 1) { + requestAnimationFrame(step); + } else { + isMoreAnimating.value = false; + shellHeight.value = target.shell; + innerOffset.value = target.offset; + viewportHeight.value = target.viewport; + moreHeight.value = target.more; + setShellMetrics(); + } + }; + + requestAnimationFrame(step); +}; + +const runSync = () => { + if (isMoreAnimating.value) return; + if (syncRaf) { + cancelAnimationFrame(syncRaf); + syncRaf = null; + } + syncUntil = performance.now() + ANIMATION_SYNC_MS; + const step = () => { + setShellMetrics(); + if (performance.now() < syncUntil) { + syncRaf = requestAnimationFrame(step); + } else { + syncRaf = null; + } + }; + syncRaf = requestAnimationFrame(step); +}; + let resizeObserver: ResizeObserver | null = null; let heightRaf: number | null = null; @@ -293,18 +379,20 @@ onBeforeUnmount(() => { cancelAnimationFrame(heightRaf); heightRaf = null; } + if (syncRaf) { + cancelAnimationFrame(syncRaf); + syncRaf = null; + } }); watch([stackableActions, moreVisible], () => { - nextTick(() => { - setShellMetrics(); - }); + nextTick(() => runSync()); }); watch( () => (props.expandedBlocks ? props.expandedBlocks.size : 0), () => { - nextTick(() => setShellMetrics()); + nextTick(() => runSync()); } ); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 6f44b17..453bfea 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -324,16 +324,12 @@ background: var(--claude-card); box-shadow: var(--claude-shadow); overflow: hidden; - transition: - height 280ms cubic-bezier(0.25, 0.9, 0.3, 1), - padding-top 280ms cubic-bezier(0.25, 0.9, 0.3, 1); min-height: 0; } .stacked-inner { position: relative; width: 100%; - transition: transform 280ms cubic-bezier(0.25, 0.9, 0.3, 1); } .stacked-viewport {