feat(viewer,ui04): E03 continuous scroll engine — pure state machine, rAF orchestration, boundary continuity
- stream.ts: advanceStream (per-frame, dt cap 50ms), loop generation keys, window math (DOM<=16, queue<=30, 480px buffer), anchorRebuild (photo-position preserving), removePhoto, single-photo static - useVerticalPhotoStream: rAF integration, direct transform writes (no per-frame Vue reactivity), low-frequency item sync, ResizeObserver re-anchor, stepForTest hook - EmbedStream wired to engine; cycle copies aria-hidden; img error -> controlled drop - tests: 14 unit cases (236 viewer total), e2e: 32px/2s speed, boundary continuity <2.5px, DOM<=16 over 20s, 32s recording with loop boundaries Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,26 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
|
||||
PhotoStreamViewport(relative/overflow hidden/手动模式滚动)
|
||||
Track(垂直照片实例带,translateY 驱动——E03 接线)
|
||||
PhotoStreamViewport(relative/overflow hidden/手动模式滚动——E04)
|
||||
Track(垂直照片实例带;useVerticalPhotoStream 逐帧 translateY 直写)
|
||||
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
|
||||
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
|
||||
CollapseButton(右上收起,§2.4 规格)
|
||||
StateOverlay(loading/empty/error 按需)
|
||||
StateOverlay(空态;错误态随取数退避复用空窗不弹 UI,A11)
|
||||
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
|
||||
(异常宽高回退 4:3 占位,§2.3);间距 3px;外窗(loader wrapper)负责整体
|
||||
圆角与边框,内部不再加圆角。 -->
|
||||
(异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角
|
||||
与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
|
||||
import { photoRatio } from '../../embed/stream';
|
||||
|
||||
const props = defineProps<{
|
||||
photos: PublicEmbedPhoto[];
|
||||
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
|
||||
photoUrl: (photoId: string) => string;
|
||||
/** 自动上滚速度(CSS px/s;reducedMotion/暂停时不动,E03 接线 rAF)。 */
|
||||
/** 自动上滚速度(CSS px/s)。 */
|
||||
speed: number;
|
||||
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
|
||||
reactions: boolean;
|
||||
@@ -35,35 +36,78 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ratios = computed(() => props.photos.map((p) => photoRatio(p)));
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
|
||||
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随滚动接线补全)──
|
||||
function onPhotoClick(photo: PublicEmbedPhoto): void {
|
||||
// 运行闸门(§4.2 原因集的 E03 子集;E04 补 pointer/focus/manualScroll 等)。
|
||||
const running = computed(() => props.active && !props.reducedMotion && props.photos.length > 1);
|
||||
|
||||
const photosRef = computed(() => props.photos);
|
||||
const speedRef = computed(() => props.speed);
|
||||
const runningRef = computed(() => running.value);
|
||||
|
||||
const stream = useVerticalPhotoStream({
|
||||
photos: photosRef,
|
||||
viewportEl,
|
||||
trackEl,
|
||||
speedPxPerSec: speedRef,
|
||||
running: runningRef,
|
||||
});
|
||||
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
|
||||
const { items, photoById, dropPhoto } = stream;
|
||||
|
||||
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
|
||||
function metaOf(photoId: string): PublicEmbedPhoto | null {
|
||||
return photoById.value.get(photoId) ?? null;
|
||||
}
|
||||
function ratioOf(photoId: string): number {
|
||||
const photo = metaOf(photoId);
|
||||
return photo === null ? 4 / 3 : photoRatio(photo);
|
||||
}
|
||||
|
||||
/** 同 photoId 的非首次出现实例(循环副本):aria-hidden + tabindex 移除(§3.2.7)。 */
|
||||
const firstKeyByPhoto = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
const first = new Set<string>();
|
||||
for (const item of items.value) {
|
||||
if (!seen.has(item.photoId)) {
|
||||
seen.add(item.photoId);
|
||||
first.add(item.key);
|
||||
}
|
||||
}
|
||||
return first;
|
||||
});
|
||||
|
||||
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随指针交互补全)——
|
||||
function onPhotoClick(photoId: string): void {
|
||||
if (!props.active) return;
|
||||
emit('open-photo', photo.photoId);
|
||||
emit('open-photo', photoId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
|
||||
<div class="stream-track">
|
||||
<div ref="viewportEl" class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
|
||||
<div ref="trackEl" class="stream-track">
|
||||
<a
|
||||
v-for="(photo, i) in photos"
|
||||
:key="photo.photoId"
|
||||
v-for="item of items"
|
||||
:key="item.key"
|
||||
class="stream-photo"
|
||||
:href="photoUrl(photo.photoId)"
|
||||
:href="photoUrl(item.photoId)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="t('embed.photoAlt', { n: i + 1 })"
|
||||
@click.prevent="onPhotoClick(photo)"
|
||||
:aria-hidden="!firstKeyByPhoto.has(item.key) || undefined"
|
||||
:tabindex="firstKeyByPhoto.has(item.key) ? 0 : -1"
|
||||
:aria-label="t('embed.photoAlt', { n: item.queueIdx + 1 })"
|
||||
@click.prevent="onPhotoClick(item.photoId)"
|
||||
>
|
||||
<img
|
||||
class="stream-photo__img"
|
||||
:src="photo.thumb.url"
|
||||
:alt="t('embed.photoAlt', { n: i + 1 })"
|
||||
:style="{ aspectRatio: `1 / ${ratios[i]}` }"
|
||||
:src="metaOf(item.photoId)?.thumb.url"
|
||||
:alt="t('embed.photoAlt', { n: item.queueIdx + 1 })"
|
||||
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
@error="dropPhoto(item.photoId)"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@@ -126,6 +170,7 @@ function onPhotoClick(photo: PublicEmbedPhoto): void {
|
||||
.stream-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user