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:
parent
04cdf964af
commit
548455f127
@ -130,8 +130,8 @@
|
|||||||
class="floating-project-status__stats"
|
class="floating-project-status__stats"
|
||||||
@click.stop="$emit('open-git-changes-panel')"
|
@click.stop="$emit('open-git-changes-panel')"
|
||||||
>
|
>
|
||||||
<span class="floating-project-status__add">+{{ projectGitSummaryForRender.additions }}</span>
|
<span class="floating-project-status__add">+<RollingNumber :value="projectGitSummaryForRender.additions" /></span>
|
||||||
<span class="floating-project-status__del">-{{ projectGitSummaryForRender.deletions }}</span>
|
<span class="floating-project-status__del">-<RollingNumber :value="projectGitSummaryForRender.deletions" /></span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -366,6 +366,7 @@ import Mention from '@tiptap/extension-mention';
|
|||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import { TextSelection } from 'prosemirror-state';
|
import { TextSelection } from 'prosemirror-state';
|
||||||
import QuickMenu from '@/components/input/QuickMenu.vue';
|
import QuickMenu from '@/components/input/QuickMenu.vue';
|
||||||
|
import RollingNumber from '@/components/input/RollingNumber.vue';
|
||||||
import { useInputStore } from '@/stores/input';
|
import { useInputStore } from '@/stores/input';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
|
|
||||||
@ -1930,10 +1931,14 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.floating-project-status__add {
|
.floating-project-status__add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--git-diff-add-text);
|
color: var(--git-diff-add-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-project-status__del {
|
.floating-project-status__del {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
color: var(--git-diff-del-text);
|
color: var(--git-diff-del-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
273
static/src/components/input/RollingNumber.vue
Normal file
273
static/src/components/input/RollingNumber.vue
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user