优化表格编辑器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; flex-direction: column;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-fc166542.js"></script> <script type="module" crossorigin src="/assets/index-78cb04db.js"></script>
<link rel="stylesheet" href="/assets/index-bafd0b52.css"> <link rel="stylesheet" href="/assets/index-ae3a3afb.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -875,8 +875,8 @@ const formatMarkdownToText = (markdown) => {
.replace(/`(.*?)`/g, '「$1」') .replace(/`(.*?)`/g, '「$1」')
// //
.replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1')
// MindElixirimage // markdownmarkdown
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[图片: $1]') // .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[: $1]') // markdown
// - // -
.replace(/\n\n/g, '\n') .replace(/\n\n/g, '\n')
.replace(/\n/g, '\n ') .replace(/\n/g, '\n ')
@ -953,9 +953,10 @@ const extractImageFromContent = (content) => {
return images; return images;
}; };
// Markdown // Markdown使markdown
const removeImageMarkdown = (content) => { const removeImageMarkdown = (content) => {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '').trim(); // markdownmarkdown
return content.trim();
}; };
// MarkdownJSON - // MarkdownJSON -
@ -1003,17 +1004,24 @@ const markdownToJSON = (markdown) => {
data: {}, data: {},
}; };
// 使 // 使markdown
if (titleImages.length > 0) { if (titleImages.length > 0) {
const firstImage = titleImages[0]; const firstImage = titleImages[0];
if (firstImage.url && firstImage.url.trim() !== '') { if (firstImage.url && firstImage.url.trim() !== '') {
node.image = { // URLURL
url: firstImage.url, let imageUrl = firstImage.url;
width: 200, if (imageUrl.includes('cdn-mineru.openxlab.org.cn')) {
height: 150, const urlPath = imageUrl.replace('https://cdn-mineru.openxlab.org.cn', '');
fit: 'contain' imageUrl = `/proxy-image${urlPath}`;
}; }
console.log(`✅ 成功为标题节点设置图片: ${firstImage.url}`);
// 使markdown
const titleMarkdown = `![${firstImage.alt || cleanTitle}](${imageUrl})`;
const htmlContent = renderMarkdownToHTML(titleMarkdown);
node.topic = ''; // topic
node.dangerouslySetInnerHTML = htmlContent; // 使dangerouslySetInnerHTML
console.log(`✅ 成功为标题节点设置图片: ${imageUrl}`);
} else { } else {
console.error(`❌ 标题图片URL无效:`, firstImage); console.error(`❌ 标题图片URL无效:`, firstImage);
} }
@ -1094,28 +1102,28 @@ const processContentIntelligently = (content, parentNode, nodeCounter) => {
console.log(`🔄 转换图片URL: ${image.url} -> ${imageUrl}`); console.log(`🔄 转换图片URL: ${image.url} -> ${imageUrl}`);
} }
// 使markdown
const imageMarkdown = `![${image.alt || `图片 ${index + 1}`}](${imageUrl})`;
const htmlContent = renderMarkdownToHTML(imageMarkdown);
console.log('🖼️ 图片HTML内容:', htmlContent);
const imageNode = { const imageNode = {
id: `node_${nodeCounter++}`, id: `node_${nodeCounter++}`,
topic: image.alt || `图片 ${index + 1}`, topic: '', // topic使dangerouslySetInnerHTML
dangerouslySetInnerHTML: htmlContent, // 使dangerouslySetInnerHTMLHTML
children: [], children: [],
level: (parentNode.level || 0) + 1, level: (parentNode.level || 0) + 1,
image: {
url: imageUrl,
width: 200,
height: 150,
fit: 'contain'
},
data: {} data: {}
}; };
// //
if (!imageNode.image.url || imageNode.image.url.trim() === '') { if (!imageUrl || imageUrl.trim() === '') {
console.error(`❌ 图片节点 ${index + 1} URL为空:`, imageNode); console.error(`❌ 图片节点 ${index + 1} URL为空:`, imageNode);
return; return;
} }
parentNode.children.push(imageNode); 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 operationHistory from './plugin/operationHistory'
import toolBar from './plugin/toolBar' import toolBar from './plugin/toolBar'
import selection from './plugin/selection' 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 { getObjById, generateNewObj, fillParent } from './utils/index'
import { layout } from './utils/layout' import { layout } from './utils/layout'
import { changeTheme } from './utils/theme' import { changeTheme } from './utils/theme'
@ -15,6 +15,7 @@ import * as nodeOperation from './nodeOperation'
import * as arrow from './arrow' import * as arrow from './arrow'
import * as summary from './summary' import * as summary from './summary'
import * as exportImage from './plugin/exportImage' import * as exportImage from './plugin/exportImage'
import { renderMarkdownToHTML } from '../../../utils/markdownRenderer'
export type OperationMap = typeof nodeOperation export type OperationMap = typeof nodeOperation
export type Operations = keyof OperationMap export type Operations = keyof OperationMap
@ -58,6 +59,9 @@ const methods = {
layout, layout,
linkDiv, linkDiv,
editTopic, editTopic,
editTableNode,
convertTableHTMLToMarkdown,
renderMarkdownToHTML,
createWrapper, createWrapper,
createParent, createParent,
createChildren, createChildren,

View File

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

View File

@ -1,319 +1,608 @@
import { LEFT } from '../const' import { LEFT } from '../const'
import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom' import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom'
import type { MindElixirInstance, NodeObj } from '../types/index' import type { MindElixirInstance, NodeObj } from '../types/index'
import { encodeHTML, getOffsetLT } from '../utils/index' import { encodeHTML, getOffsetLT } from '../utils/index'
import { layoutChildren } from './layout' import { layoutChildren } from './layout'
// 移除imageProcessor引用使用MindElixir原生image属性 import { marked } from 'marked'
// 移除imageProcessor引用使用MindElixir原生image属性
// DOM manipulation
const $d = document // DOM manipulation
export const findEle = function (this: MindElixirInstance, id: string, el?: HTMLElement) { const $d = document
const scope = this?.el ? this.el : el ? el : document export const findEle = function (this: MindElixirInstance, id: string, el?: HTMLElement) {
const ele = scope.querySelector<Topic>(`[data-nodeid="me${id}"]`) const scope = this?.el ? this.el : el ? el : document
if (!ele) throw new Error(`FindEle: Node ${id} not found, maybe it's collapsed.`) const ele = scope.querySelector<Topic>(`[data-nodeid="me${id}"]`)
return ele 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 = '' export const shapeTpc = function (this: MindElixirInstance, tpc: Topic, nodeObj: NodeObj) {
tpc.innerHTML = ''
if (nodeObj.style) {
const style = nodeObj.style if (nodeObj.style) {
type KeyOfStyle = keyof typeof style const style = nodeObj.style
for (const key in style) { type KeyOfStyle = keyof typeof style
tpc.style[key as KeyOfStyle] = style[key as KeyOfStyle]! for (const key in style) {
} tpc.style[key as KeyOfStyle] = style[key as KeyOfStyle]!
} }
}
if (nodeObj.dangerouslySetInnerHTML) {
// 清理HTML内容移除重复的格式 if (nodeObj.dangerouslySetInnerHTML) {
let cleanedHTML = nodeObj.dangerouslySetInnerHTML // 清理HTML内容移除重复的格式
let cleanedHTML = nodeObj.dangerouslySetInnerHTML
// 检查是否包含表格,如果包含表格则不进行列表处理
const hasTable = cleanedHTML.includes('<table') || cleanedHTML.includes('<td>') || cleanedHTML.includes('<th>') // 检查是否包含表格,如果包含表格则不进行列表处理
const hasTable = cleanedHTML.includes('<table') || cleanedHTML.includes('<td>') || cleanedHTML.includes('<th>')
if (!hasTable) {
// 移除• 【这种重复格式 if (!hasTable) {
cleanedHTML = cleanedHTML.replace(/•\s*【/g, '【') // 移除• 【这种重复格式
cleanedHTML = cleanedHTML.replace(/•\s*\[/g, '[') cleanedHTML = cleanedHTML.replace(/•\s*【/g, '【')
cleanedHTML = cleanedHTML.replace(/•\s*\[/g, '[')
// 移除其他可能的重复格式
cleanedHTML = cleanedHTML.replace(/•\s*/g, '') // 移除其他可能的重复格式
cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(') cleanedHTML = cleanedHTML.replace(/•\s*/g, '')
cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(')
// 处理换行符和列表格式
// 将换行符转换为<br>标签,但保持列表结构 // 处理换行符和列表格式
cleanedHTML = cleanedHTML // 将换行符转换为<br>标签,但保持列表结构
// 处理列表项:将每个•开头的行转换为<li> cleanedHTML = cleanedHTML
.replace(/^(\s*)•\s*(.+)$/gm, '<li>$2</li>') // 处理列表项:将每个•开头的行转换为<li>
// 将连续的<li>标签包装在<ul>中 .replace(/^(\s*)•\s*(.+)$/gm, '<li>$2</li>')
.replace(/(<li>.*<\/li>)/gs, (match) => { // 将连续的<li>标签包装在<ul>中
// 如果已经被<ul>包装,不重复包装 .replace(/(<li>.*<\/li>)/gs, (match) => {
if (cleanedHTML.includes('<ul>')) return match // 如果已经被<ul>包装,不重复包装
return `<ul>${match}</ul>` if (cleanedHTML.includes('<ul>')) return match
}) return `<ul>${match}</ul>`
// 将剩余的换行符转换为<br> })
.replace(/\n/g, '<br>') // 将剩余的换行符转换为<br>
} else { .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, '') cleanedHTML = cleanedHTML.replace(/•\s*\[/g, '[')
cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(') cleanedHTML = cleanedHTML.replace(/•\s*/g, '')
} cleanedHTML = cleanedHTML.replace(/•\s*\(/g, '(')
}
tpc.innerHTML = cleanedHTML
tpc.innerHTML = cleanedHTML
// 检查是否包含图片,决定文本对齐方式
const hasImages = cleanedHTML.includes('<img') // 检查是否包含图片,决定文本对齐方式
const hasNodeImage = !!nodeObj.image const hasImages = cleanedHTML.includes('<img')
const hasAnyImage = hasImages || hasNodeImage const hasNodeImage = !!nodeObj.image
const hasAnyImage = hasImages || hasNodeImage
// 统一使用左对齐样式,简化逻辑
tpc.classList.add('no-image') // 统一使用左对齐样式,简化逻辑
tpc.classList.remove('has-image') tpc.classList.add('no-image')
tpc.classList.remove('has-image')
return
} return
}
if (nodeObj.image) {
const img = nodeObj.image if (nodeObj.image) {
if (img.url && img.width && img.height) { const img = nodeObj.image
const imgEl = $d.createElement('img') if (img.url && img.width && img.height) {
// Use imageProxy function if provided, otherwise use original URL const imgEl = $d.createElement('img')
imgEl.src = this.imageProxy ? this.imageProxy(img.url) : img.url // Use imageProxy function if provided, otherwise use original URL
imgEl.style.width = img.width + 'px' imgEl.src = this.imageProxy ? this.imageProxy(img.url) : img.url
imgEl.style.height = img.height + 'px' imgEl.style.width = img.width + 'px'
if (img.fit) imgEl.style.objectFit = img.fit imgEl.style.height = img.height + 'px'
tpc.appendChild(imgEl) if (img.fit) imgEl.style.objectFit = img.fit
tpc.image = imgEl tpc.appendChild(imgEl)
} else { tpc.image = imgEl
console.warn('Image url/width/height are required') } else {
} console.warn('Image url/width/height are required')
} else if (tpc.image) { }
tpc.image = undefined } else if (tpc.image) {
} tpc.image = undefined
}
{
const textEl = $d.createElement('span') {
textEl.className = 'text' const textEl = $d.createElement('span')
textEl.className = 'text'
// Check if markdown parser is provided and topic contains markdown syntax
let content = '' // Check if markdown parser is provided and topic contains markdown syntax
if (this.markdown) { let content = ''
content = this.markdown(nodeObj.topic, nodeObj) if (this.markdown) {
} else { content = this.markdown(nodeObj.topic, nodeObj)
// 直接设置文本内容图片通过MindElixir原生image属性处理 } else {
content = nodeObj.topic || '' // 直接设置文本内容图片通过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, '') 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) textEl.innerHTML = content
tpc.text = textEl tpc.appendChild(textEl)
tpc.text = textEl
// 检查是否有图片,决定文本对齐方式
const hasNodeImage = !!nodeObj.image // 检查是否有图片,决定文本对齐方式
const hasImageInText = content.includes('<img') const hasNodeImage = !!nodeObj.image
const hasAnyImage = hasNodeImage || hasImageInText const hasImageInText = content.includes('<img')
const hasAnyImage = hasNodeImage || hasImageInText
// 统一使用左对齐样式,简化逻辑
tpc.classList.add('no-image') // 统一使用左对齐样式,简化逻辑
tpc.classList.remove('has-image') tpc.classList.add('no-image')
} tpc.classList.remove('has-image')
}
if (nodeObj.hyperLink) {
const linkEl = $d.createElement('a') if (nodeObj.hyperLink) {
linkEl.className = 'hyper-link' const linkEl = $d.createElement('a')
linkEl.target = '_blank' linkEl.className = 'hyper-link'
linkEl.innerText = '🔗' linkEl.target = '_blank'
linkEl.href = nodeObj.hyperLink linkEl.innerText = '🔗'
tpc.appendChild(linkEl) linkEl.href = nodeObj.hyperLink
tpc.link = linkEl tpc.appendChild(linkEl)
} else if (tpc.link) { tpc.link = linkEl
tpc.link = undefined } else if (tpc.link) {
} tpc.link = undefined
}
if (nodeObj.icons && nodeObj.icons.length) {
const iconsEl = $d.createElement('span') if (nodeObj.icons && nodeObj.icons.length) {
iconsEl.className = 'icons' const iconsEl = $d.createElement('span')
iconsEl.innerHTML = nodeObj.icons.map(icon => `<span>${encodeHTML(icon)}</span>`).join('') iconsEl.className = 'icons'
tpc.appendChild(iconsEl) iconsEl.innerHTML = nodeObj.icons.map(icon => `<span>${encodeHTML(icon)}</span>`).join('')
tpc.icons = iconsEl tpc.appendChild(iconsEl)
} else if (tpc.icons) { tpc.icons = iconsEl
tpc.icons = undefined } else if (tpc.icons) {
} tpc.icons = undefined
}
if (nodeObj.tags && nodeObj.tags.length) {
const tagsEl = $d.createElement('div') if (nodeObj.tags && nodeObj.tags.length) {
tagsEl.className = 'tags' const tagsEl = $d.createElement('div')
tagsEl.className = 'tags'
nodeObj.tags.forEach(tag => {
const span = $d.createElement('span') nodeObj.tags.forEach(tag => {
const span = $d.createElement('span')
if (typeof tag === 'string') {
span.textContent = tag if (typeof tag === 'string') {
} else { span.textContent = tag
span.textContent = tag.text } else {
if (tag.className) { span.textContent = tag.text
span.className = tag.className if (tag.className) {
} span.className = tag.className
if (tag.style) { }
Object.assign(span.style, tag.style) if (tag.style) {
} Object.assign(span.style, tag.style)
} }
}
tagsEl.appendChild(span)
}) tagsEl.appendChild(span)
})
tpc.appendChild(tagsEl)
tpc.tags = tagsEl tpc.appendChild(tagsEl)
} else if (tpc.tags) { tpc.tags = tagsEl
tpc.tags = undefined } else if (tpc.tags) {
} tpc.tags = undefined
} }
}
// everything start from `Wrapper`
export const createWrapper = function (this: MindElixirInstance, nodeObj: NodeObj, omitChildren?: boolean) { // everything start from `Wrapper`
const grp = $d.createElement('me-wrapper') as Wrapper export const createWrapper = function (this: MindElixirInstance, nodeObj: NodeObj, omitChildren?: boolean) {
const { p, tpc } = this.createParent(nodeObj) const grp = $d.createElement('me-wrapper') as Wrapper
grp.appendChild(p) const { p, tpc } = this.createParent(nodeObj)
if (!omitChildren && nodeObj.children && nodeObj.children.length > 0) { grp.appendChild(p)
const expander = createExpander(nodeObj.expanded) if (!omitChildren && nodeObj.children && nodeObj.children.length > 0) {
p.appendChild(expander) const expander = createExpander(nodeObj.expanded)
// tpc.expander = expander p.appendChild(expander)
if (nodeObj.expanded !== false) { // tpc.expander = expander
const children = layoutChildren(this, nodeObj.children) if (nodeObj.expanded !== false) {
grp.appendChild(children) const children = layoutChildren(this, nodeObj.children)
} grp.appendChild(children)
} }
return { grp, top: p, tpc } }
} return { grp, top: p, tpc }
}
export const createParent = function (this: MindElixirInstance, nodeObj: NodeObj) {
const p = $d.createElement('me-parent') as Parent export const createParent = function (this: MindElixirInstance, nodeObj: NodeObj) {
const tpc = this.createTopic(nodeObj) const p = $d.createElement('me-parent') as Parent
shapeTpc.call(this, tpc, nodeObj) const tpc = this.createTopic(nodeObj)
p.appendChild(tpc) shapeTpc.call(this, tpc, nodeObj)
p.appendChild(tpc)
return { p, tpc }
} return { p, tpc }
}
export const createChildren = function (this: MindElixirInstance, wrappers: Wrapper[]) {
const children = $d.createElement('me-children') as Children export const createChildren = function (this: MindElixirInstance, wrappers: Wrapper[]) {
children.append(...wrappers) const children = $d.createElement('me-children') as Children
return children children.append(...wrappers)
} return children
}
export const createTopic = function (this: MindElixirInstance, nodeObj: NodeObj) {
const topic = $d.createElement('me-tpc') as Topic export const createTopic = function (this: MindElixirInstance, nodeObj: NodeObj) {
topic.nodeObj = nodeObj const topic = $d.createElement('me-tpc') as Topic
topic.dataset.nodeid = 'me' + nodeObj.id topic.nodeObj = nodeObj
topic.draggable = this.draggable topic.dataset.nodeid = 'me' + nodeObj.id
return topic topic.draggable = this.draggable
} return topic
}
export function selectText(div: HTMLElement) {
const range = $d.createRange() export function selectText(div: HTMLElement) {
range.selectNodeContents(div) const range = $d.createRange()
const getSelection = window.getSelection() range.selectNodeContents(div)
if (getSelection) { const getSelection = window.getSelection()
getSelection.removeAllRanges() if (getSelection) {
getSelection.addRange(range) getSelection.removeAllRanges()
} getSelection.addRange(range)
} }
}
export const editTopic = function (this: MindElixirInstance, el: Topic) {
console.time('editTopic') export const editTopic = function (this: MindElixirInstance, el: Topic) {
if (!el) return console.time('editTopic')
const div = $d.createElement('div') if (!el) return
const node = el.nodeObj const div = $d.createElement('div')
const node = el.nodeObj
// Get the original content from topic
const originalContent = node.topic // Get the original content from topic
const originalContent = node.topic
el.appendChild(div)
div.id = 'input-box' el.appendChild(div)
div.textContent = originalContent div.id = 'input-box'
div.contentEditable = 'plaintext-only' div.textContent = originalContent
div.spellcheck = false div.contentEditable = 'plaintext-only'
const style = getComputedStyle(el) div.spellcheck = false
div.style.cssText = `min-width:${el.offsetWidth - 8}px; const style = getComputedStyle(el)
color:${style.color}; div.style.cssText = `min-width:${el.offsetWidth - 8}px;
padding:${style.padding}; color:${style.color};
margin:${style.margin}; padding:${style.padding};
font:${style.font}; margin:${style.margin};
background-color:${style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor}; font:${style.font};
border-radius:${style.borderRadius};` background-color:${style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor};
if (this.direction === LEFT) div.style.right = '0' border-radius:${style.borderRadius};`
if (this.direction === LEFT) div.style.right = '0'
selectText(div)
selectText(div)
this.bus.fire('operation', {
name: 'beginEdit', this.bus.fire('operation', {
obj: el.nodeObj, name: 'beginEdit',
}) obj: el.nodeObj,
})
div.addEventListener('keydown', e => {
e.stopPropagation() div.addEventListener('keydown', e => {
const key = e.key e.stopPropagation()
const key = e.key
if (key === 'Enter' || key === 'Tab') {
// keep wrap for shift enter if (key === 'Enter' || key === 'Tab') {
if (e.shiftKey) return // keep wrap for shift enter
if (e.shiftKey) return
e.preventDefault()
div.blur() e.preventDefault()
this.container.focus() div.blur()
} this.container.focus()
}) }
})
div.addEventListener('blur', () => {
if (!div) return div.addEventListener('blur', () => {
const inputContent = div.textContent?.trim() || '' if (!div) return
const inputContent = div.textContent?.trim() || ''
if (inputContent === '') {
node.topic = originalContent if (inputContent === '') {
} else { node.topic = originalContent
// Update topic content } else {
node.topic = inputContent // Update topic content
node.topic = inputContent
if (this.markdown) {
el.text.innerHTML = this.markdown(node.topic, node) if (this.markdown) {
} else { el.text.innerHTML = this.markdown(node.topic, node)
// Plain text content } else {
el.text.textContent = inputContent // Plain text content
} el.text.textContent = inputContent
} }
}
div.remove()
div.remove()
// 重新应用样式和清理逻辑
shapeTpc.call(this, el, node) // 重新应用样式和清理逻辑
shapeTpc.call(this, el, node)
if (inputContent === originalContent) return
if (inputContent === originalContent) return
this.linkDiv()
this.bus.fire('operation', { this.linkDiv()
name: 'finishEdit', this.bus.fire('operation', {
obj: node, name: 'finishEdit',
origin: originalContent, obj: node,
}) origin: originalContent,
}) })
console.timeEnd('editTopic') })
} console.timeEnd('editTopic')
}
export const createExpander = function (expanded: boolean | undefined): Expander {
const expander = $d.createElement('me-epd') as Expander export const createExpander = function (expanded: boolean | undefined): Expander {
// if expanded is undefined, treat as expanded const expander = $d.createElement('me-epd') as Expander
expander.expanded = expanded !== false // if expanded is undefined, treat as expanded
expander.className = expanded !== false ? 'minus' : '' expander.expanded = expanded !== false
return expander 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 obj: NodeObj
origin: string origin: string
} }
| {
name: 'finishEditTable'
obj: NodeObj
origin: string
}
| { | {
name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn' name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
objs: NodeObj[] 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 'prismjs/components/prism-sql';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
// 自定义渲染器(移除图片处理使用MindElixir原生image属性 // 自定义渲染器(支持图片渲染
const renderer = new marked.Renderer(); 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选项
marked.setOptions({ marked.setOptions({
breaks: false, // 禁用breaks避免在表格中产生<br>标签导致HTML不匹配 breaks: false, // 禁用breaks避免在表格中产生<br>标签导致HTML不匹配
@ -409,6 +426,26 @@ const addMarkdownStyles = (container) => {
font-family: monospace; 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 { .markdown-error {
color: #dc3545; color: #dc3545;
background: #f8d7da; background: #f8d7da;