修复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; flex-direction: column;
} }
</style> </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"> <link rel="stylesheet" href="/assets/index-356fe347.css">
</head> </head>
<body> <body>

View File

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

View File

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

View File

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