基于 CSS Grid 的简易拖拉拽 Vue3 组件,从代码到NPM发布(1)- 拖拉拽交互

news2024/12/23 9:30:36

基于特定的应用场景,需要在页面中以网格的方式,实现目标组件在网格中可以进行拖拉拽、修改大小等交互。本章开始分享如何一步步从代码设计,最后到如何在 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源码

示例地址

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2203006.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

全网最详细k8s搭建部署

目录 Kubernetes的功能&#xff1a; Kubernetes的特点&#xff1a; 1. 安装要求 2. 部署内容 1、系统环境准备 2、所有禁用swap和本地解析 3、仓库配置&#xff0c;所有安装docker 4、所有节点设定docker的资源管理模式为systemd 5、所有阶段复制harbor仓库中的证书并…

一款电子产品图册转换器

​随着科技的不断发展&#xff0c;电子产品已经成为我们生活中不可或缺的一部分。无论是手机、平板电脑还是智能家居&#xff0c;它们都离不开电子图册的支撑。一款优秀的电子产品图册转换器&#xff0c;可以帮助我们轻松实现电子图册的转换&#xff0c;为我们的生活和工作带来…

AlphaFold加冕诺奖,DeepMind CEO获奖感言:最优秀的科学家与AI配合,将完成令人难以置信的工作

继「AI 教父」摘冠 2024 年诺贝尔物理学奖后&#xff0c;AI 再下一城&#xff0c;获得了今年的诺贝尔化学奖。 北京时间 10 月 9 日&#xff0c;瑞典皇家科学院宣布了 2024 年诺贝尔化学奖的归属&#xff0c;一半授予 David Baker&#xff0c; 以表彰其在计算蛋白设计方面的贡…

python实现音频文件mp3/m4a转.wav + windows安装ffmpeg

近期在尝试使用大模型进行音频降噪、人声分离。抱脸上的模型几乎统一输入需求都是.wav&#xff0c;直接贴代码吧 ps:使用这段代码需要提前安装好ffmpeg(安装教程请往下拉) from pydub import AudioSegment# 加载.m4a文件 audio AudioSegment.from_file(r"你文件的路径&…

Pycharm使用CV2

1、windows下已经安装好python3以及opencv2 2、安装并打开pycharm 环境中装好的包就能显示出来了&#xff0c;就可以去调用cv2的接口了

TCP/IP相关

1、关于三次握手、四次挥手和TCP的11种状态&#xff1a; 记住这张图就行了&#xff1a; 2、关于慢启动、拥塞避免、超时重传、快速重传、快速恢复 记住这张图就行了&#xff1a; 一些名词解释&#xff1a; MSS&#xff1a;Maximum Segment Size&#xff0c;最大报文长度 RT…

力扣 1206. 设计跳表

Problem: 1206. 设计跳表 &#x1f469;‍&#x1f3eb; 参考题解 class Skiplist {// 定义跳表的最大层数int level 10;// 定义跳表节点类class Node {int val; // 节点值Node[] ne new Node[level]; // 节点的下一跳节点数组&#xff0c;支持多级索引// 构造函数&#xf…

深度学习之卷积CONV2D

文章目录 1.学习目的2.填充与步幅2.1填充2.2 步幅 3.总结 1.学习目的 卷积听起来简单&#xff0c;事实上不简单&#xff0c;需要多加练习 2.填充与步幅 在前面的例子 图6.2.1中&#xff0c;输入的高度和宽度都为3&#xff0c;卷积核的高度和宽度都为2&#xff0c;生成的输出…

用Python将HTML转换为Excel文件

在数据处理和分析的过程中&#xff0c;经常需要从网页上抓取信息&#xff0c;并将其转换为更易于操作的格式。HTML表格作为一种常见的数据展示方式&#xff0c;在线报告、统计资料等场景中广泛存在&#xff0c;但其结构化程度较低&#xff0c;不利于进一步的数据清洗和分析。将…

微信小程序-APP-软件开发

微信小程序开发&#xff0c;作为当下移动互联网领域的一股强劲势力&#xff0c;正以其便捷性、轻量化及高用户粘性的特点&#xff0c;深刻改变着我们的生活与工作方式。它不仅为企业和个人开发者提供了一个全新的服务入口&#xff0c;更极大地拓宽了商业应用的边界。 在微信小…

