@@ -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 ) ; 间距 3 px ; 外窗 ( loader wrapper ) 负责整体圆角
与边框 。 循环副本 ( 短队列一圈盖不满窗口 ) 非首出实例对辅助技术隐藏 ( § 3.2 .7 ) 。 -- >
( EmbedPage 于 v = 2 查询时渲染 ) 。 结构对齐方案 § 2.2 ; 交互层 § 4 :
· 暂停原因集合 ( Set , 非互斥布尔 ) : hiddenDocument / parentCollapsed ( 经 active ) /
offscreen / hoverFinePointer / focusWithin / pointerActive / manualScroll /
reducedMotion / userPaused / insufficientContent — — 全部清空才自动滚动 ;
瞬态原因 ( 指针 / 焦点 / 滚轮 / 拖动 ) 离开后 500 ms 恢复 ; 键盘 Space 暂停属
用户显式暂停 , 不自动恢复 ( § 4.2 ) ;
· 点击语义 ( § 4.1 ) : pointerdown 锁定目标 photoId ; 位移 > 8 px 或目标已
下线 → 取消点击 ; 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 : # 10161 e ;
outline : none ;
touch - action : none ;
}
. stream - viewport : focus - visible {
outline : 2 px solid rgba ( 255 , 255 , 255 , 0.7 ) ;
outline - offset : - 2 px ;
}
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
@@ -230,4 +418,13 @@ function onPhotoClick(photoId: string): void {
font : 500 0.75 rem / 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 : 1 px ;
height : 1 px ;
overflow : hidden ;
clip : rect ( 0 0 0 0 ) ;
white - space : nowrap ;
}
< / style >