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?)

名称类型必填默认值说明
anchorRefRef<HTMLElement | null>✓—锚点元素(通常是封面容器)
options.showDelaynumber300移入到弹出的延迟 ms(快速划过窗口)
options.hideDelaynumber140移出到关闭的延迟 ms(容忍顺移进浮层)
options.allowTouchbooleanfalse触摸 / 笔也响应。默认只响应鼠标;明知在做触摸端长按预览这类场景才开
options.closeOnScrollbooleantrue任意 window scroll 立即关闭(fixed 浮层语义)。原位预览(视频挂在锚点内、随卡片一起滚)传 false,否则滚轮滚动中停在卡上、懒加载布局位移、Playwright hover() 自带的 scrollIntoView 都会掐掉预览

返回值

名称类型说明
visibleRef<boolean>浮层是否可见
rectRef<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.scalenumber1.25相对封面的等比放大倍数
options.boundsPreviewBounds见上朝向分档宽度区间(朝向由 rect 宽高比推断)
options.edgenumber8与视口边缘最小间距 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()
}

相关链接

注意事项

  • 只给行为,浮层视觉归业务自绘 —— 返回 { visible, rect } 等状态;压暗 / 扫描线 / 角标等长什么样由 applier 自己写,浮层内播视频配 SwVideoPreview。别自造 hover 延时 / 定位逻辑(这正是本 composable 收编 yc001 与 starter 双实现的原因)。
  • 鼠标顺移进浮层必须调 keepOpen() —— 移出锚点后有 hideDelay(默认 140ms)关闭定时器;浮层自身要在 @mouseenter 调 keepOpen() 取消关闭、@mouseleave 调 hide(),否则鼠标一进浮层就被关掉。
  • 滚动即关是有意行为,但只对浮层成立 —— 浮层是 fixed 定位,页面(含横滚行内部)一滚就会与卡片错位,故 window scroll(capture 阶段)直接关闭,不做跟随。用它做原位预览(视频挂在封面内、随卡片滚)必须传 closeOnScroll: false:否则用户滚轮滚动中停在卡上、懒加载图片造成布局位移、Playwright hover() 对视口外元素自带的 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" 也能工作(内部回落媒体查询),只是二合一设备上会误判。