webComponent实现一个拖拽组件

创建一个可拖拽的 Web Component。这个组件将允许你放入任何 HTML 内容,并在屏幕内自由拖拽,同时确保它始终显示在其他元素之上,并且不会移出屏幕可视区域。

这个任务分为几个部分:

  1. 创建 Web Component 类: 定义组件的行为和结构。
  2. 实现拖拽逻辑: 处理鼠标/触摸事件以移动组件。
  3. 边界检测: 确保组件不会移出屏幕。
  4. 样式 : 设置必要的 CSS 样式(如 position, z-index, cursor)。
  5. 注册组件: 让浏览器识别我们的新 HTML 标签。
  6. 使用示例: 展示如何在 HTML、Vue 和 React 中使用。
  7. 详细代码讲解: 深入解释每一部分代码。

1. Web Component 代码 (draggable-component.js)

javascript 复制代码
/**
 * DraggableComponent 类定义了一个可拖拽的 Web Component。
 * 用户可以将任何 DOM 元素放入此组件的插槽中,
 * 这些内容将可以在屏幕可视区域内被拖拽。
 *
 * 特性:
 * - 使用 Shadow DOM 进行样式和结构封装。
 * - 支持鼠标和触摸事件(通过 Pointer Events)。
 * - 拖拽时自动提升层级 (z-index)。
 * - 限制拖拽范围,防止元素移出可视窗口。
 * - 允许通过 CSS 自定义变量 `--initial-top`, `--initial-left`, `--z-index`, `--dragging-z-index` 来配置初始位置和层级。
 */
class DraggableComponent extends HTMLElement {
    constructor() {
        super(); // 必须首先调用 super()

        // 初始化内部状态
        this.isDragging = false; // 标记是否正在拖拽
        this.offsetX = 0; // 鼠标/触摸点相对于元素左上角的 X 偏移
        this.offsetY = 0; // 鼠标/触摸点相对于元素左上角的 Y 偏移
        this.currentX = 0; // 元素当前的 X 坐标
        this.currentY = 0; // 元素当前的 Y 坐标

        // 创建 Shadow DOM
        this.attachShadow({ mode: 'open' });

        // 定义组件的 HTML 结构
        // 使用 <slot> 允许用户插入自定义内容
        // 使用一个内部 div (draggable-content) 来应用拖拽样式和行为
        this.shadowRoot.innerHTML = `
            <style>
                /* :host 选择器用于选择 Shadow DOM 的宿主元素 */
                :host {
                    /*
                     * 默认设置为绝对定位。用户也可以根据需要改为 'fixed'。
                     * 'absolute' 相对于最近的已定位祖先元素定位。
                     * 'fixed' 相对于视口定位。
                     * 对于全屏拖拽,'fixed' 可能更直观。我们这里使用 'absolute' 作为默认,
                     * 但拖拽逻辑是基于视口坐标计算的,因此也能很好地工作。
                     * 如果父容器有 transform, perspective, 或 filter 属性,'fixed' 的行为会像 'absolute'。
                     */
                    position: absolute;

                    /*
                     * 从 CSS 自定义变量获取初始位置,如果未提供,则默认为 10px。
                     * 用户可以通过 style="--initial-top: 50px; --initial-left: 100px;" 来设置。
                     */
                    top: var(--initial-top, 10px);
                    left: var(--initial-left, 10px);

                    /*
                     * 设置默认的 z-index。使用 CSS 变量允许用户自定义。
                     */
                    z-index: var(--z-index, 100);

                    /*
                     * 默认光标样式,提示用户这是可拖拽的。
                     */
                    cursor: grab;

                    /*
                     * 防止用户选择元素内的文本,提升拖拽体验。
                     */
                    user-select: none;
                    -webkit-user-select: none; /* 兼容 Safari */
                    -moz-user-select: none; /* 兼容 Firefox */
                    -ms-user-select: none; /* 兼容 IE/Edge */

                    /*
                     * 确保宿主元素可见,即使没有显式设置尺寸。
                     * 'inline-block' 或 'block' 都可以,取决于期望的布局行为。
                     * 'inline-block' 使其能像内联元素一样流动,但可以设置宽高。
                     */
                    display: inline-block;

                    /* 添加一个小的视觉提示,可选 */
                    /* border: 1px dashed #ccc; */
                    /* padding: 5px; */ /* 如果添加内边距,需要调整边界计算 */
                }

                /* 当 :host 元素处于拖拽状态时 (通过添加 .dragging 类) */
                :host(.dragging) {
                    /*
                     * 拖拽时的光标样式。
                     */
                    cursor: grabbing;

                    /*
                     * 拖拽时提升 z-index,确保在最上层。
                     * 使用 CSS 变量允许用户自定义拖拽时的层级。
                     */
                    z-index: var(--dragging-z-index, 1000);
                }

                /*
                 * #draggable-content 是 Shadow DOM 内部用于包裹 <slot> 的容器。
                 * 这样做可以确保即使 <slot> 内容为空,也有一个可交互的区域。
                 * 并且可以应用一些不希望直接影响宿主元素的样式。
                 * 在这个简单例子中,它不是严格必需的,但提供了更好的结构。
                 */
                #draggable-content {
                    /* 可以根据需要添加样式,例如内边距、边框等 */
                    /* padding: 10px; */
                    /* background-color: rgba(255, 255, 255, 0.8); */ /* 半透明背景 */
                    /* border-radius: 5px; */
                }
            </style>
            <div id="draggable-content">
                <slot></slot> <!-- 用户内容将插入到这里 -->
            </div>
        `;

        // 获取对内部容器的引用,虽然在这个实现中没有直接操作它,
        // 但保留引用可能对未来扩展有用。
        this.contentElement = this.shadowRoot.getElementById('draggable-content');
    }

