fix(input): 重构消息队列动画 — 去掉容器外框、纯位移FLIP同步动画
This commit is contained in:
parent
d639278182
commit
84336b1802
@ -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);
|
||||
};
|
||||
// 上一轮渲染的 ID(onBeforeUpdate 时数据已更新,必须用这个记录旧位置)
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user