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"也能運作(內部回落媒體查詢),只是二合一裝置上會誤判。