    // --- 生命周期回调 ---

    // 当元素首次连接到文档 DOM 时调用
    connectedCallback() {
        // 检查是否已在服务器端渲染环境中执行过初始化
        // (虽然对于纯客户端 Web Component 不常见,但作为良好实践)
        if (!this.ownerDocument || !this.ownerDocument.defaultView) {
            return;
        }

        // 添加事件监听器
        // 使用 pointerdown 可以同时处理鼠标和触摸事件
        this.addEventListener('pointerdown', this.onDragStart);

        // 从 CSS 变量或默认值初始化当前位置
        // 注意:这里读取的是初始 CSS 设置,拖拽后会动态更新 style 属性
        const style = window.getComputedStyle(this);
        // parseFloat 会尝试解析像素值等,例如 "10px" -> 10
        // 如果初始值不是像素(例如 %),这里可能需要更复杂的处理,
        // 但对于绝对/固定定位,像素值是最常见的。
        this.currentX = parseFloat(style.left || '10');
        this.currentY = parseFloat(style.top || '10');

        // 确保初始位置在屏幕内 (如果初始值过大)
        // 这段逻辑也可以放在 onDragStart 之后,或者在首次渲染时
        // 这里先做一次简单的检查
        this._ensureWithinBounds();
    }

    // 当元素从文档 DOM 中移除时调用
    disconnectedCallback() {
        // 移除事件监听器,防止内存泄漏
        this.removeEventListener('pointerdown', this.onDragStart);
        // 如果拖拽过程中元素被移除,也需要清理 document 上的监听器
        // (虽然 onDragEnd 通常会处理,但以防万一)
        this.ownerDocument.removeEventListener('pointermove', this.onDragging);
        this.ownerDocument.removeEventListener('pointerup', this.onDragEnd);
    }

    // --- 事件处理方法 ---

    /**
     * 处理拖拽开始事件 (pointerdown)
     * @param {PointerEvent} event - 指针事件对象
     */
    onDragStart = (event) => {
        // 阻止默认行为,例如文本选择或图片拖拽
        // event.preventDefault(); // 注意:有时阻止默认行为会干扰按钮等内部元素的点击

        // 检查是否是主按钮(通常是鼠标左键或单指触摸)
        if (event.button !== 0 && event.pointerType === 'mouse') {
            // console.log('Drag start ignored: not primary button');
            return;
        }

        this.isDragging = true;
        this.classList.add('dragging'); // 添加 CSS 类以改变样式 (如光标, z-index)

        // 获取元素的当前边界信息
        const rect = this.getBoundingClientRect();

        // 计算鼠标/触摸点相对于元素左上角的偏移量
        // event.clientX/Y: 鼠标相对于视口的坐标
        // rect.left/top: 元素左上角相对于视口的坐标
        this.offsetX = event.clientX - rect.left;
        this.offsetY = event.clientY - rect.top;

        // console.log(`Drag Start: OffsetX=${this.offsetX}, OffsetY=${this.offsetY}`);

        // 在 document 上添加移动和结束事件的监听器
        // 这样即使鼠标移出元素范围,也能继续跟踪
        // 使用 .bind(this) 或箭头函数确保 `this` 指向组件实例
        this.ownerDocument.addEventListener('pointermove', this.onDragging);
        this.ownerDocument.addEventListener('pointerup', this.onDragEnd);
        // 监听 pointercancel 以处理意外中断,例如系统对话框弹出
        this.ownerDocument.addEventListener('pointercancel', this.onDragEnd);
    }

    /**
     * 处理拖拽过程中的移动事件 (pointermove)
     * @param {PointerEvent} event - 指针事件对象
     */
    onDragging = (event) => {
        if (!this.isDragging) {
            return; // 如果没有开始拖拽,则不执行任何操作
        }

        // 阻止默认的拖拽行为(例如,在触摸设备上滚动页面)
        // event.preventDefault(); // 谨慎使用,可能阻止滚动

        // 计算元素的新理论位置 (相对于视口)
        // 新位置 = 当前鼠标/触摸位置 - 初始偏移量
        let newX = event.clientX - this.offsetX;
        let newY = event.clientY - this.offsetY;

        // console.log(`Dragging: ClientX=${event.clientX}, ClientY=${event.clientY}, NewX=${newX}, NewY=${newY}`);

        // --- 边界检测 ---
        const rect = this.getBoundingClientRect(); // 获取当前尺寸和位置
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;

        // 限制左边界
        if (newX < 0) {
            newX = 0;
        }
        // 限制上边界
        if (newY < 0) {
            newY = 0;
        }
        // 限制右边界
        // 元素的右边 (newX + width) 不能超过视口宽度
        if (newX + rect.width > viewportWidth) {
            newX = viewportWidth - rect.width;
            // 如果元素比视口还宽,确保左边至少是 0
            if (newX < 0) newX = 0;
        }
        // 限制下边界
        // 元素的下边 (newY + height) 不能超过视口高度
        if (newY + rect.height > viewportHeight) {
            newY = viewportHeight - rect.height;
            // 如果元素比视口还高,确保上边至少是 0
            if (newY < 0) newY = 0;
        }

        // 更新内部状态和元素的 style
        this.currentX = newX;
        this.currentY = newY;
        this.style.left = `${this.currentX}px`;
        this.style.top = `${this.currentY}px`;

        // console.log(`Position Updated: Left=${this.style.left}, Top=${this.style.top}`);
    }

