table表格鼠标圈选数据并复制且自带html结构

发布于:2025-05-24 ⋅ 阅读:(19) ⋅ 点赞:(0)

需求背景

想要复制浏览器网页中的table表格数据,用鼠标跨行圈选由于浏览器默认用户行为会将整行数据全部圈选,例如图所示:
浏览器圈选数据效果
实际上我们想圈选的是4.15、-1.3、3.97、-1.15四个数据;同时如果在文档中创建一个空白表格,将刚才复制的数据在最后一个格里粘贴,如图所示:
文档创建一个表格
还会发现,刚才浏览器复制的数据,在粘贴的一瞬间以表格数据的形式自动填充,说明不仅仅是复制了文本字符串这么简单,还包括了html结构,如图所示:
将刚才复制的数据粘贴到文档创建的表格
但是将数据粘贴在浏览器控制台会发现它仅仅以文本数字的形式展示:
控制台粘贴复制内容
那么,我们将要实现的效果和附加功能到这里很清晰明了。

初步实现

这里我使用的是react(v17.0.2)+antd(v4.16.7),记住这个antd版本号,有个小坑,烧掉我一把头发。以下只展示核心逻辑,切勿复制代码无脑使用,那在你的项目中是绝对不会直接出效果滴!!!

js代码

import React, { Component } from 'react';
import { Table, Empty } from 'antd';
import '../scss/tabel.scss';
import { FullscreenOutlined, FullscreenExitOutlined, EllipsisOutlined, CaretUpFilled, CaretDownFilled } from '@ant-design/icons';
import { Select, DatePicker, Dropdown, Divider, Button } from 'antd';
import { connect } from 'react-redux';
import { mapDispatchToProps, mapStateToProps } from '../../../redux/assistant';

class tabel extends Component {
  constructor(props) {
    super(props);
    this.state = {
		tabel_columns:[]
		tabel_data:[]
	};
    this.originDir = {
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
    };
  }

  componentDidMount() {
  	// cv复制所以需要开启监听单独去处理我们自己想要的操作
    document.addEventListener('keydown', this.clickSave); // 监听键盘复制
    document.addEventListener('mousedown', (e) => {
      if (e.target?.parentElement.className.includes('ant-table')) return;
      const nodes = document.querySelectorAll('.ant-table-cell');
      nodes.length > 0 &&
        nodes.forEach((item) => {
          this.clearStyle(item);
        });
    });
  }

  componentWillUnmount() {
    // 移除键盘按下事件监听器
    document.removeEventListener('keydown', this.clickSave);
  }

  // 鼠标点击
  onMouseDown = (event) => {
    const contextMenu = document.getElementById("contextMenu");
    contextMenu.style.display = "none";
    if (event.button !== 0) return;
    const rect = event.target.getBoundingClientRect();
    this.originDir = {
      top: rect.top,
      left: rect.left,
      right: rect.right,
      bottom: rect.bottom,
    };
    this.renderNodes(this.originDir);
  };

  // 鼠标移动
  onMouseMove = (event) => {
    if (!this.originDir.top) return;
    const rect = event.target.getBoundingClientRect();
    let coordinates = {};

    // 1、圈选元素在中心位置左上方
    if (
      rect.top <= this.originDir.top &&
      rect.left <= this.originDir.left &&
      rect.right <= this.originDir.left &&
      rect.bottom <= this.originDir.top
    ) {
      coordinates = {
        top: rect.top,
        left: rect.left,
        right: this.originDir.right,
        bottom: this.originDir.bottom,
      };
    }
    // 圈选元素在中心位置右上方
    if (
      rect.top <= this.originDir.top &&
      rect.left >= this.originDir.left &&
      rect.right >= this.originDir.right &&
      rect.bottom <= this.originDir.bottom
    ) {
      coordinates = {
        top: rect.top,
        left: this.originDir.left,
        right: rect.right,
        bottom: this.originDir.bottom,
      };
    }

    // 圈选元素在中心位置左下方
    if (
      rect.top >= this.originDir.top &&
      rect.left <= this.originDir.left &&
      rect.right <= this.originDir.right &&
      rect.bottom >= this.originDir.bottom
    ) {
      coordinates = {
        top: this.originDir.top,
        left: rect.left,
        right: this.originDir.right,
        bottom: rect.bottom,
      };
    }

    // 圈选元素在中心位置右下方
    if (
      rect.top >= this.originDir.top &&
      rect.left >= this.originDir.left &&
      rect.right >= this.originDir.right &&
      rect.bottom >= this.originDir.bottom
    ) {
      coordinates = {
        top: this.originDir.top,
        left: this.originDir.left,
        right: rect.right,
        bottom: rect.bottom,
      };
    }

    this.renderNodes(coordinates);
  };
  // 圈选状态
   renderNodes = (coordinates) => {
    const nodes = document.querySelectorAll(".ant-table-cell");
    
    nodes.forEach((item) => {
      const target = item?.getBoundingClientRect();
      this.clearStyle(item);
      if (
        target?.top >= coordinates.top-1 &&
        target?.right <= coordinates.right+1 &&
        target?.left >= coordinates.left-1 &&
        target?.bottom <= coordinates.bottom+1
      ) {
        item.setAttribute("data-brush", "true");
        if (target.top === coordinates.top-1) {
          item.setAttribute("brush-border-top", "true");
        }
        if (target.right === coordinates.right+1) {
          item.setAttribute("brush-border-right", "true");
        }
        if (target.left === coordinates.left-1) {
          item.setAttribute("brush-border-left", "true");
        }
        if (target.bottom === coordinates.bottom+1) {
          item.setAttribute("brush-border-bottom", "true");
        }
      }
    });
  };
  // 清除样式
  clearStyle = (item) => {
    item.hasAttribute("data-brush") && item.removeAttribute("data-brush");
    item.hasAttribute("brush-border-top") &&
      item.removeAttribute("brush-border-top");
    item.hasAttribute("brush-border-right") &&
      item.removeAttribute("brush-border-right");
    item.hasAttribute("brush-border-left") &&
      item.removeAttribute("brush-border-left");
    item.hasAttribute("brush-border-bottom") &&
      item.removeAttribute("brush-border-bottom");
  };

