fix(input): 重构消息队列动画 — 去掉容器外框、纯位移FLIP同步动画

This commit is contained in:
JOJO 2026-06-07 23:20:57 +08:00
parent d639278182
commit 84336b1802
2 changed files with 122 additions and 176 deletions

View File

@ -1143,20 +1143,16 @@ const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)';
const runtimeQueueList = ref<HTMLElement | null>(null);
const runtimeQueueItemRefs = new Map<string, HTMLElement>();
const runtimeQueuePrevIds: string[] = [];
const runtimeQueueRenderedIds: string[] = [];
const runtimeQueuePrevRects = new Map<string, DOMRect>();
const runtimeQueuePrevElements = new Map<string, HTMLElement>();
const runtimeQueueGhosts = new Map<string, HTMLElement>();
const runtimeQueueActiveAnimations = new WeakMap<HTMLElement, Animation>();
const runtimeQueueFallbackTimers = new WeakMap<HTMLElement, number>();
const runtimeQueueTransitioning = ref(false);
let runtimeQueueAnimationReady = false;
let runtimeQueueTransitionTimer: number | null = null;
const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
if (!id) {
return;
}
if (!id) return;
if (el instanceof HTMLElement) {
runtimeQueueItemRefs.set(id, el);
} else {
@ -1164,31 +1160,21 @@ const bindRuntimeQueueItemRef = (id: string) => (el: Element | null) => {
}
};
const stopRuntimeQueueAnimation = (el: HTMLElement) => {
const anim = runtimeQueueActiveAnimations.get(el);
if (anim) {
try {
anim.cancel();
} catch {
// ignore
}
runtimeQueueActiveAnimations.delete(el);
}
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) {
clearTimeout(timer);
runtimeQueueFallbackTimers.delete(el);
}
el.style.removeProperty('transition');
el.style.removeProperty('transform');
el.style.removeProperty('pointer-events');
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) return rectHeight;
if (el.offsetHeight > 0) return el.offsetHeight;
return 34;
};
const snapshotRuntimeQueueIds = (): string[] =>
runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
const keepRuntimeQueueTransitionCollapsed = () => {
runtimeQueueTransitioning.value = true;
if (runtimeQueueTransitionTimer !== null) {
window.clearTimeout(runtimeQueueTransitionTimer);
}
if (runtimeQueueTransitionTimer !== null) window.clearTimeout(runtimeQueueTransitionTimer);
runtimeQueueTransitionTimer = window.setTimeout(() => {
runtimeQueueTransitioning.value = false;
runtimeQueueTransitionTimer = null;
@ -1196,197 +1182,158 @@ const keepRuntimeQueueTransitionCollapsed = () => {
}, RUNTIME_QUEUE_ANIM_DURATION + 40);
};
const playRuntimeQueueTransform = (
const cancelAnim = (el: HTMLElement) => {
const timer = runtimeQueueFallbackTimers.get(el);
if (timer) { clearTimeout(timer); runtimeQueueFallbackTimers.delete(el); }
el.style.removeProperty('transition');
el.style.removeProperty('transform');
el.style.removeProperty('opacity');
};
/**
* Schedule a CSS transition on an element.
* All calls in the same synchronous batch share one RAF callback,
* so all animations start in the same frame.
*/
const scheduleTransition = (
el: HTMLElement,
fromY: number,
toY: number,
props: string[],
from: Record<string, string>,
to: Record<string, string>,
done?: () => void
) => {
if (Math.abs(fromY - toY) < 0.5) {
stopRuntimeQueueAnimation(el);
el.style.removeProperty('transition');
el.style.removeProperty('transform');
done?.();
return;
}
keepRuntimeQueueTransitionCollapsed();
stopRuntimeQueueAnimation(el);
const from = `translateY(${fromY}px)`;
const to = `translateY(${toY}px)`;
const finish = () => {
el.style.removeProperty('transition');
el.style.removeProperty('transform');
props.forEach((p) => el.style.removeProperty(p));
done?.();
};
// Apply from state immediately (no transition)
el.style.transition = 'none';
el.style.transform = from;
if (typeof el.animate === 'function') {
//
void el.offsetHeight;
const animation = el.animate([{ transform: from }, { transform: to }], {
duration: RUNTIME_QUEUE_ANIM_DURATION,
easing: RUNTIME_QUEUE_ANIM_EASING,
fill: 'both'
});
runtimeQueueActiveAnimations.set(el, animation);
animation.addEventListener(
'finish',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
finish();
},
{ once: true }
);
animation.addEventListener(
'cancel',
() => {
if (runtimeQueueActiveAnimations.get(el) === animation) {
runtimeQueueActiveAnimations.delete(el);
}
},
{ once: true }
);
return;
}
Object.entries(from).forEach(([k, v]) => { el.style.setProperty(k, v); });
// Defer to next frame: enable transition + apply to state
// All scheduleTransition calls in the same microtask share this RAF
requestAnimationFrame(() => {
el.style.transition = `transform ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`;
el.style.transform = to;
const timer = window.setTimeout(() => {
runtimeQueueFallbackTimers.delete(el);
finish();
}, RUNTIME_QUEUE_ANIM_DURATION + 24);
el.style.transition = props
.map((p) => `${p} ${RUNTIME_QUEUE_ANIM_DURATION}ms ${RUNTIME_QUEUE_ANIM_EASING}`)
.join(', ');
Object.entries(to).forEach(([k, v]) => { el.style.setProperty(k, v); });
const timer = window.setTimeout(finish, RUNTIME_QUEUE_ANIM_DURATION + 40);
runtimeQueueFallbackTimers.set(el, timer);
});
};
const resolveRuntimeQueueOffset = (el: HTMLElement): number => {
const rectHeight = Math.round(el.getBoundingClientRect().height);
if (rectHeight > 0) {
return rectHeight;
}
if (el.offsetHeight > 0) {
return el.offsetHeight;
}
return 34;
};
/* ═══════════════════ FLIP 动画 ═══════════════════ */
const snapshotRuntimeQueueIds = (): string[] => {
return runtimeQueuedMessagesForRender.value
.map((entry) => String(entry?.id || '').trim())
.filter((id) => !!id);
};
const spawnRuntimeQueueLeaveGhost = (id: string) => {
const sourceEl = runtimeQueuePrevElements.get(id);
const sourceRect = runtimeQueuePrevRects.get(id);
const listEl = runtimeQueueList.value;
if (!sourceEl || !sourceRect || !listEl || !listEl.isConnected) {
return;
}
const existingGhost = runtimeQueueGhosts.get(id);
if (existingGhost) {
stopRuntimeQueueAnimation(existingGhost);
existingGhost.remove();
runtimeQueueGhosts.delete(id);
}
const ghost = sourceEl.cloneNode(true) as HTMLElement;
const listRect = listEl.getBoundingClientRect();
const top = sourceRect.top - listRect.top;
const width = sourceRect.width > 0 ? sourceRect.width : listRect.width;
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(top)}px`;
ghost.style.width = `${Math.round(width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.querySelectorAll('button').forEach((button) => {
if (button instanceof HTMLButtonElement) {
button.disabled = true;
}
});
listEl.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
const offset = resolveRuntimeQueueOffset(ghost);
playRuntimeQueueTransform(ghost, 0, offset, () => {
runtimeQueueGhosts.delete(id);
ghost.remove();
});
};
const animateRuntimeQueueEnter = (el: HTMLElement) => {
const offset = resolveRuntimeQueueOffset(el);
playRuntimeQueueTransform(el, offset, 0);
};
// IDonBeforeUpdate
let runtimeQueueLastRenderedIds: string[] = [];
onBeforeUpdate(() => {
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueRenderedIds);
// ID snapshotRuntimeQueueIds()
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length, ...runtimeQueueLastRenderedIds);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueRenderedIds.forEach((id) => {
runtimeQueueLastRenderedIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
return;
}
if (!el?.isConnected) return;
runtimeQueuePrevRects.set(id, el.getBoundingClientRect());
runtimeQueuePrevElements.set(id, el);
});
});
onUpdated(() => {
const list = runtimeQueueList.value;
const currentIds = snapshotRuntimeQueueIds();
// First render: just bootstrap tracking state
if (!runtimeQueueAnimationReady) {
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueueAnimationReady = true;
runtimeQueueLastRenderedIds = [...currentIds];
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
return;
}
keepRuntimeQueueTransitionCollapsed();
const previousIdSet = new Set(runtimeQueuePrevIds);
const currentIdSet = new Set(currentIds);
const enteringIdSet = new Set(currentIds.filter((id) => !previousIdSet.has(id)));
const enteringIds = currentIds.filter((id) => !previousIdSet.has(id));
const leavingIds = runtimeQueuePrevIds.filter((id) => !currentIdSet.has(id));
if (!enteringIds.length && !leavingIds.length) return;
// Leave: create ghost clones at old positions
leavingIds.forEach((id) => {
spawnRuntimeQueueLeaveGhost(id);
const src = runtimeQueuePrevElements.get(id);
const rect = runtimeQueuePrevRects.get(id);
if (!src || !rect || !list?.isConnected) return;
cancelAnim(src);
// Clean up any stale ghost with same id
const existing = runtimeQueueGhosts.get(id);
if (existing) { cancelAnim(existing); existing.remove(); runtimeQueueGhosts.delete(id); }
const ghost = src.cloneNode(true) as HTMLElement;
const lr = list.getBoundingClientRect();
ghost.style.position = 'absolute';
ghost.style.left = '0';
ghost.style.top = `${Math.round(rect.top - lr.top)}px`;
ghost.style.width = `${Math.round(rect.width > 0 ? rect.width : lr.width)}px`;
ghost.style.pointerEvents = 'none';
ghost.style.zIndex = '0';
ghost.style.margin = '0';
ghost.style.opacity = '1';
ghost.querySelectorAll('button').forEach((b) => {
if (b instanceof HTMLButtonElement) b.disabled = true;
});
list.appendChild(ghost);
runtimeQueueGhosts.set(id, ghost);
// Schedule ghost sink shares RAF with item animations below
const offset = resolveRuntimeQueueOffset(ghost);
scheduleTransition(ghost, ['transform'],
{ transform: 'translateY(0)' },
{ transform: `translateY(${offset}px)` },
() => { runtimeQueueGhosts.delete(id); ghost.remove(); }
);
runtimeQueueItemRefs.delete(id);
});
// Remaining items: apply FLIP transforms
currentIds.forEach((id) => {
const el = runtimeQueueItemRefs.get(id);
if (!el?.isConnected) {
if (!el?.isConnected) return;
cancelAnim(el);
if (enteringIds.includes(id)) {
// New item: rise from below
const offset = resolveRuntimeQueueOffset(el);
scheduleTransition(el, ['transform'],
{ transform: `translateY(${offset}px)` },
{ transform: 'translateY(0)' }
);
return;
}
const prevRect = runtimeQueuePrevRects.get(id);
if (!prevRect || enteringIdSet.has(id)) {
animateRuntimeQueueEnter(el);
return;
}
const nextRect = el.getBoundingClientRect();
const deltaY = prevRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) {
return;
}
playRuntimeQueueTransform(el, deltaY, 0);
// Existing item: invert the position delta
const prev = runtimeQueuePrevRects.get(id);
if (!prev) return;
const next = el.getBoundingClientRect();
const deltaY = prev.top - next.top;
if (Math.abs(deltaY) < 0.5) return;
scheduleTransition(el, ['transform'],
{ transform: `translateY(${deltaY}px)` },
{ transform: 'translateY(0)' }
);
});
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length, ...currentIds);
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
// ID onBeforeUpdate 使
runtimeQueueLastRenderedIds = [...currentIds];
});
const hasComposerContent = computed(() => {
@ -1839,20 +1786,20 @@ onBeforeUnmount(() => {
editor.value?.destroy();
runtimeQueueItemRefs.forEach((el) => {
if (el) {
stopRuntimeQueueAnimation(el);
cancelAnim(el);
}
});
runtimeQueueGhosts.forEach((ghost) => {
stopRuntimeQueueAnimation(ghost);
cancelAnim(ghost);
ghost.remove();
});
runtimeQueueGhosts.clear();
runtimeQueueItemRefs.clear();
runtimeQueuePrevIds.splice(0, runtimeQueuePrevIds.length);
runtimeQueueRenderedIds.splice(0, runtimeQueueRenderedIds.length);
runtimeQueuePrevRects.clear();
runtimeQueuePrevElements.clear();
runtimeQueueAnimationReady = false;
runtimeQueueLastRenderedIds = [];
if (runtimeQueueTransitionTimer !== null) {
window.clearTimeout(runtimeQueueTransitionTimer);
runtimeQueueTransitionTimer = null;

View File

@ -39,13 +39,9 @@
max-width: 90%;
margin: 0;
overflow: visible;
border-top: 1px solid var(--runtime-queue-border);
border-right: 1px solid var(--runtime-queue-border);
border-bottom: none;
border-left: 1px solid var(--runtime-queue-border);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
pointer-events: auto;
}
@ -70,7 +66,10 @@
align-items: center;
gap: 10px;
padding: 6px 10px 6px 12px;
border: none;
border-left: 1px solid var(--runtime-queue-border);
border-right: 1px solid var(--runtime-queue-border);
border-top: 1px solid var(--runtime-queue-border);
border-bottom: none;
border-radius: 0;
background: var(--runtime-queue-bg);
overflow: hidden;