antd/fusion表格增加圈選復(fù)制功能思路詳解
背景介紹
我們存在著大量在PC頁面通過表格看數(shù)據(jù)業(yè)務(wù)場景,表格又分為兩種,一種是 antd / fusion 這種基于 dom 元素的表格,另一種是通過 canvas 繪制的類似 excel 的表格。
基于 dom 的表格功能豐富較為美觀,能實現(xiàn)多表頭、合并單元格和各種自定義渲染(如表格中渲染圖形 / 按鈕 / 進度條 / 單選框 / 輸入框),以展示為主,不提供圈選、整列復(fù)制等功能。

canvas 繪制的類 excel 外表樸素更為實用,大量數(shù)據(jù)渲染不卡頓,操作類似 excel,能行/列選中,圈選、復(fù)制等功能。

兩者使用場景有所差異,各有利弊,但業(yè)務(wù)方不希望一套系統(tǒng)中出現(xiàn)兩種類型的交互,期望能將兩種表格的優(yōu)缺點進行融合,在美觀的dom表格中增加圈選、復(fù)制的功能。
圈選效果
業(yè)務(wù)方所期望的圈選效果和excel類似,鼠標按下即選中元素,然后滑動鼠標,鼠標所經(jīng)過形成的四邊形就是選中區(qū)域,此時鼠標右鍵點擊復(fù)制按鈕,或者鍵盤按下 ctrl + c 復(fù)制文本。

而dom表格經(jīng)過如上操作,會把一整行數(shù)據(jù)都選上,不符合業(yè)務(wù)同學(xué)的使用預(yù)期。

實現(xiàn)過程
去除默認樣式
我們需要自行定義鼠標事件、元素樣式,需要先將無用的默認樣式清除,包括上圖中的 hover 和選中元素的背景色。
禁用表格本身的鼠標點擊選擇功能,設(shè)置css,userSelect: none
<Table style={{ userSelect: 'none' }} ></Table>去除 hover 樣式(這里使用的是 fusion 組件)
.next-table-row:hover {
background-color: transparent !important;
}鼠標按下,記錄選中元素
為表格綁定鼠標按鍵時觸發(fā)事件 mousedown 。
當鼠標按下時,這個元素就是中心元素,無論是向哪個方向旋轉(zhuǎn),所形成的區(qū)域一定會包含初始選中的元素。
getBoundingClientRect() 用于獲得頁面中某個元素的上下左右分別相對瀏覽器視窗的位置。
const onMouseDown = (event) => {
const rect = event.target.getBoundingClientRect();
// funsion 判斷點擊是否為表頭元素,為否時才繼續(xù)后面的邏輯。antd 不需要判斷,因為點擊表頭不會觸發(fā)該事件
const isHeaderNode = event.target?.parentNode?.getAttribute('class')?.indexOf('next-table-header-node') > -1;
if (isHeaderNode) return;
originDir = {
top: rect.top,
left: rect.left,
right: rect.right,
bottom: rect.bottom,
};
// 渲染
renderNodes(originDir);
};
<Table style={{ userSelect: 'none' }} onMouseDown={onMouseDown}></Table>鼠標滑過
為表格綁定鼠標滑過時觸發(fā)事件 mousemove 。
根據(jù)滑動元素的上下左右距離與鼠標按下時的位置進行判斷,圈選元素存在四個方向,以第一次選中的元素為中心位置?;瑒訒r元素位于鼠標按下的右下、左下、右上、左上方,根據(jù)不同的情況來設(shè)置四個角的方位。

