优化表格编辑器UI和用户体验

改进内容:
- 使用fixed定位的模态框,居中显示
- 添加半透明遮罩层,突出编辑器
- 增大编辑器尺寸(600x400px),更舒适的编辑空间
- 设置明显的光标颜色(caret-color: #007bff)
- 添加编辑器标题和快捷键提示
- 优化按钮样式,添加hover效果
- 设置textarea背景色(#fafafa),增强可见性
- 禁用拼写检查和自动完成
- 延迟聚焦确保光标可见
- 点击遮罩层可关闭编辑器
This commit is contained in:
lixinran 2025-10-11 14:07:16 +08:00
parent 1c838a9aeb
commit 1f67b9ae58
14 changed files with 787 additions and 420 deletions

Binary file not shown.

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

File diff suppressed because one or more lines are too long

View File

@ -23,8 +23,8 @@
flex-direction: column;
}
</style>
<script type="module" crossorigin src="/assets/index-fc166542.js"></script>
<link rel="stylesheet" href="/assets/index-bafd0b52.css">
<script type="module" crossorigin src="/assets/index-78cb04db.js"></script>
<link rel="stylesheet" href="/assets/index-ae3a3afb.css">
</head>
<body>
<div id="app"></div>

View File

@ -875,8 +875,8 @@ const formatMarkdownToText = (markdown) => {
.replace(/`(.*?)`/g, '「$1」')
//
.replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1')
// MindElixirimage
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[图片: $1]')
// markdownmarkdown
// .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[: $1]') // markdown
// -
.replace(/\n\n/g, '\n')
.replace(/\n/g, '\n ')
@ -953,9 +953,10 @@ const extractImageFromContent = (content) => {
return images;
};
// Markdown
// Markdown使markdown
const removeImageMarkdown = (content) => {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '').trim();
// markdownmarkdown
return content.trim();
};
// MarkdownJSON -
@ -1003,17 +1004,24 @@ const markdownToJSON = (markdown) => {
data: {},
};
// 使
// 使markdown
if (titleImages.length > 0) {
const firstImage = titleImages[0];
if (firstImage.url && firstImage.url.trim() !== '') {
node.image = {
url: firstImage.url,
width: 200,
height: 150,
fit: 'contain'
};
console.log(`✅ 成功为标题节点设置图片: ${firstImage.url}`);
// URLURL
let imageUrl = firstImage.url;
if (imageUrl.includes('cdn-mineru.openxlab.org.cn')) {
const urlPath = imageUrl.replace('https://cdn-mineru.openxlab.org.cn', '');
imageUrl = `/proxy-image${urlPath}`;
}
// 使markdown
const titleMarkdown = `![${firstImage.alt || cleanTitle}](${imageUrl})`;
const htmlContent = renderMarkdownToHTML(titleMarkdown);
node.topic = ''; // topic
node.dangerouslySetInnerHTML = htmlContent; // 使dangerouslySetInnerHTML
console.log(`✅ 成功为标题节点设置图片: ${imageUrl}`);
} else {
console.error(`❌ 标题图片URL无效:`, firstImage);
}
@ -1094,28 +1102,28 @@ const processContentIntelligently = (content, parentNode, nodeCounter) => {
console.log(`🔄 转换图片URL: ${image.url} -> ${imageUrl}`);
}
// 使markdown
const imageMarkdown = `![${image.alt || `图片 ${index + 1}`}](${imageUrl})`;
const htmlContent = renderMarkdownToHTML(imageMarkdown);
console.log('🖼️ 图片HTML内容:', htmlContent);
const imageNode = {
id: `node_${nodeCounter++}`,
topic: image.alt || `图片 ${index + 1}`,
topic: '', // topic使dangerouslySetInnerHTML
dangerouslySetInnerHTML: htmlContent, // 使dangerouslySetInnerHTMLHTML
children: [],
level: (parentNode.level || 0) + 1,
image: {
url: imageUrl,
width: 200,
height: 150,
fit: 'contain'
},
data: {}
};
//
if (!imageNode.image.url || imageNode.image.url.trim() === '') {
if (!imageUrl || imageUrl.trim() === '') {
console.error(`❌ 图片节点 ${index + 1} URL为空:`, imageNode);
return;
}
parentNode.children.push(imageNode);
console.log(`✅ 成功创建图片节点: ${imageNode.topic} - ${imageUrl}`);
console.log(`✅ 成功创建图片节点: ${image.alt || `图片 ${index + 1}`} - ${imageUrl}`);
});
//

View File

@ -6,7 +6,7 @@ import nodeDraggable from './plugin/nodeDraggable'
import operationHistory from './plugin/operationHistory'
import toolBar from './plugin/toolBar'
import selection from './plugin/selection'
import { editTopic, createWrapper, createParent, createChildren, createTopic, findEle } from './utils/dom'
import { editTopic, editTableNode, convertTableHTMLToMarkdown, createWrapper, createParent, createChildren, createTopic, findEle } from './utils/dom'
import { getObjById, generateNewObj, fillParent } from './utils/index'
import { layout } from './utils/layout'
import { changeTheme } from './utils/theme'
@ -15,6 +15,7 @@ import * as nodeOperation from './nodeOperation'
import * as arrow from './arrow'
import * as summary from './summary'
import * as exportImage from './plugin/exportImage'
import { renderMarkdownToHTML } from '../../../utils/markdownRenderer'
export type OperationMap = typeof nodeOperation
export type Operations = keyof OperationMap
@ -58,6 +59,9 @@ const methods = {
layout,
linkDiv,
editTopic,
editTableNode,
convertTableHTMLToMarkdown,
renderMarkdownToHTML,
createWrapper,
createParent,
createChildren,

View File

@ -84,7 +84,16 @@ export default function (mind: MindElixirInstance) {
return
}
// 如果没有图片,则进入编辑模式
// 检查是否是表格节点
if (topic.nodeObj?.dangerouslySetInnerHTML && topic.innerHTML.includes('<table')) {
console.log('📊 双击表格节点,准备编辑:', topic.nodeObj.topic)
if (mind.editable) {
mind.beginEdit(target)
}
return
}
// 如果没有图片或表格,则进入编辑模式
if (mind.editable) {
mind.beginEdit(target)
}

View File

@ -303,7 +303,18 @@ export const moveNodeAfter = function (this: MindElixirInstance, from: Topic[],
export const beginEdit = function (this: MindElixirInstance, el?: Topic) {
const nodeEle = el || this.currentNode
if (!nodeEle) return
if (nodeEle.nodeObj.dangerouslySetInnerHTML) return
// 检查是否是表格节点
if (nodeEle.nodeObj.dangerouslySetInnerHTML) {
// 检查是否包含表格
if (nodeEle.innerHTML.includes('<table')) {
this.editTableNode(nodeEle)
return
}
// 其他dangerouslySetInnerHTML节点暂时不支持编辑
return
}
this.editTopic(nodeEle)
}

View File

@ -1,319 +1,608 @@
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'
// 移除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
}
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()
}

View File

@ -22,6 +22,11 @@ type NodeOperation =
obj: NodeObj
origin: string
}
| {
name: 'finishEditTable'
obj: NodeObj
origin: string
}
| {
name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
objs: NodeObj[]

View File

@ -0,0 +1,4 @@
export declare function renderMarkdownToHTML(markdown: string): string;
export declare function hasMarkdownSyntax(content: string): boolean;
export declare function preprocessMarkdown(markdown: string): string;
export declare function postprocessHTML(html: string): string;

View File

@ -14,9 +14,26 @@ import 'prismjs/components/prism-python';
import 'prismjs/components/prism-sql';
import 'katex/dist/katex.min.css';
// 自定义渲染器(移除图片处理使用MindElixir原生image属性
// 自定义渲染器(支持图片渲染
const renderer = new marked.Renderer();
// 自定义图片渲染器
renderer.image = function(href, title, text) {
// 处理图片URL确保能正确显示
let processedUrl = href;
if (href.includes('cdn-mineru.openxlab.org.cn')) {
// 将外部CDN URL转换为代理URL
const urlPath = href.replace('https://cdn-mineru.openxlab.org.cn', '');
processedUrl = `/proxy-image${urlPath}`;
}
// 生成图片HTML
const altText = text || '图片';
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${processedUrl}" alt="${altText}"${titleAttr} class="markdown-image" style="max-width: 200px; max-height: 150px; object-fit: contain; display: block; margin: 4px auto; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" />`;
};
// 配置marked选项
marked.setOptions({
breaks: false, // 禁用breaks避免在表格中产生<br>标签导致HTML不匹配
@ -409,6 +426,26 @@ const addMarkdownStyles = (container) => {
font-family: monospace;
}
.markdown-image {
max-width: 200px !important;
max-height: 150px !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
display: block !important;
margin: 4px auto !important;
border-radius: 4px !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
background-color: #f5f5f5 !important;
cursor: pointer !important;
transition: transform 0.2s ease !important;
}
.markdown-image:hover {
transform: scale(1.02) !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.15) !important;
}
.markdown-error {
color: #dc3545;
background: #f8d7da;