Преглед изворни кода

enhanced-mindmap-outline-and-link (#2631)

* enhanced-mindmap-outline-and-link

* adj ann

* Delete dev.sh

* Update viewarea.h
chendapao пре 2 месеци
родитељ
комит
b8d3d26ea0

+ 3 - 0
.gitignore

@@ -14,4 +14,7 @@ aqtinstall.log
 tags
 CMakeLists.txt.user
 build
+build.*
+build*
 .DS_Store
+.vscode

+ 13 - 4
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"
                         ]
                     },
                     {

+ 7 - 5
src/data/extra/extra.qrc

@@ -82,6 +82,13 @@
         <file>web/js/mark.js/mark.min.js</file>
         <file>web/js/markjs.js</file>
 
+        <file>web/mindmap-editor-template.html</file>
+        <file>web/js/mindmap/lib/mind-elixir/MindElixir.js</file>
+        <file>web/js/mindmap/core/mindmap-core.js</file>
+        <file>web/js/mindmap/features/outline/outline.js</file>
+        <file>web/js/mindmap/features/link-handler/link-handler.js</file>
+        <file>web/js/mindmapeditor.js</file>
+
         <file>web/pdf.js/pdfviewer.js</file>
         <file>web/pdf.js/pdfviewer.css</file>
         <file>web/pdf.js/pdfviewercore.js</file>
@@ -327,11 +334,6 @@
         <file>web/pdf.js/web/cmaps/V.bcmap</file>
         <file>web/pdf.js/web/cmaps/WP-Symbol.bcmap</file>
 
-        <file>web/js/mind-elixir/MindElixir.js</file>
-        <file>web/mindmap-editor-template.html</file>
-        <file>web/js/mindmapeditorcore.js</file>
-        <file>web/js/mindmapeditor.js</file>
-
         <file>dicts/en_US.aff</file>
         <file>dicts/en_US.dic</file>
         <file>themes/native/text-editor.theme</file>

+ 479 - 0
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);
+    }
+} 

+ 1068 - 0
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);
+        }
+    }
+} 

+ 1002 - 0
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 = '<div style="color: #666; text-align: center; padding: 20px;">暂无数据</div>';
+            }
+        } catch (error) {
+            console.error('OutlineFeature: Error updating outline window:', error);
+            content.innerHTML = '<div style="color: #e74c3c; text-align: center; padding: 20px;">数据加载失败</div>';
+        }
+    }
+
+    // 检查节点是否匹配搜索条件
+    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, '<mark style="background: #ffff00; color: #000;">$1</mark>');
+            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);
+            });
+        }
+    }
+} 

+ 0 - 0
src/data/extra/web/js/mind-elixir/MindElixir.js → src/data/extra/web/js/mindmap/lib/mind-elixir/MindElixir.js


+ 0 - 0
src/data/extra/web/js/mind-elixir/README.md → src/data/extra/web/js/mindmap/lib/mind-elixir/README.md