    /**
     * 处理拖拽结束事件 (pointerup 或 pointercancel)
     * @param {PointerEvent} event - 指针事件对象
     */
    onDragEnd = (event) => {
        if (!this.isDragging) {
            return; // 如果没有开始拖拽,则不执行任何操作
        }

        this.isDragging = false;
        this.classList.remove('dragging'); // 移除拖拽状态的 CSS 类

        // 移除在 document 上添加的监听器
        this.ownerDocument.removeEventListener('pointermove', this.onDragging);
        this.ownerDocument.removeEventListener('pointerup', this.onDragEnd);
        this.ownerDocument.removeEventListener('pointercancel', this.onDragEnd);

        // console.log('Drag End');
    }

    /**
     * 内部辅助方法:确保元素在可视区域内
     * (可以在初始化或窗口大小改变时调用)
     */
    _ensureWithinBounds() {
        const rect = this.getBoundingClientRect();
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;

        let needsUpdate = false;
        let finalX = this.currentX;
        let finalY = this.currentY;

        // 检查并修正 X 坐标
        if (rect.left < 0) {
            finalX = 0;
            needsUpdate = true;
        } else if (rect.right > viewportWidth) {
            finalX = viewportWidth - rect.width;
            if (finalX < 0) finalX = 0; // 处理元素比视口宽的情况
            needsUpdate = true;
        }

        // 检查并修正 Y 坐标
        if (rect.top < 0) {
            finalY = 0;
            needsUpdate = true;
        } else if (rect.bottom > viewportHeight) {
            finalY = viewportHeight - rect.height;
             if (finalY < 0) finalY = 0; // 处理元素比视口高的情况
            needsUpdate = true;
        }

        // 如果需要更新位置
        if (needsUpdate) {
            this.currentX = finalX;
            this.currentY = finalY;
            this.style.left = `${this.currentX}px`;
            this.style.top = `${this.currentY}px`;
            // console.log(`Bounds Ensured: Left=${this.style.left}, Top=${this.style.top}`);
        }
    }

    // --- 可以在这里添加其他方法 ---
    // 例如,一个方法用于以编程方式移动元素
    // moveTo(x, y) {
    //     // 这里也应该包含边界检查
    //     this.currentX = x;
    //     this.currentY = y;
    //     this._ensureWithinBounds(); // 使用边界检查来设置最终位置
    // }

    // 例如,监听窗口大小变化,重新检查边界
    // connectedCallback() {
    //     ...
    //     window.addEventListener('resize', this._ensureWithinBounds);
    // }
    // disconnectedCallback() {
    //     ...
    //     window.removeEventListener('resize', this._ensureWithinBounds);
    // }
}

// --- 注册自定义元素 ---
// 定义自定义元素的标签名,必须包含一个连字符
const tagName = 'draggable-component';
if (!customElements.get(tagName)) {
    customElements.define(tagName, DraggableComponent);
    // console.log(`Custom element <${tagName}> defined.`);
} else {
    // console.warn(`Custom element <${tagName}> already defined.`);
}

// 导出类,如果需要在其他模块中导入(例如,用于测试或扩展)
// export default DraggableComponent; // 如果使用模块系统

2. 使用示例

a) 在纯 HTML 中使用

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Draggable Web Component 示例</title>
    <style>
        body {
            font-family: sans-serif;
            height: 200vh; /* 使页面可滚动,测试 fixed 定位(如果使用)和边界 */
            position: relative; /* 让 absolute 定位的子元素相对于 body */
            margin: 0;
            padding: 20px;
            box-sizing: border-box;
        }
        h1 {
            text-align: center;
        }
        /* 可以为特定的拖拽组件实例设置不同的初始位置和层级 */
        #drag1 {
            --initial-top: 50px;
            --initial-left: 50px;
            --z-index: 101;
            background-color: lightblue;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
        }
        #drag2 {
            --initial-top: 200px;
            --initial-left: 150px;
            --z-index: 102; /* 比 drag1 高 */
            --dragging-z-index: 1001; /* 拖拽时更高 */
            background-color: lightcoral;
            padding: 15px;
            border: 2px solid darkred;
        }
        #drag2 button {
            margin-top: 10px;
            padding: 5px 10px;
        }
    </style>
</head>
<body>

    <h1>拖拽 Web Component 示例</h1>
    <p>尝试拖拽下面的方块。它们会被限制在窗口可视区域内。</p>

    <!-- 引入 Web Component 的 JavaScript 文件 -->
    <script src="draggable-component.js"></script>

    <!-- 使用自定义元素 -->
    <draggable-component id="drag1">
        <h2>可拖拽内容 1</h2>
        <p>这里可以放任何 HTML 内容。</p>
        <img src="https://via.placeholder.com/100" alt="Placeholder">
    </draggable-component>

    <draggable-component id="drag2">
        <h3>可拖拽内容 2</h3>
        <p>包含一个按钮:</p>
        <button onclick="alert('按钮被点击了!')">点我</button>
        <p>即使有按钮,整个组件仍然可以拖拽。</p>
    </draggable-component>

    <div style="margin-top: 400px; height: 100px; background: #eee;">
        一些其他内容,用于测试层级和滚动。
    </div>

</body>
</html>

b) 在 Vue 中使用

Vue 3 对 Web Components 有良好的支持。

vue 复制代码
<template>
  <div id="app">
    <h1>Vue 中的 Draggable Web Component</h1>

    <!-- 确保 JS 文件已被加载 -->
    <!-- 你可以在 main.js 中 import './path/to/draggable-component.js'; -->
    <!-- 或者在 public/index.html 中通过 <script> 标签加载 -->

    <draggable-component id="vue-drag1" style="--initial-top: 80px; --initial-left: 300px; background-color: lightgreen; padding: 25px;">
      <h4>来自 Vue 的可拖拽内容</h4>
      <p>计数器: {{ count }}</p>
      <button @click="count++">增加</button>
    </draggable-component>

    <draggable-component id="vue-drag2" :style="drag2Style">
       <h5>另一个 Vue 拖拽块</h5>
       <input type="text" placeholder="输入一些文字...">
    </draggable-component>

  </div>
