前端實(shí)現(xiàn)右鍵自定義菜單
大家好,我是前端西瓜哥。
本文將講解 Web 頁面如何實(shí)現(xiàn)自定義菜單功能。
線上 demo:
https://codepen.io/F-star/pen/WNOvQVQ。
思路
核心思路是:注冊 contextmenu 事件,取消該事件的默認(rèn)行為,然后通過 event 對象拿到光標(biāo)相對視口的坐標(biāo)位置(event.clientX 和 event.clientY),通過絕對定位的方式,將自己自定義的初始化時(shí)不可見的 div 塊顯示出來。
實(shí)現(xiàn)
DOM 結(jié)構(gòu)
首先是 DOM 結(jié)構(gòu)。結(jié)構(gòu)依次為:
- div.page-view 為注冊 contextmenu 事件的元素。
- div.contextmenu-mask 是遮罩層,遮住整個(gè)窗口。它隨右鍵菜單出現(xiàn)而出現(xiàn),作用是防止用戶調(diào)出右鍵菜單后,還可以點(diǎn)擊菜單外的按鈕。此外還可以添加有透明度的背景色,但這樣效果就類似彈窗了。一般來說,都是不設(shè)置底色的。
- div.contextmenu-content 右鍵菜單的內(nèi)容。
<div class="page-view">
點(diǎn)擊區(qū)域
</div>
<div class="contextmenu-mask" style="display: none;"></div>
<div class="contextmenu-content">
<div class="list">
<div class="item">復(fù)制</div>
<div class="item">剪切</div>
<div class="item">粘貼粘貼粘貼粘貼粘貼粘貼粘貼粘貼</div>
<div class="item">全選</div>
</div>
</div>
CSS 樣式
.page-view {
margin: 0 auto;
width: 90%;
height: calc(100vh - 30px);
background-color: azure;
}
/* 遮罩層 */
.contextmenu-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
/* background-color: #000; */
/* opacity: .2; */
z-index: 45;
}
/* 菜單內(nèi)容的容器 */
.contextmenu-content {
position: fixed;
left: 999999px;
top: 999999px;
z-index: 50;
user-select: none;
}
/* 例子使用內(nèi)容 */
.list {
border: 1px solid #555;
border-radius: 4px;
min-width: 180px;
overflow: hidden; /* 處理圓角 */
}
.item {
box-sizing: border-box;
padding: 0 5px;
height: 30px;
line-height: 30px;
word-break: keep-all; /* 很重要,否則會換行 */
background-color: #fff;
cursor: default;
}
.item:hover {
background-color: dodgerblue;
color: #fff;
}
這里有幾個(gè)注意點(diǎn):
- contextmenu-content 并沒有使用 display: none 的方式進(jìn)行隱藏,而是通過設(shè)置非常大的 left 和 top 的方式跑到窗口外的遠(yuǎn)方。這是有原因的,我們將會在后面的腳本邏輯中進(jìn)行詳細(xì)講解。
- item 需要設(shè)置 word-break: keep-all; 。因?yàn)楫?dāng)菜單跑到窗口外時(shí),寬度會變成最小寬度,在這里是 180px。只有設(shè)置了該屬性和值,才能讓文字不換行,得到我們想要的寬度。
- contextmenu-content 需要使用固定定位,不能使用絕對定位。因?yàn)樵O(shè)置了大值的 left 和 top 的元素,對不是 overflow: hidden 的容器元素,會產(chǎn)生一個(gè)非常長的滾動(dòng)條。固定定位則不會。
腳本邏輯
右鍵顯示菜單
首先取消掉點(diǎn)擊區(qū)域的菜單事件的默認(rèn)行為。
拿到光標(biāo)的坐標(biāo),為防止菜單部分跑到窗口外,導(dǎo)致被切割,需要對坐標(biāo)進(jìn)行調(diào)整。對此我們需要再拿到 菜單的寬高、窗口可視區(qū)域?qū)捀摺?/p>
此外為了防止菜單邊緣緊貼窗口邊緣,效果不美觀,需要設(shè)置一個(gè) 最小 padding 值 參與計(jì)算。
被截?cái)嗟牟藛危?/p>
緊貼窗口邊緣的菜單:
以設(shè)置橫坐標(biāo)為例,有:
if (e.clientX + contextmenuWidth > document.documentElement.clientWidth - PADDING_RIGHT) {
finalX = e.clientX - contextmenuWidth
}
這里代碼的意思是:當(dāng)預(yù)測發(fā)現(xiàn)當(dāng)前光標(biāo)作為菜單的左側(cè)時(shí),會導(dǎo)致菜單右側(cè)一部分被切割,就以當(dāng)前坐標(biāo)作為菜單的右側(cè),此時(shí)的左上角的坐標(biāo)為光標(biāo)減去菜單寬度的值。
完整代碼為:
const areaEl = document.querySelector('.page-view')
const mask = document.querySelector('.contextmenu-mask')
const contentEl = document.querySelector('.contextmenu-content')
/**
*
* @param {number} x 將要設(shè)置的菜單的左上角坐標(biāo) x
* @param {number} y 左上角 y
* @param {number} w 菜單的寬度
* @param {number} h 菜單的高度
* @returns {x, y} 調(diào)整后的坐標(biāo)
*/
const adjustPos = (x, y, w, h) => {
const PADDING_RIGHT = 6 // 右邊留點(diǎn)空位,防止直接貼邊了,不好看
const PADDING_BOTTOM = 6 // 底部也留點(diǎn)空位
const vw = document.documentElement.clientWidth
const vh = document.documentElement.clientHeight
if (x + w > vw - PADDING_RIGHT) x -= w
if (y + h > vh - PADDING_BOTTOM) y -= h
return {x, y}
}
const onContextMenu = e => {
e.preventDefault()
const rect = contentEl.getBoundingClientRect()
// console.log(rect)
const { x, y } = adjustPos(e.clientX, e.clientY, rect.width, rect.height)
showContextMenu(x, y)
}
// 阻止指定元素下的菜單事件
areaEl.addEventListener('contextmenu', onContextMenu, false)
隱藏右鍵菜單沒有使用常規(guī)的 display: none;,而是改為使用設(shè)置了很大值的 left 和 top。這是因?yàn)槲乙獙?shí)現(xiàn)的是 自適應(yīng)寬高 的右鍵菜單。
為此需要?jiǎng)討B(tài)拿到菜單的寬高,需要用到
Element.getBoundingClientRect() 方法,而這個(gè)方法需要元素在 DOM 樹中,且為可見元素,才能拿到寬高,否則只能拿到兩個(gè) 0。
如果你要實(shí)現(xiàn)的菜單是手動(dòng)寫死寬度的,高度通過菜單項(xiàng)的數(shù)量來計(jì)算的,那么隱藏菜單最好的方案是 display: none。
隱藏菜單和點(diǎn)擊菜單項(xiàng)
然后就是點(diǎn)擊遮罩層,隱藏菜單和遮罩。以及點(diǎn)擊菜單項(xiàng),執(zhí)行對應(yīng)的命令
const hideContextMenu = () => {
mask.style.display = 'none'
contentEl.style.top = '99999px'
contentEl.style.left = '99999px'
}
// 點(diǎn)擊蒙版,隱藏
mask.addEventListener('mousedown', () => {
hideContextMenu()
}, false)
// 點(diǎn)擊菜單,隱藏
contentEl.addEventListener('click', (e) => {
console.log('點(diǎn)擊:', e.target.textContent)
// 執(zhí)行菜單項(xiàng)對應(yīng)命令
hideContextMenu()
}, false)
其他要考慮的地方
- 窗口縮小的情況:窗口縮小會導(dǎo)致右下方的菜單跑到窗口區(qū)域外,是否考慮監(jiān)聽窗口事件。
- 菜單上再點(diǎn)右鍵的邏輯:是以這個(gè)位置重新定位右鍵菜單,還是等同于點(diǎn)擊了左鍵的效果,還是不進(jìn)行處理,彈出瀏覽器原生右鍵菜單,需要根據(jù)需求進(jìn)行選擇。
結(jié)尾
實(shí)現(xiàn)自定義菜單的邏輯并不復(fù)雜,也就是修改 contextmenu 事件的行為,顯示或隱藏自己寫的 div。
但里面有些細(xì)節(jié)需要處理好,才能寫出一個(gè)沒有 bug 的優(yōu)秀右鍵菜單。