  // 鼠标抬起
  onMouseUp = () => {
    this.originDir = {};
  };
  // 鼠标右击
  onContextMenu = (event) => {
    event.preventDefault(); // 阻止默认右键菜单弹出
  };
  // 复制逻辑
  onClickCopy = () => {
    const contextMenu = document.getElementById("contextMenu");
    const copyableElements = document.querySelectorAll("[data-brush=true]");

    // 遍历保存文本
    let copiedContent = "";
    copyableElements.forEach((element, index) => {
      let separator = " ";
      if (index < copyableElements.length - 1) {
        const next = copyableElements?.[index + 1];
        if (
          next?.getBoundingClientRect().top !==
          element.getBoundingClientRect().top
        ) {
          separator = "\n";
        }
      }
      copiedContent += `${element.firstChild.innerText}${separator}`;
    });

    // 执行复制操作
    navigator.clipboard
      .writeText(copiedContent)
      .then(() => {
        console.log("已复制内容:", copiedContent);
      })
      .catch((error) => {
        console.error("复制失败:", error);
      });

    // 隐藏上下文菜单
    contextMenu.style.display = "none";
  };
  // 点击复制
  clickSave = (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
      this.onClickCopy();
      event.preventDefault(); // 阻止默认的保存操作
    }
  };

  render() {
    return (
      <div} onClick={(e) => e.stopPropagation()} id="tableArea">
        
        <div>
          {tabel_columns.length > 0 ? (
            <div className="brush-table">
              <Table
                columns={this.state.tabel_columns}
                dataSource={this.state.tabel_data}
                pagination={false}
                scroll={{x:true}}
                loading={isGetNewData}
                style={{ userSelect: 'none' }}
                bordered
                onRow={() => {
                  return {
                    onContextMenu: this.onContextMenu,
                    onMouseDown: this.onMouseDown,
                    onMouseMove: this.onMouseMove,
                    onMouseUp: this.onMouseUp,
                  };
                }}
              />
              <div id="contextMenu" className="context-menu" style={{ cursor: 'pointer' }}>
                <div onClick={this.onClickCopy}>复制</div>
              </div>

            </div>
          ) : (
            <Empty />
          )}
        </div>
      </div>
    );
  }
}


export default tabel;

样式代码

// #tableArea .ant-table-tbody > tr > td.ant-table-cell-row-hover {
//   background-color: transparent !important;
// }
.brush-table {
  // 复制弹窗样式
  .context-menu {
    position: fixed;
    display: none;
    background-color: #f1f1f1;
    border: 1px solid #ccc;
    padding: 8px;
    z-index: 9999;
  }
  
  // antd
  .ant-table-cell {
    border: 1px solid transparent;
  }
  
  .ant-table-cell-row-hover {
    background: transparent!important;
  }
  // 选区高亮样式
  [brush-border-top="true"] {
    border-top: 1px solid #b93d06 !important;
  }
  [brush-border-right="true"] {
    border-right: 1px solid #b93d06 !important;
  }
  [brush-border-left="true"] {
    border-left: 1px solid #b93d06 !important;
  }
  [brush-border-bottom="true"] {
    border-bottom: 1px solid #b93d06 !important;
  }
  [data-brush="true"],[data-brush="true"].ant-table-cell-row-hover {
    background-color: #f5f5f5 !important;
  }
}

圈选效果实现,如下图:
鼠标圈选效果
这里可以看到在控制台粘贴展示没问题:
在这里插入图片描述
但是,这里似乎并没有自动填充表格样式:
自动填充表格样式失败
如果没有强制需求,其实自由圈选并复制功能到此已经够用。

进阶突破版

看来是复制逻辑不对,那么,怎么才能将数据和html结构同时兼容存到粘贴板呢?看来还需要突破一下。所以,这里主要是针对文档粘贴自动填充表格样式进行改造优化。