</template>

<script>
import { ref, computed } from 'vue';
// 如果 draggable-component.js 没有默认导出或副作用导入,确保它在某处被执行以注册组件
// 例如,在 main.js 中: import './components/draggable-component.js';

export default {
  name: 'App',
  setup() {
    const count = ref(0);

    // 动态设置样式
    const drag2Style = computed(() => ({
      '--initial-top': '250px',
      '--initial-left': '50px',
      '--z-index': 105,
      backgroundColor: 'lightgoldenrodyellow',
      padding: '20px',
      border: '1px solid orange'
    }));

    return {
      count,
      drag2Style
    };
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  padding: 20px;
}
/* 可以添加一些全局样式或针对特定 id 的样式 */
#vue-drag1 {
  border-radius: 10px;
}
</style>

注意 : 在 Vue (或 React) 项目中,你需要确保 draggable-component.js 文件被执行一次,以便 customElements.define 被调用。通常这可以在你的主入口文件(如 main.jsindex.js)中通过 import './path/to/draggable-component.js'; 来完成。

c) 在 React 中使用

React 对 Web Components 的支持也很好,但需要注意属性传递(尤其是对于非字符串属性,不过我们这个组件主要通过 CSS 变量和 slot,比较简单)。

jsx 复制代码
import React, { useState, useEffect } from 'react';
import './App.css';
// 确保 Web Component 脚本被执行以注册组件
// import './path/to/draggable-component.js'; // 在 index.js 或 App.js 顶部导入

function App() {
  const [inputValue, setInputValue] = useState('');

  // 确保组件脚本只加载一次
  useEffect(() => {
    // 如果脚本是动态加载或有副作用,可以在这里处理
    // 如果已经在 index.js 中导入,这里就不需要了
    // import('./path/to/draggable-component.js');
  }, []);

  const drag2Style = {
    '--initial-top': '150px',
    '--initial-left': '400px',
    '--z-index': 110,
    backgroundColor: 'lightblue',
    padding: '20px',
    border: '1px dashed blue'
  };

  return (
    <div className="App">
      <h1>React 中的 Draggable Web Component</h1>

      {/* React 会将 style 对象转换为内联样式 */}
      {/* 对于 CSS 变量,可以直接在 style 对象中设置 */}
      <draggable-component id="react-drag1" style={drag2Style}>
        <h4>来自 React 的可拖拽内容</h4>
        <p>这是一个输入框:</p>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Type something..."
          // 阻止输入框的 mousedown 事件冒泡到 draggable-component,
          // 否则可能导致无法聚焦输入框就开始拖拽
          onPointerDown={(e) => e.stopPropagation()}
        />
        <p>输入的值: {inputValue}</p>
      </draggable-component>

      {/* 也可以直接写 style 字符串,但不推荐 */}
      <draggable-component id="react-drag2" style={{ '--initial-top': '350px', '--initial-left': '100px', backgroundColor: 'pink', padding: '15px' }}>
        <h5>另一个 React 拖拽块</h5>
        <button
           onClick={() => alert('React Button Clicked!')}
           // 同理,阻止按钮事件冒泡以防意外拖拽
           onPointerDown={(e) => e.stopPropagation()}
        >
           React 按钮
        </button>
      </draggable-component>
    </div>
  );
}

export default App;

React 注意事项:

  • 事件处理 : 对于 Web Component 内部触发的标准 DOM 事件,React 的合成事件系统可能无法直接捕获。如果需要监听 Web Component 的自定义事件,需要手动添加和移除事件监听器(使用 useRefuseEffect)。对于我们这个例子,拖拽逻辑是内部封装的,不需要外部监听。
  • 内部元素交互 : 正如 React 示例中 inputbuttononPointerDown={(e) => e.stopPropagation()} 所示,有时需要阻止内部可交互元素的 pointerdown 事件冒泡,否则点击输入框或按钮可能会意外触发组件的拖拽行为。这是 Web Component 与 React 事件系统交互时的一个常见细节。

3. 详细代码讲解 (draggable-component.js)

下面是 draggable-element.js 文件的详细实现和讲解。

