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 innerOffset = ref(0);
|
||||||
const viewportHeight = ref(0);
|
const viewportHeight = ref(0);
|
||||||
const contentHeights = ref<Record<string, number>>({});
|
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')));
|
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 isToolCompleted = (action: any) => action?.tool?.status === 'completed';
|
||||||
|
|
||||||
const toggleMore = () => {
|
const toggleMore = () => {
|
||||||
showAll.value = !showAll.value;
|
animateMoreToggle(!showAll.value);
|
||||||
nextTick(() => {
|
|
||||||
setShellMetrics();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBlock = (blockId: string) => {
|
const toggleBlock = (blockId: string) => {
|
||||||
if (typeof props.toggleBlock === 'function') {
|
if (typeof props.toggleBlock === 'function') {
|
||||||
props.toggleBlock(blockId);
|
props.toggleBlock(blockId);
|
||||||
nextTick(() => {
|
nextTick(() => runSync());
|
||||||
setShellMetrics();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -222,7 +222,7 @@ const moreBaseHeight = () => {
|
|||||||
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
|
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
|
const setShellMetrics = () => {
|
||||||
const innerEl = resolveEl(inner.value);
|
const innerEl = resolveEl(inner.value);
|
||||||
const shellEl = resolveEl(shell.value);
|
const shellEl = resolveEl(shell.value);
|
||||||
if (!shellEl || !innerEl) return;
|
if (!shellEl || !innerEl) return;
|
||||||
@ -234,18 +234,13 @@ const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
|
|||||||
children.forEach((el, idx) => {
|
children.forEach((el, idx) => {
|
||||||
const action = stackableActions.value[idx];
|
const action = stackableActions.value[idx];
|
||||||
const key = blockKey(action, 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 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;
|
const contentHeight = content ? Math.ceil(content.scrollHeight) : 0;
|
||||||
nextContentHeights[key] = contentHeight;
|
nextContentHeights[key] = contentHeight;
|
||||||
const contentH = expanded ? contentHeight : 0;
|
|
||||||
// 进度条是绝对定位的,不应计入布局高度,否则会导致块高度在运行时膨胀几像素
|
// 使用当前真实高度而非「展开/折叠」状态推导值,避免在折叠动画过程中出现高度骤减导致的闪烁
|
||||||
const progressH = 0;
|
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
|
||||||
const borderH = idx < children.length - 1 ? parseFloat(getComputedStyle(el).borderBottomWidth) || 0 : 0;
|
heights.push(liveHeight);
|
||||||
heights.push(Math.ceil(headerH + contentH + progressH + borderH));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
contentHeights.value = nextContentHeights;
|
contentHeights.value = nextContentHeights;
|
||||||
@ -265,6 +260,97 @@ const setShellMetrics = (expandedOverride: Record<string, boolean> = {}) => {
|
|||||||
viewportHeight.value = Math.max(0, targetShell - moreHeight.value);
|
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 resizeObserver: ResizeObserver | null = null;
|
||||||
let heightRaf: number | null = null;
|
let heightRaf: number | null = null;
|
||||||
|
|
||||||
@ -293,18 +379,20 @@ onBeforeUnmount(() => {
|
|||||||
cancelAnimationFrame(heightRaf);
|
cancelAnimationFrame(heightRaf);
|
||||||
heightRaf = null;
|
heightRaf = null;
|
||||||
}
|
}
|
||||||
|
if (syncRaf) {
|
||||||
|
cancelAnimationFrame(syncRaf);
|
||||||
|
syncRaf = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([stackableActions, moreVisible], () => {
|
watch([stackableActions, moreVisible], () => {
|
||||||
nextTick(() => {
|
nextTick(() => runSync());
|
||||||
setShellMetrics();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => setShellMetrics());
|
nextTick(() => runSync());
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -324,16 +324,12 @@
|
|||||||
background: var(--claude-card);
|
background: var(--claude-card);
|
||||||
box-shadow: var(--claude-shadow);
|
box-shadow: var(--claude-shadow);
|
||||||
overflow: hidden;
|
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;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked-inner {
|
.stacked-inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: transform 280ms cubic-bezier(0.25, 0.9, 0.3, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stacked-viewport {
|
.stacked-viewport {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user