MindMap/frontend/src/lib/mind-elixir/src/utils/dom.ts

609 lines
17 KiB
TypeScript
Raw Normal View History

import { LEFT } from '../const'
import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom'
import type { MindElixirInstance, NodeObj } from '../types/index'
import { encodeHTML, getOffsetLT } from '../utils/index'
import { layoutChildren } from './layout'
import { marked } from 'marked'
// 移除imageProcessor引用使用MindElixir原生image属性
// DOM manipulation
const $d = document
export const findEle = function (this: MindElixirInstance, id: string, el?: HTMLElement) {
const scope = this?.el ? this.el : el ? el : document
const ele = scope.querySelector<Topic>(`[data-nodeid="me${id}"]`)
if (!ele) throw new Error(`FindEle: Node ${id} not found, maybe it's collapsed.`)
return ele
}
export const shapeTpc = function (this: MindElixirInstance, tpc: Topic, nodeObj: NodeObj) {
tpc.innerHTML = ''
if (nodeObj.style) {
const style = nodeObj.style
type KeyOfStyle = keyof typeof style
for (const key in style) {
tpc.style[key as KeyOfStyle] = style[key as KeyOfStyle]!
}
}
if (nodeObj.dangerouslySetInnerHTML) {
// 清理HTML内容移除重复的格式
let cleanedHTML = nodeObj.dangerouslySetInnerHTML
// 检查是否包含表格,如果包含表格则不进行列表处理
const hasTable = cleanedHTML.includes('<table') || cleanedHTML.includes('<td>') || cleanedHTML.includes('<th>')
if (!hasTable) {
// 移除• 【这种重复格式
cleanedHTML = cleanedHTML.replace(/•\s*【/g, '【')
cleanedHTML = cleanedHTML.replace(/•\s*\[/g, '[')
// 移除其他可能的重复格式
cleanedHTML = cleanedHTML.replace(/•\s*/g, '')
cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(')
// 处理换行符和列表格式
// 将换行符转换为<br>标签,但保持列表结构
cleanedHTML = cleanedHTML
// 处理列表项:将每个•开头的行转换为<li>
.replace(/^(\s*)•\s*(.+)$/gm, '<li>$2</li>')
// 将连续的<li>标签包装在<ul>中
.replace(/(<li>.*<\/li>)/gs, (match) => {
// 如果已经被<ul>包装,不重复包装
if (cleanedHTML.includes('<ul>')) return match
return `<ul>${match}</ul>`
})
// 将剩余的换行符转换为<br>
.replace(/\n/g, '<br>')
} else {
// 对于表格内容,只移除重复格式,不进行列表处理
cleanedHTML = cleanedHTML.replace(/•\s*【/g, '【')
cleanedHTML = cleanedHTML.replace(/•\s*\[/g, '[')
cleanedHTML = cleanedHTML.replace(/•\s*/g, '')
cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(')
}
tpc.innerHTML = cleanedHTML
// 检查是否包含图片,决定文本对齐方式
const hasImages = cleanedHTML.includes('<img')
const hasNodeImage = !!nodeObj.image
const hasAnyImage = hasImages || hasNodeImage
// 统一使用左对齐样式,简化逻辑
tpc.classList.add('no-image')
tpc.classList.remove('has-image')
return
}
if (nodeObj.image) {
const img = nodeObj.image
if (img.url && img.width && img.height) {
const imgEl = $d.createElement('img')
// Use imageProxy function if provided, otherwise use original URL
imgEl.src = this.imageProxy ? this.imageProxy(img.url) : img.url
imgEl.style.width = img.width + 'px'
imgEl.style.height = img.height + 'px'
if (img.fit) imgEl.style.objectFit = img.fit
tpc.appendChild(imgEl)
tpc.image = imgEl
} else {
console.warn('Image url/width/height are required')
}
} else if (tpc.image) {
tpc.image = undefined
}
{
const textEl = $d.createElement('span')
textEl.className = 'text'
// Check if markdown parser is provided and topic contains markdown syntax
let content = ''
if (this.markdown) {
content = this.markdown(nodeObj.topic, nodeObj)
} else {
// 直接设置文本内容图片通过MindElixir原生image属性处理
content = nodeObj.topic || ''
}
// 清理文本内容,移除重复的格式
content = content.replace(/•\s*【/g, '【')
content = content.replace(/•\s*\[/g, '[')
content = content.replace(/•\s*/g, '')
content = content.replace(/•\s*\(/g, '(')
textEl.innerHTML = content
tpc.appendChild(textEl)
tpc.text = textEl
// 检查是否有图片,决定文本对齐方式
const hasNodeImage = !!nodeObj.image
const hasImageInText = content.includes('<img')
const hasAnyImage = hasNodeImage || hasImageInText
// 统一使用左对齐样式,简化逻辑
tpc.classList.add('no-image')
tpc.classList.remove('has-image')
}
if (nodeObj.hyperLink) {
const linkEl = $d.createElement('a')
linkEl.className = 'hyper-link'
linkEl.target = '_blank'
linkEl.innerText = '🔗'
linkEl.href = nodeObj.hyperLink
tpc.appendChild(linkEl)
tpc.link = linkEl
} else if (tpc.link) {
tpc.link = undefined
}
if (nodeObj.icons && nodeObj.icons.length) {
const iconsEl = $d.createElement('span')
iconsEl.className = 'icons'
iconsEl.innerHTML = nodeObj.icons.map(icon => `<span>${encodeHTML(icon)}</span>`).join('')
tpc.appendChild(iconsEl)
tpc.icons = iconsEl
} else if (tpc.icons) {
tpc.icons = undefined
}
if (nodeObj.tags && nodeObj.tags.length) {
const tagsEl = $d.createElement('div')
tagsEl.className = 'tags'
nodeObj.tags.forEach(tag => {
const span = $d.createElement('span')
if (typeof tag === 'string') {
span.textContent = tag
} else {
span.textContent = tag.text
if (tag.className) {
span.className = tag.className
}
if (tag.style) {
Object.assign(span.style, tag.style)
}
}
tagsEl.appendChild(span)
})
tpc.appendChild(tagsEl)
tpc.tags = tagsEl
} else if (tpc.tags) {
tpc.tags = undefined
}
}
// everything start from `Wrapper`
export const createWrapper = function (this: MindElixirInstance, nodeObj: NodeObj, omitChildren?: boolean) {
const grp = $d.createElement('me-wrapper') as Wrapper
const { p, tpc } = this.createParent(nodeObj)
grp.appendChild(p)
if (!omitChildren && nodeObj.children && nodeObj.children.length > 0) {
const expander = createExpander(nodeObj.expanded)
p.appendChild(expander)
// tpc.expander = expander
if (nodeObj.expanded !== false) {
const children = layoutChildren(this, nodeObj.children)
grp.appendChild(children)
}
}
return { grp, top: p, tpc }
}
export const createParent = function (this: MindElixirInstance, nodeObj: NodeObj) {
const p = $d.createElement('me-parent') as Parent
const tpc = this.createTopic(nodeObj)
shapeTpc.call(this, tpc, nodeObj)
p.appendChild(tpc)
return { p, tpc }
}
export const createChildren = function (this: MindElixirInstance, wrappers: Wrapper[]) {
const children = $d.createElement('me-children') as Children
children.append(...wrappers)
return children
}
export const createTopic = function (this: MindElixirInstance, nodeObj: NodeObj) {
const topic = $d.createElement('me-tpc') as Topic
topic.nodeObj = nodeObj
topic.dataset.nodeid = 'me' + nodeObj.id
topic.draggable = this.draggable
return topic
}
export function selectText(div: HTMLElement) {
const range = $d.createRange()
range.selectNodeContents(div)
const getSelection = window.getSelection()
if (getSelection) {
getSelection.removeAllRanges()
getSelection.addRange(range)
}
}
export const editTopic = function (this: MindElixirInstance, el: Topic) {
console.time('editTopic')
if (!el) return
const div = $d.createElement('div')
const node = el.nodeObj
// Get the original content from topic
const originalContent = node.topic
el.appendChild(div)
div.id = 'input-box'
div.textContent = originalContent
div.contentEditable = 'plaintext-only'
div.spellcheck = false
const style = getComputedStyle(el)
div.style.cssText = `min-width:${el.offsetWidth - 8}px;
color:${style.color};
padding:${style.padding};
margin:${style.margin};
font:${style.font};
background-color:${style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor};
border-radius:${style.borderRadius};`
if (this.direction === LEFT) div.style.right = '0'
selectText(div)
this.bus.fire('operation', {
name: 'beginEdit',
obj: el.nodeObj,
})
div.addEventListener('keydown', e => {
e.stopPropagation()
const key = e.key
if (key === 'Enter' || key === 'Tab') {
// keep wrap for shift enter
if (e.shiftKey) return
e.preventDefault()
div.blur()
this.container.focus()
}
})
div.addEventListener('blur', () => {
if (!div) return
const inputContent = div.textContent?.trim() || ''
if (inputContent === '') {
node.topic = originalContent
} else {
// Update topic content
node.topic = inputContent
if (this.markdown) {
el.text.innerHTML = this.markdown(node.topic, node)
} else {
// Plain text content
el.text.textContent = inputContent
}
}
div.remove()
// 重新应用样式和清理逻辑
shapeTpc.call(this, el, node)
if (inputContent === originalContent) return
this.linkDiv()
this.bus.fire('operation', {
name: 'finishEdit',
obj: node,
origin: originalContent,
})
})
console.timeEnd('editTopic')
}
export const createExpander = function (expanded: boolean | undefined): Expander {
const expander = $d.createElement('me-epd') as Expander
// if expanded is undefined, treat as expanded
expander.expanded = expanded !== false
expander.className = expanded !== false ? 'minus' : ''
return expander
}
// 表格编辑功能
export const editTableNode = function (this: MindElixirInstance, el: Topic) {
console.time('editTableNode')
if (!el) return
const node = el.nodeObj
const originalHTML = node.dangerouslySetInnerHTML || ''
// 创建遮罩层
const overlay = $d.createElement('div')
overlay.id = 'table-edit-overlay'
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
`
// 创建编辑容器 - 使用fixed定位居中显示
const editContainer = $d.createElement('div')
editContainer.id = 'table-edit-container'
editContainer.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
min-height: 400px;
max-height: 80vh;
background: white;
border: 2px solid #007bff;
border-radius: 8px;
padding: 15px;
z-index: 10000;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
`
// 创建标题
const title = $d.createElement('div')
title.textContent = '编辑表格Markdown格式'
title.style.cssText = `
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
`
// 创建文本区域用于编辑表格markdown
const textarea = $d.createElement('textarea')
textarea.value = this.convertTableHTMLToMarkdown(originalHTML)
textarea.style.cssText = `
flex: 1;
min-height: 300px;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
resize: vertical;
outline: none;
background: #fafafa;
color: #333;
caret-color: #007bff;
`
textarea.setAttribute('spellcheck', 'false')
textarea.setAttribute('autocomplete', 'off')
textarea.setAttribute('autocorrect', 'off')
textarea.setAttribute('autocapitalize', 'off')
// 创建按钮容器
const buttonContainer = $d.createElement('div')
buttonContainer.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
`
// 创建提示文字
const hint = $d.createElement('span')
hint.textContent = '提示: Ctrl+Enter保存, Esc取消'
hint.style.cssText = `
font-size: 11px;
color: #999;
`
// 创建按钮组
const buttonGroup = $d.createElement('div')
buttonGroup.style.cssText = `
display: flex;
gap: 8px;
`
// 创建取消按钮
const cancelButton = $d.createElement('button')
cancelButton.textContent = '取消'
cancelButton.style.cssText = `
padding: 8px 20px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
`
cancelButton.addEventListener('mouseenter', () => {
cancelButton.style.background = '#5a6268'
})
cancelButton.addEventListener('mouseleave', () => {
cancelButton.style.background = '#6c757d'
})
// 创建保存按钮
const saveButton = $d.createElement('button')
saveButton.textContent = '保存'
saveButton.style.cssText = `
padding: 8px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s;
`
saveButton.addEventListener('mouseenter', () => {
saveButton.style.background = '#0056b3'
})
saveButton.addEventListener('mouseleave', () => {
saveButton.style.background = '#007bff'
})
buttonGroup.appendChild(cancelButton)
buttonGroup.appendChild(saveButton)
buttonContainer.appendChild(hint)
buttonContainer.appendChild(buttonGroup)
editContainer.appendChild(title)
editContainer.appendChild(textarea)
editContainer.appendChild(buttonContainer)
// 添加到body因为使用了fixed定位
$d.body.appendChild(overlay)
$d.body.appendChild(editContainer)
// 点击遮罩层关闭编辑器
overlay.addEventListener('click', () => {
overlay.remove()
editContainer.remove()
})
// 延迟聚焦确保DOM已渲染
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(0, 0) // 光标移到开始
console.log('📊 编辑器已聚焦,光标应该可见')
}, 50)
// 保存功能
const saveTable = () => {
const newMarkdown = textarea.value.trim()
console.log('📊 保存表格原始markdown:', newMarkdown)
if (newMarkdown === this.convertTableHTMLToMarkdown(originalHTML)) {
// 没有变化,直接关闭
console.log('📊 表格内容未变化,直接关闭')
overlay.remove()
editContainer.remove()
return
}
// 配置marked
marked.setOptions({
breaks: false,
gfm: true,
})
// 转换markdown为HTML (marked.parse返回Promise或string)
let newHTML = marked.parse(newMarkdown) as string
console.log('📊 转换后的HTML:', newHTML)
// 为表格添加样式类
const styledHTML = newHTML.replace(/<table>/g, '<table class="markdown-table">')
console.log('📊 更新节点新HTML长度:', styledHTML.length)
// 移除编辑容器和遮罩层
overlay.remove()
editContainer.remove()
// 更新节点数据
node.dangerouslySetInnerHTML = styledHTML
node.topic = newMarkdown // 同时保存markdown原文
console.log('📊 直接更新DOM内容')
// 直接更新DOM不调用shapeTpc因为shapeTpc会清空innerHTML
el.innerHTML = styledHTML
// 添加样式类
el.classList.add('no-image')
el.classList.remove('has-image')
console.log('📊 触发操作事件')
// 触发操作事件
this.bus.fire('operation', {
name: 'finishEditTable',
obj: node,
origin: originalHTML,
})
console.log('📊 重新布局')
this.linkDiv()
}
// 取消功能
const cancelEdit = () => {
overlay.remove()
editContainer.remove()
}
// 绑定事件
saveButton.addEventListener('click', saveTable)
cancelButton.addEventListener('click', cancelEdit)
// 键盘事件
textarea.addEventListener('keydown', (e) => {
e.stopPropagation()
if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
} else if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
saveTable()
}
})
console.timeEnd('editTableNode')
}
// 将表格HTML转换为markdown
export const convertTableHTMLToMarkdown = function (this: MindElixirInstance, html: string): string {
// 创建临时DOM元素
const tempDiv = $d.createElement('div')
tempDiv.innerHTML = html
const table = tempDiv.querySelector('table')
if (!table) return html
let markdown = ''
// 处理表头
const thead = table.querySelector('thead')
if (thead) {
const headerRow = thead.querySelector('tr')
if (headerRow) {
const headers = Array.from(headerRow.querySelectorAll('th')).map(th => th.textContent?.trim() || '')
markdown += '| ' + headers.join(' | ') + ' |\n'
markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n'
}
}
// 处理表体
const tbody = table.querySelector('tbody')
if (tbody) {
const rows = tbody.querySelectorAll('tr')
rows.forEach(row => {
const cells = Array.from(row.querySelectorAll('td')).map(td => td.textContent?.trim() || '')
markdown += '| ' + cells.join(' | ') + ' |\n'
})
}
return markdown.trim()
}