2025-06-16 14:42:26 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-07-11 16:50:48 +08:00
|
|
|
|
<style lang="scss" scoped>
|
2025-06-16 14:42:26 +08:00
|
|
|
|
.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>
|