javascript 复制代码
// 定义 DraggableElement 类,继承自 HTMLElement
class DraggableElement extends HTMLElement {
    // 构造函数,在元素实例创建时调用
    constructor() {
        // 必须首先调用 super()
        super();

        // 创建 Shadow DOM,用于封装组件内部结构和样式
        // mode: 'open' 允许外部 JavaScript 通过 element.shadowRoot 访问 Shadow DOM
        this.attachShadow({ mode: 'open' });

        // 初始化内部状态变量
        this.isDragging = false; // 标记是否正在拖拽
        this.offsetX = 0; // 鼠标按下时,鼠标指针相对于元素左上角的 X 轴偏移量
        this.offsetY = 0; // 鼠标按下时,鼠标指针相对于元素左上角的 Y 轴偏移量
        this.currentX = 0; // 元素当前的 X 轴坐标 (相对于视口)
        this.currentY = 0; // 元素当前的 Y 轴坐标 (相对于视口)

        // 创建 Shadow DOM 的内部结构
        // 使用 <slot> 元素允许用户将任意内容插入到组件中
        // 使用 <div> 作为容器,方便应用样式和定位
        this.shadowRoot.innerHTML = `
            <style>
                /* 定义容器的基本样式 */
                .draggable-container {
                    /* 使用固定定位,使元素相对于视口定位,脱离文档流 */
                    position: fixed;
                    /* 设置一个较高的 z-index,确保元素显示在其他内容上层 */
                    z-index: 1000;
                    /* 鼠标指针样式,提示用户该元素可拖拽 */
                    cursor: grab;
                    /* 平滑过渡效果,用于位置变化 */
                    transition: box-shadow 0.2s ease-in-out;
                    /* 用户无法选中元素内的文本,优化拖拽体验 */
                    user-select: none;
                    -webkit-user-select: none; /* 兼容 Safari */
                    -moz-user-select: none; /* 兼容 Firefox */
                    -ms-user-select: none; /* 兼容 IE/Edge */
                    /* 初始位置,可以根据需要调整或通过属性设置 */
                    left: 0px;
                    top: 0px;
                    /* 添加一个细边框,方便看到元素边界 */
                    /* border: 1px dashed #ccc; */
                    /* 触摸操作优化 */
                    touch-action: none; /* 防止在触摸设备上滚动页面 */
                }
                /* 正在拖拽时的样式 */
                .draggable-container.dragging {
                    /* 改变鼠标指针样式,表示正在拖拽 */
                    cursor: grabbing;
                    /* 添加阴影效果,提升视觉反馈 */
                    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
                }
            </style>
            <div class="draggable-container">
                <!-- slot 元素是 Web Components 的一个核心特性 -->
                <!-- 它允许父文档中的 Light DOM 节点被渲染到 Shadow DOM 的指定位置 -->
                <!-- 这里未命名 slot 会接收所有未分配给具名 slot 的子节点 -->
                <slot></slot>
            </div>
        `;

        // 获取 Shadow DOM 中的容器元素
        this.container = this.shadowRoot.querySelector('.draggable-container');

        // --- 事件处理方法绑定 ---
        // 为了在事件监听器中正确访问 'this' (指向 DraggableElement 实例),
        // 需要将这些方法绑定到当前实例。否则,在事件回调中 'this' 会指向触发事件的元素 (或 window)。
        this.onMouseDown = this.onMouseDown.bind(this);
        this.onMouseMove = this.onMouseMove.bind(this);
        this.onMouseUp = this.onMouseUp.bind(this);
        // 触摸事件绑定
        this.onTouchStart = this.onTouchStart.bind(this);
        this.onTouchMove = this.onTouchMove.bind(this);
        this.onTouchEnd = this.onTouchEnd.bind(this);
    }

    // --- 生命周期回调 ---

    // connectedCallback: 当元素首次被插入到文档 DOM 时调用
    connectedCallback() {
        console.log('Draggable element connected to DOM.');
        // 添加鼠标按下事件监听器到容器元素
        // 使用 { passive: false } 确保在触摸事件中可以调用 preventDefault (如果需要的话)
        this.container.addEventListener('mousedown', this.onMouseDown, { passive: false });
        // 添加触摸开始事件监听器
        this.container.addEventListener('touchstart', this.onTouchStart, { passive: false });

        // 初始化元素位置
        // 尝试从元素的 style 属性读取初始位置
        // 如果没有设置,则默认为 (0, 0)
        const initialRect = this.getBoundingClientRect(); // 获取元素在视口中的初始位置和尺寸
        this.currentX = initialRect.left;
        this.currentY = initialRect.top;

        // 将初始位置应用到容器的 style 上
        // 注意:我们操作的是 Shadow DOM 内部的 container,而不是 host 元素 (this)
        this.container.style.left = `${this.currentX}px`;
        this.container.style.top = `${this.currentY}px`;

        // 确保初始位置在屏幕内 (如果初始位置通过 CSS 设置在屏幕外)
        this.ensureInBounds();
    }

    // disconnectedCallback: 当元素从文档 DOM 中移除时调用
    disconnectedCallback() {
        console.log('Draggable element disconnected from DOM.');
        // 移除事件监听器,防止内存泄漏
        this.container.removeEventListener('mousedown', this.onMouseDown);
        // 全局监听器也需要移除 (如果在 onMouseUp/onTouchEnd 中没有移除的话)
        // 但最佳实践是在 onMouseUp/onTouchEnd 中移除 mousemove/mouseup/touchmove/touchend
        window.removeEventListener('mousemove', this.onMouseMove);
        window.removeEventListener('mouseup', this.onMouseUp);
        // 移除触摸事件监听器
        this.container.removeEventListener('touchstart', this.onTouchStart);
        window.removeEventListener('touchmove', this.onTouchMove);
        window.removeEventListener('touchend', this.onTouchEnd);
        window.removeEventListener('touchcancel', this.onTouchEnd); // 也处理触摸取消事件
    }

    // --- 事件处理方法 ---

    // 鼠标按下事件处理
    onMouseDown(event) {
        // 阻止默认行为,例如文本选择或图片拖拽
        event.preventDefault();
        // 检查是否是鼠标左键按下 (event.button === 0)
        if (event.button !== 0) {
            return;
        }

        this.startDragging(event.clientX, event.clientY);
        // 在 window 上添加 mousemove 和 mouseup 监听器
        // 这样即使鼠标移出元素范围,拖拽也能继续
        window.addEventListener('mousemove', this.onMouseMove, { passive: false });
        window.addEventListener('mouseup', this.onMouseUp, { passive: false });
    }

    // 触摸开始事件处理
    onTouchStart(event) {
        // 阻止默认的触摸行为,如滚动
        // event.preventDefault(); // 注意:有时阻止默认行为可能会干扰其他触摸交互,根据需要启用

        // 处理单点触摸
        if (event.touches.length === 1) {
            const touch = event.touches[0];
            this.startDragging(touch.clientX, touch.clientY);
            // 在 window 上添加 touchmove 和 touchend 监听器
            window.addEventListener('touchmove', this.onTouchMove, { passive: false });
            window.addEventListener('touchend', this.onTouchEnd, { passive: false });
            window.addEventListener('touchcancel', this.onTouchEnd, { passive: false }); // 处理触摸取消
        }
    }

