fix: sync stacked more animation with container
This commit is contained in:
parent
eeb3db084b
commit
52f6135d37
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user