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();