429 lines
12 KiB
Vue
429 lines
12 KiB
Vue
<template>
|
||
<div :id="id" ref="container" class="draggable-resizable" :style="containerStyle">
|
||
<div class="header" @mousedown.prevent="onMouseDown" :style="{ cursor: mouseCursor }">
|
||
{{ id }}
|
||
</div>
|
||
|
||
<div
|
||
v-for="dir in directions"
|
||
:key="dir"
|
||
:class="['resize-handle', dir]"
|
||
@mousedown.stop.prevent="onResizeHandleMouseDown(dir, $event)"
|
||
></div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts" name="DraggableResizable">
|
||
import type { PropType } from 'vue';
|
||
import { ref, reactive, defineProps, defineEmits } from 'vue';
|
||
|
||
const props = defineProps({
|
||
id: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
x: {
|
||
type: Number,
|
||
required: true,
|
||
},
|
||
y: {
|
||
type: Number,
|
||
required: true,
|
||
},
|
||
width: {
|
||
type: Number,
|
||
required: true,
|
||
},
|
||
height: {
|
||
type: Number,
|
||
required: true,
|
||
},
|
||
zIndex: {
|
||
type: String,
|
||
default: '1',
|
||
required: true,
|
||
},
|
||
snapDistance: {
|
||
type: Number,
|
||
default: 20,
|
||
},
|
||
onDrag: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
onResize: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
detectCollision: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
detectSnap: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
checkClosestComponent: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
setCurrentComponent: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
handleCollision: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
directions: {
|
||
type: Array as PropType<string[]>,
|
||
required: false,
|
||
default: ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['drag', 'resize']);
|
||
const mouseCursor = ref('grab');
|
||
|
||
const isCollied = ref(false);
|
||
const isSnap = ref(false);
|
||
const startX = ref(0);
|
||
const startY = ref(0);
|
||
const startLeft = ref(0);
|
||
const startTop = ref(0);
|
||
|
||
const container = ref<HTMLElement | null>(null);
|
||
const ghost = ref<HTMLElement | null>(null);
|
||
|
||
const containerStyle = reactive({
|
||
id: `${props.id}`,
|
||
width: `${props.width}px`,
|
||
height: `${props.height}px`,
|
||
top: `${props.y}px`,
|
||
left: `${props.x}px`,
|
||
zIndex: props.zIndex,
|
||
transition: 'none',
|
||
});
|
||
|
||
const updatePosition = (left: number, top: number, snap = false) => {
|
||
isSnap.value = snap;
|
||
if (isSnap.value) {
|
||
containerStyle.transition = 'left 0.08s ease-out, top 0.08s ease-out';
|
||
} else {
|
||
containerStyle.transition = 'none';
|
||
}
|
||
containerStyle.left = `${left}px`;
|
||
containerStyle.top = `${top}px`;
|
||
};
|
||
|
||
const updateGhostPosition = (left: number, top: number) => {
|
||
if (ghost.value) {
|
||
ghost.value.style.left = `${left}px`;
|
||
ghost.value.style.top = `${top}px`;
|
||
}
|
||
};
|
||
|
||
const updateSize = (width: number, height: number) => {
|
||
containerStyle.width = `${width}px`;
|
||
containerStyle.height = `${height}px`;
|
||
};
|
||
|
||
const onMouseDown = (event: MouseEvent) => {
|
||
mouseCursor.value = 'grabbing';
|
||
startX.value = event.clientX;
|
||
startY.value = event.clientY;
|
||
startLeft.value = parseInt(containerStyle.left);
|
||
startTop.value = parseInt(containerStyle.top);
|
||
props.setCurrentComponent(containerStyle);
|
||
containerStyle.zIndex = '3';
|
||
|
||
ghost.value = document.createElement('div');
|
||
ghost.value.style.width = containerStyle.width;
|
||
ghost.value.style.height = containerStyle.height;
|
||
ghost.value.style.backgroundColor = '#E33B54';
|
||
ghost.value.style.zIndex = '1';
|
||
ghost.value.style.position = 'absolute';
|
||
ghost.value.style.left = containerStyle.left;
|
||
ghost.value.style.top = containerStyle.top;
|
||
const fatherEle = document.getElementById(props.id);
|
||
fatherEle?.appendChild(ghost.value);
|
||
|
||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||
//* 使用 requestAnimationFrame 来优化性能
|
||
requestAnimationFrame(() => {
|
||
const newLeft = startLeft.value + (moveEvent.clientX - startX.value);
|
||
const newTop = startTop.value + (moveEvent.clientY - startY.value);
|
||
updateGhostPosition(newLeft, newTop);
|
||
// const closestComponent = props.checkClosestComponent(
|
||
// props.id,
|
||
// newLeft,
|
||
// newTop,
|
||
// parseInt(containerStyle.width),
|
||
// parseInt(containerStyle.height),
|
||
// );
|
||
//* console.log('closestComponent', closestComponent);
|
||
updatePosition(newLeft, newTop, false);
|
||
});
|
||
};
|
||
|
||
const onMouseUp = (moveEvent: MouseEvent) => {
|
||
moveAndResize(moveEvent);
|
||
mouseCursor.value = 'grab';
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
if (isCollied.value) {
|
||
containerStyle.zIndex = '3';
|
||
} else {
|
||
containerStyle.zIndex = '1';
|
||
isCollied.value = false;
|
||
}
|
||
containerStyle.transition = 'none';
|
||
console.log('containerStyle', containerStyle);
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
ghost.value.parentNode?.removeChild(ghost.value);
|
||
};
|
||
|
||
const moveAndResize = (moveEvent: MouseEvent) => {
|
||
const newLeft = startLeft.value + (moveEvent.clientX - startX.value);
|
||
const newTop = startTop.value + (moveEvent.clientY - startY.value);
|
||
console.log(newLeft);
|
||
console.log(newTop);
|
||
|
||
// containerStyle.zIndex = '3';
|
||
|
||
//* 使用 requestAnimationFrame 来优化性能
|
||
requestAnimationFrame(() => {
|
||
//* 检测吸附
|
||
const snapResult = props.detectSnap(
|
||
props.id,
|
||
newLeft,
|
||
newTop,
|
||
parseInt(containerStyle.width),
|
||
parseInt(containerStyle.height),
|
||
props.snapDistance,
|
||
);
|
||
let finalLeft = snapResult.left;
|
||
let finalTop = snapResult.top;
|
||
isSnap.value = snapResult.isSnap;
|
||
console.log('finalLeft', finalLeft);
|
||
console.log('finalTop', finalTop);
|
||
|
||
const colliedInfo = props.detectCollision(
|
||
props.id,
|
||
finalLeft,
|
||
finalTop,
|
||
parseInt(containerStyle.width),
|
||
parseInt(containerStyle.height),
|
||
);
|
||
|
||
isCollied.value = colliedInfo.value;
|
||
|
||
updatePosition(finalLeft, finalTop, isSnap.value);
|
||
emit('drag', props.id, parseInt(containerStyle.left), parseInt(containerStyle.top));
|
||
|
||
//* 检测边界和碰撞
|
||
//todo
|
||
//* if (!colliedInfo.value) {
|
||
//* updatePosition(finalLeft, finalTop, isSnap);
|
||
//* } else {
|
||
//* const colliedComponent = colliedInfo.colliedComponent;
|
||
//* const collidedDirection = colliedInfo.collidedDirection;
|
||
//* if (colliedComponent && collidedDirection) {
|
||
//* //* 左右两边碰撞,则只移动垂直方向
|
||
//* if (
|
||
//* collidedDirection === 'left' ||
|
||
//* (collidedDirection === 'right' &&
|
||
//* (parseInt(containerStyle.height) + finalTop >= colliedComponent.y ||
|
||
//* finalTop <= colliedComponent.y + parseInt(colliedComponent.height)))
|
||
//* ) {
|
||
//* containerStyle.top = `${finalTop}px`;
|
||
//* }
|
||
//* //* 上下两边碰撞,则只移动水平方向
|
||
//* if (
|
||
//* collidedDirection === 'top' ||
|
||
//* (collidedDirection === 'bottom' &&
|
||
//* (parseInt(containerStyle.width) + finalLeft >= colliedComponent.x ||
|
||
//* finalLeft <= colliedComponent.x + parseInt(colliedComponent.width)))
|
||
//* ) {
|
||
//* containerStyle.left = `${finalLeft}px`;
|
||
//* }
|
||
//* }
|
||
//* }
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 组件resize事件
|
||
* @param dir
|
||
* @param event
|
||
*/
|
||
const onResizeHandleMouseDown = (dir: string, event: MouseEvent) => {
|
||
const startX = event.clientX;
|
||
const startY = event.clientY;
|
||
const startWidth = parseInt(containerStyle.width);
|
||
const startHeight = parseInt(containerStyle.height);
|
||
const startLeft = parseInt(containerStyle.left);
|
||
const startTop = parseInt(containerStyle.top);
|
||
|
||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||
let newWidth = startWidth;
|
||
let newHeight = startHeight;
|
||
let newLeft = startLeft;
|
||
let newTop = startTop;
|
||
|
||
if (dir.includes('right')) {
|
||
newWidth = startWidth + (moveEvent.clientX - startX);
|
||
} else if (dir.includes('left')) {
|
||
newWidth = startWidth - (moveEvent.clientX - startX);
|
||
newLeft = startLeft + (moveEvent.clientX - startX);
|
||
}
|
||
|
||
if (dir.includes('bottom')) {
|
||
newHeight = startHeight + (moveEvent.clientY - startY);
|
||
} else if (dir.includes('top')) {
|
||
newHeight = startHeight - (moveEvent.clientY - startY);
|
||
newTop = startTop + (moveEvent.clientY - startY);
|
||
}
|
||
|
||
//* 使用 requestAnimationFrame 来优化性能
|
||
requestAnimationFrame(() => {
|
||
//* 检测吸附
|
||
const snapResult = props.detectSnap(props.id, newLeft, newTop, newWidth, newHeight, props.snapDistance);
|
||
let finalWidth = snapResult.width;
|
||
let finalHeight = snapResult.height;
|
||
let finalLeft = snapResult.left;
|
||
let finalTop = snapResult.top;
|
||
isSnap.value = snapResult.isSnap;
|
||
|
||
updateSize(finalWidth, finalHeight);
|
||
updatePosition(finalLeft, finalTop, isSnap.value);
|
||
|
||
return;
|
||
|
||
const colliedInfo = props.detectCollision(props.id, finalLeft, finalTop, finalWidth, finalHeight);
|
||
//* 检测碰撞
|
||
if (!colliedInfo.value) {
|
||
updateSize(finalWidth, finalHeight);
|
||
updatePosition(finalLeft, finalTop, isSnap.value);
|
||
} else {
|
||
//todo 当元素的某一边已经吸附,且resize的方向并非和吸附的方向一致,则仍可以进行updateSize
|
||
const colliedComponent = colliedInfo.colliedComponent;
|
||
const collidedDirection = colliedInfo.collidedDirection;
|
||
if (colliedComponent && collidedDirection) {
|
||
//* 左右两边碰撞,则只移动垂直方向
|
||
if (
|
||
collidedDirection === 'left' ||
|
||
(collidedDirection === 'right' &&
|
||
(parseInt(containerStyle.height) + finalTop >= colliedComponent.y ||
|
||
finalTop <= colliedComponent.y + parseInt(colliedComponent.height)))
|
||
) {
|
||
containerStyle.top = `${finalTop}px`;
|
||
}
|
||
//* 上下两边碰撞,则只移动水平方向
|
||
if (
|
||
collidedDirection === 'top' ||
|
||
(collidedDirection === 'bottom' &&
|
||
(parseInt(containerStyle.width) + finalLeft >= colliedComponent.x ||
|
||
finalLeft <= colliedComponent.x + parseInt(colliedComponent.width)))
|
||
) {
|
||
containerStyle.left = `${finalLeft}px`;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
document.removeEventListener('mouseup', onMouseUp);
|
||
emit('resize', props.id, parseInt(containerStyle.width), parseInt(containerStyle.height));
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
document.addEventListener('mouseup', onMouseUp);
|
||
};
|
||
|
||
//* 使用 ResizeObserver 来监听容器大小变化
|
||
const resizeObserver = new ResizeObserver((entries) => {
|
||
console.log(`entries, ${entries}`);
|
||
const entry = entries[0];
|
||
const { width, height } = entry.contentRect;
|
||
updateSize(width, height);
|
||
});
|
||
|
||
//* 在容器元素存在时,才监听其大小变化
|
||
if (container.value) {
|
||
resizeObserver.observe(container.value);
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.draggable-resizable {
|
||
position: absolute;
|
||
background-color: lightblue;
|
||
border: 1px solid #333;
|
||
box-sizing: border-box;
|
||
.header {
|
||
width: 100%;
|
||
background-color: #333;
|
||
height: 40px;
|
||
border-bottom: 1px solid #333;
|
||
color: #fff;
|
||
}
|
||
}
|
||
.resize-handle {
|
||
position: absolute;
|
||
width: 10px;
|
||
height: 10px;
|
||
background-color: #333;
|
||
z-index: 1;
|
||
}
|
||
.resize-handle.top {
|
||
top: -5px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
cursor: n-resize;
|
||
}
|
||
.resize-handle.bottom {
|
||
bottom: -5px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
cursor: s-resize;
|
||
}
|
||
.resize-handle.left {
|
||
left: -5px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
cursor: w-resize;
|
||
}
|
||
.resize-handle.right {
|
||
right: -5px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
cursor: e-resize;
|
||
}
|
||
.resize-handle.top-left {
|
||
top: -5px;
|
||
left: -5px;
|
||
cursor: nw-resize;
|
||
}
|
||
.resize-handle.top-right {
|
||
top: -5px;
|
||
right: -5px;
|
||
cursor: ne-resize;
|
||
}
|
||
.resize-handle.bottom-left {
|
||
bottom: -5px;
|
||
left: -5px;
|
||
cursor: sw-resize;
|
||
}
|
||
.resize-handle.bottom-right {
|
||
bottom: -5px;
|
||
right: -5px;
|
||
cursor: se-resize;
|
||
}
|
||
</style>
|