feat(theme): add <CardMasonry> support (#379)

This commit is contained in:
pengzhanbo 2024-12-15 00:27:12 +08:00 committed by GitHub
parent e5d732bc79
commit a93d53c77a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 440 additions and 8 deletions

View File

@ -133,6 +133,7 @@ export const themeGuide = defineNoteConfig({
'链接卡片',
'图片卡片',
'卡片容器',
'瀑布流容器',
'首页布局容器',
'repoCard',
'npmBadge',

View File

@ -0,0 +1,266 @@
---
title: 瀑布流容器
icon: ri:layout-masonry-line
createTime: 2024/12/14 17:17:06
permalink: /guide/components/card-masonry/
badge:
text: v1.0.0-rc.121 +
---
## 概述
瀑布流容器是一个 通用的容器组件,你可以把任何内容放到 `<CardMasonry>` 里面,容器会自动计算每一个 **项** 的高度,
然后将它们按照瀑布流的方式进行排列。
::: details 什么是项
项 表示的是一个单独的内容,可以是图片、文字、视频等等。
- 从 markdown 的语法而言,独占一行的可以被认为是一个项。(该行的前后应该有空行)
- 从 html 的结构而言,容器下的每一个子元素都会被认为是一个项。
:::
```md
<CardMasonry>
<img src="..." alt="...">
<!-- 更多内容 -->
</CardMasonry>
```
## Props
| 名称 | 类型 | 默认值 | 说明 |
| :--- | :----------------------------------------------- | :----- | :------------- |
| cols | `number \| Record<'sm' \| 'md' \| 'lg', number>` | `3` | 列数 |
| gap | `number` | `16` | 列之间的间距 |
组件默认会根据屏幕宽度自动调整列数。在空间足够时,默认显示三列,小屏幕下显示双列。
你可以通过 `cols` 配置列数。当传入 `number` 时,所有尺寸均显示为 `number` 个卡片。
传入 `{ sm: number, md: number, lg: number }` 时,根据屏幕宽度自动调整列数。
- `sm` : `< 640px`
- `md` : `>= 640px < 960px`
- `lg` : `>= 960px`
## Markdown 语法支持
在 markdown 中,可以使用 `::: card-masonry` 容器代替 `<CardMasonry>`
``` md
::: card-masonry cols="3" gap="16" <!-- [!code hl]-->
![](/images/1.png)
<!-- 更多内容 -->
::: <!-- [!code hl]-->
```
## 示例
### 图片瀑布流
瀑布流特别适合用于展示图片,你可以直接在将 `![](image_url)` 写到 `::: card-masonry` 中。
**输入:**
``` md
::: card-masonry
![](image_url)
![](image_url)
![](image_url)
![](image_url)
![](image_url)
![](image_url)
:::
```
**输出:**
::: card-masonry
![a](https://images.unsplash.com/photo-1719937051124-91c677bc58fc?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDF8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwxfHx8ZW58MHx8fHx8)
![b](https://plus.unsplash.com/premium_photo-1731329153355-1015daf2cb92?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwyfHx8ZW58MHx8fHx8)
![c](https://images.unsplash.com/photo-1731323036230-fb37b4d9ed71?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwzfHx8ZW58MHx8fHx8)
![a](https://images.unsplash.com/photo-1730630906214-1256b57d65b7?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw0fHx8ZW58MHx8fHx8)
![b](https://plus.unsplash.com/premium_photo-1733864822156-f3cf26187fd9?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw2fHx8ZW58MHx8fHx8)
![a](https://images.unsplash.com/photo-1731756748993-85e1513dfc76?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw3fHx8ZW58MHx8fHx8)
![b](https://images.unsplash.com/photo-1733705879328-a18f2a025c67?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw4fHx8ZW58MHx8fHx)
:::
### 卡片瀑布流
瀑布流也适合用于展示卡片,你可以直接在将 `::: card` 写到 `::: card-masonry` 中。
**输入:**
``` md :collapsed-lines
:::: card-masonry
::: card title="卡片1"
卡片内容
:::
::: card title="卡片2"
卡片内容
卡片内容
:::
::: card title="卡片3"
卡片内容
:::
::: card title="卡片4"
卡片内容
:::
::: card title="卡片5"
卡片内容
卡片内容
:::
::: card title="卡片6"
卡片内容
:::
::::
```
**输出:**
:::: card-masonry
::: card title="卡片1"
卡片内容
:::
::: card title="卡片2"
卡片内容
卡片内容
:::
::: card title="卡片3"
卡片内容
:::
::: card title="卡片4"
卡片内容
:::
::: card title="卡片5"
卡片内容
卡片内容
:::
::: card title="卡片6"
卡片内容
:::
::::
### 代码块瀑布流
**输入:**
````md :collapsed-lines
:::card-masonry
```ts
const a = 1
```
```json
{
"name": "John"
}
```
```css
p {
color: red;
}
```
```html
<html>
<body>
<h1>Hello world</h1>
</body>
</html>
```
```ts
const a = 12
const b = 1
```
```rust
fn main() {
println!("Hello, world!");
}
```
:::
````
**输出:**
:::card-masonry
```ts
const a = 1
```
```json
{
"name": "John"
}
```
```css
p {
color: red;
}
```
```html
<html>
<body>
<h1>Hello world</h1>
</body>
</html>
```
```ts
const a = 12
const b = 1
```
```rust
fn main() {
println!("Hello, world!");
}
```
:::

View File

@ -7,6 +7,11 @@ interface CardAttrs {
icon?: string
}
interface CardMasonryAttrs {
cols?: number
gap?: number
}
export function cardPlugin(md: Markdown) {
/**
* ::: card title="xxx" icon="xxx"
@ -36,4 +41,28 @@ export function cardPlugin(md: Markdown) {
before: () => '<VPCardGrid>',
after: () => '</VPCardGrid>',
})
/**
* ::: card-masonry cols="2" gap="10"
* ::: card
* xxx
* :::
* ::: card
* xxx
* :::
* ::::
*/
createContainerPlugin(md, 'card-masonry', {
before: (info) => {
const { attrs } = resolveAttrs<CardMasonryAttrs>(info)
let cols!: string | number
if (attrs.cols) {
cols = attrs.cols[0] === '{' ? attrs.cols : Number.parseInt(`${attrs.cols}`)
}
const gap = Number.parseInt(`${attrs.gap}`)
return `<VPCardMasonry${cols ? ` :cols="${cols}"` : ''}${gap >= 0 ? ` :gap="${gap}"` : ''}>`
},
after: () => '</VPCardMasonry>',
})
}

View File

@ -139,7 +139,7 @@ watch(
</template>
</VPDocFooter>
<template v-if="hasComments">
<CommentService :darkmode="isDark" vp-comment />
<DocComment :darkmode="isDark" vp-comment />
</template>
<slot name="doc-after" />
</div>

View File

@ -37,6 +37,7 @@ const icon = computed<string | { svg: string } | undefined>(() => {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
padding: 16px 20px;
margin: 16px 0;
border: solid 1px var(--vp-c-divider);

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import type { VNode } from 'vue'
import { useDebounceFn, useMediaQuery, useResizeObserver } from '@vueuse/core'
import { cloneVNode, computed, h, markRaw, nextTick, onMounted, shallowRef, watch } from 'vue'
const props = withDefaults(defineProps<{
cols?: number | { sm?: number, md?: number, lg?: number }
gap?: number
}>(), {
cols: () => ({ sm: 2, md: 2, lg: 3 }),
gap: 16,
})
const slots = defineSlots<{ default: () => VNode[] | null }>()
const isMd = useMediaQuery('(min-width: 640px)')
const isLg = useMediaQuery('(min-width: 960px)')
const rawList = computed(() => {
const res = slots.default?.()
return ((Array.isArray(res) ? res : [res]) as VNode[]).map((item, index) =>
markRaw(h('div', { className: `masonry-id-${index}` }, cloneVNode(item))),
)
})
const columnsLength = computed<number>(() => {
let length = 1
if (typeof props.cols === 'number') {
length = props.cols
}
else if (typeof props.cols === 'object') {
if (isLg.value)
length = props.cols.lg || 3
else if (isMd.value)
length = props.cols.md || 2
else
length = props.cols.sm || 2
}
length = rawList.value.length < length ? rawList.value.length : length
return Number(length)
})
const columnsList = shallowRef<VNode[][]>([])
const masonry = shallowRef<HTMLDivElement>()
async function drawColumns() {
if (rawList.value.length <= 1) {
columnsList.value = []
return
}
await nextTick()
if (!masonry.value)
return
const columns: VNode[][] = Array.from({ length: columnsLength.value }, () => [])
const heights = Array.from({ length: columnsLength.value }, () => 0)
for (let i = 0; i < rawList.value.length; i++) {
const item = rawList.value[i]
const el = masonry.value.querySelector(`.masonry-id-${i}`) as HTMLElement
const height = el?.offsetHeight ?? 0
const index = heights.indexOf(Math.min(...heights))
columns[index].push(item)
heights[index] += height + props.gap
}
columnsList.value = columns
}
onMounted(() => {
drawColumns()
watch([rawList, columnsLength], drawColumns, { flush: 'post' })
useResizeObserver(masonry, useDebounceFn(drawColumns))
})
</script>
<template>
<div ref="masonry" class="vp-card-masonry" :class="[`cols-${columnsLength}`]" :style="{ gap: `${props.gap}px` }">
<div v-if="rawList.length <= 1" class="card-masonry-item" :style="{ gap: `${props.gap}px` }">
<slot />
</div>
<template v-else>
<ClientOnly>
<div v-for="(column, index) in columnsList" :key="index" class="card-masonry-item" :style="{ gap: `${props.gap}px` }">
<component :is="item" v-for="item in column" :key="item.props?.className" />
</div>
</ClientOnly>
</template>
</div>
</template>
<style>
.vp-card-masonry {
display: flex;
align-items: flex-start;
height: max-content;
margin: 16px 0;
}
.vp-card-masonry > .card-masonry-item {
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
}
.vp-card-masonry > .card-masonry-item > [class^="masonry-id-"] {
width: 100%;
}
.vp-card-masonry > .card-masonry-item > [class^="masonry-id-"] > * {
margin: 0 !important;
}
.card-masonry-item > [class^="masonry-id-"] > img:only-child,
.card-masonry-item > [class^="masonry-id-"] > p > img:only-child,
.card-masonry-item > [class^="masonry-id-"] > p > a:only-child > img:only-child {
display: block;
border-radius: 8px;
box-shadow: var(--vp-shadow-2);
}
</style>

View File

@ -114,14 +114,14 @@ const styles = computed(() => {
transform: translateY(calc(100% - 60px));
}
:where(.vp-card-grid.cols-3) .image-info {
:where(.vp-card-grid.cols-3, .vp-card-masonry.cols-3) .image-info {
padding: 8px 8px 0;
font-size: 12px;
transform: translateY(calc(100% - 36px));
}
@media (max-width: 767px) {
:where(.vp-card-grid.cols-2) .image-info {
:where(.vp-card-grid.cols-2, .vp-card-masonry.cols-2) .image-info {
padding: 8px 8px 0;
font-size: 12px;
transform: translateY(calc(100% - 36px));
@ -142,7 +142,7 @@ const styles = computed(() => {
white-space: nowrap;
}
:where(.vp-card-grid.cols-3) .image-info .title {
:where(.vp-card-grid.cols-3, .vp-card-masonry.cols-3) .image-info .title {
min-height: 20px;
margin: 0 0 8px;
font-size: 14px;
@ -150,7 +150,7 @@ const styles = computed(() => {
}
@media (max-width: 767px) {
:where(.vp-card-grid.cols-2) .image-info .title {
:where(.vp-card-grid.cols-2, .vp-card-masonry.cols-2) .image-info .title {
min-height: 20px;
margin: 0 0 8px;
font-size: 14px;
@ -169,12 +169,12 @@ const styles = computed(() => {
color: var(--vp-c-white);
}
:where(.vp-card-grid.cols-3) .image-info p {
:where(.vp-card-grid.cols-3, .vp-card-masonry.cols-3) .image-info p {
line-height: 20px;
}
@media (max-width: 767px) {
:where(.vp-card-grid.cols-2) .image-info p {
:where(.vp-card-grid.cols-2, .vp-card-masonry.cols-2) .image-info p {
line-height: 20px;
}
}

View File

@ -33,6 +33,7 @@ defineProps<{
display: flex;
gap: 8px;
align-items: flex-start;
width: 100%;
padding: 16px 20px;
margin: 16px 0;
background-color: transparent;

View File

@ -1,11 +1,13 @@
import type { App } from 'vue'
import VPBadge from '@theme/global/VPBadge.vue'
import VPCard from '@theme/global/VPCard.vue'
import VPCardGrid from '@theme/global/VPCardGrid.vue'
import VPCardMasonry from '@theme/global/VPCardMasonry.vue'
import VPImageCard from '@theme/global/VPImageCard.vue'
import VPLinkCard from '@theme/global/VPLinkCard.vue'
import VPHomeBox from '@theme/Home/VPHomeBox.vue'
import VPIcon from '@theme/VPIcon.vue'
import { hasGlobalComponent } from '@vuepress/helper/client'
import { type App, h, resolveComponent } from 'vue'
export function globalComponents(app: App) {
app.component('Badge', VPBadge)
@ -23,9 +25,19 @@ export function globalComponents(app: App) {
app.component('VPImageCard', VPImageCard)
app.component('ImageCard', VPImageCard)
app.component('VPCardMasonry', VPCardMasonry)
app.component('CardMasonry', VPCardMasonry)
app.component('Icon', VPIcon)
app.component('VPIcon', VPIcon)
app.component('HomeBox', VPHomeBox)
app.component('VPHomeBox', VPHomeBox)
app.component('DocComment', (props) => {
if (hasGlobalComponent('CommentService')) {
return h(resolveComponent('CommentService'), props)
}
return null
})
}