const onMouseMove = (event) => {
if (!originDir.top) return;
const rect = event.target.getBoundingClientRect();
let coordinates = {};
// 鼠標按下后往右下方拖動
if (
rect.top <= originDir.top &&
rect.left <= originDir.left &&
rect.right <= originDir.left &&
rect.bottom <= originDir.top
) {
coordinates = {
top: rect.top,
left: rect.left,
right: originDir.right,
bottom: originDir.bottom,
};
}
// 鼠標按下后往左下方拖動
if (
rect.top >= originDir.top &&
rect.left <= originDir.left &&
rect.right <= originDir.right &&
rect.bottom >= originDir.bottom
) {
coordinates = {
top: originDir.top,
left: rect.left,
right: originDir.right,
bottom: rect.bottom,
};
}
// 鼠標按下后往右上方拖動
if (
rect.top <= originDir.top &&
rect.left >= originDir.left &&
rect.right >= originDir.right &&
rect.bottom <= originDir.bottom
) {
coordinates = {
top: rect.top,
left: originDir.left,
right: rect.right,
bottom: originDir.bottom,
};
}
// 鼠標按下后往左上方拖動
if (
rect.top >= originDir.top &&
rect.left >= originDir.left &&
rect.right >= originDir.right &&
rect.bottom >= originDir.bottom
) {
coordinates = {
top: originDir.top,
left: originDir.left,
right: rect.right,
bottom: rect.bottom,
};
}
renderNodes(coordinates);
};
<Table
style={{ userSelect: 'none' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
></Table>渲染/清除樣式
遍歷表格中 dom 元素,如果該元素在圈選的區(qū)域內(nèi),為其添加選中的背景色,再為四邊形區(qū)域增加邊框。
這里無論是直接設(shè)置 style 還是添加 classname 都不是很好。直接添加 classname 時,antd 會在 hover 操作時重置 classname,原來設(shè)置的 classname 會被覆蓋。直接設(shè)置 style 可能存在和其他設(shè)置沖突的情況,并且最后獲取所有圈選元素時比較麻煩。
以上兩種方法都嘗試過,最后選擇了直接往 dom 元素上面添加屬性,分別用5個屬性保存是否圈選,上下左右邊框,這里沒有進行合并是因為一個dom元素可能同時存在這五個屬性。
const renderNodes = (coordinates) => {
const nodes = document.querySelectorAll('.next-table-cell-wrapper');
nodes.forEach((item) => {
const target = item?.getBoundingClientRect();
clearStyle(item);
if (
target?.top >= coordinates.top &&
target?.right <= coordinates.right &&
target?.left >= coordinates.left &&
target?.bottom <= coordinates.bottom
) {
item.setAttribute('data-brush', 'true');
if (target.top === coordinates.top) {
item.setAttribute('brush-border-top', 'true');
}
if (target.right === coordinates.right) {
item.setAttribute('brush-border-right', 'true');
}
if (target.left === coordinates.left) {
item.setAttribute('brush-border-left', 'true');
}
if (target.bottom === coordinates.bottom) {
item.setAttribute('brush-border-bottom', 'true');
}
}
});
};
const 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');
};使用 fusion 的 table 需要為每一個元素添加上透明的邊框,不然會出現(xiàn)布局抖動的情況。(antd 不用)
/* 為解決設(shè)置樣式抖動而設(shè)置 */
.next-table td .next-table-cell-wrapper {
border: 1px solid transparent;
}
[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"] {
background-color: #f5f5f5 !important;
}
.next-table-row:hover {
background-color: transparent !important;
}鼠標松開
為表格綁定鼠標松開時觸發(fā)事件 mouseup 。
從鼠標按下,到滑動,最后松開,是一整個圈選流程,在鼠標按下時保存了初始的方位,滑動時判斷是否存在方位再進行計算,松開時將初始方位置空。
const onMouseUp = () => {
originDir = {};
};
<Table
style={{ userSelect: 'none' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
></Table>到這一步,就已經(jīng)實現(xiàn)了鼠標圈選功能。

復(fù)制功能
表格圈選的交互效果其實是為復(fù)制功能做準備。
鼠標右鍵復(fù)制
原表格在選中元素時鼠標右鍵會出現(xiàn)【復(fù)制】按鈕,點擊后復(fù)制的效果是圖中圈選到的元素每一個都換行展示,圈選行為不能滿足使用需求,復(fù)制的內(nèi)容也無法按照頁面中展示的行列格式。

而當我們實現(xiàn)圈選功能之后,因為使用 css 屬性 "user-select: none" 禁止用戶選擇文本,此時鼠標右鍵已經(jīng)不會出現(xiàn)復(fù)制按鈕。

為了實現(xiàn)鼠標右鍵出現(xiàn)復(fù)制按鈕,我們需要覆蓋原鼠標右鍵事件,自定義復(fù)制功能。
1、為表格綁定鼠標右鍵事件 contextMenu
<Table
style={{ userSelect: 'none' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onContextMenu={onContextMenu}
></Table>2、創(chuàng)建一個包含復(fù)制按鈕的自定義上下文菜單
<div id="contextMenu" className="context-menu" style={{ cursor: 'pointer' }}>
<div onClick={onClickCopy}>復(fù)制</div>
</div>3、阻止默認的右鍵菜單彈出,將自定義上下文菜單添加到頁面中,并定位在鼠標右鍵點擊的位置。
const onContextMenu = (event) => {
event.preventDefault(); // 阻止默認右鍵菜單彈出
const contextMenu = document.getElementById('contextMenu');
// 定位上下文菜單的位置
contextMenu.style.left = `${event.clientX}px`;
contextMenu.style.top = `${event.clientY}px`;
// 顯示上下文菜單
contextMenu.style.display = 'block';
};這里復(fù)制按鈕沒有調(diào)整樣式,可根據(jù)自己項目情況進行一些美化。

4、點擊復(fù)制按鈕時,保存當前行列格式執(zhí)行復(fù)制操作。
復(fù)制仍然保留表格的樣式,這里想了很久,一直在想通過保存dom元素的樣式來實現(xiàn),這種方案存在兩個問題,一是保存html樣式的api,document.execCommand('copy') 不被瀏覽器支持,二是表格元素都是行內(nèi)元素,即使復(fù)制了樣式,也和頁面上看到的布局不一樣。
最后采取的方案還是自己對是否換行進行處理,遍歷元素時判斷當前元素的 top 屬性和下一個點距離,如果相同則添加空字符串,不同則添加換行符 \n 。
const 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.innerHTML}${separator}`;
});
// 執(zhí)行復(fù)制操作
navigator.clipboard.writeText(copiedContent).then(() => {
console.log('已復(fù)制內(nèi)容:', copiedContent);
}) .catch((error) => {
console.error('復(fù)制失敗:', error);
});
// 隱藏上下文菜單
contextMenu.style.display = 'none';
};5、對鼠標按下事件 onMouseDown 的處理
- 鼠標點擊右鍵也會觸發(fā) onMouseDown ,這時會造成選中區(qū)域錯亂,需要通過 event.button 判斷當前事件觸發(fā)的鼠標位置。
- 鼠標右鍵后如果沒有點擊復(fù)制按鈕而是滑走或者使用鼠標左鍵選中,這時候相當于執(zhí)行取消復(fù)制操作,復(fù)制按鈕的上下文需要清除。
const onMouseDown = (event) => {
// 0:表示鼠標左鍵。2:表示鼠標右鍵。1:表示鼠標中鍵或滾輪按鈕
if (event.button !== 0) return;
// 隱藏復(fù)制按鈕
const contextMenu = document.getElementById('contextMenu');
contextMenu.style.display = 'none';
};到這里,就已經(jīng)實現(xiàn)了圈選鼠標右鍵復(fù)制的功能。

ctrl+s / command+s 復(fù)制
使用 event.ctrlKey 來檢查 Ctrl 鍵是否按下,使用 event.metaKey 來檢查 Command 鍵是否按下,并使用 event.key 來檢查按下的鍵是否是 c 鍵。
useEffect(() => {
const clickSave = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
onClickCopy();
event.preventDefault(); // 阻止默認的保存操作
}
};
document.addEventListener('keydown', clickSave);
return () => {
document.removeEventListener('keydown', clickSave);
};
}, []);antd 也可以使用
以上功能是在 fusion design 中實現(xiàn)的,在 antd 中也可以使用,語法稍有不同。
表格中鼠標事件需要綁定在 onRow 函數(shù)中
<Table
style={{ userSelect: 'none' }}
onRow={() => {
return {
onContextMenu,
onMouseDown,
onMouseMove,
onMouseUp,
};
}}
>獲取所有表格 dom 元素的類名替換一下
const nodes = document.querySelectorAll('.ant-table-cell');覆蓋表格 hover 時樣式
.ant-table-cell-row-hover {
background: transparent;
}
.ant-table-wrapper .ant-table .ant-table-tbody > tr.ant-table-row:hover > td,
.ant-table-wrapper .ant-table .ant-table-tbody > tr > td.ant-table-cell-row-hover {
background: transparent;
}實現(xiàn)效果是這樣的

完整代碼
完整代碼在這里 table-brush-copy,包括 fusion design 和 ant design 兩個版本。
總結(jié)
表格圈選復(fù)制功能的實現(xiàn)主要是以下五步
- mousedown 按下鼠標,記錄初始坐標
- mousemove 滑動鼠標,計算所形成的四邊形區(qū)域
- mouseup 松開鼠標,清空初始坐標
- contextmenu 自定義鼠標右鍵事件,定位上下文事件
- keydown 監(jiān)聽鍵盤按下位置,判斷是否為復(fù)制操作
集合了較多的鼠標、鍵盤事件,以及 javascript 獲取屬性、元素。
到此這篇關(guān)于antd/fusion表格增加圈選復(fù)制功能的文章就介紹到這了,更多相關(guān)antd/fusion表格圈選復(fù)制內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
js中AppendChild與insertBefore的用法詳細解析
這篇文章主要是對js中AppendChild與insertBefore的用法進行了詳細的分析介紹,需要的朋友可以過來參考下,希望對大家有所幫助2013-12-12
JavaScript實現(xiàn)的冒泡排序法及統(tǒng)計相鄰數(shù)交換次數(shù)示例
這篇文章主要介紹了JavaScript實現(xiàn)的冒泡排序法及統(tǒng)計相鄰數(shù)交換次數(shù),結(jié)合實例形式分析了javascript冒泡排序的實現(xiàn)技巧及針對交換次數(shù)的統(tǒng)計方法,便于更直觀的了解冒泡排序算法,需要的朋友可以參考下2017-04-04
javascript中createElement的兩種創(chuàng)建方式
這篇文章主要介紹了javascript中createElement的兩種創(chuàng)建方式,具有一定參考借鑒價值,需要的朋友可以參考下2015-05-05
JavaScript?賦值,淺復(fù)制和深復(fù)制的區(qū)別
這篇文章主要介紹了JavaScript?賦值,淺復(fù)制和深復(fù)制的區(qū)別,文章圍繞主題展開詳細的內(nèi)容介紹,具有一定的參考價值,需要的小伙伴可以參考一下2022-05-05
JavaScript中防抖和節(jié)流的原理和區(qū)別詳解
JavaScript 中,防抖和節(jié)流是一種用于優(yōu)化事件處理函數(shù)調(diào)用頻率的技術(shù),防抖和節(jié)流的目的都是為了避免頻繁地觸發(fā)事件處理函數(shù),從而減少瀏覽器和服務(wù)器的負擔(dān),本文將給大家介紹一下JavaScript中防抖和節(jié)流的原理和區(qū)別,需要的朋友可以參考下2023-09-09

