修复SVG导出表格节点问题:使用foreignObject替代原生SVG文本,解决XML语法错误

- 将表格渲染从TableToSVGConverter改为foreignObject方式
- 修复XML语法错误:字体名加引号、添加命名空间、字符转义
- 解决表格内容压缩、对齐混乱、文本溢出问题
- 实现表格自动换行和列宽自适应
- 确保SVG导出的表格布局与HTML显示一致
This commit is contained in:
lixinran 2025-10-11 01:31:06 +08:00
parent 35766881dd
commit 2a09a6b05c
7 changed files with 525 additions and 494 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -23,7 +23,7 @@
flex-direction: column;
}
</style>
<script type="module" crossorigin src="/assets/index-3cfab743.js"></script>
<script type="module" crossorigin src="/assets/index-3ece160d.js"></script>
<link rel="stylesheet" href="/assets/index-356fe347.css">
</head>
<body>

View File

@ -1,229 +1,229 @@
import i18n from '../i18n'
import type { Topic } from '../types/dom'
import type { MindElixirInstance } from '../types/index'
import { encodeHTML, isTopic } from '../utils/index'
import './contextMenu.less'
import type { ArrowOptions } from '../arrow'
export type ContextMenuOption = {
focus?: boolean
link?: boolean
extend?: {
name: string
key?: string
onclick: (e: MouseEvent) => void
}[]
}
export default function (mind: MindElixirInstance, option: true | ContextMenuOption) {
option =
option === true
? {
focus: true,
link: true,
}
: option
const createTips = (words: string) => {
const div = document.createElement('div')
div.innerText = words
div.className = 'tips'
return div
}
const createLi = (id: string, name: string, keyname: string) => {
const li = document.createElement('li')
li.id = id
li.innerHTML = `<span>${encodeHTML(name)}</span><span ${keyname ? 'class="key"' : ''}>${encodeHTML(keyname)}</span>`
return li
}
const locale = i18n[mind.locale] ? mind.locale : 'en'
const lang = i18n[locale]
const add_child = createLi('cm-add_child', lang.addChild, 'Tab')
const add_parent = createLi('cm-add_parent', lang.addParent, 'Ctrl + Enter')
const add_sibling = createLi('cm-add_sibling', lang.addSibling, 'Enter')
const remove_child = createLi('cm-remove_child', lang.removeNode, 'Delete')
const focus = createLi('cm-fucus', lang.focus, '')
const unfocus = createLi('cm-unfucus', lang.cancelFocus, '')
const up = createLi('cm-up', lang.moveUp, 'PgUp')
const down = createLi('cm-down', lang.moveDown, 'Pgdn')
const link = createLi('cm-link', lang.link, '')
const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '')
const summary = createLi('cm-summary', lang.summary, '')
const menuUl = document.createElement('ul')
menuUl.className = 'menu-list'
menuUl.appendChild(add_child)
menuUl.appendChild(add_parent)
menuUl.appendChild(add_sibling)
menuUl.appendChild(remove_child)
if (option.focus) {
menuUl.appendChild(focus)
menuUl.appendChild(unfocus)
}
menuUl.appendChild(up)
menuUl.appendChild(down)
menuUl.appendChild(summary)
if (option.link) {
menuUl.appendChild(link)
menuUl.appendChild(linkBidirectional)
}
if (option && option.extend) {
for (let i = 0; i < option.extend.length; i++) {
const item = option.extend[i]
const dom = createLi(item.name, item.name, item.key || '')
menuUl.appendChild(dom)
dom.onclick = e => {
item.onclick(e)
}
}
}
const menuContainer = document.createElement('div')
menuContainer.className = 'context-menu'
menuContainer.appendChild(menuUl)
menuContainer.hidden = true
mind.container.append(menuContainer)
let isRoot = true
// Helper function to actually render and position context menu.
const showMenu = (e: MouseEvent) => {
console.log('showContextMenu', e)
const target = e.target as HTMLElement
if (isTopic(target)) {
if (target.parentElement!.tagName === 'ME-ROOT') {
isRoot = true
} else {
isRoot = false
}
if (isRoot) {
focus.className = 'disabled'
up.className = 'disabled'
down.className = 'disabled'
add_parent.className = 'disabled'
add_sibling.className = 'disabled'
remove_child.className = 'disabled'
} else {
focus.className = ''
up.className = ''
down.className = ''
add_parent.className = ''
add_sibling.className = ''
remove_child.className = ''
}
menuContainer.hidden = false
menuUl.style.top = ''
menuUl.style.bottom = ''
menuUl.style.left = ''
menuUl.style.right = ''
const rect = menuUl.getBoundingClientRect()
const height = menuUl.offsetHeight
const width = menuUl.offsetWidth
const relativeY = e.clientY - rect.top
const relativeX = e.clientX - rect.left
if (height + relativeY > window.innerHeight) {
menuUl.style.top = ''
menuUl.style.bottom = '0px'
} else {
menuUl.style.bottom = ''
menuUl.style.top = relativeY + 15 + 'px'
}
if (width + relativeX > window.innerWidth) {
menuUl.style.left = ''
menuUl.style.right = '0px'
} else {
menuUl.style.right = ''
menuUl.style.left = relativeX + 10 + 'px'
}
}
}
mind.bus.addListener('showContextMenu', showMenu)
menuContainer.onclick = e => {
if (e.target === menuContainer) menuContainer.hidden = true
}
add_child.onclick = () => {
mind.addChild()
menuContainer.hidden = true
}
add_parent.onclick = () => {
mind.insertParent()
menuContainer.hidden = true
}
add_sibling.onclick = () => {
if (isRoot) return
mind.insertSibling('after')
menuContainer.hidden = true
}
remove_child.onclick = () => {
if (isRoot) return
mind.removeNodes(mind.currentNodes || [])
menuContainer.hidden = true
}
focus.onclick = () => {
if (isRoot) return
mind.focusNode(mind.currentNode as Topic)
menuContainer.hidden = true
}
unfocus.onclick = () => {
mind.cancelFocus()
menuContainer.hidden = true
}
up.onclick = () => {
if (isRoot) return
mind.moveUpNode()
menuContainer.hidden = true
}
down.onclick = () => {
if (isRoot) return
mind.moveDownNode()
menuContainer.hidden = true
}
const linkFunc = (options?: ArrowOptions) => {
menuContainer.hidden = true
const from = mind.currentNode as Topic
const tips = createTips(lang.clickTips)
mind.container.appendChild(tips)
mind.map.addEventListener(
'click',
e => {
e.preventDefault()
tips.remove()
const target = e.target as Topic
if (target.parentElement.tagName === 'ME-PARENT' || target.parentElement.tagName === 'ME-ROOT') {
mind.createArrow(from, target, options)
} else {
console.log('link cancel')
}
},
{
once: true,
}
)
}
link.onclick = () => linkFunc()
linkBidirectional.onclick = () => linkFunc({ bidirectional: true })
summary.onclick = () => {
menuContainer.hidden = true
mind.createSummary()
mind.unselectNodes(mind.currentNodes)
}
return () => {
// maybe useful?
add_child.onclick = null
add_parent.onclick = null
add_sibling.onclick = null
remove_child.onclick = null
focus.onclick = null
unfocus.onclick = null
up.onclick = null
down.onclick = null
link.onclick = null
summary.onclick = null
menuContainer.onclick = null
mind.container.oncontextmenu = null
}
}
import i18n from '../i18n'
import type { Topic } from '../types/dom'
import type { MindElixirInstance } from '../types/index'
import { encodeHTML, isTopic } from '../utils/index'
import './contextMenu.less'
import type { ArrowOptions } from '../arrow'
export type ContextMenuOption = {
focus?: boolean
link?: boolean
extend?: {
name: string
key?: string
onclick: (e: MouseEvent) => void
}[]
}
export default function (mind: MindElixirInstance, option: true | ContextMenuOption) {
option =
option === true
? {
focus: true,
link: true,
}
: option
const createTips = (words: string) => {
const div = document.createElement('div')
div.innerText = words
div.className = 'tips'
return div
}
const createLi = (id: string, name: string, keyname: string) => {
const li = document.createElement('li')
li.id = id
li.innerHTML = `<span>${encodeHTML(name)}</span><span ${keyname ? 'class="key"' : ''}>${encodeHTML(keyname)}</span>`
return li
}
const locale = i18n[mind.locale] ? mind.locale : 'en'
const lang = i18n[locale]
const add_child = createLi('cm-add_child', lang.addChild, 'Tab')
const add_parent = createLi('cm-add_parent', lang.addParent, 'Ctrl + Enter')
const add_sibling = createLi('cm-add_sibling', lang.addSibling, 'Enter')
const remove_child = createLi('cm-remove_child', lang.removeNode, 'Delete')
const focus = createLi('cm-fucus', lang.focus, '')
const unfocus = createLi('cm-unfucus', lang.cancelFocus, '')
const up = createLi('cm-up', lang.moveUp, 'PgUp')
const down = createLi('cm-down', lang.moveDown, 'Pgdn')
const link = createLi('cm-link', lang.link, '')
const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '')
const summary = createLi('cm-summary', lang.summary, '')
const menuUl = document.createElement('ul')
menuUl.className = 'menu-list'
menuUl.appendChild(add_child)
menuUl.appendChild(add_parent)
menuUl.appendChild(add_sibling)
menuUl.appendChild(remove_child)
if (option.focus) {
menuUl.appendChild(focus)
menuUl.appendChild(unfocus)
}
menuUl.appendChild(up)
menuUl.appendChild(down)
menuUl.appendChild(summary)
if (option.link) {
menuUl.appendChild(link)
menuUl.appendChild(linkBidirectional)
}
if (option && option.extend) {
for (let i = 0; i < option.extend.length; i++) {
const item = option.extend[i]
const dom = createLi(item.name, item.name, item.key || '')
menuUl.appendChild(dom)
dom.onclick = e => {
item.onclick(e)
}
}
}
const menuContainer = document.createElement('div')
menuContainer.className = 'context-menu'
menuContainer.appendChild(menuUl)
menuContainer.hidden = true
mind.container.append(menuContainer)
let isRoot = true
// Helper function to actually render and position context menu.
const showMenu = (e: MouseEvent) => {
console.log('showContextMenu', e)
const target = e.target as HTMLElement
if (isTopic(target)) {
if (target.parentElement!.tagName === 'ME-ROOT') {
isRoot = true
} else {
isRoot = false
}
if (isRoot) {
focus.className = 'disabled'
up.className = 'disabled'
down.className = 'disabled'
add_parent.className = 'disabled'
add_sibling.className = 'disabled'
remove_child.className = 'disabled'
} else {
focus.className = ''
up.className = ''
down.className = ''
add_parent.className = ''
add_sibling.className = ''
remove_child.className = ''
}
menuContainer.hidden = false
menuUl.style.top = ''
menuUl.style.bottom = ''
menuUl.style.left = ''
menuUl.style.right = ''
const rect = menuUl.getBoundingClientRect()
const height = menuUl.offsetHeight
const width = menuUl.offsetWidth
const relativeY = e.clientY - rect.top
const relativeX = e.clientX - rect.left
if (height + relativeY > window.innerHeight) {
menuUl.style.top = ''
menuUl.style.bottom = '0px'
} else {
menuUl.style.bottom = ''
menuUl.style.top = relativeY + 15 + 'px'
}
if (width + relativeX > window.innerWidth) {
menuUl.style.left = ''
menuUl.style.right = '0px'
} else {
menuUl.style.right = ''
menuUl.style.left = relativeX + 10 + 'px'
}
}
}
mind.bus.addListener('showContextMenu', showMenu)
menuContainer.onclick = e => {
if (e.target === menuContainer) menuContainer.hidden = true
}
add_child.onclick = () => {
mind.addChild()
menuContainer.hidden = true
}
add_parent.onclick = () => {
mind.insertParent()
menuContainer.hidden = true
}
add_sibling.onclick = () => {
if (isRoot) return
mind.insertSibling('after')
menuContainer.hidden = true
}
remove_child.onclick = () => {
if (isRoot) return
mind.removeNodes(mind.currentNodes || [])
menuContainer.hidden = true
}
focus.onclick = () => {
if (isRoot) return
mind.focusNode(mind.currentNode as Topic)
menuContainer.hidden = true
}
unfocus.onclick = () => {
mind.cancelFocus()
menuContainer.hidden = true
}
up.onclick = () => {
if (isRoot) return
mind.moveUpNode()
menuContainer.hidden = true
}
down.onclick = () => {
if (isRoot) return
mind.moveDownNode()
menuContainer.hidden = true
}
const linkFunc = (options?: ArrowOptions) => {
menuContainer.hidden = true
const from = mind.currentNode as Topic
const tips = createTips(lang.clickTips)
mind.container.appendChild(tips)
mind.map.addEventListener(
'click',
e => {
e.preventDefault()
tips.remove()
const target = e.target as Topic
if (target.parentElement.tagName === 'ME-PARENT' || target.parentElement.tagName === 'ME-ROOT') {
mind.createArrow(from, target, options)
} else {
console.log('link cancel')
}
},
{
once: true,
}
)
}
link.onclick = () => linkFunc()
linkBidirectional.onclick = () => linkFunc({ bidirectional: true })
summary.onclick = () => {
menuContainer.hidden = true
mind.createSummary()
mind.unselectNodes(mind.currentNodes)
}
return () => {
// maybe useful?
add_child.onclick = null
add_parent.onclick = null
add_sibling.onclick = null
remove_child.onclick = null
focus.onclick = null
unfocus.onclick = null
up.onclick = null
down.onclick = null
link.onclick = null
summary.onclick = null
menuContainer.onclick = null
mind.container.oncontextmenu = null
}
}

