@vangro/virtual-masonry
v1.1.3
Published
Vue 3 virtual masonry layout with timeline scrubber and lazy image loading
Maintainers
Readme
@vangro/virtual-masonry
Vue 3 虛擬化圖片牆套件,包含瀑布流、等高橫排、時間軸捲動條三個元件。
安裝
npm install @vangro/virtual-masonry
# 或
bun add @vangro/virtual-masonry快速開始
// main.js — 全域註冊
import { VirtualMasonryPlugin } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'
app.use(VirtualMasonryPlugin)// 單檔按需引入
import { MasonryGrid, JustifiedGrid, TimelineScrubber } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'資料格式
每個 item 物件需要有 ratio 屬性(寬高比),其餘欄位自訂:
const items = [
{ id: 1, ratio: 1.5, src: '...', title: '...' },
{ id: 2, ratio: 0.75, src: '...', title: '...' },
]MasonryGrid 瀑布流
虛擬化瀑布流,只渲染視窗內的項目。
基本用法
<MasonryGrid :items="items" :item-width="200" :gap="8">
<template #default="{ item, focused }">
<img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
</template>
</MasonryGrid>Props
| Prop | 型別 | 預設 | 說明 |
|---|---|---|---|
| items | Array | [] | 資料陣列,每個 item 需有 ratio 屬性 |
| cols | Number | 0 | 固定欄數,0 = 依 itemWidth 自動計算 |
| itemWidth | Number | 160 | 自動計算欄數時的目標欄寬(px) |
| gap | Number | 8 | 欄間距與列間距(px) |
| paddingTop | Number | 0 | 頂部留白(px) |
| paddingBottom | Number | 0 | 底部留白(px) |
| smallColsRatio | Number | 0.7 | cols=0 時容器寬度佔父元素的比例 |
| overscan | Number | 300 | 視窗外預渲染的像素範圍 |
| keyboardNav | Boolean | false | 啟用鍵盤導航 |
| navMode | String | 'spatial' | 鍵盤導航模式:'spatial'(位置)/ 'linear'(線性) |
| radius | Number | 0 | 圓角(px) |
Slot
<template #default="{ item, index, position, focused }">
<!-- item: 原始資料物件 -->
<!-- index: 在 items 陣列中的索引 -->
<!-- position: { left, top, width, height } -->
<!-- focused: 是否為鍵盤焦點 -->
</template>Events
| 事件 | 參數 | 說明 |
|---|---|---|
| layout-ready | — | 佈局計算完成 |
| item-click | (item, index) | 點擊項目 |
| navigate | { index, item } | 鍵盤移動焦點 |
| confirm | { index, item } | 鍵盤 Enter 確認 |
Expose(ref 方法)
const gridRef = ref(null)
// 捲動到指定項目
gridRef.value.scrollTo(index, { position: 'start', behavior: 'smooth' })
gridRef.value.scrollTo(item, { position: 'center' })
gridRef.value.scrollTo('id', itemId, { position: 'top' })
// position: 'start'(預設,距頂 25%)| 'center' | 'top'
// 取得畫面中心對應的 item index(供 TimelineScrubber 使用)
gridRef.value.findIndexAtCenter(scrollTop, viewportHeight)
// 手動更新容器寬度(視窗 resize 後自動觸發,通常不需要手動呼叫)
gridRef.value.updateContainerWidth()
// 鍵盤焦點
gridRef.value.focusedIndex // 目前焦點的 index(ref)
gridRef.value.focus(index) // 程式設定焦點鍵盤導航
| 按鍵 | spatial 模式 | linear 模式 |
|---|---|---|
| ← | 移到左側最近的項目 | 前一個項目 |
| → | 移到右側最近的項目 | 後一個項目 |
| ↑ | 移到上方同欄最近的項目 | 前一個項目 |
| ↓ | 移到下方同欄最近的項目 | 後一個項目 |
| Enter | 觸發 confirm 事件 | 觸發 confirm 事件 |
JustifiedGrid 等高橫排
等高橫排佈局,每行圖片高度相同、寬度依比例分配撐滿容器。支援群組化。
基本用法
<JustifiedGrid :items="items" :target-height="220" :gap="8">
<template #default="{ item, focused }">
<img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
</template>
</JustifiedGrid>群組用法
<JustifiedGrid
:items="items"
:grouped="true"
group-key="year"
:group-label="(key) => `${key} 年`"
:group-header-height="48"
:sticky-header="true"
:sticky-header-top="56"
>
<template #default="{ item, focused }">
<img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
</template>
<template #group-header="{ label, items }">
<span style="font-size:16px;font-weight:700;">{{ label }}</span>
<span style="font-size:12px;color:#999;margin-left:8px;">{{ items.length }} 張</span>
</template>
</JustifiedGrid>Props
| Prop | 型別 | 預設 | 說明 |
|---|---|---|---|
| items | Array | [] | 資料陣列,每個 item 需有 ratio 屬性 |
| targetHeight | Number | 220 | 目標行高(px),實際行高會依比例微調 |
| gap | Number | 4 | 欄間距與列間距(px) |
| paddingTop | Number | 0 | 頂部留白(px) |
| paddingBottom | Number | 0 | 底部留白(px) |
| overscan | Number | 400 | 視窗外預渲染的像素範圍 |
| grouped | Boolean | false | 啟用群組模式 |
| groupKey | String\|Function | 'group' | 群組欄位名稱,或 (item) => key 函式 |
| groupLabel | Function | null | (key) => string,群組標題顯示文字 |
| groupHeaderHeight | Number | 48 | 群組標題高度(px) |
| groupGap | Number | 16 | 群組之間的間距(px) |
| stickyHeader | Boolean | true | 群組標題是否 sticky |
| stickyHeaderTop | Number | 0 | sticky 標題距頂部的距離(px),通常設為工具列高度 |
| radius | Number | 4 | 圓角(px) |
| lastRowAlign | String | 'left' | 最後一行對齊:'left' / 'center' / 'justify' |
| ratioFallback | Number | 0.75 | item 沒有 ratio 時的預設值 |
| scrollEl | Object | null | 捲動容器元素,null = 使用 window |
| loadMoreThreshold | Number | 400 | 距底部多少 px 時觸發 load-more |
| active | Boolean | true | 是否為活躍層(堆疊保活),false 時使用 visibility:hidden 保留佈局 |
| keyboardNav | Boolean | false | 啟用鍵盤導航 |
| navMode | String | 'spatial' | 鍵盤導航模式:'spatial' / 'linear' |
Slots
<!-- 項目 slot -->
<template #default="{ item, index, focused, row, pos }">
<!-- row: 所在行索引,pos: 行內位置索引 -->
</template>
<!-- 群組標題 slot(grouped 模式) -->
<template #group-header="{ label, groupKey, items }">
<!-- label: groupLabel 函式的回傳值 -->
<!-- groupKey: 原始 group key -->
<!-- items: 該群組的 item 陣列 -->
</template>Events
| 事件 | 參數 | 說明 |
|---|---|---|
| item-click | (item, index) | 點擊項目 |
| group-click | (key, items) | 點擊群組標題 |
| load-more | — | 捲動到底部附近 |
| navigate | { index, item } | 鍵盤移動焦點 |
| confirm | { index, item } | 鍵盤 Enter 確認 |
Expose
const gridRef = ref(null)
gridRef.value.scrollTo(index, { position: 'start', behavior: 'smooth' })
gridRef.value.findIndexAtCenter(scrollTop, viewportHeight)
gridRef.value.totalHeight // 總高度(computed ref)
gridRef.value.focusedIndex // 目前焦點 index(ref)
gridRef.value.focus(index) // 程式設定焦點
gridRef.value.refresh() // 手動同步寬度與捲動位置(用於堆疊保活場景)堆疊保活(Stack Keep-Alive)
多層頁面場景下,使用 active prop 隱藏非活躍層:
<div v-for="layer in stack" :key="layer.id">
<JustifiedGrid
:active="layer.id === activeId"
:items="layer.items"
/>
</div>使用 visibility:hidden + content-visibility:hidden 保留佈局空間,切回時自動同步寬度與捲動位置。
性能優化
- 增量計算:追加新 items 時只重新計算變動部分,相比全量重算快 90%(2-5ms vs 20-50ms)
- ResizeObserver 保護:
visibility:hidden保留寬度,避免display:none導致的佈局損壞
TimelineScrubber 時間軸捲動條
固定在右側的捲動條,顯示刻度標籤,支援拖曳快速捲動與預覽圖。
基本用法
<template>
<!-- 需要一個 fixed 容器 -->
<div style="position:fixed;right:0;top:0;bottom:0;width:32px;">
<TimelineScrubber
:scroll-el="docEl"
:total-items="items.length"
:index-at-center="gridRef?.findIndexAtCenter"
:offset-top="56"
:offset-bottom="48"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const docEl = ref(null)
onMounted(() => { docEl.value = document.documentElement })
</script>刻度標籤設定
<!-- 預設:顯示 item 索引數字 -->
<TimelineScrubber :total-items="items.length" />
<!-- 自訂標籤:依索引回傳文字 -->
<TimelineScrubber
:total-items="items.length"
:label-at="(index) => items[index]?.year ?? ''"
/>
<!-- 日期範例 -->
<TimelineScrubber
:total-items="items.length"
:label-at="(index) => items[index]?.date?.slice(0, 7)"
/>labelAt 函式接收 item 的索引,回傳要顯示的字串。刻度數量會依捲動條高度自動計算,確保標籤不重疊。
預覽圖
<TimelineScrubber
:total-items="items.length"
:preview-src="(index) => items[index]?.thumbnailUrl"
/>拖曳時在捲動條左側顯示預覽圖(120×80px)。
Props
| Prop | 型別 | 預設 | 說明 |
|---|---|---|---|
| scrollEl | Object | null | 要控制的捲動容器,傳 document.documentElement 控制整頁。支援動態切換(多層 stack 場景) |
| totalItems | Number | 0 | 總項目數,少於 20 時不顯示刻度 |
| offsetTop | Number | 0 | 頂部偏移(px),通常設為上方工具列高度 |
| offsetBottom | Number | 0 | 底部偏移(px),通常設為下方工具列高度 |
| dotSize | Number | 14 | 拖曳圓點直徑(px) |
| thumbPadTop | Number | 0 | 圓點活動範圍頂部額外留白(px) |
| thumbPadBottom | Number | 0 | 圓點活動範圍底部額外留白(px) |
| labelAt | Function | null | (index) => string,刻度標籤文字 |
| sortKey | String | '' | 影響刻度標籤樣式:'' 或 'name' 顯示大字首字,其他顯示完整文字 |
| indexAtCenter | Function | null | (scrollTop, viewportH) => index,傳入 grid 的 findIndexAtCenter |
| previewSrc | Function | null | (index) => url,拖曳時的預覽圖 URL |
| contained | Boolean | false | 保留,未來用於容器內捲動模式 |
Events
| 事件 | 說明 |
|---|---|
| drag-start | 開始拖曳 |
| drag-end | 結束拖曳 |
Expose
const scrubberRef = ref(null)
scrubberRef.value.isDragging // 是否正在拖曳(ref)樣式客製化
CSS 變數(需提供)
TimelineScrubber 依賴以下 CSS 變數,若未使用 DaisyUI 需手動提供:
:root {
--color-primary: #3b82f6; /* 圓點顏色 */
--color-base-100: #ffffff; /* 毛玻璃背景色 */
--color-base-300: #d1d5db; /* 拖曳氣泡背景色 */
--color-base-content: #1f2937; /* 刻度文字、track 顏色 */
}或在父元素上設定:
<div :style="{
'--color-primary': '#0087af',
'--color-base-100': '#ffffff',
'--color-base-300': '#d1d5db',
'--color-base-content': '#1f2937',
}">
<TimelineScrubber ... />
</div>MasonryGrid / JustifiedGrid 樣式
元件使用 scoped CSS,外部無法直接覆蓋內部樣式。建議透過 slot 自訂項目外觀:
<template #default="{ item, focused }">
<div :style="{
width: '100%',
height: '100%',
outline: focused ? '3px solid #0087af' : 'none',
outlineOffset: '-3px',
}">
<img :src="item.src" style="width:100%;height:100%;object-fit:cover" />
</div>
</template>焦點樣式建議用 outline 搭配負的 outlineOffset,避免影響佈局。
TimelineScrubber 可覆蓋的 CSS class
| Class | 說明 |
|---|---|
| .scrubber | 捲動條根元素 |
| .scrubber.active | 拖曳中狀態 |
| .track | 中間的軌道線 |
| .tick | 刻度容器 |
| .tick-line | 刻度短線 |
| .tick-label | 刻度文字 |
| .tick-first-char | sortKey='' 時的大字首字 |
| .thumb-dot | 拖曳圓點 |
| .thumb-label | hover 時顯示的進度文字 |
| .drag-bubble | 拖曳時的預覽氣泡 |
| .preview-img | 預覽圖片 |
| .preview-label | 預覽氣泡的文字 |
由於使用 scoped,外部覆蓋需用 :deep():
<style scoped>
:deep(.thumb-dot) {
background: #ff6b6b;
width: 18px;
height: 18px;
}
:deep(.track) {
width: 4px;
background: rgba(0,0,0,0.15);
}
</style>完整範例
<script setup>
import { ref, onMounted } from 'vue'
import { MasonryGrid, TimelineScrubber } from '@vangro/virtual-masonry'
import '@vangro/virtual-masonry/style.css'
const TOOLBAR_H = 56
const items = ref([...])
const gridRef = ref(null)
const docEl = ref(null)
onMounted(() => { docEl.value = document.documentElement })
</script>
<template>
<div style="padding-top:56px;padding-right:32px;">
<MasonryGrid
ref="gridRef"
:items="items"
:item-width="200"
:gap="8"
:radius="6"
:keyboard-nav="true"
nav-mode="spatial"
@item-click="(item, i) => console.log(item, i)"
@navigate="({ item }) => console.log('focus', item)"
@confirm="({ item }) => openDetail(item)"
>
<template #default="{ item, focused }">
<img
:src="item.src"
style="width:100%;height:100%;object-fit:cover"
:style="{ outline: focused ? '2px solid #0087af' : 'none' }"
/>
</template>
</MasonryGrid>
</div>
<div style="position:fixed;right:0;top:0;bottom:0;width:32px;
--color-primary:#0087af;--color-base-100:#fff;
--color-base-300:#d1d5db;--color-base-content:#1f2937;">
<TimelineScrubber
:scroll-el="docEl"
:total-items="items.length"
:offset-top="TOOLBAR_H"
:label-at="(i) => items[i]?.year"
:index-at-center="gridRef?.findIndexAtCenter"
:preview-src="(i) => items[i]?.thumb"
/>
</div>
</template>vLazySrc 指令
懶加載圖片指令,進入視窗才載入:
import { vLazySrc } from '@vangro/virtual-masonry'
app.directive('lazy-src', vLazySrc)<img v-lazy-src="item.src" />Changelog
1.1.0
MasonryGrid:margin+rowGap改為單一gap,佈局計算正確MasonryGrid/JustifiedGrid:加入navModeprop(spatial/linear)JustifiedGrid:修正切換grouped時containerEl重建導致containerWidth歸零的問題JustifiedGrid:修正keyboardNav動態切換不生效的問題TimelineScrubber:修正 thumb 上下超出工具列範圍的問題TimelineScrubber:修正拖曳放開後 thumb 回到頂部的問題(scroll listener 重複綁定)TimelineScrubber:加入dotSize、thumbPadTop、thumbPadBottomprops- 所有 CSS 變數加入 fallback 值,非 DaisyUI 環境可正常顯示
