博客 / 詳情

返回

基於 CSS Grid 的簡易拖拉拽 Vue3 組件,從代碼到NPM發佈(1)- 拖拉拽交互

基於特定的應用場景,需要在頁面中以網格的方式,實現目標組件在網格中可以進行拖拉拽、修改大小等交互。本章開始分享如何一步步從代碼設計,最後到如何在 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, '&nbsp; ')"></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源碼

示例地址

文檔

user avatar guizimo 頭像 esunr 頭像 buxia97 頭像 gaoming13 頭像 jasonma1995 頭像 musicfe 頭像 user_ze46ouik 頭像
7 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.