@logicflow/extension
Version:
LogicFlow Extensions
442 lines (414 loc) • 13.4 kB
text/typescript
import LogicFlow from '@logicflow/core'
import GraphData = LogicFlow.GraphData
import NodeData = LogicFlow.NodeData
import EdgeData = LogicFlow.EdgeData
import Position = LogicFlow.Position
type SetType = 'add' | 'reset'
export type MenuItem = {
text?: string
className?: string
icon?: boolean
callback: (element: any) => void
}
export type MenuConfig = {
nodeMenu?: MenuItem[] | false
edgeMenu?: MenuItem[] | false
graphMenu?: MenuItem[] | false
}
const DefaultNodeMenuKey = 'lf:defaultNodeMenu'
const DefaultEdgeMenuKey = 'lf:defaultEdgeMenu'
const DefaultGraphMenuKey = 'lf:defaultGraphMenu'
const DefaultSelectionMenuKey = 'lf:defaultSelectionMenu'
class Menu {
lf: LogicFlow
private __container?: HTMLElement
private __menuDOM?: HTMLElement
private menuTypeMap?: Map<string, MenuItem[]>
private __currentData: EdgeData | NodeData | GraphData | Position | null =
null
static pluginName = 'menu'
constructor({ lf }) {
this.lf = lf
const {
options: { isSilentMode },
} = lf
if (!isSilentMode) {
this.__menuDOM = document.createElement('ul')
this.menuTypeMap = new Map()
this.init()
this.lf.setMenuConfig = (config) => {
this.setMenuConfig(config)
}
this.lf.addMenuConfig = (config) => {
this.addMenuConfig(config)
}
this.lf.setMenuByType = (config) => {
this.setMenuByType(config)
}
}
}
/**
* 初始化设置默认内置菜单栏
*/
private init() {
const defaultNodeMenu = [
{
text: '删除',
callback: (node) => {
this.lf.deleteNode(node.id)
},
},
{
text: '编辑文本',
callback: (node) => {
this.lf.graphModel.editText(node.id)
},
},
{
text: '复制',
callback: (node) => {
this.lf.cloneNode(node.id)
},
},
]
this.menuTypeMap?.set(DefaultNodeMenuKey, defaultNodeMenu)
const defaultEdgeMenu = [
{
text: '删除',
callback: (edge) => {
this.lf.deleteEdge(edge.id)
},
},
{
text: '编辑文本',
callback: (edge) => {
this.lf.graphModel.editText(edge.id)
},
},
]
this.menuTypeMap?.set(DefaultEdgeMenuKey, defaultEdgeMenu)
this.menuTypeMap?.set(DefaultGraphMenuKey, [])
const DefaultSelectionMenu = [
{
text: '删除',
callback: (elements) => {
this.lf.clearSelectElements()
elements.edges.forEach((edge) => this.lf.deleteEdge(edge.id))
elements.nodes.forEach((node) => this.lf.deleteNode(node.id))
},
},
]
this.menuTypeMap?.set(DefaultSelectionMenuKey, DefaultSelectionMenu)
}
render(lf: LogicFlow, container: HTMLElement) {
if (lf.options.isSilentMode) return
this.__container = container
this.__currentData = null // 当前展示的菜单所属元素的model数据
if (this.__menuDOM) {
this.__menuDOM.className = 'lf-menu'
container.appendChild(this.__menuDOM)
// 将选项的click事件委托至menu容器
// 在捕获阶段拦截并执行
this.__menuDOM.addEventListener(
'click',
(event) => {
event.stopPropagation()
let target = event.target as HTMLElement
// 菜单有多层dom,需要精确获取菜单项所对应的dom
// 除菜单项dom外,应考虑两种情况
// 1. 菜单项的子元素 2. 菜单外层容器
while (
Array.from(target.classList).indexOf('lf-menu-item') === -1 &&
Array.from(target.classList).indexOf('lf-menu') === -1
) {
target = target?.parentElement as HTMLElement
}
if (Array.from(target.classList).indexOf('lf-menu-item') > -1) {
// 如果点击区域在菜单项内
;(target as any).onclickCallback(this.__currentData)
// 点击后隐藏menu
if (this.__menuDOM) {
this.__menuDOM.style.display = 'none'
}
this.__currentData = null
} else {
// 如果点击区域不在菜单项内
console.warn('点击区域不在菜单项内,请检查代码!')
}
},
true,
)
}
// 通过事件控制菜单的显示和隐藏
this.lf.on('node:contextmenu', ({ data, position, e }) => {
const {
domOverlayPosition: { x, y },
} = position
const { id } = data
const model = this.lf.graphModel.getNodeModelById(id)
if (!model) return
let menuList: any = []
const typeMenus = this.menuTypeMap?.get(model.type)
// 1.如果单个节点自定义了菜单,以单个节点自定义为准
if (model && model.menu && Array.isArray(model.menu)) {
menuList = model.menu
} else if (typeMenus) {
// 2.如果当前节点类型定义了菜单,再取该配置
menuList = typeMenus
} else {
// 3.最后取全局默认
menuList = this.menuTypeMap?.get(DefaultNodeMenuKey)
}
this.__currentData = data
this.showMenu(x, y, menuList, {
width: model.width,
height: model.height,
clientX: e.clientX,
clientY: e.clientY,
})
})
this.lf.on('edge:contextmenu', ({ data, position, e }) => {
const {
domOverlayPosition: { x, y },
} = position
const { id } = data
const model = this.lf.graphModel.getEdgeModelById(id)
if (!model) return
let menuList: any = []
const typeMenus = this.menuTypeMap?.get(model.type)
// 菜单优先级: model.menu > typeMenus > defaultEdgeMenu,注释同上节点
if (model && model.menu && Array.isArray(model.menu)) {
menuList = model.menu
} else if (typeMenus) {
menuList = typeMenus
} else {
menuList = this.menuTypeMap?.get(DefaultEdgeMenuKey) ?? []
}
this.__currentData = data
this.showMenu(x, y, menuList, {
width: model.width,
height: model.height,
clientX: e.clientX,
clientY: e.clientY,
})
})
this.lf.on('blank:contextmenu', ({ position }) => {
const menuList = this.menuTypeMap?.get(DefaultGraphMenuKey) ?? []
const {
domOverlayPosition: { x, y },
} = position
this.__currentData = { ...position.canvasOverlayPosition }
this.showMenu(x, y, menuList)
})
this.lf.on('selection:contextmenu', ({ data, position }) => {
const menuList = this.menuTypeMap?.get(DefaultSelectionMenuKey)
const {
domOverlayPosition: { x, y },
} = position
this.__currentData = data
this.showMenu(x, y, menuList)
})
this.lf.on('node:mousedown', () => {
this.__menuDOM!.style.display = 'none'
})
this.lf.on('edge:click', () => {
this.__menuDOM!.style.display = 'none'
})
this.lf.on('blank:click', () => {
this.__menuDOM!.style.display = 'none'
})
}
destroy() {
if (this.__menuDOM) {
this?.__container?.removeChild(this.__menuDOM)
this.__menuDOM = undefined
}
}
private showMenu(x, y, menuList, options?) {
if (!menuList || !menuList.length) return
const { __menuDOM: menu } = this
if (menu) {
// 菜单容器不变,需要先清空内部的菜单项
menu.innerHTML = ''
menu.append(...this.__getMenuDom(menuList))
// 菜单中没有项,不显示
if (!menu.children.length) return
menu.style.display = 'block'
if (!options) {
menu.style.top = `${y}px`
menu.style.left = `${x}px`
return
}
// https://github.com/didi/LogicFlow/issues/1019
// 根据边界判断菜单的left 和 top
const { width, height, clientX, clientY } = options
const { graphModel } = this.lf
const menuWidth = menu.offsetWidth
let menuIsRightShow = true
// ======先进行可视屏幕范围的判断=======
// 浏览器窗口可视区域兼容性写法
// eslint-disable-next-line max-len
const windowMaxX =
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
let rightDistance = windowMaxX - clientX
// ======先进行可视屏幕范围的判断=======
// ========再进行画布范围的判断========
const graphRect = graphModel.rootEl.getBoundingClientRect()
const graphMaxX = graphRect.left + graphRect.width
if (graphMaxX < windowMaxX) {
// 画布右边小于可视屏幕范围的最右边,取画布右边作为极限值,计算出当前触摸点距离右边极限值的距离
rightDistance = graphMaxX - clientX
}
// ========再进行画布范围的判断========
// 根据当前触摸点距离右边的距离 跟 menuWidth进行比较
if (rightDistance < menuWidth) {
// 空间不足够,显示在左边
menuIsRightShow = false
}
if (menuIsRightShow) {
menu.style.left = `${x}px`
} else {
menu.style.left = `${x - width}px`
}
const menuHeight = menu.offsetHeight
let menuIsBottomShow = true
// ======先进行可视屏幕范围的判断=======
// 浏览器窗口可视区域兼容性写法
// eslint-disable-next-line max-len
const windowMaxY =
window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight
let bottomDistance = windowMaxY - clientY
// ======先进行可视屏幕范围的判断=======
// ========再进行画布范围的判断========
const graphMaxY = graphRect.top + graphRect.height
if (graphMaxY < windowMaxY) {
// 画布底部小于可视屏幕范围的最底边,取画布底部作为极限值,计算出当前触摸点距离底部极限值的距离
bottomDistance = graphMaxY - clientY
}
// ========再进行画布范围的判断========
if (bottomDistance < menuHeight) {
// 如果下边距离太小,无法显示menu,则向上显示
menuIsBottomShow = false
}
if (menuIsBottomShow) {
menu.style.top = `${y}px`
} else {
menu.style.top = `${y - height}px`
}
}
}
/**
* 设置指定类型元素的菜单
*/
setMenuByType({ type, menu }: { type: string; menu: MenuItem[] }) {
if (!type || !menu) {
return
}
this.menuTypeMap?.set(type, menu)
}
/**
* 获取 Menu DOM
* @param list 菜单项
* @return 菜单项 DOM
*/
private __getMenuDom(list): HTMLElement[] {
const menuList: any = []
list &&
list.length > 0 &&
list.forEach((item) => {
const element = document.createElement('li')
if (item.className) {
element.className = `lf-menu-item ${item.className}`
} else {
element.className = 'lf-menu-item'
}
if (item.icon === true) {
const icon = document.createElement('span')
icon.className = 'lf-menu-item-icon'
element.appendChild(icon)
}
const text = document.createElement('span')
text.className = 'lf-menu-item-text'
if (item.text) {
text.innerText = item.text
}
element.appendChild(text)
;(element as any).onclickCallback = item.callback
menuList.push(element)
})
return menuList
}
// 复写菜单
setMenuConfig(config: MenuConfig) {
if (!config) {
return
}
// node
config.nodeMenu !== undefined &&
this.menuTypeMap?.set(
DefaultNodeMenuKey,
config.nodeMenu ? config.nodeMenu : [],
)
// edge
config.edgeMenu !== undefined &&
this.menuTypeMap?.set(
DefaultEdgeMenuKey,
config.edgeMenu ? config.edgeMenu : [],
)
// graph
config.graphMenu !== undefined &&
this.menuTypeMap?.set(
DefaultGraphMenuKey,
config.graphMenu ? config.graphMenu : [],
)
}
// 在默认菜单后面追加菜单项
addMenuConfig(config: MenuConfig) {
if (!config) {
return
}
// 追加项时,只支持数组类型,对false不做操作
if (Array.isArray(config.nodeMenu)) {
const menuList = this.menuTypeMap?.get(DefaultNodeMenuKey) ?? []
this.menuTypeMap?.set(
DefaultNodeMenuKey,
menuList.concat(config.nodeMenu),
)
}
if (Array.isArray(config.edgeMenu)) {
const menuList = this.menuTypeMap?.get(DefaultEdgeMenuKey) ?? []
this.menuTypeMap?.set(
DefaultEdgeMenuKey,
menuList.concat(config.edgeMenu),
)
}
if (Array.isArray(config.graphMenu)) {
const menuList = this.menuTypeMap?.get(DefaultGraphMenuKey) ?? []
this.menuTypeMap?.set(
DefaultGraphMenuKey,
menuList.concat(config.graphMenu),
)
}
}
/**
* @deprecated
* 复写添加
*/
changeMenuItem(type: SetType, config: MenuConfig) {
if (type === 'add') {
this.addMenuConfig(config)
} else if (type === 'reset') {
this.setMenuConfig(config)
} else {
throw new Error(
"The first parameter of changeMenuConfig should be 'add' or 'reset'",
)
}
}
}
export default Menu
export { Menu }