示例
引言
拖拽调整大小,听起来就是 mousedown + mousemove + mouseup 三板斧。但当你开始处理左边框拖拽时位置与宽度的联动、容器边框对定位的影响、鼠标移出文档后的状态恢复这些细节时,就会发现"三板斧"远远不够。
本文从一个实际项目中经过多次迭代的 useResizable Composables 出发,拆解拖拽调整大小时的各个关键环节,以及我在这个过程中踩过的坑和解决方案。
功能特性
- 支持 8 个方向的边缘/角落拖拽调整大小(上/下/左/右/左上/右上/左下/右下)
- 支持手柄拖拽移动整个元素
- 支持容器边界约束(自动处理容器边框偏移)
- 支持安全区域配置,避免元素紧贴边界
- 支持最小/最大尺寸限制
- 支持拖拽结束后自动恢复原始定位样式
- 支持通过响应式值控制启用/禁用
实现细节
类型定义
首先定义调整方向,这是整个拖拽系统的核心类型:
export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
然后定义拖拽过程中需要记录的状态。调整大小和拖拽移动是两套独立的状态,分开管理:
/** 调整大小状态 */
interface ResizeState {
isResizing: boolean;
direction: ResizeDirection | null;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
startLeft: number;
startTop: number;
}
/** 拖拽移动状态 */
interface DragState {
isDragging: boolean;
startX: number;
startY: number;
startLeft: number;
startTop: number;
}
边界信息包含了容器的四条边和宽高,用于约束元素位置:
interface Bounds {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
}
配置项设计:
export interface UseResizableOptions {
minWidth?: number; // 最小宽度,默认 200
minHeight?: number; // 最小高度,默认 200
maxWidth?: number; // 最大宽度,0 表示无限制
maxHeight?: number; // 最大高度,0 表示无限制
enabledEdges?: ResizeDirection[]; // 启用的方向列表
safeArea?: SafeArea; // 安全区域配置
containerRef?: Ref<HTMLElement | undefined>; // 容器元素引用
draggableRef?: Ref<HTMLElement | undefined>; // 拖拽手柄引用
autoRestore?: boolean; // 拖拽结束后自动恢复样式
}
方向识别
当鼠标在元素边缘 5px 范围内移动时,需要判断当前处于哪个边缘或角落:
const resizeHandleSize = 5;
const getDirection = (clientX: number, clientY: number): ResizeDirection | null => {
const rect = targetRef.value.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top8;
const isLeft = x <= resizeHandleSize;
const isRight = x >= rect.width - resizeHandleSize;
const isTop = y <= resizeHandleSize;
const isBottom = y >= rect.height - resizeHandleSize;
// 四角优先于边缘
if (isTop && isLeft) return 'nw';
if (isTop && isRight) return 'ne';
if (isBottom && isLeft) return 'sw';
if (isBottom && isRight) return 'se';
if (isTop) return 'n';
if (isBottom) return 's';
if (isLeft) return 'w';
if (isRight) return 'e';
return null;
};
这里的优先级设计很关键——角落方向必须优先于边缘方向判断,否则鼠标在元素角落时会被误判为边缘方向。
8 方向尺寸计算
这是整个 composable 最核心的部分。不同方向的计算逻辑差异很大:
右边框拖拽 e:最简单的方向。左边不动,宽度跟随鼠标移动变化,只需限制最大宽度不超出容器右边界。
case 'e': {
const desiredRight = startWidth + deltaX;
const maxRight = bounds.width - safeArea.right - startLeft;
newWidth = Math.max(minWidth, Math.min(maxRight, desiredRight));
break;
}
左边框拖拽 w:这是最容易出错的方向。向左拖拽时,不仅宽度增加,元素的位置也要向左移动。位置和宽度需要联动计算:
case 'w': {
const desiredLeft = startLeft + deltaX;
newLeft = Math.max(0, desiredLeft);
newWidth = startWidth + (startLeft - newLeft);
newWidth = Math.max(minWidth, Math.min(effectiveMaxWidth, newWidth));
// 如果达到最小宽度,重新计算位置
if (newWidth === minWidth) {
newLeft = startLeft + startWidth - minWidth;
}
break;
}
关键点在于:向右移动鼠标 → deltaX 为正 → newLeft 增大 → newWidth 减小。当宽度达到 minWidth 时,左侧不再向右移动,而是维持当前位置。
上边框拖拽 n 和 左边框拖拽 w 逻辑对称,方向换为垂直轴即可。
四个角落方向则是对应边缘方向逻辑的组合。例如右上角 ne = 右边 + 上边:
case 'ne': {
const maxRight = bounds.width - safeArea.right - startLeft;
newWidth = Math.max(minWidth, Math.min(maxRight, desiredRight));
newTop = Math.max(0, startTop + deltaY);
newHeight = startHeight + (startTop - newTop);
newHeight = Math.max(minHeight, Math.min(effectiveMaxHeight, newHeight));
if (newHeight === minHeight) {
newTop = startTop + startHeight - minHeight;
}
break;
}
边界处理与容器边框补偿
坑 1:容器边框导致边界偏移
getBoundingClientRect() 返回的是 border box 的尺寸,包含 border。但 position: absolute 的子元素是相对于容器的 padding box 定位的。当容器有 2px 边框时,如果不做处理,视觉上元素会越过边框。
解决方法:在计算边界时减去容器的边框宽度:
const getBounds = (): Bounds => {
if (containerRef?.value) {
const containerRect = containerRef.value.getBoundingClientRect();
const style = window.getComputedStyle(containerRef.value);
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
const borderTop = parseFloat(style.borderTopWidth) || 0;
const borderRight = parseFloat(style.borderRightWidth) || 0;
const borderBottom = parseFloat(style.borderBottomWidth) || 0;
return {
left: containerRect.left + borderLeft,
top: containerRect.top + borderTop,
right: containerRect.right - borderRight,
bottom: containerRect.bottom - borderBottom,
width: containerRect.width - borderLeft - borderRight,
height: containerRect.height - borderTop - borderBottom,
};
}
return {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
width: window.innerWidth,
height: window.innerHeight,
};
};
鼠标移出文档的状态恢复
坑 2:鼠标拖出文档后状态卡死
当用户拖拽时快速将鼠标移出文档再松开,mouseup 事件不会在目标元素上触发,导致 isResizing 仍然为 true。鼠标再次回到元素时,不用点击就会继续调整大小。
解决方案:在 mousemove 中检测鼠标按钮状态,如果左键已松开则终止拖拽:
const onMouseMove = (e: MouseEvent) => {
if (!targetRef.value || !resizable()) return;
// 如果鼠标左键未按下,但仍在拖拽状态中,终止拖拽
if ((resizeState.isResizing || dragState.isDragging) && e.buttons !== MOUSE_LEFT_BUTTON) {
onMouseUp();
return;
}
// ... 后续拖拽逻辑
};
定位转换与样式恢复
拖拽开始时,如果元素还不是绝对定位,需要自动转换。转换前保存原始样式,拖拽结束后可以恢复:
const ensureAbsolutePosition = () => {
const el = targetRef.value;
const style = window.getComputedStyle(el);
// 确保容器有定位属性
if (containerRef?.value) {
const containerStyle = window.getComputedStyle(containerRef.value);
if (containerStyle.position === 'static') {
containerRef.value.style.position = 'relative';
}
}
if (style.position !== 'absolute' && style.position !== 'fixed') {
saveOriginalStyle(el); // 保存原始样式
wasTransformed = true;
const rect = el.getBoundingClientRect();
el.style.position = 'absolute';
el.style.left = `${rect.left - bounds.left}px`;
el.style.top = `${rect.top - bounds.top}px`;
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.margin = '0';
el.style.transform = '';
}
};
事件绑定与响应式控制
通过 watchEffect 监听 resizable() 的返回值,自动绑定和解除事件。组件卸载时通过 onScopeDispose 清理:
watchEffect(() => {
if (resizable()) {
onResizable();
onDraggable();
} else {
offResizable();
offDraggable();
}
});
if (getCurrentScope()) {
onScopeDispose(() => {
offResizable();
offDraggable();
});
}
API 设计
参数
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
targetRef | 目标元素引用 | Ref<HTMLElement | undefined> | - |
resizable | 是否启用拖拽的 getter 函数 | () => boolean | - |
options.minWidth | 最小宽度 | number | 200 |
options.minHeight | 最小高度 | number | 200 |
options.maxWidth | 最大宽度,0 无限制 | number | 0 |
options.maxHeight | 最大高度,0 无限制 | number | 0 |
options.enabledEdges | 启用的方向列表 | ResizeDirection[] | 全部方向 |
options.safeArea | 安全区域 | SafeArea | {top:0,left:0,bottom:0,right:0} |
options.containerRef | 容器引用 | Ref<HTMLElement | undefined> | undefined |
options.draggableRef | 拖拽手柄引用 | Ref<HTMLElement | undefined> | undefined |
options.autoRestore | 自动恢复样式 | boolean | false |
返回值
| 方法 | 说明 |
|---|---|
offResizable() | 手动解绑调整大小事件 |
offDraggable() | 手动解绑拖拽移动事件 |
restoreOriginalStyle() | 恢复元素的原始定位样式 |
useResizable composable 完整代码
import { type Ref, getCurrentScope, onScopeDispose, watchEffect } from 'vue';
/**
* 调整方向类型
*
* - `n`: 上
* - `s`: 下
* - `e`: 右
* - `w`: 左
* - `ne`: 右上
* - `nw`: 左上
* - `se`: 右下
* - `sw`: 左下
*/
export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
/** 调整大小状态的数据结构 */
interface ResizeState {
isResizing: boolean; // 是否正在拖拽调整大小
direction: ResizeDirection | null; // 当前拖拽方向
startX: number; // 拖拽开始时的鼠标 X 坐标
startY: number; // 拖拽开始时的鼠标 Y 坐标
startWidth: number; // 拖拽开始时的元素宽度
startHeight: number; // 拖拽开始时的元素高度
startLeft: number; // 拖拽开始时的元素 left 位置
startTop: number; // 拖拽开始时的元素 top 位置
}
/** 拖拽移动状态的数据结构 */
interface DragState {
isDragging: boolean; // 是否正在拖拽移动
startX: number; // 拖拽开始时的鼠标 X 坐标
startY: number; // 拖拽开始时的鼠标 Y 坐标
startLeft: number; // 拖拽开始时的元素 left 位置
startTop: number; // 拖拽开始时的元素 top 位置
}
/** 保存的原始样式,用于拖拽结束后恢复 */
interface OriginalStyle {
position: string;
left: string;
top: string;
width: string;
height: string;
margin: string;
transform: string;
containerPosition?: string;
}
/**
* 安全区域配置,防止调整大小时超出视口或容器边界
*
* 用于在拖拽调整大小或移动时,在元素与边界之间保留一定的安全距离,
* 避免元素紧贴边缘导致遮挡或操作困难。
*/
interface SafeArea {
/** 顶部安全距离(像素) */
top: number;
/** 左侧安全距离(像素) */
left: number;
/** 底部安全距离(像素) */
bottom: number;
/** 右侧安全距离(像素) */
right: number;
}
/**
* 边界信息
*/
interface Bounds {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
}
/**
* useResizable 可选配置项
*
* 所有配置项均为可选,未设置的字段将使用 DEFAULT_OPTIONS 中的默认值。
*/
export interface UseResizableOptions {
/** 最小宽度(像素),默认 200 */
minWidth?: number;
/** 最小高度(像素),默认 200 */
minHeight?: number;
/** 最大宽度(像素),0 表示无限制,默认 0 */
maxWidth?: number;
/** 最大高度(像素),0 表示无限制,默认 0 */
maxHeight?: number;
/** 启用的调整方向列表,默认启用全部 8 个方向 */
enabledEdges?: ResizeDirection[];
/** 安全区域配置,用于控制元素距边界的最小距离 */
safeArea?: SafeArea;
/** 容器元素引用,如果提供则在容器范围内调整大小和移动 */
containerRef?: Ref<HTMLElement | undefined>;
/** 拖拽手柄元素引用,如果提供则支持通过手柄拖拽移动整个元素 */
draggableRef?: Ref<HTMLElement | undefined>;
/** 拖拽结束后是否自动恢复元素的原始定位样式,默认 false */
autoRestore?: boolean;
}
/**
* useResizable 返回值类型
*/
export interface UseResizableReturn {
/** 手动解绑调整大小事件 */
offResizable: () => void;
/** 手动解绑拖拽移动事件 */
offDraggable: () => void;
/** 恢复元素的原始定位样式 */
restoreOriginalStyle: () => void;
}
/** 默认配置 */
const DEFAULT_OPTIONS: Required<
Pick<
UseResizableOptions,
| 'minWidth'
| 'minHeight'
| 'maxWidth'
| 'maxHeight'
| 'enabledEdges'
| 'safeArea'
| 'autoRestore'
>
> = {
minWidth: 200,
minHeight: 200,
maxWidth: 0,
maxHeight: 0,
enabledEdges: ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'],
safeArea: { top: 0, left: 0, bottom: 0, right: 0 },
autoRestore: false,
};
/**
* 可调整大小与可拖拽移动的 Composable
*
* 为元素提供边缘/角落拖拽调整大小和手柄拖拽移动的能力。
* 支持最小/最大尺寸限制、安全区域、容器边界约束和自动样式恢复。
*
* @param targetRef - 目标元素引用,将被绑定 mousedown 事件用于调整大小
* @param resizable - 返回是否允许调整大小/拖拽的函数,响应式返回值控制启用状态
* @param options - 可选配置项,未设置的字段使用默认值
*
* @returns 控制方法集合
* - `offResizable()`: 手动解绑调整大小事件
* - `offDraggable()`: 手动解绑拖拽移动事件
* - `restoreOriginalStyle()`: 恢复元素的原始定位样式(仅在 autoRestore 为 false 时需要手动调用)
*
* @example
* ```ts
* import { ref, computed } from 'vue';
* import { useResizable } from '@learndo/composables';
*
* const elRef = ref<HTMLElement>();
* const isActive = computed(() => true);
*
* const { restoreOriginalStyle } = useResizable(
* elRef,
* isActive,
* {
* minWidth: 300,
* minHeight: 200,
* enabledEdges: ['e', 's', 'se'],
* safeArea: { top: 10, right: 10, bottom: 10, left: 10 },
* autoRestore: false,
* },
* );
* ```
*/
export const useResizable = (
targetRef: Ref<HTMLElement | undefined>,
resizable: () => boolean,
options: UseResizableOptions = {},
): UseResizableReturn => {
const {
minWidth,
minHeight,
maxWidth,
maxHeight,
enabledEdges,
safeArea,
containerRef,
draggableRef,
autoRestore,
} = { ...DEFAULT_OPTIONS, ...options };
/** 内部拖拽状态 */
const resizeState = {
isResizing: false,
direction: null as ResizeDirection | null,
startX: 0,
startY: 0,
startWidth: 0,
startHeight: 0,
startLeft: 0,
startTop: 0,
} as ResizeState;
/** 内部拖拽移动状态 */
const dragState = {
isDragging: false,
startX: 0,
startY: 0,
startLeft: 0,
startTop: 0,
} as DragState;
/** 边缘拖拽手柄大小 */
const resizeHandleSize = 5;
/** 鼠标左键按下时的 buttons 值 */
const MOUSE_LEFT_BUTTON = 1;
/** 保存的原始样式 */
let originalStyle: OriginalStyle | null = null;
/** 是否已转换为绝对定位 */
let wasTransformed = false;
let bounds: Bounds = {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
width: window.innerWidth,
height: window.innerHeight,
};
/**
* 获取边界信息
* 如果提供了容器引用,则使用容器的边界,否则使用视口边界
* 返回的坐标是相对于容器的(如果有容器)或视口的(如果没有容器)
*/
const getBounds = (): Bounds => {
if (containerRef?.value) {
const containerRect = containerRef.value.getBoundingClientRect();
const style = window.getComputedStyle(containerRef.value);
const borderLeft = parseFloat(style.borderLeftWidth) || 0;
const borderTop = parseFloat(style.borderTopWidth) || 0;
const borderRight = parseFloat(style.borderRightWidth) || 0;
const borderBottom = parseFloat(style.borderBottomWidth) || 0;
// 减去容器边框宽度,使边界与 padding box 对齐
// 因为 position: absolute 的子元素是相对于容器的 padding box 定位的
return {
left: containerRect.left + borderLeft,
top: containerRect.top + borderTop,
right: containerRect.right - borderRight,
bottom: containerRect.bottom - borderBottom,
width: containerRect.width - borderLeft - borderRight,
height: containerRect.height - borderTop - borderBottom,
};
}
// 使用视口边界
return {
left: 0,
top: 0,
right: window.innerWidth,
bottom: window.innerHeight,
width: window.innerWidth,
height: window.innerHeight,
};
};
/**
* 保存元素的原始样式
*/
const saveOriginalStyle = (el: HTMLElement) => {
const style = window.getComputedStyle(el);
let containerPosition: string | undefined;
if (containerRef?.value) {
containerPosition = containerRef.value.style.position || undefined;
}
originalStyle = {
position: style.position,
left: el.style.left,
top: el.style.top,
width: el.style.width,
height: el.style.height,
margin: el.style.margin,
transform: style.transform,
containerPosition,
};
};
/**
* 恢复元素的原始样式
* 在拖拽结束后调用,将元素恢复到非绝对定位的原始状态
*/
const restoreOriginalStyle = () => {
if (!targetRef.value || !originalStyle || !wasTransformed) return;
const el = targetRef.value;
el.style.position = originalStyle.position;
el.style.left = originalStyle.left;
el.style.top = originalStyle.top;
el.style.width = originalStyle.width;
el.style.height = originalStyle.height;
el.style.margin = originalStyle.margin;
el.style.transform = originalStyle.transform;
if (containerRef?.value && originalStyle.containerPosition !== undefined) {
containerRef.value.style.position = originalStyle.containerPosition;
}
originalStyle = null;
wasTransformed = false;
};
/**
* 确保元素使用绝对定位或固定定位
* 只有在元素不是绝对定位或固定定位时才转换
*/
const ensureAbsolutePosition = () => {
if (!targetRef.value) return;
const el = targetRef.value;
const style = window.getComputedStyle(el);
// 如果需要相对于容器定位,确保容器有定位属性
if (containerRef?.value) {
const containerStyle = window.getComputedStyle(containerRef.value);
if (containerStyle.position === 'static') {
containerRef.value.style.position = 'relative';
}
}
if (style.position !== 'absolute' && style.position !== 'fixed') {
saveOriginalStyle(el);
wasTransformed = true;
const rect = el.getBoundingClientRect();
el.style.position = 'absolute';
el.style.left = `${rect.left - bounds.left}px`;
el.style.top = `${rect.top - bounds.top}px`;
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.margin = '0';
el.style.transform = '';
}
};
/**
* 根据鼠标位置判断拖拽方向
* @returns 拖拽方向或 null(不在可拖拽区域)
*/
const getDirection = (clientX: number, clientY: number): ResizeDirection | null => {
if (!targetRef.value) return null;
const rect = targetRef.value.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
const width = rect.width;
const height = rect.height;
// 判断鼠标在元素的哪个边缘
const isLeft = x <= resizeHandleSize;
const isRight = x >= width - resizeHandleSize;
const isTop = y <= resizeHandleSize;
const isBottom = y >= height - resizeHandleSize;
// 按优先级判断方向(四角优先于边缘)
if (isTop && isLeft) return 'nw';
if (isTop && isRight) return 'ne';
if (isBottom && isLeft) return 'sw';
if (isBottom && isRight) return 'se';
if (isTop) return 'n';
if (isBottom) return 's';
if (isLeft) return 'w';
if (isRight) return 'e';
return null;
};
/**
* 获取鼠标光标样式
*/
const getCursorStyle = (direction: ResizeDirection): string => {
const cursorMap: Record<ResizeDirection, string> = {
n: 'ns-resize',
s: 'ns-resize',
e: 'ew-resize',
w: 'ew-resize',
ne: 'nesw-resize',
nw: 'nwse-resize',
se: 'nwse-resize',
sw: 'nesw-resize',
};
return cursorMap[direction];
};
/**
* 鼠标移动事件处理
* 处理拖拽过程中的大小调整和位置移动
*/
const onMouseMove = (e: MouseEvent) => {
if (!targetRef.value || !resizable()) return;
// 如果鼠标左键未按下,但仍在拖拽状态中(说明鼠标移出文档后松开过),终止拖拽
if ((resizeState.isResizing || dragState.isDragging) && e.buttons !== MOUSE_LEFT_BUTTON) {
onMouseUp();
return;
}
// 正在拖拽移动中,执行位置移动逻辑
if (dragState.isDragging) {
const { startX, startY, startLeft, startTop } = dragState;
// 计算鼠标偏移量
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// 计算新的位置
let newLeft = startLeft + deltaX;
let newTop = startTop + deltaY;
// 获取元素尺寸
const el = targetRef.value;
const width = el.offsetWidth;
const height = el.offsetHeight;
// 限制在容器边界内
const maxLeft = bounds.width - width - safeArea.right;
const maxTop = bounds.height - height - safeArea.bottom;
newLeft = Math.max(safeArea.left, Math.min(maxLeft, newLeft));
newTop = Math.max(safeArea.top, Math.min(maxTop, newTop));
// 应用新位置
el.style.left = `${newLeft}px`;
el.style.top = `${newTop}px`;
return;
}
// 正在调整大小中,执行大小调整逻辑
if (resizeState.isResizing) {
const { direction, startX, startY, startWidth, startHeight, startLeft, startTop } =
resizeState;
// 计算鼠标偏移量
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// 初始化新的尺寸和位置
let newWidth = startWidth;
let newHeight = startHeight;
let newLeft = startLeft;
let newTop = startTop;
// 计算有效的最大尺寸
// 0 表示无限制,使用视口尺寸减去当前位置
const effectiveMaxWidth =
maxWidth > 0 ? maxWidth : bounds.width - safeArea.left - safeArea.right;
const effectiveMaxHeight =
maxHeight > 0 ? maxHeight : bounds.height - safeArea.top - safeArea.bottom;
switch (direction) {
// ==================== 右边框拖拽 ====================
case 'e': {
// 1. 计算期望的新右边位置
const desiredRight = startWidth + deltaX;
// 2. 限制不能超出右边界(bounds.width 已减去容器边框宽度)
const maxRight = bounds.width - safeArea.right - startLeft;
// 3. 根据位置变化反推宽度(保持左边不动)
newWidth = Math.max(minWidth, Math.min(maxRight, desiredRight));
break;
}
// ==================== 左边框拖拽 ====================
case 'w': {
// 向左拖拽时 deltaX 为负值
// 1. 计算期望的新左边位置
const desiredLeft = startLeft + deltaX;
// 2. 限制不能超出左边界
newLeft = Math.max(0, desiredLeft);
// 3. 根据位置变化反推宽度(保持右边不动)
newWidth = startWidth + (startLeft - newLeft);
// 4. 确保宽度在有效范围内
newWidth = Math.max(minWidth, Math.min(effectiveMaxWidth, newWidth));
// 5. 如果达到最小宽度,重新计算位置
if (newWidth === minWidth) {
newLeft = startLeft + startWidth - minWidth;
}
break;
}
// ==================== 下边框拖拽 ====================
case 's': {
// 1. 计算期望的新下边位置
const desiredBottom = startHeight + deltaY;
const maxBottom = bounds.height - safeArea.bottom - startTop;
newHeight = Math.max(minHeight, Math.min(maxBottom, desiredBottom));
break;
}
// ==================== 上边框拖拽 ====================
case 'n': {
// 向上拖拽时 deltaY 为负值
// 1. 计算期望的新上边位置
const desiredTop = startTop + deltaY;
// 2. 限制不能超出上边界
newTop = Math.max(0, desiredTop);
// 3. 根据位置变化反推高度(保持下边不动)
newHeight = startHeight + (startTop - newTop);
// 4. 确保高度在有效范围内
newHeight = Math.max(minHeight, Math.min(effectiveMaxHeight, newHeight));
// 5. 如果达到最小高度,重新计算位置
if (newHeight === minHeight) {
newTop = startTop + startHeight - minHeight;
}
break;
}
// ==================== 右上角拖拽 ====================
case 'ne': {
// 右边:同 'e' 的逻辑
const desiredRight = startWidth + deltaX;
const maxRight = bounds.width - safeArea.right - startLeft;
newWidth = Math.max(minWidth, Math.min(maxRight, desiredRight));
// 上边:同 'n' 的完整逻辑
const desiredTop = startTop + deltaY;
newTop = Math.max(0, desiredTop);
newHeight = startHeight + (startTop - newTop);
newHeight = Math.max(minHeight, Math.min(effectiveMaxHeight, newHeight));
if (newHeight === minHeight) {
newTop = startTop + startHeight - minHeight;
}
break;
}
// ==================== 左上角拖拽 ====================
case 'nw': {
// 左边:同 'w' 的完整逻辑
const desiredLeft = startLeft + deltaX;
newLeft = Math.max(0, desiredLeft);
newWidth = startWidth + (startLeft - newLeft);
newWidth = Math.max(minWidth, Math.min(effectiveMaxWidth, newWidth));
if (newWidth === minWidth) {
newLeft = startLeft + startWidth - minWidth;
}
// 上边:同 'n' 的完整逻辑
const desiredTop = startTop + deltaY;
newTop = Math.max(0, desiredTop);
newHeight = startHeight + (startTop - newTop);
newHeight = Math.max(minHeight, Math.min(effectiveMaxHeight, newHeight));
if (newHeight === minHeight) {
newTop = startTop + startHeight - minHeight;
}
break;
}
// ==================== 右下角拖拽 ====================
case 'se': {
// 右边:同 'e' 的逻辑
const desiredRight = startWidth + deltaX;
const maxRight = bounds.width - safeArea.right - startLeft;
newWidth = Math.max(minWidth, Math.min(maxRight, desiredRight));
// 下边:同 's' 的逻辑
const desiredBottom = startHeight + deltaY;
const maxBottom = bounds.height - safeArea.bottom - startTop;
newHeight = Math.max(minHeight, Math.min(maxBottom, desiredBottom));
break;
}
// ==================== 左下角拖拽 ====================
case 'sw': {
// 左边:同 'w' 的完整逻辑
const desiredLeft = startLeft + deltaX;
newLeft = Math.max(0, desiredLeft);
newWidth = startWidth + (startLeft - newLeft);
newWidth = Math.max(minWidth, Math.min(effectiveMaxWidth, newWidth));
if (newWidth === minWidth) {
newLeft = startLeft + startWidth - minWidth;
}
// 下边:同 's' 的逻辑
const desiredBottom = startHeight + deltaY;
const maxBottom = bounds.height - safeArea.bottom - startTop;
newHeight = Math.max(minHeight, Math.min(maxBottom, desiredBottom));
break;
}
}
targetRef.value.style.width = `${newWidth}px`;
targetRef.value.style.height = `${newHeight}px`;
targetRef.value.style.left = `${newLeft}px`;
targetRef.value.style.top = `${newTop}px`;
return;
}
// 非拖拽状态:更新鼠标光标样式
const direction = getDirection(e.clientX, e.clientY);
if (direction && enabledEdges.includes(direction)) {
targetRef.value.style.cursor = getCursorStyle(direction);
} else {
targetRef.value.style.cursor = '';
}
};
/**
* 鼠标按下事件处理
* 开始拖拽调整大小
*/
const onMouseDown = (e: MouseEvent) => {
if (!targetRef.value || !resizable()) return;
bounds = getBounds();
// 获取拖拽方向
const direction = getDirection(e.clientX, e.clientY);
// 检查方向是否在启用的边缘列表中
if (!direction || !enabledEdges.includes(direction)) return;
e.preventDefault();
e.stopPropagation();
// 确保元素使用绝对定位
ensureAbsolutePosition();
// 获取元素当前尺寸和位置
const rect = targetRef.value.getBoundingClientRect();
// 保存拖拽开始状态
resizeState.isResizing = true;
resizeState.direction = direction;
resizeState.startX = e.clientX;
resizeState.startY = e.clientY;
resizeState.startWidth = rect.width;
resizeState.startHeight = rect.height;
resizeState.startLeft = rect.left - bounds.left;
resizeState.startTop = rect.top - bounds.top;
};
/**
* 拖拽手柄上的鼠标按下事件处理
* 开始拖拽移动整个元素
*/
const onDragHandleMouseDown = (e: MouseEvent) => {
if (!targetRef.value || !resizable()) return;
bounds = getBounds();
e.preventDefault();
e.stopPropagation();
// 确保元素使用绝对定位
ensureAbsolutePosition();
// 获取元素当前位置
const rect = targetRef.value.getBoundingClientRect();
// 保存拖拽移动开始状态
dragState.isDragging = true;
dragState.startX = e.clientX;
dragState.startY = e.clientY;
dragState.startLeft = rect.left - bounds.left;
dragState.startTop = rect.top - bounds.top;
};
/**
* 鼠标释放事件处理
* 结束拖拽调整大小和位置移动
*/
const onMouseUp = () => {
// 结束调整大小
resizeState.isResizing = false;
resizeState.direction = null;
// 结束拖拽移动
dragState.isDragging = false;
// 如果启用了自动恢复,则恢复元素的原始定位样式
if (autoRestore) {
restoreOriginalStyle();
}
};
// 用于追踪事件是否已绑定
let isBound = false;
let isDraggableBound = false;
/**
* 绑定拖拽事件
*/
const onResizable = () => {
if (!targetRef.value || isBound) return;
targetRef.value.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
isBound = true;
};
/**
* 绑定拖拽移动事件
*/
const onDraggable = () => {
if (!draggableRef?.value || isDraggableBound) return;
draggableRef.value.style.cursor = 'move';
draggableRef.value.addEventListener('mousedown', onDragHandleMouseDown);
isDraggableBound = true;
};
/**
* 解绑拖拽事件
*/
const offResizable = () => {
if (!targetRef.value || !isBound) return;
targetRef.value.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
isBound = false;
};
/**
* 解绑拖拽移动事件
*/
const offDraggable = () => {
if (!draggableRef?.value || !isDraggableBound) return;
document.body.style.cursor = '';
draggableRef.value.removeEventListener('mousedown', onDragHandleMouseDown);
isDraggableBound = false;
};
// 只在 resizable() 从 false 变为 true 时绑定
watchEffect(() => {
if (resizable()) {
onResizable();
onDraggable();
} else {
offResizable();
offDraggable();
}
});
// 组件卸载时解绑事件
if (getCurrentScope()) {
onScopeDispose(() => {
offResizable();
offDraggable();
});
}
return {
offResizable,
offDraggable,
restoreOriginalStyle,
};
};
感谢阅读,敬请斧正!