mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
feat(theme): add <CardMasonry> support (#379)
This commit is contained in:
parent
e5d732bc79
commit
a93d53c77a
@ -133,6 +133,7 @@ export const themeGuide = defineNoteConfig({
|
||||
'链接卡片',
|
||||
'图片卡片',
|
||||
'卡片容器',
|
||||
'瀑布流容器',
|
||||
'首页布局容器',
|
||||
'repoCard',
|
||||
'npmBadge',
|
||||
|
||||
266
docs/notes/theme/guide/组件/瀑布流容器.md
Normal file
266
docs/notes/theme/guide/组件/瀑布流容器.md
Normal 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]-->
|
||||
|
||||

|
||||
|
||||
<!-- 更多内容 -->
|
||||
|
||||
::: <!-- [!code hl]-->
|
||||
```
|
||||
|
||||
## 示例
|
||||
|
||||
### 图片瀑布流
|
||||
|
||||
瀑布流特别适合用于展示图片,你可以直接在将 `` 写到 `::: card-masonry` 中。
|
||||
|
||||
**输入:**
|
||||
|
||||
``` md
|
||||
::: card-masonry
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
:::
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
::: card-masonry
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
:::
|
||||
|
||||
### 卡片瀑布流
|
||||
|
||||
瀑布流也适合用于展示卡片,你可以直接在将 `::: 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!");
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
@ -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>',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
122
theme/src/client/components/global/VPCardMasonry.vue
Normal file
122
theme/src/client/components/global/VPCardMasonry.vue
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ defineProps<{
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
margin: 16px 0;
|
||||
background-color: transparent;
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user