Vue3 手写 useResizable:拖拽调整大小时的 8 个方向、边界约束与那些容易被忽略的坑

113 阅读14分钟

示例

引言

拖拽调整大小,听起来就是 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最小宽度number200
options.minHeight最小高度number200
options.maxWidth最大宽度,0 无限制number0
options.maxHeight最大高度,0 无限制number0
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自动恢复样式booleanfalse

返回值

方法说明
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,
  };
};


感谢阅读,敬请斧正!