基于特定的应用场景,需要在页面中以网格的方式,实现目标组件在网格中可以进行拖拉拽、修改大小等交互。本章开始分享如何一步步从代码设计,最后到如何在 NPM 上发布。
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~
github源码
示例地址
特别说明一下,此组件是基于 CSS 的 display: grid 的,并非全能型拖拉拽交互,grid 不支持的基本就是不支持的,此组件的目标是达到一些简易的网格布局拖拉拽交互。
效果图
项目结构
项目结构是基于另外一个项目 konva-designer-sample,特别说一下需要关注的部分:
└─ dist - 构建的组件库文件
└─ docs - 构建的在线示例网站
└─ src
└─ demo
│ └─ App.vue - 在线示例页面
└─ lib
└─ components
└─ GridDragResize - 组件目录
└─ GridDragResize.vue - 组件
└─ GridDragResizeItem.vue - 子组件
└─ index.ts - 组件入口
└─ style.less - 组件样式
└─ types.ts - 组件配套类型声明
└─ main.ts - 在线示例代码入口
└─ index.html - 在线示例HTML入口
└─ package.json - 库信息
└─ tsconfig.build.json - 用于构建组件库配套的类型声明文件
└─ vite.config.ts - 构建配置
使用方式
直接先看看组件的使用方式:
src/demo/App.vue
<script setup lang="ts">
import { ref, h, type Ref } from 'vue'
// 组件
import { GridDragResize } from '@/lib/components/GridDragResize'
// 组件配套类型声明
import type { GridDragResizeProps } from '@/lib/components/GridDragResize/types'
// 组件数据结构
const children: Ref<GridDragResizeProps['children']> = ref([
{
dragHandler: '.demo-item>button',
render: () => h('div', { class: "demo-item", style: { background: '#eb9c64' } }, [h('button', 'drag handler')])
},
{
columnStart: 2,
draggable: false,
render: () => h('div', { class: "demo-item", style: { background: '#ff8789' } }, 'disable drag')
},
{
rowStart: 2,
columnStart: 2,
render: () => h('div', { class: "demo-item", style: { background: '#554e4f' } }, '1')
},
{
rowStart: 2,
rowEnd: 4,
columnStart: 4,
columnEnd: 5,
render: () => h('div', { class: "demo-item", style: { background: '#8fbf9f' } }, '2')
},
{
rowStart: 4,
rowEnd: 6,
columnStart: 2,
columnEnd: 4,
render: () => h('div', { class: "demo-item", style: { background: '#346145' } }, '3')
},
{
rowStart: 4,
rowEnd: 5,
columnStart: 1,
columnEnd: 2,
render: () => h('div', { class: "demo-item", style: { background: '#c2baa6' } }, '4')
},
])
</script>
<template>
<div class="page">
<!-- 组件使用 -->
<GridDragResize :columns="4" :rows="5" :gap="10" :row-size="100" :readonly="false" :children="children">
</GridDragResize>
<!-- 组件数据结构 实时状态 -->
<div v-html="JSON.stringify(children, null, 2).replace(/\n/g, '<br>').replace(/\s/g, ' ')"></div>
</div>
</template>
<style lang="less">
// 一些样式初始化
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
<style lang="less">
// 示例样式
.page {
padding: 32px;
}
.demo-item {
padding: 10px;
height: 100%;
}
// 组件样式覆盖
.grid-drag-resize {
background-color: #eee;
.grid-drag-resize__item {
background-color: #ddd;
&--dragging {
box-shadow: 0 0 6px 2px #0000ff;
}
}
}
</style>
上面可以看出,render 是比较关键的地方,该组件使用方式并非 插槽,而是通过数据结构传入的 render 实现每一块的显示的,它可以是 h 可以是一个个 其他组件。
接下来,可以看看定义:
组件 Props 定义
// src/lib/components/GridDragResize/types.ts
import type { VNode } from 'vue'
// 子组件的 Props
export interface GridDragResizeItemProps {
draggable?: boolean
dragHandler?: string // 满足 querySelector 的查询字符串,指向可拖拉拽的元素位置
// css display grid 属性
columnStart?: number
columnEnd?: number
rowStart?: number
rowEnd?: number
//
render?: () => VNode
}
// 组件的 Props
export interface GridDragResizeProps {
dragHandler?: string // 同上,优先级 低于 子组件
readonly?: boolean // 优先级 低于 子组件 的 draggable
//
columns?: number // 列数
rows?: number // 行数
gap?: number // 间隙
columnSize?: number // 列宽,默认是 1fr
rowSize?: number // 行高,默认是 1fr
//
children?: GridDragResizeItemProps[] // 子组件
}
目前为止,定义非常简单。
组件
src/lib/components/GridDragResize/GridDragResize.vue
逻辑说明,请留意代码注释
<script setup lang="ts">
import { ref, computed, provide, type Ref } from 'vue'
import type { GridDragResizeProps, GridDragResizeItemProps } from './types'
import GridDragResizeItem from './GridDragResizeItem.vue'
const props = withDefaults(defineProps<GridDragResizeProps>(), {
children: () => []
});
const style = computed(() => {
return {
'grid-template-columns': Number.isInteger(props.columns) ? `repeat(${props.columns},${Number.isInteger(props.columnSize) ? `${props.columnSize}px` : '1fr'})` : '',
'grid-template-rows': Number.isInteger(props.rows) ? `repeat(${props.rows},${Number.isInteger(props.rowSize) ? `${props.rowSize}px` : '1fr'})` : '',
'grid-gap': Number.isInteger(props.gap) ? `${props.gap}px ${props.gap}px` : ''
}
})
const rootEle: Ref<HTMLElement | undefined> = ref()
// 给子组件穿透转递组件 Props
provide('parentProps', props)
// 组件位置、大小信息
const rootRect = computed(() => {
return rootEle?.value?.getBoundingClientRect() ?? {
height: 0,
width: 0,
x: 0,
y: 0,
bottom: 0,
right: 0
}
})
// 列宽
const columnSize = computed(() => {
return (rootRect.value.width - (props.gap ?? 0) * ((props.columns ?? 1) - 1)) / (props.columns ?? 1)
})
// 行高
const rowSize = computed(() => {
return (rootRect.value.height - (props.gap ?? 0) * ((props.rows ?? 1) - 1)) / (props.rows ?? 1)
})
// 根据鼠标拖动偏移量,计算列/行方向上,移动后最新的位置和大小
function calcStartEnd(opts: { size: number, gap: number, span: number, max: number, offset: number, startBefore: number }) {
let { size, gap, span, max, offset, startBefore } = opts
let offsetStart = Math.round(offset / (size + gap))
let start = startBefore + offsetStart
if (start < 1) {
start = 1
}
if (start + span > max) {
start = max - span + 1
}
return {
start,
end: start + span
}
}
// 当前拖动小组件的数据项
const draggingChild: Ref<GridDragResizeItemProps | undefined> = ref()
// 当前拖动小组件的数据项(初始状态)
const draggingChildBefore: Ref<GridDragResizeItemProps | undefined> = ref()
// 当前拖动小组件的位置、大小信息
const draggingChildRect: Ref<DOMRect | undefined> = ref()
// 拖动开始位置
let dragStartClientX = 0, dragStartClientY = 0;
// 拖动偏移量
let dragOffsetClientX = 0, dragOffsetClientY = 0;
let dragging = false
// 开始拖动
function dragstart(e: MouseEvent) {
if (!props.readonly) {
dragging = true
// 记录 拖动开始位置
dragStartClientX = e.clientX
dragStartClientY = e.clientY
}
}
// 拖动中
function drag(e: MouseEvent) {
if (dragging && draggingChild.value && draggingChildRect.value) {
// 计算 拖动开始位置
dragOffsetClientX = e.clientX - dragStartClientX
dragOffsetClientY = e.clientY - dragStartClientY
// 当前拖动小组件的 grid 大小
let rowSpan = (draggingChild.value.rowEnd ?? draggingChild.value.rowStart ?? 1) - (draggingChild.value.rowStart ?? 1)
let columnSpan = (draggingChild.value.columnEnd ?? draggingChild.value.columnStart ?? 1) - (draggingChild.value.columnStart ?? 1)
// 边界处理
if (rowSpan <= 0) {
rowSpan = 1
}
if (columnSpan <= 0) {
columnSpan = 1
}
// 计算行方向上,移动后最新的位置和大小
let { start: rowStart, end: rowEnd } = calcStartEnd({
size: rowSize.value, gap: (props.gap ?? 0), span: rowSpan, max: props.rows ?? 1, offset: dragOffsetClientY, startBefore: draggingChildBefore.value?.rowStart ?? 1
})
// 计算列方向上,移动后最新的位置和大小
let { start: columnStart, end: columnEnd } = calcStartEnd({
size: columnSize.value, gap: (props.gap ?? 0), span: columnSpan, max: props.columns ?? 1, offset: dragOffsetClientX, startBefore: draggingChildBefore.value?.columnStart ?? 1
})
// 当前拖动小组件的数据项
draggingChild.value.columnStart = columnStart
draggingChild.value.columnEnd = columnEnd
draggingChild.value.rowStart = rowStart
draggingChild.value.rowEnd = rowEnd
}
}
// 拖动结束
function dragend(e: MouseEvent) {
e.stopPropagation()
dragging = false
draggingChild.value = undefined
}
// 超出组件区域,补充结束事件
document.body.addEventListener('mouseup', dragend)
</script>
<template>
<div class="grid-drag-resize" :style="style" @mousedown="dragstart" @mousemove="drag" @mouseup="dragend" ref="rootEle">
<template v-for="(child, idx) of props.children" :key="idx">
<GridDragResizeItem v-bind="child" v-model:column-start="child.columnStart" v-model:column-end="child.columnEnd"
v-model:row-start="child.rowStart" v-model:row-end="child.rowEnd"
@dragging="(rect) => { draggingChild = child; draggingChildBefore = { ...child }; draggingChildRect = rect }"
:style="{ 'zIndex': draggingChild === child ? props.children.length + 1 : idx + 1 }"
:class="{ 'grid-drag-resize__item--dragging': draggingChild === child }">
<component :is="child.render"></component>
</GridDragResizeItem>
</template>
</div>
</template>
子组件
src/lib/components/GridDragResize/GridDragResizeItem.vue
逻辑说明,请留意代码注释
<script setup lang="ts">
import { ref, computed, watchEffect, inject, type Ref } from 'vue'
import type { GridDragResizeProps, GridDragResizeItemProps } from './types'
const parentProps = inject<GridDragResizeProps>('parentProps')
const props = withDefaults(defineProps<GridDragResizeItemProps>(), {
draggable: true
});
const emit = defineEmits(['update:columnStart', 'update:columnEnd', 'update:rowStart', 'update:rowEnd', 'dragging'])
// 数据整理
watchEffect(() => {
if (props.columnStart !== void 0) {
if (props.columnEnd === void 0 || props.columnEnd < props.columnStart) {
emit('update:columnEnd', props.columnStart + 1)
}
} else {
emit('update:columnStart', 1)
}
if (props.rowStart !== void 0) {
if (props.rowEnd === void 0 || props.rowEnd < props.rowStart) {
emit('update:rowEnd', props.rowStart + 1)
}
} else {
emit('update:rowStart', 1)
}
})
// 样式
const style = computed(() => {
return {
'grid-column-start': props.columnStart,
'grid-column-end': props.columnEnd,
'grid-row-start': props.rowStart,
'grid-row-end': props.rowEnd,
}
})
const itemEle: Ref<HTMLElement | undefined> = ref()
const dragHandlerParsed = computed(() => props.dragHandler ?? parentProps?.dragHandler)
const draggableParsed = computed(() => parentProps?.readonly ? false : props.draggable)
// dragHandler 定位、处理、事件绑定
watchEffect(() => {
if (draggableParsed.value && dragHandlerParsed.value && itemEle.value) {
const handlerEle = itemEle.value.querySelector(dragHandlerParsed.value)
if (handlerEle instanceof HTMLElement) {
handlerEle.style.cursor = 'grab'
handlerEle.addEventListener('mousedown', dragstart)
}
}
})
// 拖动开始
function dragstart() {
if (draggableParsed.value) {
// 通知父组件 当前拖动小组件
emit('dragging', itemEle?.value?.getBoundingClientRect() ?? {
height: 0,
width: 0,
x: 0,
y: 0,
bottom: 0,
right: 0
})
}
}
</script>
<template>
<div class="grid-drag-resize__item" :class="{
'grid-drag-resize__item--draggable': draggableParsed,
'grid-drag-resize__item--draggable-full': draggableParsed && dragHandlerParsed === void 0
}" :style="style" @mousedown="() => dragHandlerParsed ? undefined : dragstart()" ref="itemEle">
<slot></slot>
</div>
</template>
样式
.grid-drag-resize {
display: grid;
.grid-drag-resize__item {
&--draggable-full {
cursor: grab;
user-select: none;
}
&--dragging {
opacity: 0.6;
}
}
}
组件入口
// src/lib/components/GridDragResize/index.ts
import GridDragResize from './GridDragResize.vue'
import GridDragResizeItem from './GridDragResizeItem.vue'
import './style.less'
export * from './types'
export { GridDragResize, GridDragResizeItem }
Thanks watching~
下一章,我们说说如何构建在线示例、组件库,及其如何发布到 NPM 上供开源使用!
More Stars please!勾勾手指~
github源码
示例地址