背景介紹
我們存在著大量在PC頁面透過表格看資料業務場景,表格又分為兩種,一種是 antd / fusion 這種基於 dom 元素的表格,另一種是透過 canvas 繪製的類似 excel 的表格。
基於 dom 的表格功能豐富較為美觀,能實現多表頭、合併單元格和各種自定義渲染(如表格中渲染圖形 / 按鈕 / 進度條 / 單選框 / 輸入框),以展示為主,不提供圈選、整列複製等功能。
canvas 繪製的類 excel 外表樸素更為實用,大量資料渲染不卡頓,操作類似 excel,能行/列選中,圈選、複製等功能。
兩者使用場景有所差異,各有利弊,但業務方不希望一套系統中出現兩種型別的互動,期望能將兩種表格的優缺點進行融合,在美觀的dom表格中增加圈選、複製的功能。
圈選效果
業務方所期望的圈選效果和excel類似,滑鼠按下即選中元素,然後滑動滑鼠,滑鼠所經過形成的四邊形就是選中區域,此時滑鼠右鍵點選複製按鈕,或者鍵盤按下 ctrl + c 複製文字。
而dom表格經過如上操作,會把一整行資料都選上,不符合業務同學的使用預期。
實現過程
去除預設樣式
我們需要自行定義滑鼠事件、元素樣式,需要先將無用的預設樣式清除,包括上圖中的 hover 和選中元素的背景色。
禁用表格本身的滑鼠點選選擇功能,設定css,userSelect: none
<Table style={{ userSelect: 'none' }} ></Table>
去除 hover 樣式(這裡使用的是 fusion 元件)
.next-table-row:hover { background-color: transparent !important; }
滑鼠按下,記錄選中元素
為表格繫結滑鼠按鍵時觸發事件 mousedown
。
當滑鼠按下時,這個元素就是中心元素,無論是向哪個方向旋轉,所形成的區域一定會包含初始選中的元素。
getBoundingClientRect()
用於獲得頁面中某個元素的上下左右分別相對瀏覽器視窗的位置。
const onMouseDown = (event) => {
const rect = event.target.getBoundingClientRect();
// funsion 判斷點選是否為表頭元素,為否時才繼續後面的邏輯。antd 不需要判斷,因為點選表頭不會觸發該事件
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>
滑鼠滑過
為表格繫結滑鼠滑過時觸發事件 mousemove
。
根據滑動元素的上下左右距離與滑鼠按下時的位置進行判斷,圈選元素存在四個方向,以第一次選中的元素為中心位置。滑動時元素位於滑鼠按下的右下、左下、右上、左上方,根據不同的情況來設定四個角的方位。
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 元素,如果該元素在圈選的區域內,為其新增選中的背景色,再為四邊形區域增加邊框。
這裡無論是直接設定 style 還是新增 classname 都不是很好。直接新增 classname 時,antd 會在 hover 操作時重置 classname,原來設定的 classname 會被覆蓋。直接設定 style 可能存在和其他設定衝突的情況,並且最後獲取所有圈選元素時比較麻煩。
以上兩種方法都嘗試過,最後選擇了直接往 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 需要為每一個元素新增上透明的邊框,不然會出現佈局抖動的情況。(antd 不用)
/* 為解決設定樣式抖動而設定 */
.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;
}
滑鼠鬆開
為表格繫結滑鼠鬆開時觸發事件 mouseup
。
從滑鼠按下,到滑動,最後鬆開,是一整個圈選流程,在滑鼠按下時儲存了初始的方位,滑動時判斷是否存在方位再進行計算,鬆開時將初始方位置空。
const onMouseUp = () => {
originDir = {};
};
<Table
style={{ userSelect: 'none' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
></Table>
到這一步,就已經實現了滑鼠圈選功能。
複製功能
表格圈選的互動效果其實是為複製功能做準備。
滑鼠右鍵複製
原表格在選中元素時滑鼠右鍵會出現【複製】按鈕,點選後複製的效果是圖中圈選到的元素每一個都換行展示,圈選行為不能滿足使用需求,複製的內容也無法按照頁面中展示的行列格式。
而當我們實現圈選功能之後,因為使用 css 屬性 "user-select: none" 禁止使用者選擇文字,此時滑鼠右鍵已經不會出現複製按鈕。
為了實現滑鼠右鍵出現複製按鈕,我們需要覆蓋原滑鼠右鍵事件,自定義複製功能。
1、為表格繫結滑鼠右鍵事件 contextMenu
<Table
style={{ userSelect: 'none' }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onContextMenu={onContextMenu}
></Table>
2、建立一個包含複製按鈕的自定義上下文選單
<div id="contextMenu" className="context-menu" style={{ cursor: 'pointer' }}>
<div onClick={onClickCopy}>複製</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';
};
這裡複製按鈕沒有調整樣式,可根據自己專案情況進行一些美化。
4、點選複製按鈕時,儲存當前行列格式執行復制操作。
複製仍然保留表格的樣式,這裡想了很久,一直在想透過儲存dom元素的樣式來實現,這種方案存在兩個問題,一是儲存html樣式的api,document.execCommand('copy') 不被瀏覽器支援,二是表格元素都是行內元素,即使複製了樣式,也和頁面上看到的佈局不一樣。
最後採取的方案還是自己對是否換行進行處理,遍歷元素時判斷當前元素的 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}`;
});
// 執行復制操作
navigator.clipboard.writeText(copiedContent).then(() => {
console.log('已複製內容:', copiedContent);
}) .catch((error) => {
console.error('複製失敗:', error);
});
// 隱藏上下文選單
contextMenu.style.display = 'none';
};
5、對滑鼠按下事件 onMouseDown 的處理
- 滑鼠點選右鍵也會觸發 onMouseDown ,這時會造成選中區域錯亂,需要透過 event.button 判斷當前事件觸發的滑鼠位置。
- 滑鼠右鍵後如果沒有點選複製按鈕而是滑走或者使用滑鼠左鍵選中,這時候相當於執行取消複製操作,複製按鈕的上下文需要清除。
const onMouseDown = (event) => {
// 0:表示滑鼠左鍵。2:表示滑鼠右鍵。1:表示滑鼠中鍵或滾輪按鈕
if (event.button !== 0) return;
// 隱藏複製按鈕
const contextMenu = document.getElementById('contextMenu');
contextMenu.style.display = 'none';
};
到這裡,就已經實現了圈選滑鼠右鍵複製的功能。
ctrl+s / command+s 複製
使用 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 中實現的,在 antd 中也可以使用,語法稍有不同。
表格中滑鼠事件需要繫結在 onRow 函式中
<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;
}
實現效果是這樣的
完整程式碼
完整程式碼在這裡 table-brush-copy,包括 fusion design 和 ant design 兩個版本,歡迎大家來點個 star。
總結
表格圈選複製功能的實現主要是以下五步
- mousedown 按下滑鼠,記錄初始座標
- mousemove 滑動滑鼠,計算所形成的四邊形區域
- mouseup 鬆開滑鼠,清空初始座標
- contextmenu 自定義滑鼠右鍵事件,定位上下文事件
- keydown 監聽鍵盤按下位置,判斷是否為複製操作
集合了較多的滑鼠、鍵盤事件,以及 javascript 獲取屬性、元素。