标题
- 如果标题是折叠的状态,按下 enter 键,自动创建同级标题
- 一键打开/关闭同级所有标题
- 自动修改标题序列(基于别人的上面改的)
块
- 在子序号为空的时候,按下 tab 键,则该行转为段落
- 如果是段落,按 tab 键会缩进合并到上方列表里面(不完美,因为 moveblock 会强制全局刷新一次)
- 优化列表的箭头颜色
- 自动更正错误列表序号,包含列表和段落的序列对齐
- 块内如果有任务,自动统计任务显示在序列标题的末尾初
- 一键关闭同级块/一键展开块的子模块
- 编辑的时候高亮,颜色为红色(整合其他人的包)
- 段落高亮编辑特效(结合别人的)
工具栏
- 段落/标题新增工具栏编辑
- 列表下的段落增加工具栏编辑
- 新增工具栏功能整合成下拉框,同时允许自动弹出,高亮等
- 批量编辑多个块粗体/斜体/下划线/标注,颜色编辑
- 新增批量颜色编辑
- 新增编辑器增加颜色快捷点选
美中不足需要官方优化的包括:
看了官方有个新的开源社区,貌似也可以做下面功能?不确定有些想法,周末再试
1 列表块的拖动排序,经常排列错,多个手势太繁琐,最好是在思源设置里可选,默认只有一个拖动到元素下方(不要还能拖动成了折叠里面的子项目(消失了),还有经常拖动不到下方)
不出意外,后面应该不会再更新新功能,这几个是拿来练手 ai 编程的.....😄😄😄😄
从此江湖上只有哥的传说,而哥已不再江湖,再默默用思源学习..😂😂😂😂😂
第 1. 编辑器颜色快捷按钮和批量编辑颜色
第 2.标题自动加序列,改序列号
在之前一位朋友的 css 基础上改的,主要是增加了如果小题在上面,或者不是从 h1 开始,序号正常显示的支持
第 3.折叠标题创建同级标题
第 4.一键打开/关闭同级所有标题/块
使用方法:
按照 alt+ 点击标题前的小箭头: 打开/关闭同级标题
按住 alt+ 点击块钱的小箭头: 关闭同级列表,展开该列表下的所有块(含子块)
第 5. 在子序号为空的时候,按下 tab 键,则该行转为段落
第 6.如果是段落,按 tab 键会缩进合并到上方列表里面
注意: 不完美,因为 moveblock 会强制全局刷新一次,也就只有官方解决这个问题了
第 7.优化列表的箭头颜色
第 8.自动更正错误列表序号,包含列表和段落的序列对齐
第 9.块内如果有任务,自动统计任务显示在序列标题的末尾初
第 10. 新增工具栏编辑支持段落/标题/列表下的段落
第 11. 批量编辑多个块粗体/斜体/下划线/标注(很多人在找的功能吧)
第 12. 高亮编辑区
代码 1: 标题和块的优化
注意: 尽管我都做了测试,难免还有问题.在 G_CONFIG 里根据需要自行开关功能
//version: v1.0
//plugin name: 标题自动序号(需要搭配下面的js一并使用)
//author: 少侠,微信:572378589
//update time: 更新时间7/10
/* ================= 标题自动编号模块 ================= */
body {
counter-reset: h1-count;
}
h1,
.h1 {
counter-increment: h1-count;
counter-reset: h2-count;
}
h2,
.h2 {
counter-increment: h2-count;
counter-reset: h3-count;
}
h3,
.h3 {
counter-increment: h3-count;
counter-reset: h4-count;
}
h4,
.h4 {
counter-increment: h4-count;
counter-reset: h5-count;
}
h5,
.h5 {
counter-increment: h5-count;
counter-reset: h6-count;
}
h6,
.h6 {
counter-increment: h6-count;
}
/* 通用计数器样式 */
.protyle-wysiwyg [data-node-id][class^="h"] div:first-child:before,
.b3-typography h1:before,
.b3-typography h2:before,
.b3-typography h3:before,
.b3-typography h4:before,
.b3-typography h5:before,
.b3-typography h6:before {
display: inline-block !important;
float: none;
margin-right: 8px;
font-size: 100%;
opacity: 1 !important;
/* 强制显示防止折叠隐藏 */
}
/* 层级式编号生成规则 */
.protyle-wysiwyg [data-node-id].h1 div:first-child:before,
.b3-typography h1:before {
content: counter(h1-count) "\00A0";
}
.protyle-wysiwyg [data-node-id].h2 div:first-child:before,
.b3-typography h2:before {
content: counter(h1-count) "." counter(h2-count) "\00A0";
}
.protyle-wysiwyg [data-node-id].h3 div:first-child:before,
.b3-typography h3:before {
content: counter(h1-count) "." counter(h2-count) "." counter(h3-count) "\00A0";
}
.protyle-wysiwyg [data-node-id].h4 div:first-child:before,
.b3-typography h4:before {
content: counter(h1-count) "." counter(h2-count) "." counter(h3-count) "." counter(h4-count) "\00A0";
}
.protyle-wysiwyg [data-node-id].h5 div:first-child:before,
.b3-typography h5:before {
content: counter(h1-count) "." counter(h2-count) "." counter(h3-count) "." counter(h4-count) "." counter(h5-count) "\00A0";
font-size: 90% !important;
}
.protyle-wysiwyg [data-node-id].h6 div:first-child:before,
.b3-typography h6:before {
content: counter(h1-count) "." counter(h2-count) "." counter(h3-count) "." counter(h4-count) "." counter(h5-count) "." counter(h6-count) "\00A0";
font-size: 85% !important;
}
/* ================= 动态编号适配模块 (JS 辅助) ================= */
/* 适配:当 H2 是顶级标题时 */
body.top-heading-h2 .protyle-wysiwyg [data-node-id].h2 div:first-child:before,
body.top-heading-h2 .b3-typography h2:before {
content: counter(h2-count) "\00A0";
}
body.top-heading-h2 .protyle-wysiwyg [data-node-id].h3 div:first-child:before,
body.top-heading-h2 .b3-typography h3:before {
content: counter(h2-count) "." counter(h3-count) "\00A0";
}
body.top-heading-h2 .protyle-wysiwyg [data-node-id].h4 div:first-child:before,
body.top-heading-h2 .b3-typography h4:before {
content: counter(h2-count) "." counter(h3-count) "." counter(h4-count) "\00A0";
}
body.top-heading-h2 .protyle-wysiwyg [data-node-id].h5 div:first-child:before,
body.top-heading-h2 .b3-typography h5:before {
content: counter(h2-count) "." counter(h3-count) "." counter(h4-count) "." counter(h5-count) "\00A0";
}
body.top-heading-h2 .protyle-wysiwyg [data-node-id].h6 div:first-child:before,
body.top-heading-h2 .b3-typography h6:before {
content: counter(h2-count) "." counter(h3-count) "." counter(h4-count) "." counter(h5-count) "." counter(h6-count) "\00A0";
}
/* 适配:当 H3 是顶级标题时 */
body.top-heading-h3 .protyle-wysiwyg [data-node-id].h3 div:first-child:before,
body.top-heading-h3 .b3-typography h3:before {
content: counter(h3-count) "\00A0";
}
body.top-heading-h3 .protyle-wysiwyg [data-node-id].h4 div:first-child:before,
body.top-heading-h3 .b3-typography h4:before {
content: counter(h3-count) "." counter(h4-count) "\00A0";
}
body.top-heading-h3 .protyle-wysiwyg [data-node-id].h5 div:first-child:before,
body.top-heading-h3 .b3-typography h5:before {
content: counter(h3-count) "." counter(h4-count) "." counter(h5-count) "\00A0";
}
body.top-heading-h3 .protyle-wysiwyg [data-node-id].h6 div:first-child:before,
body.top-heading-h3 .b3-typography h6:before {
content: counter(h3-count) "." counter(h4-count) "." counter(h5-count) "." counter(h6-count) "\00A0";
}
/* 适配:当 H4 是顶级标题时 */
body.top-heading-h4 .protyle-wysiwyg [data-node-id].h4 div:first-child:before,
body.top-heading-h4 .b3-typography h4:before {
content: counter(h4-count) "\00A0";
}
body.top-heading-h4 .protyle-wysiwyg [data-node-id].h5 div:first-child:before,
body.top-heading-h4 .b3-typography h5:before {
content: counter(h4-count) "." counter(h5-count) "\00A0";
}
body.top-heading-h4 .protyle-wysiwyg [data-node-id].h6 div:first-child:before,
body.top-heading-h4 .b3-typography h6:before {
content: counter(h4-count) "." counter(h5-count) "." counter(h6-count) "\00A0";
}
/* 适配:当 H5 是顶级标题时 */
body.top-heading-h5 .protyle-wysiwyg [data-node-id].h5 div:first-child:before,
body.top-heading-h5 .b3-typography h5:before {
content: counter(h5-count) "\00A0";
}
body.top-heading-h5 .protyle-wysiwyg [data-node-id].h6 div:first-child:before,
body.top-heading-h5 .b3-typography h6:before {
content: counter(h5-count) "." counter(h6-count) "\00A0";
}
/* 适配:当 H6 是顶级标题时 */
body.top-heading-h6 .protyle-wysiwyg [data-node-id].h6 div:first-child:before,
body.top-heading-h6 .b3-typography h6:before {
content: counter(h6-count) "\00A0";
}
/* ================= 标题视觉标识模块 ================= */
/* 标题颜色分级系统 */
[data-type="NodeHeading"].h1 {
color: #d40045 !important;
}
[data-type="NodeHeading"].h2 {
color: #ff7f00 !important;
}
[data-type="NodeHeading"].h3 {
color: #66b82b !important;
}
[data-type="NodeHeading"].h4 {
color: #093f86 !important;
}
[data-type="NodeHeading"].h5 {
color: #340c81 !important;
}
/* ================= 折叠状态适配模块 ================= */
.protyle-wysiwyg [data-fold="1"] [data-node-id][class^="h"] {
position: relative;
padding-right: 4em !important;
/* 为折叠标记留出空间 */
}
//version: v1.1
//plugin name: 标题和块的优化
//author: 少侠,微信:572378589
//update time: 更新时间7/10
//增加了对于标题序号的支持
(() => {
// ===================================================================
// ===================================================================
//
// 1. 全局配置中心 (G_CONFIG) - 用户配置区
//
// ===================================================================
// ===================================================================
const G_CONFIG = {
/**
* 功能总开关
*/
featureToggles: {
headingEnhancer: true, // 标题折叠创建
listIndent: true, // 块缩进合并
listConvert: true, // 列表转段落
listIndexCorrector: true, // 智能序号更正
listFoldedStyle: true, // 有序列表折叠样式优化
taskStats: true, // 任务列表统计显示
batchFold: true, // 批量折叠展开
highlightEditingBlock: true, // 高亮编辑块
headingindex:true, // 标题自动更新序列
},
/**
* 功能调试日志开关
*/
debugToggles: {
headingEnhancer: false,
listIndent: false,
listConvert: false,
listIndexCorrector: false,
listFoldedStyle: false,
taskStats: false,
batchFold: false,
headingindex:false,
},
/**
* CSS 选择器配置
*/
CSS: {
focusedTab: '.layout-tab-bar .item--focus',
protyleWysiwyg: '.protyle-wysiwyg',
},
/**
* API 路径配置
*/
API: {
insertBlock: '/api/block/insertBlock',
moveBlock: '/api/block/moveBlock',
updateBlock: '/api/block/updateBlock',
getBlockKramdown: '/api/block/getBlockKramdown',
},
/**
* 功能特定配置
*/
FEATURES: {
listIndexCorrector: {
checkInterval: 3000, // 定时检查间隔3秒
},
listFoldedStyle: {
backgroundColor: 'rgb(1 227 177 / 30%)', // 折叠列表背景色
},
taskStats: {
updateDelay: 1000, // 更新延迟1秒
statsFormat: '{completed}/{total}个任务', // 统计显示格式
},
batchFold: {
hideGutterAfterClick: true,
foldDelay: 100,
},
}
};
// ===================================================================
// ===================================================================
//
// 2. 核心工具 (通常无需修改)
//
// ===================================================================
// ===================================================================
const Logger = {
log(moduleId, ...args) {
if (G_CONFIG.debugToggles[moduleId]) {
console.log(`[增强插件][${moduleId}]`, ...args);
}
},
error(moduleId, ...args) {
console.error(`[增强插件][${moduleId}]`, ...args);
}
};
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 HeadingindexModule = {
id: 'headingindex',
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('模块已启动,开始监控标题层级变化。');
// 初始加载时运行
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', () => this.updateHeadingClass());
} else {
this.updateHeadingClass();
}
// 使用 MutationObserver 监听文档变化
const observer = new MutationObserver((mutations) => {
for(let mutation of mutations) {
// 只关心节点增删
if (mutation.type === 'childList') {
this.updateHeadingClass();
return; // 找到变化后即可退出
}
}
});
// 观察整个文档的变化
observer.observe(document.body, {
childList: true,
subtree: true
});
// 监听编辑器焦点变化
document.addEventListener('focusin', (e) => {
if (this.isInEditor(e.target)) {
this.updateHeadingClass();
}
}, true);
},
/**
* 检查元素是否在编辑器中
*/
isInEditor(element) {
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) return false;
const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
return activeEditor && activeEditor.contains(element);
},
updateHeadingClass() {
// 获取当前激活的编辑器
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) {
this.log('未找到激活的标签页');
return;
}
const protyleContent = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
if (!protyleContent) {
this.log('未找到激活的编辑器内容区');
return;
}
// 直接获取所有标题的 data-subtype 属性
const allHeadings = protyleContent.querySelectorAll('[data-type="NodeHeading"]');
let topLevel = 6;
allHeadings.forEach(heading => {
const subtype = heading.getAttribute('data-subtype'); // 获取 h1~h6
if (subtype) {
const level = parseInt(subtype.substring(1)); // 从 "h1" 提取数字 1
if (level > 0 && level < topLevel) {
topLevel = level;
}
}
});
// 移除旧的class
document.body.className = document.body.className.replace(/ top-heading-h\d/g, '');
if (topLevel !== 6) {
document.body.classList.add(`top-heading-h${topLevel}`);
this.log(`当前文档最高标题层级为 h${topLevel}, 共发现 ${allHeadings.length} 个标题`);
} else {
this.log('当前文档未找到任何标题');
}
}
};
const HeadingModule = {
id: 'headingEnhancer',
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('模块已启动,开始监听全局键盘事件。');
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
this.handleEnterKey(e);
}
}, true);
},
async handleEnterKey(e) {
if (this.isProcessing) {
this.log('[Ignored] Event ignored, another is processing.');
e.preventDefault(); e.stopPropagation(); return;
}
this.isProcessing = true;
setTimeout(() => { this.isProcessing = false; }, 200);
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) { this.isProcessing = false; return; }
const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
if (!activeEditor || !activeEditor.contains(e.target)) { this.isProcessing = false; return; }
const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { this.isProcessing = false; return; }
const range = selection.getRangeAt(0);
const blockEl = (range.startContainer.nodeType === 3 ? range.startContainer.parentElement : range.startContainer).closest('[data-node-id]');
if (!blockEl || blockEl.dataset.type !== 'NodeHeading' || blockEl.getAttribute('fold') !== '1') { this.isProcessing = false; return; }
e.preventDefault(); e.stopPropagation();
const blockId = blockEl.dataset.nodeId;
const subtype = blockEl.dataset.subtype;
const prefix = "#".repeat(parseInt(subtype.substring(1))) + " ";
const newMarkdown = prefix + '\u200B';
try {
const insertResponse = await fetch(G_CONFIG.API.insertBlock, { method: 'POST', body: JSON.stringify({ dataType: "markdown", data: newMarkdown, previousID: blockId }) });
if (!insertResponse.ok) throw new Error(`insertBlock API call failed`);
const resultData = await insertResponse.json();
const newBlockId = resultData?.data?.[0]?.doOperations?.[0]?.id || resultData?.data?.[0]?.do?.[0]?.id;
if (newBlockId) {
const newBlockElement = await waitForElement(`[data-node-id="${newBlockId}"]`);
const editableDiv = newBlockElement.querySelector('[contenteditable="true"]');
if (!editableDiv) return;
editableDiv.focus();
const textNode = Array.from(editableDiv.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
if (!textNode) return;
const domRange = document.createRange();
domRange.setStart(textNode, textNode.length);
domRange.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(domRange);
}
} catch (apiError) {
this.error(`🔴 FAILED during API call:`, apiError);
}
}
};
const ListIndentModule = {
id: 'listIndent',
name: '块缩进合并',
get enabled() {return G_CONFIG.featureToggles[this.id]; }, // 可后续接入 G_CONFIG.featureToggles
log(...args) { Logger.log(this.id, ...args); },
error(...args) { Logger.error(this.id, ...args); },
init() {
this.log('模块已启动,监听 Tab 缩进。');
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
this.handleTabKey(e);
}, true);
},
handleTabKey(e) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let blockEl = (range.startContainer.nodeType === 3 ? range.startContainer.parentElement : range.startContainer).closest('[data-node-id]');
if (!blockEl || blockEl.dataset.type !== 'NodeParagraph') return;
// 获取前一个同级块
const parent = blockEl.parentElement;
if (!parent) return;
const children = Array.from(parent.children);
const idx = children.indexOf(blockEl);
if (idx <= 0) return;
const prev = children[idx - 1];
// 前一个块为 NodeList
if (prev.dataset.type === 'NodeList') {
// 找到最后一个 NodeListItem
const listItems = prev.querySelectorAll('[data-type="NodeListItem"]');
if (!listItems.length) return;
const lastListItem = listItems[listItems.length - 1];
this.mergeParagraphToList(blockEl, lastListItem, e);
}
},
/**
* @param {HTMLElement} paragraphDiv 需要缩进的段落块
* @param {HTMLElement} prevListItem 目标 NodeListItem
* @param {KeyboardEvent} e 事件对象
*/
async mergeParagraphToList(paragraphDiv, prevListItem, e) {
e.preventDefault();
e.stopPropagation();
this.log('模拟原生拖拽事件', paragraphDiv, '到', prevListItem);
try {
// 1. 先做DOM操作
prevListItem.appendChild(paragraphDiv);
// 主动设置焦点和selection到新位置,提升交互体验
const editable = paragraphDiv.querySelector('[contenteditable="true"]');
if (editable) {
editable.focus();
const textNode = Array.from(editable.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
if (textNode) {
const domRange = document.createRange();
domRange.setStart(textNode, textNode.length);
domRange.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(domRange);
}
this.log('已设置焦点到缩进块', editable, 'selection:', window.getSelection());
} else {
this.log('未找到contenteditable区域');
}
// 2. 调用 moveBlock API 同步块结构
const paraId = paragraphDiv.dataset.nodeId;
const targetParentId = prevListItem.dataset.nodeId;
if (!paraId || !targetParentId) {
this.error('无法获取块ID', paraId, targetParentId);
return;
}
const children = Array.from(prevListItem.children).filter(
el => el.dataset && el.dataset.nodeId && el !== paragraphDiv
);
const lastChild = children.length > 0 ? children[children.length - 1] : null;
const token = localStorage.getItem('authToken') || '';
const body = { id: paraId, parentID: targetParentId };
if (lastChild) {
body.previousID = lastChild.dataset.nodeId;
}
// 【已优化】使用 G_CONFIG.API.moveBlock
const moveRes = await fetch(G_CONFIG.API.moveBlock, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : undefined
},
body: JSON.stringify(body)
});
const moveData = await moveRes.json();
if (moveData.code === 0) {
this.log('moveBlock成功', paraId, '->', targetParentId);
} else {
this.error('API移动块失败', moveData.msg);
}
} catch (err) {
this.error('模拟拖拽失败', err);
}
},
/**
* 保存指定 NodeListItem 及其子块到后端
* @param {HTMLElement} nodeListItem 目标 NodeListItem
*/
async saveNodeListItemToBackend(nodeListItem) {
const nodeListItemId = nodeListItem.dataset.nodeId;
if (!nodeListItemId) {
this.error('保存失败:未获取到 NodeListItem 的 data-node-id');
return;
}
const domStr = nodeListItem.outerHTML;
console.log('domStr', domStr);
const token = localStorage.getItem('authToken') || '';
try {
// 【已优化】使用 G_CONFIG.API.updateBlock
const res = await fetch(G_CONFIG.API.updateBlock, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : undefined
},
body: JSON.stringify({
id: nodeListItemId,
dataType: 'dom',
data: domStr
})
});
const data = await res.json();
if (data.code === 0) {
this.log('保存成功', nodeListItemId);
this.log('data', data);
} else {
this.error('保存失败', data.msg);
}
} catch (err) {
this.error('保存失败', err);
}
}
};
const ListConvertModule = {
id: 'listConvert',
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('模块已启动,监听列表转段落。');
document.addEventListener('keydown', (e) => {
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
this.handleTabKey(e);
}, true);
},
handleTabKey(e) {
this.log('Tab键被按下,开始检查列表项转换条件');
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
this.log('未找到有效的selection,退出处理');
return;
}
const range = selection.getRangeAt(0);
let blockEl = (range.startContainer.nodeType === 3 ? range.startContainer.parentElement : range.startContainer).closest('[data-node-id]');
if (!blockEl) {
this.log('未找到包含data-node-id的块元素,退出处理');
return;
}
this.log('当前块元素类型:', blockEl.dataset.type, '节点ID:', blockEl.dataset.nodeId);
// 查找包含当前块的列表项
let listItem = null;
if (blockEl.dataset.type === 'NodeListItem') {
// 当前块就是列表项
listItem = blockEl;
this.log('当前块就是列表项');
} else {
// 向上查找包含的列表项
listItem = blockEl.closest('[data-type="NodeListItem"]');
if (listItem) {
this.log('找到包含的列表项,节点ID:', listItem.dataset.nodeId);
} else {
this.log('未找到包含的列表项,跳过处理');
return;
}
}
// 检查找到的列表项
this.log('开始检查列表项转换条件');
this.checkAndConvertListItem(listItem, e);
},
checkAndConvertListItem(listItem, e) {
this.log('开始检查列表项转换条件,列表项ID:', listItem.dataset.nodeId);
// 检查列表项是否为空
const paragraphEl = listItem.querySelector('[data-type="NodeParagraph"]');
if (!paragraphEl) {
this.log('未找到段落元素,退出转换');
return;
}
const editableDiv = paragraphEl.querySelector('[contenteditable="true"]');
if (!editableDiv) {
this.log('未找到可编辑区域,退出转换');
return;
}
// 检查内容是否为空(去除空白字符)
const content = editableDiv.textContent || '';
const trimmedContent = content.trim();
this.log('段落内容检查:', '原始内容长度:', content.length, '去除空白后长度:', trimmedContent.length);
if (trimmedContent !== '') {
this.log('段落内容不为空,退出转换');
return;
}
// 检查是否有子列表
const childList = listItem.querySelector('[data-type="NodeList"]');
if (childList) {
this.log('检测到子列表,应该执行缩进操作而不是转换段落,退出转换');
return;
}
// 检查是否可以缩进到前一个同级列表项
const canIndent = this.canIndentToPreviousListItem(listItem);
if (canIndent) {
this.log('可以缩进到前一个同级列表项,应该执行缩进操作,退出转换');
return;
}
this.log('所有转换条件满足:空列表项且无子列表且无法缩进,开始执行转换');
// 满足条件:空列表项且无子列表且无法缩进,执行转换
this.convertListItemToParagraph(listItem, e);
},
canIndentToPreviousListItem(listItem) {
this.log('检查是否可以缩进到前一个同级列表项');
// 获取当前列表项的父级列表
const parentList = listItem.parentElement;
if (!parentList || parentList.dataset.type !== 'NodeList') {
this.log('当前列表项不在NodeList中,无法缩进');
return false;
}
// 获取同级列表项
const siblingListItems = Array.from(parentList.children).filter(child => child.dataset.type === 'NodeListItem');
const currentIndex = siblingListItems.indexOf(listItem);
this.log('同级列表项数量:', siblingListItems.length, '当前索引:', currentIndex);
if (currentIndex <= 0) {
this.log('当前列表项是第一个或不在列表中,无法缩进');
return false;
}
// 检查前一个同级列表项
const previousListItem = siblingListItems[currentIndex - 1];
this.log('前一个同级列表项ID:', previousListItem.dataset.nodeId);
// 前一个列表项存在,可以缩进
this.log('找到前一个同级列表项,可以缩进');
return true;
},
async convertListItemToParagraph(listItem, e) {
e.preventDefault();
e.stopPropagation();
this.log('开始转换列表项为段落,列表项ID:', listItem.dataset.nodeId);
try {
// 1. 创建新的段落DOM
this.log('步骤1: 创建新的段落DOM结构');
const paragraphDiv = document.createElement('div');
paragraphDiv.dataset.nodeId = listItem.dataset.nodeId;
paragraphDiv.dataset.nodeIndex = listItem.dataset.nodeIndex || '1';
paragraphDiv.dataset.type = 'NodeParagraph';
paragraphDiv.className = 'p';
paragraphDiv.setAttribute('updated', Date.now().toString());
this.log('新段落DOM属性设置完成:', {
nodeId: paragraphDiv.dataset.nodeId,
nodeIndex: paragraphDiv.dataset.nodeIndex,
type: paragraphDiv.dataset.type
});
// 复制原有的段落内容
const originalParagraph = listItem.querySelector('[data-type="NodeParagraph"]');
if (originalParagraph) {
this.log('复制原有段落内容');
paragraphDiv.innerHTML = originalParagraph.innerHTML;
} else {
this.log('创建空的段落内容结构');
// 创建空的段落内容
paragraphDiv.innerHTML = `
<div contenteditable="true" spellcheck="false"></div>
<div class="protyle-attr" contenteditable="false"></div>
`;
}
// 2. 替换DOM
this.log('步骤2: 执行DOM替换操作');
const parent = listItem.parentElement;
if (!parent) {
this.error('无法找到父元素,DOM替换失败');
return;
}
this.log('DOM替换前 - 父元素:', parent.tagName, '子元素数量:', parent.children.length);
parent.replaceChild(paragraphDiv, listItem);
this.log('DOM替换完成,新段落已插入到父元素中');
// 3. 设置焦点
this.log('步骤3: 设置焦点到新段落');
const editable = paragraphDiv.querySelector('[contenteditable="true"]');
if (editable) {
editable.focus();
this.log('焦点已设置到可编辑区域');
const textNode = Array.from(editable.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
if (textNode) {
const domRange = document.createRange();
domRange.setStart(textNode, textNode.length);
domRange.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(domRange);
this.log('光标位置已设置到文本末尾');
} else {
this.log('未找到文本节点,光标位置设置跳过');
}
} else {
this.log('未找到可编辑区域,焦点设置失败');
}
// 4. 调用API同步到后端
this.log('步骤4: 开始同步到后端');
await this.syncToBackend(paragraphDiv, listItem.dataset.nodeId);
this.log('列表项转段落转换完成,所有步骤执行成功');
} catch (err) {
this.error('转换过程中发生错误:', err);
}
},
async syncToBackend(paragraphDiv, originalNodeId) {
this.log('开始同步到后端,节点ID:', originalNodeId);
try {
const token = localStorage.getItem('authToken') || '';
this.log('获取认证token:', token ? '已获取' : '未获取');
const domStr = paragraphDiv.outerHTML;
this.log('生成DOM字符串,长度:', domStr.length);
const requestBody = {
id: originalNodeId,
dataType: 'dom',
data: domStr
};
this.log('准备发送API请求,请求体:', requestBody);
const res = await fetch(G_CONFIG.API.updateBlock, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : undefined
},
body: JSON.stringify(requestBody)
});
this.log('API请求已发送,响应状态:', res.status);
const data = await res.json();
this.log('API响应数据:', data);
if (data.code === 0) {
this.log('后端同步成功,节点已更新');
} else {
this.error('后端同步失败,错误信息:', data.msg);
}
} catch (err) {
this.error('后端同步过程中发生异常:', err);
}
}
};
const ListIndexCorrectorModule = {
id: 'listIndexCorrector',
name: '智能序号更正',
get enabled() { return G_CONFIG.featureToggles[this.id]; },
log(...args) { Logger.log(this.id, ...args); },
error(...args) { Logger.error(this.id, ...args); },
isProcessing: false,
checkInterval: null,
init() {
this.log('模块已启动,开始监控列表索引。');
// 启动定时检查
this.startPeriodicCheck();
// 监听编辑器变化
this.setupEventListeners();
},
/**
* 启动定时检查
*/
startPeriodicCheck() {
const interval = G_CONFIG.FEATURES.listIndexCorrector.checkInterval || 3000;
this.checkInterval = setInterval(() => {
if (!this.isProcessing) {
this.updateListIndexes();
}
}, interval);
this.log(`定时检查已启动,间隔: ${interval}ms`);
},
/**
* 设置事件监听器
*/
setupEventListeners() {
// 监听blur事件
document.addEventListener('blur', (e) => {
if (this.isInEditor(e.target)) {
setTimeout(() => {
this.updateListIndexes();
}, 100);
}
}, true);
// 监听键盘事件
document.addEventListener('keyup', (e) => {
if (this.isInEditor(e.target)) {
const listItem = e.target.closest('[data-type="NodeListItem"]');
if (listItem) {
setTimeout(() => {
this.updateListIndexes();
}, 200);
}
}
}, true);
// 监听粘贴事件
document.addEventListener('paste', (e) => {
if (this.isInEditor(e.target)) {
const listItem = e.target.closest('[data-type="NodeListItem"]');
if (listItem) {
setTimeout(() => {
this.updateListIndexes();
}, 100);
}
}
}, true);
},
/**
* 检查元素是否在编辑器中
*/
isInEditor(element) {
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) return false;
const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
return activeEditor && activeEditor.contains(element);
},
/**
* 检查用户是否正在编辑
*/
isUserEditing() {
const focusedElement = document.activeElement;
if (!focusedElement) return false;
// 检查是否有焦点在可编辑元素上
return focusedElement.getAttribute('contenteditable') === 'true' ||
focusedElement.closest('[contenteditable="true"]');
},
/**
* 主逻辑函数 - 更新列表索引
*/
async updateListIndexes() {
if (this.isProcessing) return;
this.isProcessing = true;
this.log('--- 开始执行检查 ---');
try {
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) {
this.log('1. 未找到激活的Tab (item--focus),退出。');
return;
}
const tabId = focusedTab.dataset.id;
this.log('1. 找到激活的Tab,ID: ' + tabId);
const activeEditorContainer = document.querySelector(`.protyle[data-id="${tabId}"]`);
if (!activeEditorContainer) {
this.log('2. 未找到与Tab ID匹配的编辑器容器');
return;
}
const activeEditor = activeEditorContainer.querySelector(G_CONFIG.CSS.protyleWysiwyg);
if (!activeEditor) {
this.log('2. 在编辑器容器内未找到编辑器');
return;
}
const topLevelBlocks = activeEditor.querySelectorAll(':scope > [data-node-id]');
if (topLevelBlocks.length === 0) {
this.log('3. 当前编辑器内没有发现顶级块。');
return;
}
this.log('3. 获取到 ' + topLevelBlocks.length + ' 个顶级块。');
const listGroups = this.groupAdjacentOrderedLists(topLevelBlocks);
this.log('4. 发现 ' + listGroups.length + ' 组需要处理的相邻有序列表。');
for (let i = 0; i < listGroups.length; i++) {
this.log('5. 开始处理第 ' + (i + 1) + ' 组...');
await this.processListGroup(listGroups[i], activeEditor);
}
this.log('所有列表组处理完成');
} catch (err) {
this.error('更新列表索引失败', err);
} finally {
this.isProcessing = false;
}
},
/**
* 将相邻的有序列表分组
*/
groupAdjacentOrderedLists(blocks) {
const groups = [];
let currentGroup = [];
blocks.forEach(block => {
const isOrderedList = block.dataset.type === 'NodeList' && block.dataset.subtype === 'o';
const contentDiv = block.querySelector(':scope > [contenteditable="true"]');
const isEmptyParagraph = block.dataset.type === 'NodeParagraph' && contentDiv && contentDiv.innerHTML.trim() === '';
if (isOrderedList) {
currentGroup.push(block);
} else if (!isEmptyParagraph) {
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
currentGroup = [];
}
});
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups;
},
/**
* 递归收集需要变更的项目
*/
collectChangesRecursively(listElement, startNumber, changes, activeEditor) {
let counter = startNumber;
const listItems = listElement.querySelectorAll(':scope > .li[data-type="NodeListItem"]');
for (const item of listItems) {
const correctMarker = counter + '.';
const currentMarker = item.dataset.marker;
const itemId = item.dataset.nodeId;
if (itemId && currentMarker !== correctMarker) {
changes.push({ id: itemId, newMarker: correctMarker });
}
// 遍历列表项的所有直接子块
const childBlocks = item.querySelectorAll(':scope > [data-node-id]');
let subCounter = 1; // 为该项内部的子列表们维护一个独立的计数器
for (const childBlock of childBlocks) {
// 如果子块是一个有序列表,就用 subCounter 继续编号并递归
if (childBlock.matches('div[data-type="NodeList"][data-subtype="o"]')) {
subCounter = this.collectChangesRecursively(childBlock, subCounter, changes, activeEditor);
} else {
// 如果遇到任何非列表块(如段落),就将子列表的计数器重置
subCounter = 1;
}
}
counter++; // 为当前层级的下一个项目增加计数器
}
return counter; // 返回下一个可用的序号
},
/**
* 处理列表组
*/
async processListGroup(group, activeEditor) {
const changes = [];
let globalCounter = 1;
this.log('5a. 开始递归收集需要变更的项目...');
for (const listElement of group) {
globalCounter = this.collectChangesRecursively(listElement, globalCounter, changes, activeEditor);
}
this.log('5b. 收集完成,总计发现 ' + changes.length + ' 个需要变更的项目。');
await this.executeUpdates(changes, activeEditor);
},
/**
* 执行更新
*/
async executeUpdates(itemsToUpdate, activeEditor) {
if (itemsToUpdate.length === 0) {
this.log('6. 所有索引均正确,无需执行更新。');
return;
}
this.log('6. 待更新列表已生成,总计 ' + itemsToUpdate.length + ' 项。开始执行API调用...');
const updatePromises = itemsToUpdate.map(itemInfo => {
return new Promise(async (resolve) => {
const focusedElement = activeEditor.querySelector('.block-focus');
const itemElement = activeEditor.querySelector(`[data-node-id="${itemInfo.id}"]`);
if (itemElement && focusedElement && itemElement.contains(focusedElement)) {
this.log(' - ID ' + itemInfo.id + ' 正在编辑中,已跳过本次更新。');
return resolve();
}
try {
this.log(' - 正在处理 ID: ' + itemInfo.id + '...');
const getContentResponse = await fetch(G_CONFIG.API.getBlockKramdown, {
method: 'POST',
body: JSON.stringify({ id: itemInfo.id })
});
if (!getContentResponse.ok) throw new Error('获取块内容失败');
const blockData = await getContentResponse.json();
if (blockData && blockData.data && typeof blockData.data.kramdown === 'string') {
const originalMarkdown = blockData.data.kramdown;
this.log(' 原始MD: "' + originalMarkdown.replace(/\n/g, "\\n") + '"');
const newMarkdown = originalMarkdown.replace(/^\s*\d+\.\s/, itemInfo.newMarker + ' ');
this.log(' 新的MD: "' + newMarkdown.replace(/\n/g, "\\n") + '"');
const payload = {
"dataType": "markdown",
"data": newMarkdown,
"id": itemInfo.id
};
await fetch(G_CONFIG.API.updateBlock, {
method: 'POST',
body: JSON.stringify(payload)
});
this.log(' ID ' + itemInfo.id + ' 更新成功。');
resolve();
} else {
throw new Error('API返回数据无效: ' + JSON.stringify(blockData));
}
} catch (error) {
this.error(' - 处理ID为 ' + itemInfo.id + ' 的块时出错:', error);
resolve();
}
});
});
await Promise.all(updatePromises);
this.log('7. 所有项处理完毕。');
},
/**
* 手动触发序号纠正(用于测试)
*/
manualCorrect() {
this.log('手动触发序号纠正');
this.updateListIndexes();
}
};
const ListFoldedStyleModule = {
id: 'listFoldedStyle',
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.injectFoldedListStyle();
},
/**
* 注入折叠列表样式
*/
injectFoldedListStyle() {
try {
const styleId = 'siyuan-list-folded-style';
// 检查是否已存在样式表
if (document.getElementById(styleId)) {
this.log('样式表已存在,跳过注入');
return;
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.protyle-wysiwyg [data-node-id].li[fold="1"]>.protyle-action::after {
background-color: ${G_CONFIG.FEATURES.listFoldedStyle.backgroundColor} !important;
opacity: ${G_CONFIG.FEATURES.listFoldedStyle.opacity} !important;
}
`;
document.head.appendChild(style);
this.log('有序列表折叠样式注入成功');
} catch (error) {
this.error('注入折叠列表样式失败:', error);
}
},
};
const TaskStatsModule = {
id: 'taskStats',
name: '任务列表统计显示',
get enabled() { return G_CONFIG.featureToggles[this.id]; },
log(...args) { Logger.log(this.id, ...args); },
error(...args) { Logger.error(this.id, ...args); },
// 状态管理
isProcessing: false,
updateTimer: null,
init() {
this.log('模块已启动,监听任务状态变化。');
this.setupEventListeners();
this.setupCopyProtection();
// 立即执行一次初始统计
this.scheduleUpdate();
},
/**
* 设置事件监听器
*/
setupEventListeners() {
// 监听任务状态变化
document.addEventListener('click', (e) => {
if (e.target.closest('.protyle-action--task')) {
this.scheduleUpdate();
}
}, true);
// 监听列表结构变化
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.querySelector('.protyle-action--task') ||
node.classList?.contains('protyle-action--task')) {
shouldUpdate = true;
}
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.querySelector('.protyle-action--task') ||
node.classList?.contains('protyle-action--task')) {
shouldUpdate = true;
}
}
});
}
});
if (shouldUpdate) {
this.scheduleUpdate();
}
});
// 立即开始监听,不等待DOMContentLoaded
this.startObserving(observer);
},
/**
* 开始监听编辑器变化
*/
startObserving(observer) {
// 立即监听现有的编辑器
const editors = document.querySelectorAll(G_CONFIG.CSS.protyleWysiwyg);
editors.forEach(editor => {
observer.observe(editor, {
childList: true,
subtree: true
});
});
// 监听新编辑器的创建
const editorObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const newEditors = node.querySelectorAll?.(G_CONFIG.CSS.protyleWysiwyg) || [];
if (node.matches?.(G_CONFIG.CSS.protyleWysiwyg)) {
newEditors.push(node);
}
newEditors.forEach(editor => {
observer.observe(editor, {
childList: true,
subtree: true
});
});
}
});
});
});
editorObserver.observe(document.body, {
childList: true,
subtree: true
});
},
/**
* 设置复制保护
*/
setupCopyProtection() {
document.addEventListener('copy', (e) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const fragment = range.cloneContents();
// 移除统计信息元素
const statsElements = fragment.querySelectorAll('[data-task-stats="true"]');
statsElements.forEach(el => el.remove());
// 创建新的剪贴板数据
const newData = new DataTransfer();
newData.setData('text/html', fragment.innerHTML);
newData.setData('text/plain', fragment.textContent || '');
e.clipboardData.setData('text/html', newData.getData('text/html'));
e.clipboardData.setData('text/plain', newData.getData('text/plain'));
e.preventDefault();
this.log('复制保护已应用,统计信息已过滤');
}, true);
},
/**
* 安排更新任务
*/
scheduleUpdate() {
if (!this.enabled || this.isProcessing) return;
// 清除之前的定时器
if (this.updateTimer) {
clearTimeout(this.updateTimer);
}
// 设置新的定时器
this.updateTimer = setTimeout(() => {
this.updateTaskStats();
}, G_CONFIG.FEATURES.taskStats.updateDelay);
},
/**
* 更新任务统计
*/
async updateTaskStats() {
this.isProcessing = true;
this.log('开始更新任务统计');
try {
const activeEditor = this.getActiveEditor();
if (!activeEditor) {
this.log('未找到激活的编辑器');
return;
}
// 清除现有的统计信息
this.clearExistingStats(activeEditor);
// 查找需要显示统计信息的列表(只在最外层包裹任务的NodeListItem所属NodeList显示)
const topLevelTaskLists = this.findTopLevelTaskLists(activeEditor);
this.log('找到最外层包含任务的列表数量:', topLevelTaskLists.length);
// 为每个最外层列表添加统计信息
topLevelTaskLists.forEach(listItem => {
this.addStatsToList(listItem);
});
this.log('任务统计更新完成');
} catch (error) {
this.error('更新任务统计失败:', error);
} finally {
this.isProcessing = false;
}
},
/**
* 获取当前激活的编辑器
*/
getActiveEditor() {
const focusedTab = document.querySelector(G_CONFIG.CSS.focusedTab);
if (!focusedTab) return null;
const activeEditor = document.querySelector(`.protyle[data-id="${focusedTab.dataset.id}"] ${G_CONFIG.CSS.protyleWysiwyg}`);
return activeEditor;
},
/**
* 清除现有的统计信息
*/
clearExistingStats(editor) {
const statsElements = editor.querySelectorAll('[data-task-stats="true"]');
statsElements.forEach(el => el.remove());
this.log('清除现有统计信息,数量:', statsElements.length);
},
/**
* 查找需要显示统计信息的列表(只在最外层包裹任务的NodeListItem所属NodeList显示)
*/
findTopLevelTaskLists(editor) {
const result = [];
const allListItems = editor.querySelectorAll('[data-type="NodeListItem"]');
this.log('开始查找包含任务的列表项...');
allListItems.forEach(item => {
// 1. 该item下有任务
if (item.querySelector('.protyle-action--task')) {
const itemNodeId = item.dataset.nodeId;
this.log(`找到包含任务的列表项: ${itemNodeId}`);
// 2. 向上查找祖先NodeListItem,祖先里不能有任务
let hasAncestorWithTask = false;
let parent = item.parentElement;
while (parent) {
if (parent.dataset && parent.dataset.type === 'NodeListItem') {
if (parent.querySelector('.protyle-action--task')) {
hasAncestorWithTask = true;
this.log(` 发现祖先列表项 ${parent.dataset.nodeId} 也包含任务,跳过当前项`);
break;
}
}
parent = parent.parentElement;
}
if (!hasAncestorWithTask) {
// 只在最外层item所属的NodeList显示
const parentList = item.closest('[data-type="NodeList"]');
if (parentList && !result.includes(parentList)) {
const parentListNodeId = parentList.dataset.nodeId;
this.log(` 确定在最外层列表项 ${itemNodeId} 所属的列表 ${parentListNodeId} 显示统计信息`);
result.push(item);
}
}
}
});
this.log(`最终确定显示统计信息的列表数量: ${result.length}`);
result.forEach((list, index) => {
this.log(` 列表 ${index + 1}: ${list.dataset.nodeId}`);
});
return result;
},
/**
* 为列表添加统计信息
*/
addStatsToList(listItem) {
const stats = this.calculateTaskStats(listItem);
if (stats.total === 0) return;
// 创建统计元素
const statsElement = this.createStatsElement(stats);
// 找到该列表项中的段落元素
const paragraphElement = listItem.querySelector('[data-type="NodeParagraph"]');
if (paragraphElement) {
// 找到段落中的可编辑内容区域
const editableDiv = paragraphElement.querySelector('[contenteditable="true"]');
if (editableDiv) {
editableDiv.appendChild(statsElement);
} else {
paragraphElement.appendChild(statsElement);
}
} else {
// 没有段落就加到列表项末尾
listItem.appendChild(statsElement);
}
this.log('为列表项添加统计信息:', stats);
},
/**
* 计算任务统计
*/
calculateTaskStats(listItem) {
let total = 0;
let completed = 0;
// 查找所有任务复选框(包括子列表中的)
const taskActions = listItem.querySelectorAll('.protyle-action--task');
taskActions.forEach(action => {
total++;
// 检查是否已完成(通过图标判断)
const icon = action.querySelector('svg use');
if (icon && icon.getAttribute('xlink:href') === '#iconCheck') {
completed++;
}
});
this.log('统计结果:', { total, completed, listId: listItem.dataset.nodeId });
return { total, completed };
},
/**
* 创建统计元素
*/
createStatsElement(stats) {
const statsElement = document.createElement('span');
statsElement.dataset.taskStats = 'true';
statsElement.className = 'task-stats';
statsElement.style.cssText = `
font-size: 0.9em;
color: #666;
margin-left: 8px;
padding: 2px 6px;
background-color: #f5f5f5;
border-radius: 3px;
display: inline;
white-space: nowrap;
`;
const format = G_CONFIG.FEATURES.taskStats.statsFormat;
const text = format
.replace('{completed}', stats.completed)
.replace('{total}', stats.total);
statsElement.textContent = text;
return statsElement;
}
};
const HighlightEditingBlockModule = {
id: 'highlightEditingBlock',
name: '高亮编辑块',
get enabled() { return true; }, // 如需开关可接入 G_CONFIG
log(...args) { Logger.log(this.id, ...args); },
error(...args) { Logger.error(this.id, ...args); },
init() {
this.log('模块已启动,监听 selectionchange 事件。');
document.addEventListener('selectionchange', function() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const node = range.startContainer;
let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
while (element && !element.classList.contains('protyle-wysiwyg')) {
element = element.parentElement;
}
if (element && element.classList.contains('protyle-wysiwyg')) {
const highlightedElements = element.querySelectorAll('.highlight');
highlightedElements.forEach(el => el.classList.remove('highlight'));
let targetElement = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
while (targetElement && (!element.contains(targetElement) || !targetElement.classList.contains('p'))) {
targetElement = targetElement.parentElement;
}
if (targetElement && targetElement.classList.contains('p')) {
targetElement.classList.add('highlight');
}
}
}
});
// 注入高亮样式
if (!document.getElementById('highlight-editing-block-style')) {
const style = document.createElement('style');
style.id = 'highlight-editing-block-style';
style.textContent = `
.protyle-wysiwyg .sb:hover {
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.15), -2px -2px 6px rgba(0, 0, 0, 0.15), 0 0 12px rgba(0, 0, 0, 0.1) !important;
transition: background-color 0.5s ease-out, box-shadow 0.5s ease-out !important;
}
.protyle-wysiwyg .p:hover {
color:red;
box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.15), -2px -2px 6px rgba(0, 0, 0, 0.15), 0 0 12px rgba(0, 0, 0, 0.1) !important;
transition: background-color 0.5s ease-out, box-shadow 0.5s ease-out !important;
.protyle-wysiwyg .p.highlight {
background: #ffe58f !important;
transition: background 0.2s;
}
`;
document.head.appendChild(style);
}
}
};
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('模块已启动,监听批量折叠展开操作。');
this.setupEventListeners();
},
/**
* 设置事件监听器
*/
setupEventListeners() {
document.addEventListener('mousedown', (event) => {
this.handleMouseDown(event);
}, true);
},
/**
* 处理鼠标按下事件
* @param {MouseEvent} event 鼠标事件对象
*/
handleMouseDown(event) {
// 检查是否按住Alt键且是左键点击
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;
}
},
/**
* 处理标题折叠操作
* @param {MouseEvent} event 鼠标事件对象
* @param {HTMLElement} arrow 箭头元素
*/
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) {
// Ctrl/Meta + Alt + 点击:折叠/展开所有标题
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) {
// 仅 Alt + 点击:折叠/展开同级标题
this.log('执行同级标题折叠/展开操作');
const sameLevelHeadings = protyle.querySelectorAll(`.protyle-wysiwyg [data-type="NodeHeading"][data-subtype="${headingType}"]${selectedSelector}`);
this.foldHeadings(sameLevelHeadings, isFolded);
this.hideGutterAfterClick(headingButton, arrow);
}
},
/**
* 折叠/展开标题集合
* @param {NodeList} headings 标题元素集合
* @param {boolean} isFolded 当前标题是否折叠(用于判断操作方向)
*/
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 ? '折叠' : '展开'}标题操作完成`);
},
/**
* 处理块折叠操作
* @param {MouseEvent} event 鼠标事件对象
* @param {HTMLElement} arrow 箭头元素
*/
handleBlockFold(event, arrow) {
// 通过箭头元素找到对应的NodeListItem按钮来获取块ID
const blockButton = arrow.parentElement.querySelector('button[data-type="NodeListItem"]');
if (!blockButton) {
this.log('未找到对应的NodeListItem按钮元素');
return;
}
const blockId = blockButton.dataset?.nodeId;
if (!blockId) {
this.log('未找到块ID');
return;
}
const block = document.querySelector(`div[data-node-id="${blockId}"]`);
if (!block) {
this.log('未找到对应的块元素:', blockId);
return;
}
this.log('找到目标块:', blockId, '类型:', block.dataset.type);
// 检查块是否有子内容(子列表或其他可折叠的内容)
const hasChildren = this.hasFoldableChildren(block);
if (!hasChildren) {
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) {
// Alt + 点击:折叠/展开同级块
this.log('执行同级块折叠/展开操作');
if (isFolded) {
// 如果当前块是折叠状态,展开该块的所有内容
this.expandBlockContent(block);
} else {
// 如果当前块是展开状态,折叠所有同级块(包括当前块)
this.foldAllSiblingBlocks(block, protyle, selectedSelector);
}
// 隐藏按钮
this.hideGutterAfterClick(blockButton, arrow);
}
},
/**
* 折叠所有同级块(包括当前块)
* @param {HTMLElement} currentBlock 当前块元素
* @param {HTMLElement} protyle 编辑器元素
* @param {string} selectedSelector 选中范围选择器
*/
async foldAllSiblingBlocks(currentBlock, protyle, selectedSelector) {
const blockType = currentBlock.dataset.type;
const parent = currentBlock.parentElement;
if (!parent) return;
// 获取所有同级块(包括当前块)
const siblings = Array.from(parent.children).filter(child =>
child.dataset.type === blockType &&
(!selectedSelector || child.matches(selectedSelector))
);
this.log(`找到 ${siblings.length} 个同级块`);
// 过滤出有可折叠子内容的块
const foldableSiblings = siblings.filter(sibling => this.hasFoldableChildren(sibling));
this.log(`开始折叠 ${foldableSiblings.length} 个有子内容的同级块`);
for (const sibling of foldableSiblings) {
const siblingId = sibling.dataset?.nodeId;
if (!siblingId) continue;
const isCurrentlyFolded = !!sibling.getAttribute('fold');
if (isCurrentlyFolded) continue; // 已经是折叠状态
try {
await this.foldBlock(siblingId, true);
await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
} catch (error) {
this.error('折叠同级块失败:', siblingId, error);
}
}
this.log('所有同级块折叠操作完成');
},
/**
* 展开块内容
* @param {HTMLElement} block 块元素
*/
async expandBlockContent(block) {
this.log('开始展开块内容');
const blockId = block.dataset?.nodeId;
if (!blockId) {
this.log('未找到块ID,无法展开');
return;
}
// 1. 先展开当前块本身
try {
await this.foldBlock(blockId, false);
this.log('当前块展开成功:', blockId);
await new Promise(resolve => setTimeout(resolve, G_CONFIG.FEATURES.batchFold.foldDelay));
} catch (error) {
this.error('展开当前块失败:', blockId, error);
return; // 如果当前块展开失败,就不继续展开子块了
}
// 2. 收集所有需要展开的子块
const foldedChildren = this.collectFoldedChildren(block);
this.log(`找到 ${foldedChildren.length} 个需要展开的子块`);
// 3. 展开所有子块
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);
}
}
this.log('块内容展开操作完成');
},
/**
* 收集折叠的子块
* @param {HTMLElement} block 块元素
* @returns {string[]} 折叠子块的ID列表
*/
collectFoldedChildren(block) {
const foldedIds = [];
const collectFolded = (element) => {
const children = element.children;
for (const child of children) {
if (child.dataset?.nodeId) {
const isFolded = !!child.getAttribute('fold');
if (isFolded) {
foldedIds.push(child.dataset.nodeId);
}
// 递归检查子元素
collectFolded(child);
}
}
};
collectFolded(block);
return foldedIds;
},
/**
* 折叠/展开单个块
* @param {string} id 块ID
* @param {boolean} isFold 是否折叠
*/
async foldBlock(id, isFold = true) {
const url = `/api/block/${isFold ? 'foldBlock' : 'unfoldBlock'}`;
const token = localStorage.getItem('authToken') || '';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : undefined
},
body: JSON.stringify({ id: id })
});
const result = await response.json();
if (!result || result.code !== 0) {
throw new Error(result?.msg || 'API调用失败');
}
this.log(`${isFold ? '折叠' : '展开'}块成功:`, id);
} catch (error) {
this.error(`${isFold ? '折叠' : '展开'}块失败:`, id, error);
throw error;
}
},
/**
* 操作后隐藏按钮
* @param {HTMLElement} button 按钮元素
* @param {HTMLElement} arrow 箭头元素
*/
hideGutterAfterClick(button, arrow) {
if (G_CONFIG.FEATURES.batchFold.hideGutterAfterClick) {
button.style.display = 'none';
arrow.style.display = 'none';
this.log('操作按钮已隐藏');
} else {
// 旋转箭头图标
const arrowSvg = arrow.querySelector('svg');
if (arrowSvg) {
if (!arrowSvg.style.transform || arrowSvg.style.transform === '') {
arrowSvg.style.transform = 'rotate(90deg)';
} else {
arrowSvg.style.transform = '';
}
}
}
},
/**
* 通过鼠标位置获取protyle元素
* @param {MouseEvent} event 鼠标事件对象
* @returns {HTMLElement|null} protyle元素
*/
getProtyleByMouseAt(event) {
const mouseX = event.clientX;
const mouseY = event.clientY;
const element = document.elementFromPoint(mouseX, mouseY);
if (element) return element.closest('.protyle');
return null;
},
/**
* 判断是否有Ctrl键,兼容Mac
* @param {KeyboardEvent} event 键盘事件对象
* @returns {boolean} 是否有Ctrl键
*/
isCtrlKey(event) {
if (this.isMac()) return event.metaKey;
return event.ctrlKey;
},
/**
* 检查块是否有可折叠的子内容
* @param {HTMLElement} block 块元素
* @returns {boolean} 是否有可折叠的子内容
*/
hasFoldableChildren(block) {
// 检查是否有子列表
const childList = block.querySelector('[data-type="NodeList"]');
if (childList) {
this.log('找到子列表,块可折叠');
return true;
}
// 检查是否有其他可折叠的子块(如段落、标题等)
const childBlocks = block.querySelectorAll('[data-node-id]');
if (childBlocks.length > 0) {
this.log('找到子块,块可折叠,子块数量:', childBlocks.length);
return true;
}
this.log('块没有可折叠的子内容');
return false;
},
/**
* 判断是否Mac
* @returns {boolean} 是否Mac系统
*/
isMac() {
return navigator.platform.indexOf("Mac") > -1;
}
};
const G_FEATURE_MODULES = [
HeadingModule,
ListIndentModule,
ListConvertModule,
ListIndexCorrectorModule,
ListFoldedStyleModule,
TaskStatsModule,
HighlightEditingBlockModule,
BatchFoldModule,
];
// ===================================================================
// ===================================================================
//
// 4. 主引擎 (通常无需修改)
//
// ===================================================================
// ===================================================================
function initializeModules() {
console.log('[增强插件] Main engine started. Initializing modules...');
G_FEATURE_MODULES.forEach(module => {
if (module.enabled) {
if (typeof module.init === 'function') {
try { module.init(); }
catch (e) { console.error(`[增强插件] Failed to initialize module: ${module.name}`, e); }
}
} else {
console.log(`[增强插件] Module [${module.name}] is disabled via settings.`);
}
});
console.log('[增强插件] All modules processed.');
}
setTimeout(initializeModules, 1000);
// 暴露测试方法到全局
window.siyuanEnhancer = {
correctListNumbers: () => {
const module = G_FEATURE_MODULES.find(m => m.id === 'listIndexCorrector');
if (module && module.manualCorrect) {
module.manualCorrect();
}
}
};
if (window.Protyle && window.Protyle.prototype && window.Protyle.prototype.reload) {
const originalReload = window.Protyle.prototype.reload;
let isRefreshDisabled = false;
window.Protyle.prototype.reload = function (...args) {
if (isRefreshDisabled) {
console.log('[MonkeyPatch] 刷新被禁止');
return;
}
return originalReload.apply(this, args);
};
// 在需要禁止刷新时
isRefreshDisabled = true;
setTimeout(() => {
isRefreshDisabled = false;
console.log('刷新已解锁,现在可以正常刷新页面了。');
}, 1000);
}
})();
代码 2: 工具箱增强
注意:批量编辑功能,我没有做太多测试,最好你就编辑段落,因为涉及到同时编辑标题和段落,会有些不可控的因素.
超出论坛代码长度了,我打个包:
tooleditor.zip
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于