js代码

import React, { Component } from 'react';
import { Table, Empty } from 'antd';
import '../scss/tabel.scss';
import { FullscreenOutlined, FullscreenExitOutlined, EllipsisOutlined, CaretUpFilled, CaretDownFilled } from '@ant-design/icons';
import { Select, DatePicker, Dropdown, Divider, Button } from 'antd';
import { connect } from 'react-redux';
import { mapDispatchToProps, mapStateToProps } from '../../../redux/assistant';

class tabel extends Component {
  constructor(props) {
    super(props);
    this.state = {
		tabel_columns:[]
		tabel_data:[]
	};
    this.originDir = {
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
    };
  }

  componentDidMount() {
  	// cv复制所以需要开启监听单独去处理我们自己想要的操作
    document.addEventListener('keydown', this.clickSave); // 监听键盘复制
    document.addEventListener('mousedown', (e) => {
      if (e.target?.parentElement.className.includes('ant-table')) return;
      const nodes = document.querySelectorAll('.ant-table-cell');
      nodes.length > 0 &&
        nodes.forEach((item) => {
          this.clearStyle(item);
        });
    });
  }

  componentWillUnmount() {
    // 移除键盘按下事件监听器
    document.removeEventListener('keydown', this.clickSave);
  }

  // 鼠标点击
  onMouseDown = (event) => {
    if (event.button !== 0) return;
    const rect = event.target.getBoundingClientRect();
    this.originDir = {
      top: rect.top,
      left: rect.left,
      right: rect.right,
      bottom: rect.bottom,
    };
    this.renderNodes(this.originDir);
  };
  // 鼠标移动
  onMouseMove = (event) => {
    if (!this.originDir.top) return;
    const rect = event.target.getBoundingClientRect();
    let coordinates = {};

    // 1、圈选元素在中心位置左上方
    if (
      rect.top <= this.originDir.top &&
      rect.left <= this.originDir.left &&
      rect.right <= this.originDir.left &&
      rect.bottom <= this.originDir.top
    ) {
      coordinates = {
        top: rect.top,
        left: rect.left,
        right: this.originDir.right,
        bottom: this.originDir.bottom,
      };
    }
    // 圈选元素在中心位置右上方
    if (
      rect.top <= this.originDir.top &&
      rect.left >= this.originDir.left &&
      rect.right >= this.originDir.right &&
      rect.bottom <= this.originDir.bottom
    ) {
      coordinates = {
        top: rect.top,
        left: this.originDir.left,
        right: rect.right,
        bottom: this.originDir.bottom,
      };
    }

    // 圈选元素在中心位置左下方
    if (
      rect.top >= this.originDir.top &&
      rect.left <= this.originDir.left &&
      rect.right <= this.originDir.right &&
      rect.bottom >= this.originDir.bottom
    ) {
      coordinates = {
        top: this.originDir.top,
        left: rect.left,
        right: this.originDir.right,
        bottom: rect.bottom,
      };
    }

    // 圈选元素在中心位置右下方
    if (
      rect.top >= this.originDir.top &&
      rect.left >= this.originDir.left &&
      rect.right >= this.originDir.right &&
      rect.bottom >= this.originDir.bottom
    ) {
      coordinates = {
        top: this.originDir.top,
        left: this.originDir.left,
        right: rect.right,
        bottom: rect.bottom,
      };
    }

    this.renderNodes(coordinates);
  };
  // 圈选状态
  renderNodes = (coordinates) => {
    const nodes = document.querySelectorAll('.ant-table-cell');

    nodes.forEach((item) => {
      const target = item?.getBoundingClientRect();
      this.clearStyle(item);
      if (
        target?.top >= coordinates.top &&
        target?.right <= coordinates.right + 2 &&
        target?.left >= coordinates.left &&
        target?.bottom <= coordinates.bottom + 2
      ) {
        item.setAttribute('data-brush', 'true');
      }
    });
  };
  // 清除样式
  clearStyle = (item) => {
    item.hasAttribute('data-brush') && item.removeAttribute('data-brush');
  };
  // 鼠标抬起
  onMouseUp = () => {
    this.originDir = {};
  };
  // 鼠标右击
  onContextMenu = (event) => {
    event.preventDefault(); // 阻止默认右键菜单弹出
  };
  // 复制逻辑
  onClickCopy = () => {
    const copyableElements = document.querySelectorAll('[data-brush=true]');
    if (copyableElements.length === 0) return;
    // 按行分组单元格
    const rows = [];
    let currentRowTop = null;
    let currentRow = [];

    copyableElements.forEach((element) => {
      const rect = element.getBoundingClientRect();
      const textContent = element.firstChild?.innerText || '';

      // 如果元素顶部位置与当前行不同,则开始新行
      if (currentRowTop === null) {
        currentRowTop = rect.top;
      } else if (Math.abs(rect.top - currentRowTop) > 2) {
        // 允许小误差
        rows.push(currentRow);
        currentRow = [];
        currentRowTop = rect.top;
      }

      currentRow.push(textContent);
    });

    // 添加最后一行
    if (currentRow.length > 0) {
      rows.push(currentRow);
    }

    // 生成HTML表格
    const htmlTable = `
    <table border="1" cellspacing="0" style="border-collapse:collapse;">
      ${rows
        .map(
          (row) => `
        <tr>
          ${row
            .map(
              (cell) => ` <td style="padding:4px;border:1px solid #ccc;">${cell}</td> `
            )
            .join('')}
        </tr>
      `
        )
        .join('')}
    </table>
  `;

    // 生成TSV文本(用于纯文本粘贴)
    const tsvText = rows.map((row) => row.join('\t')).join('\n');

    // 执行复制操作(支持HTML和纯文本格式)
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard
        .write([
          new ClipboardItem({
            'text/html': new Blob([htmlTable], { type: 'text/html' }),
            'text/plain': new Blob([tsvText], { type: 'text/plain' }),
          }),
        ])
        .then(() => {
          console.log('已复制内容:', tsvText);
        })
        .catch((error) => {
          console.error('复制失败:', error);
          this.fallbackCopyTextToClipboard(tsvText);
        });
    } else {
      this.fallbackCopyTextToClipboard(tsvText);
    }
  };
  // 兜底的复制方法
  fallbackCopyTextToClipboard = (tsvText) => {
    const textarea = document.createElement('textarea');
    textarea.value = tsvText;
    // 使其不在视口中显示
    textarea.style.position = 'fixed';
    textarea.style.top = '-9999px';
    textarea.style.left = '-9999px';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();
    document.execCommand('copy');
    document.body.removeChild(textarea);
  };
  // 点击复制
  clickSave = (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
      this.onClickCopy();
      event.preventDefault(); // 阻止默认的保存操作
    }
  };

  render() {
    return (
      <div} onClick={(e) => e.stopPropagation()} id="tableArea">
        
        <div>
          {tabel_columns.length > 0 ? (
            <div className="brush-table">
              <Table
                columns={this.state.tabel_columns}
                dataSource={this.state.tabel_data}
                pagination={false}
                scroll={{x:true}}
                loading={isGetNewData}
                style={{ userSelect: 'none' }}
                bordered
                onRow={() => {
                  return {
                    onContextMenu: this.onContextMenu,
                    onMouseDown: this.onMouseDown,
                    onMouseMove: this.onMouseMove,
                    onMouseUp: this.onMouseUp,
                  };
                }}
              />
            </div>
          ) : (
            <Empty />
          )}
        </div>
      </div>
    );
  }
}