+ 130 - 0
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`)
+
+此功能为复杂的思维导图提供了一个悬浮的、可交互的大纲窗口,极大地提升了导航和概览效率。
+
+-   **悬浮窗口**: 大纲是一个独立、可拖拽、可调整大小的悬浮窗口。
+-   **实时同步**: 大纲内容与思维导图实时双向同步。在思维导图中做的任何修改都会立刻反映到大纲树状图中。
+-   **快速导航**: 在大纲窗口中点击任意节点,主思维导图视图会自动平移并将该节点居中高亮显示。
+-   **搜索过滤**: 内置的搜索框可以快速过滤大纲,只显示匹配关键词的节点及其父节点,方便在大型脑图中快速定位信息。
+-   **界面调整**:
+    -   **折叠/展开**: 用户可以在大纲中自由折叠和展开节点,以关注不同层级的内容。
+    -   **自适应布局**: 当主窗口尺寸缩小时,大纲窗口会自动折叠并移动到角落,避免遮挡内容;当主窗口恢复尺寸时,大纲窗口也会自动展开并恢复到原来的位置和大小。

+ 228 - 15
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();
+        }
     }
-});
+}; 

+ 0 - 39
src/data/extra/web/js/mindmapeditorcore.js

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

+ 5 - 0
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) {

+ 79 - 13
src/data/extra/web/mindmap-editor-template.html

@@ -4,31 +4,97 @@
     <meta charset="utf-8">
     <title>VNoteX MindMap Viewer</title>
 
+    <!-- 全局样式占位符 -->
     <style type="text/css">
     /* VX_GLOBAL_STYLES_PLACEHOLDER */
-        #vx-mindmap {
-            height: 100vh;
-            width: 100%;
-        }
-
-        body {
-            margin: 0px !important;
-            padding: 0px !important;
-        }
-
-        span#fullscreen {
-            display: none;
-        }
+    html, body {
+        margin: 0;
+        padding: 0;
+        width: 100%;
+        height: 100%;
+        overflow: hidden;
+    }
+
+    #vx-mindmap {
+        width: 100%;
+        height: 100%;
+        position: relative;
+        overflow: hidden;
+        background-color: var(--vx-mindmap-background-color);
+    }
+
+    /* 主题变量 */
+    :root {
+        --vx-mindmap-primary-color: var(--vx-primary-color);
+        --vx-mindmap-box-color: var(--vx-background-color);
+        --vx-mindmap-line-color: var(--vx-border-color);
+        --vx-mindmap-root-color: var(--vx-text-color);
+        --vx-mindmap-root-background: var(--vx-primary-color);
+        --vx-mindmap-child-color: var(--vx-text-color);
+        --vx-mindmap-child-background: var(--vx-background-color);
+    }
+
+    /* 基础样式 */
+    .mind-elixir-node {
+        font-family: var(--vx-mindmap-font-family);
+        color: var(--vx-mindmap-text-color);
+        border: 1px solid var(--vx-mindmap-line-color);
+        border-radius: 4px;
+        padding: 6px 12px;
+        transition: all 0.2s ease;
+    }
+
+    .mind-elixir-node:hover {
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    }
+
+    .mind-elixir-node.selected {
+        border-color: var(--vx-mindmap-primary-color);
+        box-shadow: 0 0 0 2px var(--vx-mindmap-primary-color);
+    }
+
+    /* 连接线样式 */
+    .mind-elixir-line {
+        stroke: var(--vx-mindmap-line-color);
+        stroke-width: 1px;
+        transition: stroke 0.2s ease;
+    }
+
+    .mind-elixir-line:hover {
+        stroke: var(--vx-mindmap-primary-color);
+        stroke-width: 2px;
+    }
+
+    /* 根节点样式 */
+    .mind-elixir-root {
+        background-color: var(--vx-mindmap-root-background);
+        color: var(--vx-mindmap-root-color);
+        font-size: 16px;
+        font-weight: bold;
+        border: none;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    }
+
+    /* 子节点样式 */
+    .mind-elixir-child {
+        background-color: var(--vx-mindmap-child-background);
+        color: var(--vx-mindmap-child-color);
+        font-size: 14px;
+    }
     </style>
 
+    <!-- 主题样式占位符 -->
     <!-- VX_THEME_STYLES_PLACEHOLDER -->
 
+    <!-- 其他样式占位符 -->
     <!-- VX_STYLES_PLACEHOLDER -->
 
+    <!-- 全局配置 -->
     <script type="text/javascript">
         /* VX_GLOBAL_OPTIONS_PLACEHOLDER */
     </script>
 
+    <!-- 脚本占位符 -->
     <!-- VX_SCRIPTS_PLACEHOLDER -->
 </head>
 <body>

+ 25 - 0
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);
+}

+ 12 - 0
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:
     };
 }

+ 284 - 0
src/widgets/mindmapviewwindow.cpp

@@ -2,6 +2,11 @@
 
 #include <QToolBar>
 #include <QSplitter>
+#include <QFileInfo>
+#include <QDir>
+#include <QUrl>
+#include <QMetaObject>
+#include <QTimer>
 
 #include <core/vnotex.h>
 #include <core/thememgr.h>
@@ -11,11 +16,13 @@
 #include <core/mindmapeditorconfig.h>
 #include <utils/utils.h>
 #include <utils/pathutils.h>
+#include <utils/widgetutils.h>
 
 #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<FileOpenParameters>::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<ViewArea *>(parent);
+            parent = parent->parentWidget();
+        }
+
+        if (!viewArea) {
+            qWarning() << "Could not find ViewArea, using fallback";
+            auto paras = QSharedPointer<FileOpenParameters>::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<FileOpenParameters>::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<FileOpenParameters>::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<FileOpenParameters>::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<ViewArea *>(parent);
+            parent = parent->parentWidget();
+        }
+
+        if (!viewArea) {
+            qWarning() << "Could not find ViewArea, using fallback";
+            auto paras = QSharedPointer<FileOpenParameters>::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<FileOpenParameters>::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<FileOpenParameters>::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;
+    }
+}

+ 11 - 0
src/widgets/mindmapviewwindow.h

@@ -4,6 +4,7 @@
 #include "viewwindow.h"
 
 #include <QScopedPointer>
+#include <QMap>
 
 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<QString, ViewSplit*> m_directionSplits;
     };
 }
 

+ 21 - 0
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,

+ 3 - 1
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<FileOpenParameters> &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<ViewWorkspace> createWorkspace();
 

+ 3 - 0
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();