fix: 首页滚动条改为恒速并补移动端拖动
This commit is contained in:
@@ -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) // 单份 4000px,60px/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()
|
||||
})
|
||||
})
|
||||
@@ -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('一轮距离为单份内容宽度,时长由恒速换算', () => {
|
||||
// 单份 2000px;60px/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、轨道 1000px:ceil(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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user