export default tabel;

样式代码

#tableArea .ant-table-tbody > tr > td, #tableArea .ant-table-tbody > tr  {
  padding: 0!important;
  margin: 0 !important;
}

.brush-table {
  // antd
  .ant-table-cell-row-hover {
    background: transparent!important;
  }
  // 选区高亮样式
  [data-brush="true"],
  [data-brush="true"].ant-table-cell-row-hover div{
    background-color: #e3ecfe !important;
  }
}

再到文档中粘贴查看,OK,也是可以自动填充表格:
在这里插入图片描述
到目前为止,上述的基本功能已经实现,但是还存在一些漏洞。具体体现在哪里呢?我不说你能发现么?

终极进化版

本阶段着重针对圈选数据过程中table发生横向滚动做出优化。也是在本阶段因为此项目中table组件不支持onscroll监听事件,各种调试不生效让本人开始怀疑人生,最后发现antd版本过低本身就不支持。

js代码

import React, { Component } from 'react';
import { Table, Empty } from 'antd';
import '../scss/tabel.scss';
import { FullscreenOutlined, FullscreenExitOutlined, EllipsisOutlined, CaretUpFilled, CaretDownFilled } from '@ant-design/icons';
import { Select, DatePicker, Dropdown, Divider, Button } from 'antd';

class tabel extends Component {
  constructor(props) {
    super(props);
    this.state = {
    	tabel_columns:[],
		tabel_data:[]
    };
    this.originDir = {
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
    };
    this.startP = { x: 0, y: 0 }; // 鼠标点击table表圈选范围的起始坐标
    this.endP = { x: 0, y: 0 }; // 鼠标点击table表圈选范围的结束坐标
    this.isSelect = false; // 鼠标是否开始圈选table数据
    this.scrollInfo = { scrollLeft: 0, scrollTop: 0 }; // 记录table滚动的信息
    this.preScrollOffset = 0; // 记录table表X轴的初始的偏移量记录,用来判断向左还是向右滚动
  }

  componentDidMount() {
    document.addEventListener('keydown', this.clickSave); // 监听键盘复制
    document.addEventListener('mousedown', (e) => {
      if (e.target?.parentElement.className.includes('ant-table')) return;
      const nodes = document.querySelectorAll('.ant-table-cell');
      nodes.length > 0 &&
        nodes.forEach((item) => {
          this.clearStyle(item);
        });
    });
  }

  componentWillUnmount() {
    // 移除键盘按下事件监听器
    document.removeEventListener('keydown', this.clickSave);
  }

