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

616 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 #660874;
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: #660874;
`
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: #660874;
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 = '#4d0655'
})
saveButton.addEventListener('mouseleave', () => {
saveButton.style.background = '#660874'
})
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
// 触发操作历史记录,确保数据持久化
this.bus.fire('operation', {
name: 'finishEditTable',
obj: node,
origin: originalHTML,
})
// 添加样式类
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()
}