feat(chat): 优化块展开/折叠滚动锚定,避免 stick-to-bottom 弹簧追底顿挫
This commit is contained in:
parent
55b7b7deea
commit
edb557bdd3
@ -108,6 +108,7 @@
|
||||
:render-markdown="renderMarkdown"
|
||||
:register-thinking-ref="registerThinkingRef"
|
||||
:handle-thinking-scroll="props.handleThinkingScroll"
|
||||
@group-toggle="handleMinimalGroupToggle"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="stackedBlocksEnabled">
|
||||
@ -154,6 +155,7 @@
|
||||
group.action.blockId || `${index}-thinking-${group.actionIndex}`
|
||||
)
|
||||
}"
|
||||
:data-block-id="group.action.blockId || `${index}-thinking-${group.actionIndex}`"
|
||||
>
|
||||
<div
|
||||
class="collapsible-header"
|
||||
@ -345,6 +347,7 @@
|
||||
:streaming-message="streamingMessage"
|
||||
:register-collapse-content="registerCollapseContent"
|
||||
:collapse-key="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||||
:block-id="group.action.blockId || `${index}-tool-${group.actionIndex}`"
|
||||
@toggle="
|
||||
toggleBlock(group.action.blockId || `${index}-tool-${group.actionIndex}`)
|
||||
"
|
||||
@ -378,6 +381,7 @@
|
||||
action.blockId || `${index}-thinking-${actionIndex}`
|
||||
)
|
||||
}"
|
||||
:data-block-id="action.blockId || `${index}-thinking-${actionIndex}`"
|
||||
>
|
||||
<div
|
||||
class="collapsible-header"
|
||||
@ -551,6 +555,7 @@
|
||||
:streaming-message="streamingMessage"
|
||||
:register-collapse-content="registerCollapseContent"
|
||||
:collapse-key="action.blockId || `${index}-tool-${actionIndex}`"
|
||||
:block-id="action.blockId || `${index}-tool-${actionIndex}`"
|
||||
@toggle="toggleBlock(action.blockId || `${index}-tool-${actionIndex}`)"
|
||||
/>
|
||||
</div>
|
||||
@ -572,6 +577,7 @@
|
||||
<div
|
||||
class="collapsible-block system-block"
|
||||
:class="{ expanded: expandedBlocks?.has(`system-${index}`) }"
|
||||
:data-block-id="`system-${index}`"
|
||||
>
|
||||
<div class="collapsible-header" @click="toggleBlock(`system-${index}`)">
|
||||
<div class="arrow"></div>
|
||||
@ -603,6 +609,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useStickToBottom } from 'vue-stick-to-bottom';
|
||||
import { useBlockExpansionAnchor } from '@/composables/useBlockExpansionAnchor';
|
||||
import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
||||
import StackedBlocks from './StackedBlocks.vue';
|
||||
import MinimalBlocks from './MinimalBlocks.vue';
|
||||
@ -797,6 +804,10 @@ const {
|
||||
initial: 'instant'
|
||||
});
|
||||
const rootEl = scrollRef;
|
||||
const { anchorBlockElement, stopAll: stopBlockExpansionAnchors } = useBlockExpansionAnchor(
|
||||
scrollRef,
|
||||
{ stopScroll }
|
||||
);
|
||||
const thinkingRefs = new Map<string, HTMLElement | null>();
|
||||
let scrollbarResizeObserver: ResizeObserver | null = null;
|
||||
let bounceTraceCount = 0;
|
||||
@ -1059,6 +1070,78 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function escapeBlockSelector(value: string) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return value.replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function elementLooksStreaming(el: HTMLElement) {
|
||||
return (
|
||||
el.classList.contains('processing') ||
|
||||
el.classList.contains('running') ||
|
||||
!!el.querySelector('.processing, .running, .thinking-animation')
|
||||
);
|
||||
}
|
||||
|
||||
const prevExpandedBlocks = ref(new Set<string>());
|
||||
|
||||
watch(
|
||||
() => props.expandedBlocks,
|
||||
(newSet, oldSet) => {
|
||||
const prev = oldSet ?? prevExpandedBlocks.value;
|
||||
const all = new Set([...Array.from(newSet), ...Array.from(prev)]);
|
||||
const changed = new Set<string>();
|
||||
for (const id of all) {
|
||||
if (newSet.has(id) !== prev.has(id)) {
|
||||
changed.add(id);
|
||||
}
|
||||
}
|
||||
prevExpandedBlocks.value = new Set(newSet);
|
||||
|
||||
if (changed.size === 0) return;
|
||||
const container = scrollRef.value;
|
||||
if (!container) return;
|
||||
|
||||
for (const id of changed) {
|
||||
const el = container.querySelector(
|
||||
`[data-block-id="${escapeBlockSelector(id)}"]`
|
||||
) as HTMLElement | null;
|
||||
if (el) {
|
||||
const isExpanding = newSet.has(id);
|
||||
const streaming = elementLooksStreaming(el);
|
||||
anchorBlockElement(el, id, {
|
||||
direction: 'auto',
|
||||
duration: streaming ? 5000 : 260,
|
||||
extendOnGrowth: streaming,
|
||||
phase: isExpanding ? 'expand' : 'collapse'
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
function handleMinimalGroupToggle(payload: { groupId: string; expanded: boolean }) {
|
||||
const { groupId, expanded } = payload;
|
||||
const container = scrollRef.value;
|
||||
if (!container) return;
|
||||
const el = container.querySelector(
|
||||
`[data-group-id="${escapeBlockSelector(groupId)}"]`
|
||||
) as HTMLElement | null;
|
||||
if (el) {
|
||||
const streaming = elementLooksStreaming(el);
|
||||
anchorBlockElement(el, groupId, {
|
||||
direction: 'auto',
|
||||
duration: streaming ? 5000 : 300,
|
||||
extendOnGrowth: streaming,
|
||||
phase: expanded ? 'expand' : 'collapse'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const registerCollapseContent = (key: string, el: Element | null) => {
|
||||
if (!(el instanceof HTMLElement)) {
|
||||
return;
|
||||
@ -1530,6 +1613,7 @@ watch(scrollRef, () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detachBounceListener();
|
||||
stopBlockExpansionAnchors();
|
||||
if (scrollbarResizeObserver) {
|
||||
scrollbarResizeObserver.disconnect();
|
||||
scrollbarResizeObserver = null;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
<div
|
||||
class="summary-line-text"
|
||||
:class="{ running: isSummaryRunning(group.actions, group.id) }"
|
||||
:data-group-id="group.id"
|
||||
@click="toggleExpand(group.id)"
|
||||
>
|
||||
<div class="summary-content-wrapper">
|
||||
@ -200,6 +201,10 @@ const props = defineProps<{
|
||||
handleThinkingScroll?: (blockId: string, event: Event) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'group-toggle', payload: { groupId: string; expanded: boolean }): void;
|
||||
}>();
|
||||
|
||||
const personalizationStore = usePersonalizationStore();
|
||||
const expandedGroups = ref(new Set<string>());
|
||||
const thinkingRefs = new Map<string, HTMLElement>();
|
||||
@ -672,6 +677,7 @@ const getSummarySteps = (actions: Action[]) => {
|
||||
const toggleExpand = (groupId: string) => {
|
||||
if (expandedGroups.value.has(groupId)) {
|
||||
expandedGroups.value.delete(groupId);
|
||||
emit('group-toggle', { groupId, expanded: false });
|
||||
|
||||
// 折叠后,取消注册该组中的思考内容 ref
|
||||
nextTick(() => {
|
||||
@ -689,6 +695,7 @@ const toggleExpand = (groupId: string) => {
|
||||
});
|
||||
} else {
|
||||
expandedGroups.value.add(groupId);
|
||||
emit('group-toggle', { groupId, expanded: true });
|
||||
|
||||
// 展开后,注册该组中的思考内容 ref
|
||||
nextTick(() => {
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
v-if="action.type === 'thinking'"
|
||||
class="collapsible-block thinking-block stacked-block"
|
||||
:class="{ expanded: isExpanded(action, idx), processing: action.streaming }"
|
||||
:data-block-id="blockKey(action, idx)"
|
||||
>
|
||||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||
<div class="arrow"></div>
|
||||
@ -71,6 +72,7 @@
|
||||
processing: isToolProcessing(action),
|
||||
completed: isToolCompleted(action)
|
||||
}"
|
||||
:data-block-id="blockKey(action, idx)"
|
||||
>
|
||||
<div class="collapsible-header" @click="toggleBlock(blockKey(action, idx))">
|
||||
<div class="arrow"></div>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
|
||||
completed: action.tool.status === 'completed'
|
||||
}"
|
||||
:data-block-id="blockId || collapseKey || action.tool.id || action.id || 'tool'"
|
||||
>
|
||||
<div class="collapsible-header" @click="$emit('toggle')">
|
||||
<div class="arrow"></div>
|
||||
@ -51,6 +52,7 @@ defineOptions({ name: 'ToolAction' });
|
||||
const props = defineProps<{
|
||||
action: any;
|
||||
expanded: boolean;
|
||||
blockId?: string;
|
||||
iconStyle: (key: string) => Record<string, string>;
|
||||
getToolAnimationClass: (tool: any) => Record<string, unknown>;
|
||||
getToolIcon: (tool: any) => string;
|
||||
|
||||
183
static/src/composables/useBlockExpansionAnchor.ts
Normal file
183
static/src/composables/useBlockExpansionAnchor.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import { type Ref } from 'vue';
|
||||
|
||||
export type BlockExpandDirection = 'auto' | 'up' | 'down';
|
||||
|
||||
interface AnchorAnimation {
|
||||
element: HTMLElement;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
mode: 'up' | 'down';
|
||||
initialTop: number;
|
||||
initialBottom: number;
|
||||
extendOnGrowth: boolean;
|
||||
phase: 'expand' | 'collapse';
|
||||
}
|
||||
|
||||
interface UseBlockExpansionAnchorOptions {
|
||||
stopScroll: () => void;
|
||||
duration?: number;
|
||||
defaultDirection?: BlockExpandDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 块展开/折叠时,以块的某条边为锚点保持视口位置稳定。
|
||||
*
|
||||
* 传统 stick-to-bottom 在内容高度变化时会用弹簧动画追底,和 CSS 展开动画不同步,
|
||||
* 产生顿挫。该 composable 在 CSS 过渡期间用 rAF 持续覆盖 scrollTop。
|
||||
*
|
||||
* 方向策略:
|
||||
* - 展开时根据块顶边在视口的位置决定:上半向下展开(顶边不动),下半向上展开(底边不动)。
|
||||
* - 该方向在本次展开-收起周期内保持一致,确保收起是展开的倒放。
|
||||
* - 收起动画结束后清除方向记录,下次展开再根据新位置重新判断。
|
||||
*/
|
||||
export function useBlockExpansionAnchor(
|
||||
scrollRef: Ref<HTMLElement | null>,
|
||||
options: UseBlockExpansionAnchorOptions
|
||||
) {
|
||||
const { stopScroll, duration = 300, defaultDirection = 'auto' } = options;
|
||||
const animations = new Map<string, AnchorAnimation>();
|
||||
const preferredModes = new Map<string, 'up' | 'down'>();
|
||||
let rafId: number | null = null;
|
||||
|
||||
function resolveMode(rect: DOMRect, containerRect: DOMRect, direction: BlockExpandDirection) {
|
||||
if (direction !== 'auto') return direction;
|
||||
const top = rect.top - containerRect.top;
|
||||
const midY = containerRect.height / 2;
|
||||
return top > midY ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const container = scrollRef.value;
|
||||
if (!container) {
|
||||
rafId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const now = performance.now();
|
||||
let hasActive = false;
|
||||
|
||||
for (const [id, anim] of animations) {
|
||||
const elapsed = now - anim.startTime;
|
||||
const stillInDom =
|
||||
document.body.contains(container) && container.contains(anim.element);
|
||||
if (!stillInDom) {
|
||||
animations.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = anim.element.getBoundingClientRect();
|
||||
const currentTop = rect.top - containerRect.top;
|
||||
const currentBottom = rect.bottom - containerRect.top;
|
||||
|
||||
let delta = 0;
|
||||
if (anim.mode === 'up') {
|
||||
delta = currentBottom - anim.initialBottom;
|
||||
} else {
|
||||
delta = -(currentTop - anim.initialTop);
|
||||
}
|
||||
|
||||
const stillGrowing = Math.abs(delta) > 1;
|
||||
const shouldExtend = anim.extendOnGrowth && stillGrowing && elapsed > anim.duration * 0.6;
|
||||
if (shouldExtend) {
|
||||
anim.startTime = now - anim.duration * 0.3;
|
||||
}
|
||||
|
||||
if (elapsed >= anim.duration && !shouldExtend) {
|
||||
// 收起动画结束后清除方向记录,下次展开按新位置重新判断
|
||||
if (anim.phase === 'collapse') {
|
||||
preferredModes.delete(id);
|
||||
}
|
||||
animations.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasActive = true;
|
||||
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasActive) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
} else {
|
||||
rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function anchorBlockElement(
|
||||
element: HTMLElement,
|
||||
id: string,
|
||||
opts?: {
|
||||
duration?: number;
|
||||
direction?: BlockExpandDirection;
|
||||
extendOnGrowth?: boolean;
|
||||
phase?: 'expand' | 'collapse';
|
||||
}
|
||||
) {
|
||||
const container = scrollRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
const initialTop = rect.top - containerRect.top;
|
||||
const initialBottom = rect.bottom - containerRect.top;
|
||||
|
||||
// 只处理视口附近可见的块
|
||||
if (initialBottom < -50 || initialTop > containerRect.height + 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = opts?.phase ?? 'expand';
|
||||
const explicitDirection = opts?.direction ?? defaultDirection;
|
||||
let mode: 'up' | 'down';
|
||||
|
||||
if (explicitDirection !== 'auto') {
|
||||
mode = explicitDirection;
|
||||
} else if (phase === 'collapse' && preferredModes.has(id)) {
|
||||
// 收起时沿用展开时记录的方向,保证倒放一致
|
||||
mode = preferredModes.get(id)!;
|
||||
} else if (phase === 'expand' && preferredModes.has(id)) {
|
||||
// 同一次周期内再次展开(不应常见),沿用已有方向
|
||||
mode = preferredModes.get(id)!;
|
||||
} else {
|
||||
mode = resolveMode(rect, containerRect, 'auto');
|
||||
preferredModes.set(id, mode);
|
||||
}
|
||||
|
||||
const extendOnGrowth = opts?.extendOnGrowth ?? false;
|
||||
|
||||
stopScroll();
|
||||
|
||||
const existing = animations.get(id);
|
||||
animations.set(id, {
|
||||
element,
|
||||
startTime: performance.now(),
|
||||
duration: opts?.duration ?? duration,
|
||||
mode,
|
||||
initialTop,
|
||||
initialBottom,
|
||||
extendOnGrowth: extendOnGrowth || existing?.extendOnGrowth || false,
|
||||
phase
|
||||
});
|
||||
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
animations.clear();
|
||||
preferredModes.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
anchorBlockElement,
|
||||
stopAll
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user