  // 鼠标点击
  onMouseDown = (event) => {
    // 清除所有旧圈选高亮数据的样式
    const nodes = document.querySelectorAll('[data-brush=true]');
    nodes.length > 0 &&
      nodes.forEach((item) => {
        this.clearStyle(item);
      });
    // 判断是否是鼠标左键点击以及点击的元素非表头数据
    if (event.button !== 0 || event.target.closest('.ant-table-thead')) return;
    // 获取table滚动体的相关位置信息
    const rect = document.querySelector('.ant-table-container').getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;
    this.isSelect = true; // 开启圈选数据模式
    this.startP = { x, y }; // 记录鼠标点击起始坐标
    this.endP = { x, y }; // 单击情况下终止坐标与起始坐标记录为一致
    document.addEventListener('mousemove', this.onMouseMove); // 开启鼠标移动监听
    document.addEventListener('mouseup', this.onMouseUp); // 开启鼠标抬起监听
    this.preScrollOffset = document.querySelector('.ant-table-content').scrollLeft; // 记录table表格当前的横向滚动偏移量
    // 开启table表的横向滚动监听,以更新鼠标移动过程中table表发生横滚的相关数据记录
    // 不直接给table开启onScroll监听事件是因为项目antd版本较低,不支持
    document.querySelector('.ant-table-content').addEventListener('scroll', (event) => {
      if (!this.isSelect) return; // 非圈选数据状态不做任何更新
      // 记录table横滚时产生的滚动信息
      this.scrollInfo = {
        // 如果滚动的左偏移量大于等于初始记录的偏移量,说明鼠标圈选过程中table产生了向右横滚,则直接更新;
        // 如果滚动的左偏移量小于等于初始记录的偏移量,说明鼠标圈选过程中table产生了向左横滚,则需进行【新偏移量-初始偏移量】计算
        // 由于当前页面的table未开启Y轴滚动,所以scrollTop暂不处理
        scrollLeft: event.target.scrollLeft >= this.preScrollOffset ? event.target.scrollLeft : event.target.scrollLeft - this.preScrollOffset,
        screenTop: event.target.scrollTop,
      };
    });
  };
  // 鼠标移动
  onMouseMove = (event) => {
    if (!this.isSelect) return; // 判断是否是table圈选状态
    // 获取table滚动体
    const rect = document.querySelector('.ant-table-container').getBoundingClientRect();

    // 限制选框在表格范围内
    const x = Math.max(0, Math.min(event.clientX - rect.left, rect.width));
    const y = Math.max(0, Math.min(event.clientY - rect.top, rect.height));

    this.endP = { x, y }; // 记录结束坐标
    this.renderNodes(); // 同步开启圈选高亮样式
  };
  // 圈选高亮状态
  renderNodes = () => {
    const { x: startX, y: startY } = this.startP;
    const { x: endX, y: endY } = this.endP;
    // 圈选范围信息
    const selectionRect = {
      left: Math.min(startX - this.scrollInfo.scrollLeft, endX), // 需要减掉table横滚发生的偏移量
      top: Math.min(startY, endY),
      width: Math.abs(endX - startX + this.scrollInfo.scrollLeft), // 需要减掉table横滚发生的偏移量
      height: Math.abs(endY - startY),
    };
    // 获取所有数据行单元格并判断是否在选框内
    const cells = Array.from(document.querySelector('.ant-table-container').querySelectorAll('tbody td'));
    cells.forEach((cell) => {
      const cellRect = cell.getBoundingClientRect();
      const tableRect = document.querySelector('.ant-table-container').getBoundingClientRect();
      // 单元格相对于表格的位置
      const cellRelativeRect = {
        left: cellRect.left - tableRect.left,
        top: cellRect.top - tableRect.top,
        right: cellRect.right - tableRect.left,
        bottom: cellRect.bottom - tableRect.top,
      };

      // 判断单元格是否在选框内(部分重叠即算选中)
      if (
        cellRelativeRect.left < selectionRect.left + selectionRect.width &&
        cellRelativeRect.right > selectionRect.left &&
        cellRelativeRect.top < selectionRect.top + selectionRect.height &&
        cellRelativeRect.bottom > selectionRect.top
      ) {
        cell.setAttribute('data-brush', 'true'); // 添加高亮样式
      } else {
        this.clearStyle(cell); // 清除多余高亮
      }
    });
  };
  // 清除样式
  clearStyle = (item) => {
    item.hasAttribute('data-brush') && item.removeAttribute('data-brush');
  };
  // 鼠标抬起
  onMouseUp = () => {
    window.getSelection().removeAllRanges(); // 清除浏览器默认的圈选项及默认事件
    if (!this.isSelect) return;
    this.isSelect = false;
    this.renderNodes();
    // 移除全局事件监听
    document.removeEventListener('mousemove', this.onMouseMove);
    document.removeEventListener('mouseup', this.onMouseUp);
    document.querySelector('.ant-table-content').removeEventListener('scroll', () => {});
    this.scrollInfo = { scrollLeft: 0, scrollTop: 0 };
  };
  // 鼠标右击
  onContextMenu = (event) => {
    event.preventDefault(); // 阻止默认右键菜单弹出
  };
  // 复制逻辑
  onClickCopy = () => {
    const copyableElements = document.querySelectorAll('[data-brush=true]');
    if (copyableElements.length === 0) return;
    // 按行分组单元格
    const rows = [];
    let currentRowTop = null;
    let currentRow = [];

    copyableElements.forEach((element) => {
      const rect = element.getBoundingClientRect();
      const textContent = element.firstChild?.innerText || '';

      // 如果元素顶部位置与当前行不同,则开始新行
      if (currentRowTop === null) {
        currentRowTop = rect.top + window.scrollY;
      } else if (Math.abs(rect.top + window.scrollY - currentRowTop) > 2) {
        // 允许小误差
        rows.push(currentRow);
        currentRow = [];
        currentRowTop = rect.top + window.scrollY;
      }

      currentRow.push(textContent);
    });

    // 添加最后一行
    if (currentRow.length > 0) {
      rows.push(currentRow);
    }

    // 生成HTML表格
    const htmlTable = `
    <table border="1" cellspacing="0" style="border-collapse:collapse;">
      ${rows
        .map(
          (row) => `
        <tr>
          ${row
            .map(
              (cell) => ` <td style="padding:4px;border:1px solid #ccc;">${cell}</td> `
            )
            .join('')}
        </tr>
      `
        )
        .join('')}
    </table>
  `;

    // 生成TSV文本(用于纯文本粘贴)
    const tsvText = rows.map((row) => row.join('\t')).join('\n');

    // 执行复制操作(支持HTML和纯文本格式)
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard
        .write([
          new ClipboardItem({
            'text/html': new Blob([htmlTable], { type: 'text/html' }),
            'text/plain': new Blob([tsvText], { type: 'text/plain' }),
          }),
        ])
        .then(() => {
          console.log('已复制内容:', tsvText);
        })
        .catch((error) => {
          console.error('复制失败:', error);
          this.fallbackCopyTextToClipboard(tsvText);
        });
    } else {
      this.fallbackCopyTextToClipboard(tsvText);
    }
  };
  // 兜底的复制方法
  fallbackCopyTextToClipboard = (tsvText) => {
    const textarea = document.createElement('textarea');
    textarea.value = tsvText;
    // 使其不在视口中显示
    textarea.style.position = 'fixed';
    textarea.style.top = '-9999px';
    textarea.style.left = '-9999px';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();
    document.execCommand('copy');
    document.body.removeChild(textarea);
  };
  // 点击复制
  clickSave = (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
      this.onClickCopy();
      event.preventDefault(); // 阻止默认的保存操作
    }
  };

  render() {
    return (
      <div id="tableArea">
        <div>
          {tabel_columns.length > 0 ? (
            <div className="brush-table">
              <Table
                columns={this.state.tabel_columns}
                dataSource={this.state.tabel_data}
                pagination={false}
                scroll={{x:true}}
                loading={isGetNewData}
                bordered
                onRow={() => {
                  return {
                    onContextMenu: this.onContextMenu,
                    onMouseDown: this.onMouseDown,
                    onMouseMove: this.onMouseMove,
                    onMouseUp: this.onMouseUp,
                  };
                }}
              />
            </div>
          ) : (
            <Empty />
          )}
        </div>
      </div>
    );
  }
}

