feat(frontend): 状态栏 +/- 行数变化加数字滚筒动画

新增 RollingNumber.vue:逐位纵向滚筒,值变大下面顶上来、变小上面压下来,
高位先动(38ms 错开),一次变化两格直达不路过中间数字。接入 InputComposer
状态栏的 +additions / -deletions 两处。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-06-01 19:15:30 +08:00
parent 04cdf964af
commit 548455f127
2 changed files with 280 additions and 2 deletions

View File

@ -130,8 +130,8 @@
class="floating-project-status__stats"
@click.stop="$emit('open-git-changes-panel')"
>
<span class="floating-project-status__add">+{{ projectGitSummaryForRender.additions }}</span>
<span class="floating-project-status__del">-{{ projectGitSummaryForRender.deletions }}</span>
<span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span>
<span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span>
</button>
</span>
</div>
@ -366,6 +366,7 @@ import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { TextSelection } from 'prosemirror-state';
import QuickMenu from '@/components/input/QuickMenu.vue';
import RollingNumber from '@/components/input/RollingNumber.vue';
import { useInputStore } from '@/stores/input';
import { usePersonalizationStore } from '@/stores/personalization';
@ -1930,10 +1931,14 @@ onBeforeUnmount(() => {
}
.floating-project-status__add {
display: inline-flex;
align-items: center;
color: var(--git-diff-add-text);
}
.floating-project-status__del {
display: inline-flex;
align-items: center;
color: var(--git-diff-del-text);
}

View File

@ -0,0 +1,273 @@
<template>
<!--
数字滚筒(直达式):每一位是一个固定高度的窗口,内部只放旧值 / 新值两格
一次变化只滚一步,不路过中间数字
- 值变大:旧在上新在下,带子上移 下面的数字顶上来
- 值变小:新在上旧在下,带子先停在下格再回滚 上面的数字压下来
各位用 transition-delay 错开,高位先动,间隔很短
-->
<span class="rolling-number">
<TransitionGroup name="rn-place" tag="span" class="rolling-number__track">
<span
v-for="item in renderPlaces"
:key="item.place"
class="rolling-number__digit"
>
<span
class="rolling-number__strip"
:class="{ 'is-animating': item.state.animate }"
:style="{ '--rn-offset': item.state.offset, transitionDelay: item.state.delay + 'ms' }"
>
<span class="rolling-number__cell">{{ item.state.top }}</span>
<span class="rolling-number__cell">{{ item.state.bottom }}</span>
</span>
</span>
</TransitionGroup>
</span>
</template>
<script setup lang="ts">
import { ref, computed, reactive, nextTick, watch, onBeforeUnmount } from 'vue';
const props = defineProps<{ value: number }>();
// (ms),,
const STEP = 38;
// , css transition
const DUR = 360;
type PlaceState = {
top: number; //
bottom: number; //
offset: number; // :0=,1=
animate: boolean; // (false )
delay: number; // transition-delay,
};
// place:,0=,
const order = ref<number[]>([]); // ()
const states = new Map<number, PlaceState>(); // place => (reactive)
let booted = false;
let gen = 0; // ,
const normalizeTimers = new Set<number>();
const removeTimers = new Map<number, number>();
function digitsOf(v: number): Map<number, number> {
const s = Math.max(0, Math.trunc(Number(v) || 0)).toString();
const len = s.length;
const m = new Map<number, number>();
for (let i = 0; i < len; i++) m.set(len - 1 - i, Number(s[i]));
return m;
}
const renderPlaces = computed(() =>
order.value
.map((p) => ({ place: p, state: states.get(p) as PlaceState }))
.filter((x) => !!x.state)
);
function setOrderDesc(places: Iterable<number>) {
order.value = [...new Set(places)].sort((a, b) => b - a);
}
watch(
() => props.value,
async (val) => {
const target = digitsOf(val);
const myGen = ++gen;
// ,
if (!booted) {
booted = true;
states.clear();
for (const [p, d] of target) {
states.set(p, reactive({ top: d, bottom: d, offset: 0, animate: false, delay: 0 }));
}
setOrderDesc(target.keys());
return;
}
const union = new Set<number>([...states.keys(), ...target.keys()]);
const maxPlace = Math.max(...union, 0);
const ups: PlaceState[] = [];
const downs: PlaceState[] = [];
// ,
for (const p of target.keys()) {
const t = removeTimers.get(p);
if (t) {
window.clearTimeout(t);
removeTimers.delete(p);
}
}
for (const place of union) {
const delay = (maxPlace - place) * STEP;
const nv = target.get(place);
if (nv === undefined) continue; // ,
const existing = states.get(place);
if (!existing) {
// :( TransitionGroup ),
states.set(place, reactive({ top: nv, bottom: nv, offset: 0, animate: false, delay }));
continue;
}
existing.delay = delay;
const ov = existing.top; //
if (ov === nv) {
existing.bottom = nv;
existing.offset = 0;
existing.animate = false;
continue;
}
if (ov < nv) {
// :=,=,offset 0 1()
existing.animate = false;
existing.top = ov;
existing.bottom = nv;
existing.offset = 0;
ups.push(existing);
} else {
// :=,=,offset 1 0()
existing.animate = false;
existing.top = nv;
existing.bottom = ov;
existing.offset = 1;
downs.push(existing);
}
}
// (,)
setOrderDesc(target.keys());
// :
for (const place of [...states.keys()]) {
if (!target.has(place) && !removeTimers.has(place)) {
const t = window.setTimeout(() => {
removeTimers.delete(place);
states.delete(place);
}, 260);
removeTimers.set(place, t);
}
}
// ,
await nextTick();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (myGen !== gen) return;
for (const s of ups) {
s.animate = true;
s.offset = 1;
}
for (const s of downs) {
s.animate = true;
s.offset = 0;
}
});
});
// : top=offset=0(,)
const settle = maxPlace * STEP + DUR + 80;
const norm = window.setTimeout(() => {
normalizeTimers.delete(norm);
if (myGen !== gen) return;
for (const [p, d] of target) {
const s = states.get(p);
if (s) {
s.animate = false;
s.top = d;
s.bottom = d;
s.offset = 0;
}
}
}, settle);
normalizeTimers.add(norm);
},
{ immediate: true }
);
onBeforeUnmount(() => {
normalizeTimers.forEach((t) => window.clearTimeout(t));
normalizeTimers.clear();
removeTimers.forEach((t) => window.clearTimeout(t));
removeTimers.clear();
});
</script>
<style scoped>
.rolling-number {
display: inline-flex;
vertical-align: baseline;
}
.rolling-number__track {
position: relative;
display: inline-flex;
align-items: stretch;
}
.rolling-number__digit {
--rn-h: 1.2em;
display: inline-block;
height: var(--rn-h);
line-height: var(--rn-h);
overflow: hidden;
}
.rolling-number__strip {
display: flex;
flex-direction: column;
transform: translateY(calc(var(--rn-offset, 0) * var(--rn-h) * -1));
will-change: transform;
}
.rolling-number__strip.is-animating {
transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.rolling-number__cell {
height: var(--rn-h);
line-height: var(--rn-h);
text-align: center;
font-variant-numeric: inherit;
}
/* 位数增减时整位的进出:新增位淡入下沉,消失位淡出上移 */
.rn-place-enter-active,
.rn-place-leave-active {
transition: opacity 220ms ease, transform 220ms ease;
}
.rn-place-enter-from {
opacity: 0;
transform: translateY(45%);
}
.rn-place-leave-to {
opacity: 0;
transform: translateY(-45%);
}
/* 让消失位脱离布局,其余位平滑左移而非瞬跳 */
.rn-place-leave-active {
position: absolute;
}
.rn-place-move {
transition: transform 220ms ease;
}
@media (prefers-reduced-motion: reduce) {
.rolling-number__strip.is-animating {
transition: none;
}
.rn-place-enter-active,
.rn-place-leave-active,
.rn-place-move {
transition: none;
}
}
</style>