useHoverPreview()
useHoverPreview 是「卡片 hover 弹出放大浮层预览」(Netflix / 视频站封面预览范式)的机制层:
- hover 意图判定:移入锚点
showDelay(默认 300ms)后才弹出——快速划过不触发;移出hideDelay(默认 140ms)后关闭——期间鼠标可顺移进浮层(keepOpen()取消关闭)。 - 锚点捕获:
show()时捕获锚点getBoundingClientRect(),供浮层以封面几何中心定位。 - 滚动即关:window scroll(capture 阶段,可捕获 SwRail 等横滚容器内部滚动)立即关闭——浮层是 fixed 定位,滚动会与卡片错位。原位预览传
closeOnScroll: false关掉。 - 按下即取消待弹出:在锚点内按下(
pointerdown)时,还没弹出的预览不再弹出——用户已决定点进去;否则浮层可能在按下与抬起之间弹出、盖住指针,按下落在卡片、抬起落在浮层,浏览器不会派发click,这次点击被吞。已显示的预览不受影响(原位预览封面里的静音按钮等照常可点);触摸按下不取消(allowTouch的长按预览靠按住计时)。 - 触摸设备默认不响应:内置门控(
allowTouch可 opt-out)。⚠️ 注意「触摸设备没有 mouseenter」是错的——tap 会派发兼容性鼠标事件,详见下方注意事项。
它只管行为,不管长相——浮层组件(压暗、扫描线、角标等视觉)由业务自绘,浮层内播视频用 SwVideoPreview。定位用同文件导出的纯函数 computePreviewBox。
收编自 yc001 / starter 双实现(framework#253);starter 的
MovieCard+MovieHoverPreview是完整参考实现。
参数
useHoverPreview(anchorRef, options?)
| 名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
anchorRef | Ref<HTMLElement | null> | ✓ | — | 锚点元素(通常是封面容器) |
options.showDelay | number | 300 | 移入到弹出的延迟 ms(快速划过窗口) | |
options.hideDelay | number | 140 | 移出到关闭的延迟 ms(容忍顺移进浮层) | |
options.allowTouch | boolean | false | 触摸 / 笔也响应。默认只响应鼠标;明知在做触摸端长按预览这类场景才开 | |
options.closeOnScroll | boolean | true | 任意 window scroll 立即关闭(fixed 浮层语义)。原位预览(视频挂在锚点内、随卡片一起滚)传 false,否则滚轮滚动中停在卡上、懒加载布局位移、Playwright hover() 自带的 scrollIntoView 都会掐掉预览 |
返回值
| 名称 | 类型 | 说明 |
|---|---|---|
visible | Ref<boolean> | 浮层是否可见 |
rect | Ref<DOMRect | null> | 锚点矩形(show() 时捕获) |
show | (e?: Event) => void | 绑卡片 @pointerenter(传入事件可让门控按 pointerType 精确判定;绑 @mouseenter 也能用,回落设备判定) |
hide | () => void | 绑卡片 / 浮层 @mouseleave |
keepOpen | () => void | 绑浮层 @mouseenter(顺移取消关闭) |
computePreviewBox()
浮层定位盒纯函数:封面宽 × scale(默认 1.25)等比放大 → 按朝向 clamp 进 bounds(默认横版 [272, 600] / 竖版 [198, 400] px)→ 保封面真实宽高比 → 居中于封面几何中心 → 距视口边缘 ≥ edge(默认 8px)。
computePreviewBox(rect, viewport, options?) => { left, top, width, height }(px,配 position: fixed 用)
| 名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
rect | { left, top, width, height } | ✓ | — | 锚点矩形(即返回值 rect) |
viewport | { w, h } | ✓ | — | 视口尺寸(window.innerWidth/Height) |
options.scale | number | 1.25 | 相对封面的等比放大倍数 | |
options.bounds | PreviewBounds | 见上 | 朝向分档宽度区间(朝向由 rect 宽高比推断) | |
options.edge | number | 8 | 与视口边缘最小间距 px |
使用示例
<!-- 示例1:卡片接线(封面锚点 + 浮层开合) -->
<script setup lang="ts">
const coverRef = ref<HTMLElement | null>(null)
const { visible, rect, show, hide, keepOpen } = useHoverPreview(coverRef)
</script>
<template>
<a class="sw-my-card" @pointerenter="show" @pointerleave="hide">
<div ref="coverRef" class="cover"><SwImage :src="item.img" :alt="item.name" ratio="16/9" /></div>
<p class="title">{{ item.name }}</p>
</a>
<!-- 浮层组件业务自绘:接 rect/visible,@enter="keepOpen" @leave="hide" -->
<MyHoverPreview :item="item" :rect="rect" :visible="visible" @enter="keepOpen" @leave="hide" />
</template>
<!-- 示例2:浮层组件内定位 + SwVideoPreview 播预览(业务自绘视觉)
SSR 守卫不能省:浮层组件会被服务端渲染到,那时 window 不存在 -->
<script setup lang="ts">
const props = defineProps<{ item: MyItem; rect: DOMRect | null; visible: boolean }>()
const box = computed(() =>
props.rect && typeof window !== 'undefined'
? computePreviewBox(props.rect, { w: window.innerWidth, h: window.innerHeight })
: null)
</script>
<template>
<ClientOnly>
<Teleport to="body">
<a
v-if="visible && box"
class="sw-my-hover-preview"
:style="{ position: 'fixed', left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` }"
@mouseenter="$emit('enter')"
@mouseleave="$emit('leave')"
>
<SwVideoPreview :id="item.id" />
</a>
</Teleport>
</ClientOnly>
</template>
// 示例3:调开合节奏 / 放大参数
useHoverPreview(coverRef, { showDelay: 450, hideDelay: 200 })
computePreviewBox(rect, vp, { scale: 1.4, edge: 16 })
// 示例4:业务预热钩子(如固定样片 warm)包在自己的 show 外层,framework 不内置
function onEnter() {
warmPreviewVideos()
show()
}
相关链接
- SwVideoPreview 静音循环预览播放器 —— 浮层内播视频的官配组件
- SwRail 横滚横排 —— 封面横排场景(箭头对齐封面用
--sw-rail-arrow-top) - starter
MovieCard/MovieHoverPreview—— 完整参考实现(含压暗 / 扫描线 / 淡入视觉)
注意事项
- 只给行为,浮层视觉归业务自绘 —— 返回
{ visible, rect }等状态;压暗 / 扫描线 / 角标等长什么样由 applier 自己写,浮层内播视频配SwVideoPreview。别自造 hover 延时 / 定位逻辑(这正是本 composable 收编 yc001 与 starter 双实现的原因)。 - 鼠标顺移进浮层必须调
keepOpen()—— 移出锚点后有hideDelay(默认 140ms)关闭定时器;浮层自身要在@mouseenter调keepOpen()取消关闭、@mouseleave调hide(),否则鼠标一进浮层就被关掉。 - 滚动即关是有意行为,但只对浮层成立 —— 浮层是
fixed定位,页面(含横滚行内部)一滚就会与卡片错位,故 window scroll(capture 阶段)直接关闭,不做跟随。用它做原位预览(视频挂在封面内、随卡片滚)必须传closeOnScroll: false:否则用户滚轮滚动中停在卡上、懒加载图片造成布局位移、Playwrighthover()对视口外元素自带的 scrollIntoView,都会在 pointerenter 后立刻掐掉 pending 的 show,预览静默不起(表现为 e2e 按当天卡片位置红)。 - 触摸设备默认不响应(内置门控,别自己再判一遍) —— 「触摸设备没有 mouseenter」是个流传很广的错误认知:浏览器为兼容只监听鼠标事件的老站点,会在 tap 后补发一串兼容性鼠标事件(
mouseover/mouseenter/mousemove/click),mouseenter照样触发。更糟的是手指离开屏幕不产生mouseleave(要等下次 tap 别处才补发)⇒ 没有门控时,移动端 tap 一下浮层会弹出来并一直挂着,不是「闪一下就被导航冲掉」。
本 composable 已内置门控,业务侧不需要再自己加设备判断。要触摸端也响应传allowTouch: true。 - 绑
@pointerenter而不是@mouseenter,也别自己用matchMedia判 ——matchMedia('(hover: hover) and (pointer: fine)')判的是「这台设备的主要输入能力」,而我们要判的是「这一次交互是用什么发起的」。二合一设备上这两者不是一回事,且两个方向都会错:设备 媒体查询 手指 tap 鼠标 hover iPad + 妙控键盘 / 触控板 true放行 ⇒ 门控失效 正常 触摸主导的二合一 + 外接鼠标 false正常拦截 拦截 ⇒ 鼠标 hover 被误杀 PointerEvent.pointerType是按每次交互判定的(手指'touch'/ 笔'pen'/ 鼠标'mouse'),天然没有这个问题。所以绑@pointerenter="show"把事件传进来最准;绑@mouseenter="show"也能工作(内部回落媒体查询),只是二合一设备上会误判。