fix: 首页滚动条改为恒速并补移动端拖动

This commit is contained in:
2026-09-13 19:06:38 +08:00
parent 1254e3d5d0
commit cc4c20e77b
4 changed files with 587 additions and 25 deletions
+234
View File
@@ -0,0 +1,234 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { createMemoryHistory, createRouter } from 'vue-router'
import Welcome from '@/components/Welcome.vue'
import { getReviewFeed } from '@/api/review'
import { BASE_SPEED_PX_PER_SEC, NARROW_SPEED_FACTOR } from '@/utils/autoScroll'
vi.mock('@/api/review', () => ({
getReviewFeed: vi.fn(),
getTaskReview: vi.fn(),
}))
const makeFragment = (id: number) => ({
id,
sessionNum: `SESSION_${id}`,
taskName: `任务${id}`,
taskNum: `T${id}`,
sourceType: 'FRAGMENT',
content: `残片内容${id}`,
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/welcome', component: { template: '<div />' } },
{ path: '/study', component: { template: '<div />' } },
{ path: '/review', component: { template: '<div />' } },
],
})
/** jsdom 不实现 Web Animations API,用可控桩替代 */
interface AnimStub {
keyframes: unknown
options: { duration: number; iterations: number; easing: string }
currentTime: number
paused: boolean
cancel: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
play: ReturnType<typeof vi.fn>
}
let animations: AnimStub[] = []
let animateSpy: ReturnType<typeof vi.fn>
/**
* jsdom 不做布局,用宽度桩模拟渲染结果:
* scrollWidth = 单份内容宽 × 当前已渲染份数(由 DOM 里的 chip 数量推得),
* clientWidth = 轨道宽度。
*/
const stubLayout = (perCopyWidth: number, trackWidth: number) => {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get(this: HTMLElement) {
if (!this.classList?.contains('review-scroll-content')) return 0
const renderedChips = this.querySelectorAll('.review-chip').length
return perCopyWidth * (renderedChips / SOURCE_CHIP_COUNT)
},
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) {
return this.classList?.contains('review-scroll-track') ? trackWidth : 0
},
})
}
/** mock 数据里的残留条数,用于把“份数”换算回“条数” */
const SOURCE_CHIP_COUNT = 2
/** 从渲染结果反推当前份数 */
const renderedRepeatCount = (wrapper: { findAll: (s: string) => unknown[] }) =>
wrapper.findAll('.review-chip').length / SOURCE_CHIP_COUNT
const mountWelcome = async () => {
const wrapper = mount(Welcome, { global: { plugins: [router, ElementPlus] } })
await flushPromises()
return wrapper
}
beforeEach(() => {
animations = []
animateSpy = vi.fn((keyframes: unknown, options: AnimStub['options']) => {
const anim: AnimStub = {
keyframes,
options,
currentTime: 0,
paused: false,
cancel: vi.fn(),
pause: vi.fn(function (this: AnimStub) { this.paused = true }),
play: vi.fn(function (this: AnimStub) { this.paused = false }),
}
animations.push(anim)
return anim as unknown as Animation
})
Element.prototype.animate = animateSpy as unknown as Element['animate']
vi.mocked(getReviewFeed).mockResolvedValue({ code: 200, data: [makeFragment(1), makeFragment(2)] })
})
afterEach(() => {
// @ts-expect-error 清理原型上的自定义布局桩
delete HTMLElement.prototype.scrollWidth
// @ts-expect-error 清理原型上的自定义布局桩
delete HTMLElement.prototype.clientWidth
})
describe('Welcome 首页滚动条', () => {
it('内容超过一屏时按基准速度建立动画,时长由恒速换算', async () => {
stubLayout(4000, 1000) // 单份 4000px60px/s → 66666ms
await mountWelcome()
expect(animateSpy).toHaveBeenCalledTimes(1)
const options = animations[0].options
expect(options.iterations).toBe(Infinity)
expect(options.easing).toBe('linear')
expect(options.duration).toBeCloseTo((4000 / BASE_SPEED_PX_PER_SEC) * 1000)
})
it('速度与内容量无关:内容翻倍只让时长翻倍', async () => {
stubLayout(4000, 1000)
await mountWelcome()
const few = animations[0].options.duration
animations = []
animateSpy.mockClear()
stubLayout(40000, 1000)
await mountWelcome()
const many = animations[0].options.duration
expect(many / few).toBeCloseTo(10)
})
it('内容不足一屏时仍然滚动,且按单份宽度换算时长', async () => {
stubLayout(300, 1000) // 单份 300px,远小于轨道
await mountWelcome()
expect(animateSpy).toHaveBeenCalled()
expect(animations[animations.length - 1].options.duration)
.toBeCloseTo((300 / BASE_SPEED_PX_PER_SEC) * 1000)
})
it('内容比轨道窄时补足份数后重建动画,避免循环露白', async () => {
stubLayout(300, 1000) // 单份 300px、轨道 1000px → ceil(1000/300)+1 = 5 份
const wrapper = await mountWelcome()
expect(renderedRepeatCount(wrapper)).toBe(5)
expect(animateSpy).toHaveBeenCalledTimes(1)
// 位移按份数换算:100% / 5 = 20%
expect(animations[0].keyframes).toEqual([
{ transform: 'translateX(0)' },
{ transform: 'translateX(-20%)' },
])
})
it('内容足够宽时保持最少两份,不会反复重建', async () => {
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
expect(renderedRepeatCount(wrapper)).toBe(2)
expect(animateSpy).toHaveBeenCalledTimes(1)
})
it('窄屏(手机)按系数降速', async () => {
stubLayout(4000, 375)
await mountWelcome()
const speed = 4000 / (animations[0].options.duration / 1000)
expect(speed).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
})
it('滚轮按速度换算手动位移,并夹在一轮时长内', async () => {
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
const track = wrapper.find('.review-scroll-track')
await track.trigger('wheel', { deltaY: BASE_SPEED_PX_PER_SEC, deltaMode: 0 })
// 滚动 60px = 1 秒动画时间
expect(animations[0].currentTime).toBeCloseTo(1000)
await track.trigger('wheel', { deltaY: -100000, deltaMode: 0 })
expect(animations[0].currentTime).toBe(0)
})
it('手指左右拖动共用同一套换算,拖动后恢复播放', async () => {
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
const track = wrapper.find('.review-scroll-track')
await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
expect(animations[0].paused).toBe(true)
await track.trigger('touchmove', { touches: [{ clientX: 360 }] })
expect(animations[0].currentTime).toBeCloseTo(1000)
await track.trigger('touchend')
expect(animations[0].paused).toBe(false)
})
it('拖动后松开不会误触打开回忆卡片', async () => {
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
const track = wrapper.find('.review-scroll-track')
await track.trigger('touchstart', { touches: [{ clientX: 300 }] })
await track.trigger('touchmove', { touches: [{ clientX: 400 }] })
await wrapper.find('.review-chip').trigger('click')
await flushPromises()
expect(wrapper.find('.recall-question').exists()).toBe(false)
})
it('点击残片(未拖动)会打开回忆卡片', async () => {
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
await wrapper.find('.review-chip').trigger('click')
await flushPromises()
expect(wrapper.find('.recall-question').exists()).toBe(true)
expect(wrapper.find('.recall-snippet').text()).toContain('残片内容1')
})
it('卸载时取消动画并移除窗口监听', async () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
stubLayout(4000, 1000)
const wrapper = await mountWelcome()
wrapper.unmount()
expect(animations[0].cancel).toHaveBeenCalled()
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
removeSpy.mockRestore()
})
})
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest'
import {
BASE_SPEED_PX_PER_SEC,
MAX_REPEAT_COUNT,
MAX_SPEED_PX_PER_SEC,
MIN_REPEAT_COUNT,
MIN_SPEED_PX_PER_SEC,
NARROW_SPEED_FACTOR,
clampAutoScrollTime,
getRepeatCount,
getScrollSpeedPxPerSec,
measureAutoScroll,
pxToTimeOffset,
} from '@/utils/autoScroll'
const DESKTOP = 1440
describe('getScrollSpeedPxPerSec', () => {
it('桌面端使用基准速度', () => {
expect(getScrollSpeedPxPerSec(DESKTOP)).toBe(BASE_SPEED_PX_PER_SEC)
})
it('正好 768px 属于手机端,769px 回到桌面速度', () => {
expect(getScrollSpeedPxPerSec(768)).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
expect(getScrollSpeedPxPerSec(769)).toBe(BASE_SPEED_PX_PER_SEC)
})
it('手机端是桌面速度的固定倍数', () => {
const narrow = getScrollSpeedPxPerSec(375)
expect(narrow).toBeCloseTo(BASE_SPEED_PX_PER_SEC * NARROW_SPEED_FACTOR)
expect(narrow).toBeLessThan(getScrollSpeedPxPerSec(DESKTOP))
})
it('桌面速度始终夹在上下限内', () => {
;[0, 769, 1024, 1440, 2560].forEach(width => {
const speed = getScrollSpeedPxPerSec(width)
expect(speed).toBeGreaterThanOrEqual(MIN_SPEED_PX_PER_SEC)
expect(speed).toBeLessThanOrEqual(MAX_SPEED_PX_PER_SEC)
})
})
})
describe('measureAutoScroll', () => {
it('一轮距离为单份内容宽度,时长由恒速换算', () => {
// 单份 2000px60px/s → 33.3s
const metrics = measureAutoScroll(2000, DESKTOP)
expect(metrics).not.toBeNull()
expect(metrics!.loopWidth).toBe(2000)
expect(metrics!.speed).toBe(BASE_SPEED_PX_PER_SEC)
expect(metrics!.duration).toBeCloseTo((2000 / BASE_SPEED_PX_PER_SEC) * 1000)
})
it('速度恒定:内容量翻倍只改变时长,不改变速度', () => {
const few = measureAutoScroll(2000, DESKTOP)!
const many = measureAutoScroll(20000, DESKTOP)!
expect(few.speed).toBe(many.speed)
expect(many.duration / few.duration).toBeCloseTo(10)
})
it('内容为空或宽度异常时返回 null', () => {
expect(measureAutoScroll(0, DESKTOP)).toBeNull()
expect(measureAutoScroll(-100, DESKTOP)).toBeNull()
expect(measureAutoScroll(Number.NaN, DESKTOP)).toBeNull()
})
})
describe('getRepeatCount', () => {
it('内容再多也用最少两份', () => {
expect(getRepeatCount(5000, 1000)).toBe(MIN_REPEAT_COUNT)
})
it('内容比轨道窄时补足到能铺满并预留衔接份', () => {
// 单份 300px、轨道 1000pxceil(1000/300)+1 = 5
expect(getRepeatCount(300, 1000)).toBe(5)
// 刚好铺满也要多一份用于衔接
expect(getRepeatCount(1000, 1000)).toBe(2)
})
it('份数不超过上限', () => {
expect(getRepeatCount(1, 100000)).toBe(MAX_REPEAT_COUNT)
})
it('宽度非法时回退到最少份数', () => {
expect(getRepeatCount(0, 1000)).toBe(MIN_REPEAT_COUNT)
expect(getRepeatCount(Number.NaN, 1000)).toBe(MIN_REPEAT_COUNT)
expect(getRepeatCount(300, 0)).toBe(MIN_REPEAT_COUNT)
})
it('measureAutoScroll 会带上份数', () => {
expect(measureAutoScroll(300, 1000)!.repeatCount).toBe(5)
})
})
describe('pxToTimeOffset', () => {
it('按速度把位移换算成时间', () => {
expect(pxToTimeOffset(BASE_SPEED_PX_PER_SEC, BASE_SPEED_PX_PER_SEC)).toBe(1000)
expect(pxToTimeOffset(30, 60)).toBe(500)
})
it('速度为 0 或参数非法时返回 0', () => {
expect(pxToTimeOffset(100, 0)).toBe(0)
expect(pxToTimeOffset(Number.NaN, 60)).toBe(0)
})
})
describe('clampAutoScrollTime', () => {
it('夹在 0 与一轮时长之间', () => {
expect(clampAutoScrollTime(-500, 1000)).toBe(0)
expect(clampAutoScrollTime(1500, 1000)).toBe(1000)
expect(clampAutoScrollTime(400, 1000)).toBe(400)
})
it('参数非法时归零', () => {
expect(clampAutoScrollTime(Number.NaN, 1000)).toBe(0)
expect(clampAutoScrollTime(100, 0)).toBe(0)
})
})
+134 -25
View File
@@ -1,12 +1,21 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import router from "@/router";
import { getReviewFeed, getTaskReview, type ReviewFeedItem } from "@/api/review";
import {
clampAutoScrollTime,
measureAutoScroll,
pxToTimeOffset,
type AutoScrollMetrics,
} from "@/utils/autoScroll";
const reviewItems = ref<ReviewFeedItem[]>([]);
const contentRef = ref<HTMLElement | null>(null);
const AUTO_SCROLL_DURATION = 150_000;
const trackRef = ref<HTMLElement | null>(null);
let autoScroll: Animation | null = null;
let autoScrollMetrics: AutoScrollMetrics | null = null;
/** 当前渲染的内容份数:内容比轨道窄时要多铺几份,否则循环时会露白 */
const repeatCount = ref(2);
interface FragmentChip {
id: number;
@@ -32,8 +41,10 @@ const fragmentChips = computed<FragmentChip[]>(() =>
})),
);
// 复制份实现无缝循环
const duplicatedChips = computed(() => [...fragmentChips.value, ...fragmentChips.value]);
// 复制份实现无缝循环,份数按轨道宽度动态补足
const duplicatedChips = computed(() =>
Array.from({ length: repeatCount.value }, () => fragmentChips.value).flat(),
);
const loadReviewFeed = async () => {
try {
@@ -41,6 +52,10 @@ const loadReviewFeed = async () => {
reviewItems.value = res?.data || [];
} catch {
// feed 加载失败不影响页面主体功能
} finally {
// 残片宽度依赖真实内容,必须等 DOM 更新后再按实际宽度重建动画
await nextTick();
startAutoScroll();
}
};
@@ -124,27 +139,117 @@ const startReview = () => {
router.push("/review");
};
const handleTrackWheel = (event: WheelEvent) => {
const anim = autoScroll;
/** 按当前内容宽度与轨道宽度(重新)建立动画 */
const startAutoScroll = () => {
const content = contentRef.value;
if (!anim || !content) return;
const track = trackRef.value;
if (!content || !track) return;
anim.pause();
// 保留手动滚动后的位置:重建动画前把当前进度换成比例
const elapsed = autoScroll && typeof autoScroll.currentTime === "number"
? autoScroll.currentTime
: 0;
const prevDuration = autoScrollMetrics?.duration ?? 0;
const progress = prevDuration > 0 ? elapsed / prevDuration : 0;
autoScroll?.cancel();
autoScroll = null;
autoScrollMetrics = null;
// 渲染总宽 / 当前份数 = 单份内容的实际宽度
const singleCopyWidth = content.scrollWidth / Math.max(repeatCount.value, 1);
const metrics = measureAutoScroll(singleCopyWidth, track.clientWidth);
if (!metrics) return;
// 份数不足时先补足再重新测量,下一轮才是准确的循环宽度
if (metrics.repeatCount !== repeatCount.value) {
repeatCount.value = metrics.repeatCount;
nextTick(() => startAutoScroll());
return;
}
autoScrollMetrics = metrics;
const anim = content.animate(
[
{ transform: "translateX(0)" },
{ transform: `translateX(-${100 / repeatCount.value}%)` },
],
{ duration: metrics.duration, iterations: Infinity, easing: "linear" },
);
anim.currentTime = progress * metrics.duration;
autoScroll = anim;
};
/** 滚轮与手指拖动共用的手动位移 */
const scrollByPixels = (deltaPx: number) => {
const anim = autoScroll;
const metrics = autoScrollMetrics;
if (!anim || !metrics) return;
const current = anim.currentTime;
if (typeof current !== "number") return;
anim.currentTime = clampAutoScrollTime(
current + pxToTimeOffset(deltaPx, metrics.speed),
metrics.duration,
);
};
const handleTrackWheel = (event: WheelEvent) => {
if (!autoScrollMetrics) return;
autoScroll?.pause();
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? rawDelta * 16 : rawDelta;
if (!delta) return;
scrollByPixels(delta);
};
// 一屏内容的宽度对应一轮自动滚动
const loopWidth = content.scrollWidth / 2;
if (!loopWidth) return;
let touchLastX = 0;
let touchMovedDistance = 0;
const TOUCH_TAP_THRESHOLD = 6;
const current = anim.currentTime;
if (typeof current !== "number") return;
anim.currentTime = Math.min(
Math.max(current + (delta / loopWidth) * AUTO_SCROLL_DURATION, 0),
AUTO_SCROLL_DURATION,
);
const handleTouchStart = (event: TouchEvent) => {
if (!autoScrollMetrics) return;
const touch = event.touches[0];
if (!touch) return;
touchLastX = touch.clientX;
touchMovedDistance = 0;
autoScroll?.pause();
};
const handleTouchMove = (event: TouchEvent) => {
if (!autoScrollMetrics) return;
const touch = event.touches[0];
if (!touch) return;
const deltaX = touch.clientX - touchLastX;
touchLastX = touch.clientX;
touchMovedDistance += Math.abs(deltaX);
if (!deltaX) return;
scrollByPixels(deltaX);
};
const handleTouchEnd = () => {
autoScroll?.play();
};
/** 拖动过就吞掉这次 click,避免误触打开回忆卡片 */
const handleChipClick = (fragment: FragmentChip, event: MouseEvent) => {
if (touchMovedDistance > TOUCH_TAP_THRESHOLD) {
event.preventDefault();
event.stopPropagation();
touchMovedDistance = 0;
return;
}
touchMovedDistance = 0;
openRecallCard(fragment);
};
/** 视口跨过断点时速度会变,需重新测量时长并保持当前进度 */
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const handleViewportResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
startAutoScroll();
}, 150);
};
const pauseAutoScroll = () => {
@@ -157,17 +262,14 @@ const resumeAutoScroll = () => {
onMounted(() => {
loadReviewFeed();
autoScroll = contentRef.value?.animate(
[
{ transform: "translateX(0)" },
{ transform: "translateX(-50%)" },
],
{ duration: AUTO_SCROLL_DURATION, iterations: Infinity, easing: "linear" },
) || null;
window.addEventListener("resize", handleViewportResize);
});
onBeforeUnmount(() => {
autoScroll?.cancel();
autoScroll = null;
window.removeEventListener("resize", handleViewportResize);
if (resizeTimer) clearTimeout(resizeTimer);
});
</script>
@@ -181,10 +283,15 @@ onBeforeUnmount(() => {
</span>
</div>
<div
ref="trackRef"
class="review-scroll-track"
@mouseenter="pauseAutoScroll"
@mouseleave="resumeAutoScroll"
@wheel.prevent="handleTrackWheel"
@touchstart.passive="handleTouchStart"
@touchmove.passive="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<div
ref="contentRef"
@@ -194,7 +301,7 @@ onBeforeUnmount(() => {
v-for="(chip, index) in duplicatedChips"
:key="index"
class="review-chip fragment-chip"
@click="openRecallCard(chip)"
@click="handleChipClick(chip, $event)"
>
<strong class="chip-task">{{ chip.taskName }}</strong>
<span class="chip-content">{{ chip.content }}</span>
@@ -359,6 +466,8 @@ onBeforeUnmount(() => {
.review-scroll-track {
overflow: hidden;
/* 限制横向手势交给脚本处理,避免拖动时页面跟着晃 */
touch-action: pan-y;
mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 3%, black 97%, transparent);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 无缝横向跑马灯的恒速换算。
*
* 滚动内容复制多份,动画从 translateX(0) 走到 translateX(-100%/份数)
* 因此一"轮"的距离就是单份内容的宽度。
*
* 速度恒定(与内容量无关):同样的 px/s 在任何数据量、任何环境下观感一致;
* 上下限只作为安全边界,避免以后有人把基准值改坏导致完全不可用。
*
* 说明:内容不足一屏时仍然滚动——轨道右侧有渐变遮罩,不滚动就看不到后面的内容。
*/
/** 桌面基准速度:60px/s,一屏 1000px 内容约 17 秒走完 */
export const BASE_SPEED_PX_PER_SEC = 60;
/** 桌面基准速度的安全上限:内容再怪也不超过这个速度 */
export const MAX_SPEED_PX_PER_SEC = 85;
/** 桌面基准速度的安全下限:再慢也不会显得像卡住了 */
export const MIN_SPEED_PX_PER_SEC = 45;
/** 窄屏(手机)相对桌面的速度系数:视口窄,同 px/s 的视觉速度更快 */
export const NARROW_SPEED_FACTOR = 0.6;
/** 移动端断点,与仓库样式约定保持一致 */
export const NARROW_MAX_WIDTH = 768;
/** 最少复制份数:一份用于展示,一份用于无缝衔接 */
export const MIN_REPEAT_COUNT = 2;
/** 复制份数上限,避免内容异常时渲染过多节点 */
export const MAX_REPEAT_COUNT = 24;
export interface AutoScrollMetrics {
/** 一轮滚动距离(px),即单份内容的宽度 */
loopWidth: number;
/** 轨道可视宽度(px */
viewportWidth: number;
/** 实际使用速度(px/s */
speed: number;
/** 一轮动画时长(ms */
duration: number;
/** 需要渲染的内容份数 */
repeatCount: number;
}
/**
* 按视口宽度取速度(px/s)。
* 上下限只约束桌面基准速度(防止基准值被改坏),移动端系数在其后生效,
* 这样手机端才是真正的桌面 0.6 倍。
*/
export function getScrollSpeedPxPerSec(viewportWidth: number): number {
const baseSpeed = Math.min(Math.max(BASE_SPEED_PX_PER_SEC, MIN_SPEED_PX_PER_SEC), MAX_SPEED_PX_PER_SEC);
const isNarrow = viewportWidth > 0 && viewportWidth <= NARROW_MAX_WIDTH;
return baseSpeed * (isNarrow ? NARROW_SPEED_FACTOR : 1);
}
/**
* 需要渲染多少份内容。
* 动画把内容向前推一份的宽度,若总宽不够,循环后半段轨道右侧会露白,
* 所以至少要能铺满「可视宽度 + 无缝衔接的那一份」。
*/
export function getRepeatCount(singleCopyWidth: number, viewportWidth: number): number {
if (!Number.isFinite(singleCopyWidth) || singleCopyWidth <= 0) return MIN_REPEAT_COUNT;
if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return MIN_REPEAT_COUNT;
const needed = Math.ceil(viewportWidth / singleCopyWidth) + 1;
return Math.min(Math.max(needed, MIN_REPEAT_COUNT), MAX_REPEAT_COUNT);
}
/**
* 计算一轮滚动的距离、速度与时长。
* `singleCopyWidth` 是单份内容宽度(调用方按实际用量总宽折算)。
* 无法计算时返回 null,调用方据此不启动动画。
*/
export function measureAutoScroll(
singleCopyWidth: number,
viewportWidth: number,
): AutoScrollMetrics | null {
const speed = getScrollSpeedPxPerSec(viewportWidth);
if (!Number.isFinite(singleCopyWidth) || singleCopyWidth <= 0) return null;
if (!Number.isFinite(speed) || speed <= 0) return null;
return {
loopWidth: singleCopyWidth,
viewportWidth,
speed,
duration: (singleCopyWidth / speed) * 1000,
repeatCount: getRepeatCount(singleCopyWidth, viewportWidth),
};
}
/** 把位移(px)换算成动画时间(ms),滚轮与手指拖动共用同一套换算 */
export function pxToTimeOffset(deltaPx: number, speed: number): number {
if (!Number.isFinite(deltaPx) || !Number.isFinite(speed) || speed <= 0) return 0;
return (deltaPx / speed) * 1000;
}
/** 把动画时间夹在 [0, duration] 内,配合 iterations: Infinity 保持循环连续 */
export function clampAutoScrollTime(time: number, duration: number): number {
if (!Number.isFinite(time)) return 0;
if (!Number.isFinite(duration) || duration <= 0) return 0;
return Math.min(Math.max(time, 0), duration);
}