vue-fantable
Version:
Vue table component for huge data
568 lines (501 loc) • 16.7 kB
JSX
import { clsName } from './util/index'
import VeIcon from '@P/ve-icon/ve-icon'
import { ICON_NAMES } from '@P/ve-icon/src/util/constant'
import { getMousePosition, getViewportOffset } from '@P/src/utils/dom'
import {
INIT_DATA,
CONTEXTMENU_NODE_TYPES,
COMPS_NAME
} from './util/constant'
import { getRandomId } from '@P/src/utils/random'
import { debounce, cloneDeep } from '@P/src/utils/index.js'
import eventsOutside from '@P/src/directives/events-outside'
export default {
name: COMPS_NAME.VE_CONTEXTMENU,
directives: {
'events-outside': eventsOutside,
},
props: {
options: {
type: Array,
required: true,
},
// eventTarget: contextmenu event will register on it
eventTarget: {
type: [String, HTMLElement],
required: true,
},
},
emits: ['nodeClick'],
data() {
return {
internalOptions: [],
panelOptions: [],
// event target element
eventTargetEl: '',
// root contextmenu id
rootContextmenuId: '',
/*
is children panels clicked
如果点击了则不关闭 panels
*/
isChildrenPanelsClicked: false,
/*
is panel right direction
决定了子 panel 默认展示方向
*/
isPanelRightDirection: true,
/*
is panels remove
防止hover后菜单被移除,仍然显示子集菜单的问题
*/
isPanelsEmptyed: true,
}
},
computed: {
// active menus ids
activeMenuIds() {
const { panelOptions } = this
return panelOptions.map((x) => x.parentId)
},
},
watch: {
options: {
handler(val) {
if (Array.isArray(val) && val.length > 0) {
/*
如果配置项修改,则重新销毁并创建
*/
this.removeOrEmptyPanels(true)
this.rootContextmenuId = this.getRandomIdWithPrefix()
this.createInternalOptions()
this.createPanelOptions({ options: this.internalOptions })
this.resetContextmenu()
this.addRootContextmenuPanelToBody()
}
},
immediate: true,
},
eventTarget: {
handler(val) {
if (val) {
this.registerContextmenuEvent()
}
},
immediate: true,
},
},
created() {
this.debounceCreatePanelByHover = debounce(
this.createPanelByHover,
300,
)
},
mounted() {
this.addRootContextmenuPanelToBody()
},
unmounted() {
this.removeContextmenuEvent()
this.removeOrEmptyPanels(true)
},
methods: {
// get random id
getRandomIdWithPrefix() {
return clsName(getRandomId())
},
// has children
hasChildren(option) {
return Array.isArray(option.children) && option.children.length
},
// get panel option by menu id
getPanelOptionByMenuId(options, menuId) {
for (let i = 0; i < options.length; i++) {
if (options[i].id === menuId) {
return options[i].children
}
if (options[i].children) {
const panelOption = this.getPanelOptionByMenuId(
options[i].children,
menuId,
)
if (panelOption) return panelOption
}
}
},
// get parent contextmenu panel element
getParentContextmenuPanelEl(contextmenuPanelId) {
let result
const { panelOptions } = this
const panelIndex = panelOptions.findIndex(
(x) => x.parentId === contextmenuPanelId,
)
if (panelIndex > 0) {
// preview panel's panelId
const parentPanelId = panelOptions[panelIndex - 1].parentId
result = document.querySelector(`#${parentPanelId}`)
}
return result
},
// create panel by hover
createPanelByHover({ event, menu }) {
const { internalOptions, panelOptions } = this
// 如果被移除则不创建
if (this.isPanelsEmptyed) {
return false
}
// has already exists
if (panelOptions.findIndex((x) => x.parentId === menu.id) > -1) {
return false
}
// remove panels
// 移除 panel 深度大于等于当前悬浮菜单的。从后往前删除
const deletePanelDeeps = panelOptions
.filter((x) => x.parentDeep >= menu.deep)
.map((x) => x.parentDeep)
.reverse()
if (deletePanelDeeps.length) {
for (let i = deletePanelDeeps.length - 1; i >= 0; i--) {
const delIndex = panelOptions.findIndex(
(x) => x.parentDeep === deletePanelDeeps[i],
)
if (delIndex > -1) {
this.panelOptions.splice(delIndex, 1)
}
}
}
const panelOption = this.getPanelOptionByMenuId(
internalOptions,
menu.id,
)
if (panelOption) {
this.createPanelOptions({
options: panelOption,
currentMenu: menu,
})
this.$nextTick(() => {
this.addContextmenuPanelToBody({
contextmenuId: menu.id,
})
this.showContextmenuPanel({
event,
contextmenuId: menu.id,
})
})
}
},
// create panels option
createPanelOptions({ options, currentMenu }) {
const { hasChildren, rootContextmenuId } = this
if (Array.isArray(options)) {
//
const menus = options.map((option) => {
return {
hasChildren: hasChildren(option),
...option,
}
})
this.panelOptions.push({
parentId: currentMenu ? currentMenu.id : rootContextmenuId,
parentDeep: currentMenu
? currentMenu.deep
: INIT_DATA.PARENT_DEEP,
menus,
})
}
},
// create internal options recursion
createInternalOptionsRecursion(options, deep = 0) {
options.id = this.getRandomIdWithPrefix()
options.deep = deep
deep++
if (Array.isArray(options.children)) {
options.children.map((option) => {
return this.createInternalOptionsRecursion(option, deep)
})
}
return options
},
// create internal options
createInternalOptions() {
this.internalOptions = cloneDeep(this.options).map((option) => {
return this.createInternalOptionsRecursion(option)
})
},
// show root contextmenu panel
showRootContextmenuPanel(event) {
event.preventDefault()
const { rootContextmenuId } = this
if (rootContextmenuId) {
// refresh contextmenu
this.resetContextmenu()
this.showContextmenuPanel({
event,
contextmenuId: rootContextmenuId,
isRootContextmenu: true,
})
this.isPanelsEmptyed = false
}
},
// show contextmenu panel
showContextmenuPanel({ event, contextmenuId, isRootContextmenu }) {
const { getParentContextmenuPanelEl } = this
const contextmenuPanelEl = document.querySelector(`#${contextmenuId}`)
if (contextmenuPanelEl) {
// remove first
contextmenuPanelEl.innerHTML = ''
contextmenuPanelEl.appendChild(this.$refs[contextmenuId])
contextmenuPanelEl.style.position = 'absolute'
contextmenuPanelEl.classList.add(clsName('popper'))
const { width: currentPanelWidth, height: currentPanelHeight } =
contextmenuPanelEl.getBoundingClientRect()
if (isRootContextmenu) {
const {
left: clickLeft,
top: clickTop,
right: clickRight,
bottom: clickBottom,
} = getMousePosition(event)
let panelX = 0
let panelY = 0
// 右方宽度够显示
if (clickRight >= currentPanelWidth) {
panelX = clickLeft
this.isPanelRightDirection = true
} else { // 右方宽度不够显示在鼠标点击左方
panelX = clickLeft - currentPanelWidth
this.isPanelRightDirection = false
}
// 下方高度够显示
if (clickBottom >= currentPanelHeight) {
panelY = clickTop
} else { // 下方高度不够显示在鼠标点击上方
panelY = clickTop - currentPanelHeight
}
contextmenuPanelEl.style.left = panelX + 'px'
contextmenuPanelEl.style.top = panelY + 'px'
} else {
const parentContextmenuPanelEl =
getParentContextmenuPanelEl(contextmenuId)
if (parentContextmenuPanelEl) {
const {
left: parentPanelLeft,
right: parentPanelRight,
} = getViewportOffset(parentContextmenuPanelEl)
const { top: clickTop, bottom: clickBottom } =
getMousePosition(event)
const { width: parentPanelWidth } =
parentContextmenuPanelEl.getBoundingClientRect()
let panelX = 0
let panelY = 0
// 如果默认展示在右方向
if (this.isPanelRightDirection) {
// 右方宽度够显示
if (parentPanelRight >= currentPanelWidth) {
panelX = parentPanelLeft + parentPanelWidth
} else { // 右方宽度不够显示在鼠标点击左方
panelX = parentPanelLeft - parentPanelWidth
}
} else { // 如果默认展示在左方向
// 左方宽度够显示
if (parentPanelLeft >= currentPanelWidth) {
panelX = parentPanelLeft - parentPanelWidth
} else { // 左方宽度不够显示在鼠标点击右方
panelX = parentPanelLeft + parentPanelWidth
}
}
// 下方高度够显示
if (clickBottom >= currentPanelHeight) {
panelY = clickTop
} else { // 下方高度不够显示在鼠标点击上方
panelY = clickTop - currentPanelHeight
}
contextmenuPanelEl.style.left = panelX + 'px'
contextmenuPanelEl.style.top = panelY + 'px'
}
}
}
},
// empty contextmenu panels
emptyContextmenuPanels() {
// wait for children panel clicked by setTimeout
// 如果点击的是非 root panel 不关闭
setTimeout(() => {
if (this.isChildrenPanelsClicked) {
this.isChildrenPanelsClicked = false
} else {
this.removeOrEmptyPanels()
this.isPanelsEmptyed = true
}
})
},
// remove or empty panels
removeOrEmptyPanels(isRemove) {
const { panelOptions } = this
panelOptions.forEach((panelOption) => {
const contextmenuPanelEl = document.querySelector(
`#${panelOption.parentId}`,
)
if (contextmenuPanelEl) {
if (isRemove) {
contextmenuPanelEl.remove()
} else {
contextmenuPanelEl.innerHTML = ''
}
}
})
},
// reset contextmeny
resetContextmenu() {
this.panelOptions = []
this.createPanelOptions({ options: this.internalOptions })
},
// add context menu panel to body
addContextmenuPanelToBody({ contextmenuId }) {
const contextmenuPanelEl = document.querySelector(`#${contextmenuId}`)
if (contextmenuPanelEl) {
return false
} else {
const containerEl = document.createElement('div')
containerEl.setAttribute('id', contextmenuId)
document.body.appendChild(containerEl)
}
},
// add root contextmenu panel to body
addRootContextmenuPanelToBody() {
if (this.rootContextmenuId) {
this.addContextmenuPanelToBody({
contextmenuId: this.rootContextmenuId,
})
}
},
// register contextmenu event
registerContextmenuEvent() {
const { eventTarget } = this
if (typeof eventTarget === 'string' && eventTarget.length > 0) {
this.eventTargetEl = document.querySelector(eventTarget)
} else {
this.eventTargetEl = eventTarget
}
if (this.eventTargetEl) {
// contextmenu is on the current element
this.eventTargetEl.addEventListener(
'contextmenu',
this.showRootContextmenuPanel,
)
}
},
// unregister contextmen event
removeContextmenuEvent() {
if (this.eventTargetEl) {
this.eventTargetEl.removeEventListener(
'contextmenu',
this.showRootContextmenuPanel,
)
}
},
// hide contextmenu
// INSTANCE_METHODS.HIDE_CONTEXTMENU
hideContextmenu() {
this.emptyContextmenuPanels()
},
},
render() {
const {
panelOptions,
activeMenuIds,
hasChildren,
emptyContextmenuPanels,
debounceCreatePanelByHover,
} = this
const contextmenuProps = {
class: ['ve-contextmenu'],
style: { display: 'none' },
}
return (
<div {...contextmenuProps}>
{panelOptions.map((panelOption, panelIndex) => {
const contextmenuPanelProps = {
ref: panelOption.parentId,
class: clsName('panel'),
onClick: () => {
if (panelIndex !== 0) {
this.isChildrenPanelsClicked = true
}
},
onContextmenu: (e) => {
e.preventDefault()
},
}
return (
<div {...contextmenuPanelProps} v-events-outside={{
events: ['click'],
callback: (e) => {
// only for root panel
if (panelIndex === 0) {
emptyContextmenuPanels()
}
},
}}>
<ul class={clsName('list')}>
{panelOption.menus.map((menu) => {
let contextmenuNodeProps
if (menu.type !== CONTEXTMENU_NODE_TYPES.SEPARATOR) {
const nodeActive = clsName('node-active')
const nodeDisable = clsName('node-disabled')
contextmenuNodeProps = {
class: {
[clsName('node')]: true,
[nodeActive]: activeMenuIds.includes(menu.id),
[nodeDisable]: menu.disabled
},
onMouseover: (event) => {
// disable
if (!menu.disabled) {
debounceCreatePanelByHover({ event, menu })
}
},
onClick: () => {
if (!menu.disabled && !hasChildren(menu)) {
// EMIT_EVENTS.ON_NODE_CLICK,
this.$emit('nodeClick', menu.type)
setTimeout(() => {
emptyContextmenuPanels()
}, 50)
}
},
}
} else { // separator
contextmenuNodeProps = {
class: {
[clsName('node-separator')]: true,
},
}
}
if (menu.type !== CONTEXTMENU_NODE_TYPES.SEPARATOR) {
return (
<li {...contextmenuNodeProps}>
<span class={clsName('node-label')}>
{menu.label}
</span>
{menu.hasChildren && (
<VeIcon
class={clsName('node-icon-postfix')}
name={ICON_NAMES.RIGHT_ARROW}
/>
)}
</li>
)
} else {
return (
<li {...contextmenuNodeProps}></li>
)
}
})}
</ul>
</div>
)
})}
</div>
)
},
}