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