    // 开始拖拽的通用逻辑 (由 onMouseDown 和 onTouchStart 调用)
    startDragging(clientX, clientY) {
        this.isDragging = true;
        // 添加拖拽中的视觉反馈样式
        this.container.classList.add('dragging');
        // 改变鼠标样式为 grabbing
        this.container.style.cursor = 'grabbing';

        // 计算鼠标点击位置相对于元素左上角的偏移量
        // clientX/Y: 鼠标指针相对于浏览器视口左上角的坐标
        // this.currentX/Y: 元素容器当前相对于视口左上角的坐标
        this.offsetX = clientX - this.currentX;
        this.offsetY = clientY - this.currentY;

        console.log(`Dragging started at (${clientX}, ${clientY}), offset (${this.offsetX}, ${this.offsetY})`);
    }


    // 鼠标移动事件处理
    onMouseMove(event) {
        // 阻止默认行为
        event.preventDefault();
        this.handleMove(event.clientX, event.clientY);
    }

    // 触摸移动事件处理
    onTouchMove(event) {
        // 阻止默认行为,如页面滚动
        event.preventDefault();

        // 处理单点触摸移动
        if (event.touches.length === 1) {
            const touch = event.touches[0];
            this.handleMove(touch.clientX, touch.clientY);
        }
    }

    // 处理移动的通用逻辑 (由 onMouseMove 和 onTouchMove 调用)
    handleMove(clientX, clientY) {
        // 如果没有处于拖拽状态,则不执行任何操作
        if (!this.isDragging) {
            return;
        }

        // --- 计算元素的新位置 ---
        // 新的 X 坐标 = 当前鼠标 X 坐标 - 鼠标按下时的 X 轴偏移量
        let newX = clientX - this.offsetX;
        // 新的 Y 坐标 = 当前鼠标 Y 坐标 - 鼠标按下时的 Y 轴偏移量
        let newY = clientY - this.offsetY;

        // --- 边界检测 ---
        // 获取视口的尺寸
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;
        // 获取元素容器的尺寸 (offsetWidth/Height 包含边框和内边距)
        const elementWidth = this.container.offsetWidth;
        const elementHeight = this.container.offsetHeight;

        // 限制左边界:元素的左边不能超过视口的左边 (0)
        if (newX < 0) {
            newX = 0;
        }
        // 限制上边界:元素的上边不能超过视口的上边 (0)
        if (newY < 0) {
            newY = 0;
        }
        // 限制右边界:元素的右边 (newX + elementWidth) 不能超过视口的右边 (viewportWidth)
        if (newX + elementWidth > viewportWidth) {
            // 如果超过,将元素的左边定位在 (viewportWidth - elementWidth)
            newX = viewportWidth - elementWidth;
            // 处理元素比视口宽的情况
            if (newX < 0) newX = 0;
        }
        // 限制下边界:元素的下边 (newY + elementHeight) 不能超过视口的下边 (viewportHeight)
        if (newY + elementHeight > viewportHeight) {
            // 如果超过,将元素的上边定位在 (viewportHeight - elementHeight)
            newY = viewportHeight - elementHeight;
            // 处理元素比视口高的情况
            if (newY < 0) newY = 0;
        }

        // --- 更新元素位置 ---
        // 更新内部状态变量
        this.currentX = newX;
        this.currentY = newY;
        // 应用新的坐标到容器的 style 上
        // 使用 transform: translate() 性能通常比直接修改 left/top 好,
        // 但为了边界计算简单,这里继续使用 left/top。
        // 如果追求极致性能,可以考虑 translate 并相应调整边界计算。
        this.container.style.left = `${this.currentX}px`;
        this.container.style.top = `${this.currentY}px`;

        // console.log(`Dragging to (${this.currentX}, ${this.currentY})`); // 频繁输出会影响性能
    }

    // 鼠标松开事件处理
    onMouseUp(event) {
        // 阻止默认行为
        event.preventDefault();
        this.stopDragging();
        // 移除在 window 上添加的事件监听器
        window.removeEventListener('mousemove', this.onMouseMove);
        window.removeEventListener('mouseup', this.onMouseUp);
    }

    // 触摸结束/取消事件处理
    onTouchEnd(event) {
        // 阻止默认行为
        // event.preventDefault(); // 可能不需要
        this.stopDragging();
        // 移除在 window 上添加的事件监听器
        window.removeEventListener('touchmove', this.onTouchMove);
        window.removeEventListener('touchend', this.onTouchEnd);
        window.removeEventListener('touchcancel', this.onTouchEnd);
    }

    // 停止拖拽的通用逻辑 (由 onMouseUp 和 onTouchEnd 调用)
    stopDragging() {
        if (this.isDragging) {
            this.isDragging = false;
            // 移除拖拽中的视觉反馈样式
            this.container.classList.remove('dragging');
            // 恢复鼠标指针样式
            this.container.style.cursor = 'grab';
            console.log(`Dragging stopped at (${this.currentX}, ${this.currentY})`);
        }
    }

    // --- 辅助方法 ---

    // 确保元素在视口边界内
    ensureInBounds() {
        // 获取当前视口和元素尺寸
        const viewportWidth = window.innerWidth;
        const viewportHeight = window.innerHeight;
        const elementWidth = this.container.offsetWidth;
        const elementHeight = this.container.offsetHeight;

        let correctedX = this.currentX;
        let correctedY = this.currentY;
        let needsCorrection = false;

        // 检查并修正 X 轴位置
        if (correctedX < 0) {
            correctedX = 0;
            needsCorrection = true;
        } else if (correctedX + elementWidth > viewportWidth) {
            correctedX = Math.max(0, viewportWidth - elementWidth); // 处理元素比视口宽的情况
            needsCorrection = true;
        }

        // 检查并修正 Y 轴位置
        if (correctedY < 0) {
            correctedY = 0;
            needsCorrection = true;
        } else if (correctedY + elementHeight > viewportHeight) {
            correctedY = Math.max(0, viewportHeight - elementHeight); // 处理元素比视口高的情况
            needsCorrection = true;
        }

        // 如果位置需要修正,则更新状态和样式
        if (needsCorrection) {
            console.log(`Position corrected to stay in bounds: (${correctedX}, ${correctedY})`);
            this.currentX = correctedX;
            this.currentY = correctedY;
            this.container.style.left = `${this.currentX}px`;
            this.container.style.top = `${this.currentY}px`;
        }
    }