2015年国赛高教杯数学建模D题众筹筑屋规划方案设计解题全过程文档及程序

2015年国赛高教杯数学建模 D题 众筹筑屋规划方案设计 众筹筑屋是互联网时代一种新型的房地产形式。现有占地面积为102077.6平方米的众筹筑屋项目&#xff08;详情见附件1&#xff09;。项目推出后&#xff0c;有上万户购房者登记参筹。项目规定参筹者每户只能认购一套住房。  …

游戏录屏必备!五款超实用软件让你轻松记录精彩游戏瞬间

在游戏的世界里&#xff0c;每一个精彩的操作、每一场激烈的对战都值得被记录下来。无论是想要分享给朋友&#xff0c;还是留作自己的游戏回忆&#xff0c;一款优秀的游戏录屏软件都是必不可少的。下面就为大家介绍五款备受好评的游戏录屏软件&#xff0c;让你轻松成为游戏录屏…

【读书笔记·VLSI电路设计方法解密】问题9:什么是SOC发展趋势的推动力

推动SoC趋势的主要力量之一是成本。将更多功能集成到单一芯片中可以减少系统中的芯片数量&#xff0c;从而缩小封装和电路板的成本。这可能会降低整个系统的成本&#xff0c;使产品更具竞争力。在当今的消费电子市场和其他市场中&#xff0c;较低的价格总能带来获得市场份额的优…

激光避障的运行算法!

一、激光传感器的工作原理 激光避障技术利用激光束的直线传播和反射特性&#xff0c;通过发送激光束并接收反射回来的信号&#xff0c;来检测和计算周围障碍物的距离和位置。激光传感器能够生成高精度的距离数据和三维环境信息&#xff0c;为机器人或无人机提供详细的障碍物分…

留学期间如何提高职业竞争力?

留学期间是提高职业竞争力的关键时期&#xff0c;以下是一些具体的建议&#xff0c;帮助留学生在留学期间增强自身的职业竞争力&#xff1a; 一、深化专业知识与技能 1. 专注于课程学习&#xff1a;努力学习专业课程&#xff0c;掌握扎实的专业知识&#xff0c;这是提高职业竞…

qiankun 主项目和子项目都是 vue2,部署在同一台服务器上,nginx 配置

1、主项目配置 1.1 micro.vue 组件 <template><div id"container-sub-app"></div> </template><script> import { loadMicroApp } from qiankun; import actions from /utils/actions.js;export default {name: microApp,mixins: [ac…

颠覆传统!团购新玩法带你零风险狂赚社交红利

你是否曾经被某个看似大胆且充满挑战的商业策略深深吸引&#xff0c;最终却惊喜地发现它在短时间内创造了惊人的价值&#xff1f;今天&#xff0c;我们将一起探索一个别出心裁的商业模式&#xff0c;看看它是如何在短短一个月内实现超过600万的利润奇迹。这不仅仅是一次对商业机…

第十一章:规划过程组 (11.1制定项目管理计划--11.5创建WBS)

11.1 制定项目管理计划 • 项目管理计划可以是概括或详细的&#xff0c;每个组成部分的详细程度取决于具体项目的要求 • 项目管理计划应基准化&#xff0c;即至少应规定项目的范围、时间和成本方面的基准以便据此考核项目执行情况和管理项目绩效。 • 在确定基准之前&#xf…

前端开发攻略---分块加载大数据

一、问题 解决当遇到较大的数据请求&#xff0c;当用户网络较差的时候&#xff0c;需要等很久很久才能拿到服务器的响应结果&#xff0c;如果这个响应结果需要再页面上展示的话&#xff0c;会导致页面长时间白屏的问题 二、实现原理 当发送一个请求时&#xff0c;需要等服务器把…

UM-Net: 重新思考用于息肉分割的ICGNet,结合不确定性建模|文献速递-基于多模态-半监督深度学习的病理学诊断与病灶分割

Title 题目 UM-Net: Rethinking ICGNet for polyp segmentation with uncertainty modeling UM-Net: 重新思考用于息肉分割的ICGNet&#xff0c;结合不确定性建模 01 文献速递介绍 结直肠癌&#xff08;CRC&#xff09;是男性中第三大、女性中第二大常见的恶性肿瘤&#x…