多选调整大纲标题(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);
})();

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