View File

@ -150,18 +150,24 @@ class TableToSVGConverter {
// 计算这个单元格内容需要的宽度
// 中文字符宽度大约是字体大小的1倍英文是0.6倍
let contentWidth = 0
for (const char of cell.content) {
if (/[\u4e00-\u9fa5]/.test(char)) {
// 中文字符
contentWidth += this.fontSize * 1.0
} else {
// 英文字符
contentWidth += this.fontSize * 0.6
const lines = cell.content.split('\n')
lines.forEach((line: string) => {
let lineWidth = 0
for (const char of line) {
if (/[\u4e00-\u9fa5]/.test(char)) {
// 中文字符
lineWidth += this.fontSize * 1.0
} else {
// 英文字符
lineWidth += this.fontSize * 0.6
}
}
}
contentWidth = Math.max(contentWidth, lineWidth)
})
// 加上内边距
contentWidth += 16 // 左右各8px的padding
contentWidth += 20 // 左右各10px的padding
// 考虑colspan平均分配宽度
const avgWidthPerCol = contentWidth / cell.colspan
@ -174,21 +180,21 @@ class TableToSVGConverter {
// 设置最小列宽,确保不会太窄
columnWidths.forEach((width, index) => {
columnWidths[index] = Math.max(width, 80) // 最小80px
columnWidths[index] = Math.max(width, 120) // 增加最小宽度到120px
})
// 计算总宽度
const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0)
// 计算行高,大幅减少
this.cellHeight = Math.max(15, this.fontSize * 1.0) // 大幅减少行高
// 计算行高
this.cellHeight = Math.max(25, this.fontSize * 1.2) // 合理的行高
// 为每行计算实际高度(考虑多行文本)
const rowHeights: number[] = new Array(maxRows).fill(this.cellHeight)
structure.forEach(cell => {
const lines = cell.content.split('\n').length
const cellHeight = Math.max(this.cellHeight, lines * this.fontSize * 1.0 + 2)
const cellHeight = Math.max(this.cellHeight, lines * this.fontSize * 1.3 + 8)
// 更新这一行涉及的行的高度
for (let row = cell.row; row < cell.row + cell.rowspan; row++) {
@ -310,9 +316,9 @@ class TableToSVGConverter {
if (cell.content) {
const text = document.createElementNS(ns, 'text')
setAttributes(text, {
x: cellX + cellWidth / 2 + '',
x: cellX + 10 + '', // 左对齐左边距10px
y: cellY + cellHeight / 2 + this.fontSize / 3 + '',
'text-anchor': 'middle',
'text-anchor': 'start', // 左对齐
'dominant-baseline': 'central',
'font-family': this.fontFamily,
'font-size': this.fontSize + '',
@ -328,7 +334,7 @@ class TableToSVGConverter {
lines.forEach((line: string, index: number) => {
const tspan = document.createElementNS(ns, 'tspan')
setAttributes(tspan, {
x: cellX + cellWidth / 2 + '',
x: cellX + 10 + '', // 每行都左对齐
dy: index === 0 ? '0' : '1.2em'
})
tspan.textContent = line
@ -415,26 +421,58 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
contentLength: htmlContent.length
})
// 如果包含表格,使用新的SVG原生转换器
// 如果包含表格,使用foreignObject方式渲染HTML表格
if (hasTableContent) {
console.log('🔄 检测到表格内容,使用SVG原生转换器')
console.log('🔄 检测到表格内容,使用foreignObject方式渲染HTML表格')
// 创建一个临时DOM元素来解析表格
const tempDiv = document.createElement('div')
tempDiv.innerHTML = cleanHtmlForSvg(htmlContent)
const table = tempDiv.querySelector('table') as HTMLTableElement
// 获取节点尺寸
const nodeWidth = tpc.offsetWidth || 400
const nodeHeight = tpc.offsetHeight || 200
if (table) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const fontFamily = tpcStyle.fontFamily || 'Arial, sans-serif'
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y)
g.appendChild(tableSVG)
console.log('✅ 表格已转换为SVG原生元素')
return g
}
// 创建背景矩形
const bg = document.createElementNS(ns, 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
width: nodeWidth + '',
height: nodeHeight + '',
fill: 'white',
stroke: '#ccc',
'stroke-width': '1'
})
g.appendChild(bg)
// 创建foreignObject包含HTML表格
const foreignObject = document.createElementNS(ns, 'foreignObject')
setAttributes(foreignObject, {
x: x + '',
y: y + '',
width: nodeWidth + '',
height: nodeHeight + ''
})
// 创建HTML内容确保XML语法正确
const safeFontFamily = (tpcStyle.fontFamily || 'Arial, sans-serif').replace(/"/g, '&quot;')
const htmlContentForForeignObject = `
<div xmlns="http://www.w3.org/1999/xhtml" style="
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
overflow: hidden;
font-family: '${safeFontFamily}';
font-size: ${tpcStyle.fontSize || '14px'};
line-height: 1.4;
">
${cleanHtmlForSvg(htmlContent)}
</div>
`
foreignObject.innerHTML = htmlContentForForeignObject
g.appendChild(foreignObject)
console.log('✅ 表格已使用foreignObject渲染')
return g
}
if (hasHTMLContent && !hasTableContent) {
@ -474,7 +512,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
top: -9999px;
left: -9999px;
width: ${nodeWidth}px;
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
`
@ -569,7 +607,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
div.style.cssText = `
width: 100%;
height: 100%;
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
font-size: ${tpcStyle.fontSize};
color: ${tpcStyle.color};
background: transparent;
@ -592,7 +630,7 @@ function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleD
width: 100%;
border-collapse: collapse;
font-size: ${tpcStyle.fontSize};
font-family: ${tpcStyle.fontFamily};
font-family: '${(tpcStyle.fontFamily || 'Arial').replace(/"/g, '&quot;')}';
margin: 0 auto 0px auto;
border: 1px solid #ccc;
`
@ -772,27 +810,9 @@ function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignOb
console.log('🔍 处理dangerouslySetInnerHTML内容:', tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML.substring(0, 200))
if (hasTableContent3) {
console.log('✅ 检测到表格内容使用TableToSVGConverter')
// 对于表格内容直接使用TableToSVGConverter避免重复的背景rect
const tempDiv = document.createElement('div')
tempDiv.innerHTML = tpcWithNodeObj3.nodeObj.dangerouslySetInnerHTML
const table = tempDiv.querySelector('table') as HTMLTableElement
if (table) {
const fontSize = parseFloat(tpcStyle.fontSize) || 14
const fontFamily = tpcStyle.fontFamily || 'Arial, sans-serif'
// 获取实际DOM元素的尺寸用于精确的rect高度
const rect = tpc.getBoundingClientRect()
const actualHeight = rect.height
const converter = new TableToSVGConverter(table, fontSize, fontFamily)
const tableSVG = converter.convert(x, y, actualHeight)
// 直接返回表格SVG不创建额外的背景rect
return tableSVG
}
console.log('✅ 检测到表格内容使用foreignObject方式渲染')
// 对于表格内容使用foreignObject方式确保表格布局正确
return generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
}
// 对于非表格的dangerouslySetInnerHTML使用ForeignObject
@ -1039,7 +1059,7 @@ const generateSvg = async (mei: MindElixirInstance, noForeignObject = false) =>
if (hasDangerouslySetInnerHTML || hasHTMLContent) {
// 对于HTML内容表格等使用ForeignObject
console.log('✅ 使用ForeignObject渲染HTML内容')
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true))
g.appendChild(convertDivToSvg(mei, tpc, !noForeignObject))
} else if (!hasImage) {
// 对于没有图片的普通文本内容
g.appendChild(convertDivToSvg(mei, tpc, false))

View File

@ -1,117 +1,117 @@
import type { Arrow } from '../arrow'
import type { Summary } from '../summary'
import type { NodeObj } from '../types/index'
type NodeOperation =
| {
name: 'moveNodeIn' | 'moveDownNode' | 'moveUpNode' | 'copyNode' | 'addChild' | 'insertParent' | 'insertBefore' | 'beginEdit'
obj: NodeObj
}
| {
name: 'insertSibling'
type: 'before' | 'after'
obj: NodeObj
}
| {
name: 'reshapeNode'
obj: NodeObj
origin: NodeObj
}
| {
name: 'finishEdit'
obj: NodeObj
origin: string
}
| {
name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
objs: NodeObj[]
toObj: NodeObj
}
type MultipleNodeOperation =
| {
name: 'removeNodes'
objs: NodeObj[]
}
| {
name: 'copyNodes'
objs: NodeObj[]
}
export type SummaryOperation =
| {
name: 'createSummary'
obj: Summary
}
| {
name: 'removeSummary'
obj: { id: string }
}
| {
name: 'finishEditSummary'
obj: Summary
}
export type ArrowOperation =
| {
name: 'createArrow'
obj: Arrow
}
| {
name: 'removeArrow'
obj: { id: string }
}
| {
name: 'finishEditArrowLabel'
obj: Arrow
}
export type Operation = NodeOperation | MultipleNodeOperation | SummaryOperation | ArrowOperation
export type OperationType = Operation['name']
export type EventMap = {
operation: (info: Operation) => void
selectNewNode: (nodeObj: NodeObj) => void
selectNodes: (nodeObj: NodeObj[]) => void
unselectNodes: (nodeObj: NodeObj[]) => void
expandNode: (nodeObj: NodeObj) => void
linkDiv: () => void
scale: (scale: number) => void
move: (data: { dx: number; dy: number }) => void
/**
* please use throttling to prevent performance degradation
*/
updateArrowDelta: (arrow: Arrow) => void
showContextMenu: (e: MouseEvent) => void
}
export function createBus<T extends Record<string, (...args: any[]) => void> = EventMap>() {
return {
handlers: {} as Record<keyof T, ((...arg: any[]) => void)[]>,
addListener: function <K extends keyof T>(type: K, handler: T[K]) {
if (this.handlers[type] === undefined) this.handlers[type] = []
this.handlers[type].push(handler)
},
fire: function <K extends keyof T>(type: K, ...payload: Parameters<T[K]>) {
if (this.handlers[type] instanceof Array) {
const handlers = this.handlers[type]
for (let i = 0; i < handlers.length; i++) {
handlers[i](...payload)
}
}
},
removeListener: function <K extends keyof T>(type: K, handler: T[K]) {
if (!this.handlers[type]) return
const handlers = this.handlers[type]
if (!handler) {
handlers.length = 0
} else if (handlers.length) {
for (let i = 0; i < handlers.length; i++) {
if (handlers[i] === handler) {
this.handlers[type].splice(i, 1)
}
}
}
},
}
}
import type { Arrow } from '../arrow'
import type { Summary } from '../summary'
import type { NodeObj } from '../types/index'
type NodeOperation =
| {
name: 'moveNodeIn' | 'moveDownNode' | 'moveUpNode' | 'copyNode' | 'addChild' | 'insertParent' | 'insertBefore' | 'beginEdit'
obj: NodeObj
}
| {
name: 'insertSibling'
type: 'before' | 'after'
obj: NodeObj
}
| {
name: 'reshapeNode'
obj: NodeObj
origin: NodeObj
}
| {
name: 'finishEdit'
obj: NodeObj
origin: string
}
| {
name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
objs: NodeObj[]
toObj: NodeObj
}
type MultipleNodeOperation =
| {
name: 'removeNodes'
objs: NodeObj[]
}
| {
name: 'copyNodes'
objs: NodeObj[]
}
export type SummaryOperation =
| {
name: 'createSummary'
obj: Summary
}
| {
name: 'removeSummary'
obj: { id: string }
}
| {
name: 'finishEditSummary'
obj: Summary
}
export type ArrowOperation =
| {
name: 'createArrow'
obj: Arrow
}
| {
name: 'removeArrow'
obj: { id: string }
}
| {
name: 'finishEditArrowLabel'
obj: Arrow
}
export type Operation = NodeOperation | MultipleNodeOperation | SummaryOperation | ArrowOperation
export type OperationType = Operation['name']
export type EventMap = {
operation: (info: Operation) => void
selectNewNode: (nodeObj: NodeObj) => void
selectNodes: (nodeObj: NodeObj[]) => void
unselectNodes: (nodeObj: NodeObj[]) => void
expandNode: (nodeObj: NodeObj) => void
linkDiv: () => void
scale: (scale: number) => void
move: (data: { dx: number; dy: number }) => void
/**
* please use throttling to prevent performance degradation
*/
updateArrowDelta: (arrow: Arrow) => void
showContextMenu: (e: MouseEvent) => void
}
export function createBus<T extends Record<string, (...args: any[]) => void> = EventMap>() {
return {
handlers: {} as Record<keyof T, ((...arg: any[]) => void)[]>,
addListener: function <K extends keyof T>(type: K, handler: T[K]) {
if (this.handlers[type] === undefined) this.handlers[type] = []
this.handlers[type].push(handler)
},
fire: function <K extends keyof T>(type: K, ...payload: Parameters<T[K]>) {
if (this.handlers[type] instanceof Array) {
const handlers = this.handlers[type]
for (let i = 0; i < handlers.length; i++) {
handlers[i](...payload)
}
}
},
removeListener: function <K extends keyof T>(type: K, handler: T[K]) {
if (!this.handlers[type]) return
const handlers = this.handlers[type]
if (!handler) {
handlers.length = 0
} else if (handlers.length) {
for (let i = 0; i < handlers.length; i++) {
if (handlers[i] === handler) {
this.handlers[type].splice(i, 1)
}
}
}
},
}
}