From 84336b1802eac7ce4da0c8feb18ca7b5e9234658 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 7 Jun 2026 23:20:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(input):=20=E9=87=8D=E6=9E=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=98=9F=E5=88=97=E5=8A=A8=E7=94=BB=20=E2=80=94=20?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=AE=B9=E5=99=A8=E5=A4=96=E6=A1=86=E3=80=81?= =?UTF-8?q?=E7=BA=AF=E4=BD=8D=E7=A7=BBFLIP=E5=90=8C=E6=AD=A5=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/src/components/input/InputComposer.vue | 285 +++++++----------- .../styles/components/input/_composer.scss | 13 +- 2 files changed, 122 insertions(+), 176 deletions(-) diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 67976b4..023cf77 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -1143,20 +1143,16 @@ const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)'; const runtimeQueueList = ref(null); const runtimeQueueItemRefs = new Map(); const runtimeQueuePrevIds: string[] = []; -const runtimeQueueRenderedIds: string[] = []; const runtimeQueuePrevRects = new Map(); const runtimeQueuePrevElements = new Map(); const runtimeQueueGhosts = new Map(); -const runtimeQueueActiveAnimations = new WeakMap(); const runtimeQueueFallbackTimers = new WeakMap(); 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, + to: Record, 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; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index b5e98e3..648c19d 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -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;