分享多选调整大纲标题(少侠优化版),鼠标中键展开折叠

多选调整大纲标题(js 代码)

原作者:
传说哥! 插入大纲 / 子标题, 批量修改标题层级 - 链滴

优化了下,用 ctrl 多选标题后

可以直接调整标题等级
加了个功能:取消大纲标题,变段落

// 插件名: 思源大纲体验优化(提取版-精简)
// 版本: v1.31.0 (去除“多选提升/降低(非含子)”“顶级标题从X开始”“插入大纲/子文档列表” + 轻度优化)
// 最近更新日期: 2025.10.20
// 作者: 少侠 (由Gemini提取整合并修改) — 后续精简 by ChatGPT
// 联系方式: 572378589

(() => {
    // ===================================================================
    //
    //              1. 全局配置中心 (G_CONFIG) - 用户配置区
    //
    // ===================================================================

    const G_CONFIG = {
        /**
         * 功能总开关
         */
        featureToggles: {
            // 大纲相关优化
            outlineEnhancer: true,      // 1. 大纲标题右键批量修改
            outlineSearch: true,        // 2. 大纲搜索框注入
            outlineAutoExpand: true,    // 3. 点击大纲自动展开
            outlineFoldSync: true,      // 4. 大纲与标题的折叠/打开状态同步

            // 依赖功能:批量折叠(大纲增强功能需要)
            batchFold: true,
        },

        /**
         * 功能调试日志开关
         */
        debugToggles: {
            mainEngine: true,
            outlineEnhancer: false,
            outlineSearch: false,
            outlineAutoExpand: false,
            outlineFoldSync: false,
            batchFold: false,
        },

        /**
         * CSS 选择器配置
         */
        CSS: {
            focusedTab: '.layout-tab-bar .item--focus',
            protyleWysiwyg: '.protyle-wysiwyg',
        },

        /**
         * API 路径配置(已移除不再使用的 insert/prepend/setAttrs/listDocsByPath)
         */
        API: {
            unfoldBlock: '/api/block/unfoldBlock',
            updateBlock: '/api/block/updateBlock',
            sql: '/api/query/sql',
            getDocOutline: '/api/outline/getDocOutline',
        },

        /**
         * 功能特定配置
         */
        FEATURES: {
            batchFold: {
                hideGutterAfterClick: true,
                foldDelay: 100,
            },
        },
    };

    // ===================================================================
    //
    //                     2. 全局工具函数 (通常无需修改)
    //
    // ===================================================================

    const Logger = {
        log(moduleId, ...args) {
            if (G_CONFIG.debugToggles[moduleId]) {
                console.log(`[大纲插件][${moduleId}]`, ...args);
            }
        },
        warn(moduleId, ...args) {
            if (G_CONFIG.debugToggles[moduleId]) {
                console.warn(`[大纲插件][${moduleId}]`, ...args);
            }
        },
        error(moduleId, ...args) {
            if (G_CONFIG.debugToggles[moduleId]) {
                console.error(`[大纲插件][${moduleId}]`, ...args);
            }
        }
    };

    // 保留空壳以便后续需要时快速启用
    function showToast(message, duration = 2000) { /* disabled as requested */ }

    function waitForElement(selector, timeout = 2000) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const check = () => {
                const element = document.querySelector(selector);
                if (element) {
                    resolve(element);
                } else if (Date.now() - startTime > timeout) {
                    reject(new Error(`Element "${selector}" not found.`));
                } else {
                    requestAnimationFrame(check);
                }
            };
            check();
        });
    }

    // ===================================================================
    //
    //                     3. 功能模块定义区
    //
    // ===================================================================

    const EventManager = {
        listeners: {
            keydown: [],
            keyup: [],
            blur: [],
            paste: [],
            selectionchange: [],
            mousedown: [],
            click: [],
            contextmenu: [],
            docSwitch: [], // 文档切换事件
            focusModeChange: [] // 聚焦模式切换事件
        },
        _context: null,
        _isInitialized: false,
        _currentDocId: null,
        _focusModeStates: new Map(), // 追踪每个文档的聚焦状态

        init() {
            if (this._isInitialized) return;
            this.log('mainEngine', 'EventManager 初始化...');

            // 1) 挂载 DOM 事件
            const domEventHandler = (eventType, e) => {
                this.updateContext(e);
                this.dispatchEvent(eventType, this._context);
            };

            document.addEventListener('keydown', (e) => domEventHandler('keydown', e), true);
            document.addEventListener('blur', (e) => domEventHandler('blur', e), true);
            document.addEventListener('paste', (e) => domEventHandler('paste', e), true);
            document.addEventListener('selectionchange', (e) => domEventHandler('selectionchange', e), true);
            document.addEventListener('mousedown', (e) => domEventHandler('mousedown', e), true);
            document.addEventListener('click', (e) => domEventHandler('click', e), true);
            document.addEventListener('contextmenu', (e) => domEventHandler('contextmenu', e), true);
            this.log('mainEngine', '已挂载所有DOM事件监听器。');

            // 2) 观察文档切换 & 聚焦变化
            this.observeDocSwitch();
            this.observeFocusMode();

            this._isInitialized = true;
        },

        subscribe(eventType, callback) {
            if (this.listeners[eventType]) {
                this.listeners[eventType].push(callback);
                this.log('mainEngine', `模块 ${callback.name || 'anonymous'} 订阅了 [${eventType}] 事件`);
            }
        },

        updateContext(event) {
            const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
            if (!focusedTab) {
                this._context = { event, target: event.target, focusedTab: null, activeEditor: null, selection: null };
                return;
            }
            const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
            const selection = window.getSelection();
            this._context = { event, target: event.target, focusedTab, activeEditor, selection };
        },

        dispatchEvent(eventType, context) {
            if (this.listeners[eventType]) {
                for (const listener of this.listeners[eventType]) {
                    try {
                        listener(context);
                    } catch (error) {
                        console.error(`[大纲插件] 在执行 ${eventType} 事件监听时出错:`, error);
                    }
                }
            }
        },

        observeDocSwitch() {
            this.log('mainEngine', '启动文档切换轮询检查...');
            setInterval(() => {
                const activeWnd = document.querySelector('.layout__wnd--active');
                let focusedTab = activeWnd ? activeWnd.querySelector('.layout-tab-bar .item--focus') : document.querySelector(G_CONFIG.CSS.focusedTab);

                if (focusedTab) {
                    const newDocId = focusedTab.dataset.id;
                    if (newDocId && newDocId !== this._currentDocId) {
                        this._currentDocId = newDocId;
                        this.log('mainEngine', `检测到文档切换, 新文档ID: ${newDocId}`);

                        const activeEditor = document.querySelector(`.protyle[data-id="${newDocId}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
                        if (activeEditor) {
                            const context = {
                                event: new Event('docSwitch'),
                                target: activeEditor,
                                focusedTab,
                                activeEditor,
                                selection: null
                            };
                            this.dispatchEvent('docSwitch', context);
                        }
                    }
                } else {
                    if (this._currentDocId !== null) {
                        this._currentDocId = null;
                        this.log('mainEngine', '检测到所有文档标签页已关闭。');
                        const context = {
                            event: new Event('docSwitch'),
                            target: null,
                            focusedTab: null,
                            activeEditor: null,
                            selection: null
                        };
                        this.dispatchEvent('docSwitch', context);
                    }
                }
            }, 500); // 每500ms检查一次
        },

        observeFocusMode() {
            const observer = new MutationObserver(() => {
                const activeWnd = document.querySelector('.layout__wnd--active');
                let focusedTab = activeWnd ? activeWnd.querySelector('.layout-tab-bar .item--focus') : document.querySelector(G_CONFIG.CSS.focusedTab);

                if (!focusedTab) return;

                const docId = focusedTab.dataset.id;
                const activeProtyle = document.querySelector(`.protyle[data-id="${docId}"]`);
                if (!activeProtyle) return;

                const exitFocusBtn = activeProtyle.querySelector('.protyle-breadcrumb__icon[data-type="exit-focus"]');
                const isInFocus = exitFocusBtn && !exitFocusBtn.classList.contains('fn__none');

                let currentFocusState = null;
                if (isInFocus) {
                    const activeBreadcrumbItem = activeProtyle.querySelector('.protyle-breadcrumb__item--active');
                    currentFocusState = activeBreadcrumbItem ? activeBreadcrumbItem.dataset.nodeId : true;
                }

                const oldState = this._focusModeStates.get(docId);

                if (currentFocusState !== oldState) {
                    this._focusModeStates.set(docId, currentFocusState);
                    this.log('mainEngine', `检测到聚焦模式/路径变化, 文档ID: ${docId}, 状态: ${currentFocusState}`);

                    const context = {
                        event: new Event('focusModeChange'),
                        target: activeProtyle,
                        focusedTab,
                        activeEditor: activeProtyle.querySelector(G_CONFIG.CSS.protyleWysiwyg),
                        selection: null,
                        inFocus: isInFocus
                    };
                    this.dispatchEvent('focusModeChange', context);
                }
            });

            observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
            this.log('mainEngine', '聚焦模式监听器已设置。');
        },
        log(...args) { Logger.log(...args); },
        error(...args) { Logger.error(...args); }
    };

    const BatchFoldModule = {
        id: 'batchFold',
        name: '批量折叠展开',
        get enabled() { return G_CONFIG.featureToggles[this.id]; },
        log(...args) { Logger.log(this.id, ...args); },
        error(...args) { Logger.error(this.id, ...args); },

        init() {
            this.log('模块已启动,订阅 mousedown 事件。');
            EventManager.subscribe('mousedown', this.handleMouseDown.bind(this));
        },

        handleMouseDown(context) {
            const { event } = context;
            if (!event.altKey || event.button !== 0) return;

            const headingArrow = event.target.closest('.protyle-gutters:has(button[data-type="NodeHeading"]) button[data-type="fold"]');
            if (headingArrow) {
                this.handleHeadingFold(event, headingArrow);
                return;
            }

            const blockArrow = event.target.closest('.protyle-gutters:has(button[data-type="NodeListItem"]) button[data-type="fold"]');
            if (blockArrow) {
                this.handleBlockFold(event, blockArrow);
                return;
            }
        },

        handleHeadingFold(event, arrow) {
            const headingButton = arrow.parentElement.querySelector('button[data-type="NodeHeading"]');
            if (!headingButton) return;

            const headingId = headingButton.dataset?.nodeId;
            const heading = document.querySelector(`div[data-node-id="${headingId}"]`);
            if (!heading) return;

            const headingType = heading.dataset?.subtype;
            const isFolded = !!heading.getAttribute('fold');
            const isSelected = heading.classList.contains('protyle-wysiwyg--select');
            const selectedSelector = isSelected ? '.protyle-wysiwyg--select' : '';
            const protyle = this.getProtyleByMouseAt(event);

            if (!protyle) return;

            if (this.isCtrlKey(event) && event.altKey && !event.shiftKey) {
                this.log('执行全局标题折叠/展开操作');
                const allHeadings = protyle.querySelectorAll(`.protyle-wysiwyg [data-type="NodeHeading"]${selectedSelector}`);
                this.foldHeadings(allHeadings, isFolded);
                this.hideGutterAfterClick(headingButton, arrow);
            } else if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
                this.log('执行同级标题折叠/展开操作');
                const sameLevelHeadings = protyle.querySelectorAll(`.protyle-wysiwyg [data-type="NodeHeading"][data-subtype="${headingType}"]${selectedSelector}`);
                this.foldHeadings(sameLevelHeadings, isFolded);
                this.hideGutterAfterClick(headingButton, arrow);
            }
        },

        async foldHeadings(headings, isFolded) {
            const targetFoldState = !isFolded;
            this.log(`开始${targetFoldState ? '折叠' : '展开'} ${headings.length} 个标题`);

            for (const heading of headings) {
                const headingId = heading.dataset?.nodeId;
                if (!headingId) continue;

                const isCurrentlyFolded = !!heading.getAttribute('fold');
                if (isCurrentlyFolded === targetFoldState) continue;

                try {
                    await this.foldBlock(headingId, targetFoldState);
                    await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
                } catch (error) {
                    this.error(`${targetFoldState ? '折叠' : '展开'}标题失败:`, headingId, error);
                }
            }
            this.log(`${targetFoldState ? '折叠' : '展开'}标题操作完成`);
        },

        handleBlockFold(event, arrow) {
            const blockButton = arrow.parentElement.querySelector('button[data-type="NodeListItem"]');
            if (!blockButton) return;

            const blockId = blockButton.dataset?.nodeId;
            if (!blockId) return;

            const block = document.querySelector(`div[data-node-id="${blockId}"]`);
            if (!block) return;

            if (!this.hasFoldableChildren(block)) {
                this.log('块没有可折叠的子内容,跳过操作');
                return;
            }

            const isFolded = !!block.getAttribute('fold');
            const isSelected = block.classList.contains('protyle-wysiwyg--select');
            const selectedSelector = isSelected ? '.protyle-wysiwyg--select' : '';
            const protyle = this.getProtyleByMouseAt(event);
            if (!protyle) return;

            if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
                this.log('执行同级块折叠/展开操作');
                if (isFolded) {
                    this.expandBlockContent(block);
                } else {
                    this.foldAllSiblingBlocks(block, protyle, selectedSelector);
                }
                this.hideGutterAfterClick(blockButton, arrow);
            }
        },

        async foldAllSiblingBlocks(currentBlock, protyle, selectedSelector) {
            const blockType = currentBlock.dataset.type;
            const parentList = currentBlock.parentElement;

            if (!parentList || parentList.dataset.type !== 'NodeList') return;

            this.log('执行同级块折叠/展开操作(仅限当前列表)');
            const allListItems = Array.from(parentList.children).filter(child =>
                child.dataset.type === blockType &&
                (!selectedSelector || child.matches(selectedSelector))
            );

            const foldableSiblings = allListItems.filter(sibling => this.hasFoldableChildren(sibling));
            this.log(`开始折叠 ${foldableSiblings.length} 个有子内容的同级块`);

            for (const sibling of foldableSiblings) {
                const siblingId = sibling.dataset?.nodeId;
                if (!siblingId) continue;
                if (!!sibling.getAttribute('fold')) continue;

                try {
                    await this.foldBlock(siblingId, true);
                    await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
                } catch (error) {
                    this.error('折叠同级块失败:', siblingId, error);
                }
            }
        },

        async expandBlockContent(block) {
            const blockId = block.dataset?.nodeId;
            if (!blockId) return;

            try {
                await this.foldBlock(blockId, false);
                await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
            } catch (error) {
                this.error('展开当前块失败:', blockId, error);
                return;
            }

            const foldedChildren = this.collectFoldedChildren(block);
            for (const childId of foldedChildren) {
                try {
                    await this.foldBlock(childId, false);
                    await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
                } catch (error) {
                    this.error('展开子块失败:', childId, error);
                }
            }
        },

        collectFoldedChildren(block) {
            const foldedIds = [];
            const collectFolded = (element) => {
                for (const child of element.children) {
                    if (child.dataset?.nodeId) {
                        if (!!child.getAttribute('fold')) {
                            foldedIds.push(child.dataset.nodeId);
                        }
                        collectFolded(child);
                    }
                }
            };
            collectFolded(block);
            return foldedIds;
        },

        async foldBlock(id, isFold = true) {
            const url = `/api/block/${isFold ? 'foldBlock' : 'unfoldBlock'}`;
            try {
                const response = await fetch(url, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ id })
                });
                const result = await response.json();
                if (!result || result.code !== 0) {
                    throw new Error(result?.msg || 'API调用失败');
                }
            } catch (error) {
                this.error(`${isFold ? '折叠' : '展开'}块失败:`, id, error);
                throw error;
            }
        },

        hideGutterAfterClick(button, arrow) {
            if (G_CONFIG.FEATURES.batchFold.hideGutterAfterClick) {
                button.style.display = 'none';
                arrow.style.display = 'none';
            }
        },

        getProtyleByMouseAt(event) {
            const element = document.elementFromPoint(event.clientX, event.clientY);
            return element ? element.closest('.protyle') : null;
        },

        isCtrlKey(event) {
            return navigator.platform.indexOf("Mac") > -1 ? event.metaKey : event.ctrlKey;
        },

        hasFoldableChildren(block) {
            return !!block.querySelector('[data-type="NodeList"]');
        }
    };

    const OutlineEnhancerModule = {
        id: 'outlineEnhancer',
        name: '大纲增强',
        get enabled() { return G_CONFIG.featureToggles[this.id]; },
        log(...args) { Logger.log(this.id, ...args); },
        error(...args) { Logger.error(this.id, ...args); },

        selectedNodes: new Set(),
        contextMenu: null,

        init() {
            this.log('模块已启动');
            document.addEventListener('click', this.handleClick.bind(this), true);
            document.addEventListener('contextmenu', this.handleContextMenu.bind(this), true);

            const styleId = 'outline-enhancer-style';
            if (!document.getElementById(styleId)) {
                const style = document.createElement('style');
                style.id = styleId;
                style.textContent = `.sy__outline .b3-list-item.outline-item--selected { background-color: var(--b3-theme-primary-light) !important; }`;
                document.head.appendChild(style);
            }
            this.setupOutlineObserver();
        },

        setupOutlineObserver() {
            this.reapplyTimeout = null;
            const debouncedReapply = () => {
                if (this.reapplyTimeout) clearTimeout(this.reapplyTimeout);
                this.reapplyTimeout = setTimeout(() => this.reapplySelectionStyles(), 50);
            };
            const observer = new MutationObserver(debouncedReapply);
            observer.observe(document.body, { childList: true, subtree: true });
        },

        reapplySelectionStyles() {
            if (this.selectedNodes.size === 0) {
                document.querySelectorAll('.sy__outline .b3-list-item.outline-item--selected').forEach(item => {
                    item.classList.remove('outline-item--selected');
                });
                return;
            }
            document.querySelectorAll('.sy__outline .b3-list-item').forEach(item => {
                const nodeId = item.dataset.nodeId;
                if (nodeId && this.selectedNodes.has(nodeId)) {
                    item.classList.add('outline-item--selected');
                } else {
                    item.classList.remove('outline-item--selected');
                }
            });
        },

        handleClick(e) {
            if (e.target.closest('#outline-enhancer-menu')) return;
            if (this.contextMenu) this.removeContextMenu();

            const listItem = e.target.closest('.sy__outline .b3-list-item');
            if (!listItem) {
                if (this.selectedNodes.size > 0) this.clearSelection();
                return;
            }
            const nodeId = listItem.dataset.nodeId;
            if (!nodeId) return;

            // 支持 Ctrl/Cmd 多选:用于批量改 H1–H6 或批量转段落
            if (e.ctrlKey || e.metaKey) {
                e.preventDefault();
                if (this.selectedNodes.has(nodeId)) {
                    this.selectedNodes.delete(nodeId);
                    listItem.classList.remove('outline-item--selected');
                } else {
                    this.selectedNodes.add(nodeId);
                    listItem.classList.add('outline-item--selected');
                }
            } else {
                if (!this.selectedNodes.has(nodeId) || this.selectedNodes.size > 1) {
                    this.clearSelection();
                    this.selectedNodes.add(nodeId);
                    listItem.classList.add('outline-item--selected');
                }
            }
        },

        clearSelection() {
            document.querySelectorAll('.sy__outline .b3-list-item.outline-item--selected').forEach(item => {
                item.classList.remove('outline-item--selected');
            });
            this.selectedNodes.clear();
        },

        handleContextMenu(e) {
            const listItem = e.target.closest('.sy__outline .b3-list-item');
            if (!listItem) return;

            e.preventDefault();
            const nodeId = listItem.dataset.nodeId;
            if (!nodeId) return;

            if (!this.selectedNodes.has(nodeId)) {
                this.clearSelection();
                this.selectedNodes.add(nodeId);
                listItem.classList.add('outline-item--selected');
            }

            if (this.selectedNodes.size < 1) return;
            this.createContextMenu(e.clientX, e.clientY);
        },

        createContextMenu(x, y) {
            this.removeContextMenu();
            this.contextMenu = document.createElement('div');
            const menu = this.contextMenu;
            menu.id = 'outline-enhancer-menu';
            menu.className = 'b3-menu';
            menu.style.cssText = `position: fixed; top: ${y}px; left: ${x}px; z-index: 500;`;
            menu.innerHTML = `
                <div class="b3-menu__items">
                    <div style="display: flex; padding: 2px 6px;">
                        <button class="b3-menu__item" data-level="h1" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH1"></use></svg></button>
                        <button class="b3-menu__item" data-level="h2" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH2"></use></svg></button>
                        <button class="b3-menu__item" data-level="h3" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH3"></use></svg></button>
                    </div>
                    <div style="display: flex; padding: 2px 6px;">
                        <button class="b3-menu__item" data-level="h4" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH4"></use></svg></button>
                        <button class="b3-menu__item" data-level="h5" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH5"></use></svg></button>
                        <button class="b3-menu__item" data-level="h6" style="margin: 0 1px; padding: 2px 5px; justify-content: center;"><svg class="b3-menu__icon"><use xlink:href="#iconH6"></use></svg></button>
                    </div>
                    <div class="b3-menu__separator"></div>
                    <button class="b3-menu__item" data-action="un-title"><svg class="b3-menu__icon"><use xlink:href="#iconParagraph"></use></svg><span class="b3-menu__label">取消标题变为段落</span></button>
                    <div class="b3-menu__separator"></div>
                    <button class="b3-menu__item" data-action="collapse-next"><svg class="b3-menu__icon"><use xlink:href="#iconContract"></use></svg><span class="b3-menu__label">折叠所有子标题</span></button>
                    <div class="b3-menu__separator"></div>
                    <button class="b3-menu__item" data-action="promote-with-children"><svg class="b3-menu__icon"><use xlink:href="#iconUp"></use></svg><span class="b3-menu__label">提升标题一级(含子标题)</span></button>
                    <button class="b3-menu__item" data-action="demote-with-children"><svg class="b3-menu__icon"><use xlink:href="#iconDown"></use></svg><span class="b3-menu__label">降低标题一级(含子标题)</span></button>
                </div>
            `;
            document.body.appendChild(menu);

            // 统一监听
            menu.addEventListener('click', (e) => {
                const button = e.target.closest('button.b3-menu__item');
                if (!button) return;

                // 1) 直接设定 H1~H6
                if (button.dataset.level) {
                    this.bulkUpdateHeadingLevel(button.dataset.level);
                    this.removeContextMenu();
                    return;
                }

                // 2) 其它动作
                const action = button.dataset.action;
                switch (action) {
                    case 'promote-with-children':
                        this.promoteOrDemoteHeadingsWithChildren('promote');
                        this.removeContextMenu();
                        break;
                    case 'demote-with-children':
                        this.promoteOrDemoteHeadingsWithChildren('demote');
                        this.removeContextMenu();
                        break;
                    case 'collapse-next':
                        this.collapseNextLevelHeadings();
                        this.removeContextMenu();
                        break;
                    case 'un-title':
                        this.convertToParagraphs();
                        this.removeContextMenu();
                        break;
                }
            });

            // 点击外部关闭
            this._boundRemoveContextMenu = this.removeContextMenu.bind(this);
            setTimeout(() => document.addEventListener('click', this._boundRemoveContextMenu, { once: true }), 0);
        },

        removeContextMenu() {
            if (this.contextMenu) {
                this.contextMenu.remove();
                this.contextMenu = null;
            }
            if (this._boundRemoveContextMenu) document.removeEventListener('click', this._boundRemoveContextMenu);
        },

        async getBlocks(ids) {
            if (ids.length === 0) return [];
            const stmt = `SELECT * FROM blocks WHERE id IN (${ids.map(id => `'${id}'`).join(',')})`;
            const response = await fetch(G_CONFIG.API.sql, { method: 'POST', body: JSON.stringify({ stmt }) });
            const resData = await response.json();
            return resData.data || [];
        },

        async updateBlock(id, newSubtype, content) {
            const htmlData = `<div data-subtype="${newSubtype}" data-node-id="${id}" data-type="NodeHeading" class="${newSubtype}"><div contenteditable="true" spellcheck="false">${content}</div><div class="protyle-attr" contenteditable="false"></div></div>`;
            const payload = { id, dataType: 'dom', data: htmlData };
            try {
                const response = await fetch(G_CONFIG.API.updateBlock, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });
                const resData = await response.json();
                if (resData.code !== 0) this.error(`更新块 ${id} 失败:`, resData.msg);
            } catch (err) {
                this.error(`更新块 ${id} 时发生网络错误:`, err);
            }
        },

        _extractHeadingContent(block) {
            return block.markdown ? block.markdown.replace(/^#+\s*/, '').split('\n')[0] || '' : '';
        },

        async bulkUpdateHeadingLevel(newLevel) {
            const nodeIds = Array.from(this.selectedNodes);
            if (nodeIds.length === 0) return;
            try {
                const blocks = await this.getBlocks(nodeIds);
                if (!blocks || blocks.length === 0) return;
                for (const block of blocks) {
                    const content = this._extractHeadingContent(block);
                    await this.updateBlock(block.id, newLevel, content);
                }
            } catch (error) {
                this.error('批量修改过程中发生错误:', error);
            } finally {
                this.clearSelection();
            }
        },

        async updateBlockToParagraph(id, content) {
            const htmlData = `<div data-node-id="${id}" data-type="NodeParagraph" class="p"><div contenteditable="true" spellcheck="false">${content}</div><div class="protyle-attr" contenteditable="false"></div></div>`;
            const payload = { id, dataType: 'dom', data: htmlData };
            try {
                const response = await fetch(G_CONFIG.API.updateBlock, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });
                const resData = await response.json();
                if (resData.code !== 0) {
                    this.error(`将块 ${id} 转换为段落失败:`, resData.msg);
                }
            } catch (err) {
                this.error(`将块 ${id} 转换为段落时发生网络错误:`, err);
            }
        },

        async convertToParagraphs() {
            const nodeIds = Array.from(this.selectedNodes);
            if (nodeIds.length === 0) return;

            try {
                const blocks = await this.getBlocks(nodeIds);
                if (!blocks || blocks.length === 0) return;

                for (const block of blocks) {
                    if (block.type === 'h') {
                        const content = this._extractHeadingContent(block);
                        await this.updateBlockToParagraph(block.id, content);
                    }
                }
            } catch (error) {
                this.error('取消标题过程中发生错误:', error);
            } finally {
                this.clearSelection();
            }
        },

        async getAllChildrenIds(nodeIds) {
            if (nodeIds.length === 0) return [];
            const blocks = await this.getBlocks(nodeIds.slice(0, 1));
            if (!blocks || blocks.length === 0) return [];
            const docId = blocks[0].root_id;
            if (!docId) return [];
            const response = await fetch(G_CONFIG.API.getDocOutline, { method: 'POST', body: JSON.stringify({ id: docId }) });
            const resData = await response.json();
            if (resData.code !== 0 || !resData.data || resData.data.length === 0) return [];

            const childrenIds = [];
            const findAllChildren = (nodes) => {
                for (const node of nodes) {
                    const children = (node.blocks && node.blocks.length > 0) ? node.blocks : node.children;
                    if (nodeIds.includes(node.id)) {
                        if (children && children.length > 0) {
                            const collectAllDescendants = (childNodes) => {
                                for (const child of childNodes) {
                                    childrenIds.push(child.id);
                                    const grandChildren = (child.blocks && child.blocks.length > 0) ? child.blocks : child.children;
                                    if (grandChildren && grandChildren.length > 0) collectAllDescendants(grandChildren);
                                }
                            };
                            collectAllDescendants(children);
                        }
                    } else if (children && children.length > 0) {
                        findAllChildren(children);
                    }
                }
            };
            findAllChildren(resData.data);
            return childrenIds;
        },

        async promoteOrDemoteHeadingsWithChildren(direction) {
            const nodeIds = Array.from(this.selectedNodes);
            if (nodeIds.length === 0) return;
            const childrenIds = await this.getAllChildrenIds(nodeIds);
            const totalIds = [...nodeIds, ...childrenIds];
            try {
                const blocks = await this.getBlocks(totalIds);
                if (!blocks || blocks.length === 0) return;
                for (const block of blocks) {
                    const currentLevel = parseInt(block.subtype.substring(1));
                    let newLevelNum = direction === 'promote' ? Math.max(1, currentLevel - 1) : Math.min(6, currentLevel + 1);
                    const newSubtype = `h${newLevelNum}`;
                    const content = this._extractHeadingContent(block);
                    await this.updateBlock(block.id, newSubtype, content);
                }
            } catch (error) {
                this.error('升级/降级过程(含子标题)中发生错误:', error);
            } finally {
                this.clearSelection();
            }
        },

        async collapseNextLevelHeadings() {
            if (this.selectedNodes.size === 0) return;
            const selectedNodeIds = Array.from(this.selectedNodes);
            const blocks = await this.getBlocks(selectedNodeIds.slice(0, 1));
            if (!blocks || blocks.length === 0) return;
            const docId = blocks[0].root_id;
            if (!docId) return;
            const response = await fetch(G_CONFIG.API.getDocOutline, { method: 'POST', body: JSON.stringify({ id: docId }) });
            const resData = await response.json();
            if (resData.code !== 0 || !resData.data || resData.data.length === 0) return;
            const nodeIdsToCollapse = new Set();
            const findAndAddChildren = (nodes) => {
                for (const node of nodes) {
                    const children = (node.blocks && node.blocks.length > 0) ? node.blocks : node.children;
                    if (selectedNodeIds.includes(node.id)) {
                        if (children && children.length > 0) children.forEach(child => nodeIdsToCollapse.add(child.id));
                    } else if (children && children.length > 0) {
                        findAndAddChildren(children);
                    }
                }
            };
            findAndAddChildren(resData.data);
            if (nodeIdsToCollapse.size > 0) {
                for (const nodeId of nodeIdsToCollapse) {
                    await BatchFoldModule.foldBlock(nodeId, true);
                    await new Promise(resolve => setTimeout(resolve, 50));
                }
            }
        },
    };

    const OutlineSearchModule = {
        id: 'outlineSearch',
        name: '大纲搜索注入',
        get enabled() { return G_CONFIG.featureToggles[this.id]; },
        log(...args) { Logger.log(this.id, ...args); },
        error(...args) { Logger.error(this.id, ...args); },
        _docSwitchTimer: null,

        init() {
            if (this.isMobile()) {
                this.log('移动端,跳过初始化。');
                return;
            }
            this.log('模块已启动,订阅文档切换事件以管理搜索框。');
            EventManager.subscribe('docSwitch', this.handleDocSwitch.bind(this));

            setTimeout(() => {
                const outlineElement = document.querySelector('.sy__outline:not(.fn__none)');
                if (outlineElement && !document.getElementById('plugin-outline-filter-container')) {
                    this.log('初始加载检测到大纲,注入搜索框。');
                    this.addFilter_outline(outlineElement);
                }
            }, 1000);
        },

        handleDocSwitch(context) {
            clearTimeout(this._docSwitchTimer);
            this._docSwitchTimer = setTimeout(() => {
                if (!context || !context.focusedTab) {
                    this.removeSearchBox();
                    return;
                }
                const outlineElement = document.querySelector('.sy__outline:not(.fn__none)');
                if (outlineElement) {
                    this.addFilter_outline(outlineElement);
                } else {
                    this.removeSearchBox();
                }
            }, 250);
        },

        removeSearchBox() {
            const existingBox = document.getElementById('plugin-outline-filter-container');
            if (existingBox) existingBox.remove();
        },

        isMobile() {
            return !!document.getElementById("sidebar");
        },

        addFilter_outline(outlineElement) {
            this.removeSearchBox();
            const container = document.createElement('div');
            container.id = 'plugin-outline-filter-container';
            container.style.cssText = 'position: relative; padding: 4px 8px;';

            const input = document.createElement('input');
            input.type = 'text';
            input.placeholder = '点我搜索';
            input.className = 'b3-text-field fn__block';
            input.style.cssText = `
                width: 100%; border: 1px solid #ccc; border-radius: 6px;
                padding: 4px 8px 4px 32px; font-size: 14px; outline: none; background-color: #f0f0f0;
            `;
            input.addEventListener('focus', () => { input.style.backgroundColor = '#e0e0e0'; });
            input.addEventListener('blur', () => { input.style.backgroundColor = '#f0f0f0'; });

            const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
            svgIcon.setAttribute('viewBox', '0 0 24 24');
            svgIcon.style.cssText = `
                position: absolute; left: 16px; top: 50%; transform: translateY(-50%);
                width: 16px; height: 16px; color: #aaa; pointer-events: none;
                fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
            `;
            svgIcon.innerHTML = `<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>`;

            container.appendChild(svgIcon);
            container.appendChild(input);
            container.addEventListener('click', (e) => e.stopPropagation());
            container.addEventListener('mousedown', (e) => e.stopPropagation());

            const targetElement = outlineElement.querySelector('.sy__outline .fn__flex-1');
            if (targetElement) {
                targetElement.insertBefore(container, targetElement.firstChild);
            } else {
                outlineElement.insertAdjacentElement('beforebegin', container);
            }

            const resetDisplay = () => {
                const spans = document.querySelectorAll('.layout-tab-container .sy__outline li span.b3-list-item__text.ariaLabel');
                spans.forEach(span => { span.parentElement.style.display = ''; });
            };

            input.addEventListener('input', () => {
                const filterText = input.value.toLowerCase();
                const spans = document.querySelectorAll('.layout-tab-container .sy__outline li span.b3-list-item__text.ariaLabel');
                if (!filterText) {
                    resetDisplay();
                } else {
                    spans.forEach(span => {
                        const listItem = span.parentElement;
                        const text = span.textContent.toLowerCase();
                        listItem.style.display = text.includes(filterText) ? '' : 'none';
                    });
                }
            });
        },
    };

    const OutlineAutoExpandModule = {
        id: 'outlineAutoExpand',
        name: '点击大纲自动展开正文',
        get enabled() { return G_CONFIG.featureToggles[this.id]; },
        log(...args) { Logger.log(this.id, ...args); },
        error(...args) { Logger.error(this.id, ...args); },

        init() {
            this.log('模块已启动,订阅 click 事件。');
            EventManager.subscribe('click', this.handleOutlineClick.bind(this));
        },

        async handleOutlineClick(context) {
            const { event } = context;
            const outlineItem = event.target.closest('.sy__outline .b3-list-item');
            if (!outlineItem) return;

            const nodeId = outlineItem.dataset.nodeId;
            if (!nodeId) return;

            await new Promise(resolve => setTimeout(resolve, 100));

            const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
            if (!focusedTab) return;

            const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
            if (!activeEditor) return;

            const headingInContent = activeEditor.querySelector(`[data-node-id="${nodeId}"]`);
            if (headingInContent && headingInContent.getAttribute('fold') === '1') {
                try {
                    await this.unfoldBlock(nodeId);
                } catch (err) {
                    this.error(`展开标题 ${nodeId} 时出错:`, err);
                }
            }
        },

        async unfoldBlock(id) {
            try {
                const response = await fetch(G_CONFIG.API.unfoldBlock, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ id })
                });
                const result = await response.json();
                if (result.code !== 0) throw new Error(result?.msg || 'API调用失败');
            } catch (error) {
                this.error(`展开块 ${id} 失败:`, error);
                throw error;
            }
        },
    };

    const OutlineFoldSyncModule = {
        id: 'outlineFoldSync',
        name: '大纲与正文折叠状态同步',
        get enabled() { return G_CONFIG.featureToggles[this.id]; },
        log(...args) { Logger.log(this.id, ...args); },
        error(...args) { Logger.error(this.id, ...args); },

        init() {
            this.log('模块已启动');
            this.setupObserver();
            setTimeout(() => this.initialSync(), 1000);
            EventManager.subscribe('docSwitch', this.initialSync.bind(this));
        },

        setupObserver() {
            const observer = new MutationObserver(mutations => {
                for (const mutation of mutations) {
                    if (mutation.type === 'attributes' && mutation.attributeName === 'fold') {
                        const target = mutation.target;
                        if (target.dataset.type === 'NodeHeading') {
                            this.syncOutlineState(target.dataset.nodeId, target.getAttribute('fold') === '1');
                        }
                    }
                }
            });
            observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['fold'] });
        },

        syncOutlineState(nodeId, isFolded) {
            setTimeout(() => {
                if (!nodeId) return;
                document.querySelectorAll(`.sy__outline .b3-list-item[data-node-id="${nodeId}"]`).forEach(itemLi => {
                    const childrenUl = itemLi.nextElementSibling;
                    const arrowSvg = itemLi.querySelector('.b3-list-item__arrow');
                    if (!childrenUl || childrenUl.tagName !== 'UL' || !arrowSvg) return;
                    const isCurrentlyFoldedInOutline = childrenUl.classList.contains('fn__none');
                    if (isFolded && !isCurrentlyFoldedInOutline) {
                        arrowSvg.classList.remove('b3-list-item__arrow--open');
                        childrenUl.classList.add('fn__none');
                    } else if (!isFolded && isCurrentlyFoldedInOutline) {
                        arrowSvg.classList.add('b3-list-item__arrow--open');
                        childrenUl.classList.remove('fn__none');
                    }
                });
            }, 0);
        },

        initialSync() {
            this.log('开始初始状态同步...');
            const activeProtyle = document.querySelector('.protyle:not(.fn__none)');
            if (!activeProtyle) return;
            const activeEditor = activeProtyle.querySelector('.protyle-wysiwyg');
            const docTitleElement = activeProtyle.querySelector('.protyle-title__input');
            if (!activeEditor || !docTitleElement) return;

            const checkAndSync = () => {
                if (!activeEditor.querySelector('[data-type="NodeHeading"]')) {
                    setTimeout(checkAndSync, 500);
                    return;
                }
                const docTitle = docTitleElement.textContent;
                const outline = document.querySelector('.sy__outline');
                if (!outline) {
                    setTimeout(checkAndSync, 500);
                    return;
                }
                const outlineTitle = outline.querySelector('.b3-list-item[title] .b3-list-item__text')?.textContent;
                if (outlineTitle === docTitle && outline.querySelector('.b3-list-item[data-node-id]')) {
                    activeEditor.querySelectorAll('[data-type="NodeHeading"]').forEach(heading => {
                        this.syncOutlineState(heading.dataset.nodeId, heading.getAttribute('fold') === '1');
                    });
                } else {
                    setTimeout(checkAndSync, 500);
                }
            };
            checkAndSync();
        }
    };

    // ===================================================================
    //
    //                     4. 全局控制台 (通常无需修改)
    //
    // ===================================================================

    const G_FEATURE_MODULES = [
        BatchFoldModule,
        OutlineEnhancerModule,
        OutlineSearchModule,
        OutlineAutoExpandModule,
        OutlineFoldSyncModule,
        // 已移除 OutlineInsertModule
    ];

    function initializeModules() {
        EventManager.init();
        G_FEATURE_MODULES.forEach(module => {
            if (module.enabled && typeof module.init === 'function') {
                try {
                    module.init();
                } catch (e) {
                    console.error(`[大纲插件] Failed to initialize module: ${module.name}`, e);
                }
            }
        });
    }

    setTimeout(initializeModules, 1000);

})();

中键展开折叠大纲(js 代码)

视频里,是用鼠标中键操作

(function () {
  function onMiddleMouseDown(event) {
    // 只处理鼠标中键
    if (event.button !== 1) return;

    // 1. 查找当前点击的标题项
    const headingItem = event.target.closest('li.b3-list-item[data-type="NodeHeading"]');
    if (!headingItem) return;

    // 阻止中键默认行为(自动滚动/中键粘贴等)并阻止冒泡
    event.preventDefault();
    event.stopPropagation();

    // 辅助函数:切换当前标题的展开/折叠状态
    function toggleCurrentHeading() {
      const toggleBtn = headingItem.querySelector('.b3-list-item__toggle:not(.fn__hidden)');
      if (toggleBtn) toggleBtn.click();
    }

    // 2. 检查当前标题是否有下级列表
    const nextSibling = headingItem.nextElementSibling;
    const hasChildren = nextSibling && nextSibling.tagName === 'UL';

    // 3. 检查当前标题是否有上级
    const parentUL = headingItem.closest('ul');
    const hasParent = parentUL && parentUL.previousElementSibling;

    // 4. 如果有下级列表,总是提供当前标题的展开/折叠功能
    if (hasChildren) {
      toggleCurrentHeading();
      return; // 直接返回,不执行后续操作
    }

    // 5. 如果没有下级,执行原功能(折叠上一级标题)
    if (hasParent) {
      const parentHeading = parentUL.previousElementSibling;
      if (!parentHeading || !parentHeading.classList.contains('b3-list-item')) return;

      const parentToggleBtn = parentHeading.querySelector('.b3-list-item__toggle:not(.fn__hidden)');
      if (parentToggleBtn) parentToggleBtn.click();
    }
  }

  // 用 mousedown 捕获中键,确保能阻止默认行为
  document.addEventListener('mousedown', onMiddleMouseDown, true);

  // 兼容性:在部分环境中,中键还会触发 auxclick,这里拦截以避免默认动作(如打开链接)
  document.addEventListener('auxclick', function (event) {
    if (event.button !== 1) return;
    const headingItem = event.target.closest('li.b3-list-item[data-type="NodeHeading"]');
    if (!headingItem) return;
    event.preventDefault();
    event.stopPropagation();
  }, true);
})();

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    28442 引用 • 119754 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • 365

    image.png

    按住 alt 键,再点击这个小箭头,可一键折叠或展开,同级别的大纲标题(shaoxia 写的代码,gpt 扒取)

推荐标签 标签

  • 支付宝

    支付宝是全球领先的独立第三方支付平台,致力于为广大用户提供安全快速的电子支付/网上支付/安全支付/手机支付体验,及转账收款/水电煤缴费/信用卡还款/AA 收款等生活服务应用。

    29 引用 • 347 回帖 • 2 关注
  • Firefox

    Mozilla Firefox 中文俗称“火狐”(正式缩写为 Fx 或 fx,非正式缩写为 FF),是一个开源的网页浏览器,使用 Gecko 排版引擎,支持多种操作系统,如 Windows、OSX 及 Linux 等。

    7 引用 • 30 回帖 • 367 关注
  • flomo

    flomo 是新一代 「卡片笔记」 ,专注在碎片化时代,促进你的记录,帮你积累更多知识资产。

    6 引用 • 144 回帖
  • 一些有用的避坑指南。

    69 引用 • 93 回帖
  • Vue.js

    Vue.js(读音 /vju ː/,类似于 view)是一个构建数据驱动的 Web 界面库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

    269 引用 • 666 回帖 • 1 关注
  • AWS
    11 引用 • 28 回帖 • 2 关注
  • 安装

    你若安好,便是晴天。

    134 引用 • 1184 回帖 • 2 关注
  • MySQL

    MySQL 是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 是最流行的关系型数据库管理系统之一。

    695 引用 • 538 回帖 • 1 关注
  • Kafka

    Kafka 是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者规模的网站中的所有动作流数据。 这种动作(网页浏览,搜索和其他用户的行动)是现代系统中许多功能的基础。 这些数据通常是由于吞吐量的要求而通过处理日志和日志聚合来解决。

    36 引用 • 35 回帖
  • 钉钉

    钉钉,专为中国企业打造的免费沟通协同多端平台, 阿里巴巴出品。

    15 引用 • 67 回帖 • 236 关注
  • RabbitMQ

    RabbitMQ 是一个开源的 AMQP 实现,服务器端用 Erlang 语言编写,支持多种语言客户端,如:Python、Ruby、.NET、Java、C、PHP、ActionScript 等。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

    49 引用 • 60 回帖 • 342 关注
  • 创业

    你比 99% 的人都优秀么?

    81 引用 • 1396 回帖 • 1 关注
  • Netty

    Netty 是一个基于 NIO 的客户端-服务器编程框架,使用 Netty 可以让你快速、简单地开发出一个可维护、高性能的网络应用,例如实现了某种协议的客户、服务端应用。

    49 引用 • 33 回帖 • 63 关注
  • FreeMarker

    FreeMarker 是一款好用且功能强大的 Java 模版引擎。

    23 引用 • 20 回帖 • 475 关注
  • 黑曜石

    黑曜石是一款强大的知识库工具,支持本地 Markdown 文件编辑,支持双向链接和关系图。

    A second brain, for you, forever.

    34 引用 • 333 回帖
  • jsDelivr

    jsDelivr 是一个开源的 CDN 服务,可为 npm 包、GitHub 仓库提供免费、快速并且可靠的全球 CDN 加速服务。

    5 引用 • 31 回帖 • 121 关注
  • Elasticsearch

    Elasticsearch 是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

    117 引用 • 99 回帖 • 191 关注
  • 百度

    百度(Nasdaq:BIDU)是全球最大的中文搜索引擎、最大的中文网站。2000 年 1 月由李彦宏创立于北京中关村,致力于向人们提供“简单,可依赖”的信息获取方式。“百度”二字源于中国宋朝词人辛弃疾的《青玉案·元夕》词句“众里寻他千百度”,象征着百度对中文信息检索技术的执著追求。

    63 引用 • 785 回帖 • 46 关注
  • IDEA

    IDEA 全称 IntelliJ IDEA,是一款 Java 语言开发的集成环境,在业界被公认为最好的 Java 开发工具之一。IDEA 是 JetBrains 公司的产品,这家公司总部位于捷克共和国的首都布拉格,开发人员以严谨著称的东欧程序员为主。

    182 引用 • 400 回帖
  • webpack

    webpack 是一个用于前端开发的模块加载器和打包工具,它能把各种资源,例如 JS、CSS(less/sass)、图片等都作为模块来使用和处理。

    43 引用 • 130 回帖 • 259 关注
  • abitmean

    有点意思就行了

    44 关注
  • 新人

    让我们欢迎这对新人。哦,不好意思说错了,让我们欢迎这位新人!
    新手上路,请谨慎驾驶!

    52 引用 • 228 回帖
  • 电影

    这是一个不能说的秘密。

    125 引用 • 610 回帖
  • Swift

    Swift 是苹果于 2014 年 WWDC(苹果开发者大会)发布的开发语言,可与 Objective-C 共同运行于 Mac OS 和 iOS 平台,用于搭建基于苹果平台的应用程序。

    34 引用 • 37 回帖 • 565 关注
  • 音乐

    你听到信仰的声音了么?

    63 引用 • 513 回帖
  • TGIF

    Thank God It's Friday! 感谢老天,总算到星期五啦!

    293 引用 • 4496 回帖 • 687 关注
  • TensorFlow

    TensorFlow 是一个采用数据流图(data flow graphs),用于数值计算的开源软件库。节点(Nodes)在图中表示数学操作,图中的线(edges)则表示在节点间相互联系的多维数据数组,即张量(tensor)。

    20 引用 • 19 回帖