feat(viewer,ui04): E04 interactions — pause reason set, click lock/drag cancel, manual scrub, keyboard semantics, destroy
- pauseReasons Set (hover/focus/pointer/offscreen/hidden/manual + userPaused via Space, no auto-resume); 500ms transient resume with re-evaluation - pointerdown target lock; >8px drag suppresses click and scrubs via nudgeOffset (window-clamped); removed photos clear pending target - keyboard: Space pause toggle, Escape collapse, ArrowUp/Down scrub; input fields untouched - aria-live status announcements (zh/en); viewport touch-action none - e2e: hover freeze/resume, popup deep link hit, drag cancel, keyboard, slot disable destroy Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,136 @@ test.describe.serial('E03 recording: 30s+ including loop boundary', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E04 interactions: pause, click lock, keyboard, destroy', () => {
|
||||
async function trackTransform(page: Page): Promise<number> {
|
||||
const embedFrame = page.frames().find((f) => f.url().includes('v=2'));
|
||||
expect(embedFrame).toBeDefined();
|
||||
return (await embedFrame!.locator('.stream-track').evaluate((el) => {
|
||||
const m = /translateY\((-?[\d.]+)px\)/.exec(el.style.transform ?? '');
|
||||
return m === null ? 0 : Number.parseFloat(m[1]);
|
||||
})) as number;
|
||||
}
|
||||
|
||||
test('fine-pointer hover pauses; leaving resumes after 500ms (§4.2)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
await page.waitForTimeout(1500);
|
||||
// 悬停 iframe 中心(细指针环境):300ms 后 transform 冻结。
|
||||
const box = await frame.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
|
||||
await page.waitForTimeout(400);
|
||||
const pausedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(800);
|
||||
expect(await trackTransform(page)).toBe(pausedAt); // 悬停期间静止
|
||||
// 离开:500ms 恢复延时后继续上滚。
|
||||
await page.mouse.move(40, 40);
|
||||
await page.waitForTimeout(400);
|
||||
const resumedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(900);
|
||||
const after = await trackTransform(page);
|
||||
expect(after).toBeLessThan(resumedAt); // 恢复滚动(translateY 更负)
|
||||
});
|
||||
|
||||
test('photo click opens viewer deep link for the clicked photo (§4.1)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
// 先悬停冻结(避免点击瞬间目标位移——点击锁定的正是防错图语义)。
|
||||
const box = await frame.boundingBox();
|
||||
await page.mouse.move((box!.x ?? 0) + 90, (box!.y ?? 0) + 140);
|
||||
await page.waitForTimeout(400);
|
||||
const target = inside.locator('a.stream-photo').first();
|
||||
const photoId = await target.getAttribute('data-photo-id');
|
||||
expect(photoId).toBeTruthy();
|
||||
const popupPromise = page.waitForEvent('popup', { timeout: 5000 });
|
||||
await target.click();
|
||||
const popup = await popupPromise;
|
||||
expect(popup.url()).toContain(`/p/${photoId}`);
|
||||
await popup.close();
|
||||
});
|
||||
|
||||
test('drag beyond 8px cancels click (§4.1)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
const box = await frame.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
const cx = (box!.x ?? 0) + 90;
|
||||
const cy = (box!.y ?? 0) + 200;
|
||||
let popupOpened = false;
|
||||
page.on('popup', () => {
|
||||
popupOpened = true;
|
||||
});
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.waitForTimeout(300);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx, cy - 30, { steps: 5 }); // 拖动 30px > 8px
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(800);
|
||||
expect(popupOpened).toBe(false);
|
||||
});
|
||||
|
||||
test('keyboard: Space toggles pause (no auto-resume), Escape collapses (§4.2)', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await page.waitForTimeout(1200);
|
||||
// 聚焦流区域(点击视口非链接处不触发照片点击——顶部渐隐带命中视口)。
|
||||
const box = await frame.boundingBox();
|
||||
await page.mouse.click((box!.x ?? 0) + 90, (box!.y ?? 0) + 6);
|
||||
await inside.locator('.stream-viewport').focus();
|
||||
await page.keyboard.press(' ');
|
||||
await page.waitForTimeout(300);
|
||||
const pausedAt = await trackTransform(page);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await trackTransform(page)).toBe(pausedAt); // Space 暂停:不自动恢复
|
||||
// Escape → collapse 上行宿主:wrapper 隐藏 + 把手出现。
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[role="button"]').first()).toBeVisible();
|
||||
await expect(frame).toBeHidden();
|
||||
});
|
||||
|
||||
test('slot disable → destroy: wrapper and handle removed, no ghost (§6.3)', async ({ page }) => {
|
||||
// 独立槽(不破坏主 slot 的后续用例)。
|
||||
const album = await seedAlbum({
|
||||
alias: 'ui04v2-destroy',
|
||||
defaultLocale: 'zh-CN',
|
||||
translations: [{ locale: 'zh-CN', title: '销毁测试' }],
|
||||
});
|
||||
await seedPhoto(album.id, 'ui04v2-destroy-p1', { publishedAt: new Date() });
|
||||
await flushSync(album.id);
|
||||
const slot = await createEmbedSlot('ui04-v2-destroy');
|
||||
await updateEmbedSlot(slot.id, {
|
||||
albumId: album.id,
|
||||
settings: {
|
||||
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
|
||||
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
|
||||
displayMode: 'vertical-stream', scrollSpeed: 16, reactionsEnabled: true,
|
||||
},
|
||||
});
|
||||
await flushOutbox();
|
||||
|
||||
currentHtml = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8" />
|
||||
<title>destroy host</title></head><body style="min-height: 600px">
|
||||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slot.token}" data-poll="1000" defer></script>
|
||||
</body></html>`;
|
||||
await page.setViewportSize({ width: 800, height: 600 });
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await frame.waitFor();
|
||||
// 停用:管理面 enabled=false → 高优先级 slot.updated → ov 404 → destroy。
|
||||
await updateEmbedSlot(slot.id, { enabled: false });
|
||||
await flushOutbox();
|
||||
// dev 构建 data-poll=1000 → 1s 轮询加速(loader 仅显式透传)。
|
||||
await expect(frame).toBeHidden({ timeout: 15_000 });
|
||||
await expect(page.locator('[role="button"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.serial('E02 static fidelity: measured styles + screenshots', () => {
|
||||
test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => {
|
||||
await openHost(page, { width: 1440, height: 900 });
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
advanceStream,
|
||||
anchorRebuild,
|
||||
createStreamState,
|
||||
MAX_DOM_INSTANCES,
|
||||
photoDisplayHeight,
|
||||
PHOTO_GAP_PX,
|
||||
removePhoto,
|
||||
@@ -161,6 +162,38 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
if (!scrollable()) stopLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动滚动(§4.3):外部(滚轮/拖动/键盘)直接平移当前窗口内容。夹取在
|
||||
* 已渲染窗口范围内(有限真实列表语义——不重放全部历史);自动恢复时从
|
||||
* 当前 offset 锚点继续(不回顶)。paused 状态下亦可用(reducedMotion 手动
|
||||
* 查看路径)。超出窗口尾部时按需补窗(anchor 语义由下次 reanchor 收敛)。
|
||||
*/
|
||||
function nudgeOffset(deltaPx: number): void {
|
||||
const s = state.value;
|
||||
if (s === null || s.items.length === 0) return;
|
||||
let total = 0;
|
||||
for (const item of s.items) {
|
||||
total += photoDisplayHeight(s.sizes[item.queueIdx], innerWidth) + PHOTO_GAP_PX;
|
||||
}
|
||||
const maxOffset = Math.max(0, total - viewportHeight);
|
||||
// 头部上界:负向钳 0(已到最新);尾部下界钳 maxOffset(到窗口尾)。
|
||||
s.offset = Math.max(0, Math.min(s.offset + deltaPx, maxOffset));
|
||||
// 手动平移可能越过退役边界:复用 advance 的退役循环(速度 0 的纯夹取)。
|
||||
let guard = 0;
|
||||
while (
|
||||
guard < MAX_DOM_INSTANCES &&
|
||||
s.offset >= photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX
|
||||
) {
|
||||
const headH =
|
||||
photoDisplayHeight(s.sizes[s.headIdx], innerWidth) + PHOTO_GAP_PX;
|
||||
s.offset -= headH;
|
||||
s.headIdx = (s.headIdx + 1) % s.queue.length;
|
||||
if (s.headIdx === 0) s.gen += 1;
|
||||
guard += 1;
|
||||
}
|
||||
syncRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前与视口相交的照片(可见定义 §5.2:交集面积>0 且高度≥20px)——
|
||||
* E05 气泡归属依据。纯计算,不触 DOM。
|
||||
@@ -215,6 +248,7 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
photoById,
|
||||
offsetView,
|
||||
dropPhoto,
|
||||
nudgeOffset,
|
||||
visiblePhotoIds,
|
||||
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
|
||||
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
|
||||
|
||||
@@ -36,6 +36,8 @@ export default {
|
||||
// UI-04 v2 vertical photo stream window
|
||||
streamLabel: 'Event photo window',
|
||||
collapseWindow: 'Collapse photo window',
|
||||
streamPaused: 'Photo scrolling paused',
|
||||
streamResumed: 'Photo scrolling resumed',
|
||||
},
|
||||
download: {
|
||||
action: 'Download photo',
|
||||
|
||||
@@ -37,6 +37,8 @@ export default {
|
||||
// —— UI-04 v2 垂直照片流窗 ——
|
||||
streamLabel: '活动照片窗',
|
||||
collapseWindow: '收起照片窗',
|
||||
streamPaused: '照片滚动已暂停',
|
||||
streamResumed: '照片滚动继续',
|
||||
},
|
||||
download: {
|
||||
action: '下载图片',
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
|
||||
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(空态;错误态随取数退避复用空窗不弹 UI,A11)
|
||||
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
|
||||
(异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角
|
||||
与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->
|
||||
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2;交互层 §4:
|
||||
· 暂停原因集合(Set,非互斥布尔):hiddenDocument/parentCollapsed(经 active)/
|
||||
offscreen/hoverFinePointer/focusWithin/pointerActive/manualScroll/
|
||||
reducedMotion/userPaused/insufficientContent——全部清空才自动滚动;
|
||||
瞬态原因(指针/焦点/滚轮/拖动)离开后 500ms 恢复;键盘 Space 暂停属
|
||||
用户显式暂停,不自动恢复(§4.2);
|
||||
· 点击语义(§4.1):pointerdown 锁定目标 photoId;位移 >8px 或目标已
|
||||
下线 → 取消点击;photoUrl 失效(换绑)亦拦截;
|
||||
· 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎
|
||||
nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。
|
||||
无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
|
||||
@@ -39,12 +40,37 @@ const { t } = useI18n();
|
||||
const viewportEl = ref<HTMLElement | null>(null);
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
|
||||
// 运行闸门(§4.2 原因集的 E03 子集;E04 补 pointer/focus/manualScroll 等)。
|
||||
const running = computed(() => props.active && !props.reducedMotion && props.photos.length > 1);
|
||||
// ── 暂停原因集合(§4.2:Set 管理,自动滚动 = 全空)──
|
||||
const pauseReasons = reactive(new Set<string>());
|
||||
/** 用户显式键盘暂停(不自动恢复)。 */
|
||||
const userPaused = ref(false);
|
||||
/** 状态播报(aria-live;非布局占位,不常驻抢视觉)。 */
|
||||
const statusText = ref('');
|
||||
|
||||
function setReason(reason: string, present: boolean): void {
|
||||
if (present) pauseReasons.add(reason);
|
||||
else pauseReasons.delete(reason);
|
||||
}
|
||||
|
||||
const RESUME_DELAY_MS = 500; // 瞬态原因清空后的恢复延时(§4.2)
|
||||
let resumeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function announceStatus(paused: boolean): void {
|
||||
statusText.value = paused ? t('embed.streamPaused') : t('embed.streamResumed');
|
||||
}
|
||||
|
||||
const autoRunning = computed(
|
||||
() =>
|
||||
props.active &&
|
||||
!props.reducedMotion &&
|
||||
!userPaused.value &&
|
||||
pauseReasons.size === 0 &&
|
||||
props.photos.length > 1,
|
||||
);
|
||||
|
||||
const photosRef = computed(() => props.photos);
|
||||
const speedRef = computed(() => props.speed);
|
||||
const runningRef = computed(() => running.value);
|
||||
const runningRef = computed(() => autoRunning.value);
|
||||
|
||||
const stream = useVerticalPhotoStream({
|
||||
photos: photosRef,
|
||||
@@ -54,7 +80,7 @@ const stream = useVerticalPhotoStream({
|
||||
running: runningRef,
|
||||
});
|
||||
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
|
||||
const { items, photoById, dropPhoto } = stream;
|
||||
const { items, photoById, dropPhoto, nudgeOffset } = stream;
|
||||
|
||||
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
|
||||
function metaOf(photoId: string): PublicEmbedPhoto | null {
|
||||
@@ -78,20 +104,174 @@ const firstKeyByPhoto = computed(() => {
|
||||
return first;
|
||||
});
|
||||
|
||||
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随指针交互补全)——
|
||||
// ── 指针交互(§4.1/§4.2)──
|
||||
const DRAG_CANCEL_PX = 8;
|
||||
let pointerDownY: number | null = null;
|
||||
let pointerMoved = 0;
|
||||
/** pointerdown 锁定的目标 photoId(pointerup 校验后才放行点击)。 */
|
||||
let lockedTarget: string | null = null;
|
||||
/** 拖动抑制一次 click。 */
|
||||
let suppressClick = false;
|
||||
|
||||
function scheduleResume(): void {
|
||||
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
||||
resumeTimer = setTimeout(() => {
|
||||
resumeTimer = null;
|
||||
// 延时到点复评:原因已全清 → 恢复(原因重现则保持暂停)。
|
||||
if (pauseReasons.size === 0) announceStatus(false);
|
||||
}, RESUME_DELAY_MS);
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent): void {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointerDownY = event.clientY;
|
||||
pointerMoved = 0;
|
||||
const anchor = (event.target as HTMLElement).closest<HTMLElement>('a.stream-photo');
|
||||
lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null);
|
||||
setReason('pointerActive', true);
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
if (pointerDownY === null) return;
|
||||
const dy = event.clientY - pointerDownY;
|
||||
pointerMoved = Math.max(pointerMoved, Math.abs(dy));
|
||||
if (pointerMoved > DRAG_CANCEL_PX) {
|
||||
suppressClick = true;
|
||||
setReason('manualScroll', true);
|
||||
// 拖动平移:手指上移(dy<0)→ 内容上移(offset 增),同向跟手。
|
||||
nudgeOffset(-dy * 0.5);
|
||||
pointerDownY = event.clientY;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(): void {
|
||||
pointerDownY = null;
|
||||
setReason('pointerActive', false);
|
||||
scheduleResume();
|
||||
}
|
||||
|
||||
function onPhotoClick(photoId: string): void {
|
||||
if (suppressClick) {
|
||||
suppressClick = false;
|
||||
return;
|
||||
}
|
||||
// 目标锁定校验(§4.1):未锁定(非照片按下)/ 换目标(移动到另一实例)/
|
||||
// 目标已下线 → 取消。
|
||||
if (!props.active) return;
|
||||
if (lockedTarget !== null && lockedTarget !== photoId) return;
|
||||
emit('open-photo', photoId);
|
||||
}
|
||||
|
||||
// 照片下线 → 清待触发点击(引擎 dropPhoto 与 feed 移除路径都经此)。
|
||||
function onPhotoRemoved(photoId: string): void {
|
||||
if (lockedTarget === photoId) lockedTarget = null;
|
||||
}
|
||||
|
||||
// ── 键盘语义(§4.2:聚焦时 Space 暂停切换 / Escape 收起 / 上下键手动)──
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// 不抢输入字段(防御:iframe 内无输入,但保持通用语义)。
|
||||
if (target !== null && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
emit('collapse');
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Spacebar') {
|
||||
event.preventDefault();
|
||||
userPaused.value = !userPaused.value;
|
||||
announceStatus(userPaused.value || pauseReasons.size > 0);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setReason('manualScroll', true);
|
||||
nudgeOffset(event.key === 'ArrowDown' ? 60 : -60);
|
||||
scheduleResume();
|
||||
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
if (props.photos.length <= 1) return;
|
||||
event.preventDefault();
|
||||
setReason('manualScroll', true);
|
||||
nudgeOffset(event.deltaY);
|
||||
scheduleResume();
|
||||
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
|
||||
}
|
||||
|
||||
// ── 可见性/悬停/焦点(§4.2/§5.3)──
|
||||
let hoverMedia: MediaQueryList | null = null;
|
||||
let visibilityObserver: IntersectionObserver | null = null;
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
setReason('hiddenDocument', document.hidden);
|
||||
}
|
||||
function onPointerEnter(): void {
|
||||
if (hoverMedia?.matches === true) setReason('hoverFinePointer', true);
|
||||
}
|
||||
function onPointerLeave(): void {
|
||||
setReason('hoverFinePointer', false);
|
||||
scheduleResume();
|
||||
}
|
||||
function onFocusIn(): void {
|
||||
setReason('focusWithin', true);
|
||||
}
|
||||
function onFocusOut(): void {
|
||||
setReason('focusWithin', false);
|
||||
scheduleResume();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
onVisibilityChange();
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'undefined' && viewportEl.value !== null) {
|
||||
visibilityObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
setReason('offscreen', !entries.some((entry) => entry.isIntersecting));
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
visibilityObserver.observe(viewportEl.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
setReason('destroyed', true);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
visibilityObserver?.disconnect();
|
||||
if (resumeTimer !== null) clearTimeout(resumeTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="viewportEl" class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
|
||||
<div
|
||||
ref="viewportEl"
|
||||
class="stream-viewport"
|
||||
role="region"
|
||||
tabindex="0"
|
||||
:aria-label="t('embed.streamLabel')"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@pointerenter="onPointerEnter"
|
||||
@pointerleave="onPointerLeave"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
@keydown="onKeydown"
|
||||
@wheel="onWheel"
|
||||
>
|
||||
<div ref="trackEl" class="stream-track">
|
||||
<a
|
||||
v-for="item of items"
|
||||
:key="item.key"
|
||||
class="stream-photo"
|
||||
:data-photo-id="item.photoId"
|
||||
:href="photoUrl(item.photoId)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -107,7 +287,7 @@ function onPhotoClick(photoId: string): void {
|
||||
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
@error="dropPhoto(item.photoId)"
|
||||
@error="dropPhoto(item.photoId); onPhotoRemoved(item.photoId)"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@@ -115,6 +295,8 @@ function onPhotoClick(photoId: string): void {
|
||||
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
||||
{{ t('embed.comingSoon') }}
|
||||
</div>
|
||||
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
|
||||
<span class="stream-status" aria-live="polite">{{ statusText }}</span>
|
||||
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -138,6 +320,12 @@ function onPhotoClick(photoId: string): void {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #10161e;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.stream-viewport:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.7);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
|
||||
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
|
||||
@@ -230,4 +418,13 @@ function onPhotoClick(photoId: string): void {
|
||||
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
/* 状态播报:仅读屏消费,不出布局(§4.2「不常驻抢视觉」)。 */
|
||||
.stream-status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user