    // --- 属性处理 (可选) ---
    // 如果需要通过 HTML 属性配置初始位置或行为,可以在这里添加
    // static get observedAttributes() { return ['initial-x', 'initial-y']; }
    // attributeChangedCallback(name, oldValue, newValue) { ... }

} // 类定义结束

// --- 注册自定义元素 ---
// 将 DraggableElement 类与自定义标签名 'draggable-element' 关联起来
// 标签名必须包含一个连字符 (-)
// window.customElements.get() 可以用来检查是否已注册
if (!window.customElements.get('draggable-element')) {
    window.customElements.define('draggable-element', DraggableElement);
    console.log('Custom element "draggable-element" defined.');
} else {
    console.warn('"draggable-element" is already defined.');
}

// --- 导出 (如果使用模块) ---
// export default DraggableElement; // 如果在模块化环境中使用

代码讲解:

  1. 类定义 (class DraggableElement extends HTMLElement):

    • 我们创建了一个名为 DraggableElement 的类,它继承自 HTMLElement,这是所有 HTML 元素接口的基类。这使得我们的类能够表现得像一个标准的 HTML 元素。
  2. 构造函数 (constructor):

    • super(): 必须首先调用父类的构造函数。
    • this.attachShadow({ mode: 'open' }): 创建一个 Shadow DOM。Shadow DOM 提供了封装,将组件的内部结构和样式与主文档隔离。mode: 'open' 意味着可以通过组件实例的 shadowRoot 属性从外部 JavaScript 访问 Shadow DOM。
    • 状态变量初始化 :
      • isDragging: 布尔值,标记当前是否正在拖拽元素。
      • offsetX, offsetY: 数字,存储鼠标按下时,鼠标指针位置与元素左上角之间的距离。这确保了拖拽时元素不会突然跳到鼠标指针的位置,而是保持相对位置。
      • currentX, currentY: 数字,存储元素容器当前在视口中的 lefttop 坐标。
    • Shadow DOM 结构 :
      • <style>: 定义了组件内部的 CSS 样式。
        • .draggable-container: 设置了 position: fixed 使元素相对于视口定位,z-index 确保它在顶层,cursor: grab 提供可拖拽的视觉提示,user-select: none 防止拖拽时选中内部文本。touch-action: none 阻止触摸设备上的默认滚动行为。
        • .draggable-container.dragging: 定义了正在拖拽时的样式,如 cursor: grabbingbox-shadow,提供视觉反馈。
      • <div class="draggable-container">: 这是实际进行定位和应用样式的容器。
      • <slot></slot>: 这是 Web Components 的关键部分。它是一个占位符,允许使用者将任何 HTML 内容(Light DOM)插入到这个 Web Component 中,这些内容会被渲染在 <slot> 所在的位置。
    • this.container: 获取对 Shadow DOM 中 .draggable-container div 的引用,方便后续操作。
    • 事件处理方法绑定 : 使用 bind(this) 将事件处理函数(如 onMouseDown)的 this 上下文永久绑定到 DraggableElement 实例。这在将方法作为事件监听器传递时至关重要。
  3. 生命周期回调 (connectedCallback, disconnectedCallback):

    • connectedCallback: 当元素被添加到 DOM 时触发。
      • 添加 mousedowntouchstart 事件监听器到内部的 container 元素上。拖拽的起点是用户与这个容器交互。
      • 获取元素的初始位置 (getBoundingClientRect) 并存储到 currentX, currentY
      • 将初始位置应用到 containerstyle.leftstyle.top
      • 调用 ensureInBounds() 确保初始位置(可能由外部 CSS 设置)在屏幕内。
    • disconnectedCallback: 当元素从 DOM 中移除时触发。
      • 移除所有添加的事件监听器(包括在 window 上添加的 mousemove/mouseup/touchmove/touchend),以防止内存泄漏。
  4. 事件处理方法 (onMouseDown, onTouchStart, onMouseMove, onTouchMove, onMouseUp, onTouchEnd):

    • onMouseDown/onTouchStart:
      • 阻止事件的默认行为(如文本选择、默认触摸滚动)。
      • 记录拖拽状态 (isDragging = true)。
      • 计算 offsetXoffsetY
      • window 对象上添加 mousemove/touchmovemouseup/touchend 监听器。监听 window 确保了即使鼠标/手指移出元素区域,拖拽也能继续进行,并在任何地方松开都能停止拖拽。
      • 调用 startDragging 处理通用逻辑。
    • startDragging:
      • 设置 isDragging 标志。
      • 添加 .dragging 类以应用拖拽样式。
      • 计算 offsetX, offsetY
    • onMouseMove/onTouchMove:
      • 阻止默认行为。
      • 如果 isDraggingtrue,则调用 handleMove
    • handleMove:
      • 计算基于当前鼠标/触摸位置和初始偏移量的新坐标 (newX, newY)。
      • 边界检测 : 获取视口 (window.innerWidth/Height) 和元素 (container.offsetWidth/Height) 的尺寸。检查 newX, newY 是否会导致元素超出视口边界。如果超出,则将坐标调整为边界值。特别处理了元素尺寸大于视口尺寸的情况(通过 Math.max(0, ...) 确保坐标不为负)。
      • 更新 currentX, currentY 状态。
      • 将新的 currentX, currentY 应用到 containerstyle.leftstyle.top,移动元素。
    • onMouseUp/onTouchEnd:
      • 阻止默认行为。
      • 调用 stopDragging 处理通用逻辑。
      • 关键 : 移除在 window 上添加的 mousemove/touchmovemouseup/touchend 监听器。这是必要的清理步骤。
    • stopDragging:
      • 设置 isDragging = false
      • 移除 .dragging 类。
      • 恢复默认的 grab 光标。
  5. 辅助方法 (ensureInBounds):

    • 一个独立的函数,用于检查当前 currentX, currentY 是否使元素保持在视口内,并在必要时进行修正。这在 connectedCallback 中用于处理初始位置,也可以在窗口大小改变时调用(如果需要响应式调整)。
  6. 自定义元素注册 (customElements.define):

    • window.customElements.define('draggable-element', DraggableElement); 这行代码将 DraggableElement 类注册到浏览器,并将其与 HTML 标签名 draggable-element 关联起来。之后,就可以在 HTML 中像使用 <p>, <div> 一样使用 <draggable-element> 了。
    • 使用 window.customElements.get() 检查是为了防止重复注册引发错误。