export default tabel;

css代码

#tableArea .ant-table-tbody > tr > td, #tableArea .ant-table-tbody > tr  {
  padding: 0!important;
  margin: 0 !important;
}
html::selection,body ::selection {
  background-color: transparent;
  color: #000;
}
.brush-table {
  // antd
  .ant-table-cell-row-hover {
    background: transparent!important;
  }
  // 选区高亮样式
  [data-brush="true"],
  [data-brush="true"].ant-table-cell-row-hover div{
    background-color: #e3ecfe !important;
  }
}

由于大体的复制效果在进阶突破阶段已经有效果图这里就不重复展示,但值得注意的是,navigator.clipboard方法在本地调试过程中是可以正常获取到并复制生效,这是因为在开发环境中默认为网络安全环境,而真正放到服务器上,如果你的项目是http的访问地址,那么恭喜你,只能走默认兜底的复制逻辑,也就是说你只能得到一个字符串,并不会复制到html结构。在https的环境中放心使用,亲测有效。

结语

目前来说,我并没有找到更好的兼容方法。如果仅复制保留字符串的内容,其实诸多复制方法均可实现,能从我这里学习到的仅仅是自由圈选的逻辑思路。一个细节点,即使在终极进化版里,因为我只开启了table组件的scroll-x轴为true,所以监听的滚动体为document.querySelector('.ant-table-container'),如果设置为数字,如scroll={{x:100}},或者x和y均开启滚动scroll={{x:100,y: 100}},那么相应的逻辑也要学会随机应变不要一味无脑复制我的代码哟。重要的事情说三次,核心思路是重点!核心思路是重点!!核心思路是重点!!!

