修复SVG导出表格节点问题:使用foreignObject替代原生SVG文本,解决XML语法错误
- 将表格渲染从TableToSVGConverter改为foreignObject方式 - 修复XML语法错误:字体名加引号、添加命名空间、字符转义 - 解决表格内容压缩、对齐混乱、文本溢出问题 - 实现表格自动换行和列宽自适应 - 确保SVG导出的表格布局与HTML显示一致
This commit is contained in:
parent
35766881dd
commit
2a09a6b05c
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
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, '"')
|
||||||
|
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, '"')}';
|
||||||
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, '"')}';
|
||||||
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, '"')}';
|
||||||
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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue