fix(frontend): 重构堆叠块高度引擎,根治鬼畜抖动并统一动画
StackedBlocks 旧实现每帧用 getBoundingClientRect 读正在过渡的块高度, 叠加 360ms runSync 轮询 + ResizeObserver + animateMoreToggle 三个驱动源 互相触发,形成不收敛的测量-回写反馈环 = 高频上下抽搐;且新块出现/展开 收起无外框动画(瞬间赋值且无 transition)。 高度引擎重构: - 改测真实 DOM 稳定值(header.offsetHeight + content-inner.offsetHeight + border)算每块高度。content-inner 自身高度不受父级 collapsible-content 的 max-height 过渡/overflow 裁切影响,是稳定目标态值,杜绝读动画中间帧。 - 删除 runSync 轮询、animateMoreToggle JS lerp、逐帧 getBoundingClientRect。 measureAndCompute 仅在状态变化时测一次。 - ResizeObserver 只观察 content-inner(流式增长源),不观察被 transition 的 元素,断开写→RO→写回环。 - shell/viewport/inner 三者同一条 height/transform 260ms transition 同步过渡, 外框增高、传送带上移、展开收起、收起裁切全平滑。 单块路径统一(修 1→2 块重建鬼畜): - splitActionGroups 阈值 >=2 改 >=1,单个 thinking/tool 也走 StackedBlocks, 消除 single↔stack 路径整体切换导致的组件重新挂载(表现为外框瞬间塌扁再展开)。 - 单块用 isSingle + .stacked-shell--single 还原原 single 外观(12px 圆角+透明底)。 首次/后续进场动画区分: - ChatArea 传 conversation-running + is-latest-message。 - animated(运行中+最新) 控 TransitionGroup :appear/:css(块进场动画); ready(首次 measure 后一次性) 控 shell/viewport/inner/collapsible-content 的 transition:初始挂载/历史批量加载高度直接到位不跳动,之后启用使展开/ 收起在历史对话里也平滑。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d4f6c9e56
commit
4807c0b9f1
@ -117,6 +117,8 @@
|
||||
class="stacked-blocks-wrapper"
|
||||
:actions="group.actions"
|
||||
:expanded-blocks="expandedBlocks"
|
||||
:conversation-running="streamingMessage"
|
||||
:is-latest-message="index === latestMessageIndex"
|
||||
:icon-style="iconStyleSafe"
|
||||
:toggle-block="toggleBlock"
|
||||
:register-thinking-ref="registerThinkingRef"
|
||||
@ -1535,20 +1537,15 @@ const splitActionGroups = (actions: any[] = [], messageIndex = 0) => {
|
||||
}
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (buffer.length >= 2) {
|
||||
// 单个可堆叠块(thinking/tool)也走 stack 路径,避免 1↔2 块时在 single/stack
|
||||
// 两套渲染路径之间整体切换导致 StackedBlocks 重新挂载(表现为外框瞬间塌成最扁再展开)。
|
||||
// 单块时 StackedBlocks 内部 moreVisible=false,不显示「更多」头,外观由 CSS 对齐 single。
|
||||
if (buffer.length >= 1) {
|
||||
result.push({
|
||||
kind: 'stack',
|
||||
actions: buffer.slice(),
|
||||
key: `stack-${messageIndex}-${result.length}`
|
||||
});
|
||||
} else if (buffer.length === 1) {
|
||||
const single = buffer[0];
|
||||
result.push({
|
||||
kind: 'single',
|
||||
action: single,
|
||||
actionIndex: actions.indexOf(single),
|
||||
key: single.id || `single-${messageIndex}-${result.length}`
|
||||
});
|
||||
}
|
||||
buffer = [];
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="stacked-shell"
|
||||
:class="{ 'stacked-shell--single': isSingle, 'stacked-shell--not-ready': !ready }"
|
||||
ref="shell"
|
||||
:style="{
|
||||
height: `${shellHeight}px`,
|
||||
@ -28,7 +29,8 @@
|
||||
class="stacked-inner"
|
||||
ref="inner"
|
||||
:style="{ transform: `translateY(${innerOffset}px)` }"
|
||||
appear
|
||||
:appear="animated"
|
||||
:css="animated"
|
||||
>
|
||||
<div
|
||||
v-for="(action, idx) in stackableActions"
|
||||
@ -158,6 +160,8 @@ defineOptions({ name: 'StackedBlocks' });
|
||||
const props = defineProps<{
|
||||
actions: any[];
|
||||
expandedBlocks: Set<string>;
|
||||
conversationRunning?: boolean;
|
||||
isLatestMessage?: boolean;
|
||||
iconStyle: (key: string, size?: string) => Record<string, string>;
|
||||
toggleBlock: (blockId: string) => void;
|
||||
registerThinkingRef?: (key: string, el: Element | null) => void;
|
||||
@ -198,13 +202,13 @@ const shellHeight = ref(0);
|
||||
const moreHeight = ref(0);
|
||||
const innerOffset = ref(0);
|
||||
const viewportHeight = ref(0);
|
||||
// 首帧就绪标志:false 时禁用高度过渡(初始挂载/批量加载直接到位),首次 measure 后置 true。
|
||||
const ready = ref(false);
|
||||
const contentHeights = ref<Record<string, number>>({});
|
||||
const ANIMATION_SYNC_MS = 360;
|
||||
const MORE_ANIMATION_MS = 300;
|
||||
const COLLAPSE_MAX_HEIGHT = 600;
|
||||
let syncRaf: number | null = null;
|
||||
let syncUntil = 0;
|
||||
const isMoreAnimating = ref(false);
|
||||
const HEADER_FALLBACK = 36; // 仅在 DOM 未挂载、读不到 header 时兜底
|
||||
// content-inner 真实 DOM 引用,用于读取稳定的内容高度(不受 max-height 过渡影响)
|
||||
const contentInnerEls = new Map<string, HTMLElement>();
|
||||
|
||||
const stackableActions = computed(() =>
|
||||
(props.actions || []).filter((item) => item && (item.type === 'thinking' || item.type === 'tool'))
|
||||
@ -212,6 +216,14 @@ const stackableActions = computed(() =>
|
||||
|
||||
const hiddenCount = computed(() => Math.max(0, stackableActions.value.length - VISIBLE_LIMIT));
|
||||
const moreVisible = computed(() => hiddenCount.value > 0 || showAll.value);
|
||||
// 单块场景:外观需与原 single 路径(.collapsible-block 12px 圆角/透明底)一致,
|
||||
// 由 .stacked-shell--single 还原,避免「单块也走堆叠组件」后单块外观变样。
|
||||
const isSingle = computed(() => stackableActions.value.length === 1);
|
||||
|
||||
// 是否播放进场动画:仅「对话运行中 + 当前是最新消息」时播(新块逐个平滑进场)。
|
||||
// 历史加载 / 对话重建 / 非最新消息:禁用 appear + enter 动画,块瞬间出现,
|
||||
// 避免一次性挂载多个块时整个对话区剧烈高度跳动。
|
||||
const animated = computed(() => !!props.conversationRunning && !!props.isLatestMessage);
|
||||
const totalSteps = computed(() => stackableActions.value.length);
|
||||
const moreTitle = computed(() => (showAll.value ? '已展开全部' : '更多'));
|
||||
const moreDesc = computed(() =>
|
||||
@ -235,17 +247,24 @@ const isToolProcessing = (action: any) => {
|
||||
const isToolCompleted = (action: any) => action?.tool?.status === 'completed';
|
||||
|
||||
const toggleMore = () => {
|
||||
animateMoreToggle(!showAll.value);
|
||||
showAll.value = !showAll.value;
|
||||
scheduleMeasure();
|
||||
};
|
||||
|
||||
const toggleBlock = (blockId: string) => {
|
||||
if (typeof props.toggleBlock === 'function') {
|
||||
props.toggleBlock(blockId);
|
||||
nextTick(() => runSync());
|
||||
scheduleMeasure();
|
||||
}
|
||||
};
|
||||
|
||||
const registerThinking = (key: string, el: Element | null) => {
|
||||
// 记录 content-inner 真实引用用于高度测量,同时透传给上层(思考块自动滚动)
|
||||
if (el instanceof HTMLElement) {
|
||||
contentInnerEls.set(key, el);
|
||||
} else {
|
||||
contentInnerEls.delete(key);
|
||||
}
|
||||
if (typeof props.registerThinkingRef === 'function') {
|
||||
props.registerThinkingRef(key, el);
|
||||
}
|
||||
@ -271,27 +290,38 @@ const moreBaseHeight = () => {
|
||||
return Math.max(48, Math.ceil(label?.getBoundingClientRect().height || 56));
|
||||
};
|
||||
|
||||
const setShellMetrics = () => {
|
||||
// 核心:测真实 DOM 的稳定值算每块高度,再算 shell/offset/viewport。
|
||||
// 关键稳定性保证:
|
||||
// - header.offsetHeight:表头固定高(min/max-height:36),展开/折叠都不变;
|
||||
// - content-inner.offsetHeight:内容自然高度,即使父级 collapsible-content 的 max-height
|
||||
// 正在 0↔目标 过渡(被 overflow:hidden 裁切),content-inner 自身 offsetHeight 仍是
|
||||
// 稳定的完整值(裁切不影响子级布局高度)。
|
||||
// 因此任何时刻读到的都是「目标态」高度,不是动画中间帧 → 写入 shellHeight/innerOffset 后由
|
||||
// CSS transition 平滑(外框增高 / 传送带上移 / 展开收起),绝不抖动。
|
||||
const measureAndCompute = () => {
|
||||
const innerEl = resolveEl(inner.value);
|
||||
const shellEl = resolveEl(shell.value);
|
||||
if (!shellEl || !innerEl) return;
|
||||
|
||||
const children = Array.from(innerEl.children) as HTMLElement[];
|
||||
const acts = stackableActions.value;
|
||||
const heights: number[] = [];
|
||||
const nextContentHeights: Record<string, number> = {};
|
||||
|
||||
children.forEach((el, idx) => {
|
||||
const action = stackableActions.value[idx];
|
||||
const action = acts[idx];
|
||||
const key = blockKey(action, idx);
|
||||
const content = el.querySelector('.collapsible-content') as HTMLElement | null;
|
||||
const contentHeight = content
|
||||
? Math.min(Math.ceil(content.scrollHeight), COLLAPSE_MAX_HEIGHT)
|
||||
: 0;
|
||||
nextContentHeights[key] = contentHeight;
|
||||
|
||||
// 使用当前真实高度而非「展开/折叠」状态推导值,避免在折叠动画过程中出现高度骤减导致的闪烁
|
||||
const liveHeight = Math.ceil(el.getBoundingClientRect().height || el.offsetHeight || 0);
|
||||
heights.push(liveHeight);
|
||||
const header = el.querySelector('.collapsible-header') as HTMLElement | null;
|
||||
const innerC = el.querySelector('.content-inner') as HTMLElement | null;
|
||||
const headerH = header ? Math.ceil(header.offsetHeight) : HEADER_FALLBACK;
|
||||
// 展开后内容可见高度(封顶 600);content-inner 自身高度稳定(思考块被自己的
|
||||
// max-height:240 限制,工具块为内容自然高),不受 collapsible-content 过渡影响。
|
||||
const fullContent = innerC ? Math.min(Math.ceil(innerC.offsetHeight), COLLAPSE_MAX_HEIGHT) : 0;
|
||||
nextContentHeights[key] = fullContent;
|
||||
const expanded = isExpandedById(key);
|
||||
// .stacked-item 之间 1px 分隔(最后一个无 border-bottom)
|
||||
const borderH = idx === children.length - 1 ? 0 : 1;
|
||||
heights.push(headerH + (expanded ? fullContent : 0) + borderH);
|
||||
});
|
||||
|
||||
contentHeights.value = nextContentHeights;
|
||||
@ -302,123 +332,48 @@ const setShellMetrics = () => {
|
||||
const windowHeight = sum(heights.slice(-VISIBLE_LIMIT));
|
||||
|
||||
moreHeight.value = moreVisible.value ? moreBaseHeight() : 0;
|
||||
|
||||
const targetShell =
|
||||
moreHeight.value + (showAll.value || !moreVisible.value ? totalHeight : windowHeight);
|
||||
const targetOffset = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
|
||||
|
||||
shellHeight.value = targetShell;
|
||||
innerOffset.value = targetOffset;
|
||||
innerOffset.value = showAll.value || !moreVisible.value ? 0 : -hiddenHeight;
|
||||
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);
|
||||
// 等 DOM 渲染稳定后测量一次(双帧:Vue patch + 浏览器布局完成后再读 offsetHeight)。
|
||||
const scheduleMeasure = () => {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
measureAndCompute();
|
||||
// 首次测量、高度直接到位后,再下一帧启用过渡;此后展开/收起/增长等变化才平滑,
|
||||
// 而初始挂载/批量加载不会从 0 高度过渡跳动。
|
||||
if (!ready.value) {
|
||||
requestAnimationFrame(() => {
|
||||
ready.value = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let heightRaf: number | null = null;
|
||||
let roRaf: number | null = null;
|
||||
|
||||
const observeAll = () => {
|
||||
if (!resizeObserver) return;
|
||||
resizeObserver.disconnect();
|
||||
// 只观察 content-inner(内容自然高度,流式增长会变)。不观察 collapsible-content /
|
||||
// stacked-inner 等被 transition 的元素,避免「写 → 触发 RO → 再写」的回环抖动。
|
||||
contentInnerEls.forEach((el) => resizeObserver!.observe(el));
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const innerEl = resolveEl(inner.value);
|
||||
setShellMetrics();
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (heightRaf) {
|
||||
cancelAnimationFrame(heightRaf);
|
||||
}
|
||||
heightRaf = requestAnimationFrame(() => {
|
||||
setShellMetrics();
|
||||
scheduleMeasure();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (roRaf) cancelAnimationFrame(roRaf);
|
||||
roRaf = requestAnimationFrame(() => measureAndCompute());
|
||||
});
|
||||
});
|
||||
if (innerEl) {
|
||||
resizeObserver.observe(innerEl);
|
||||
nextTick(() => observeAll());
|
||||
}
|
||||
});
|
||||
|
||||
@ -427,24 +382,82 @@ onBeforeUnmount(() => {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (heightRaf) {
|
||||
cancelAnimationFrame(heightRaf);
|
||||
heightRaf = null;
|
||||
if (roRaf) {
|
||||
cancelAnimationFrame(roRaf);
|
||||
roRaf = null;
|
||||
}
|
||||
if (syncRaf) {
|
||||
cancelAnimationFrame(syncRaf);
|
||||
syncRaf = null;
|
||||
}
|
||||
});
|
||||
|
||||
watch([stackableActions, moreVisible], () => {
|
||||
nextTick(() => runSync());
|
||||
});
|
||||
|
||||
watch(
|
||||
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||||
() => {
|
||||
nextTick(() => runSync());
|
||||
const acts = stackableActions.value;
|
||||
const firstId = acts[0]?.blockId || acts[0]?.id || '';
|
||||
const lastId = acts[acts.length - 1]?.blockId || acts[acts.length - 1]?.id || '';
|
||||
// 指纹:块数 + 首尾 + moreVisible + 每块内部状态/内容长度桶(追踪流式增长),
|
||||
// 比直接 watch 数组引用更精准,避免父组件重渲染传新引用导致的过度触发。
|
||||
const snap = acts
|
||||
.map((a: any) => {
|
||||
if (a?.type === 'tool') return `T:${a?.tool?.status || '?'}${a?.tool?.result ? 'R' : '-'}`;
|
||||
if (a?.type === 'thinking')
|
||||
return `H:${a?.streaming ? 'S' : '-'}${Math.floor((a?.content?.length || 0) / 48)}`;
|
||||
return '?';
|
||||
})
|
||||
.join(',');
|
||||
return `${acts.length}|${firstId}|${lastId}|${moreVisible.value}|${snap}`;
|
||||
},
|
||||
() => {
|
||||
nextTick(() => {
|
||||
observeAll();
|
||||
scheduleMeasure();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => (props.expandedBlocks ? props.expandedBlocks.size : 0),
|
||||
() => scheduleMeasure()
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 重构核心:shell 高度、viewport 高度、inner 位移三者用同一条 transition,使「外框增高」
|
||||
「内部显示区裁切边界」「传送带上移/展开收起」完全同步过渡,时长/缓动与单块
|
||||
collapsible-content(max-height 260ms) 对齐,互不打架。
|
||||
替换了原先 JS 逐帧 lerp(animateMoreToggle) + 360ms runSync 轮询的方案,从根上去抖。
|
||||
注意:viewport(显示区) 必须与 shell 同步过渡,否则收起瞬间 viewport 高度先跳到折叠后
|
||||
终值,把还在收缩的下方块瞬间裁掉再重现,造成割裂。 */
|
||||
.stacked-shell {
|
||||
transition:
|
||||
height 260ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
padding-top 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: height;
|
||||
}
|
||||
.stacked-viewport {
|
||||
transition: height 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: height;
|
||||
}
|
||||
.stacked-inner {
|
||||
transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
/* 首帧未就绪(ready=false):禁用 shell/viewport/inner 的高度/位移过渡,让初始挂载 /
|
||||
批量历史加载时高度直接到位,避免从 0 高度过渡造成整个对话区剧烈跳动。
|
||||
首次 measure 完成后置 ready=true,此后展开/收起、流式增长、传送带等高度变化均平滑——
|
||||
注意:展开/收起是用户主动交互,历史对话里也必须平滑,故用 ready(一次性) 而非 animated 控制。
|
||||
块的 enter/appear 进场动画另由模板 :css/:appear="animated" 控制(仅运行中+最新才播)。
|
||||
同时禁用内部 collapsible-content 的 max-height/opacity 过渡,否则历史加载时已展开块的
|
||||
内容会从 opacity:0 淡入(表现为内部文字仍有动画)。 */
|
||||
.stacked-shell--not-ready,
|
||||
.stacked-shell--not-ready .stacked-viewport,
|
||||
.stacked-shell--not-ready .stacked-inner,
|
||||
.stacked-shell--not-ready .collapsible-content {
|
||||
transition: none !important;
|
||||
}
|
||||
/* 单块场景:还原原 single 路径(.collapsible-block)的外观——12px 圆角 + 透明底,
|
||||
而非多块外壳的 16px 圆角 + card 底。其余(1px 边框)两者本就一致。
|
||||
这样「单块也走堆叠组件」后,单块视觉与改造前完全相同。 */
|
||||
.stacked-shell--single {
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user