彩蛋天外天

你是不是以为上面终极进化版已是绝唱,其实就连我也以为的就是你以为的那样,万万没想到,你以为的我以为你以为的我又又给突破了一下子。那么,直接上代码。

import React, { Component } from 'react';
import { Table, Empty } from 'antd';
import '../scss/tabel.scss';
import { FullscreenOutlined, FullscreenExitOutlined, EllipsisOutlined, CaretUpFilled, CaretDownFilled } from '@ant-design/icons';
import { Select, DatePicker, Dropdown, Divider, Button } from 'antd';

class tabel extends Component {
  constructor(props) {
    super(props);
    this.state = {
      	tabel_columns:[],
		tabel_data:[]
    };
    this.originDir = {
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
    };
    this.startP = { x: 0, y: 0 }; // 鼠标点击table表圈选范围的起始坐标
    this.endP = { x: 0, y: 0 }; // 鼠标点击table表圈选范围的结束坐标
    this.isSelect = false; // 鼠标是否开始圈选table数据
    this.scrollInfo = { scrollLeft: 0, scrollTop: 0 }; // 记录table滚动的信息
    this.preScrollOffset = 0; // 记录table表X轴的初始的偏移量记录,用来判断向左还是向右滚动
  }

  componentDidMount() {
    document.addEventListener('mousedown', (e) => {
      if (e.target?.parentElement.className.includes('ant-table')) return;
      const nodes = document.querySelectorAll('.ant-table-cell');
      nodes.length > 0 &&
        nodes.forEach((item) => {
          this.clearStyle(item);
        });
    });
  }

