diff --git a/.gitignore b/.gitignore index bed905dd42..aa77ced955 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,7 @@ aqtinstall.log tags CMakeLists.txt.user build +build.* +build* .DS_Store +.vscode \ No newline at end of file diff --git a/src/data/core/vnotex.json b/src/data/core/vnotex.json index 6682237348..182b73d99b 100644 --- a/src/data/core/vnotex.json +++ b/src/data/core/vnotex.json @@ -532,6 +532,13 @@ "editor_resource" : { "template" : "web/mindmap-editor-template.html", "resources" : [ + { + "name" : "global_styles", + "enabled" : true, + "styles" : [ + "web/css/globalstyles.css" + ] + }, { "name" : "built_in", "enabled" : true, @@ -539,15 +546,17 @@ "web/js/qwebchannel.js", "web/js/eventemitter.js", "web/js/utils.js", - "web/js/vxcore.js", - "web/js/mindmapeditorcore.js" + "web/js/vxcore.js" ] }, { - "name" : "mind_elixir", + "name" : "mindmap_dependencies", "enabled" : true, "scripts" : [ - "web/js/mind-elixir/MindElixir.js" + "web/js/mindmap/lib/mind-elixir/MindElixir.js", + "web/js/mindmap/core/mindmap-core.js", + "web/js/mindmap/features/outline/outline.js", + "web/js/mindmap/features/link-handler/link-handler.js" ] }, { diff --git a/src/data/extra/extra.qrc b/src/data/extra/extra.qrc index 463829305d..b901b06b37 100644 --- a/src/data/extra/extra.qrc +++ b/src/data/extra/extra.qrc @@ -82,6 +82,13 @@ web/js/mark.js/mark.min.js web/js/markjs.js + web/mindmap-editor-template.html + web/js/mindmap/lib/mind-elixir/MindElixir.js + web/js/mindmap/core/mindmap-core.js + web/js/mindmap/features/outline/outline.js + web/js/mindmap/features/link-handler/link-handler.js + web/js/mindmapeditor.js + web/pdf.js/pdfviewer.js web/pdf.js/pdfviewer.css web/pdf.js/pdfviewercore.js @@ -327,11 +334,6 @@ web/pdf.js/web/cmaps/V.bcmap web/pdf.js/web/cmaps/WP-Symbol.bcmap - web/js/mind-elixir/MindElixir.js - web/mindmap-editor-template.html - web/js/mindmapeditorcore.js - web/js/mindmapeditor.js - dicts/en_US.aff dicts/en_US.dic themes/native/text-editor.theme diff --git a/src/data/extra/web/js/mindmap/core/mindmap-core.js b/src/data/extra/web/js/mindmap/core/mindmap-core.js new file mode 100644 index 0000000000..3f9f6a66fe --- /dev/null +++ b/src/data/extra/web/js/mindmap/core/mindmap-core.js @@ -0,0 +1,479 @@ +/** + * 思维导图核心类 + * 负责功能模块的管理和基础功能的实现 + */ +class MindMapCore { + constructor() { + // 功能模块映射表 + this.features = new Map(); + // MindElixir 实例 + this.mindElixir = null; + // 事件发射器 + this.eventEmitter = new EventEmitter(); + // 初始化标志 + this.initialized = false; + // MutationObserver 实例 + this.observer = null; + } + + /** + * 初始化 + * 步骤: + * 1. 初始化思维导图实例 + * 2. 设置功能模块 + * 3. 初始化各功能模块 + */ + init() { + console.log('MindMapCore: init called'); + + // 初始化思维导图实例 + console.log('MindMapCore: About to init MindElixir'); + this.initMindElixir(); + + // 设置和初始化功能模块 + console.log('MindMapCore: About to setup features'); + this.setupFeatures(); + console.log('MindMapCore: About to init features'); + this.initFeatures(); + + // 监听内容变更事件 + this.on('contentChanged', () => { + console.log('MindMapCore: Content changed, triggering auto-save'); + // 自动保存统一使用ID 'auto_save',在saveData中会被转换成0 + this.saveData('auto_save'); + }); + + // 添加键盘快捷键监听 + this.setupKeyboardShortcuts(); + + // 设置初始化标志并触发ready事件 + this.initialized = true; + console.log('MindMapCore: Emitting ready event'); + this.emit('ready'); + } + + /** + * 事件监听 + * @param {string} event - 事件名称 + * @param {function} callback - 回调函数 + */ + on(event, callback) { + this.eventEmitter.on(event, callback); + } + + /** + * 触发事件 + * @param {string} event - 事件名称 + * @param {...any} args - 事件参数 + */ + emit(event, ...args) { + this.eventEmitter.emit(event, ...args); + } + + + + /** + * 初始化思维导图实例 + */ + initMindElixir() { + // 确保 MindElixir 已加载 + if (typeof MindElixir === 'undefined') { + console.error('MindElixir library not loaded'); + return; + } + + // 创建思维导图实例 + this.mindElixir = new MindElixir({ + el: '#vx-mindmap', + direction: 2, + draggable: true, + contextMenu: true, + toolBar: true, + nodeMenu: true, + keypress: true, + allowUndo: true, + theme: { + primary: 'var(--vx-mindmap-primary-color)', + box: 'var(--vx-mindmap-box-color)', + line: 'var(--vx-mindmap-line-color)', + root: { + color: 'var(--vx-mindmap-root-color)', + background: 'var(--vx-mindmap-root-background)', + fontSize: '16px', + borderRadius: '4px', + padding: '8px 16px' + }, + child: { + color: 'var(--vx-mindmap-child-color)', + background: 'var(--vx-mindmap-child-background)', + fontSize: '14px', + borderRadius: '4px', + padding: '6px 12px' + } + }, + before: { + insertSibling: () => true, + async addChild() { return true; } + } + }); + + // 等待MindElixir实例初始化完成 + const waitForInit = () => { + if (this.mindElixir && typeof this.mindElixir.getData === 'function') { + this.setupMindElixirEvents(); + } else { + setTimeout(waitForInit, 100); + } + }; + waitForInit(); + + // 使用MutationObserver监听DOM变化,确保链接在所有操作后都能重新渲染 + this.setupMutationObserver(); + + console.log('MindMapCore: MindElixir instance created'); + } + + /** + * 设置MindElixir事件监听器 + */ + setupMindElixirEvents() { + console.log('MindMapCore: Setting up MindElixir events'); + + // 监听操作事件,这些事件包括节点的添加、删除、移动和编辑 + this.mindElixir.bus.addListener('operation', (name, obj) => { + console.log('MindMapCore: MindElixir operation event received. Name:', name, 'Object:', obj); + + // 针对Hyperlink的编辑,进行一次即时的、有针对性的重绘 + if (name === 'editHyperLink' && obj) { + // 使用微任务或短延迟确保在MindElixir的DOM操作后执行 + setTimeout(() => { + const linkHandler = this.getFeature('linkHandler'); + const domNode = document.querySelector(`tpc[data-nodeid=me${obj.id}]`); + if (linkHandler && domNode) { + console.log('MindMapCore: Directly processing node after hyperlink edit:', obj.id); + linkHandler.processNodeWithData(domNode, linkHandler.nodeDataMap); + } else { + console.warn('MindMapCore: Could not find linkHandler or domNode for hyperlink edit.'); + } + }, 50); + // 此次操作已精确处理,无需触发全局重绘 + return; + } + + // 对其他所有操作使用防抖处理,避免频繁的全局更新 + if (this._processNodesTimeout) { + clearTimeout(this._processNodesTimeout); + } + + this._processNodesTimeout = setTimeout(() => { + this.processNodesAndRelayout(); + }, 100); + }); + + // 监听展开/折叠事件 + // MindElixir的expandNode事件同时处理展开和折叠 + this.mindElixir.bus.addListener('expandNode', () => { + console.log('MindMapCore: Node expanded/collapsed'); + // 添加一个短暂的延迟,以确保DOM更新稳定后再进行处理 + setTimeout(() => { + this.processNodesAndRelayout(); + }, 50); + }); + + console.log('MindMapCore: MindElixir events setup complete'); + } + + /** + * 设置MutationObserver来监听DOM变化 + * 这是一种更可靠的方式来捕捉所有由MindElixir引起的UI更新 + */ + setupMutationObserver() { + if (!this.mindElixir || !this.mindElixir.box) { + console.error('MindMapCore: Cannot setup MutationObserver, mindElixir.box is not available.'); + return; + } + + this.observer = new MutationObserver((mutations) => { + // 使用防抖避免过于频繁的调用 + if (this._mutationTimeout) { + clearTimeout(this._mutationTimeout); + } + this._mutationTimeout = setTimeout(() => { + console.log('MindMapCore: DOM changed, processing nodes due to mutation.'); + this.processNodesAndRelayout(); + }, 150); + }); + + this.observer.observe(this.mindElixir.box, { + childList: true, // 监听子节点的添加或删除 + subtree: true, // 监听所有后代节点 + }); + + console.log('MindMapCore: MutationObserver setup complete, watching for changes.'); + } + + /** + * 禁用MutationObserver + */ + disableObserver() { + if (this.observer) { + this.observer.disconnect(); + // console.log('MindMapCore: MutationObserver disabled.'); + } + } + + /** + * 启用MutationObserver + */ + enableObserver() { + if (this.observer) { + this.observer.observe(this.mindElixir.box, { + childList: true, + subtree: true, + }); + // console.log('MindMapCore: MutationObserver enabled.'); + } + } + + /** + * 处理节点并强制重新布局 + * 确保在添加自定义元素(如链接图标)后,思维导图的布局能够更新 + */ + processNodesAndRelayout() { + if (!this.mindElixir || typeof this.mindElixir.getAllData !== 'function') { + console.warn('MindMapCore: MindElixir not ready, skipping node processing.'); + return; + } + + const linkHandler = this.getFeature('linkHandler'); + if (!linkHandler) { + console.warn('MindMapCore: LinkHandler feature not available'); + return; + } + + try { + // 1. 触发linkHandler处理所有节点,添加自定义图标 + linkHandler.processAllNodes(); + + // 2. 强制MindElixir重新计算布局和连线 + // 这是解决布局错乱的关键 + if (this.mindElixir && typeof this.mindElixir.linkDiv === 'function') { + console.log('MindMapCore: Forcing re-layout after node processing.'); + this.mindElixir.linkDiv(); + } + + // 3. 触发内容变更事件,以启动自动保存 + this.emit('contentChanged'); + + } catch (error) { + console.error('MindMapCore: Error processing nodes and re-layouting:', error); + } + } + + /** + * 设置功能模块 + * 在此方法中注册所需的功能模块 + * 子类应该重写此方法来注册具体的功能模块 + */ + setupFeatures() { + // 子类应该重写此方法 + console.log('MindMapCore: setupFeatures called - should be overridden by subclass'); + } + + /** + * 初始化所有功能模块 + * 步骤: + * 1. 遍历所有已注册的功能模块 + * 2. 调用每个模块的init方法进行初始化 + */ + initFeatures() { + console.log('MindMapCore: initFeatures called, features count:', this.features.size); + for (const [name, feature] of this.features.entries()) { + console.log('MindMapCore: Initializing feature:', name); + if (typeof feature.init === 'function') { + feature.init(); + console.log('MindMapCore: Feature', name, 'initialized'); + } else { + console.warn('MindMapCore: Feature', name, 'has no init method'); + } + } + } + + /** + * 注册功能模块 + * 步骤: + * 1. 将功能模块实例保存到映射表中 + * 2. 注入核心实例到功能模块中 + * + * @param {string} name - 功能模块名称 + * @param {object} feature - 功能模块实例 + */ + registerFeature(name, feature) { + this.features.set(name, feature); + // 注入核心实例到功能模块 + if (typeof feature.setCore === 'function') { + feature.setCore(this); + } + } + + /** + * 获取功能模块实例 + * @param {string} name - 功能模块名称 + * @returns {object} 功能模块实例 + */ + getFeature(name) { + return this.features.get(name); + } + + /** + * 设置思维导图数据 + * 步骤: + * 1. 验证数据有效性 + * 2. 保存数据 + * 3. 更新思维导图显示 + * 4. 通知所有功能模块数据变更 + * + * @param {object} p_data - 思维导图数据 + */ + setData(p_data) { + console.log('MindMapCore: setData called with:', p_data); + + let data; + try { + // 解析数据或使用默认数据 + if (p_data && p_data !== "") { + // 检查p_data是否已经是对象 + if (typeof p_data === 'object') { + data = p_data; + } else { + data = JSON.parse(p_data); + } + console.log('MindMapCore: Using data:', data); + } else { + data = MindElixir.new('New Topic'); + console.log('MindMapCore: Using default data'); + } + + // 检查数据格式 + if (!data.nodeData) { + console.error('MindMapCore: Invalid data format - missing nodeData'); + data = MindElixir.new('New Topic'); + } + + // 保存数据供功能模块使用 + this.data = data; + + // 初始化思维导图 + console.log('MindMapCore: Initializing MindElixir with data'); + this.mindElixir.init(data); + + // 通知所有功能模块数据变更 + console.log('MindMapCore: Notifying features of data change'); + for (const feature of this.features.values()) { + if (typeof feature.onDataChange === 'function') { + feature.onDataChange(data); + } + } + + // 等待MindElixir渲染完成后处理节点,确保链接标签正确显示 + this.processNodesAndRelayout(); + + // 触发渲染完成事件 + console.log('MindMapCore: Emitting rendered event'); + this.emit('rendered'); + + } catch (error) { + console.error('MindMapCore: Error in setData:', error); + // 如果解析失败,使用默认数据 + data = MindElixir.new('New Topic'); + this.mindElixir.init(data); + } + } + + /** + * 设置键盘快捷键 + * 监听保存快捷键 (Ctrl+S / Cmd+S) + */ + setupKeyboardShortcuts() { + document.addEventListener('keydown', (event) => { + // 检查是否是保存快捷键 + const isSaveShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's'; + + if (isSaveShortcut) { + event.preventDefault(); // 阻止浏览器默认的保存行为 + console.log('MindMapCore: Save shortcut detected, notifying contents changed.'); + + // 标准做法:只通知后端内容已变更,由后端处理后续保存逻辑 + if (window.vxAdapter?.notifyContentsChanged) { + window.vxAdapter.notifyContentsChanged(); + } + } + }); + + console.log('MindMapCore: Keyboard shortcuts setup complete'); + } + + /** + * 保存思维导图数据 + * @param {number|string} p_id - 数据ID + */ + saveData(p_id) { + console.log('MindMapCore: saveData called with id:', p_id); + + if (!this.mindElixir) { + const error = 'Cannot save - mindElixir instance is null'; + console.error('MindMapCore:', error); + this.emitSaveResult(p_id, false, error); + return; + } + + try { + console.log('MindMapCore: Getting all data from mindElixir'); + const allData = this.mindElixir.getAllData(); + + // 验证数据有效性 + if (!allData || !allData.nodeData) { + const error = 'Invalid mind map data structure'; + console.error('MindMapCore:', error); + this.emitSaveResult(p_id, false, error); + return; + } + + // 准备要保存的数据 + const dataToSave = JSON.stringify(allData); + + if (window.vxAdapter?.setSavedData) { + // 将内部使用的 'auto_save' ID 转换为后端能理解的 0 + const saveId = p_id === 'auto_save' ? 0 : p_id; + window.vxAdapter.setSavedData(saveId, dataToSave); + this.emitSaveResult(saveId, true, '', dataToSave); + } else { + const error = 'vxAdapter.setSavedData is not available'; + console.error('MindMapCore:', error); + this.emitSaveResult(p_id, false, error); + } + } catch (error) { + console.error('MindMapCore: Error in save process:', error); + this.emitSaveResult(p_id, false, error.message); + } + } + + /** + * 发送保存结果事件 + * @param {number|string} id - 保存ID + * @param {boolean} success - 是否成功 + * @param {string} [error] - 错误信息 + * @param {string} [data] - 保存的数据 + */ + emitSaveResult(id, success, error = '', data = '') { + const result = { + id: id, + success: success, + error: error, + timestamp: Date.now(), + data: data + }; + + this.emit('saveCompleted', result); + } +} \ No newline at end of file diff --git a/src/data/extra/web/js/mindmap/features/link-handler/link-handler.js b/src/data/extra/web/js/mindmap/features/link-handler/link-handler.js new file mode 100644 index 0000000000..793e2964c9 --- /dev/null +++ b/src/data/extra/web/js/mindmap/features/link-handler/link-handler.js @@ -0,0 +1,1068 @@ +/** + * 思维导图链接处理功能模块 + * 提供节点链接的可视化和交互功能 + */ +class LinkHandlerFeature { + constructor() { + this.core = null; + this.nodeDataMap = new Map(); + } + + /** + * 设置核心实例引用 + * @param {MindMapCore} core - 核心实例 + */ + setCore(core) { + this.core = core; + } + + /** + * 初始化链接处理功能 + */ + init() { + console.log('LinkHandlerFeature: init called'); + this.setupLinkTagClickListener(); + console.log('LinkHandlerFeature: initialization complete'); + } + + /** + * 处理节点数据添加 link 增强功能 + * 步骤: + * 1. 验证节点数据 + * 2. 检查是否存在超链接 + * 3. 添加链接标签 + * + * @param {HTMLElement} domNode - DOM节点元素 + * @param {object} nodeDataMapOrNodeData - 节点数据映射或单个节点数据 + */ + processNodeWithData(domNode, nodeDataMapOrNodeData) { + if (!domNode) { + console.warn('LinkHandlerFeature: No DOM node provided'); + return; + } + + let nodeData = null; + let nodeId = null; + + // 检查第二个参数是Map还是单个nodeData对象 + if (nodeDataMapOrNodeData instanceof Map) { + // 如果是Map,需要通过domNode查找对应的nodeData + const nodeDataMap = nodeDataMapOrNodeData; + + // 通过data-nodeid属性获取节点ID + if (domNode.hasAttribute('data-nodeid')) { + nodeId = domNode.getAttribute('data-nodeid'); + // console.log('LinkHandlerFeature: Processing node with ID:', nodeId); + + // 处理MindElixir的ID前缀(DOM中可能有"me"前缀,但nodeData中没有) + let cleanNodeId = nodeId; + if (nodeId.startsWith('me')) { + cleanNodeId = nodeId.substring(2); // 移除"me"前缀 + // console.log('LinkHandlerFeature: Cleaned node ID:', cleanNodeId); + } + + // 首先尝试用原始ID匹配 + nodeData = nodeDataMap.get(nodeId); + + // 如果失败,尝试用清理后的ID匹配 + if (!nodeData) { + nodeData = nodeDataMap.get(cleanNodeId); + } + + // debug use + // if (nodeData) { + // console.log('LinkHandlerFeature: Found node data:', { + // id: nodeData.id, + // topic: nodeData.topic, + // hyperLink: nodeData.hyperLink + // }); + // } else { + // console.warn('LinkHandlerFeature: No node data found for ID:', nodeId); + // } + } + } else { + // 如果是单个nodeData对象 + nodeData = nodeDataMapOrNodeData; + nodeId = nodeData ? nodeData.id : null; + } + + // 移除MindElixir默认生成的超链接元素,避免重叠 + const defaultLink = domNode.querySelector('a.hyper-link'); + if (defaultLink) { + defaultLink.remove(); + } + + // 如果没有找到nodeData或没有hyperLink,移除可能存在的旧标签并返回 + if (!nodeData || !nodeData.hyperLink) { + const existingContainer = domNode.querySelector('.vx-link-container'); + if (existingContainer) { + existingContainer.remove(); + } + return; + } + + // 查找或创建链接容器 + let textContainer = this.findTextContainer(domNode); + if (!textContainer) { + console.warn('LinkHandlerFeature: Could not find text container for node:', nodeId); + return; + } + + // 检查是否已存在链接标签 + let existingContainer = textContainer.querySelector('.vx-link-container'); + if (existingContainer) { + existingContainer.remove(); + } + + // 提取文件扩展名 + const extension = this.extractFileExtension(nodeData.hyperLink); + if (!extension) { + console.warn('LinkHandlerFeature: Could not extract extension from:', nodeData.hyperLink); + return; + } + + // debug use + // console.log('LinkHandlerFeature: Creating link tag for node:', { + // nodeId: nodeId, + // extension: extension, + // hyperLink: nodeData.hyperLink + // }); + + // 获取样式配置 + const style = this.getLinkTagStyle(extension); + + // 创建链接标签容器 + const linkContainer = document.createElement('span'); + linkContainer.className = 'vx-link-container'; + linkContainer.style.cssText = ` + display: inline-flex; + align-items: center; + margin-left: 4px; + vertical-align: baseline; + flex-shrink: 0; + position: relative; + z-index: 1; + `; + + // 创建链接标签 + const linkTag = document.createElement('span'); + linkTag.className = 'vx-link-tag'; + linkTag.textContent = `[${extension}]`; + linkTag.dataset.url = nodeData.hyperLink; + linkTag.dataset.nodeid = nodeId; + linkTag.title = `点击打开: ${nodeData.hyperLink}\n拖拽到不同方向可以控制打开位置\n↑上方 ↓下方 ←左侧 →右侧(默认)`; + linkTag.style.cssText = ` + background: ${style.backgroundColor}; + color: ${style.textColor}; + padding: 2px 4px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + cursor: pointer; + user-select: none; + border: 1px solid ${style.borderColor}; + display: inline-flex; + align-items: center; + line-height: 1; + min-width: 16px; + text-align: center; + transition: all 0.2s ease; + font-family: monospace; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + white-space: nowrap; + `; + + // 将链接标签添加到容器中 + linkContainer.appendChild(linkTag); + + // 确保文本容器使用正确的布局 + textContainer.style.display = 'inline-flex'; + textContainer.style.alignItems = 'center'; + textContainer.style.flexWrap = 'nowrap'; + textContainer.style.gap = '4px'; + textContainer.style.width = 'auto'; + textContainer.style.position = 'relative'; + + // 添加链接标签到文本容器 + textContainer.appendChild(linkContainer); + + // 设置拖拽事件处理 + this.setupDragEvents(linkTag); + + // 确保父节点计算正确的宽度 + const parentNode = domNode.closest('.map-node'); + if (parentNode) { + parentNode.style.width = 'auto'; + parentNode.style.minWidth = 'fit-content'; + } + + // console.log('LinkHandlerFeature: Link tag added successfully for node:', nodeId); + } + + /** + * 设置拖拽事件处理 + * @param {HTMLElement} linkTag - 链接标签元素 + */ + setupDragEvents(linkTag) { + // 拖拽状态变量 + let isDragging = false; + let startX = 0; + let startY = 0; + let dragThreshold = 15; // 拖拽阈值(像素) + + // 添加hover效果 + linkTag.addEventListener('mouseenter', () => { + if (!isDragging) { + linkTag.style.transform = 'scale(1.05)'; + linkTag.style.boxShadow = '0 3px 6px rgba(0,0,0,0.2)'; + } + }); + + linkTag.addEventListener('mouseleave', () => { + if (!isDragging) { + linkTag.style.transform = 'scale(1)'; + linkTag.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + } + }); + + // 鼠标按下事件 - 开始拖拽检测 + linkTag.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + + isDragging = false; + startX = event.clientX; + startY = event.clientY; + + // 添加拖拽样式 + linkTag.style.cursor = 'grabbing'; + linkTag.style.transform = 'scale(1.1)'; + linkTag.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; + linkTag.style.transition = 'none'; + + // 显示拖拽指示器(初始状态) + this.showDragIndicator(startX, startY, 0, 0, 'Right'); + + // 添加文档级别的事件监听器 + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + + // 鼠标移动事件 - 检测拖拽方向 + const handleMouseMove = (event) => { + if (event.buttons !== 1) return; // 确保鼠标左键按下 + + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > dragThreshold) { + isDragging = true; + } + + // 如果开始拖拽,更新指示器和方向线 + if (distance > 5) { // 更低的阈值,更敏感的响应 + this.updateDragIndicator(startX, startY, deltaX, deltaY); + } + }; + + // 鼠标释放事件 - 处理点击或拖拽 + const handleMouseUp = (event) => { + event.preventDefault(); + event.stopPropagation(); + + // 移除事件监听器 + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // 恢复样式 + linkTag.style.cursor = 'pointer'; + linkTag.style.transform = 'scale(1)'; + linkTag.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + linkTag.style.transition = 'all 0.2s ease'; + + // 移除拖拽指示器 + this.hideDragIndicator(); + + if (isDragging) { + // 计算拖拽方向 + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + const direction = this.calculateDragDirection(deltaX, deltaY); + + // 发送带方向的URL点击事件 + this.handleUrlClickWithDirection(linkTag.dataset.url, direction); + } else { + // 普通点击 - 默认右边 + this.handleUrlClickWithDirection(linkTag.dataset.url, 'Right'); + } + + isDragging = false; + }; + } + + // 提取节点文本 + extractNodeText(domNode) { + let text = ''; + + // 尝试多种方式获取文本 + const textElement = domNode.querySelector('tpc') || + domNode.querySelector('.topic') || + domNode; + + if (textElement) { + // 排除已有的链接标签 + const cloned = textElement.cloneNode(true); + const linkContainers = cloned.querySelectorAll('.vx-link-container'); + linkContainers.forEach(container => container.remove()); + text = cloned.textContent || cloned.innerText || ''; + } + + return text.trim(); + } + + /** + * 查找节点的文本容器 + * 步骤: + * 1. 查找内容元素 + * 2. 查找或使用文本容器 + * + * @param {HTMLElement} nodeElement - 节点元素 + * @returns {HTMLElement} 文本容器元素 + */ + findTextContainer(nodeElement) { + const selectors = ['tpc', '.topic', '.node-topic', '.mind-elixir-topic']; + + for (const selector of selectors) { + const container = nodeElement.querySelector(selector); + if (container) { + return container; + } + } + + // 如果找不到特定容器,返回节点本身 + return nodeElement; + } + + /** + * 提取文件扩展名或URL类型 + * @param {string} hyperLink - 超链接URL + * @returns {string} 文件扩展名或URL类型 + */ + extractFileExtension(hyperLink) { + if (!hyperLink) { + return 'link'; + } + + // HTTP/HTTPS URLs + if (hyperLink.startsWith('https://')) { + return 'https'; + } + if (hyperLink.startsWith('http://')) { + return 'http'; + } + + // 文件路径 - 提取扩展名 + const match = hyperLink.match(/\.([a-zA-Z0-9]+)$/); + if (match) { + return match[1].toLowerCase(); + } + + // 如果无法识别,返回通用的'link' + return 'link'; + } + + /** + * 根据链接类型获取样式配置 + * @param {string} extension - 文件扩展名 + * @returns {object} 样式配置对象 + */ + getLinkTagStyle(extension) { + let backgroundColor, borderColor, textColor; + + switch (extension) { + case 'md': + backgroundColor = '#276f86'; + borderColor = '#276f86'; + textColor = '#f7f7f7'; + break; + case 'pdf': + backgroundColor = '#f6f6f6'; + borderColor = '#ff6b35'; + textColor = '#ff6b35'; + break; + case 'http': + case 'https': + backgroundColor = '#f7f7f7'; + borderColor = '#00aaff'; + textColor = '#26b4f9'; + break; + default: + backgroundColor = '#f7f7f7'; + borderColor = '#444444'; + textColor = '#444444'; + break; + } + + return { backgroundColor, borderColor, textColor }; + } + + /** + * 创建链接标签 + * 步骤: + * 1. 创建标签容器和标签 + * 2. 设置样式和内容 + * 3. 添加拖拽事件 + * 4. 添加到容器 + * + * @param {HTMLElement} textContainer - 文本容器元素 + * @param {string} nodeId - 节点ID + * @param {string} hyperLink - 超链接URL + * @param {string} extension - 文件扩展名 + */ + createLinkTag(textContainer, nodeId, hyperLink, extension) { + // 获取样式配置 + const style = this.getLinkTagStyle(extension); + + // 创建链接标签容器 + const linkContainer = document.createElement('span'); + linkContainer.className = 'vx-link-container'; + linkContainer.style.cssText = ` + display: inline-flex; + align-items: center; + margin-left: 4px; + vertical-align: baseline; + flex-shrink: 0; + `; + + // 创建链接标签 + const linkTag = document.createElement('span'); + linkTag.className = 'vx-link-tag'; + linkTag.textContent = `[${extension}]`; + linkTag.dataset.url = hyperLink; + linkTag.dataset.nodeid = nodeId; + linkTag.title = `点击打开: ${hyperLink}\n拖拽到不同方向可以控制打开位置\n↑上方 ↓下方 ←左侧 →右侧(默认)`; + linkTag.style.cssText = ` + background: ${style.backgroundColor}; + color: ${style.textColor}; + padding: 2px 4px; + border-radius: 3px; + font-size: 10px; + font-weight: bold; + cursor: pointer; + user-select: none; + border: 1px solid ${style.borderColor}; + display: inline-flex; + align-items: center; + line-height: 1; + min-width: 16px; + text-align: center; + transition: all 0.2s ease; + font-family: monospace; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + white-space: nowrap; + position: relative; + z-index: 1; + `; + + // 拖拽状态变量 + let isDragging = false; + let startX = 0; + let startY = 0; + let dragThreshold = 15; // 拖拽阈值(像素) + + // 添加hover效果 + linkTag.addEventListener('mouseenter', () => { + if (!isDragging) { + linkTag.style.transform = 'scale(1.05)'; + linkTag.style.boxShadow = '0 3px 6px rgba(0,0,0,0.2)'; + } + }); + + linkTag.addEventListener('mouseleave', () => { + if (!isDragging) { + linkTag.style.transform = 'scale(1)'; + linkTag.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + } + }); + + // 鼠标按下事件 - 开始拖拽检测 + linkTag.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + + isDragging = false; + startX = event.clientX; + startY = event.clientY; + + // 添加拖拽样式 + linkTag.style.cursor = 'grabbing'; + linkTag.style.transform = 'scale(1.1)'; + linkTag.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; + linkTag.style.transition = 'none'; + + // 显示拖拽指示器(初始状态) + this.showDragIndicator(startX, startY, 0, 0, 'Right'); + }); + + // 鼠标移动事件 - 检测拖拽方向 + const handleMouseMove = (event) => { + if (event.buttons !== 1) return; // 确保鼠标左键按下 + + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > dragThreshold) { + isDragging = true; + } + + // 如果开始拖拽,更新指示器和方向线 + if (distance > 5) { // 更低的阈值,更敏感的响应 + this.updateDragIndicator(startX, startY, deltaX, deltaY); + } + }; + + // 鼠标释放事件 - 处理点击或拖拽 + const handleMouseUp = (event) => { + event.preventDefault(); + event.stopPropagation(); + + // 移除事件监听器 + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // 恢复样式 + linkTag.style.cursor = 'pointer'; + linkTag.style.transform = 'scale(1)'; + linkTag.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; + linkTag.style.transition = 'all 0.2s ease'; + + // 移除拖拽指示器 + this.hideDragIndicator(); + + if (isDragging) { + // 计算拖拽方向 + const deltaX = event.clientX - startX; + const deltaY = event.clientY - startY; + const direction = this.calculateDragDirection(deltaX, deltaY); + + console.log(`LinkHandlerFeature: Drag detected, direction: ${direction}`); + + // 发送带方向的URL点击事件 + this.handleUrlClickWithDirection(hyperLink, direction); + } else { + // 普通点击 - 默认右边 + console.log('LinkHandlerFeature: Normal click, using default right direction'); + this.handleUrlClickWithDirection(hyperLink, 'Right'); + } + + isDragging = false; + }; + + // 添加文档级别的事件监听器 + linkTag.addEventListener('mousedown', () => { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + + linkContainer.appendChild(linkTag); + textContainer.appendChild(linkContainer); + + console.log(`LinkHandlerFeature: Link tag [${extension}] created successfully with style:`, style); + } + + /** + * 显示拖拽方向指示器 + */ + showDragIndicator(startX, startY, deltaX, deltaY, initialDirection) { + // 移除现有指示器 + this.hideDragIndicator(); + + const direction = deltaX === 0 && deltaY === 0 ? initialDirection : this.calculateDragDirection(deltaX, deltaY); + + // 创建指示器容器 + const container = document.createElement('div'); + container.id = 'vx-drag-indicator-container'; + container.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 10000; + `; + + // 创建方向线条(如果有移动) + if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) { + const line = document.createElement('div'); + line.className = 'vx-drag-line'; + + const endX = startX + deltaX; + const endY = startY + deltaY; + const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI; + + line.style.cssText = ` + position: absolute; + left: ${startX}px; + top: ${startY}px; + width: ${length}px; + height: 2px; + background: linear-gradient(to right, + rgba(74, 144, 226, 0.8) 0%, + rgba(74, 144, 226, 0.6) 50%, + rgba(74, 144, 226, 1) 100%); + transform-origin: 0 50%; + transform: rotate(${angle}deg); + border-radius: 1px; + box-shadow: 0 0 6px rgba(74, 144, 226, 0.4); + transition: none; + `; + container.appendChild(line); + + // 在线条末端添加箭头 + const arrowHead = document.createElement('div'); + arrowHead.className = 'vx-drag-arrow'; + arrowHead.style.cssText = ` + position: absolute; + left: ${endX - 6}px; + top: ${endY - 6}px; + width: 12px; + height: 12px; + background: #4a90e2; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(74, 144, 226, 0.6); + `; + container.appendChild(arrowHead); + } + + // 创建文字指示器 + const indicator = document.createElement('div'); + indicator.id = 'vx-drag-indicator'; + indicator.style.cssText = ` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.85); + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 18px; + font-weight: bold; + white-space: nowrap; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + border: 2px solid #4a90e2; + `; + + let directionText = ''; + let arrow = ''; + switch (direction) { + case 'Up': + directionText = '上方打开'; + arrow = '↑'; + break; + case 'Down': + directionText = '下方打开'; + arrow = '↓'; + break; + case 'Left': + directionText = '左侧打开'; + arrow = '←'; + break; + case 'Right': + default: + directionText = '右侧打开'; + arrow = '→'; + break; + } + + indicator.innerHTML = `${arrow} ${directionText}`; + container.appendChild(indicator); + + // 添加CSS动画 + if (!document.getElementById('vx-drag-styles')) { + const style = document.createElement('style'); + style.id = 'vx-drag-styles'; + style.textContent = ` + @keyframes dragFadeIn { + from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } + } + #vx-drag-indicator { + animation: dragFadeIn 0.2s ease; + } + `; + document.head.appendChild(style); + } + + document.body.appendChild(container); + } + + /** + * 更新拖拽指示器 + */ + updateDragIndicator(startX, startY, deltaX, deltaY) { + const container = document.getElementById('vx-drag-indicator-container'); + if (!container) { + // 如果容器不存在,重新创建 + this.showDragIndicator(startX, startY, deltaX, deltaY, 'Right'); + return; + } + + const direction = this.calculateDragDirection(deltaX, deltaY); + + // 更新方向线条 + let line = container.querySelector('.vx-drag-line'); + let arrowHead = container.querySelector('.vx-drag-arrow'); + + if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) { + const endX = startX + deltaX; + const endY = startY + deltaY; + const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI; + + if (!line) { + line = document.createElement('div'); + line.className = 'vx-drag-line'; + container.appendChild(line); + } + + line.style.cssText = ` + position: absolute; + left: ${startX}px; + top: ${startY}px; + width: ${length}px; + height: 2px; + background: linear-gradient(to right, + rgba(74, 144, 226, 0.8) 0%, + rgba(74, 144, 226, 0.6) 50%, + rgba(74, 144, 226, 1) 100%); + transform-origin: 0 50%; + transform: rotate(${angle}deg); + border-radius: 1px; + box-shadow: 0 0 6px rgba(74, 144, 226, 0.4); + transition: none; + `; + + if (!arrowHead) { + arrowHead = document.createElement('div'); + arrowHead.className = 'vx-drag-arrow'; + container.appendChild(arrowHead); + } + + arrowHead.style.cssText = ` + position: absolute; + left: ${endX - 6}px; + top: ${endY - 6}px; + width: 12px; + height: 12px; + background: #4a90e2; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(74, 144, 226, 0.6); + `; + } + + // 更新文字指示器 + const indicator = container.querySelector('#vx-drag-indicator'); + if (indicator) { + let directionText = ''; + let arrow = ''; + switch (direction) { + case 'Up': + directionText = '上方打开'; + arrow = '↑'; + break; + case 'Down': + directionText = '下方打开'; + arrow = '↓'; + break; + case 'Left': + directionText = '左侧打开'; + arrow = '←'; + break; + case 'Right': + default: + directionText = '右侧打开'; + arrow = '→'; + break; + } + indicator.innerHTML = `${arrow} ${directionText}`; + } + } + + /** + * 隐藏拖拽指示器 + */ + hideDragIndicator() { + const container = document.getElementById('vx-drag-indicator-container'); + if (container) { + container.remove(); + } + + // 清理旧的指示器(向后兼容) + const oldIndicator = document.getElementById('vx-drag-indicator'); + if (oldIndicator) { + oldIndicator.remove(); + } + } + + /** + * 计算拖拽方向 + */ + calculateDragDirection(deltaX, deltaY) { + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + // 判断主要拖拽方向 + if (absDeltaX > absDeltaY) { + // 水平方向 + return deltaX > 0 ? 'Right' : 'Left'; + } else { + // 垂直方向 + return deltaY > 0 ? 'Down' : 'Up'; + } + } + + /** + * 根据方向处理URL点击 + * 步骤: + * 1. 验证URL + * 2. 根据方向选择打开方式 + * + * @param {string} url - 链接URL + * @param {string} direction - 拖拽方向 + */ + handleUrlClickWithDirection(url, direction) { + if (!url) return; + + // 根据方向处理点击 + if (window.vxAdapter && window.vxAdapter.handleUrlClickWithDirection) { + window.vxAdapter.handleUrlClickWithDirection(url, direction); + } else { + console.warn('vxAdapter.handleUrlClickWithDirection not available, falling back to normal click'); + this.handleUrlClick(url); + } + } + + /** + * 设置链接标签点击监听器 + */ + setupLinkTagClickListener() { + document.addEventListener('click', (e) => { + const linkTag = e.target.closest('.link-tag'); + if (linkTag) { + const url = linkTag.dataset.url; + if (url) { + this.handleUrlClick(url); + } + } + }); + } + + /** + * 处理URL点击 + * @param {string} url - 链接URL + */ + handleUrlClick(url) { + if (!url) return; + + if (window.vxAdapter && window.vxAdapter.handleUrlClick) { + window.vxAdapter.handleUrlClick(url); + } else { + console.warn('vxAdapter.handleUrlClick not available'); + } + } + + /** + * 移除所有链接标签 + */ + removeAllLinkTags() { + try { + const linkContainers = document.querySelectorAll('.vx-link-container'); + console.log('LinkHandlerFeature: Removing', linkContainers.length, 'existing link tags'); + linkContainers.forEach(container => container.remove()); + } catch (error) { + console.error('LinkHandlerFeature: Error removing link tags:', error); + } + } + + /** + * 数据变更处理 + * 步骤: + * 1. 清空节点数据映射 + * 2. 重建节点数据映射 + * 3. 处理所有节点 + * + * @param {object} data - 新的数据 + */ + onDataChange(data) { + console.log('LinkHandlerFeature: onDataChange called with data:', data); + + // 清空现有映射 + this.nodeDataMap.clear(); + + if (!data || !data.nodeData) { + console.warn('LinkHandlerFeature: Invalid data structure'); + return; + } + + // 重建节点数据映射 + this.buildNodeDataMapRecursive(data, this.nodeDataMap); + + // 验证映射结果 + this.validateNodeDataMap(); + + console.log('LinkHandlerFeature: Built nodeDataMap with', this.nodeDataMap.size, 'entries'); + + // 处理所有节点 + this.processAllNodes(); + } + + /** + * 验证节点数据映射 + */ + validateNodeDataMap() { + console.log('LinkHandlerFeature: Validating nodeDataMap'); + let hyperLinkCount = 0; + + this.nodeDataMap.forEach((nodeData, nodeId) => { + if (nodeData.hyperLink) { + hyperLinkCount++; + console.log('LinkHandlerFeature: Found node with hyperlink:', { + nodeId: nodeId, + topic: nodeData.topic, + hyperLink: nodeData.hyperLink + }); + } + }); + + console.log(`LinkHandlerFeature: Found ${hyperLinkCount} nodes with hyperlinks out of ${this.nodeDataMap.size} total nodes`); + } + + /** + * 递归构建节点数据映射 + * @param {object} data - 节点数据 + * @param {Map} map - 映射表 + */ + buildNodeDataMapRecursive(data, map) { + if (!data) return; + + // 如果是根节点,从nodeData开始 + const nodeData = data.nodeData || data; + if (!nodeData) return; + + // debug use + // 添加当前节点到映射 + // console.log('LinkHandlerFeature: Adding node to map:', { + // id: nodeData.id, + // topic: nodeData.topic, + // hyperLink: nodeData.hyperLink + // }); + map.set(nodeData.id, nodeData); + + // 如果有子节点,递归处理 + if (nodeData.children && Array.isArray(nodeData.children)) { + nodeData.children.forEach(child => { + this.buildNodeDataMapRecursive(child, map); + }); + } + } + + // 检查是否是脑图节点 + isMindmapNode(element) { + // 检查多种可能的脑图节点特征 + return element.hasAttribute && ( + element.hasAttribute('data-nodeid') || + element.classList.contains('topic') || + element.classList.contains('node') || + element.tagName.toLowerCase() === 'tpc' + ); + } + + /** + * 处理所有节点 + * 步骤: + * 1. 移除现有链接标签 + * 2. 获取所有思维导图节点 + * 3. 为每个节点处理链接 + */ + processAllNodes() { + if (!this.core) { + console.warn('LinkHandlerFeature: Core not available, cannot process nodes.'); + return; + } + + try { + // 在处理DOM前禁用观察者,防止无限循环 + this.core.disableObserver(); + + console.log('LinkHandlerFeature: processAllNodes called'); + + // 关键修复:每次处理时,都从core主动获取最新的数据,确保数据同步 + if (this.core && this.core.mindElixir) { + const mindmapData = this.core.mindElixir.getAllData(); + if (mindmapData && mindmapData.nodeData) { + this.nodeDataMap.clear(); + this.buildNodeDataMapRecursive(mindmapData.nodeData, this.nodeDataMap); + console.log('LinkHandlerFeature: Node data map rebuilt with latest data. Size:', this.nodeDataMap.size); + } else { + console.warn('LinkHandlerFeature: Could not get latest data from core.'); + } + } else { + console.warn('LinkHandlerFeature: Core or MindElixir instance not available to fetch latest data.'); + return; // 如果没有核心实例,无法继续 + } + + this.removeAllLinkTags(); + + try { + // 查找所有可能的脑图节点 + const mindmapElement = document.getElementById('vx-mindmap'); + if (!mindmapElement) { + console.warn('LinkHandlerFeature: Could not find #vx-mindmap element'); + return; + } + + // 查找所有节点 + const mindmapNodes = mindmapElement.querySelectorAll('tpc[data-nodeid]'); + + console.log('LinkHandlerFeature: Found', mindmapNodes.length, 'potential mindmap nodes'); + console.log('LinkHandlerFeature: nodeDataMap size:', this.nodeDataMap.size); + + // 处理每个节点 + mindmapNodes.forEach((domNode, index) => { + const nodeId = domNode.dataset.nodeid; + if (nodeId) { + const cleanNodeId = nodeId.startsWith('me') ? nodeId.substring(2) : nodeId; + const nodeData = this.nodeDataMap.get(cleanNodeId); + if (nodeData && nodeData.hyperLink) { + // console.log(`LinkHandlerFeature: Processing node ${index + 1}/${mindmapNodes.length}:`, { + // nodeId: cleanNodeId, + // hyperLink: nodeData.hyperLink + // }); + this.processNodeWithData(domNode, this.nodeDataMap); + } + } + }); + + // 验证处理结果 + const addedTags = document.querySelectorAll('.vx-link-container'); + console.log('LinkHandlerFeature: Added', addedTags.length, 'link tags'); + + } catch (error) { + console.error('LinkHandlerFeature: Error processing nodes:', error); + } + } finally { + // 在finally块中重新启用观察者,确保即使发生错误也能恢复 + // 使用setTimeout确保在当前事件循环结束后再启用,避免立即重新触发 + setTimeout(() => { + if (this.core) { + this.core.enableObserver(); + } + }, 50); + } + } +} \ No newline at end of file diff --git a/src/data/extra/web/js/mindmap/features/outline/outline.js b/src/data/extra/web/js/mindmap/features/outline/outline.js new file mode 100644 index 0000000000..55ed73a113 --- /dev/null +++ b/src/data/extra/web/js/mindmap/features/outline/outline.js @@ -0,0 +1,1002 @@ +/** + * 思维导图大纲功能模块 + * 提供思维导图节点的大纲视图和导航功能 + */ +class OutlineFeature { + constructor() { + this.core = null; + this.outlineWindow = null; + this.nodeDataMap = new Map(); + this.isCollapsed = false; + this.isResizing = false; + this.originalSize = { width: 280, height: 500 }; + this.minimumSize = { width: 200, height: 300 }; + this.defaultPosition = { top: 580, right: 20 }; + this.lastPosition = null; // 记录最后的位置 + this.lastSize = null; // 记录最后的大小 + this.COLLAPSE_THRESHOLD = 750; // 思维导图尺寸小于这个值时自动折叠 + this.titleBarHeight = 45; // 标题栏高度 + } + + /** + * 设置核心实例引用 + * @param {MindMapCore} core - 核心实例 + */ + setCore(core) { + this.core = core; + } + + /** + * 初始化大纲功能 + * 步骤: + * 1. 创建大纲窗口 + * 2. 设置DOM观察器 + */ + init() { + console.log('OutlineFeature: init called'); + // 先检查并删除已存在的大纲窗口 + const existingWindow = document.getElementById('vx-outline-window'); + if (existingWindow) { + existingWindow.remove(); + } + this.createOutlineWindow(); + this.setupDOMObserver(); + this.setupResizeObserver(); + console.log('OutlineFeature: initialization complete'); + } + + /** + * 创建大纲窗口 + * 步骤: + * 1. 创建窗口容器 + * 2. 添加标题栏、搜索框和内容区 + * 3. 设置拖拽功能 + * 4. 添加事件监听 + */ + createOutlineWindow() { + // 创建大纲窗口容器 + this.outlineWindow = document.createElement('div'); + this.outlineWindow.id = 'vx-outline-window'; + this.outlineWindow.className = 'vx-outline-window'; + + // 设置窗口样式 + this.outlineWindow.style.cssText = ` + position: fixed; + top: ${this.defaultPosition.top}px; + right: ${this.defaultPosition.right}px; + width: ${this.originalSize.width}px; + height: ${this.originalSize.height}px; + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow: hidden; + user-select: none; + display: flex; + flex-direction: column; + transition: width 0.3s ease, height 0.3s ease; + `; + + // 创建标题栏 + const titleBar = document.createElement('div'); + titleBar.className = 'vx-outline-title'; + titleBar.style.cssText = ` + background: #f8f9fa; + padding: 12px 16px; + border-bottom: 1px solid #e0e0e0; + cursor: move; + user-select: none; + font-weight: 600; + font-size: 14px; + color: #333; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + height: ${this.titleBarHeight}px; + box-sizing: border-box; + position: relative; + z-index: 2; + `; + + // 创建标题文本 + const titleText = document.createElement('span'); + titleText.textContent = '脑图大纲'; + titleText.style.cssText = ` + flex: 1; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `; + + // 创建折叠按钮 + const collapseButton = document.createElement('button'); + collapseButton.className = 'vx-outline-collapse-btn'; + collapseButton.innerHTML = '◀'; + collapseButton.title = '折叠/展开'; + collapseButton.style.cssText = ` + width: 24px; + height: 24px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + color: #666; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + margin-left: 8px; + outline: none; + position: relative; + z-index: 3; + `; + + collapseButton.addEventListener('click', () => this.toggleCollapse()); + collapseButton.addEventListener('mouseenter', () => { + collapseButton.style.backgroundColor = '#f0f0f0'; + collapseButton.style.borderColor = '#999'; + }); + collapseButton.addEventListener('mouseleave', () => { + collapseButton.style.backgroundColor = '#fff'; + collapseButton.style.borderColor = '#ddd'; + }); + + titleBar.appendChild(titleText); + titleBar.appendChild(collapseButton); + + // 创建搜索框容器 + const searchContainer = document.createElement('div'); + searchContainer.className = 'vx-outline-search'; + searchContainer.style.cssText = ` + padding: 8px 12px; + border-bottom: 1px solid #e0e0e0; + background: #fafafa; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + position: relative; + z-index: 1; + `; + + // 创建搜索框 + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = '搜索节点...'; + searchInput.className = 'vx-outline-search-input'; + searchInput.style.cssText = ` + flex: 1; + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + outline: none; + background: #fff; + transition: border-color 0.2s ease; + min-width: 0; + `; + + // 创建清空按钮 + const clearButton = document.createElement('button'); + clearButton.className = 'vx-outline-clear-btn'; + clearButton.innerHTML = '✕'; + clearButton.title = '清空搜索'; + clearButton.style.cssText = ` + width: 24px; + height: 24px; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + color: #666; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + opacity: 0.5; + `; + + // 搜索框事件 + searchInput.addEventListener('input', (e) => { + this.searchTerm = e.target.value.toLowerCase().trim(); + this.updateOutlineWindow(); + clearButton.style.opacity = this.searchTerm ? '1' : '0.5'; + }); + + searchInput.addEventListener('focus', () => { + searchInput.style.borderColor = '#4a90e2'; + }); + + searchInput.addEventListener('blur', () => { + searchInput.style.borderColor = '#ddd'; + }); + + // 清空按钮事件 + clearButton.addEventListener('click', () => { + searchInput.value = ''; + this.searchTerm = ''; + this.updateOutlineWindow(); + clearButton.style.opacity = '0.5'; + searchInput.focus(); + }); + + clearButton.addEventListener('mouseenter', () => { + clearButton.style.backgroundColor = '#f0f0f0'; + clearButton.style.borderColor = '#999'; + }); + + clearButton.addEventListener('mouseleave', () => { + clearButton.style.backgroundColor = '#fff'; + clearButton.style.borderColor = '#ddd'; + }); + + searchContainer.appendChild(searchInput); + searchContainer.appendChild(clearButton); + + // 创建内容容器(用于折叠动画) + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'vx-outline-content-wrapper'; + contentWrapper.style.cssText = ` + flex: 1; + display: flex; + flex-direction: column; + transition: all 0.1s; + overflow: hidden; + position: relative; + z-index: 1; + height: ${this.originalSize.height - this.titleBarHeight}px; + `; + + // 创建内容区域 + const content = document.createElement('div'); + content.className = 'vx-outline-content'; + content.style.cssText = ` + padding: 8px; + overflow-y: auto; + flex: 1; + font-size: 13px; + line-height: 1.4; + position: relative; + z-index: 1; + `; + + // 创建调整大小的手柄 + const resizeHandle = document.createElement('div'); + resizeHandle.className = 'vx-outline-resize-handle'; + resizeHandle.style.cssText = ` + position: absolute; + bottom: 0; + right: 0; + width: 15px; + height: 15px; + cursor: nwse-resize; + background: linear-gradient(135deg, transparent 50%, #ccc 50%); + border-radius: 0 0 8px 0; + z-index: 2; + transition: opacity 0.3s ease; + `; + + contentWrapper.appendChild(searchContainer); + contentWrapper.appendChild(content); + + this.outlineWindow.appendChild(titleBar); + this.outlineWindow.appendChild(contentWrapper); + this.outlineWindow.appendChild(resizeHandle); + + // 添加到页面 + document.body.appendChild(this.outlineWindow); + + // 设置拖动功能 + this.setupWindowDrag(titleBar); + // 设置调整大小功能 + this.setupWindowResize(resizeHandle); + + // 初始化变量 + this.outlineVisible = true; + this.collapsedNodes = new Set(); + this.searchTerm = ''; + + console.log('OutlineFeature: Outline window created with search functionality'); + } + + /** + * 设置窗口拖拽功能 + * @param {HTMLElement} titleBar - 标题栏元素 + */ + setupWindowDrag(titleBar) { + let isDragging = false; + let startX, startY; + let initialX, initialY; + let lastValidX, lastValidY; + let animationFrameId = null; + + const updatePosition = (e) => { + if (!isDragging) return; + + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + // 计算新位置 + let newX = initialX + deltaX; + let newY = initialY + deltaY; + + // 获取窗口尺寸 + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const outlineWidth = this.outlineWindow.offsetWidth; + const outlineHeight = this.outlineWindow.offsetHeight; + + // 限制在窗口内 + newX = Math.max(0, Math.min(newX, windowWidth - outlineWidth)); + newY = Math.max(0, Math.min(newY, windowHeight - outlineHeight)); + + // 使用 transform 进行平滑移动 + this.outlineWindow.style.transform = `translate3d(${newX - initialX}px, ${newY - initialY}px, 0)`; + + // 记录有效位置 + lastValidX = newX; + lastValidY = newY; + }; + + const handleMouseMove = (e) => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + animationFrameId = requestAnimationFrame(() => updatePosition(e)); + }; + + const handleMouseUp = () => { + if (!isDragging) return; + isDragging = false; + + // 移除临时事件监听 + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // 重置 transform 并设置实际位置 + this.outlineWindow.style.transform = 'none'; + if (lastValidX !== undefined && lastValidY !== undefined) { + this.outlineWindow.style.left = lastValidX + 'px'; + this.outlineWindow.style.top = lastValidY + 'px'; + this.lastPosition = { left: lastValidX, top: lastValidY }; + } + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + }; + + titleBar.addEventListener('mousedown', (e) => { + if (e.target.classList.contains('vx-outline-collapse-btn')) return; + isDragging = true; + startX = e.clientX; + startY = e.clientY; + initialX = this.outlineWindow.offsetLeft; + initialY = this.outlineWindow.offsetTop; + lastValidX = initialX; + lastValidY = initialY; + + // 添加临时事件监听 + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + } + + /** + * 设置窗口大小调整功能 + * @param {HTMLElement} handle - 调整大小的手柄元素 + */ + setupWindowResize(handle) { + let startX, startY, startWidth, startHeight, startLeft, startTop; + + const handleMouseDown = (e) => { + // 如果处于折叠状态,不允许调整大小 + if (this.isCollapsed) return; + + this.isResizing = true; + startX = e.clientX; + startY = e.clientY; + startWidth = this.outlineWindow.offsetWidth; + startHeight = this.outlineWindow.offsetHeight; + startLeft = this.outlineWindow.offsetLeft; + startTop = this.outlineWindow.offsetTop; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + e.preventDefault(); // 防止文本选择 + }; + + const handleMouseMove = (e) => { + if (!this.isResizing) return; + + // 计算新的尺寸 + let newWidth = Math.max(this.minimumSize.width, startWidth + (e.clientX - startX)); + let newHeight = Math.max(this.minimumSize.height, startHeight + (e.clientY - startY)); + + // 限制最大尺寸 + const maxWidth = window.innerWidth - startLeft; + const maxHeight = window.innerHeight - startTop; + newWidth = Math.min(newWidth, maxWidth); + newHeight = Math.min(newHeight, maxHeight); + + // 更新窗口大小 + this.outlineWindow.style.width = `${newWidth}px`; + this.outlineWindow.style.height = `${newHeight}px`; + + // 更新内容区域高度 + const contentWrapper = this.outlineWindow.querySelector('.vx-outline-content-wrapper'); + if (contentWrapper) { + contentWrapper.style.height = `${newHeight - this.titleBarHeight}px`; + } + + // 保存新的尺寸 + this.lastSize = { width: newWidth, height: newHeight }; + + // 请求动画帧以提高性能 + requestAnimationFrame(() => { + // 触发内容更新 + this.updateOutlineWindow(); + }); + }; + + const handleMouseUp = () => { + this.isResizing = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + handle.addEventListener('mousedown', handleMouseDown); + } + + /** + * 设置窗口大小监视器 + */ + setupResizeObserver() { + const mindmapContainer = document.querySelector('.map-container'); + if (!mindmapContainer) return; + + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + const isSmall = width < this.COLLAPSE_THRESHOLD || height < this.COLLAPSE_THRESHOLD; + + if (isSmall) { + // 保存当前位置和大小(如果还没有保存) + if (!this.lastPosition) { + this.lastPosition = { + left: this.outlineWindow.offsetLeft, + top: this.outlineWindow.offsetTop + }; + this.lastSize = { + width: this.outlineWindow.offsetWidth, + height: this.outlineWindow.offsetHeight + }; + } + + // 无论当前是否已经折叠,都确保窗口移动到左上角 + this.outlineWindow.style.top = '80px'; + this.outlineWindow.style.left = '20px'; + + // 如果还没有折叠,则进行折叠 + if (!this.isCollapsed) { + this.toggleCollapse(true); + } + } else { + // 只有在之前是由于窗口大小变化导致的折叠时,才自动展开和恢复位置 + if (this.isCollapsed && this.lastPosition) { + this.toggleCollapse(false); + // 恢复到之前保存的位置 + this.outlineWindow.style.left = this.lastPosition.left + 'px'; + this.outlineWindow.style.top = this.lastPosition.top + 'px'; + } + } + } + }); + + resizeObserver.observe(mindmapContainer); + } + + /** + * 切换大纲窗口的折叠状态 + * @param {boolean} [forceCollapse] - 是否强制折叠 + */ + toggleCollapse(forceCollapse) { + const newState = forceCollapse !== undefined ? forceCollapse : !this.isCollapsed; + this.isCollapsed = newState; + + const collapseBtn = this.outlineWindow.querySelector('.vx-outline-collapse-btn'); + const contentWrapper = this.outlineWindow.querySelector('.vx-outline-content-wrapper'); + const resizeHandle = this.outlineWindow.querySelector('.vx-outline-resize-handle'); + + if (this.isCollapsed) { + // 折叠状态 - 只保留标题栏 + contentWrapper.style.height = '0'; + contentWrapper.style.opacity = '0'; + resizeHandle.style.opacity = '0'; + resizeHandle.style.pointerEvents = 'none'; + collapseBtn.innerHTML = '▶'; + + // 保存当前位置和大小(如果不是强制折叠) + if (!forceCollapse) { + this.lastPosition = { + left: this.outlineWindow.offsetLeft, + top: this.outlineWindow.offsetTop + }; + this.lastSize = { + width: this.outlineWindow.offsetWidth, + height: this.outlineWindow.offsetHeight + }; + } + + // 如果是由于窗口大小变化触发的折叠,则移动到左上角 + if (forceCollapse) { + this.outlineWindow.style.top = '80px'; + this.outlineWindow.style.left = '20px'; + } + + this.outlineWindow.style.height = this.titleBarHeight + 'px'; + } else { + // 展开状态 + const targetHeight = this.lastSize?.height || this.originalSize.height; + const targetWidth = this.lastSize?.width || this.originalSize.width; + + this.outlineWindow.style.width = targetWidth + 'px'; + this.outlineWindow.style.height = targetHeight + 'px'; + contentWrapper.style.height = (targetHeight - this.titleBarHeight) + 'px'; + contentWrapper.style.opacity = '1'; + resizeHandle.style.opacity = '1'; + resizeHandle.style.pointerEvents = 'auto'; + collapseBtn.innerHTML = '◀'; + + // 只在手动折叠后展开时才恢复到之前保存的位置 + if (this.lastPosition && !forceCollapse && !this.isWindowSmall()) { + this.outlineWindow.style.left = this.lastPosition.left + 'px'; + this.outlineWindow.style.top = this.lastPosition.top + 'px'; + } + } + + // 更新内容 + setTimeout(() => { + this.updateOutlineWindow(); + }, 300); + } + + /** + * 检查窗口是否处于小尺寸状态 + * @returns {boolean} 如果窗口小于阈值返回 true + */ + isWindowSmall() { + const mindmapContainer = document.querySelector('.map-container'); + if (!mindmapContainer) return false; + + const { width, height } = mindmapContainer.getBoundingClientRect(); + return width < this.COLLAPSE_THRESHOLD || height < this.COLLAPSE_THRESHOLD; + } + + /** + * 更新大纲窗口内容 + * 步骤: + * 1. 清空现有内容 + * 2. 获取根节点数据 + * 3. 递归渲染节点结构 + */ + updateOutlineWindow() { + if (!this.outlineWindow) { + console.warn('OutlineFeature: outlineWindow not found'); + return; + } + + const content = this.outlineWindow.querySelector('.vx-outline-content'); + if (!content) { + console.warn('OutlineFeature: content area not found'); + return; + } + + try { + // 获取MindElixir数据 + const allData = this.core.mindElixir && this.core.mindElixir.getAllData(); + + if (allData && allData.nodeData) { + content.innerHTML = ''; + this.renderOutlineNode(allData.nodeData, content, 0); + console.log('OutlineFeature: Outline window updated successfully'); + } else { + console.warn('OutlineFeature: No valid data found'); + content.innerHTML = '
暂无数据
'; + } + } catch (error) { + console.error('OutlineFeature: Error updating outline window:', error); + content.innerHTML = '
数据加载失败
'; + } + } + + // 检查节点是否匹配搜索条件 + nodeMatchesSearch(nodeData) { + if (!this.searchTerm) return true; + const topic = (nodeData.topic || '').toLowerCase(); + return topic.includes(this.searchTerm); + } + + // 检查节点或其子节点是否匹配搜索条件 + nodeOrChildrenMatchSearch(nodeData) { + if (this.nodeMatchesSearch(nodeData)) return true; + + if (nodeData.children && nodeData.children.length > 0) { + return nodeData.children.some(child => this.nodeOrChildrenMatchSearch(child)); + } + + return false; + } + + /** + * 渲染大纲节点 + * 步骤: + * 1. 检查搜索过滤 + * 2. 创建节点元素 + * 3. 添加展开/折叠控件和内容 + * 4. 递归渲染子节点 + * + * @param {object} nodeData - 节点数据 + * @param {HTMLElement} container - 容器元素 + * @param {number} level - 节点层级 + */ + renderOutlineNode(nodeData, container, level) { + if (!nodeData) return; + + // 如果有搜索词,检查是否应该显示此节点 + if (this.searchTerm && !this.nodeOrChildrenMatchSearch(nodeData)) { + return; + } + + // 创建节点容器 + const nodeDiv = document.createElement('div'); + nodeDiv.className = 'vx-outline-node'; + nodeDiv.style.cssText = ` + margin-left: ${level * 16}px; + margin-bottom: 2px; + `; + + // 创建节点内容 + const nodeContent = document.createElement('div'); + nodeContent.className = 'vx-outline-node-content'; + nodeContent.dataset.nodeid = nodeData.id; + nodeContent.style.cssText = ` + padding: 6px 8px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: background-color 0.2s ease; + word-break: break-word; + border: 1px solid transparent; + `; + + // 添加展开/折叠图标 + const hasChildren = nodeData.children && nodeData.children.length > 0; + const isCollapsed = this.collapsedNodes.has(nodeData.id); + + let expandIcon = ''; + if (hasChildren) { + expandIcon = isCollapsed ? '▶' : '▼'; + } else { + expandIcon = '●'; + } + + const iconSpan = document.createElement('span'); + iconSpan.className = 'vx-outline-expand-icon'; + iconSpan.style.cssText = ` + font-size: 10px; + color: #666; + min-width: 12px; + text-align: center; + cursor: ${hasChildren ? 'pointer' : 'default'}; + padding: 2px; + border-radius: 2px; + transition: background-color 0.2s ease; + `; + iconSpan.textContent = expandIcon; + + // 折叠/展开功能 + if (hasChildren) { + iconSpan.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleNodeCollapse(nodeData.id); + }); + } + + // 创建文本内容 + const textSpan = document.createElement('span'); + textSpan.style.cssText = ` + flex: 1; + color: ${nodeData.root ? '#2c3e50' : '#34495e'}; + font-weight: ${nodeData.root ? 'bold' : 'normal'}; + font-size: ${nodeData.root ? '14px' : '13px'}; + `; + + // 高亮搜索匹配的文本 + const topic = nodeData.topic || '未命名节点'; + if (this.searchTerm && this.nodeMatchesSearch(nodeData)) { + const regex = new RegExp(`(${this.searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + const highlightedText = topic.replace(regex, '$1'); + textSpan.innerHTML = highlightedText; + } else { + textSpan.textContent = topic; + } + + nodeContent.appendChild(iconSpan); + nodeContent.appendChild(textSpan); + + // 添加点击事件 + nodeContent.addEventListener('click', (e) => { + if (e.target === iconSpan) return; // 避免与折叠图标冲突 + e.stopPropagation(); + + this.highlightOutlineNode(nodeContent); + this.locateNodeInMindMap(nodeData.id); + }); + + nodeDiv.appendChild(nodeContent); + container.appendChild(nodeDiv); + + // 递归渲染子节点(如果未折叠且有子节点) + if (hasChildren && !isCollapsed) { + nodeData.children.forEach(child => { + this.renderOutlineNode(child, container, level + 1); + }); + } + } + + /** + * 切换节点的展开/折叠状态 + * @param {string} nodeId - 节点ID + */ + toggleNodeCollapse(nodeId) { + if (this.collapsedNodes.has(nodeId)) { + this.collapsedNodes.delete(nodeId); + } else { + this.collapsedNodes.add(nodeId); + } + this.updateOutlineWindow(); + } + + /** + * 高亮显示大纲节点 + * 步骤: + * 1. 移除之前的高亮 + * 2. 添加新的高亮 + * + * @param {HTMLElement} nodeElement - 节点元素 + */ + highlightOutlineNode(nodeElement) { + // 清除之前所有大纲节点的高亮 + const prevHighlighted = this.outlineWindow.querySelectorAll('.vx-outline-highlighted'); + prevHighlighted.forEach(el => { + el.classList.remove('vx-outline-highlighted'); + el.style.backgroundColor = 'transparent'; + el.style.borderColor = 'transparent'; + el.style.boxShadow = ''; + el.style.transition = ''; + }); + + // 添加新的高亮 + nodeElement.classList.add('vx-outline-highlighted'); + nodeElement.style.transition = 'all 0.3s ease'; + nodeElement.style.backgroundColor = '#e3f2fd'; // 浅蓝色高亮 + nodeElement.style.borderColor = '#1976d2'; + nodeElement.style.boxShadow = '0 2px 8px rgba(25, 118, 210, 0.3)'; + + // 2秒后移除高亮 + setTimeout(() => { + if (nodeElement.classList.contains('vx-outline-highlighted')) { + nodeElement.style.transition = 'all 0.3s ease'; + nodeElement.classList.remove('vx-outline-highlighted'); + nodeElement.style.backgroundColor = 'transparent'; + nodeElement.style.borderColor = 'transparent'; + nodeElement.style.boxShadow = ''; + + setTimeout(() => { + nodeElement.style.transition = ''; + }, 300); + } + }, 2000); + } + + /** + * 定位到思维导图中的节点 + * @param {string} nodeId - 节点ID + */ + locateNodeInMindMap(nodeId) { + if (!nodeId) return; + + if (this.isLocatingNode) { + console.log('MindMapEditorCore: Skipping locate request - already locating node:', nodeId); + return; + } + + try { + this.isLocatingNode = true; + console.log('MindMapEditorCore: Attempting to locate node:', nodeId); + + // 查找目标节点元素(限制在脑图区域) + const mindmapContainer = document.querySelector('.map-container'); + if (!mindmapContainer) { + console.warn('MindMapEditorCore: Could not find .map-container'); + this.isLocatingNode = false; + return; + } + + const selectors = [ + `[data-nodeid="${nodeId}"]`, + `[data-nodeid="me${nodeId}"]` + ]; + + let targetElement = null; + for (const selector of selectors) { + // 只在脑图容器中查找,避免选择到大纲窗口的节点 + targetElement = mindmapContainer.querySelector(selector); + if (targetElement) { + console.log('MindMapEditorCore: Found target element in mindmap for node:', nodeId); + break; + } + } + + if (!targetElement) { + console.warn('MindMapEditorCore: Could not find element in mindmap for node:', nodeId); + this.isLocatingNode = false; + return; + } + + // 找到 MindElixir 的真实容器(map-container,有overflow:scroll的那个) + const mapContainer = document.querySelector('.map-container'); + if (!mapContainer) { + console.warn('MindMapEditorCore: Could not find .map-container'); + this.isLocatingNode = false; + return; + } + + // 获取节点在20000x20000画布中的绝对位置 + // 直接从style属性获取,因为MindElixir的节点都是绝对定位 + let nodeCanvasX, nodeCanvasY; + + // 尝试从父元素或节点本身获取位置信息 + let positionElement = targetElement; + console.log('MindMapEditorCore: Target element tagName:', targetElement.tagName, 'style:', targetElement.style.cssText); + + while (positionElement && !positionElement.style.left) { + positionElement = positionElement.parentElement; + if (positionElement) { + console.log('MindMapEditorCore: Checking parent:', positionElement.tagName, 'style:', positionElement.style.cssText); + } + if (positionElement && positionElement.tagName.toLowerCase() === 'body') { + break; + } + } + + if (positionElement && positionElement.style.left && positionElement.style.top) { + // 从style属性直接获取位置 + const styleLeft = parseFloat(positionElement.style.left); + const styleTop = parseFloat(positionElement.style.top); + nodeCanvasX = styleLeft + positionElement.offsetWidth / 2; + nodeCanvasY = styleTop + positionElement.offsetHeight / 2; + + console.log('MindMapEditorCore: Using style positioning:', JSON.stringify({ + element: positionElement.tagName, + styleLeft: styleLeft, + styleTop: styleTop, + offsetWidth: positionElement.offsetWidth, + offsetHeight: positionElement.offsetHeight, + calculatedCenter: { x: nodeCanvasX, y: nodeCanvasY } + })); + } else { + // 回退方案:使用getBoundingClientRect计算 + const nodeRect = targetElement.getBoundingClientRect(); + const canvasRect = document.querySelector('.map-canvas').getBoundingClientRect(); + nodeCanvasX = nodeRect.left - canvasRect.left + nodeRect.width / 2 + mapContainer.scrollLeft; + nodeCanvasY = nodeRect.top - canvasRect.top + nodeRect.height / 2 + mapContainer.scrollTop; + + console.log('MindMapEditorCore: Using fallback getBoundingClientRect positioning'); + } + + // MindElixir 居中算法:让容器滚动到 (节点位置 - 容器大小/2) + const targetScrollX = nodeCanvasX - mapContainer.offsetWidth / 2; + const targetScrollY = nodeCanvasY - mapContainer.offsetHeight / 2; + + console.log('MindMapEditorCore: MindElixir positioning calculation:', JSON.stringify({ + nodeInCanvas: { x: Math.round(nodeCanvasX), y: Math.round(nodeCanvasY) }, + containerSize: { width: mapContainer.offsetWidth, height: mapContainer.offsetHeight }, + targetScroll: { x: Math.round(targetScrollX), y: Math.round(targetScrollY) } + })); + + // 使用 MindElixir 的容器执行滚动(关键!) + mapContainer.scrollTo({ + left: targetScrollX, + top: targetScrollY, + behavior: 'smooth' + }); + + // 添加高亮效果,并在高亮后恢复原始背景色 + const originalBg = targetElement.style.backgroundColor; + targetElement.style.backgroundColor = '#ffff00'; + setTimeout(() => { + targetElement.style.backgroundColor = originalBg; + }, 1000); + + console.log('MindMapEditorCore: Successfully scrolled MindElixir container to center node'); + + // 滚动完成后重置状态 + setTimeout(() => { + this.isLocatingNode = false; + console.log('MindMapEditorCore: Node location completed for:', nodeId); + }, 300); + + } catch (error) { + console.error('MindMapEditorCore: Error in locateNodeInMindMap:', error); + this.isLocatingNode = false; + } + } + + /** + * 设置DOM观察器 + * 监听思维导图变化并更新大纲 + */ + setupDOMObserver() { + const observer = new MutationObserver(() => { + this.updateOutlineWindow(); + }); + + observer.observe(document.getElementById('vx-mindmap'), { + childList: true, + subtree: true, + characterData: true + }); + } + + /** + * 数据变更处理 + * 步骤: + * 1. 构建节点数据映射 + * 2. 更新大纲显示 + * + * @param {object} data - 新的数据 + */ + onDataChange(data) { + console.log('OutlineFeature: onDataChange called with data:', data); + this.buildNodeDataMap(data); + console.log('OutlineFeature: Built nodeDataMap with', this.nodeDataMap.size, 'entries'); + this.updateOutlineWindow(); + } + + /** + * 构建节点数据映射表 + * @param {object} data - 思维导图数据 + */ + buildNodeDataMap(data) { + this.nodeDataMap.clear(); + this.buildNodeDataMapRecursive(data, this.nodeDataMap); + } + + /** + * 递归构建节点数据映射表 + * @param {object} nodeData - 节点数据 + * @param {Map} map - 映射表 + */ + buildNodeDataMapRecursive(nodeData, map) { + if (!nodeData) return; + + map.set(nodeData.id, nodeData); + + if (nodeData.children) { + nodeData.children.forEach(child => { + this.buildNodeDataMapRecursive(child, map); + }); + } + } +} \ No newline at end of file diff --git a/src/data/extra/web/js/mind-elixir/MindElixir.js b/src/data/extra/web/js/mindmap/lib/mind-elixir/MindElixir.js similarity index 100% rename from src/data/extra/web/js/mind-elixir/MindElixir.js rename to src/data/extra/web/js/mindmap/lib/mind-elixir/MindElixir.js diff --git a/src/data/extra/web/js/mind-elixir/README.md b/src/data/extra/web/js/mindmap/lib/mind-elixir/README.md similarity index 100% rename from src/data/extra/web/js/mind-elixir/README.md rename to src/data/extra/web/js/mindmap/lib/mind-elixir/README.md diff --git a/src/data/extra/web/js/mindmap/mindmap-readme.md b/src/data/extra/web/js/mindmap/mindmap-readme.md new file mode 100644 index 0000000000..909c0cb5c8 --- /dev/null +++ b/src/data/extra/web/js/mindmap/mindmap-readme.md @@ -0,0 +1,130 @@ +# VNote 自定义思维导图(Mind Map)功能文档 + +本功能基于 `MindElixir.js` 实现了相关思维导图与 VNote 笔记的增强。 + +## 1. 工程架构 + +新的思维导图功能遵循清晰的模块化目录结构,以便于维护和扩展。所有相关文件都位于 `src/data/extra/web/js/mindmap/` 目录下。 + +``` +mindmap/ +├── core/ +│ └── mindmap-core.js # 核心逻辑,封装第三方库 +├── features/ +│ ├── link-handler/ +│ │ └── link-handler.js # 功能模块:链接增强 +│ └── outline/ +│ └── outline.js # 功能模块:大纲视图 +├── lib/ +│ └── mind-elixir/ +│ └── MindElixir.js # 第三方依赖库 +└── mindmap-readme.md # 本文档 +``` + +- **`lib/`**: 存放第三方依赖库,目前为 `MindElixir.js`。这使得主代码与外部库解耦。 +- **`core/`**: 存放核心封装和逻辑。`mindmap-core.js` 作为 `MindElixir.js` 的直接封装层,为上层应用提供统一、稳定的接口,并管理各个功能模块的生命周期。 +- **`features/`**: 存放所有可插拔的功能模块。每个子目录代表一个独立的功能(如链接处理、大纲视图) + +此外,在 `mindmapeditor.js` 的同级目录下,还有一个 `vxcore.js` 文件,它提供了与Qt后端通信的基础能力。 + +## 2. 架构设计与开发指南 + +为了实现高度的灵活性和可扩展性,我们采用了分层和面向对象的插件式架构。 + +### 2.1. 核心关系:`mindmapeditor.js` 与 `mindmap-core.js` + +两者的关系是 **组合(Composition)而非继承**,这是一种“has-a”关系,遵循了组合优于继承的设计原则。 + +- **`MindMapEditor` (`mindmapeditor.js`)**: + - **角色**: **集成与通信层 (The Integrator)**。 + - **职责**: + 1. **继承 `VXCore`**: 获取与Qt后端通信的基础能力。 + 2. **对接Qt**: 作为JavaScript世界与Qt世界的桥梁,处理来自 `vxAdapter` 对象的信号(如 `saveDataRequested`, `dataUpdated`)和调用其方法(如 `setSavedData`)。 + 3. **创建核心实例**: `MindMapEditor` 在其构造函数中创建 `MindMapCore` 的实例。它“拥有”一个 `MindMapCore`。 + 4. **注册功能模块**: 它决定加载哪些功能,并调用 `mindMapCore.registerFeature()` 方法将 `OutlineFeature` 和 `LinkHandlerFeature` 等模块“注入”到核心中。 + +- **`MindMapCore` (`mindmap-core.js`)**: + - **角色**: **封装与管理层 (The Engine)**。 + - **职责**: + 1. **封装 `MindElixir`**: 直接初始化和操作 `MindElixir.js` 实例。所有对思维导图的底层操作(如设置数据、获取数据、布局)都由它代理。这隐藏了第三方库的实现细节。 + 2. **管理功能模块**: 内部维护一个功能模块列表(`features` Map)。提供了 `registerFeature()`、`getFeature()` 等方法,并负责在适当的时机(如 `init`, `onDataChange`)调用每个模块的生命周期方法。 + 3. **事件中心**: 拥有自己的事件系统(`on`, `emit`),发布如 `ready`, `contentChanged`, `saveCompleted` 等关键事件,让上层和同级模块能响应核心状态的变化。 + +这种设计带来了几个好处: +- **解耦**: `MindMapEditor` 不关心用的是哪个思维导图库,它只与 `MindMapCore` 的稳定API交互。未来如果更换 `MindElixir.js`,只需重写 `MindMapCore`,而 `MindMapEditor` 和所有功能模块几乎不受影响。 +- **清晰职责**: `MindMapEditor` 负责“对外”(与Qt通信),`MindMapCore` 负责“对内”(管理思维导图和功能)。 +- **可扩展性**: 新功能可以作为独立的`Feature`类开发,然后在 `MindMapEditor` 中注册即可,无需修改核心代码。 + +### 2.2. 功能模块(Feature)的实现规范 + +所有功能模块(如 `LinkHandlerFeature`, `OutlineFeature`)都遵循一个统一的接口约定: + +- 是一个独立的 `class`。 +- **`setCore(core)`**: 一个方法,由 `MindMapCore` 在注册时调用,用于将核心实例注入到模块中,使模块能访问核心功能(如 `this.core.mindElixir`)。 +- **`init()`**: 初始化方法。在 `MindMapCore` 初始化完成后被调用,用于设置事件监听、创建UI元素等。 +- **`onDataChange(data)`**: 当思维导图加载新数据时被调用,用于同步模块状态。 + +### 2.3. 未来如何开发新功能 + +如果你想基于当前架构添加一个新的自定义功能(例如“节点计数器”),应遵循以下步骤: + +1. **创建功能文件**: 在 `features/` 目录下创建一个新的子目录,例如 `node-counter/`,并在其中创建 `node-counter.js` 文件。 +2. **实现功能类**: 在 `node-counter.js` 中,创建一个 `NodeCounterFeature` 类,并实现 `setCore`, `init` 等必要方法。 + ```javascript + class NodeCounterFeature { + setCore(core) { + this.core = core; + } + + init() { + // 创建一个显示计数的UI元素 + // ... + this.updateCount(); + } + + onDataChange(data) { + // 数据变化时更新计数 + this.updateCount(); + } + + updateCount() { + const nodeCount = this.core.mindElixir.getAllData().nodeData.children.length; + // 更新UI... + } + } + ``` +3. **注册新功能**: 在 `mindmapeditor.js` 的 `setupFeatures` 方法中,实例化并注册你的新功能。 + ```javascript + // in mindmapeditor.js + setupFeatures() { + // ... aiting other features + this.mindMapCore.registerFeature('nodeCounter', new NodeCounterFeature()); + } + ``` +4. **更新HTML模板**: 根据项目的设计模式 [[memory:4144812]],不要在 `mindmap-editor-template.html` 中硬编码JS路径。应在 `VNote` 的资源管理系统中注册新JS文件,使其在后端被自动注入。 + +## 3. 已实现功能介绍 + +### 3.1. 链接增强 (`LinkHandlerFeature`) + +此功能彻底重做了 `MindElixir` 的默认超链接行为,提供了更强大、更符合 `VNote` 使用场景的交互。 + +- **可视化标签**: 它会检测节点数据中的 `hyperLink` 字段,并自动在节点文本旁生成一个可视化的标签(如 `[md]`, `[pdf]`, `[http]`)。标签的样式会根据链接类型(文件扩展名)变化,一目了然。 +- **定向打开**: 这是此功能的核心。用户可以通过 **拖拽** 这个链接标签来决定在 `VNote` 的哪个区域打开链接: + - 向上拖拽: 在上方打开 + - 向下拖拽: 在下方打开 + - 向左拖拽: 在左侧打开 + - 向右拖拽或直接点击: 在右侧打开(默认) +- **动态更新**: 利用 `MutationObserver`,无论是添加新节点、编辑现有节点还是撤销/重做操作,链接标签都能被实时、正确地渲染,并保持布局不乱。 + +### 3.2. 大纲 (`OutlineFeature`) + +此功能为复杂的思维导图提供了一个悬浮的、可交互的大纲窗口,极大地提升了导航和概览效率。 + +- **悬浮窗口**: 大纲是一个独立、可拖拽、可调整大小的悬浮窗口。 +- **实时同步**: 大纲内容与思维导图实时双向同步。在思维导图中做的任何修改都会立刻反映到大纲树状图中。 +- **快速导航**: 在大纲窗口中点击任意节点,主思维导图视图会自动平移并将该节点居中高亮显示。 +- **搜索过滤**: 内置的搜索框可以快速过滤大纲,只显示匹配关键词的节点及其父节点,方便在大型脑图中快速定位信息。 +- **界面调整**: + - **折叠/展开**: 用户可以在大纲中自由折叠和展开节点,以关注不同层级的内容。 + - **自适应布局**: 当主窗口尺寸缩小时,大纲窗口会自动折叠并移动到角落,避免遮挡内容;当主窗口恢复尺寸时,大纲窗口也会自动展开并恢复到原来的位置和大小。 diff --git a/src/data/extra/web/js/mindmapeditor.js b/src/data/extra/web/js/mindmapeditor.js index 7031cf511a..ed14f69c12 100644 --- a/src/data/extra/web/js/mindmapeditor.js +++ b/src/data/extra/web/js/mindmapeditor.js @@ -1,33 +1,246 @@ -/* Main script file for MindMapEditor. */ +/** + * 思维导图编辑器主入口文件 + * 负责初始化和管理思维导图功能 + */ -new QWebChannel(qt.webChannelTransport, - function(p_channel) { +/** + * 思维导图编辑器主类 + * 负责与Qt后端对接和功能模块的管理 + * 继承自VXCore以获取基础功能 + */ +class MindMapEditor extends VXCore { + /** + * 构造函数 + * 步骤: + * 1. 调用父类构造函数 + * 2. 初始化MindMapCore实例 + */ + constructor() { + super(); + // MindMapCore实例 + this.mindMapCore = null; + // 初始化标志 + this.initialized = false; + } + + /** + * 初始化加载 + * 步骤: + * 1. 调用父类初始化 + * 2. 初始化MindMapCore + * 3. 设置事件监听 + */ + initOnLoad() { + console.log('MindMapEditor: initOnLoad called'); + + // 确保父类初始化完成 + super.initOnLoad(); + + // 创建MindMapCore实例 + console.log('MindMapEditor: Creating MindMapCore instance'); + this.mindMapCore = new MindMapCore(); + + // 设置功能模块 + console.log('MindMapEditor: Setting up features'); + this.setupFeatures(); + + // 设置事件监听 + console.log('MindMapEditor: Setting up event listeners'); + this.setupEventListeners(); + + // 初始化MindMapCore + console.log('MindMapEditor: Initializing MindMapCore'); + this.mindMapCore.init(); + + // 设置初始化标志 + this.initialized = true; + console.log('MindMapEditor: Initialization complete'); + } + + /** + * 设置功能模块 + * 步骤: + * 1. 注册大纲功能模块 + * 2. 注册链接处理模块 + */ + setupFeatures() { + console.log('MindMapEditor: setupFeatures called'); + // 注册功能模块 + this.mindMapCore.registerFeature('outline', new OutlineFeature()); + this.mindMapCore.registerFeature('linkHandler', new LinkHandlerFeature()); + console.log('MindMapEditor: Features registered:', this.mindMapCore.features.size); + } + + /** + * 生成数字ID + * @returns {number} 时间戳的数字形式 + */ + generateNumericId() { + return parseInt(Date.now().toString().slice(-8), 10); + } + + /** + * 设置事件监听 + */ + setupEventListeners() { + // 监听MindMapCore的ready事件 + this.mindMapCore.on('ready', () => { + if (window.vxAdapter) { + window.vxAdapter.setReady(true); + + // 监听保存请求 + if (typeof window.vxAdapter.saveDataRequested === 'function') { + window.vxAdapter.saveDataRequested.connect((id) => { + this.saveData(id); + }); + } + } + }); + + // 监听内容变更事件 + this.mindMapCore.on('contentChanged', () => { + if (window.vxAdapter?.notifyContentsChanged) { + window.vxAdapter.notifyContentsChanged(); + } + }); + + // 监听保存完成事件 + this.mindMapCore.on('saveCompleted', (result) => { + // 只有手动保存(ID>0)成功时才显示消息,或在任何保存失败时显示消息 + if (window.vxAdapter?.showMessage) { + if (result.success) { + if (typeof result.id === 'number' && result.id > 0) { + window.vxAdapter.showMessage('保存成功'); + } + } else { + window.vxAdapter.showMessage('保存失败: ' + (result.error || '未知错误')); + } + } + }); + } + + /** + * 设置思维导图数据 + * @param {object} data - 思维导图数据 + */ + setData(data) { + // console.log('MindMapEditor: setData called with data:', data); + if (this.mindMapCore) { + this.mindMapCore.setData(data); + } + } + + /** + * 保存思维导图数据 + * @param {number} id - 数据ID + */ + saveData(id) { + if (this.mindMapCore) { + this.mindMapCore.saveData(id); + } + } + + /** + * 获取功能模块 + * @param {string} name - 功能模块名称 + * @returns {object} 功能模块实例 + */ + getFeature(name) { + return this.mindMapCore ? this.mindMapCore.getFeature(name) : null; + } +} + +// 等待 DOM 加载完成后初始化 +document.addEventListener('DOMContentLoaded', () => { + // 确保所有依赖都已加载 + if (typeof VXCore === 'undefined') { + console.error('VXCore not loaded'); + return; + } + + if (typeof MindMapCore === 'undefined') { + console.error('MindMapCore not loaded'); + return; + } + + if (typeof OutlineFeature === 'undefined') { + console.error('OutlineFeature not loaded'); + return; + } + + if (typeof LinkHandlerFeature === 'undefined') { + console.error('LinkHandlerFeature not loaded'); + return; + } + + // 创建全局实例 + window.mindMapEditor = new MindMapEditor(); + + // 设置Qt后端对接 + new QWebChannel(qt.webChannelTransport, function(p_channel) { let adapter = p_channel.objects.vxAdapter; // Export the adapter globally. window.vxAdapter = adapter; // Connect signals from CPP side. adapter.saveDataRequested.connect(function(p_id) { - window.vxcore.saveData(p_id); + window.mindMapEditor.saveData(p_id); }); adapter.dataUpdated.connect(function(p_data) { - window.vxcore.setData(p_data); + window.mindMapEditor.setData(p_data); }); - adapter.findTextRequested.connect(function(p_texts, p_options, p_currentMatchLine) { - window.vxcore.findText(p_texts, p_options, p_currentMatchLine); - }); + // 添加URL点击处理函数到adapter对象 + adapter.handleUrlClick = function(url) { + console.log('MindMapEditor: handleUrlClick called with URL:', url); + try { + if (typeof adapter.urlClicked === 'function') { + console.log('MindMapEditor: Calling adapter.urlClicked'); + adapter.urlClicked(url); + } else { + console.error('MindMapEditor: adapter.urlClicked is not a function'); + console.log('MindMapEditor: Available adapter methods:', Object.getOwnPropertyNames(adapter)); + } + } catch (error) { + console.error('MindMapEditor: Error in handleUrlClick:', error); + } + }; + + // 添加带方向的URL点击处理函数 + adapter.handleUrlClickWithDirection = function(url, direction) { + console.log('MindMapEditor: handleUrlClickWithDirection called with URL:', url, 'Direction:', direction); + try { + if (typeof adapter.urlClickedWithDirection === 'function') { + console.log('MindMapEditor: Calling adapter.urlClickedWithDirection'); + adapter.urlClickedWithDirection(url, direction); + } else { + console.error('MindMapEditor: adapter.urlClickedWithDirection is not a function'); + } + } catch (error) { + console.error('MindMapEditor: Error in handleUrlClickWithDirection:', error); + } + }; - console.log('QWebChannel has been set up'); + console.log('MindMapEditor: QWebChannel has been set up successfully'); + console.log('MindMapEditor: Adapter methods available:', Object.getOwnPropertyNames(adapter)); - if (window.vxcore.initialized) { - window.vxAdapter.setReady(true); + // 检查window.load是否已经触发 + if (document.readyState === 'complete') { + console.log('MindMapEditor: Window already loaded, calling initOnLoad manually'); + window.mindMapEditor.initOnLoad(); + } else { + console.log('MindMapEditor: Window not yet loaded, VXCore will handle initOnLoad'); } }); +}); -window.vxcore.on('ready', function() { - if (window.vxAdapter) { - window.vxAdapter.setReady(true); +// 添加全局大纲窗口控制函数 +window.showOutline = function() { + if (window.mindMapEditor) { + const outlineFeature = window.mindMapEditor.getFeature('outline'); + if (outlineFeature) { + outlineFeature.showOutlineWindow(); + } } -}); +}; \ No newline at end of file diff --git a/src/data/extra/web/js/mindmapeditorcore.js b/src/data/extra/web/js/mindmapeditorcore.js deleted file mode 100644 index db008f2870..0000000000 --- a/src/data/extra/web/js/mindmapeditorcore.js +++ /dev/null @@ -1,39 +0,0 @@ -class MindMapEditorCore extends VXCore { - constructor() { - super(); - } - - initOnLoad() { - let options = { - el: '#vx-mindmap', - direction: MindElixir.SIDE, - allowUndo: true, - } - - this.mind = new MindElixir(options); - - this.mind.bus.addListener('operation', operation => { - if (operation === 'beginEdit') { - return; - } - window.vxAdapter.notifyContentsChanged(); - }); - } - - saveData(p_id) { - let data = this.mind.getAllDataString(); - window.vxAdapter.setSavedData(p_id, data); - } - - setData(p_data) { - if (p_data && p_data !== "") { - this.mind.init(JSON.parse(p_data)); - } else { - const data = MindElixir.new('New Topic') - this.mind.init(data) - } - this.emit('rendered'); - } -} - -window.vxcore = new MindMapEditorCore(); diff --git a/src/data/extra/web/js/vxcore.js b/src/data/extra/web/js/vxcore.js index 11c49e6eb6..c732b51d40 100644 --- a/src/data/extra/web/js/vxcore.js +++ b/src/data/extra/web/js/vxcore.js @@ -30,6 +30,11 @@ class VXCore extends EventEmitter { }); } + // Base implementation of initOnLoad - can be overridden by subclasses + initOnLoad() { + // Base class does nothing - subclasses should override this method + } + static detectOS() { let osName="Unknown OS"; if (navigator.appVersion.indexOf("Win")!=-1) { diff --git a/src/data/extra/web/mindmap-editor-template.html b/src/data/extra/web/mindmap-editor-template.html index 3e748f9441..bc93130d23 100644 --- a/src/data/extra/web/mindmap-editor-template.html +++ b/src/data/extra/web/mindmap-editor-template.html @@ -4,31 +4,97 @@ VNoteX MindMap Viewer + + + + + diff --git a/src/widgets/editors/mindmapeditoradapter.cpp b/src/widgets/editors/mindmapeditoradapter.cpp index f54b093020..551bdd1274 100644 --- a/src/widgets/editors/mindmapeditoradapter.cpp +++ b/src/widgets/editors/mindmapeditoradapter.cpp @@ -5,6 +5,7 @@ using namespace vnotex; MindMapEditorAdapter::MindMapEditorAdapter(QObject *p_parent) : WebViewAdapter(p_parent) { + qDebug() << "MindMapEditorAdapter: Constructor called"; } void MindMapEditorAdapter::setData(const QString &p_data) @@ -37,3 +38,27 @@ void MindMapEditorAdapter::notifyContentsChanged() { emit contentsChanged(); } + +void MindMapEditorAdapter::urlClicked(const QString &p_url) +{ + if (p_url.isEmpty()) { + qWarning() << "MindMapEditorAdapter::urlClicked: URL is empty"; + return; + } + + qDebug() << "MindMapEditorAdapter::urlClicked: Emitting urlClickRequested signal with URL:" << p_url; + + emit urlClickRequested(p_url); +} + +void MindMapEditorAdapter::urlClickedWithDirection(const QString &p_url, const QString &p_direction) +{ + if (p_url.isEmpty()) { + qWarning() << "MindMapEditorAdapter::urlClickedWithDirection: URL is empty"; + return; + } + + qDebug() << "MindMapEditorAdapter::urlClickedWithDirection: URL:" << p_url << "Direction:" << p_direction; + + emit urlClickWithDirectionRequested(p_url, p_direction); +} diff --git a/src/widgets/editors/mindmapeditoradapter.h b/src/widgets/editors/mindmapeditoradapter.h index 1a320d534a..4938ae2acd 100644 --- a/src/widgets/editors/mindmapeditoradapter.h +++ b/src/widgets/editors/mindmapeditoradapter.h @@ -29,6 +29,12 @@ namespace vnotex void notifyContentsChanged(); + // 处理来自JavaScript的URL点击事件 + void urlClicked(const QString &p_url); + + // 处理来自JavaScript的带方向的URL点击事件 + void urlClickedWithDirection(const QString &p_url, const QString &p_direction); + // Signals to be connected at web side. signals: void dataUpdated(const QString& p_data); @@ -38,6 +44,12 @@ namespace vnotex signals: void contentsChanged(); + // 发出URL点击信号,供其他组件处理 + void urlClickRequested(const QString &p_url); + + // 发出带方向的URL点击信号 + void urlClickWithDirectionRequested(const QString &p_url, const QString &p_direction); + private: }; } diff --git a/src/widgets/mindmapviewwindow.cpp b/src/widgets/mindmapviewwindow.cpp index 5e4d8175a0..e36d0272e4 100644 --- a/src/widgets/mindmapviewwindow.cpp +++ b/src/widgets/mindmapviewwindow.cpp @@ -2,6 +2,11 @@ #include #include +#include +#include +#include +#include +#include #include #include @@ -11,11 +16,13 @@ #include #include #include +#include #include "toolbarhelper.h" #include "findandreplacewidget.h" #include "editors/mindmapeditor.h" #include "editors/mindmapeditoradapter.h" +#include "viewarea.h" using namespace vnotex; @@ -46,10 +53,14 @@ void MindMapViewWindow::setupEditor() HtmlTemplateHelper::updateMindMapEditorTemplate(mindMapEditorConfig); auto adapter = new MindMapEditorAdapter(nullptr); + qDebug() << "MindMapViewWindow::setupEditor: Created adapter:" << adapter; + m_editor = new MindMapEditor(adapter, VNoteX::getInst().getThemeMgr().getBaseBackground(), 1.0, this); + qDebug() << "MindMapViewWindow::setupEditor: Created editor:" << m_editor; + connect(m_editor, &MindMapEditor::contentsChanged, this, [this]() { getBuffer()->setModified(m_editor->isModified()); @@ -58,6 +69,14 @@ void MindMapViewWindow::setupEditor() this->setBufferRevisionAfterInvalidation(p_revision); }); }); + + // 连接URL点击信号 + connect(adapter, &MindMapEditorAdapter::urlClickRequested, + this, &MindMapViewWindow::handleUrlClick); + + // 连接带方向的URL点击信号 + connect(adapter, &MindMapEditorAdapter::urlClickWithDirectionRequested, + this, &MindMapViewWindow::handleUrlClickWithDirection); } QString MindMapViewWindow::getLatestContent() const @@ -290,3 +309,268 @@ void MindMapViewWindow::showFindAndReplaceWidget() m_findAndReplace->setOptionsEnabled(FindOption::WholeWordOnly | FindOption::RegularExpression, false); } } + +// 思维导图 link 增强功能, 支持打开 url 里的内容, 支持多方向打开 +void MindMapViewWindow::handleUrlClick(const QString &p_url) +{ + if (p_url.isEmpty()) { + return; + } + + qDebug() << "MindMapViewWindow: Handling URL click:" << p_url; + + // 检查是否为本地文件路径 + QString filePath = p_url; + + // 如果是相对路径,尝试相对于当前文件解析 + if (QFileInfo(filePath).isRelative()) { + auto buffer = getBuffer(); + if (buffer) { + const QString basePath = QFileInfo(buffer->getContentPath()).absolutePath(); + filePath = QDir(basePath).absoluteFilePath(p_url); + } + } + + // 检查文件是否存在 + if (QFileInfo::exists(filePath)) { + // 获取当前脑图所在的ViewSplit + auto currentSplit = getViewSplit(); + if (!currentSplit) { + // 如果无法获取当前split,使用原来的逻辑 + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + qInfo() << "Requested to open file in new workspace (fallback):" << filePath; + return; + } + + // 查找ViewArea + ViewArea *viewArea = nullptr; + QWidget *parent = currentSplit->parentWidget(); + while (parent && !viewArea) { + viewArea = dynamic_cast(parent); + parent = parent->parentWidget(); + } + + if (!viewArea) { + qWarning() << "Could not find ViewArea, using fallback"; + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + return; + } + + // 查找是否已有合适的目标split(右边的split) + ViewSplit *targetSplit = nullptr; + const auto &allSplits = viewArea->getAllViewSplits(); + + // 尝试找到当前脑图右边的split + for (auto split : allSplits) { + if (split != currentSplit) { + // 简单策略:如果有其他split,就使用第一个找到的 + targetSplit = split; + break; + } + } + + if (targetSplit) { + // 如果找到了目标split,直接在其中打开文件 + qDebug() << "Found existing target split, opening file directly"; + + // 设置目标split为当前split,这样文件会在那里打开 + viewArea->setCurrentViewSplit(targetSplit, true); + + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + + qInfo() << "Opened file in existing target split:" << filePath; + } else { + // 如果没有目标split,创建一个新的空split(默认右边) + emit currentSplit->emptySplitRequested(currentSplit, Direction::Right); + + // 延迟打开文件 + QTimer::singleShot(50, this, [filePath]() { + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + qInfo() << "Opened file in newly created empty split:" << filePath; + }); + + qInfo() << "Created new empty split and scheduled file opening:" << filePath; + } + + } else if (p_url.startsWith("http://") || p_url.startsWith("https://")) { + // 处理HTTP/HTTPS链接,使用系统默认程序打开 + WidgetUtils::openUrlByDesktop(QUrl(p_url)); + qInfo() << "Opened URL with system default program:" << p_url; + } else { + // 文件不存在或URL格式不支持 + showMessage(tr("File does not exist or unsupported URL format: %1").arg(p_url)); + qWarning() << "File does not exist or unsupported URL:" << p_url; + } +} + +void MindMapViewWindow::handleUrlClickWithDirection(const QString &p_url, const QString &p_direction) +{ + if (p_url.isEmpty()) { + return; + } + + qDebug() << "MindMapViewWindow: Handling URL click with direction:" << p_url << "Direction:" << p_direction; + + // 将字符串方向转换为Direction枚举 + Direction direction = Direction::Right; // 默认右边 + if (p_direction == "Up") { + direction = Direction::Up; + } else if (p_direction == "Down") { + direction = Direction::Down; + } else if (p_direction == "Left") { + direction = Direction::Left; + } else if (p_direction == "Right") { + direction = Direction::Right; + } + + // 检查是否为本地文件路径 + QString filePath = p_url; + + // 如果是相对路径,尝试相对于当前文件解析 + if (QFileInfo(filePath).isRelative()) { + auto buffer = getBuffer(); + if (buffer) { + const QString basePath = QFileInfo(buffer->getContentPath()).absolutePath(); + filePath = QDir(basePath).absoluteFilePath(p_url); + } + } + + // 检查文件是否存在 + if (QFileInfo::exists(filePath)) { + // 获取当前脑图所在的ViewSplit + auto currentSplit = getViewSplit(); + if (!currentSplit) { + // 如果无法获取当前split,使用原来的逻辑 + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + qInfo() << "Requested to open file in new workspace (fallback):" << filePath; + return; + } + + // 查找ViewArea + ViewArea *viewArea = nullptr; + QWidget *parent = currentSplit->parentWidget(); + while (parent && !viewArea) { + viewArea = dynamic_cast(parent); + parent = parent->parentWidget(); + } + + if (!viewArea) { + qWarning() << "Could not find ViewArea, using fallback"; + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + return; + } + + // 清理无效的split引用 + cleanupInvalidSplits(viewArea); + + // 查找指定方向是否已有目标split + ViewSplit *targetSplit = m_directionSplits.value(p_direction, nullptr); + + // 验证target split是否仍然有效 + if (targetSplit) { + const auto &allSplits = viewArea->getAllViewSplits(); + if (!allSplits.contains(targetSplit)) { + // split已经被删除,清除映射 + m_directionSplits.remove(p_direction); + targetSplit = nullptr; + qDebug() << "Removed invalid split for direction:" << p_direction; + } + } + + if (targetSplit && targetSplit != currentSplit) { + // 如果找到了有效的目标split,直接在其中打开文件 + qDebug() << "Found existing target split for direction:" << p_direction; + + viewArea->setCurrentViewSplit(targetSplit, true); + + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + + qInfo() << "Opened file in existing target split with direction:" << p_direction << filePath; + } else { + // 如果没有目标split,根据指定方向创建新的空split + qDebug() << "Creating new empty split in direction:" << p_direction; + emit currentSplit->emptySplitRequested(currentSplit, direction); + + // 延迟打开文件,并记录新创建的split + QTimer::singleShot(100, this, [this, filePath, p_direction, viewArea]() { + // 查找新创建的split(应该是最新的) + const auto &allSplits = viewArea->getAllViewSplits(); + ViewSplit *newSplit = nullptr; + + for (auto split : allSplits) { + if (split != getViewSplit() && !m_directionSplits.values().contains(split)) { + newSplit = split; + break; + } + } + + if (newSplit) { + // 记录这个方向对应的split + m_directionSplits[p_direction] = newSplit; + qDebug() << "Recorded new split for direction:" << p_direction; + } + + auto paras = QSharedPointer::create(); + paras->m_alwaysNewWindow = true; + paras->m_focus = true; + emit VNoteX::getInst().openFileRequested(filePath, paras); + qInfo() << "Opened file in newly created empty split with direction:" << p_direction << filePath; + }); + + qInfo() << "Created new empty split in direction:" << p_direction << "and scheduled file opening:" << filePath; + } + + } else if (p_url.startsWith("http://") || p_url.startsWith("https://")) { + // 处理HTTP/HTTPS链接,使用系统默认程序打开 + WidgetUtils::openUrlByDesktop(QUrl(p_url)); + qInfo() << "Opened URL with system default program:" << p_url; + } else { + // 文件不存在或URL格式不支持 + showMessage(tr("File does not exist or unsupported URL format: %1").arg(p_url)); + qWarning() << "File does not exist or unsupported URL:" << p_url; + } +} + +void MindMapViewWindow::cleanupInvalidSplits(ViewArea *viewArea) +{ + if (!viewArea) { + return; + } + + const auto &validSplits = viewArea->getAllViewSplits(); + QStringList invalidDirections; + + // 检查每个记录的split是否仍然有效 + for (auto it = m_directionSplits.begin(); it != m_directionSplits.end(); ++it) { + if (!validSplits.contains(it.value())) { + invalidDirections.append(it.key()); + } + } + + // 移除无效的映射 + for (const QString &direction : invalidDirections) { + m_directionSplits.remove(direction); + qDebug() << "Cleaned up invalid split for direction:" << direction; + } +} diff --git a/src/widgets/mindmapviewwindow.h b/src/widgets/mindmapviewwindow.h index 74d4d50221..eea58dc68f 100644 --- a/src/widgets/mindmapviewwindow.h +++ b/src/widgets/mindmapviewwindow.h @@ -4,6 +4,7 @@ #include "viewwindow.h" #include +#include class QWebEngineView; @@ -11,6 +12,7 @@ namespace vnotex { class MindMapEditor; class MindMapEditorAdapter; + class ViewArea; class MindMapViewWindow : public ViewWindow { @@ -80,6 +82,12 @@ namespace vnotex void setupDebugViewer(); + void handleUrlClick(const QString &p_url); + + void handleUrlClickWithDirection(const QString &p_url, const QString &p_direction); + + void cleanupInvalidSplits(ViewArea *viewArea); + // Managed by QObject. MindMapEditor *m_editor = nullptr; @@ -87,6 +95,9 @@ namespace vnotex QWebEngineView *m_debugViewer = nullptr; int m_editorConfigRevision = 0; + + // 记录每个方向对应的目标split,用于智能方向打开 + QMap m_directionSplits; }; } diff --git a/src/widgets/viewarea.cpp b/src/widgets/viewarea.cpp index f3af96991f..50ef2f1806 100644 --- a/src/widgets/viewarea.cpp +++ b/src/widgets/viewarea.cpp @@ -255,6 +255,27 @@ ViewSplit *ViewArea::createViewSplit(QWidget *p_parent, ID p_viewSplitId) splitViewSplit(p_split, SplitType::Horizontal); emit windowsChanged(); }); + // 连接空split创建信号, 方便思维导图, 看板, 等其他前端与后端笔记联动 + connect(split, &ViewSplit::emptySplitRequested, + this, [this](ViewSplit *p_split, Direction p_direction) { + // 根据方向确定split类型 + SplitType splitType = (p_direction == Direction::Left || p_direction == Direction::Right) ? + SplitType::Vertical : SplitType::Horizontal; + // 创建空的split(p_cloneViewWindow = false) + auto newSplit = splitViewSplit(p_split, splitType, false); + + // 如果是左边或上边,需要调整split位置 + if (p_direction == Direction::Left || p_direction == Direction::Up) { + auto splitter = tryGetParentSplitter(newSplit); + if (splitter && splitter->indexOf(newSplit) == 1) { + splitter->insertWidget(0, newSplit); + } + } + + // 设置新split为当前split + setCurrentViewSplit(newSplit, true); + emit windowsChanged(); + }); connect(split, &ViewSplit::maximizeSplitRequested, this, &ViewArea::maximizeViewSplit); connect(split, &ViewSplit::distributeSplitsRequested, diff --git a/src/widgets/viewarea.h b/src/widgets/viewarea.h index 2982ee92c9..1dd5241898 100644 --- a/src/widgets/viewarea.h +++ b/src/widgets/viewarea.h @@ -78,6 +78,9 @@ namespace vnotex void setCurrentViewWindow(ID p_splitId, int p_windowIndex); + // 调整设置当前 ViewSplit 为 public 方法, 方便思维导图, 看板, 等其他前端与后端笔记联动 + void setCurrentViewSplit(ViewSplit *p_split, bool p_focus = true); + public slots: void openBuffer(Buffer *p_buffer, const QSharedPointer &p_paras); @@ -179,7 +182,6 @@ namespace vnotex void setCurrentViewWindow(ViewWindow *p_win); ViewSplit *getCurrentViewSplit() const; - void setCurrentViewSplit(ViewSplit *p_split, bool p_focus); QSharedPointer createWorkspace(); diff --git a/src/widgets/viewsplit.h b/src/widgets/viewsplit.h index 870c5d4c03..ba621f5c46 100644 --- a/src/widgets/viewsplit.h +++ b/src/widgets/viewsplit.h @@ -88,6 +88,9 @@ namespace vnotex void horizontalSplitRequested(ViewSplit *p_split); + // 创建空的 split 的信号, 方便思维导图, 看板, 等其他前端与后端笔记联动 + void emptySplitRequested(ViewSplit *p_split, Direction p_direction); + void maximizeSplitRequested(ViewSplit *p_split); void distributeSplitsRequested();