fix: sync stacked more animation with container

This commit is contained in:
JOJO 2026-01-01 15:00:42 +08:00
parent eeb3db084b
commit 52f6135d37
2 changed files with 109 additions and 25 deletions

View File

@ -153,6 +153,11 @@ const moreHeight = ref(0);
const innerOffset = ref(0);
const viewportHeight = ref(0);
const contentHeights = ref<Record<string, number>>({});
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<string, boolean> = {}) => {
const setShellMetrics = () => {
const innerEl = resolveEl(inner.value);
const shellEl = resolveEl(shell.value);
if (!shellEl || !innerEl) return;
@ -234,18 +234,13 @@ const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
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<string, boolean> = {}) => {
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());
}
);
</script>

View File

@ -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 {