  // 鼠标点击
  onMouseDown = (event) => {
    // 清除所有旧圈选高亮数据的样式
    const nodes = document.querySelectorAll('[data-brush=true]');
    nodes.length > 0 &&
      nodes.forEach((item) => {
        this.clearStyle(item);
      });
    // 判断是否是鼠标左键点击以及点击的元素非表头数据
    if (event.button !== 0 || event.target.closest('.ant-table-thead')) return;
    // 获取table滚动体的相关位置信息
    const rect = document.querySelector('.ant-table-container').getBoundingClientRect();
    const x = event.clientX - rect.left;
    const y = event.clientY - rect.top;
    this.isSelect = true; // 开启圈选数据模式
    this.startP = { x, y }; // 记录鼠标点击起始坐标
    this.endP = { x, y }; // 单击情况下终止坐标与起始坐标记录为一致
    document.addEventListener('mousemove', this.onMouseMove); // 开启鼠标移动监听
    document.addEventListener('mouseup', this.onMouseUp); // 开启鼠标抬起监听
    this.preScrollOffset = document.querySelector('.ant-table-content').scrollLeft; // 记录table表格当前的横向滚动偏移量
    // 开启table表的横向滚动监听,以更新鼠标移动过程中table表发生横滚的相关数据记录
    // 不直接给table开启onScroll监听事件是因为项目antd版本较低,不支持
    document.querySelector('.ant-table-content').addEventListener('scroll', (event) => {
      if (!this.isSelect) return; // 非圈选数据状态不做任何更新
      // 记录table横滚时产生的滚动信息
      this.scrollInfo = {
        // 如果滚动的左偏移量大于等于初始记录的偏移量,说明鼠标圈选过程中table产生了向右横滚,则直接更新;
        // 如果滚动的左偏移量小于等于初始记录的偏移量,说明鼠标圈选过程中table产生了向左横滚,则需进行【新偏移量-初始偏移量】计算
        // 由于当前页面的table未开启Y轴滚动,所以scrollTop暂不处理
        scrollLeft: event.target.scrollLeft >= this.preScrollOffset ? event.target.scrollLeft : event.target.scrollLeft - this.preScrollOffset,
        screenTop: event.target.scrollTop,
      };
    });
  };
  // 鼠标移动
  onMouseMove = (event) => {
    if (!this.isSelect) return; // 判断是否是table圈选状态
    // 获取table滚动体
    const rect = document.querySelector('.ant-table-container').getBoundingClientRect();

    // 限制选框在表格范围内
    const x = Math.max(0, Math.min(event.clientX - rect.left, rect.width));
    const y = Math.max(0, Math.min(event.clientY - rect.top, rect.height));

    this.endP = { x, y }; // 记录结束坐标
    this.renderNodes(); // 同步开启圈选高亮样式
  };
  // 圈选高亮状态
  renderNodes = () => {
    const { x: startX, y: startY } = this.startP;
    const { x: endX, y: endY } = this.endP;
    // 圈选范围信息
    const selectionRect = {
      left: Math.min(startX - this.scrollInfo.scrollLeft, endX), // 需要减掉table横滚发生的偏移量
      top: Math.min(startY, endY),
      width: Math.abs(endX - startX + this.scrollInfo.scrollLeft), // 需要减掉table横滚发生的偏移量
      height: Math.abs(endY - startY),
    };
    // 获取所有数据行单元格并判断是否在选框内
    const cells = Array.from(document.querySelector('.ant-table-container').querySelectorAll('tbody td'));
    cells.forEach((cell) => {
      const cellRect = cell.getBoundingClientRect();
      const tableRect = document.querySelector('.ant-table-container').getBoundingClientRect();
      // 单元格相对于表格的位置
      const cellRelativeRect = {
        left: cellRect.left - tableRect.left,
        top: cellRect.top - tableRect.top,
        right: cellRect.right - tableRect.left,
        bottom: cellRect.bottom - tableRect.top,
      };

      // 判断单元格是否在选框内(部分重叠即算选中)
      if (
        cellRelativeRect.left < selectionRect.left + selectionRect.width &&
        cellRelativeRect.right > selectionRect.left &&
        cellRelativeRect.top < selectionRect.top + selectionRect.height &&
        cellRelativeRect.bottom > selectionRect.top
      ) {
        cell.setAttribute('data-brush', 'true'); // 添加高亮样式
      } else {
        this.clearStyle(cell); // 清除多余高亮
      }
    });

    this.onClickCopy();
  };
  // 清除样式
  clearStyle = (item) => {
    item.hasAttribute('data-brush') && item.removeAttribute('data-brush');
  };
  // 鼠标抬起
  onMouseUp = () => {
    window.getSelection().removeAllRanges(); // 清除浏览器默认的圈选项及默认事件
    if (!this.isSelect) return;
    this.isSelect = false;
    this.renderNodes();
    // 移除全局事件监听
    document.removeEventListener('mousemove', this.onMouseMove);
    document.removeEventListener('mouseup', this.onMouseUp);
    document.querySelector('.ant-table-content').removeEventListener('scroll', () => {});
    this.scrollInfo = { scrollLeft: 0, scrollTop: 0 };
    this.onClickCopy();
  };
  // 鼠标右击
  onContextMenu = (event) => {
    event.preventDefault(); // 阻止默认右键菜单弹出
  };
  // 复制逻辑
  onClickCopy = () => {
    const copyableElements = document.querySelectorAll('[data-brush=true]');
    if (copyableElements.length === 0) return;
    // 按行分组单元格
    const rows = [];
    let currentRowTop = null;
    let currentRow = [];

    copyableElements.forEach((element) => {
      const rect = element.getBoundingClientRect();
      const textContent = element.firstChild?.innerText || '';

      // 如果元素顶部位置与当前行不同,则开始新行
      if (currentRowTop === null) {
        currentRowTop = rect.top + window.scrollY;
      } else if (Math.abs(rect.top + window.scrollY - currentRowTop) > 2) {
        // 允许小误差
        rows.push(currentRow);
        currentRow = [];
        currentRowTop = rect.top + window.scrollY;
      }

      currentRow.push(textContent);
    });

    // 添加最后一行
    if (currentRow.length > 0) {
      rows.push(currentRow);
    }

    // 生成HTML表格
    const htmlTable = `
    <table border="1" cellspacing="0" style="border-collapse:collapse;">
      ${rows
        .map(
          (row) => `
        <tr>
          ${row
            .map(
              (cell) => ` <td style="padding:4px;border:1px solid #ccc;">${cell}</td> `
            )
            .join('')}
        </tr>
      `
        )
        .join('')}
    </table>
  `;
    // 清除旧节点dom
    document.querySelector('#copy-dom') && document.body.removeChild(document.querySelector('#copy-dom'));
    // 创建新的复制节点
    const div = document.createElement('div');
    div.innerHTML = htmlTable;
    div.id = 'copy-dom';
    div.style.position = 'fixed';
    div.style.left = '-9999px'; // 移出可视区域而不是仅透明
    document.body.appendChild(div);
    const range = document.createRange();
    range.selectNode(div);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);
  };

  render() {
    return (
      <div className={isBig ? 'tabelWrap big' : 'tabelWrap'} onClick={(e) => e.stopPropagation()} id="tableArea">
        
        <div>
          {tabel_columns.length > 0 ? (
            <div className="brush-table">
              <Table
                columns={this.state.tabel_columns}
                dataSource={this.state.tabel_data}
                pagination={false}
                scroll={tableScroll}
                loading={isGetNewData}
                bordered
                onRow={() => {
                  return {
                    onContextMenu: this.onContextMenu,
                    onMouseDown: this.onMouseDown,
                    onMouseMove: this.onMouseMove,
                    onMouseUp: this.onMouseUp,
                  };
                }}
              />
            </div>
          ) : (
            <Empty />
          )}
        </div>
      </div>
    );
  }
}

export default tabel;

主要思路,在于放弃了navigator.clipboard.write()方法,将视图处理和复制处理解耦,将获取到的数据单独生成一份表格,并利用document.createRange()生成一个rang,然后再将创造出的表格添加到浏览器选中节点window.getSelection().addRange(range),此时进行cv复制,实际上并非我们单独去处理的逻辑,而是走的浏览器用户行为。


网站公告

今日签到

点亮在社区的每一天
去签到