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.describe.serial('E02 static fidelity: measured styles + screenshots', () => {
|
||||||
test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => {
|
test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => {
|
||||||
await openHost(page, { width: 1440, height: 900 });
|
await openHost(page, { width: 1440, height: 900 });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
advanceStream,
|
advanceStream,
|
||||||
anchorRebuild,
|
anchorRebuild,
|
||||||
createStreamState,
|
createStreamState,
|
||||||
|
MAX_DOM_INSTANCES,
|
||||||
photoDisplayHeight,
|
photoDisplayHeight,
|
||||||
PHOTO_GAP_PX,
|
PHOTO_GAP_PX,
|
||||||
removePhoto,
|
removePhoto,
|
||||||
@@ -161,6 +162,38 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
|||||||
if (!scrollable()) stopLoop();
|
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)——
|
* 当前与视口相交的照片(可见定义 §5.2:交集面积>0 且高度≥20px)——
|
||||||
* E05 气泡归属依据。纯计算,不触 DOM。
|
* E05 气泡归属依据。纯计算,不触 DOM。
|
||||||
@@ -215,6 +248,7 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
|||||||
photoById,
|
photoById,
|
||||||
offsetView,
|
offsetView,
|
||||||
dropPhoto,
|
dropPhoto,
|
||||||
|
nudgeOffset,
|
||||||
visiblePhotoIds,
|
visiblePhotoIds,
|
||||||
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
|
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
|
||||||
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
|
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default {
|
|||||||
// UI-04 v2 vertical photo stream window
|
// UI-04 v2 vertical photo stream window
|
||||||
streamLabel: 'Event photo window',
|
streamLabel: 'Event photo window',
|
||||||
collapseWindow: 'Collapse photo window',
|
collapseWindow: 'Collapse photo window',
|
||||||
|
streamPaused: 'Photo scrolling paused',
|
||||||
|
streamResumed: 'Photo scrolling resumed',
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
action: 'Download photo',
|
action: 'Download photo',
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default {
|
|||||||
// —— UI-04 v2 垂直照片流窗 ——
|
// —— UI-04 v2 垂直照片流窗 ——
|
||||||
streamLabel: '活动照片窗',
|
streamLabel: '活动照片窗',
|
||||||
collapseWindow: '收起照片窗',
|
collapseWindow: '收起照片窗',
|
||||||
|
streamPaused: '照片滚动已暂停',
|
||||||
|
streamResumed: '照片滚动继续',
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
action: '下载图片',
|
action: '下载图片',
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||||
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
|
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2;交互层 §4:
|
||||||
PhotoStreamViewport(relative/overflow hidden/手动模式滚动——E04)
|
· 暂停原因集合(Set,非互斥布尔):hiddenDocument/parentCollapsed(经 active)/
|
||||||
Track(垂直照片实例带;useVerticalPhotoStream 逐帧 translateY 直写)
|
offscreen/hoverFinePointer/focusWithin/pointerActive/manualScroll/
|
||||||
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
|
reducedMotion/userPaused/insufficientContent——全部清空才自动滚动;
|
||||||
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
|
瞬态原因(指针/焦点/滚轮/拖动)离开后 500ms 恢复;键盘 Space 暂停属
|
||||||
CollapseButton(右上收起,§2.4 规格)
|
用户显式暂停,不自动恢复(§4.2);
|
||||||
StateOverlay(空态;错误态随取数退避复用空窗不弹 UI,A11)
|
· 点击语义(§4.1):pointerdown 锁定目标 photoId;位移 >8px 或目标已
|
||||||
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
|
下线 → 取消点击;photoUrl 失效(换绑)亦拦截;
|
||||||
(异常宽高回退 4:3,§2.3);间距 3px;外窗(loader wrapper)负责整体圆角
|
· 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎
|
||||||
与边框。循环副本(短队列一圈盖不满窗口)非首出实例对辅助技术隐藏(§3.2.7)。 -->
|
nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。
|
||||||
|
无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { PublicEmbedPhoto } from '../../api/client';
|
import type { PublicEmbedPhoto } from '../../api/client';
|
||||||
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
|
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
|
||||||
@@ -39,12 +40,37 @@ const { t } = useI18n();
|
|||||||
const viewportEl = ref<HTMLElement | null>(null);
|
const viewportEl = ref<HTMLElement | null>(null);
|
||||||
const trackEl = ref<HTMLElement | null>(null);
|
const trackEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
// 运行闸门(§4.2 原因集的 E03 子集;E04 补 pointer/focus/manualScroll 等)。
|
// ── 暂停原因集合(§4.2:Set 管理,自动滚动 = 全空)──
|
||||||
const running = computed(() => props.active && !props.reducedMotion && props.photos.length > 1);
|
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 photosRef = computed(() => props.photos);
|
||||||
const speedRef = computed(() => props.speed);
|
const speedRef = computed(() => props.speed);
|
||||||
const runningRef = computed(() => running.value);
|
const runningRef = computed(() => autoRunning.value);
|
||||||
|
|
||||||
const stream = useVerticalPhotoStream({
|
const stream = useVerticalPhotoStream({
|
||||||
photos: photosRef,
|
photos: photosRef,
|
||||||
@@ -54,7 +80,7 @@ const stream = useVerticalPhotoStream({
|
|||||||
running: runningRef,
|
running: runningRef,
|
||||||
});
|
});
|
||||||
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
|
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
|
||||||
const { items, photoById, dropPhoto } = stream;
|
const { items, photoById, dropPhoto, nudgeOffset } = stream;
|
||||||
|
|
||||||
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
|
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
|
||||||
function metaOf(photoId: string): PublicEmbedPhoto | null {
|
function metaOf(photoId: string): PublicEmbedPhoto | null {
|
||||||
@@ -78,20 +104,174 @@ const firstKeyByPhoto = computed(() => {
|
|||||||
return first;
|
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 {
|
function onPhotoClick(photoId: string): void {
|
||||||
|
if (suppressClick) {
|
||||||
|
suppressClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 目标锁定校验(§4.1):未锁定(非照片按下)/ 换目标(移动到另一实例)/
|
||||||
|
// 目标已下线 → 取消。
|
||||||
if (!props.active) return;
|
if (!props.active) return;
|
||||||
|
if (lockedTarget !== null && lockedTarget !== photoId) return;
|
||||||
emit('open-photo', photoId);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div ref="trackEl" class="stream-track">
|
||||||
<a
|
<a
|
||||||
v-for="item of items"
|
v-for="item of items"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="stream-photo"
|
class="stream-photo"
|
||||||
|
:data-photo-id="item.photoId"
|
||||||
:href="photoUrl(item.photoId)"
|
:href="photoUrl(item.photoId)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@@ -107,7 +287,7 @@ function onPhotoClick(photoId: string): void {
|
|||||||
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
|
:style="{ aspectRatio: `1 / ${ratioOf(item.photoId)}` }"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
@error="dropPhoto(item.photoId)"
|
@error="dropPhoto(item.photoId); onPhotoRemoved(item.photoId)"
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +295,8 @@ function onPhotoClick(photoId: string): void {
|
|||||||
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
||||||
{{ t('embed.comingSoon') }}
|
{{ t('embed.comingSoon') }}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
|
||||||
|
<span class="stream-status" aria-live="polite">{{ statusText }}</span>
|
||||||
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -138,6 +320,12 @@ function onPhotoClick(photoId: string): void {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #10161e;
|
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):暗示滚动照片带被窗口
|
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
|
||||||
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
|
* 裁切(设计稿 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;
|
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||||
color: rgba(255, 255, 255, 0.72);
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user