基于Vue3制作一个可以拖拽排列的卡片,支持nuxt3
1. 前情交代和说明
首先感谢dwanda大佬的github项目cardDragger,github地址:
https://github.com/dwanda/dragComponent/tree/master
在使用以上github仓库代码时,发现在使用vue3 setup 语法糖,没找到支持的库,于是乎把代码clone下来重新做了改动。使用方法,参考如上github文档,核心功能没做改动。
效果视频:
1747307682547
改动的地方:
(1) 修改了支持vue3 setup 语法糖。
(2) 修改了原来计算卡片的位置 left, top 坐标的函数,如下:
function computeLeft(num) {// return (num-1) % props.colNum * props.cardOutsideWidth; // 老代码const interval = props.cardOutsideWidth - props.cardInsideWidth;const col = (num - 1) % props.colNum // num 计算是在第几列 是从1开始的const ret = interval + col * (props.cardOutsideWidth + interval)// console.log('computeLeft num=', num, ', left=', ret, ', intv=', interval, ', w1=', props.cardOutsideWidth, ', w2=', props.cardInsideWidth);return ret;
}function computeTop(num) {// return (Math.ceil(num / props.colNum) - 1) * props.cardOutsideHeight; // 老代码const interval = props.cardOutsideHeight - props.cardInsideHeight;const row = Math.floor((num-1) / props.colNum); // num从1开始,计算在第几行const ret = interval + row * (props.cardOutsideHeight + interval);// console.log('computeTop num=', num, ', top=', ret, ', intv=', interval, ', h1=', props.cardOutsideHeight, ', h2=', props.cardInsideHeight);return ret;
}
(3) 封装成一个子组件,直接在项目组引用,并且在父组件窗体大小变化时,动态计算每行卡片的数量(colNum)并动态刷新排版。
2. 以下附上完整的代码:
(1)父组件(NewsList.vue),调用cardDradder.vue的地方
<style>
.topMenuBox {display: flex;height: 40px;align-items: center;.menuTitle {padding: 10px;}.refreshBtn {margin-left: auto;right: 10px;}
}</style><template><div ref="myElementRef"><card-draggerref="myCardDraggerRef":data="pageData.cardList":card-outside-width="pageData.cardOutsideWidth":card-outside-height="pageData.cardOutsideHeight":card-inside-width="pageData.cardInsideWidth":card-inside-height="pageData.cardInsideHeight":col-num="pageData.cardColNum"><template v-slot:header="slotProps"><!--自定义内容--><div class="topMenuBox" ><img class="iconHeader" :src="slotProps.item.headIcon" v-if="slotProps.item.headIcon !== ''" alt="headIcon" height="32" width="32" /><div class="menuTitle" v-if="slotProps.item.name">{{slotProps.item.name}}</div><el-button class="refreshBtn" size="small" :icon="Refresh" round/></div></template></card-dragger>></div>
</template><script setup >import cardDragger from "~/components/comment/cardDragger.vue";
import { Refresh } from '@element-plus/icons-vue';const pageData = ref({cardOutsideWidth: 300,cardOutsideHeight: 200,cardInsideWidth: 280,cardInsideHeight: 180,cardColNum: 2,cardList: [{positionNum: 1,name: "演示卡片1",id: "card1",headIcon: 'https://www.w3school.com.cn/i/photo/tree.png',},{positionNum: 2,name: "演示卡片2",id: "card2",headIcon: '',},{positionNum: 3,name: "演示卡片3",id: "card3",headIcon: '',},{positionNum: 4,name: "演示卡片4",id: "card4",headIcon: 'https://www.w3school.com.cn/i/photo/tree.png',},{positionNum: 5,name: "演示卡片5",id: "card5",headIcon: '',},{positionNum: 6,name: "演示卡片6",id: "card6",headIcon: 'https://www.w3school.com.cn/i/photo/tree.png',},{positionNum: 7,name: "演示卡片7",id: "card7",headIcon: '',},]
})const myElementRef = ref(null);onMounted(() => {calcWindowInfo();window.addEventListener('resize', onWindowResize);
})onUnmounted(() => {window.removeEventListener('resize', onWindowResize);
})const myCardDraggerRef = ref(null);
function calcWindowInfo() {const width = myElementRef.value.offsetWidth;// console.log('onMounted width = ', pageData.value, ' colNum = ', pageData.value.cardColNum);// 计算每排可以容纳多少个卡片const oldColNum = pageData.value.cardColNum;const colNum = width / pageData.value.cardOutsideWidth;if (oldColNum !== colNum) {pageData.value.cardColNum = Math.floor(colNum);}console.log(`onMounted width = ${width}, colNum = ${pageData.value.cardColNum}`);
}function onWindowResize() {console.log('onWindowResize')calcWindowInfo();
}</script>
(2)子组件(cardDragger.vue)
<template><div:style="{position:'relative',height:computeTop(data.length)+cardOutsideHeight+'px',width:cardOutsideWidth*colNum+'px'}"><divclass="d_cardBorderBox"v-for="item of data":key="item.id":id="item.id":style="{ width:cardOutsideWidth+'px', height:cardOutsideHeight+'px'}"><divclass="d_cardInsideBox":style="{ width:cardInsideWidth+'px', height:cardInsideHeight+'px'}"><div @mousedown="touchStart($event,item.id)" class="d_topWrapBox"><slot name="header" v-bind:item="item"><div class="d_topMenuBox" ><div class="d_menuTitle" v-if="item.name">{{item.name}}</div><div class="d_menuTitle" v-else> 默认标题 </div></div></slot></div><component :is="item.componentData" :itemData="item" v-if="item.componentData"></component><slot name="content" v-bind:item="item" v-else><div class="d_emptyContent">暂无内容</div></slot></div></div></div>
</template><script setup>import { ref, nextTick } from 'vue'const props = defineProps({data: {type:Array,default: function () {return []}},colNum: {type:Number,default:2},cardOutsideWidth: {type:Number,default:590},cardOutsideHeight: {type:Number,default:380},cardInsideWidth: {type:Number,default:560},cardInsideHeight: {type:Number,default:350}
});
const mousedownTimer = ref(null);watch(() => [props.colNum, props.data],() => {console.log('watch colNum = ', props.colNum, ', data = ', props.data);addCardStyle(); // 执行刷新操作},{ immediate: true }
);function computeLeft(num) {// return (num-1) % props.colNum * props.cardOutsideWidth; // 老代码const interval = props.cardOutsideWidth - props.cardInsideWidth;const col = (num - 1) % props.colNum // num 计算是在第几列 是从1开始的const ret = interval + col * (props.cardOutsideWidth + interval)// console.log('computeLeft num=', num, ', left=', ret, ', intv=', interval, ', w1=', props.cardOutsideWidth, ', w2=', props.cardInsideWidth);return ret;
}function computeTop(num) {// return (Math.ceil(num / props.colNum) - 1) * props.cardOutsideHeight; // 老代码const interval = props.cardOutsideHeight - props.cardInsideHeight;const row = Math.floor((num-1) / props.colNum); // num从1开始,计算在第几行const ret = interval + row * (props.cardOutsideHeight + interval);// console.log('computeTop num=', num, ', top=', ret, ', intv=', interval, ', h1=', props.cardOutsideHeight, ', h2=', props.cardInsideHeight);return ret;
}function addCardStyle(){nextTick(()=>{props.data.forEach(item=>{document.querySelector('#'+item.id).style.top = computeTop(item.positionNum)+'px'document.querySelector('#'+item.id).style.left = computeLeft(item.positionNum)+'px'})})
}const emits = defineEmits(['startDrag', 'swicthPosition', 'swicthPosition']);function touchStart(event, selectId) {if (mousedownTimer.value) {return false;}//若触发了点击事件,则返回一个暴露出一个方法//this.$emit('startDrag',event,selectId)emits('startDrag', event, selectId);let DectetTimer = null;let originTop = document.body.scrollTop === 0 ? document.documentElement.scrollTop : document.body.scrollTop;let scrolTop = originTop;//记录鼠标移动的距离let moveTop = 0;let moveLeft = 0;//起始组件位置let OriginObjPosition = {left: 0,top: 0,originNum: -1};// 起始鼠标信息let OriginMousePosition = {x: 0,y: 0};// 记录交换位置的号码let OldPositon = null;let NewPositon = null;// 选中的卡片的dom和数据let selectDom = document.getElementById(selectId);let selectMenuData = props.data.find(item => {return item.id === selectId;});OriginMousePosition.x = event.screenX;OriginMousePosition.y = event.screenY;selectDom.classList.add('d_moveBox')moveLeft = OriginObjPosition.left = parseInt(selectDom.style.left.slice(0, selectDom.style.left.length - 2));moveTop = OriginObjPosition.top = parseInt(selectDom.style.top.slice(0, selectDom.style.top.length - 2));document.addEventListener("mousemove", mouseMoveListener);document.addEventListener("mouseup", mouseUpListener);document.addEventListener("scroll", mouseScroll);function mouseMoveListener(event) {// console.log('mouseMoveListener event => ', event);moveTop = OriginObjPosition.top + ( event.screenY - OriginMousePosition.y );moveLeft = OriginObjPosition.left + ( event.screenX - OriginMousePosition.x );document.querySelector(".d_moveBox").style.left = moveLeft + "px";document.querySelector(".d_moveBox").style.top = moveTop + (scrolTop - originTop) + "px"; //这里要加上滚动的高度if (!DectetTimer) {DectetTimer = setTimeout(()=>{cardDetect(moveTop + (scrolTop - originTop),moveLeft)DectetTimer = null;}, 200);}}function mouseScroll(event) {// console.log('mouseScroll event => ', event);scrolTop =document.body.scrollTop === 0? document.documentElement.scrollTop: document.body.scrollTop;document.querySelector(".d_moveBox").style.top = moveTop + scrolTop - originTop + "px";}function cardDetect(moveItemTop, moveItemLeft){//计算当前移动卡片,可以覆盖的号码位置let newWidthNum = Math.round((moveItemLeft/ props.cardOutsideWidth))+1let newHeightNum = Math.round((moveItemTop/ props.cardOutsideHeight))if(newHeightNum>(Math.ceil(props.data.length / props.colNum) - 1)||newHeightNum < 0||newWidthNum <= 0||newWidthNum > props.colNum){return false}const newPositionNum = (newWidthNum) + newHeightNum * props.colNumif(newPositionNum!==selectMenuData.positionNum){let newItem = props.data.find(item=>{return item.positionNum === newPositionNum})if( newItem ){swicthPosition(newItem, selectMenuData);}}}function swicthPosition(newItem, originItem) {OldPositon = originItem.positionNum;NewPositon = newItem.positionNum;//that.$emit('swicthPosition',OldPositon,NewPositon,originItem)emits('swicthPosition', OldPositon,NewPositon,originItem);//位置号码从小移动到大if (NewPositon > OldPositon) {let changeArray = [];//从小移动到大,那小的号码就会空出来,其余卡片应往前移动一位//找出两个号码中间对应的卡片数据for (let i = OldPositon + 1; i <= NewPositon; i++) {let pushData = props.data.find(item => {return item.positionNum === i;});changeArray.push(pushData);}for (let item of changeArray) {//vue的$set使更改数据的同时实时刷新样式// that.$set(item, "positionNum", item.positionNum - 1);item['positionNum'] = item.positionNum - 1;document.querySelector('#'+item.id).style.top = computeTop(item.positionNum)+'px'document.querySelector('#'+item.id).style.left = computeLeft(item.positionNum)+'px'}// that.$set(originItem, "positionNum", NewPositon);originItem['positionNum'] = NewPositon;}//位置号码从大移动到小if (NewPositon < OldPositon) {let changeArray = [];//从大移动到小,那大的号码就会空出来,其余卡片应往后移动一位//找出两个号码中间对应的卡片数据for (let i = OldPositon - 1; i >= NewPositon; i--) {let pushData = props.data.find(item => {return item.positionNum === i;});changeArray.push(pushData);}for (let item of changeArray) {// that.$set(item, "positionNum", item.positionNum + 1);item['positionNum'] = item.positionNum + 1;document.querySelector('#'+item.id).style.top = computeTop(item.positionNum)+'px'document.querySelector('#'+item.id).style.left = computeLeft(item.positionNum)+'px'}// that.$set(originItem, "positionNum", NewPositon);originItem['positionNum'] = NewPositon;}}function mouseUpListener() {// console.log('mouseUpListener ...');//取消位于交换队列的检测事件、对位置进行最后一次检测clearTimeout(DectetTimer)DectetTimer = nullcardDetect(moveTop + (scrolTop - originTop),moveLeft)document.querySelector(".d_moveBox").classList.add('d_transition');document.querySelector(".d_moveBox").style.top = computeTop(selectMenuData.positionNum) + "px";document.querySelector(".d_moveBox").style.left = computeLeft(selectMenuData.positionNum) + "px";// that.$emit('finishDrag',OldPositon,NewPositon,selectMenuData)emits('finishDrag', OldPositon,NewPositon,selectMenuData);mousedownTimer.value = setTimeout(() => {/*用0.3秒来过渡mousedownTimer在一开始对点击事件进行了判断,若还在过渡则不能进行下一次点击*/document.querySelector(".d_moveBox").classList.remove('d_transition')document.querySelector(".d_moveBox").classList.remove('d_moveBox')clearTimeout(mousedownTimer.value);mousedownTimer.value = null;}, 300);document.removeEventListener("mousemove", mouseMoveListener);document.removeEventListener("mouseup", mouseUpListener);document.removeEventListener("scroll", mouseScroll);}
}</script>
<style scoped>
.d_cardBorderBox {user-select: none;position: absolute;transition: all 0.3s;display: flex;justify-content: center;align-items: center;
}
.d_cardInsideBox {border-radius: 5px;box-shadow: 0 0 5px #cacaca;display: flex;flex-direction: column;overflow: hidden;
}
.d_menuTitle {pointer-events: none;
}
.d_topMenuBox {height: 50px;display: flex;align-items: center;font-size: 14px;color: #838383;background-color: white;padding: 0px 15px;
}
.d_moveBox {top:20px;left: 20px;z-index: 300;transition: none;
}
.d_topWrapBox {cursor: move;border-bottom: 1px solid #e0e0e0;
}
.d_emptyContent{width: 100%;height: 100%;font-size: 16px;color: #979797;display: flex;justify-content: center;align-items: center;
}
.d_transition{transition: all 0.3s;
}
</style>