如何在 Vue 和 React 中使用:

由于这是一个标准的 Web Component,你可以在任何支持 Web Components 的框架(包括 Vue 和 React)中直接使用它,就像使用普通的 HTML 标签一样。

Vue 示例:

vue 复制代码
<template>
  <div>
    <h1>Vue App</h1>
    <draggable-element style="top: 50px; left: 50px;">
      <div class="content-box">
        <h2>Drag Me (Vue)</h2>
        <p>This is content inside the draggable element.</p>
        <button @click="vueClickHandler">Vue Button</button>
      </div>
    </draggable-element>
    <!-- 确保 draggable-element.js 已被加载 -->
  </div>
</template>

<script>
// 确保 draggable-element.js 在某处被加载,例如在 main.js 或 index.html
// import './path/to/draggable-element.js'; // 如果使用构建工具

export default {
  name: 'App',
  methods: {
    vueClickHandler() {
      alert('Vue button clicked!');
    }
  },
  // 告诉 Vue 忽略自定义元素 draggable-element
  compilerOptions: {
    isCustomElement: tag => tag === 'draggable-element'
  } // Vue 3 配置方式
  // Vue 2: Vue.config.ignoredElements = ['draggable-element']; (在 main.js 中设置)
}
</script>

<style scoped>
.content-box {
  background-color: lightblue;
  padding: 20px;
  border: 1px solid blue;
  min-width: 150px; /* 给内容一个最小宽度 */
}
h1 {
  text-align: center;
}
</style>

React 示例:

jsx 复制代码
import React from 'react';
// 确保 draggable-element.js 在某处被加载,例如在 index.js 或 index.html
// import './path/to/draggable-element.js'; // 如果使用构建工具

function App() {
  const reactClickHandler = () => {
    alert('React button clicked!');
  };

  return (
    <div>
      <h1 style={{ textAlign: 'center' }}>React App</h1>
      {/* React 会将 style 转换为字符串或处理对象 */}
      <draggable-element style={{ top: '150px', left: '150px' }}>
        <div style={{ backgroundColor: 'lightcoral', padding: '20px', border: '1px solid red', minWidth: '150px' }}>
          <h2>Drag Me (React)</h2>
          <p>This is content inside the draggable element.</p>
          {/* 事件处理仍然可以在内部元素上正常工作 */}
          <button onClick={reactClickHandler}>React Button</button>
        </div>
      </draggable-element>
    </div>
  );
}

export default App;

关键点总结:

  • 封装: Shadow DOM 提供了样式和结构的封装。
  • 内容投射 : <slot> 允许将任意用户提供的 HTML (Light DOM) 渲染到组件内部。
  • 定位 : 使用 position: fixed 相对于视口定位。
  • 事件处理 : 通过 mousedown/touchstart, mousemove/touchmove, mouseup/touchend 实现拖拽逻辑。
  • 坐标计算: 精确计算偏移量和新位置。
  • 边界检测: 确保元素始终在视口内。
  • 跨框架兼容: 作为标准 Web Component,无需额外包装即可在 Vue、React、Angular 或原生 JS 中使用。
  • 清理 : 在 disconnectedCallbackonMouseUp/onTouchEnd 中移除事件监听器至关重要,以避免内存泄漏。
  • 触摸支持 : 添加了 touchstart, touchmove, touchend 事件处理,使其在移动设备上也能工作。
相关推荐
leluckys1 分钟前
flutter 专题 六十三 Flutter入门与实战作者:xiangzhihong8Fluter 应用调试
前端·javascript·flutter
kidding72315 分钟前
微信小程序怎么分包步骤(包括怎么主包跳转到分包)
前端·微信小程序·前端开发·分包·wx.navigateto·subpackages
微学AI30 分钟前
详细介绍:MCP(大模型上下文协议)的架构与组件,以及MCP的开发实践
前端·人工智能·深度学习·架构·llm·mcp
liangshanbo12151 小时前
CSS 包含块
前端·css
Mitchell_C1 小时前
语义化 HTML (Semantic HTML)
前端·html
倒霉男孩1 小时前
CSS文本属性
前端·css
晚风3081 小时前
前端
前端
JiangJiang1 小时前
🚀 Vue 人如何玩转 React 自定义 Hook?从 Mixins 到 Hook 的华丽转身
前端·react.js·面试
鱼樱前端1 小时前
让人头痛的原型和原型链知识
前端·javascript
用户19727304821961 小时前
传说中的开发增效神器-Trae,让我在开发智能旅拍小程序节省40%时间
前端